mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79388370e9 | |||
| 1251613f45 | |||
| 70d19759e8 | |||
| f11b376ce7 | |||
| c9c507f939 | |||
| 327e1eb4b8 | |||
| 82e966caaf | |||
| 346da19108 | |||
| b74185b2af | |||
| c393e0195b | |||
| 86954784fd | |||
| 72e8f5031f | |||
| f3c464fad5 | |||
| b10bb6103a | |||
| 8c2990e68a |
@@ -59,9 +59,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
|
||||
* __DOWNLOAD_DIR__: Path to where the downloads will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
||||
* __AUDIO_DOWNLOAD_DIR__: Path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`.
|
||||
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`.
|
||||
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a **Download Folder** field appears under **Advanced Options**, where the directory for each download can be specified. Defaults to `true`.
|
||||
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
||||
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
|
||||
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the folder field's suggestions. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
|
||||
* __DEFAULT_FOLDER__: Custom directory to pre-select in the download folder field, relative to __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__), for when most downloads go to the same place. It is only a starting value — the field stays editable, so any other folder can still be picked per download. Requires __CUSTOM_DIRS__; ignored with a warning otherwise. Defaults to empty, i.e. the base download directory.
|
||||
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
|
||||
* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
|
||||
@@ -90,7 +90,7 @@ Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a fee
|
||||
|
||||
### 🌐 Web Server & URLs
|
||||
|
||||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
||||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0`, which is every IPv4 interface but no IPv6 one. Set it to `*` (or leave it empty) to listen on both stacks, or to `::` for IPv6 only — `::` does not also accept IPv4, whatever the host's `bindv6only` setting says.
|
||||
* __PORT__: The port number the web server will listen on. Defaults to `8081`.
|
||||
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
||||
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
|
||||
@@ -98,7 +98,7 @@ Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a fee
|
||||
* __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
|
||||
* __CERTFILE__: HTTPS certificate file path.
|
||||
* __KEYFILE__: HTTPS key file path.
|
||||
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API; `*` allows all. When unset or empty, all cross-origin requests are denied. Required for browser extensions and bookmarklets — see [Sending links to MeTube](#-sending-links-to-metube).
|
||||
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API; `*` allows all. When unset or empty, all cross-origin requests are denied. Required for browser extensions and bookmarklets — see [Sending links to MeTube](#-sending-links-to-metube). Naming origins explicitly also lets them send credentials (a login cookie, or the `Authorization` header a reverse proxy checks), which `*` deliberately does not: it would let any site you visit drive your instance with your own session.
|
||||
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
|
||||
|
||||
## 🎛️ Configuring yt-dlp options
|
||||
@@ -243,7 +243,7 @@ __Browser extensions__ allow right-clicking videos and sending them directly to
|
||||
* __Chrome:__ contributed by [Rpsl](https://github.com/rpsl) — install from the [Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or [from sources](https://github.com/Rpsl/metube-browser-extension).
|
||||
* __Firefox:__ contributed by [nanocortex](https://github.com/nanocortex) — install from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources [here](https://github.com/nanocortex/metube-firefox-addon).
|
||||
|
||||
__Bookmarklets__ send the currently open page to MeTube with one click. Add the origins of the sites where you use them to `CORS_ALLOWED_ORIGINS`, e.g. `https://www.youtube.com,https://www.vimeo.com`. The code (Chrome and Firefox variants, contributed by [kushfest](https://github.com/kushfest) and [shoonya75](https://github.com/shoonya75)) is in the [Bookmarklets wiki page](https://github.com/alexta69/metube/wiki/Bookmarklets).
|
||||
__Bookmarklets__ send the currently open page to MeTube with one click. Add the origins of the sites where you use them to `CORS_ALLOWED_ORIGINS`, e.g. `https://www.youtube.com,https://www.vimeo.com`. If your instance sits behind authentication, list the origins individually rather than using `*` — only named origins are allowed to send credentials. The code (Chrome and Firefox variants, contributed by [kushfest](https://github.com/kushfest) and [shoonya75](https://github.com/shoonya75)) is in the [Bookmarklets wiki page](https://github.com/alexta69/metube/wiki/Bookmarklets).
|
||||
|
||||
__iOS Shortcut:__ [rithask](https://github.com/rithask) created an [iOS shortcut](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6) for sending URLs to MeTube from Safari's share menu; it prompts for your instance address on first use.
|
||||
|
||||
|
||||
+100
-4
@@ -113,9 +113,32 @@ class Config:
|
||||
sys.exit(1)
|
||||
setattr(self, k, v in ('true', 'True', 'on', '1'))
|
||||
|
||||
# aiohttp hands HOST straight to getaddrinfo, which has no notion of a
|
||||
# '*' wildcard: the lookup fails and takes the server down at startup
|
||||
# with an opaque DNS error. '*' is nevertheless what people reach for
|
||||
# when they want to serve both IP stacks, while the value that actually
|
||||
# does it -- an empty string, which asyncio expands to one listening
|
||||
# socket per address family -- is undiscoverable. Accept '*' as the
|
||||
# spelling for "every interface, both stacks". Note that '::' on its own
|
||||
# is IPv6-only regardless of the host's bindv6only setting, because
|
||||
# asyncio always sets IPV6_V6ONLY on the sockets it binds.
|
||||
if self.HOST.strip() == '*':
|
||||
self.HOST = ''
|
||||
|
||||
if not self.URL_PREFIX.endswith('/'):
|
||||
self.URL_PREFIX += '/'
|
||||
|
||||
# Strip trailing slashes from the download directories. get_custom_dirs()
|
||||
# builds the folder dropdown by removing the base path as a prefix from
|
||||
# each subdirectory, and the base directory's own path does not carry the
|
||||
# trailing slash — so 'DOWNLOAD_DIR=/downloads/' failed to match itself
|
||||
# and leaked 'downloads' into the dropdown as a bogus folder option.
|
||||
# Runs after the '%%' indirection above so AUDIO_DOWNLOAD_DIR is resolved.
|
||||
for attr in ('DOWNLOAD_DIR', 'AUDIO_DOWNLOAD_DIR', 'TEMP_DIR', 'STATE_DIR'):
|
||||
val = getattr(self, attr)
|
||||
if isinstance(val, str) and len(val) > 1 and val.endswith('/'):
|
||||
setattr(self, attr, val.rstrip('/') or '/')
|
||||
|
||||
# A blank PUBLIC_HOST_AUDIO_URL (e.g. set empty in a compose file) bypasses the
|
||||
# default via os.environ.get, which would leave audio links root-relative and 404.
|
||||
# Fall back to the 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR. When
|
||||
@@ -335,6 +358,12 @@ async def state_dir_guard(request, handler):
|
||||
|
||||
app = web.Application(middlewares=[state_dir_guard])
|
||||
_cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else []
|
||||
if '*' in _cors_origins and len(_cors_origins) > 1:
|
||||
log.warning(
|
||||
"CORS_ALLOWED_ORIGINS mixes '*' with named origins %s. '*' wins, and credentialed "
|
||||
"cross-origin requests stay disabled for every origin in the list. Remove '*' if you "
|
||||
"need a bookmarklet to reach an authenticated instance.",
|
||||
[o for o in _cors_origins if o != '*'])
|
||||
sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else [])
|
||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||
routes = web.RouteTableDef()
|
||||
@@ -725,6 +754,7 @@ def parse_download_options(post: dict) -> dict:
|
||||
playlist_item_limit = post.get('playlist_item_limit')
|
||||
auto_start = post.get('auto_start')
|
||||
split_by_chapters = post.get('split_by_chapters')
|
||||
sponsorblock = bool(post.get('sponsorblock'))
|
||||
chapter_template = post.get('chapter_template')
|
||||
subtitle_language = post.get('subtitle_language')
|
||||
subtitle_mode = post.get('subtitle_mode')
|
||||
@@ -845,6 +875,7 @@ def parse_download_options(post: dict) -> dict:
|
||||
'playlist_item_limit': playlist_item_limit,
|
||||
'auto_start': auto_start,
|
||||
'split_by_chapters': split_by_chapters,
|
||||
'sponsorblock': sponsorblock,
|
||||
'chapter_template': chapter_template,
|
||||
'subtitle_language': subtitle_language,
|
||||
'subtitle_mode': subtitle_mode,
|
||||
@@ -890,6 +921,7 @@ async def add(request):
|
||||
o['ytdl_options_overrides'],
|
||||
o['clip_start'],
|
||||
o['clip_end'],
|
||||
sponsorblock=o['sponsorblock'],
|
||||
)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@@ -970,6 +1002,7 @@ async def subscribe(request):
|
||||
subtitle_mode=o['subtitle_mode'],
|
||||
ytdl_options_presets=o['ytdl_options_presets'],
|
||||
ytdl_options_overrides=o['ytdl_options_overrides'],
|
||||
sponsorblock=o['sponsorblock'],
|
||||
title_regex=post.get('title_regex'),
|
||||
skip_subscriber_only=skip_subscriber_only,
|
||||
clip_start=sub_clip_start,
|
||||
@@ -1059,6 +1092,30 @@ async def start(request):
|
||||
|
||||
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
|
||||
|
||||
|
||||
def warn_if_cookiefile_shadowed():
|
||||
"""Warn before an uploaded cookies file displaces an operator-configured one.
|
||||
|
||||
Uploaded cookies deliberately win: the upload exists so cookies can be
|
||||
refreshed without restarting the container, and letting YTDL_OPTIONS win
|
||||
would leave a visible UI button doing nothing. But set_runtime_override
|
||||
writes straight into YTDL_OPTIONS, so the configured path is gone from the
|
||||
live config the moment an uploaded file is applied — after that, nothing
|
||||
downstream can report the conflict (delete_cookies' has_manual_cookiefile
|
||||
check cannot fire once the value has been replaced). This is the only point
|
||||
where both are still visible, so it is the only place the warning can be
|
||||
issued. Must be called before set_runtime_override. See issue #881, where
|
||||
the silence cost the reporter days of debugging.
|
||||
"""
|
||||
configured = config.YTDL_OPTIONS.get('cookiefile')
|
||||
if isinstance(configured, str) and configured and configured != COOKIES_PATH:
|
||||
log.warning(
|
||||
'Uploaded cookies at %s take precedence over the cookiefile configured in '
|
||||
'YTDL_OPTIONS (%s), which will not be used. Delete the uploaded cookies from '
|
||||
'the UI to go back to the configured file.',
|
||||
COOKIES_PATH, configured)
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'upload-cookies')
|
||||
async def upload_cookies(request):
|
||||
reader = await request.multipart()
|
||||
@@ -1088,6 +1145,7 @@ async def upload_cookies(request):
|
||||
except OSError as exc:
|
||||
log.warning(f'Could not restrict permissions on cookies file: {exc}')
|
||||
os.replace(tmp_cookie_path, COOKIES_PATH)
|
||||
warn_if_cookiefile_shadowed()
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
|
||||
@@ -1282,9 +1340,44 @@ app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
||||
|
||||
async def on_prepare(request, response):
|
||||
origin = request.headers.get('Origin')
|
||||
if origin and _cors_origins and ('*' in _cors_origins or origin in _cors_origins):
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
if not origin or not _cors_origins:
|
||||
return
|
||||
|
||||
# Naming an origin in CORS_ALLOWED_ORIGINS is a deliberate trust grant, so
|
||||
# such an origin may send credentials: the cookie or Authorization header
|
||||
# that a reverse proxy in front of MeTube checks. Without this a bookmarklet
|
||||
# cannot reach an authenticated instance at all (issue #155).
|
||||
#
|
||||
# The '*' wildcard is emphatically not such a grant — it matches origins the
|
||||
# operator never enumerated, including every site the user happens to visit.
|
||||
# Echoing the origin back (which we must do, since '*' is illegal alongside
|
||||
# credentials) and allowing credentials would let any page drive the user's
|
||||
# instance with the user's own session. So the wildcard keeps exactly the
|
||||
# uncredentialed behaviour it has always had, and a wildcard anywhere in the
|
||||
# list disables credentials for every origin in it.
|
||||
#
|
||||
# Derived here rather than held in a second module global so the wildcard
|
||||
# test and the membership test can never disagree about the same list.
|
||||
wildcard = '*' in _cors_origins
|
||||
trusted = not wildcard and origin in _cors_origins
|
||||
if not (wildcard or trusted):
|
||||
return
|
||||
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
# Authorization rides on the same grant: allowing it under the wildcard
|
||||
# would let an arbitrary page attempt credentials against an instance it
|
||||
# can already reach, from inside the victim's network.
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' if trusted else 'Content-Type'
|
||||
if trusted:
|
||||
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
|
||||
# The response now differs per Origin, so a shared cache must not hand one
|
||||
# origin's Allow-Origin to another.
|
||||
vary = response.headers.get('Vary')
|
||||
if not vary:
|
||||
response.headers['Vary'] = 'Origin'
|
||||
elif 'origin' not in (v.strip().lower() for v in vary.split(',')):
|
||||
response.headers['Vary'] = f'{vary}, Origin'
|
||||
|
||||
app.on_response_prepare.append(on_prepare)
|
||||
|
||||
@@ -1305,11 +1398,14 @@ def isAccessLogEnabled():
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||
# An empty HOST binds every interface on both stacks; print the '*' spelling
|
||||
# that selects it rather than a bare ':8081'.
|
||||
log.info(f"Listening on {config.HOST or '*'}:{config.PORT}")
|
||||
|
||||
|
||||
# Auto-detect cookie file on startup
|
||||
if os.path.exists(COOKIES_PATH):
|
||||
warn_if_cookiefile_shadowed()
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookie file detected at {COOKIES_PATH}')
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ class SubscriptionInfo:
|
||||
auto_start: bool = True
|
||||
playlist_item_limit: int = 0
|
||||
split_by_chapters: bool = False
|
||||
sponsorblock: bool = False
|
||||
chapter_template: str = ""
|
||||
subtitle_language: str = "en"
|
||||
subtitle_mode: str = "prefer_manual"
|
||||
@@ -242,6 +243,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
||||
"auto_start": sub.auto_start,
|
||||
"playlist_item_limit": sub.playlist_item_limit,
|
||||
"split_by_chapters": sub.split_by_chapters,
|
||||
"sponsorblock": sub.sponsorblock,
|
||||
"chapter_template": sub.chapter_template,
|
||||
"subtitle_language": sub.subtitle_language,
|
||||
"subtitle_mode": sub.subtitle_mode,
|
||||
@@ -487,6 +489,7 @@ class SubscriptionManager:
|
||||
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||
clip_start: Optional[float] = None,
|
||||
clip_end: Optional[float] = None,
|
||||
sponsorblock: bool = False,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
queued_ids: list[str] = []
|
||||
queue_errors: list[str] = []
|
||||
@@ -519,6 +522,7 @@ class SubscriptionManager:
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
sponsorblock=sponsorblock,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("status") == "error":
|
||||
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
|
||||
@@ -606,6 +610,7 @@ class SubscriptionManager:
|
||||
subtitle_mode: str,
|
||||
ytdl_options_presets: Optional[list[str]] = None,
|
||||
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||
sponsorblock: bool = False,
|
||||
title_regex: Any = None,
|
||||
skip_subscriber_only: Any = None,
|
||||
clip_start: Optional[float] = None,
|
||||
@@ -689,6 +694,7 @@ class SubscriptionManager:
|
||||
auto_start=bool(auto_start),
|
||||
playlist_item_limit=int(playlist_item_limit),
|
||||
split_by_chapters=bool(split_by_chapters),
|
||||
sponsorblock=bool(sponsorblock),
|
||||
chapter_template=chapter_template or "",
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
@@ -942,6 +948,7 @@ class SubscriptionManager:
|
||||
dl_plimit = cur.playlist_item_limit
|
||||
dl_autostart = cur.auto_start
|
||||
dl_split = cur.split_by_chapters
|
||||
dl_sponsorblock = cur.sponsorblock
|
||||
dl_chapter = cur.chapter_template
|
||||
dl_sublang = cur.subtitle_language
|
||||
dl_submode = cur.subtitle_mode
|
||||
@@ -1010,6 +1017,7 @@ class SubscriptionManager:
|
||||
playlist_item_limit=dl_plimit,
|
||||
auto_start=dl_autostart,
|
||||
split_by_chapters=dl_split,
|
||||
sponsorblock=dl_sponsorblock,
|
||||
chapter_template=dl_chapter or "",
|
||||
subtitle_language=dl_sublang,
|
||||
subtitle_mode=dl_submode,
|
||||
|
||||
@@ -366,6 +366,25 @@ async def test_subscribe_passes_clip_bounds(mock_dqueue, monkeypatch):
|
||||
assert kwargs["clip_end"] == pytest.approx(204.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_passes_sponsorblock(mock_dqueue, monkeypatch):
|
||||
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||
req = _json_request(
|
||||
{**_valid_video_add_body(), "check_interval_minutes": 60, "sponsorblock": True}
|
||||
)
|
||||
resp = await main.subscribe(req)
|
||||
assert resp.status == 200
|
||||
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_defaults_sponsorblock_off(mock_dqueue, monkeypatch):
|
||||
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
|
||||
await main.subscribe(req)
|
||||
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch):
|
||||
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||
@@ -506,3 +525,130 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
||||
(download_dir / "video.mp4").unlink(missing_ok=True)
|
||||
(download_dir / percent_filename).unlink(missing_ok=True)
|
||||
state_dir.rmdir()
|
||||
|
||||
# --- CORS (issue #155) -------------------------------------------------------
|
||||
#
|
||||
# The security property under test: credentials are granted only to an origin
|
||||
# the operator named explicitly, and never under the '*' wildcard. Each test
|
||||
# builds a fresh Application because main.app binds to the first event loop
|
||||
# that runs it; the logic under test lives entirely in main.on_prepare, and the
|
||||
# real main.add_cors preflight handler is mounted so the preflight path is the
|
||||
# production one.
|
||||
|
||||
async def _cors_version(request):
|
||||
return web.Response(text="v")
|
||||
|
||||
|
||||
def _cors_app():
|
||||
app = web.Application()
|
||||
app.router.add_route("OPTIONS", "/add", main.add_cors)
|
||||
app.router.add_get("/version", _cors_version)
|
||||
app.on_response_prepare.append(main.on_prepare)
|
||||
return app
|
||||
|
||||
|
||||
async def _cors_headers(monkeypatch, origins, origin, path="/add", method="OPTIONS"):
|
||||
monkeypatch.setattr(main, "_cors_origins", origins)
|
||||
async with TestClient(TestServer(_cors_app())) as client:
|
||||
resp = await client.request(
|
||||
method, path,
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type,authorization",
|
||||
},
|
||||
)
|
||||
return resp.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_listed_origin_gets_credentials(monkeypatch):
|
||||
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], "https://www.youtube.com")
|
||||
assert h["Access-Control-Allow-Origin"] == "https://www.youtube.com"
|
||||
assert h["Access-Control-Allow-Credentials"] == "true"
|
||||
assert "Authorization" in h["Access-Control-Allow-Headers"]
|
||||
assert "Origin" in h["Vary"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_wildcard_never_grants_credentials(monkeypatch):
|
||||
h = await _cors_headers(monkeypatch, ["*"], "https://evil.example")
|
||||
# The wildcard still reflects the origin, exactly as before...
|
||||
assert h["Access-Control-Allow-Origin"] == "https://evil.example"
|
||||
# ...but must not hand out the user's session, nor let a page attempt
|
||||
# credentials of its own.
|
||||
assert "Access-Control-Allow-Credentials" not in h
|
||||
assert h["Access-Control-Allow-Headers"] == "Content-Type"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_wildcard_mixed_with_named_origin_still_denies_credentials(monkeypatch):
|
||||
# '*' anywhere in the list disables credentials for everyone in it, so a
|
||||
# stray wildcard cannot silently widen a named grant.
|
||||
h = await _cors_headers(monkeypatch, ["*", "https://www.youtube.com"], "https://www.youtube.com")
|
||||
assert h["Access-Control-Allow-Origin"] == "https://www.youtube.com"
|
||||
assert "Access-Control-Allow-Credentials" not in h
|
||||
assert h["Access-Control-Allow-Headers"] == "Content-Type"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_unlisted_origin_gets_nothing(monkeypatch):
|
||||
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], "https://evil.example")
|
||||
assert "Access-Control-Allow-Origin" not in h
|
||||
assert "Access-Control-Allow-Credentials" not in h
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_disabled_by_default(monkeypatch):
|
||||
h = await _cors_headers(monkeypatch, [], "https://www.youtube.com")
|
||||
assert "Access-Control-Allow-Origin" not in h
|
||||
assert "Access-Control-Allow-Credentials" not in h
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_credentials_apply_to_actual_response_not_just_preflight(monkeypatch):
|
||||
# The browser checks Allow-Credentials on the real response too, so a
|
||||
# preflight-only grant would still fail.
|
||||
h = await _cors_headers(
|
||||
monkeypatch, ["https://www.youtube.com"], "https://www.youtube.com",
|
||||
path="/version", method="GET")
|
||||
assert h["Access-Control-Allow-Credentials"] == "true"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_origin_match_is_exact(monkeypatch):
|
||||
# Substring or suffix matching here would be a bypass.
|
||||
for impostor in (
|
||||
"https://www.youtube.com.evil.example",
|
||||
"https://evilwww.youtube.com",
|
||||
"http://www.youtube.com",
|
||||
"https://www.youtube.com:8443",
|
||||
):
|
||||
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], impostor)
|
||||
assert "Access-Control-Allow-Origin" not in h, impostor
|
||||
assert "Access-Control-Allow-Credentials" not in h, impostor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_null_origin_is_not_trusted(monkeypatch):
|
||||
# Sandboxed iframes and some file:// contexts send Origin: null.
|
||||
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], "null")
|
||||
assert "Access-Control-Allow-Origin" not in h
|
||||
assert "Access-Control-Allow-Credentials" not in h
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_vary_appends_to_existing_value(monkeypatch):
|
||||
# Static responses can already carry a Vary; clobbering it would break
|
||||
# content negotiation.
|
||||
monkeypatch.setattr(main, "_cors_origins", ["https://www.youtube.com"])
|
||||
|
||||
async def handler(request):
|
||||
return web.Response(text="x", headers={"Vary": "Accept-Encoding"})
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/v", handler)
|
||||
app.on_response_prepare.append(main.on_prepare)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.get("/v", headers={"Origin": "https://www.youtube.com"})
|
||||
assert resp.headers["Vary"] == "Accept-Encoding, Origin"
|
||||
|
||||
@@ -51,6 +51,24 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertEqual(c.PUBLIC_HOST_URL, "")
|
||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
|
||||
|
||||
def test_host_wildcard_becomes_empty_for_dual_stack(self):
|
||||
# Regression: aiohttp passes HOST to getaddrinfo, which does not resolve
|
||||
# '*' -- the server died at startup on a DNS error. '*' now selects the
|
||||
# empty string, the only value asyncio expands to a listening socket per
|
||||
# address family.
|
||||
for raw in ("*", " * "):
|
||||
with self.subTest(raw=raw):
|
||||
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.HOST, "")
|
||||
|
||||
def test_host_literal_addresses_are_untouched(self):
|
||||
for raw in ("0.0.0.0", "::", "127.0.0.1", ""):
|
||||
with self.subTest(raw=raw):
|
||||
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.HOST, raw)
|
||||
|
||||
def test_blank_audio_host_falls_back_to_audio_download_route(self):
|
||||
# Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty
|
||||
# (which produced root-relative, 404ing audio links). It falls back to the
|
||||
@@ -77,6 +95,34 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
|
||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/")
|
||||
|
||||
def test_download_dirs_lose_trailing_slash(self):
|
||||
# get_custom_dirs strips the base path as a prefix from each subdirectory,
|
||||
# and the base directory's own path has no trailing slash -- so a trailing
|
||||
# slash here leaked the absolute path into the folder dropdown.
|
||||
with patch.dict(os.environ, _base_env(
|
||||
DOWNLOAD_DIR="/downloads/",
|
||||
AUDIO_DOWNLOAD_DIR="/audio/",
|
||||
TEMP_DIR="/tmp/",
|
||||
STATE_DIR="/state/",
|
||||
), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.DOWNLOAD_DIR, "/downloads")
|
||||
self.assertEqual(c.AUDIO_DOWNLOAD_DIR, "/audio")
|
||||
self.assertEqual(c.TEMP_DIR, "/tmp")
|
||||
self.assertEqual(c.STATE_DIR, "/state")
|
||||
|
||||
def test_root_download_dir_survives_normalisation(self):
|
||||
with patch.dict(os.environ, _base_env(DOWNLOAD_DIR="/", AUDIO_DOWNLOAD_DIR="///"), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.DOWNLOAD_DIR, "/")
|
||||
self.assertEqual(c.AUDIO_DOWNLOAD_DIR, "/")
|
||||
|
||||
def test_download_dirs_without_trailing_slash_unchanged(self):
|
||||
with patch.dict(os.environ, _base_env(DOWNLOAD_DIR="/downloads", AUDIO_DOWNLOAD_DIR="."), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.DOWNLOAD_DIR, "/downloads")
|
||||
self.assertEqual(c.AUDIO_DOWNLOAD_DIR, ".")
|
||||
|
||||
def test_ytdl_options_json_loaded(self):
|
||||
opts = {"quiet": True, "no_warnings": True}
|
||||
with patch.dict(
|
||||
|
||||
@@ -330,7 +330,7 @@ async def test_retry_restores_playlist_output_context(dq_env):
|
||||
chapter_template="",
|
||||
)
|
||||
failed_info.status = "error"
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, failed_info))
|
||||
await dq.done.put(Download(None, None, None, None, "best", "any", {}, failed_info))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
@@ -389,7 +389,7 @@ async def test_retry_keeps_playlist_context_through_url_indirection(dq_env):
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
resolved = "https://example.com/resolved?v=1"
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url)))
|
||||
await dq.done.put(Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url)))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
if extracted_url == url:
|
||||
@@ -427,7 +427,7 @@ async def test_retry_reapplies_current_options_gates(dq_env):
|
||||
ytdl_options_presets=["Still There", "Removed Preset"],
|
||||
ytdl_options_overrides={"paths": {"home": "/etc"}},
|
||||
)
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||
await dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
@@ -457,7 +457,7 @@ async def test_retry_keeps_overrides_while_still_allowed(dq_env):
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
info = _failed_playlist_item(url, ytdl_options_overrides={"writesubtitles": True})
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||
await dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
@@ -476,6 +476,32 @@ async def test_retry_keeps_overrides_while_still_allowed(dq_env):
|
||||
assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_carries_the_sponsorblock_flag(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
await dq.done.put(
|
||||
Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url, sponsorblock=True))
|
||||
)
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": extracted_url,
|
||||
"webpage_url": extracted_url,
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
result = await dq.retry(url)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert dq.queue.get(url).info.sponsorblock is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
|
||||
notifier = AsyncMock()
|
||||
@@ -1415,9 +1441,9 @@ async def test_post_download_cleanup_clears_filename_on_error(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
download = _make_download(dq_env, status="downloading", filename="../tmp/partial.mp4")
|
||||
dq.queue.put(download)
|
||||
await dq.queue.put(download)
|
||||
|
||||
dq._post_download_cleanup(download)
|
||||
await dq._post_download_cleanup(download)
|
||||
|
||||
assert download.info.status == "error"
|
||||
assert download.info.filename is None
|
||||
@@ -1430,9 +1456,9 @@ async def test_post_download_cleanup_keeps_captured_subtitles_on_error(dq_env):
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
download = _make_download(dq_env, download_type="captions", status="downloading", filename="en.srt")
|
||||
download.info.subtitle_files = [{"filename": "en.srt", "size": 42}]
|
||||
dq.queue.put(download)
|
||||
await dq.queue.put(download)
|
||||
|
||||
dq._post_download_cleanup(download)
|
||||
await dq._post_download_cleanup(download)
|
||||
|
||||
assert download.info.status == "error"
|
||||
assert download.info.filename == "en.srt"
|
||||
@@ -1452,7 +1478,7 @@ async def test_clear_skips_deletion_outside_download_directory(dq_env):
|
||||
# A crafted/legacy relative filename that escapes DOWNLOAD_DIR via '..'.
|
||||
escaping_filename = os.path.relpath(outside_file, dq_env.DOWNLOAD_DIR)
|
||||
download = _make_download(dq_env, status="finished", filename=escaping_filename)
|
||||
dq.done.put(download)
|
||||
await dq.done.put(download)
|
||||
|
||||
await dq.clear([download.info.url])
|
||||
|
||||
|
||||
@@ -313,3 +313,40 @@ class GetCustomDirsTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class WarnIfCookiefileShadowedTests(unittest.TestCase):
|
||||
"""Issue #881: an uploaded cookies file wins over an operator-configured
|
||||
cookiefile, and used to do so with no way for anyone to notice."""
|
||||
|
||||
def setUp(self):
|
||||
self._saved = main.config.YTDL_OPTIONS
|
||||
main.config.YTDL_OPTIONS = dict(self._saved)
|
||||
|
||||
def tearDown(self):
|
||||
main.config.YTDL_OPTIONS = self._saved
|
||||
|
||||
def test_warns_when_a_different_cookiefile_is_configured(self):
|
||||
main.config.YTDL_OPTIONS["cookiefile"] = "/cookies/cookies.txt"
|
||||
with self.assertLogs("main", level="WARNING") as cm:
|
||||
main.warn_if_cookiefile_shadowed()
|
||||
joined = "\n".join(cm.output)
|
||||
self.assertIn("/cookies/cookies.txt", joined)
|
||||
self.assertIn(main.COOKIES_PATH, joined)
|
||||
|
||||
def test_silent_when_no_cookiefile_configured(self):
|
||||
main.config.YTDL_OPTIONS.pop("cookiefile", None)
|
||||
with self.assertNoLogs("main", level="WARNING"):
|
||||
main.warn_if_cookiefile_shadowed()
|
||||
|
||||
def test_silent_when_configured_file_is_the_uploaded_one(self):
|
||||
# The steady state after an upload: re-running must not nag.
|
||||
main.config.YTDL_OPTIONS["cookiefile"] = main.COOKIES_PATH
|
||||
with self.assertNoLogs("main", level="WARNING"):
|
||||
main.warn_if_cookiefile_shadowed()
|
||||
|
||||
def test_silent_on_non_string_or_empty_values(self):
|
||||
for value in (None, "", 0, [], {}):
|
||||
main.config.YTDL_OPTIONS["cookiefile"] = value
|
||||
with self.assertNoLogs("main", level="WARNING"):
|
||||
main.warn_if_cookiefile_shadowed()
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import shelve
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -69,22 +72,22 @@ def _create_legacy_shelf(path: str, *infos: DownloadInfo) -> None:
|
||||
shelf[info.url] = info
|
||||
|
||||
|
||||
class PersistentQueueTests(unittest.TestCase):
|
||||
def test_put_get_delete_roundtrip(self):
|
||||
class PersistentQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_put_get_delete_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq = PersistentQueue("queue", path)
|
||||
dl = _FakeDownload(_make_info("http://a.example"))
|
||||
pq.put(dl)
|
||||
await pq.put(dl)
|
||||
self.assertTrue(os.path.exists(path + ".json"))
|
||||
self.assertTrue(pq.exists("http://a.example"))
|
||||
self.assertFalse(pq.empty())
|
||||
got = pq.get("http://a.example")
|
||||
self.assertEqual(got.info.url, "http://a.example")
|
||||
pq.delete("http://a.example")
|
||||
await pq.delete("http://a.example")
|
||||
self.assertFalse(pq.exists("http://a.example"))
|
||||
|
||||
def test_saved_items_sorted_by_timestamp(self):
|
||||
async def test_saved_items_sorted_by_timestamp(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq = PersistentQueue("queue", path)
|
||||
@@ -92,16 +95,16 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
b = _FakeDownload(_make_info("http://second.example"))
|
||||
a.info.timestamp = 100
|
||||
b.info.timestamp = 200
|
||||
pq.put(a)
|
||||
pq.put(b)
|
||||
await pq.put(a)
|
||||
await pq.put(b)
|
||||
keys = [k for k, _ in pq.saved_items()]
|
||||
self.assertEqual(keys, ["http://first.example", "http://second.example"])
|
||||
|
||||
def test_load_restores_from_json(self):
|
||||
async def test_load_restores_from_json(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq1 = PersistentQueue("queue", path)
|
||||
pq1.put(_FakeDownload(_make_info("http://load.example")))
|
||||
await pq1.put(_FakeDownload(_make_info("http://load.example")))
|
||||
pq2 = PersistentQueue("queue", path)
|
||||
pq2.load()
|
||||
self.assertTrue(pq2.exists("http://load.example"))
|
||||
@@ -115,7 +118,7 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
self.assertTrue(pq.exists("http://legacy.example"))
|
||||
self.assertTrue(os.path.exists(path + ".json"))
|
||||
|
||||
def test_queue_persists_only_compact_entry_subset(self):
|
||||
async def test_queue_persists_only_compact_entry_subset(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq = PersistentQueue("queue", path)
|
||||
@@ -128,7 +131,7 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
"formats": [{"id": "huge"}],
|
||||
"description": "very large payload",
|
||||
}
|
||||
pq.put(_FakeDownload(info))
|
||||
await pq.put(_FakeDownload(info))
|
||||
|
||||
with open(path + ".json", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
@@ -146,7 +149,7 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
self.assertNotIn("formats", record["entry"])
|
||||
self.assertNotIn("description", record["entry"])
|
||||
|
||||
def test_completed_queue_persists_only_failed_retry_context(self):
|
||||
async def test_completed_queue_persists_only_failed_retry_context(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "completed")
|
||||
pq = PersistentQueue("completed", path)
|
||||
@@ -161,7 +164,7 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
"formats": [{"id": "huge"}],
|
||||
}
|
||||
info.filename = "done.mp4"
|
||||
pq.put(_FakeDownload(info))
|
||||
await pq.put(_FakeDownload(info))
|
||||
|
||||
with open(path + ".json", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
@@ -180,7 +183,7 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
self.assertEqual(record["filename"], "done.mp4")
|
||||
|
||||
info.status = "finished"
|
||||
pq.put(_FakeDownload(info))
|
||||
await pq.put(_FakeDownload(info))
|
||||
with open(path + ".json", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
self.assertNotIn("entry", payload["items"][0]["info"])
|
||||
@@ -256,7 +259,7 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
self.assertNotIn("speed", record)
|
||||
self.assertNotIn("eta", record)
|
||||
|
||||
def test_put_rollbacks_in_memory_queue_when_state_write_fails(self):
|
||||
async def test_put_rollbacks_in_memory_queue_when_state_write_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq = PersistentQueue("queue", path)
|
||||
@@ -272,18 +275,18 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
|
||||
with patch("ytdl.AtomicJsonStore.save", bad_save):
|
||||
with self.assertRaises(OSError):
|
||||
pq.put(dl)
|
||||
await pq.put(dl)
|
||||
|
||||
self.assertFalse(pq.exists("http://rollback.example"))
|
||||
|
||||
def test_put_rollbacks_to_previous_download_when_replace_fails(self):
|
||||
async def test_put_rollbacks_to_previous_download_when_replace_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq = PersistentQueue("queue", path)
|
||||
first = _FakeDownload(_make_info("http://same.example"))
|
||||
second = _FakeDownload(_make_info("http://same.example"))
|
||||
second.info.title = "Replaced title"
|
||||
pq.put(first)
|
||||
await pq.put(first)
|
||||
|
||||
orig_save = __import__("state_store").AtomicJsonStore.save
|
||||
|
||||
@@ -294,10 +297,68 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
|
||||
with patch("ytdl.AtomicJsonStore.save", bad_save):
|
||||
with self.assertRaises(OSError):
|
||||
pq.put(second)
|
||||
await pq.put(second)
|
||||
|
||||
self.assertEqual(pq.get("http://same.example").info.title, "Title")
|
||||
|
||||
|
||||
class StateWriteOffEventLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""State writes fsync twice; on a slow disk that must not stall the loop.
|
||||
|
||||
Before this, put()/delete() wrote inline, so a queue mutation blocked every
|
||||
other request the server was serving for as long as the filesystem took.
|
||||
See issue #980.
|
||||
"""
|
||||
|
||||
async def test_save_runs_off_the_event_loop_thread(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pq = PersistentQueue("queue", os.path.join(tmp, "queue"))
|
||||
self.addCleanup(pq.close)
|
||||
loop_thread = threading.get_ident()
|
||||
save_threads = []
|
||||
|
||||
orig_save = __import__("state_store").AtomicJsonStore.save
|
||||
|
||||
def recording_save(store, data):
|
||||
save_threads.append(threading.get_ident())
|
||||
return orig_save(store, data)
|
||||
|
||||
with patch("ytdl.AtomicJsonStore.save", recording_save):
|
||||
await pq.put(_FakeDownload(_make_info("http://a.example")))
|
||||
|
||||
self.assertEqual(len(save_threads), 1)
|
||||
self.assertNotEqual(save_threads[0], loop_thread)
|
||||
|
||||
async def test_a_slow_write_does_not_stall_other_coroutines(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pq = PersistentQueue("queue", os.path.join(tmp, "queue"))
|
||||
self.addCleanup(pq.close)
|
||||
orig_save = __import__("state_store").AtomicJsonStore.save
|
||||
|
||||
def slow_save(store, data):
|
||||
time.sleep(0.3)
|
||||
return orig_save(store, data)
|
||||
|
||||
ticks = 0
|
||||
|
||||
async def ticker():
|
||||
nonlocal ticks
|
||||
while True:
|
||||
await asyncio.sleep(0.01)
|
||||
ticks += 1
|
||||
|
||||
ticking = asyncio.create_task(ticker())
|
||||
try:
|
||||
with patch("ytdl.AtomicJsonStore.save", slow_save):
|
||||
await pq.put(_FakeDownload(_make_info("http://a.example")))
|
||||
finally:
|
||||
ticking.cancel()
|
||||
|
||||
# An inline write would have starved the loop for the whole 0.3s and
|
||||
# left ticks at 0.
|
||||
self.assertGreater(ticks, 5)
|
||||
self.assertTrue(pq.exists("http://a.example"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -479,6 +479,69 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(reloaded.get(sub_id).clip_start, 12.5)
|
||||
self.assertIsNone(reloaded.get(sub_id).clip_end)
|
||||
|
||||
async def test_check_now_applies_subscription_sponsorblock(self):
|
||||
"""Subscriptions download unattended, so the sponsor-segment removal has
|
||||
to reach every entry the subscription queues, not just manual adds."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
side_effect=[
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[
|
||||
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
|
||||
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||
],
|
||||
),
|
||||
],
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
sponsorblock=True,
|
||||
)
|
||||
sub_id = result["subscription"]["id"]
|
||||
self.assertTrue(mgr.get(sub_id).sponsorblock)
|
||||
await mgr.check_now([sub_id])
|
||||
|
||||
self.assertEqual(len(queue.entries), 1)
|
||||
_entry, _args, kwargs = queue.entries[0]
|
||||
self.assertIs(kwargs["sponsorblock"], True)
|
||||
|
||||
async def test_sponsorblock_survives_reload_and_defaults_to_false(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
sub_id = await self._add_one_subscription(mgr)
|
||||
# Records written before the field existed simply take the default.
|
||||
self.assertFalse(mgr.get(sub_id).sponsorblock)
|
||||
|
||||
mgr.get(sub_id).sponsorblock = True
|
||||
async with mgr._lock:
|
||||
mgr._save_locked()
|
||||
|
||||
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
self.assertTrue(reloaded.get(sub_id).sponsorblock)
|
||||
|
||||
async def test_check_now_queues_subscriber_only_when_skip_disabled(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
|
||||
+71
-36
@@ -12,7 +12,7 @@ from url_guard import (
|
||||
_address_allowed_at_connect,
|
||||
_address_is_global,
|
||||
_guarded_getaddrinfo,
|
||||
_proxy_endpoint,
|
||||
_url_endpoint,
|
||||
install_socket_guard,
|
||||
)
|
||||
|
||||
@@ -121,19 +121,19 @@ class ConnectAddressPolicyTests(unittest.TestCase):
|
||||
self.assertFalse(_address_allowed_at_connect("::1"))
|
||||
|
||||
def test_loopback_allowed_only_when_opted_in(self):
|
||||
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("::1", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_allowed_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("::1", is_allowed_endpoint=True))
|
||||
|
||||
def test_proxy_opt_in_covers_any_internal_range(self):
|
||||
# A proxy is just as legitimately on the LAN or a VPN range as on
|
||||
# loopback (#1055): the allowance follows the operator's configured
|
||||
# endpoint, not a particular address family.
|
||||
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("fd00::1", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_allowed_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_allowed_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("fd00::1", is_allowed_endpoint=True))
|
||||
|
||||
def test_opt_in_still_rejects_non_addresses(self):
|
||||
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_proxy_endpoint=True))
|
||||
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_allowed_endpoint=True))
|
||||
|
||||
def test_link_local_metadata_blocked(self):
|
||||
self.assertFalse(_address_allowed_at_connect("169.254.169.254"))
|
||||
@@ -187,36 +187,36 @@ class TunnelledIPv4Tests(unittest.TestCase):
|
||||
self.assertIsNotNone(validate_url("http://nat64.example/x"))
|
||||
|
||||
|
||||
class ProxyEndpointParsingTests(unittest.TestCase):
|
||||
class EndpointParsingTests(unittest.TestCase):
|
||||
def test_explicit_port(self):
|
||||
self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
||||
self.assertEqual(_url_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
||||
|
||||
def test_default_port_per_scheme(self):
|
||||
self.assertEqual(_proxy_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
|
||||
self.assertEqual(_proxy_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
|
||||
self.assertEqual(_url_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
|
||||
self.assertEqual(_url_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
|
||||
|
||||
def test_bare_host_port(self):
|
||||
self.assertEqual(_proxy_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
|
||||
self.assertEqual(_url_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
|
||||
|
||||
def test_hostname_lowercased(self):
|
||||
self.assertEqual(_proxy_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
|
||||
self.assertEqual(_url_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
|
||||
|
||||
def test_ipv6_literal(self):
|
||||
self.assertEqual(_proxy_endpoint("http://[::1]:9050"), ("::1", 9050))
|
||||
self.assertEqual(_url_endpoint("http://[::1]:9050"), ("::1", 9050))
|
||||
|
||||
def test_empty_and_invalid(self):
|
||||
self.assertIsNone(_proxy_endpoint(""))
|
||||
self.assertIsNone(_proxy_endpoint(" "))
|
||||
self.assertIsNone(_proxy_endpoint(None))
|
||||
self.assertIsNone(_proxy_endpoint("http://"))
|
||||
self.assertIsNone(_url_endpoint(""))
|
||||
self.assertIsNone(_url_endpoint(" "))
|
||||
self.assertIsNone(_url_endpoint(None))
|
||||
self.assertIsNone(_url_endpoint("http://"))
|
||||
|
||||
|
||||
class GuardedGetaddrinfoTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Default state: no proxy configured, so no loopback destination allowed.
|
||||
saved = set(url_guard._allowed_proxy_endpoints)
|
||||
url_guard._allowed_proxy_endpoints = set()
|
||||
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved))
|
||||
saved = set(url_guard._allowed_endpoints)
|
||||
url_guard._allowed_endpoints = set()
|
||||
self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
|
||||
|
||||
def test_internal_only_raises(self):
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")):
|
||||
@@ -235,27 +235,27 @@ class GuardedGetaddrinfoTests(unittest.TestCase):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("127.0.0.1", 9999)
|
||||
|
||||
def test_loopback_allowed_at_configured_proxy_endpoint(self):
|
||||
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
||||
def test_loopback_allowed_at_configured_url_endpoint(self):
|
||||
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("127.0.0.1", 9050)
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
|
||||
def test_loopback_blocked_at_other_port_on_proxy_host(self):
|
||||
# Same host as the proxy, different port: still off limits.
|
||||
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
||||
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("127.0.0.1", 9999)
|
||||
|
||||
def test_proxy_reachable_by_hostname(self):
|
||||
url_guard._allowed_proxy_endpoints = {("localhost", 9050)}
|
||||
url_guard._allowed_endpoints = {("localhost", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("localhost", 9050)
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
|
||||
def test_string_port_is_normalised(self):
|
||||
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
||||
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("127.0.0.1", "9050")
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
@@ -263,22 +263,38 @@ class GuardedGetaddrinfoTests(unittest.TestCase):
|
||||
def test_lan_proxy_reachable(self):
|
||||
# #1055: a socks5 proxy on the LAN, refused while the allowance was
|
||||
# loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES.
|
||||
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
||||
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
||||
results = _guarded_getaddrinfo("10.1.20.30", 1080)
|
||||
self.assertEqual([r[4][0] for r in results], ["10.1.20.30"])
|
||||
|
||||
def test_other_lan_host_still_blocked(self):
|
||||
# The allowance is the proxy's endpoint, not its subnet.
|
||||
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
||||
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("10.1.20.31", 1080)
|
||||
|
||||
def test_pot_provider_reachable_on_loopback(self):
|
||||
# #1064: the bundled PO token provider listens on loopback, and blocking
|
||||
# it left every default install downloading YouTube without a token.
|
||||
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("127.0.0.1", 4416)
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
|
||||
def test_other_loopback_service_still_blocked(self):
|
||||
# MeTube's own port is one hop away on the same interface: allowing the
|
||||
# token provider must not allow the rest of loopback.
|
||||
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("127.0.0.1", 8081)
|
||||
|
||||
def test_proxy_address_not_borrowable_by_another_host(self):
|
||||
# Matching is on the configured host string: a manifest URL that resolves
|
||||
# to the proxy's address under its own name gets no allowance.
|
||||
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
||||
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("evil.example", 1080)
|
||||
@@ -312,9 +328,9 @@ class AllowPrivateBypassTests(unittest.TestCase):
|
||||
|
||||
class InstallSocketGuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
original, saved = socket.getaddrinfo, set(url_guard._allowed_proxy_endpoints)
|
||||
original, saved = socket.getaddrinfo, set(url_guard._allowed_endpoints)
|
||||
self.addCleanup(lambda: setattr(socket, "getaddrinfo", original))
|
||||
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved))
|
||||
self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
|
||||
# Keep the host's own environment out of the assertions below.
|
||||
patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={})
|
||||
self.getproxies = patcher.start()
|
||||
@@ -329,26 +345,45 @@ class InstallSocketGuardTests(unittest.TestCase):
|
||||
|
||||
def test_no_proxy_means_no_loopback_allowance(self):
|
||||
install_socket_guard()
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
||||
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||
|
||||
def test_explicit_proxy_is_registered(self):
|
||||
install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",))
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 9050)})
|
||||
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 9050)})
|
||||
|
||||
def test_unset_proxy_option_is_ignored(self):
|
||||
# ytdl_opts.get('proxy') is None when the operator configured no proxy.
|
||||
install_socket_guard(proxy_urls=(None,))
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
||||
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||
|
||||
def test_environment_proxies_are_registered(self):
|
||||
self.getproxies.return_value = {"http": "http://127.0.0.1:8080"}
|
||||
install_socket_guard()
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 8080)})
|
||||
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 8080)})
|
||||
|
||||
def test_service_url_is_registered(self):
|
||||
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
|
||||
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 4416)})
|
||||
|
||||
def test_service_and_proxy_endpoints_coexist(self):
|
||||
install_socket_guard(
|
||||
proxy_urls=("socks5://10.1.20.30:1080",),
|
||||
service_urls=("http://127.0.0.1:4416",),
|
||||
)
|
||||
self.assertEqual(
|
||||
url_guard._allowed_endpoints,
|
||||
{("10.1.20.30", 1080), ("127.0.0.1", 4416)},
|
||||
)
|
||||
|
||||
def test_service_urls_reset_between_installs(self):
|
||||
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
|
||||
install_socket_guard()
|
||||
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||
|
||||
def test_endpoints_reset_between_installs(self):
|
||||
install_socket_guard(proxy_urls=("http://127.0.0.1:8080",))
|
||||
install_socket_guard(proxy_urls=(None,))
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
||||
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pickle
|
||||
import signal
|
||||
@@ -11,7 +12,8 @@ import threading
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
fake_yt_dlp = types.ModuleType("yt_dlp")
|
||||
fake_networking = types.ModuleType("yt_dlp.networking")
|
||||
@@ -77,6 +79,7 @@ from ytdl import (
|
||||
MusicMetadataPreProcessor,
|
||||
_compact_persisted_entry,
|
||||
_convert_srt_to_txt_file,
|
||||
_pot_provider_urls,
|
||||
_AlbumArtistPostProcessor,
|
||||
_resolve_outtmpl_fields,
|
||||
_sanitize_entry_for_pickle,
|
||||
@@ -678,6 +681,69 @@ class DownloadResultTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
def _capture_ytdl_params(download: Download) -> dict:
|
||||
"""Run ``_download`` far enough to capture the params it builds."""
|
||||
fake_ydl = MagicMock()
|
||||
fake_ydl.download.return_value = 0
|
||||
download.status_queue = types.SimpleNamespace(put=lambda _: None)
|
||||
|
||||
with patch('ytdl.install_socket_guard'), \
|
||||
patch.object(Download, '_make_youtube_dl', return_value=fake_ydl) as make:
|
||||
download._download()
|
||||
|
||||
params, = make.call_args.args
|
||||
return params
|
||||
|
||||
|
||||
class SponsorBlockPostprocessorTests(unittest.TestCase):
|
||||
def test_no_sponsorblock_postprocessors_when_disabled(self):
|
||||
download = _make_test_download()
|
||||
|
||||
params = _capture_ytdl_params(download)
|
||||
|
||||
keys = [pp['key'] for pp in params.get('postprocessors', [])]
|
||||
self.assertNotIn('SponsorBlock', keys)
|
||||
self.assertNotIn('ModifyChapters', keys)
|
||||
|
||||
def test_sponsorblock_pair_matches_the_cli(self):
|
||||
download = _make_test_download()
|
||||
download.info.sponsorblock = True
|
||||
|
||||
params = _capture_ytdl_params(download)
|
||||
|
||||
self.assertEqual(
|
||||
params['postprocessors'],
|
||||
[
|
||||
{
|
||||
'key': 'SponsorBlock',
|
||||
'categories': ['sponsor'],
|
||||
'when': 'after_filter',
|
||||
},
|
||||
{
|
||||
'key': 'ModifyChapters',
|
||||
'remove_sponsor_segments': ['sponsor'],
|
||||
'force_keyframes': False,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def test_segment_removal_runs_before_the_chapter_split(self):
|
||||
# yt-dlp runs same-stage postprocessors in list order, so ModifyChapters
|
||||
# has to rewrite the chapter list before FFmpegSplitChapters cuts the
|
||||
# file up -- the order the CLI builds for
|
||||
# --sponsorblock-remove sponsor --split-chapters.
|
||||
download = _make_test_download()
|
||||
download.info.sponsorblock = True
|
||||
download.info.split_by_chapters = True
|
||||
download.info.chapter_template = '%(section_number)s.%(ext)s'
|
||||
|
||||
params = _capture_ytdl_params(download)
|
||||
|
||||
keys = [pp['key'] for pp in params['postprocessors']]
|
||||
self.assertEqual(keys, ['SponsorBlock', 'ModifyChapters', 'FFmpegSplitChapters'])
|
||||
self.assertEqual(params['outtmpl']['chapter'], '%(section_number)s.%(ext)s')
|
||||
|
||||
|
||||
class ProgressThrottleTests(unittest.TestCase):
|
||||
def test_downloading_ticks_are_throttled(self):
|
||||
dl = _make_test_download()
|
||||
@@ -711,6 +777,80 @@ class ProgressThrottleTests(unittest.TestCase):
|
||||
self.assertIn("error", statuses)
|
||||
|
||||
|
||||
class PostprocessorHookTests(unittest.TestCase):
|
||||
"""The postprocessing phase must be visible without ever displacing the
|
||||
terminal 'finished' status that _post_download_cleanup keys off."""
|
||||
|
||||
def _hook(self):
|
||||
dl = _make_test_download()
|
||||
forwarded = []
|
||||
dl.status_queue = types.SimpleNamespace(put=forwarded.append)
|
||||
return dl._make_postprocessor_hook(), forwarded
|
||||
|
||||
def test_postprocessor_start_reports_the_phase(self):
|
||||
hook, forwarded = self._hook()
|
||||
hook({"postprocessor": "VideoConvertor", "status": "started", "info_dict": {}})
|
||||
self.assertEqual(forwarded, [{"status": "postprocessing"}])
|
||||
|
||||
def test_postprocessor_finish_is_not_reported(self):
|
||||
# Only 'started' moves the UI; a PP finishing says nothing about what
|
||||
# comes next, and an extra broadcast per PP buys nothing.
|
||||
hook, forwarded = self._hook()
|
||||
hook({"postprocessor": "VideoConvertor", "status": "finished", "info_dict": {}})
|
||||
self.assertEqual(forwarded, [])
|
||||
|
||||
def test_move_files_still_reports_finished_with_the_final_path(self):
|
||||
hook, forwarded = self._hook()
|
||||
hook({
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"filepath": "/tmp/video.mp4"},
|
||||
})
|
||||
self.assertEqual(forwarded, [{"status": "finished", "filename": "/tmp/video.mp4"}])
|
||||
|
||||
def test_nothing_is_reported_after_move_files_finished(self):
|
||||
# Regression guard: _post_download_cleanup turns any final status other
|
||||
# than 'finished' into an error, so an 'after_move' postprocessor
|
||||
# starting up must not overwrite it and fail the download.
|
||||
hook, forwarded = self._hook()
|
||||
hook({
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"filepath": "/tmp/video.mp4"},
|
||||
})
|
||||
hook({"postprocessor": "SomeAfterMovePP", "status": "started", "info_dict": {}})
|
||||
self.assertEqual([item["status"] for item in forwarded], ["finished"])
|
||||
|
||||
def test_realistic_sequence_ends_finished(self):
|
||||
hook, forwarded = self._hook()
|
||||
for pp in ("Merger", "VideoConvertor", "Metadata"):
|
||||
hook({"postprocessor": pp, "status": "started", "info_dict": {}})
|
||||
hook({"postprocessor": pp, "status": "finished", "info_dict": {}})
|
||||
hook({"postprocessor": "MoveFiles", "status": "started", "info_dict": {}})
|
||||
hook({
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"filepath": "/tmp/video.mp4"},
|
||||
})
|
||||
|
||||
statuses = [item["status"] for item in forwarded]
|
||||
self.assertEqual(statuses[-1], "finished")
|
||||
self.assertEqual(statuses.count("finished"), 1)
|
||||
self.assertTrue(all(st == "postprocessing" for st in statuses[:-1]))
|
||||
|
||||
def test_split_chapters_still_captures_files_without_a_status(self):
|
||||
hook, forwarded = self._hook()
|
||||
hook({
|
||||
"postprocessor": "SplitChapters",
|
||||
"status": "finished",
|
||||
"info_dict": {"chapters": [{"filepath": "/tmp/ch1.mp4"}, {"filepath": "/tmp/ch2.mp4"}]},
|
||||
})
|
||||
self.assertEqual(
|
||||
forwarded,
|
||||
[{"chapter_file": "/tmp/ch1.mp4"}, {"chapter_file": "/tmp/ch2.mp4"}],
|
||||
)
|
||||
|
||||
|
||||
class CancelProcessGroupTests(unittest.TestCase):
|
||||
# cancel() now sends SIGINT first (so yt-dlp/ffmpeg can finalize the
|
||||
# partial file) and schedules a SIGKILL escalation via the event loop
|
||||
@@ -1071,5 +1211,142 @@ class ShortTitleForFailedUrlTests(unittest.TestCase):
|
||||
self.assertEqual(_short_title_for_failed_url(malformed), malformed)
|
||||
|
||||
|
||||
class PotProviderUrlsTests(unittest.TestCase):
|
||||
"""#1064: the connect-time guard must let the download reach the PO token
|
||||
provider, so it has to know every endpoint yt-dlp might dial for one."""
|
||||
|
||||
def test_bundled_provider_by_default(self):
|
||||
self.assertEqual(_pot_provider_urls({}), ("http://127.0.0.1:4416",))
|
||||
|
||||
def test_configured_base_url_is_added(self):
|
||||
urls = _pot_provider_urls({
|
||||
"extractor_args": {"youtubepot-bgutilhttp": {"base_url": ["http://pot:4416"]}},
|
||||
})
|
||||
# The bundled server runs regardless, so both stay reachable.
|
||||
self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416"))
|
||||
|
||||
def test_deprecated_base_url_arg_is_honoured(self):
|
||||
urls = _pot_provider_urls({
|
||||
"extractor_args": {"youtube": {"getpot_bgutil_baseurl": ["http://pot:4416"]}},
|
||||
})
|
||||
self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416"))
|
||||
|
||||
def test_unrelated_extractor_args_are_ignored(self):
|
||||
urls = _pot_provider_urls({
|
||||
"extractor_args": {"youtube": {"player_client": ["web"]}},
|
||||
})
|
||||
self.assertEqual(urls, ("http://127.0.0.1:4416",))
|
||||
|
||||
def test_malformed_extractor_args_do_not_raise(self):
|
||||
# YTDL_OPTIONS is operator-supplied JSON and reaches here unvalidated.
|
||||
for opts in (
|
||||
{"extractor_args": None},
|
||||
{"extractor_args": "youtube:player_client=web"},
|
||||
{"extractor_args": {"youtubepot-bgutilhttp": "http://pot:4416"}},
|
||||
{"extractor_args": {"youtubepot-bgutilhttp": {"base_url": []}}},
|
||||
):
|
||||
with self.subTest(opts=opts):
|
||||
self.assertEqual(_pot_provider_urls(opts), ("http://127.0.0.1:4416",))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
unittest.main()
|
||||
|
||||
|
||||
class UpdateStatusFileStatTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""The progress path must not touch the filesystem on the event loop.
|
||||
|
||||
yt-dlp reports 'filename' on every progress tick, but until the download
|
||||
finishes that path does not exist yet -- the bytes are in 'tmpfilename'.
|
||||
Stating it per tick meant blocking syscalls on the event loop twice a
|
||||
second per download, always answering None. See issue #980.
|
||||
"""
|
||||
|
||||
async def _run_update_status(self, statuses):
|
||||
import queue as _queue
|
||||
|
||||
download = _make_test_download()
|
||||
download.download_dir = "/tmp"
|
||||
source = _queue.Queue()
|
||||
for status in statuses:
|
||||
source.put(status)
|
||||
source.put(None)
|
||||
download.status_queue = source
|
||||
download.loop = asyncio.get_running_loop()
|
||||
download._executor = ThreadPoolExecutor(max_workers=1)
|
||||
notifier = MagicMock()
|
||||
# updated() is handed the same DownloadInfo every time, so the status has
|
||||
# to be copied out when the call happens -- reading it off the recorded
|
||||
# call args afterwards only ever shows the final value.
|
||||
notifier.broadcast_statuses = []
|
||||
notifier.updated = AsyncMock(
|
||||
side_effect=lambda info: notifier.broadcast_statuses.append(info.status)
|
||||
)
|
||||
download.notifier = notifier
|
||||
|
||||
stat_calls = []
|
||||
|
||||
def record_exists(path):
|
||||
stat_calls.append(path)
|
||||
return False
|
||||
|
||||
try:
|
||||
with patch("ytdl.os.path.exists", side_effect=record_exists):
|
||||
await download.update_status()
|
||||
finally:
|
||||
download._executor.shutdown(wait=True)
|
||||
return download, stat_calls
|
||||
|
||||
async def test_downloading_ticks_do_not_stat_the_output_file(self):
|
||||
ticks = [
|
||||
{"status": "downloading", "filename": "/tmp/v.mp4",
|
||||
"tmpfilename": "/tmp/v.mp4.part", "downloaded_bytes": i}
|
||||
for i in range(1, 6)
|
||||
]
|
||||
download, stat_calls = await self._run_update_status(ticks)
|
||||
|
||||
self.assertEqual(stat_calls, [])
|
||||
self.assertEqual(download.info.filename, "v.mp4")
|
||||
|
||||
async def test_finished_status_still_stats_the_output_file(self):
|
||||
download, stat_calls = await self._run_update_status([
|
||||
{"status": "downloading", "filename": "/tmp/v.mp4", "downloaded_bytes": 1},
|
||||
{"status": "finished", "filename": "/tmp/v.mp4"},
|
||||
])
|
||||
|
||||
self.assertEqual(stat_calls, ["/tmp/v.mp4"])
|
||||
self.assertIsNone(download.info.size)
|
||||
|
||||
async def test_repeated_postprocessing_status_is_broadcast_once(self):
|
||||
# yt-dlp's metaclass wraps run() once per class in a postprocessor's MRO,
|
||||
# so one whose subclass overrides run (FFmpegCopyStream, among others)
|
||||
# reports 'started' twice. Observed live on a 13s libx264 re-encode.
|
||||
download, _ = await self._run_update_status([
|
||||
{"status": "downloading", "downloaded_bytes": 1},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "finished", "filename": "/tmp/v.mp4"},
|
||||
])
|
||||
|
||||
self.assertEqual(
|
||||
download.notifier.broadcast_statuses,
|
||||
["downloading", "postprocessing", "finished"],
|
||||
)
|
||||
|
||||
async def test_postprocessing_is_announced_again_after_the_download_resumes(self):
|
||||
# An audio download runs pre_process postprocessors before the bytes
|
||||
# arrive, so 'postprocessing' legitimately appears on both sides of the
|
||||
# download. Deduping against the live status keeps the second one.
|
||||
download, _ = await self._run_update_status([
|
||||
{"status": "postprocessing"},
|
||||
{"status": "downloading", "downloaded_bytes": 1},
|
||||
{"status": "postprocessing"},
|
||||
{"status": "finished", "filename": "/tmp/v.mp4"},
|
||||
])
|
||||
|
||||
self.assertEqual(
|
||||
download.notifier.broadcast_statuses,
|
||||
["postprocessing", "downloading", "postprocessing", "finished"],
|
||||
)
|
||||
|
||||
+52
-38
@@ -37,8 +37,8 @@ log = logging.getLogger('url_guard')
|
||||
|
||||
_ALLOWED_SCHEMES = ('http', 'https')
|
||||
|
||||
# Ports to assume when a configured proxy URL omits one, per proxy scheme.
|
||||
_PROXY_DEFAULT_PORTS = {
|
||||
# Ports to assume when a configured endpoint URL omits one, per scheme.
|
||||
_SCHEME_DEFAULT_PORTS = {
|
||||
'http': 80,
|
||||
'https': 443,
|
||||
'socks4': 1080,
|
||||
@@ -129,31 +129,32 @@ def _address_is_global(addr: str) -> bool:
|
||||
return bool(ips) and all(ip.is_global for ip in ips)
|
||||
|
||||
|
||||
def _address_allowed_at_connect(addr: str, is_proxy_endpoint: bool = False) -> bool:
|
||||
def _address_allowed_at_connect(addr: str, is_allowed_endpoint: bool = False) -> bool:
|
||||
"""True if *addr* may be connected to at download time.
|
||||
|
||||
Permits global addresses, and anything at all when the destination is an
|
||||
operator-configured proxy (see ``_is_proxy_endpoint``). Internal addresses
|
||||
are otherwise refused with no blanket exception: media URLs that yt-dlp
|
||||
derives from a remote manifest are attacker-controlled and reach this policy
|
||||
without passing ``validate_url``, so any range opened here is a range a
|
||||
hostile playlist can read from the server's own network. Blocks link-local
|
||||
endpoint the operator or the image configured — a proxy, or the PO token
|
||||
provider (see ``_is_allowed_endpoint``). Internal addresses are otherwise
|
||||
refused with no blanket exception: media URLs that yt-dlp derives from a
|
||||
remote manifest are attacker-controlled and reach this policy without passing
|
||||
``validate_url``, so any range opened here is a range a hostile playlist can
|
||||
read from the server's own network. Blocks link-local
|
||||
(cloud metadata at 169.254.169.254), private (RFC1918), loopback,
|
||||
unique-local and every other non-global range.
|
||||
"""
|
||||
ips = _ips_to_judge(addr)
|
||||
if not ips:
|
||||
return False
|
||||
return is_proxy_endpoint or all(ip.is_global for ip in ips)
|
||||
return is_allowed_endpoint or all(ip.is_global for ip in ips)
|
||||
|
||||
|
||||
def _proxy_endpoint(proxy_url: str):
|
||||
"""Parse a proxy URL into a ``(hostname, port)`` pair, or ``None`` if it has
|
||||
no usable host. Used to scope the internal-address allowance to that endpoint
|
||||
alone."""
|
||||
if not isinstance(proxy_url, str) or not proxy_url.strip():
|
||||
def _url_endpoint(url: str):
|
||||
"""Parse a configured URL into a ``(hostname, port)`` pair, or ``None`` if it
|
||||
has no usable host. Used to scope the internal-address allowance to that
|
||||
endpoint alone."""
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return None
|
||||
candidate = proxy_url.strip()
|
||||
candidate = url.strip()
|
||||
if '://' not in candidate:
|
||||
# Bare host:port, as accepted by the *_proxy environment variables.
|
||||
candidate = '//' + candidate
|
||||
@@ -165,23 +166,28 @@ def _proxy_endpoint(proxy_url: str):
|
||||
if not hostname:
|
||||
return None
|
||||
if port is None:
|
||||
port = _PROXY_DEFAULT_PORTS.get(parts.scheme.lower())
|
||||
port = _SCHEME_DEFAULT_PORTS.get(parts.scheme.lower())
|
||||
return (hostname.rstrip('.').lower(), port)
|
||||
|
||||
|
||||
def _endpoints(urls) -> set:
|
||||
"""The parseable endpoints among *urls*, dropping any that name no host."""
|
||||
return {ep for ep in map(_url_endpoint, urls) if ep is not None}
|
||||
|
||||
|
||||
def _collect_proxy_endpoints(proxy_urls) -> set:
|
||||
"""Endpoints of every proxy this download may legitimately dial: the explicit
|
||||
yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls
|
||||
back to. All are operator-configured, unlike the URLs inside fetched media."""
|
||||
candidates = list(proxy_urls) + list(urllib.request.getproxies().values())
|
||||
return {ep for ep in map(_proxy_endpoint, candidates) if ep is not None}
|
||||
return _endpoints(candidates)
|
||||
|
||||
|
||||
# Captured at import so re-installing the guard never wraps the wrapper.
|
||||
_real_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
# Populated by install_socket_guard; empty means no internal destination is allowed.
|
||||
_allowed_proxy_endpoints: set = set()
|
||||
_allowed_endpoints: set = set()
|
||||
|
||||
|
||||
def _normalise_port(port):
|
||||
@@ -196,28 +202,29 @@ def _normalise_port(port):
|
||||
return port
|
||||
|
||||
|
||||
def _is_proxy_endpoint(host, port) -> bool:
|
||||
"""True when host:port is exactly an endpoint the operator configured as a
|
||||
proxy. Matching is on the configured host *string*, not on the resolved
|
||||
address, so a hostile media URL cannot borrow the allowance by resolving to
|
||||
the same address under a different name."""
|
||||
if not _allowed_proxy_endpoints or host is None:
|
||||
def _is_allowed_endpoint(host, port) -> bool:
|
||||
"""True when host:port is exactly one of the endpoints this download is
|
||||
configured to dial — a proxy or the PO token provider. Matching is on the
|
||||
configured host *string*, not on the resolved address, so a hostile media URL
|
||||
cannot borrow the allowance by resolving to the same address under a
|
||||
different name."""
|
||||
if not _allowed_endpoints or host is None:
|
||||
return False
|
||||
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_proxy_endpoints
|
||||
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_endpoints
|
||||
|
||||
|
||||
def _guarded_getaddrinfo(host, *args, **kwargs):
|
||||
results = _real_getaddrinfo(host, *args, **kwargs)
|
||||
# Mirrors getaddrinfo(host, port, ...): port is the first optional argument.
|
||||
port = args[0] if args else kwargs.get('port')
|
||||
is_proxy = _is_proxy_endpoint(host, port)
|
||||
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_proxy)]
|
||||
is_configured = _is_allowed_endpoint(host, port)
|
||||
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_configured)]
|
||||
if not allowed:
|
||||
raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}')
|
||||
return allowed
|
||||
|
||||
|
||||
def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
|
||||
def install_socket_guard(allow_private: bool = False, proxy_urls=(), service_urls=()) -> None:
|
||||
"""Enforce the no-internal-hosts policy at actual connection time.
|
||||
|
||||
``validate_url`` only checks the *submitted* URL string; yt-dlp then follows
|
||||
@@ -230,12 +237,16 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
|
||||
isolation as the backstop.
|
||||
|
||||
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
|
||||
the ``*_proxy`` environment variables are picked up automatically). A proxy is
|
||||
reachable at its own host:port wherever it lives — loopback, the LAN, a VPN
|
||||
range — and nothing else internal is. That costs proxied setups nothing and
|
||||
gives away nothing: yt-dlp resolves the proxy itself at exactly that host:port,
|
||||
and a media URL is either handed to the proxy unresolved or resolved on its own
|
||||
merits — never inheriting the proxy's allowance.
|
||||
the ``*_proxy`` environment variables are picked up automatically), and
|
||||
*service_urls* the helper services the download itself has to reach — the PO
|
||||
token provider this image ships and starts on loopback. Each is reachable at
|
||||
its own host:port wherever it lives — loopback, the LAN, a VPN range — and
|
||||
nothing else internal is. That costs those setups nothing and gives away
|
||||
little: yt-dlp dials each at exactly that host:port, and a media URL is either
|
||||
handed to the proxy unresolved or resolved on its own merits — never
|
||||
inheriting the allowance. A hostile media URL naming an allowed endpoint
|
||||
reaches only what is listening there: a proxy that would have fetched it
|
||||
anyway, or a token server with two endpoints and nothing to read.
|
||||
|
||||
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
|
||||
installed at all, so proxy/VPN setups that route through private or Fake-IP
|
||||
@@ -243,10 +254,13 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
|
||||
"""
|
||||
if allow_private:
|
||||
return
|
||||
_allowed_proxy_endpoints.clear()
|
||||
_allowed_proxy_endpoints.update(_collect_proxy_endpoints(proxy_urls))
|
||||
for host, port in sorted(_allowed_proxy_endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
|
||||
log.info(f'Allowing connections to configured proxy {host}:{port}')
|
||||
proxy_endpoints = _collect_proxy_endpoints(proxy_urls)
|
||||
service_endpoints = _endpoints(service_urls) - proxy_endpoints
|
||||
_allowed_endpoints.clear()
|
||||
_allowed_endpoints.update(proxy_endpoints | service_endpoints)
|
||||
for label, endpoints in (('proxy', proxy_endpoints), ('service', service_endpoints)):
|
||||
for host, port in sorted(endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
|
||||
log.info(f'Allowing connections to configured {label} {host}:{port}')
|
||||
socket.getaddrinfo = _guarded_getaddrinfo
|
||||
|
||||
|
||||
|
||||
+219
-73
@@ -92,6 +92,36 @@ class _DownloadYtdlLogger:
|
||||
# vanish in the child can deadlock it silently before it does any work. This
|
||||
# app creates background threads (executors, notifier callbacks) well before
|
||||
# any download starts, so forcing fork there reproduces exactly that hazard.
|
||||
# The image ships yt-dlp's bgutil PO token provider and starts it on loopback
|
||||
# (docker-entrypoint.sh); the plugin dials this URL unless pointed elsewhere.
|
||||
# Without a token YouTube serves 403s, so the connect-time guard has to let the
|
||||
# download subprocess reach it.
|
||||
_POT_PROVIDER_DEFAULT_URL = 'http://127.0.0.1:4416'
|
||||
|
||||
# extractor-arg keys the bgutil HTTP provider reads its base URL from: the
|
||||
# current one first, then the deprecated form it still honours.
|
||||
_POT_PROVIDER_BASE_URL_ARGS = (
|
||||
('youtubepot-bgutilhttp', 'base_url'),
|
||||
('youtube', 'getpot_bgutil_baseurl'),
|
||||
)
|
||||
|
||||
|
||||
def _pot_provider_urls(ytdl_opts: dict) -> tuple:
|
||||
"""Every PO token provider endpoint this download may dial: the bundled one,
|
||||
plus any the operator pointed yt-dlp at through ``extractor_args``. The
|
||||
bundled server runs either way, so it stays allowed even when a base URL is
|
||||
configured."""
|
||||
urls = [_POT_PROVIDER_DEFAULT_URL]
|
||||
extractor_args = ytdl_opts.get('extractor_args')
|
||||
if isinstance(extractor_args, dict):
|
||||
for ie_key, arg in _POT_PROVIDER_BASE_URL_ARGS:
|
||||
section = extractor_args.get(ie_key)
|
||||
values = section.get(arg) if isinstance(section, dict) else None
|
||||
if values:
|
||||
urls.append(values[0])
|
||||
return tuple(urls)
|
||||
|
||||
|
||||
_MP_CTX = (
|
||||
multiprocessing.get_context("fork")
|
||||
if sys.platform.startswith("linux") and "fork" in multiprocessing.get_all_start_methods()
|
||||
@@ -453,6 +483,7 @@ class DownloadInfo:
|
||||
clip_end=None,
|
||||
live_status=None,
|
||||
live_release_timestamp=None,
|
||||
sponsorblock=False,
|
||||
):
|
||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||
@@ -472,6 +503,7 @@ class DownloadInfo:
|
||||
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
|
||||
self.playlist_item_limit = playlist_item_limit
|
||||
self.split_by_chapters = split_by_chapters
|
||||
self.sponsorblock = sponsorblock
|
||||
self.chapter_template = chapter_template
|
||||
self.subtitle_language = subtitle_language
|
||||
self.subtitle_mode = subtitle_mode
|
||||
@@ -541,6 +573,8 @@ class DownloadInfo:
|
||||
self.playlist_item_limit = 0
|
||||
if not hasattr(self, "split_by_chapters"):
|
||||
self.split_by_chapters = False
|
||||
if not hasattr(self, "sponsorblock"):
|
||||
self.sponsorblock = False
|
||||
if not hasattr(self, "chapter_template"):
|
||||
self.chapter_template = ""
|
||||
if not hasattr(self, "subtitle_language"):
|
||||
@@ -585,6 +619,7 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
||||
"custom_name_prefix",
|
||||
"playlist_item_limit",
|
||||
"split_by_chapters",
|
||||
"sponsorblock",
|
||||
"chapter_template",
|
||||
"subtitle_language",
|
||||
"subtitle_mode",
|
||||
@@ -736,6 +771,59 @@ class Download:
|
||||
|
||||
return put_status
|
||||
|
||||
def _make_postprocessor_hook(self):
|
||||
# yt-dlp reports the download 'finished' as soon as the media bytes
|
||||
# have landed, but the ffmpeg work that follows -- merging,
|
||||
# re-encoding, chapter splitting, sponsor removal -- routinely takes
|
||||
# longer than the download itself and said nothing until now, leaving
|
||||
# the row on a full, frozen progress bar for minutes. Report the phase
|
||||
# so the UI can run its indeterminate bar instead.
|
||||
#
|
||||
# Latched off once MoveFiles reports finished: that branch emits the
|
||||
# terminal 'finished', and _post_download_cleanup turns any other
|
||||
# final status into an error, so nothing may overwrite it. yt-dlp does
|
||||
# run an 'after_move' stage after MoveFiles, but every postprocessor
|
||||
# MeTube configures is 'after_filter' or 'post_process', both of which
|
||||
# precede it. Each postprocessor emits exactly one started/finished
|
||||
# pair, so this needs no throttling.
|
||||
postprocessing_done = False
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
nonlocal postprocessing_done
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
postprocessing_done = True
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
finaldir = d['info_dict']['__finaldir']
|
||||
filename = os.path.join(finaldir, os.path.basename(filepath))
|
||||
else:
|
||||
filename = filepath
|
||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||
# For captions-only downloads, yt-dlp may still report a media-like
|
||||
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||
# UI can link to real caption files.
|
||||
if getattr(self.info, 'download_type', '') == 'captions':
|
||||
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
|
||||
# Capture all chapter files when SplitChapters finishes
|
||||
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
|
||||
chapters = d.get('info_dict', {}).get('chapters', [])
|
||||
if chapters:
|
||||
for chapter in chapters:
|
||||
if isinstance(chapter, dict) and 'filepath' in chapter:
|
||||
log.info(f"Captured chapter file: {chapter['filepath']}")
|
||||
self.status_queue.put({'chapter_file': chapter['filepath']})
|
||||
else:
|
||||
log.warning("SplitChapters finished but no chapter files found in info_dict")
|
||||
|
||||
elif d.get('status') == 'started' and not postprocessing_done:
|
||||
self.status_queue.put({'status': 'postprocessing'})
|
||||
|
||||
return put_status_postprocessor
|
||||
|
||||
def _make_youtube_dl(self, params):
|
||||
ydl = _ConfinedYoutubeDL(
|
||||
params=params,
|
||||
@@ -764,10 +852,15 @@ class Download:
|
||||
# Re-validate every outbound connection at fetch time. validate_url only
|
||||
# saw the submitted URL string; this catches redirects, DNS rebinding and
|
||||
# attacker-controlled media URLs pulled from a remote manifest, none of
|
||||
# which it can see. The configured proxy is passed so that a proxy on an
|
||||
# internal address stays reachable at its own host:port without opening up
|
||||
# anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the environment.
|
||||
install_socket_guard(self.allow_private, proxy_urls=(self.ytdl_opts.get('proxy'),))
|
||||
# which it can see. The configured proxy and the PO token provider are
|
||||
# passed so that each stays reachable at its own host:port without opening
|
||||
# up anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the
|
||||
# environment.
|
||||
install_socket_guard(
|
||||
self.allow_private,
|
||||
proxy_urls=(self.ytdl_opts.get('proxy'),),
|
||||
service_urls=_pot_provider_urls(self.ytdl_opts),
|
||||
)
|
||||
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
||||
# Bound outside the try so the except branch can read what was captured
|
||||
# before the error was raised.
|
||||
@@ -775,35 +868,7 @@ class Download:
|
||||
try:
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
put_status = self._make_progress_hook()
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
finaldir = d['info_dict']['__finaldir']
|
||||
filename = os.path.join(finaldir, os.path.basename(filepath))
|
||||
else:
|
||||
filename = filepath
|
||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||
# For captions-only downloads, yt-dlp may still report a media-like
|
||||
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||
# UI can link to real caption files.
|
||||
if getattr(self.info, 'download_type', '') == 'captions':
|
||||
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
|
||||
# Capture all chapter files when SplitChapters finishes
|
||||
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
|
||||
chapters = d.get('info_dict', {}).get('chapters', [])
|
||||
if chapters:
|
||||
for chapter in chapters:
|
||||
if isinstance(chapter, dict) and 'filepath' in chapter:
|
||||
log.info(f"Captured chapter file: {chapter['filepath']}")
|
||||
self.status_queue.put({'chapter_file': chapter['filepath']})
|
||||
else:
|
||||
log.warning("SplitChapters finished but no chapter files found in info_dict")
|
||||
put_status_postprocessor = self._make_postprocessor_hook()
|
||||
|
||||
ytdl_params = {
|
||||
'quiet': not debug_logging,
|
||||
@@ -822,6 +887,27 @@ class Download:
|
||||
# this logger, so a user-supplied one must not replace it.
|
||||
ytdl_params['logger'] = ytdl_logger
|
||||
|
||||
# SponsorBlock: mark sponsor segments and cut them out, the same
|
||||
# postprocessor pair the CLI's --sponsorblock-remove sponsor builds.
|
||||
# This has to stay above the chapter-splitting block: yt-dlp runs
|
||||
# same-stage postprocessors in list order, and ModifyChapters must
|
||||
# rewrite the chapter list before FFmpegSplitChapters cuts the file
|
||||
# up, or the chapter files keep the sponsor segments and the
|
||||
# removal desyncs the remaining chapter timings.
|
||||
if getattr(self.info, 'sponsorblock', False):
|
||||
if 'postprocessors' not in ytdl_params:
|
||||
ytdl_params['postprocessors'] = []
|
||||
ytdl_params['postprocessors'].append({
|
||||
'key': 'SponsorBlock',
|
||||
'categories': ['sponsor'],
|
||||
'when': 'after_filter',
|
||||
})
|
||||
ytdl_params['postprocessors'].append({
|
||||
'key': 'ModifyChapters',
|
||||
'remove_sponsor_segments': ['sponsor'],
|
||||
'force_keyframes': False,
|
||||
})
|
||||
|
||||
# Add chapter splitting options if enabled
|
||||
if self.info.split_by_chapters:
|
||||
ytdl_params['outtmpl']['chapter'] = self.info.chapter_template
|
||||
@@ -966,7 +1052,17 @@ class Download:
|
||||
if not rel_name.lower().endswith(allowed_caption_exts):
|
||||
continue
|
||||
self.info.filename = rel_name
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
# Stat only on a terminal status. yt-dlp documents 'filename' as
|
||||
# always present in a progress hook, but until the download
|
||||
# finishes the bytes are in tmpfilename and 'filename' is a
|
||||
# destination that does not exist yet -- so this was two
|
||||
# blocking syscalls on the event loop, twice a second per
|
||||
# active download, to arrive at None. A stat that takes seconds
|
||||
# on a contended filesystem stalls every other request the
|
||||
# server is serving. Nothing displays the size before
|
||||
# completion: the Downloading table has no size column.
|
||||
if status.get('status') == 'finished':
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||
# The thumbnail convertor always emits a .jpg, but yt-dlp may
|
||||
# report the pre-conversion media/thumbnail extension
|
||||
@@ -1019,6 +1115,17 @@ class Download:
|
||||
self.info.size = file_size
|
||||
continue
|
||||
|
||||
# yt-dlp's postprocessor metaclass wraps run() once per class in the
|
||||
# MRO, so a postprocessor whose subclass overrides run reports
|
||||
# started twice (FFmpegCopyStream, among others). Nothing has changed
|
||||
# between the two, so drop the repeat rather than re-encode and
|
||||
# rebroadcast the same state to every connected client. Compared
|
||||
# against the live status, not a flag in the hook, so the phase is
|
||||
# still announced when a pre_process postprocessor ran before the
|
||||
# download and 'downloading' came in between.
|
||||
if status['status'] == 'postprocessing' and self.info.status == 'postprocessing':
|
||||
continue
|
||||
|
||||
self.info.status = status['status']
|
||||
self.info.msg = status.get('msg')
|
||||
if 'downloaded_bytes' in status:
|
||||
@@ -1040,6 +1147,19 @@ class PersistentQueue:
|
||||
self.path = f"{path}.json"
|
||||
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
||||
self.dict = OrderedDict()
|
||||
# A state write fsyncs twice (the file and its directory). On a slow or
|
||||
# contended filesystem that is seconds, and running it inline in an
|
||||
# async caller blocked the event loop -- every other request stalled
|
||||
# behind a single queue mutation. One dedicated thread keeps the writes
|
||||
# off the loop and, being single, keeps them ordered. The default
|
||||
# executor is not usable for this: extract_info shares it and can hold
|
||||
# its threads for minutes, which is exactly when state writes happen.
|
||||
self._store_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix=f"state-{name}")
|
||||
# Guards the mutate-write-rollback section. The write is awaited now, so
|
||||
# without this two callers could interleave between changing self.dict
|
||||
# and persisting it, and a rollback could revert the wrong entry.
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def load(self):
|
||||
for k, v in self.saved_items():
|
||||
@@ -1080,8 +1200,14 @@ class PersistentQueue:
|
||||
for key, download in self.dict.items()
|
||||
]
|
||||
|
||||
def _save_dict(self):
|
||||
self.store.save({"items": self._serialize_items()})
|
||||
async def _save_dict_async(self):
|
||||
# Serialize on the event loop -- it is pure CPU and sub-millisecond --
|
||||
# and hand the finished payload to the writer thread. Serializing in the
|
||||
# thread instead would have it walk live DownloadInfo objects while the
|
||||
# loop mutates them.
|
||||
payload = {"items": self._serialize_items()}
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
self._store_executor, self.store.save, payload)
|
||||
|
||||
def _load_state_items(self):
|
||||
payload = self.store.load()
|
||||
@@ -1122,32 +1248,39 @@ class PersistentQueue:
|
||||
self.store.save({"items": items})
|
||||
return items
|
||||
|
||||
def put(self, value):
|
||||
async def put(self, value):
|
||||
key = value.info.url
|
||||
old = self.dict.get(key)
|
||||
self.dict[key] = value
|
||||
try:
|
||||
self._save_dict()
|
||||
except Exception:
|
||||
if old is None:
|
||||
del self.dict[key]
|
||||
else:
|
||||
self.dict[key] = old
|
||||
raise
|
||||
|
||||
def delete(self, key):
|
||||
if key in self.dict:
|
||||
old = self.dict[key]
|
||||
del self.dict[key]
|
||||
async with self._lock:
|
||||
old = self.dict.get(key)
|
||||
self.dict[key] = value
|
||||
try:
|
||||
self._save_dict()
|
||||
await self._save_dict_async()
|
||||
except Exception:
|
||||
self.dict[key] = old
|
||||
if old is None:
|
||||
del self.dict[key]
|
||||
else:
|
||||
self.dict[key] = old
|
||||
raise
|
||||
|
||||
async def delete(self, key):
|
||||
async with self._lock:
|
||||
if key in self.dict:
|
||||
old = self.dict[key]
|
||||
del self.dict[key]
|
||||
try:
|
||||
await self._save_dict_async()
|
||||
except Exception:
|
||||
self.dict[key] = old
|
||||
raise
|
||||
|
||||
def empty(self):
|
||||
return not bool(self.dict)
|
||||
|
||||
def close(self):
|
||||
# wait=True so a write already in flight reaches disk before the
|
||||
# process exits; there is at most one, and it is the newest state.
|
||||
self._store_executor.shutdown(wait=True)
|
||||
|
||||
class DownloadQueue:
|
||||
def __init__(self, config, notifier):
|
||||
self.config = config
|
||||
@@ -1324,8 +1457,8 @@ class DownloadQueue:
|
||||
if not info.error:
|
||||
info.error = str(exc)
|
||||
self._unregister_scheduled(url)
|
||||
self.queue.delete(url)
|
||||
self.done.put(download)
|
||||
await self.queue.delete(url)
|
||||
await self.done.put(download)
|
||||
await self.notifier.completed(info)
|
||||
else:
|
||||
log.warning(
|
||||
@@ -1359,9 +1492,9 @@ class DownloadQueue:
|
||||
await self.notifier.updated(info)
|
||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||
|
||||
def _schedule_upcoming_download(self, download: Download) -> None:
|
||||
async def _schedule_upcoming_download(self, download: Download) -> None:
|
||||
download.info.status = 'scheduled'
|
||||
self.queue.put(download)
|
||||
await self.queue.put(download)
|
||||
self._register_scheduled(download)
|
||||
|
||||
def _force_start_scheduled(self, download: Download) -> None:
|
||||
@@ -1380,9 +1513,9 @@ class DownloadQueue:
|
||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||
return
|
||||
await download.start(self.notifier, self._download_executor)
|
||||
self._post_download_cleanup(download)
|
||||
await self._post_download_cleanup(download)
|
||||
|
||||
def _post_download_cleanup(self, download):
|
||||
async def _post_download_cleanup(self, download):
|
||||
if download.info.status != 'finished':
|
||||
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
||||
try:
|
||||
@@ -1402,11 +1535,11 @@ class DownloadQueue:
|
||||
download.info.size = None
|
||||
download.close()
|
||||
if self.queue.exists(download.info.url):
|
||||
self.queue.delete(download.info.url)
|
||||
await self.queue.delete(download.info.url)
|
||||
if download.canceled:
|
||||
bg_tasks.create_task(self.notifier.canceled(download.info.url), name="notify_canceled")
|
||||
else:
|
||||
self.done.put(download)
|
||||
await self.done.put(download)
|
||||
bg_tasks.create_task(self.notifier.completed(download.info), name="notify_completed")
|
||||
try:
|
||||
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||
@@ -1514,12 +1647,12 @@ class DownloadQueue:
|
||||
)
|
||||
if auto_start is True:
|
||||
if is_upcoming:
|
||||
self._schedule_upcoming_download(download)
|
||||
await self._schedule_upcoming_download(download)
|
||||
else:
|
||||
self.queue.put(download)
|
||||
await self.queue.put(download)
|
||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||
else:
|
||||
self.pending.put(download)
|
||||
await self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
|
||||
def __write_feed_metadata_sync(self, entry, etype, download_type, folder,
|
||||
@@ -1619,6 +1752,7 @@ class DownloadQueue:
|
||||
already,
|
||||
_add_gen=None,
|
||||
retry_entry=None,
|
||||
sponsorblock=False,
|
||||
):
|
||||
if not entry:
|
||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||
@@ -1662,6 +1796,7 @@ class DownloadQueue:
|
||||
already,
|
||||
_add_gen,
|
||||
retry_entry,
|
||||
sponsorblock=sponsorblock,
|
||||
)
|
||||
elif etype == 'playlist' or etype == 'channel':
|
||||
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
||||
@@ -1729,6 +1864,7 @@ class DownloadQueue:
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
sponsorblock=sponsorblock,
|
||||
)
|
||||
)
|
||||
if any(res['status'] == 'error' for res in results):
|
||||
@@ -1769,6 +1905,7 @@ class DownloadQueue:
|
||||
clip_end=clip_end,
|
||||
live_status=entry.get('live_status'),
|
||||
live_release_timestamp=entry.get('release_timestamp'),
|
||||
sponsorblock=sponsorblock,
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
@@ -1824,7 +1961,7 @@ class DownloadQueue:
|
||||
info.status = 'error'
|
||||
info.msg = msg
|
||||
download = Download(None, None, None, None, quality, format, {}, info)
|
||||
self.done.put(download)
|
||||
await self.done.put(download)
|
||||
await self.notifier.completed(info)
|
||||
|
||||
async def add(
|
||||
@@ -1849,13 +1986,14 @@ class DownloadQueue:
|
||||
already=None,
|
||||
_add_gen=None,
|
||||
retry_entry=None,
|
||||
sponsorblock=False,
|
||||
):
|
||||
if ytdl_options_presets is None:
|
||||
ytdl_options_presets = []
|
||||
log.info(
|
||||
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} '
|
||||
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
|
||||
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=}'
|
||||
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=} {sponsorblock=}'
|
||||
)
|
||||
if already is None:
|
||||
_add_gen = self._add_generation
|
||||
@@ -1918,6 +2056,7 @@ class DownloadQueue:
|
||||
already,
|
||||
_add_gen,
|
||||
retry_entry,
|
||||
sponsorblock=sponsorblock,
|
||||
)
|
||||
|
||||
async def retry(self, id):
|
||||
@@ -1954,6 +2093,7 @@ class DownloadQueue:
|
||||
info.clip_start,
|
||||
info.clip_end,
|
||||
retry_entry=info.entry,
|
||||
sponsorblock=info.sponsorblock,
|
||||
)
|
||||
|
||||
async def add_entry(
|
||||
@@ -1975,6 +2115,7 @@ class DownloadQueue:
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
sponsorblock=False,
|
||||
):
|
||||
if ytdl_options_presets is None:
|
||||
ytdl_options_presets = []
|
||||
@@ -2000,17 +2141,18 @@ class DownloadQueue:
|
||||
clip_end,
|
||||
already,
|
||||
None,
|
||||
sponsorblock=sponsorblock,
|
||||
)
|
||||
|
||||
async def start_pending(self, ids):
|
||||
for id in ids:
|
||||
if self.pending.exists(id):
|
||||
dl = self.pending.get(id)
|
||||
self.pending.delete(id)
|
||||
await self.pending.delete(id)
|
||||
if getattr(dl.info, 'live_status', None) == 'is_upcoming':
|
||||
self._schedule_upcoming_download(dl)
|
||||
await self._schedule_upcoming_download(dl)
|
||||
else:
|
||||
self.queue.put(dl)
|
||||
await self.queue.put(dl)
|
||||
bg_tasks.create_task(self.__start_download(dl), name="start_download")
|
||||
continue
|
||||
if self.queue.exists(id):
|
||||
@@ -2026,7 +2168,7 @@ class DownloadQueue:
|
||||
# Track URL so playlist add loop won't re-queue it
|
||||
self._canceled_urls.add(id)
|
||||
if self.pending.exists(id):
|
||||
self.pending.delete(id)
|
||||
await self.pending.delete(id)
|
||||
await self.notifier.canceled(id)
|
||||
continue
|
||||
if not self.queue.exists(id):
|
||||
@@ -2039,7 +2181,7 @@ class DownloadQueue:
|
||||
dl.cancel()
|
||||
else:
|
||||
dl.canceled = True
|
||||
self.queue.delete(id)
|
||||
await self.queue.delete(id)
|
||||
await self.notifier.canceled(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -2077,7 +2219,7 @@ class DownloadQueue:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning(f'deleting file "{rel_name}" for download {id} failed with error message {e!r}')
|
||||
self.done.delete(id)
|
||||
await self.done.delete(id)
|
||||
await self.notifier.cleared(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
@@ -2095,3 +2237,7 @@ class DownloadQueue:
|
||||
if download.started() and download.running():
|
||||
download.cancel()
|
||||
self._download_executor.shutdown(wait=False, cancel_futures=True)
|
||||
# Unlike the download executor these are drained, not cancelled: a
|
||||
# queued write is the newest state and must reach disk before exit.
|
||||
for queue in (self.queue, self.pending, self.done):
|
||||
queue.close()
|
||||
|
||||
+418
-383
@@ -399,6 +399,16 @@
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-sponsorblock"
|
||||
name="sponsorblock" [(ngModel)]="sponsorblock" (change)="sponsorblockChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
<label class="form-check-label" for="checkbox-sponsorblock"
|
||||
ngbPopover="Cut out sponsor segments using SponsorBlock's crowd-sourced markers (YouTube only)."
|
||||
triggers="hover" container="body">Remove sponsor segments</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
||||
@@ -680,399 +690,424 @@
|
||||
Connecting to server...
|
||||
</div>
|
||||
}
|
||||
<div class="metube-section-header">Downloading</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" style="width: 7rem;">Format</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
<th scope="col" style="width: 7rem;">ETA</th>
|
||||
<th scope="col" style="width: 6rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
||||
</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 class="d-flex align-items-center flex-wrap gap-2">
|
||||
<span>{{ download.value.title }}</span>
|
||||
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||
<span class="badge bg-danger">LIVE</span>
|
||||
}
|
||||
</div>
|
||||
@if (download.value.status === 'scheduled') {
|
||||
<span class="badge bg-warning text-dark">
|
||||
<fa-icon [icon]="faClock" />
|
||||
Waiting for stream
|
||||
@if (liveCountdownSeconds(download.value); as secs) {
|
||||
- starts in {{ secs | eta }}
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
||||
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">{{ formatLabel(download.value) }}</td>
|
||||
<td>{{ download.value.speed | speed }}</td>
|
||||
<td>{{ download.value.eta | eta }}</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||
}
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'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>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="metube-section-header">Completed</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Codec / Format</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col">Downloaded</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSortedDone; track entry[0]) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@if (entry[1].status === 'finished') {
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||
}
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0"
|
||||
(click)="toggleErrorDetail(entry[0])"
|
||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||
} @else {
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
|
||||
{{entry[1].title}}
|
||||
@if (!isErrorExpanded(entry[0])) {
|
||||
<small class="text-danger ms-2">
|
||||
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
||||
</small>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<span>{{entry[1].title}}</span>
|
||||
}
|
||||
}</span>
|
||||
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
||||
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
@if (entry[1].msg) {
|
||||
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
||||
}
|
||||
@if (entry[1].error) {
|
||||
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
||||
}
|
||||
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
||||
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
||||
ngbTooltip="Copy error details to clipboard">
|
||||
@if (lastCopiedErrorId === entry[0]) {
|
||||
<span class="text-success">Copied!</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faCopy" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ downloadTypeLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatQualityLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatCodecLabel(entry[1]) }}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].size) {
|
||||
<span>{{ entry[1].size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].timestamp) {
|
||||
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
}
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
@if (entry[1].filename && canShareDownloads()) {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
|
||||
}
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
||||
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td></td>
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
||||
getChapterFileName(chapterFile.filename) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
@if (chapterFile.size) {
|
||||
<span>{{ chapterFile.size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="metube-section-header">Subscriptions</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
@if (checkingAllSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkAllSubscriptions()"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check all now
|
||||
</button>
|
||||
}
|
||||
@if (checkingSelectedSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check selected
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="deleteSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
||||
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
||||
<div class="metube-section-header">
|
||||
<button type="button" class="metube-section-toggle" (click)="toggleDownloadingCollapsed()" [attr.aria-expanded]="!downloadingCollapsed">
|
||||
<span>Downloading</span>
|
||||
<fa-icon [icon]="downloadingCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="allSubsSelected()"
|
||||
(change)="toggleSubMaster($event)"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0"
|
||||
aria-label="Select all subscriptions" />
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
|
||||
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||
<th scope="col" class="text-nowrap">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSubs; track entry[0]) {
|
||||
@if (!downloadingCollapsed) {
|
||||
<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>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="isSubSelected(entry[0])"
|
||||
(change)="toggleSubSelected(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||
</td>
|
||||
<td>
|
||||
@if (editingNameId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subName' + entry[0]"
|
||||
[(ngModel)]="nameEditDraft"
|
||||
[maxlength]="subscriptionNameMaxLength"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveName(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditName()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-break">{{ entry[1].name }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditName(entry[0], entry[1].name)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>
|
||||
@if (editingTitleRegexId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subTitleRegex' + entry[0]"
|
||||
[(ngModel)]="titleRegexEditDraft"
|
||||
[disabled]="downloads.loading" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveTitleRegex(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditTitleRegex()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-muted small text-break"
|
||||
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ entry[1].check_interval_minutes }}</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].last_checked !== null) {
|
||||
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
} @else {
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].error) {
|
||||
<span class="text-danger small">{{ entry[1].error }}</span>
|
||||
} @else if (entry[1].enabled) {
|
||||
<span class="text-success">Active</span>
|
||||
} @else {
|
||||
<span class="text-secondary">Paused</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@if (isSubscriptionChecking(entry[0])) {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
disabled
|
||||
[attr.aria-label]="'Checking ' + entry[1].name"
|
||||
ngbTooltip="Checking now">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="checkSubscriptionNow(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Check now ' + entry[1].name"
|
||||
ngbTooltip="Check now">
|
||||
<fa-icon [icon]="faRedoAlt" />
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="toggleSubscriptionEnabled(entry[1])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
||||
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
||||
@if (entry[1].enabled) {
|
||||
<fa-icon [icon]="faPause" />
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" style="width: 7rem;">Format</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
<th scope="col" style="width: 7rem;">ETA</th>
|
||||
<th scope="col" style="width: 6rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
||||
</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 class="d-flex align-items-center flex-wrap gap-2">
|
||||
<span>{{ download.value.title }}</span>
|
||||
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||
<span class="badge bg-danger">LIVE</span>
|
||||
}
|
||||
</div>
|
||||
@if (download.value.status === 'scheduled') {
|
||||
<span class="badge bg-warning text-dark">
|
||||
<fa-icon [icon]="faClock" />
|
||||
Waiting for stream
|
||||
@if (liveCountdownSeconds(download.value); as secs) {
|
||||
- starts in {{ secs | eta }}
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faPlay" />
|
||||
<ngb-progressbar height="1.5rem" [showValue]="!isIndeterminate(download.value)" [striped]="isIndeterminate(download.value)" [animated]="isIndeterminate(download.value)" type="success"
|
||||
[value]="isIndeterminate(download.value) ? 100 : download.value.percent" class="download-progressbar">
|
||||
@if (download.value.status === 'postprocessing') {
|
||||
<span>Post-processing</span>
|
||||
}
|
||||
</ngb-progressbar>
|
||||
}
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
||||
(click)="deleteSubscription(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Delete subscription ' + entry[1].name">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">{{ formatLabel(download.value) }}</td>
|
||||
<td>{{ download.value.speed | speed }}</td>
|
||||
<td>{{ download.value.eta | eta }}</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||
}
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'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>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="metube-section-header">
|
||||
<button type="button" class="metube-section-toggle" (click)="toggleCompletedCollapsed()" [attr.aria-expanded]="!completedCollapsed">
|
||||
<span>Completed</span>
|
||||
<fa-icon [icon]="completedCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
@if (!completedCollapsed) {
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Codec / Format</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col">Downloaded</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSortedDone; track entry[0]) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@if (entry[1].status === 'finished') {
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||
}
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0"
|
||||
(click)="toggleErrorDetail(entry[0])"
|
||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||
} @else {
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
|
||||
{{entry[1].title}}
|
||||
@if (!isErrorExpanded(entry[0])) {
|
||||
<small class="text-danger ms-2">
|
||||
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
||||
</small>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<span>{{entry[1].title}}</span>
|
||||
}
|
||||
}</span>
|
||||
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
||||
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
@if (entry[1].msg) {
|
||||
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
||||
}
|
||||
@if (entry[1].error) {
|
||||
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
||||
}
|
||||
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
||||
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
||||
ngbTooltip="Copy error details to clipboard">
|
||||
@if (lastCopiedErrorId === entry[0]) {
|
||||
<span class="text-success">Copied!</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faCopy" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ downloadTypeLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatQualityLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatCodecLabel(entry[1]) }}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].size) {
|
||||
<span>{{ entry[1].size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].timestamp) {
|
||||
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
}
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
@if (entry[1].filename && canShareDownloads()) {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
|
||||
}
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
||||
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td></td>
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
||||
getChapterFileName(chapterFile.filename) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
@if (chapterFile.size) {
|
||||
<span>{{ chapterFile.size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="metube-section-header">
|
||||
<button type="button" class="metube-section-toggle" (click)="toggleSubscriptionsCollapsed()" [attr.aria-expanded]="!subscriptionsCollapsed">
|
||||
<span>Subscriptions</span>
|
||||
<fa-icon [icon]="subscriptionsCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
@if (!subscriptionsCollapsed) {
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
@if (checkingAllSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkAllSubscriptions()"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check all now
|
||||
</button>
|
||||
}
|
||||
@if (checkingSelectedSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check selected
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="deleteSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
||||
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="allSubsSelected()"
|
||||
(change)="toggleSubMaster($event)"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0"
|
||||
aria-label="Select all subscriptions" />
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
|
||||
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||
<th scope="col" class="text-nowrap">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSubs; track entry[0]) {
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="isSubSelected(entry[0])"
|
||||
(change)="toggleSubSelected(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||
</td>
|
||||
<td>
|
||||
@if (editingNameId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subName' + entry[0]"
|
||||
[(ngModel)]="nameEditDraft"
|
||||
[maxlength]="subscriptionNameMaxLength"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveName(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditName()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-break">{{ entry[1].name }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditName(entry[0], entry[1].name)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>
|
||||
@if (editingTitleRegexId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subTitleRegex' + entry[0]"
|
||||
[(ngModel)]="titleRegexEditDraft"
|
||||
[disabled]="downloads.loading" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveTitleRegex(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditTitleRegex()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-muted small text-break"
|
||||
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ entry[1].check_interval_minutes }}</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].last_checked !== null) {
|
||||
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
} @else {
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].error) {
|
||||
<span class="text-danger small">{{ entry[1].error }}</span>
|
||||
} @else if (entry[1].enabled) {
|
||||
<span class="text-success">Active</span>
|
||||
} @else {
|
||||
<span class="text-secondary">Paused</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@if (isSubscriptionChecking(entry[0])) {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
disabled
|
||||
[attr.aria-label]="'Checking ' + entry[1].name"
|
||||
ngbTooltip="Checking now">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="checkSubscriptionNow(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Check now ' + entry[1].name"
|
||||
ngbTooltip="Check now">
|
||||
<fa-icon [icon]="faRedoAlt" />
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="toggleSubscriptionEnabled(entry[1])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
||||
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
||||
@if (entry[1].enabled) {
|
||||
<fa-icon [icon]="faPause" />
|
||||
} @else {
|
||||
<fa-icon [icon]="faPlay" />
|
||||
}
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
||||
(click)="deleteSubscription(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Delete subscription ' + entry[1].name">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</main><!-- /.container -->
|
||||
|
||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||
|
||||
@@ -10,6 +10,28 @@
|
||||
padding: 0.5rem 0
|
||||
margin-top: 3.5rem
|
||||
|
||||
.metube-section-toggle
|
||||
// Positioned so it paints above the header's full-bleed :before overlay.
|
||||
position: relative
|
||||
display: flex
|
||||
align-items: center
|
||||
// Title left, chevron against the right edge, so all three section titles
|
||||
// stay on the same left margin whether or not a chevron is present.
|
||||
justify-content: space-between
|
||||
gap: 0.75rem
|
||||
width: 100%
|
||||
padding: 0
|
||||
border: 0
|
||||
background: none
|
||||
color: inherit
|
||||
font: inherit
|
||||
text-align: left
|
||||
|
||||
.metube-section-chevron
|
||||
font-size: 1.1rem
|
||||
width: 1.1rem
|
||||
color: var(--bs-secondary-color)
|
||||
|
||||
.metube-section-header:before
|
||||
content: ""
|
||||
position: absolute
|
||||
|
||||
@@ -168,6 +168,31 @@ describe('App', () => {
|
||||
expect(fixture.componentInstance.folder).toBe('music');
|
||||
});
|
||||
|
||||
it('collapses each section independently and remembers it (#1070)', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const app = fixture.componentInstance;
|
||||
const cookies = TestBed.inject(CookieService);
|
||||
|
||||
expect(app.downloadingCollapsed).toBe(false);
|
||||
expect(app.completedCollapsed).toBe(false);
|
||||
expect(app.subscriptionsCollapsed).toBe(false);
|
||||
|
||||
app.toggleCompletedCollapsed();
|
||||
|
||||
expect(app.completedCollapsed).toBe(true);
|
||||
expect(app.downloadingCollapsed).toBe(false);
|
||||
expect(app.subscriptionsCollapsed).toBe(false);
|
||||
expect(cookies.get('metube_completed_collapsed')).toBe('true');
|
||||
|
||||
// A fresh component picks the state back up from the cookie.
|
||||
const restored = TestBed.createComponent(App);
|
||||
restored.detectChanges();
|
||||
expect(restored.componentInstance.completedCollapsed).toBe(true);
|
||||
expect(restored.componentInstance.downloadingCollapsed).toBe(false);
|
||||
expect(restored.componentInstance.subscriptionsCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
@@ -413,4 +438,110 @@ describe('App', () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith('Subscription name must not be empty');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
// Issue #533: the server picks AUDIO_DOWNLOAD_DIR on download_type alone
|
||||
// (ytdl.py), so the UI's choice of URL base has to use the same rule. It used
|
||||
// to also treat any .mp3 as audio, which pointed the link at audio_download/
|
||||
// for files the server had written to DOWNLOAD_DIR.
|
||||
describe('download links follow the server directory rule (#533)', () => {
|
||||
const makeDownload = (over: Partial<Download>): Download => ({
|
||||
id: 'vid1',
|
||||
title: 'Test',
|
||||
url: 'https://example.com/v',
|
||||
download_type: 'video',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
custom_name_prefix: '',
|
||||
playlist_item_limit: 0,
|
||||
status: 'finished',
|
||||
msg: '',
|
||||
percent: 100,
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
filename: 'song.mp4',
|
||||
checked: false,
|
||||
...over,
|
||||
} as Download);
|
||||
|
||||
const appWithDirs = () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
const downloads = TestBed.inject(DownloadsService) as unknown as DownloadsServiceStub;
|
||||
downloads.configuration['PUBLIC_HOST_URL'] = 'download/';
|
||||
downloads.configuration['PUBLIC_HOST_AUDIO_URL'] = 'audio_download/';
|
||||
return app;
|
||||
};
|
||||
|
||||
it('uses the audio base for an audio download', () => {
|
||||
const app = appWithDirs();
|
||||
const link = app.buildDownloadLink(makeDownload({ download_type: 'audio', filename: 'song.mp3' }));
|
||||
expect(link).toBe('audio_download/song.mp3');
|
||||
});
|
||||
|
||||
it('uses the video base for an mp3 produced by a video download', () => {
|
||||
const app = appWithDirs();
|
||||
const link = app.buildDownloadLink(makeDownload({ download_type: 'video', filename: 'song.mp3' }));
|
||||
expect(link).toBe('download/song.mp3');
|
||||
});
|
||||
|
||||
it('uses the video base for a video download', () => {
|
||||
const app = appWithDirs();
|
||||
const link = app.buildDownloadLink(makeDownload({ filename: 'clip.mp4' }));
|
||||
expect(link).toBe('download/clip.mp4');
|
||||
});
|
||||
|
||||
it('applies the same rule to chapter links', () => {
|
||||
const app = appWithDirs();
|
||||
const dl = makeDownload({ download_type: 'video' });
|
||||
expect(app.buildChapterDownloadLink(dl, 'ch1.mp3')).toBe('download/ch1.mp3');
|
||||
const audio = makeDownload({ download_type: 'audio' });
|
||||
expect(app.buildChapterDownloadLink(audio, 'ch1.mp3')).toBe('audio_download/ch1.mp3');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #424: ffmpeg work after the bytes land (merge, re-encode, split) used
|
||||
// to leave the row on a full, frozen bar with the item counted as neither
|
||||
// active nor queued.
|
||||
describe('post-processing is visible (#424)', () => {
|
||||
const queueEntry = (status: string): Download => ({
|
||||
id: 'vid1',
|
||||
title: 'Test',
|
||||
url: 'https://example.com/v',
|
||||
download_type: 'video',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
custom_name_prefix: '',
|
||||
playlist_item_limit: 0,
|
||||
status,
|
||||
msg: '',
|
||||
percent: 100,
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
filename: '',
|
||||
checked: false,
|
||||
} as Download);
|
||||
|
||||
it('runs the bar indeterminate while preparing or post-processing', () => {
|
||||
const app = TestBed.createComponent(App).componentInstance;
|
||||
expect(app.isIndeterminate(queueEntry('preparing'))).toBe(true);
|
||||
expect(app.isIndeterminate(queueEntry('postprocessing'))).toBe(true);
|
||||
expect(app.isIndeterminate(queueEntry('downloading'))).toBe(false);
|
||||
expect(app.isIndeterminate(queueEntry('pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('labels the bar and counts the item as active', () => {
|
||||
// The component subscribes to queueChanged on construction, so the entry
|
||||
// has to be announced after it exists or updateMetrics never runs.
|
||||
const fixture = TestBed.createComponent(App);
|
||||
downloads.queue.set('https://example.com/v', queueEntry('postprocessing'));
|
||||
downloads.queueChanged.next();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Post-processing');
|
||||
expect(fixture.componentInstance.activeDownloads).toBe(1);
|
||||
expect(fixture.componentInstance.queuedDownloads).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+45
-3
@@ -86,6 +86,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
autoStart: boolean;
|
||||
playlistItemLimit!: number;
|
||||
splitByChapters: boolean;
|
||||
sponsorblock: boolean;
|
||||
chapterTemplate: string;
|
||||
clipStart = '';
|
||||
clipEnd = '';
|
||||
@@ -135,6 +136,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
metubeVersion: string | null = null;
|
||||
isAdvancedOpen = false;
|
||||
sortAscending = false;
|
||||
downloadingCollapsed = false;
|
||||
completedCollapsed = false;
|
||||
subscriptionsCollapsed = false;
|
||||
expandedErrors: Set<string> = new Set<string>();
|
||||
cachedSortedDone: [string, Download][] = [];
|
||||
// The done ids in rendered order, so a shift-click range follows the sort
|
||||
@@ -259,6 +263,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.quality = this.cookieService.get('metube_quality') || 'best';
|
||||
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
|
||||
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||
this.sponsorblock = this.cookieService.get('metube_sponsorblock') === 'true';
|
||||
// Will be set from backend configuration, use empty string as placeholder
|
||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||
this.clipStart = this.cookieService.get('metube_clip_start') || '';
|
||||
@@ -287,6 +292,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.previousDownloadType = this.downloadType;
|
||||
this.saveSelection(this.downloadType);
|
||||
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
|
||||
this.downloadingCollapsed = this.cookieService.get('metube_downloading_collapsed') === 'true';
|
||||
this.completedCollapsed = this.cookieService.get('metube_completed_collapsed') === 'true';
|
||||
this.subscriptionsCollapsed = this.cookieService.get('metube_subscriptions_collapsed') === 'true';
|
||||
|
||||
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
|
||||
if (!Number.isNaN(ci) && ci >= 1) {
|
||||
@@ -855,6 +863,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
sponsorblockChanged() {
|
||||
this.cookieService.set('metube_sponsorblock', this.sponsorblock ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
splitByChaptersChanged() {
|
||||
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
@@ -1111,6 +1123,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
|
||||
autoStart: overrides.autoStart ?? this.autoStart,
|
||||
splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
|
||||
sponsorblock: overrides.sponsorblock ?? this.sponsorblock,
|
||||
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
|
||||
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
|
||||
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
|
||||
@@ -1177,6 +1190,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.downloads.startById([id]).subscribe((res) => this.handleActionResult(res, 'Start download failed'));
|
||||
}
|
||||
|
||||
// 'preparing' (yt-dlp starting up) and 'postprocessing' (ffmpeg merging,
|
||||
// re-encoding or splitting once the bytes have landed) both have real work in
|
||||
// flight with no percentage to report, so the bar runs animated at full width
|
||||
// instead of showing a number that cannot move.
|
||||
isIndeterminate(download: Download): boolean {
|
||||
return download.status === 'preparing' || download.status === 'postprocessing';
|
||||
}
|
||||
|
||||
liveCountdownSeconds(download: Download): number | null {
|
||||
const ts = download.live_release_timestamp;
|
||||
if (ts == null || download.status !== 'scheduled') {
|
||||
@@ -1277,7 +1298,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
|
||||
buildDownloadLink(download: Download) {
|
||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
||||
if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) {
|
||||
// Must match the server's directory rule exactly: ytdl.py writes to
|
||||
// AUDIO_DOWNLOAD_DIR on download_type alone. Treating any .mp3 as audio
|
||||
// sent the link to audio_download/ for mp3s produced under a video-type
|
||||
// download (a postprocessor, a preset, or a legacy record), which the
|
||||
// server had written to DOWNLOAD_DIR -- a 404 whenever the two differ.
|
||||
if (download.download_type === 'audio') {
|
||||
baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"];
|
||||
}
|
||||
|
||||
@@ -1375,7 +1401,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
|
||||
buildChapterDownloadLink(download: Download, chapterFilename: string) {
|
||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
||||
if (download.download_type === 'audio' || chapterFilename.endsWith('.mp3')) {
|
||||
// Same server-side rule as buildDownloadLink above.
|
||||
if (download.download_type === 'audio') {
|
||||
baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"];
|
||||
}
|
||||
|
||||
@@ -1548,6 +1575,21 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.rebuildSortedDone();
|
||||
}
|
||||
|
||||
toggleDownloadingCollapsed() {
|
||||
this.downloadingCollapsed = !this.downloadingCollapsed;
|
||||
this.cookieService.set('metube_downloading_collapsed', this.downloadingCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
toggleCompletedCollapsed() {
|
||||
this.completedCollapsed = !this.completedCollapsed;
|
||||
this.cookieService.set('metube_completed_collapsed', this.completedCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
toggleSubscriptionsCollapsed() {
|
||||
this.subscriptionsCollapsed = !this.subscriptionsCollapsed;
|
||||
this.cookieService.set('metube_subscriptions_collapsed', this.subscriptionsCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
private rebuildSortedDone() {
|
||||
const result: [string, Download][] = [];
|
||||
this.downloads.done.forEach((dl, key) => {
|
||||
@@ -1684,7 +1726,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
if (download.status === 'downloading') {
|
||||
active++;
|
||||
speed += download.speed || 0;
|
||||
} else if (download.status === 'preparing') {
|
||||
} else if (download.status === 'preparing' || download.status === 'postprocessing') {
|
||||
active++;
|
||||
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
||||
queued++;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Download {
|
||||
custom_name_prefix: string;
|
||||
playlist_item_limit: number;
|
||||
split_by_chapters?: boolean;
|
||||
sponsorblock?: boolean;
|
||||
chapter_template?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
|
||||
@@ -36,6 +36,7 @@ function basePayload(): AddDownloadPayload {
|
||||
playlistItemLimit: 0,
|
||||
autoStart: true,
|
||||
splitByChapters: false,
|
||||
sponsorblock: false,
|
||||
chapterTemplate: '',
|
||||
subtitleLanguage: 'en',
|
||||
subtitleMode: 'prefer_manual',
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AddDownloadPayload {
|
||||
playlistItemLimit: number;
|
||||
autoStart: boolean;
|
||||
splitByChapters: boolean;
|
||||
sponsorblock: boolean;
|
||||
chapterTemplate: string;
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
@@ -148,6 +149,7 @@ export class DownloadsService {
|
||||
playlist_item_limit: payload.playlistItemLimit,
|
||||
auto_start: payload.autoStart,
|
||||
split_by_chapters: payload.splitByChapters,
|
||||
sponsorblock: payload.sponsorblock,
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Subject } from 'rxjs';
|
||||
import { SubscriptionsService, SubscribePayload } from './subscriptions.service';
|
||||
import { MeTubeSocket } from './metube-socket.service';
|
||||
|
||||
class MeTubeSocketStub {
|
||||
private subjects: Record<string, Subject<string>> = {};
|
||||
|
||||
fromEvent(event: string) {
|
||||
if (!this.subjects[event]) {
|
||||
this.subjects[event] = new Subject<string>();
|
||||
}
|
||||
return this.subjects[event].asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
function basePayload(): SubscribePayload {
|
||||
return {
|
||||
url: 'https://example.com/channel',
|
||||
downloadType: 'video',
|
||||
codec: 'auto',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
customNamePrefix: '',
|
||||
playlistItemLimit: 0,
|
||||
autoStart: true,
|
||||
splitByChapters: false,
|
||||
sponsorblock: false,
|
||||
chapterTemplate: '',
|
||||
subtitleLanguage: 'en',
|
||||
subtitleMode: 'prefer_manual',
|
||||
ytdlOptionsPresets: [],
|
||||
ytdlOptionsOverrides: '',
|
||||
clipStart: '',
|
||||
clipEnd: '',
|
||||
checkIntervalMinutes: 60,
|
||||
titleRegex: '',
|
||||
skipSubscriberOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubscriptionsService', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let service: SubscriptionsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SubscriptionsService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: MeTubeSocket, useValue: new MeTubeSocketStub() },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(SubscriptionsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
it('subscribe() carries the sponsorblock flag', () => {
|
||||
service.subscribe({ ...basePayload(), sponsorblock: true }).subscribe();
|
||||
const req = httpMock.expectOne('subscribe');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: true }));
|
||||
req.flush({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('subscribe() sends the flag off by default', () => {
|
||||
service.subscribe(basePayload()).subscribe();
|
||||
const req = httpMock.expectOne('subscribe');
|
||||
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: false }));
|
||||
req.flush({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ export class SubscriptionsService {
|
||||
playlist_item_limit: payload.playlistItemLimit,
|
||||
auto_start: payload.autoStart,
|
||||
split_by_chapters: payload.splitByChapters,
|
||||
sponsorblock: payload.sponsorblock,
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
|
||||
@@ -1236,11 +1236,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2026.7.4"
|
||||
version = "2026.8.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/e0/832fa4ca334b766a06933a196066edc3dba37cdb6f14cd98d59bcc69a4b4/yt_dlp-2026.8.19.tar.gz", hash = "sha256:9e213e48cea35c66b378e4447903f118f6392a5fa380a2b6d7070ec86f4e0af1", size = 3052025, upload-time = "2026-08-19T23:48:59.291Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/8cd1613f56eed7ceb64fbd4df3f1c01246bfb098e6f398228bafda22b80b/yt_dlp-2026.8.19-py3-none-any.whl", hash = "sha256:1d57897e94c6665a0a6f9bc54b34e584284e32c034ffab3a7df25d8f7b24eedf", size = 3185533, upload-time = "2026-08-19T23:48:56.925Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
Reference in New Issue
Block a user