feat: add generic browser extractor
This commit is contained in:
parent
9b3cc8b310
commit
928ddd5b9a
10 changed files with 659 additions and 184 deletions
112
FAQ.md
112
FAQ.md
|
|
@ -37,6 +37,7 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `(not_set)` |
|
||||
| YTP_BROWSER_URL | Remote browser endpoint for the bundled `browser` extractor | `(not_set)` |
|
||||
| YTP_FLARESOLVERR_URL | FlareSolverr endpoint URL. | `(not_set)` |
|
||||
| YTP_FLARESOLVERR_MAX_TIMEOUT | Max FlareSolverr challenge timeout in seconds | `120` |
|
||||
| YTP_FLARESOLVERR_CLIENT_TIMEOUT | HTTP client timeout (seconds) when calling FlareSolverr | `120` |
|
||||
|
|
@ -614,3 +615,114 @@ services:
|
|||
```
|
||||
|
||||
For more information please visit [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) project.
|
||||
|
||||
# How to use the browser extractor?
|
||||
|
||||
YTPTube ships with a bundled `browser` extractor plugin for `yt-dlp`. It opens the target page in a remote browser,
|
||||
waits for the page network activity, then tries to extract media urls from the captured requests and media elements.
|
||||
|
||||
This is mainly useful for sites where the regular extractor or generic page parsing does not expose the final media url,
|
||||
but a real browser session does.
|
||||
|
||||
## Environment variables
|
||||
|
||||
You can define the remote browser endpoint globally with the following environment variable.
|
||||
|
||||
### Selenium example
|
||||
|
||||
```env
|
||||
YTP_BROWSER_URL=selenium+http://selenium:4444/wd/hub
|
||||
```
|
||||
|
||||
### Playwright example
|
||||
|
||||
For a native Playwright server, point to the Playwright websocket endpoint:
|
||||
|
||||
```env
|
||||
YTP_BROWSER_URL=playwright+ws://playwright:3000/
|
||||
```
|
||||
|
||||
### Playwright CDP example
|
||||
|
||||
To connect Playwright over the Chrome DevTools Protocol, use either the shorthand `playwright+cdp://` form or an explicit transport form:
|
||||
|
||||
```env
|
||||
YTP_BROWSER_URL=playwright+cdp://chrome:9222/
|
||||
# same as playwright+cdp+http://chrome:9222/
|
||||
```
|
||||
|
||||
- `YTP_BROWSER_URL` is the full backend selector and endpoint in one value.
|
||||
- Supported forms are:
|
||||
- `selenium+http://...`
|
||||
- `selenium+https://...`
|
||||
- `playwright+ws://...`
|
||||
- `playwright+wss://...`
|
||||
- `playwright+cdp://...` which is treated as `playwright+cdp+http://...`
|
||||
- `playwright+cdp+http://...`
|
||||
- `playwright+cdp+https://...`
|
||||
- `playwright+cdp+ws://...`
|
||||
- `playwright+cdp+wss://...`
|
||||
|
||||
## yt-dlp usage
|
||||
|
||||
To enable the extractor for a preset or task, add the following to the `Command options for yt-dlp` field:
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default"
|
||||
```
|
||||
|
||||
If you want to set the browser extractor options directly on the yt-dlp side, you can also use `--extractor-args` with `browser:url=...`:
|
||||
|
||||
### Selenium example
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default" --extractor-args "browser:url=selenium+http://selenium:4444/wd/hub"
|
||||
```
|
||||
|
||||
### Playwright example
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default" --extractor-args "browser:url=playwright+ws://playwright:3000/"
|
||||
```
|
||||
|
||||
### Playwright CDP example
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default" --extractor-args "browser:url=playwright+cdp://chrome:9222/"
|
||||
```
|
||||
|
||||
The explicit `--extractor-args` value takes priority over `YTP_BROWSER_URL`.
|
||||
|
||||
## Example compose setup
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}"
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- YTP_BROWSER_URL=selenium+http://selenium:4444/wd/hub # or playwright+ws://playwright:3000/ or playwright+cdp://chrome:9222/
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/config:rw
|
||||
- ./downloads:/downloads:rw
|
||||
|
||||
selenium:
|
||||
image: selenium/standalone-chrome:latest
|
||||
container_name: selenium
|
||||
restart: unless-stopped
|
||||
shm_size: 2gb
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.59.0-noble
|
||||
container_name: playwright
|
||||
restart: unless-stopped
|
||||
command: /bin/sh -c "npx -y playwright@1.59.0 run-server --port 3000 --host 0.0.0.0"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The browser extractor is slower than the normal extractor flow and should only be used when a site actually needs a real browser session.
|
||||
> playwright require same version for both the server and the client, so make sure to use the same version in the container and in your local environment if you want to test it locally.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ Example of the Simple mode interface.
|
|||
* Conditions feature to apply custom options based on `yt-dlp` returned info.
|
||||
* Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance.
|
||||
* A bundled executable version for Windows, macOS and Linux. `MacOS version is untested`.
|
||||
* Use playwright or selenium for extractors that require a browser. see [related FAQ](FAQ.md#how-to-use-the-browser-extractor).
|
||||
|
||||
Please read the [FAQ](FAQ.md) for more information.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,21 @@ from app.library.Singleton import Singleton
|
|||
LOG: logging.Logger = logging.getLogger("downloads.extractor")
|
||||
|
||||
|
||||
def _ytdlp_logger(target: logging.Logger):
|
||||
def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||
if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "):
|
||||
target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
|
||||
return
|
||||
|
||||
if level <= logging.DEBUG:
|
||||
target.info(msg, *args, **kwargs)
|
||||
return
|
||||
|
||||
target.log(level, msg, *args, **kwargs)
|
||||
|
||||
return _log
|
||||
|
||||
|
||||
def _get_process_pool_kwargs() -> dict[str, Any]:
|
||||
"""Use a fork-based pool for frozen Linux builds."""
|
||||
if sys.platform == "linux" and getattr(sys, "frozen", False):
|
||||
|
|
@ -254,64 +269,69 @@ def extract_info_sync(
|
|||
log_wrapper = LogWrapper()
|
||||
id_dict: dict[str, str | None] = get_archive_id(url=url)
|
||||
archive_id: str | None = f".{id_dict['id']}" if id_dict.get("id") else None
|
||||
|
||||
log_wrapper.add_target(
|
||||
target=logging.getLogger(f"yt-dlp{archive_id or '.extract_info'}"),
|
||||
level=logging.DEBUG if debug else logging.WARNING,
|
||||
)
|
||||
|
||||
captured_logs: list[str] = kwargs.get("captured_logs", [])
|
||||
if capture_logs is not None:
|
||||
log_wrapper.add_target(
|
||||
target=lambda _, msg: captured_logs.append(msg),
|
||||
level=capture_logs,
|
||||
name="log-capture",
|
||||
)
|
||||
|
||||
if log_wrapper.has_targets():
|
||||
if "logger" in params:
|
||||
log_wrapper.add_target(target=params["logger"], level=logging.DEBUG)
|
||||
|
||||
params["logger"] = log_wrapper
|
||||
|
||||
if kwargs.get("no_log", False):
|
||||
params["logger"] = LogWrapper()
|
||||
params["quiet"] = True
|
||||
params["no_warnings"] = True
|
||||
|
||||
if no_archive and "download_archive" in params:
|
||||
del params["download_archive"]
|
||||
|
||||
data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False)
|
||||
|
||||
if data and follow_redirect and "_type" in data and "url" == data["_type"]:
|
||||
return extract_info_sync(
|
||||
config,
|
||||
data["url"],
|
||||
debug=debug,
|
||||
no_archive=no_archive,
|
||||
follow_redirect=follow_redirect,
|
||||
sanitize_info=sanitize_info,
|
||||
capture_logs=capture_logs,
|
||||
captured_logs=captured_logs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return (data, captured_logs)
|
||||
logger_name: str = f"yt-dlp{archive_id or '.extract_info'}"
|
||||
|
||||
try:
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
log_wrapper.add_target(
|
||||
target=_ytdlp_logger(logging.getLogger(logger_name)),
|
||||
level=logging.DEBUG,
|
||||
name=logger_name,
|
||||
)
|
||||
|
||||
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
|
||||
if not data["is_premiere"]:
|
||||
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
|
||||
except ImportError:
|
||||
pass
|
||||
captured_logs: list[str] = kwargs.get("captured_logs", [])
|
||||
if capture_logs is not None:
|
||||
log_wrapper.add_target(
|
||||
target=lambda _, msg: captured_logs.append(msg),
|
||||
level=capture_logs,
|
||||
name="log-capture",
|
||||
)
|
||||
|
||||
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
|
||||
if log_wrapper.has_targets():
|
||||
if "logger" in params:
|
||||
log_wrapper.add_target(target=params["logger"], level=logging.DEBUG)
|
||||
|
||||
return (result, captured_logs)
|
||||
params["logger"] = log_wrapper
|
||||
|
||||
if kwargs.get("no_log", False):
|
||||
params["logger"] = LogWrapper()
|
||||
params["quiet"] = True
|
||||
params["no_warnings"] = True
|
||||
|
||||
if no_archive and "download_archive" in params:
|
||||
del params["download_archive"]
|
||||
|
||||
data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False)
|
||||
|
||||
if data and follow_redirect and "_type" in data and "url" == data["_type"]:
|
||||
return extract_info_sync(
|
||||
config,
|
||||
data["url"],
|
||||
debug=debug,
|
||||
no_archive=no_archive,
|
||||
follow_redirect=follow_redirect,
|
||||
sanitize_info=sanitize_info,
|
||||
capture_logs=capture_logs,
|
||||
captured_logs=captured_logs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return (data, captured_logs)
|
||||
|
||||
try:
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
|
||||
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
|
||||
if not data["is_premiere"]:
|
||||
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
|
||||
|
||||
return (result, captured_logs)
|
||||
finally:
|
||||
logging.Logger.manager.loggerDict.pop(logger_name, None)
|
||||
|
||||
|
||||
async def fetch_info(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.features.ytdlp.extractor import ExtractorConfig, ExtractorPool, _get_process_pool_kwargs, extract_info_sync
|
||||
from app.features.ytdlp.extractor import (
|
||||
ExtractorConfig,
|
||||
ExtractorPool,
|
||||
_get_process_pool_kwargs,
|
||||
_ytdlp_logger,
|
||||
extract_info_sync,
|
||||
)
|
||||
|
||||
|
||||
class TestProcessPoolConfiguration:
|
||||
|
|
@ -76,3 +83,90 @@ class TestExtractInfo:
|
|||
(result, logs) = extract_info_sync(config, url, debug=True)
|
||||
assert isinstance(result, dict), "Result should be a dictionary"
|
||||
assert isinstance(logs, list), "Logs should be a list"
|
||||
|
||||
@patch("app.features.ytdlp.extractor.YTDLP")
|
||||
def test_extract_info_mirrors_debug_to_console(self, mock_ytdlp_class):
|
||||
seen: list[tuple[int, str]] = []
|
||||
|
||||
def fake_extract_info(url, download=False): # noqa: ARG001
|
||||
logger = mock_ytdlp_class.call_args.kwargs["params"]["logger"]
|
||||
logger.debug("[generic_browser] Using remote browser for https://example.com/video")
|
||||
logger.debug("[debug] [generic_browser] Loading page https://example.com/video")
|
||||
logger.warning("[generic_browser] Browser fallback warning")
|
||||
return {"title": "Test Video", "id": "test123"}
|
||||
|
||||
mock_ytdlp = MagicMock()
|
||||
mock_ytdlp.extract_info.side_effect = fake_extract_info
|
||||
mock_ytdlp_class.return_value = mock_ytdlp
|
||||
|
||||
logger = logging.getLogger("yt-dlp.extract_info")
|
||||
with patch.object(
|
||||
logger, "info", side_effect=lambda msg, *a, **k: seen.append((logging.INFO, msg % a if a else msg))
|
||||
):
|
||||
with patch.object(
|
||||
logger,
|
||||
"debug",
|
||||
side_effect=lambda msg, *a, **k: seen.append((logging.DEBUG, msg % a if a else msg)),
|
||||
):
|
||||
with patch.object(
|
||||
logger,
|
||||
"log",
|
||||
side_effect=lambda level, msg, *a, **k: seen.append((level, msg % a if a else msg)),
|
||||
):
|
||||
(result, logs) = extract_info_sync(
|
||||
{}, "https://example.com/video", debug=True, capture_logs=logging.WARNING
|
||||
)
|
||||
|
||||
assert result["id"] == "test123"
|
||||
assert logs == ["[generic_browser] Browser fallback warning"]
|
||||
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
|
||||
assert (logging.DEBUG, "[generic_browser] Loading page https://example.com/video") in seen
|
||||
assert (logging.WARNING, "[generic_browser] Browser fallback warning") in seen
|
||||
|
||||
@patch("app.features.ytdlp.extractor.YTDLP")
|
||||
def test_extract_info_mirrors_screen_logs_without_debug(self, mock_ytdlp_class):
|
||||
seen: list[tuple[int, str]] = []
|
||||
|
||||
def fake_extract_info(url, download=False): # noqa: ARG001
|
||||
logger = mock_ytdlp_class.call_args.kwargs["params"]["logger"]
|
||||
logger.debug("[generic_browser] Using remote browser for https://example.com/video")
|
||||
logger.warning("[generic_browser] Browser fallback warning")
|
||||
return {"title": "Test Video", "id": "test123"}
|
||||
|
||||
mock_ytdlp = MagicMock()
|
||||
mock_ytdlp.extract_info.side_effect = fake_extract_info
|
||||
mock_ytdlp_class.return_value = mock_ytdlp
|
||||
|
||||
logger = logging.getLogger("yt-dlp.extract_info")
|
||||
with patch.object(
|
||||
logger, "info", side_effect=lambda msg, *a, **k: seen.append((logging.INFO, msg % a if a else msg))
|
||||
):
|
||||
with patch.object(
|
||||
logger,
|
||||
"log",
|
||||
side_effect=lambda level, msg, *a, **k: seen.append((level, msg % a if a else msg)),
|
||||
):
|
||||
(result, logs) = extract_info_sync(
|
||||
{}, "https://example.com/video", debug=False, capture_logs=logging.WARNING
|
||||
)
|
||||
|
||||
assert result["id"] == "test123"
|
||||
assert logs == ["[generic_browser] Browser fallback warning"]
|
||||
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
|
||||
assert (logging.WARNING, "[generic_browser] Browser fallback warning") in seen
|
||||
|
||||
|
||||
class TestYtdlpLogger:
|
||||
def test_debug_prefix_uses_debug(self) -> None:
|
||||
logger = MagicMock()
|
||||
|
||||
_ytdlp_logger(logger)(logging.DEBUG, "[debug] hello")
|
||||
|
||||
logger.debug.assert_called_once_with("hello")
|
||||
|
||||
def test_screen_style_debug_uses_info(self) -> None:
|
||||
logger = MagicMock()
|
||||
|
||||
_ytdlp_logger(logger)(logging.DEBUG, "screen line")
|
||||
|
||||
logger.info.assert_called_once_with("screen line")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import importlib
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from yt_dlp.globals import extractors as ytdlp_extractors
|
||||
|
||||
from app.features.ytdlp.outtmpl import rewrite_outtmpl
|
||||
from app.features.ytdlp.patches import patch_windows_popen_wait
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# flake8: noqa: F401, RUF100, W291, I001
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import yt_dlp
|
||||
from yt_dlp.globals import extractors as ytdlp_extractors
|
||||
from yt_dlp.utils import make_archive_id
|
||||
|
||||
from app.features.ytdlp.outtmpl import rewrite_outtmpl
|
||||
|
|
@ -56,6 +58,17 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
def __init__(self, params=None, auto_init=True):
|
||||
apply_ytdlp_patches()
|
||||
|
||||
if not YTDLP._registered:
|
||||
try:
|
||||
from app.yt_dlp_plugins.extractor import generic_browser
|
||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
||||
from yt_dlp.postprocessor import postprocessors
|
||||
|
||||
postprocessors.value.update({"NFOMakerPP": NFOMakerPP})
|
||||
YTDLP._registered = True
|
||||
except Exception:
|
||||
logging.getLogger("ytdlp.wrapper").exception("Failed to register yt-dlp plugins")
|
||||
|
||||
# Avoid yt-dlp preloading the archive file by stripping the param first
|
||||
orig_file = None
|
||||
patched_params: dict[str, Any] | None = None
|
||||
|
|
@ -80,15 +93,6 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
self._ytptube_outtmpl_info: dict[str, Any] | None = None
|
||||
self._ytptube_outtmpl_cache: dict[str, Any] = {}
|
||||
self.archive = _ArchiveProxy(orig_file)
|
||||
if not YTDLP._registered:
|
||||
try:
|
||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
||||
from yt_dlp.postprocessor import postprocessors
|
||||
|
||||
postprocessors.value.update({"NFOMakerPP": NFOMakerPP})
|
||||
YTDLP._registered = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _delete_downloaded_files(self, *args, **kwargs) -> None:
|
||||
if self._interrupted:
|
||||
|
|
|
|||
|
|
@ -165,3 +165,62 @@ class TestConditionIgnorePropagation:
|
|||
assert captured["extras"]["playlist"] == "Playlist"
|
||||
assert "source_name" not in captured["extras"]
|
||||
queue.add.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playlist_keeps_child_urls(self) -> None:
|
||||
class FakeItem:
|
||||
def __init__(self) -> None:
|
||||
self.extras = {}
|
||||
|
||||
def get_ytdlp_opts(self):
|
||||
return Mock(get_all=Mock(return_value={}))
|
||||
|
||||
def new_with(self, **kwargs):
|
||||
return SimpleNamespace(extras=kwargs["extras"], url=kwargs["url"])
|
||||
|
||||
queue = Mock(add=AsyncMock(return_value={"status": "ok"}))
|
||||
entry = {
|
||||
"_type": "playlist",
|
||||
"id": "playlist-1",
|
||||
"title": "Playlist",
|
||||
"webpage_url": "https://example.com/page",
|
||||
"original_url": "https://example.com/page",
|
||||
"entries": [
|
||||
{
|
||||
"_type": "video",
|
||||
"id": "video-1",
|
||||
"title": "Video 1",
|
||||
"url": "https://cdn.example/1.mp3",
|
||||
"webpage_url": "https://cdn.example/1.mp3",
|
||||
"original_url": "https://cdn.example/1.mp3",
|
||||
"formats": [{"url": "https://cdn.example/1.mp3", "ext": "mp3"}],
|
||||
},
|
||||
{
|
||||
"_type": "video",
|
||||
"id": "video-2",
|
||||
"title": "Video 2",
|
||||
"url": "https://cdn.example/2.mp3",
|
||||
"webpage_url": "https://cdn.example/2.mp3",
|
||||
"original_url": "https://cdn.example/2.mp3",
|
||||
"formats": [{"url": "https://cdn.example/2.mp3", "ext": "mp3"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("app.library.downloads.playlist_processor.ytdlp_reject", return_value=(True, "")):
|
||||
result = await process_playlist(queue=queue, entry=entry, item=FakeItem())
|
||||
|
||||
assert result == {"status": "ok"}
|
||||
assert queue.add.await_count == 2
|
||||
assert [call.kwargs["item"].url for call in queue.add.await_args_list] == [
|
||||
"https://cdn.example/1.mp3",
|
||||
"https://cdn.example/2.mp3",
|
||||
]
|
||||
assert [call.kwargs["entry"]["webpage_url"] for call in queue.add.await_args_list] == [
|
||||
"https://cdn.example/1.mp3",
|
||||
"https://cdn.example/2.mp3",
|
||||
]
|
||||
assert [call.kwargs["entry"]["original_url"] for call in queue.add.await_args_list] == [
|
||||
"https://cdn.example/1.mp3",
|
||||
"https://cdn.example/2.mp3",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -327,94 +327,6 @@ class TestDownloadFlow:
|
|||
assert queue.items[0]["download_skipped"] is True
|
||||
assert queue.items[1]["download_skipped"] is True
|
||||
|
||||
def test_download_resets_sigint(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
max_workers = 1
|
||||
temp_keep = False
|
||||
temp_disabled = True
|
||||
download_info_expires = 3600
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return Cfg
|
||||
|
||||
monkeypatch.setattr("app.library.downloads.core.Config", Cfg)
|
||||
|
||||
item = make_item(id="live-id")
|
||||
item.is_live = True
|
||||
download = Download(
|
||||
info=item,
|
||||
info_dict={"id": "live-id", "url": "http://u", "formats": [{"format_id": "18"}]},
|
||||
)
|
||||
download._process_manager.cancel_event = Mock(wait=Mock())
|
||||
download.status_queue = cast(Any, DummyQueue())
|
||||
download._hook_handlers = Mock(
|
||||
progress_hook=Mock(),
|
||||
postprocessor_hook=Mock(),
|
||||
post_hook=Mock(),
|
||||
)
|
||||
download.info.get_ytdlp_opts = Mock(
|
||||
return_value=Mock(add=Mock(return_value=Mock(get_all=Mock(return_value={}))))
|
||||
)
|
||||
|
||||
created_ydl: list[Any] = []
|
||||
|
||||
class FakeYTDLP:
|
||||
def __init__(self, params):
|
||||
self.params = params
|
||||
self._download_retcode = 0
|
||||
self._interrupted = False
|
||||
self.to_screen = Mock()
|
||||
created_ydl.append(self)
|
||||
|
||||
def process_ie_result(self, ie_result, download):
|
||||
return ie_result, download
|
||||
|
||||
def download(self, url_list):
|
||||
return 0
|
||||
|
||||
signal_mock = Mock()
|
||||
thread_instances: list[Mock] = []
|
||||
|
||||
def build_thread(*_args, **_kwargs):
|
||||
thread = Mock(start=Mock())
|
||||
thread_instances.append(thread)
|
||||
return thread
|
||||
|
||||
thread_mock = Mock(side_effect=build_thread)
|
||||
monkeypatch.setattr("app.library.downloads.core.YTDLP", FakeYTDLP)
|
||||
monkeypatch.setattr("app.library.downloads.core.signal.signal", signal_mock)
|
||||
monkeypatch.setattr("app.library.downloads.core.threading.Thread", thread_mock)
|
||||
|
||||
download._download()
|
||||
|
||||
signal_mock.assert_any_call(signal.SIGINT, signal.default_int_handler)
|
||||
|
||||
live_cancel_thread = next(
|
||||
call for call in thread_mock.call_args_list if call.kwargs.get("name", "").startswith("cancel-watch-")
|
||||
)
|
||||
live_cancel_thread_index = thread_mock.call_args_list.index(live_cancel_thread)
|
||||
thread_instances[live_cancel_thread_index].start.assert_called_once()
|
||||
target = live_cancel_thread.kwargs["target"]
|
||||
ydl = created_ydl[0]
|
||||
|
||||
if "posix" == os.name:
|
||||
with (
|
||||
patch("app.library.downloads.core.os.getpid", return_value=12345),
|
||||
patch("app.library.downloads.core.os.kill") as mock_kill,
|
||||
):
|
||||
target()
|
||||
mock_kill.assert_called_once_with(12345, signal.SIGINT)
|
||||
else:
|
||||
with patch("app.library.downloads.core._thread.interrupt_main") as mock_interrupt_main:
|
||||
target()
|
||||
mock_interrupt_main.assert_called_once_with()
|
||||
|
||||
assert ydl._interrupted is True
|
||||
ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...")
|
||||
|
||||
def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
|
|
|
|||
213
app/tests/test_generic_browser.py
Normal file
213
app/tests/test_generic_browser.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.yt_dlp_plugins.extractor import generic_browser
|
||||
|
||||
|
||||
def _make_ie(config: dict[str, str | None] | None = None) -> generic_browser.GenericBrowserIE:
|
||||
ie = object.__new__(generic_browser.GenericBrowserIE)
|
||||
values = config or {}
|
||||
ie._configuration_arg = lambda name, default: [values.get(name)]
|
||||
ie._generic_id = lambda url: "vid"
|
||||
ie._generic_title = lambda url, webpage=None: "title"
|
||||
ie._get_timeout_ms = lambda: None
|
||||
ie.report_warning = Mock()
|
||||
ie.report_extraction = Mock()
|
||||
ie.playlist_result = Mock(side_effect=lambda entries, **kwargs: {"_type": "playlist", "entries": entries, **kwargs})
|
||||
ie._looks_like_html = lambda webpage: False
|
||||
ie._merge_requests = lambda network, media: list(network) + list(media)
|
||||
ie._extract_network_formats = Mock(
|
||||
return_value={"formats": [{"url": "https://cdn.example/video.mp4", "ext": "mp4"}]}
|
||||
)
|
||||
ie.to_screen = Mock()
|
||||
ie.write_debug = Mock()
|
||||
ie._downloader = Mock()
|
||||
ie._downloader.params = {}
|
||||
ie._failed = False
|
||||
return ie
|
||||
|
||||
|
||||
def test_cfg_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", " selenium+http://browser:4444/wd/hub ")
|
||||
|
||||
assert ie._get_config("url", "YTP_BROWSER_URL") == "selenium+http://browser:4444/wd/hub"
|
||||
|
||||
|
||||
def test_cfg_arg_wins(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie({"url": "selenium+http://arg:4444/wd/hub"})
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "selenium+http://env:4444/wd/hub")
|
||||
|
||||
assert ie._get_config("url", "YTP_BROWSER_URL") == "selenium+http://arg:4444/wd/hub"
|
||||
|
||||
|
||||
def test_real_extract_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "selenium+http://browser:4444/wd/hub")
|
||||
monkeypatch.setattr(generic_browser.SeleniumDriver, "is_available", staticmethod(lambda: False))
|
||||
|
||||
with pytest.raises(generic_browser.ExtractorError, match="No matching browser driver available"):
|
||||
ie._real_extract("https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_invalid_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "http://browser:4444/wd/hub")
|
||||
|
||||
with pytest.raises(generic_browser.ExtractorError, match="Invalid browser URL"):
|
||||
ie._real_extract("https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_logs_connect_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
ie.__wrapped__ = Mock()
|
||||
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "selenium+http://browser:4444/wd/hub")
|
||||
monkeypatch.setattr(generic_browser.SeleniumDriver, "is_available", staticmethod(lambda: True))
|
||||
|
||||
class BrokenDriver:
|
||||
__name__ = "BrokenDriver"
|
||||
|
||||
@staticmethod
|
||||
def connect(ws_url: str, timeout: int | None = None):
|
||||
raise RuntimeError("remote browser down")
|
||||
|
||||
monkeypatch.setattr(generic_browser.GenericBrowserIE, "_select_driver", lambda self, ws_url: BrokenDriver)
|
||||
|
||||
result = ie._real_extract("https://example.com/watch")
|
||||
|
||||
assert result == {"id": "fallback"}
|
||||
ie.report_warning.assert_called_once_with(
|
||||
"Remote browser unavailable: remote browser down, marking as failed.", "vid"
|
||||
)
|
||||
ie.to_screen.assert_called_once_with("Using remote browser for https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_logs_session_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser")
|
||||
|
||||
session = Mock()
|
||||
session.goto.side_effect = RuntimeError("page crashed")
|
||||
|
||||
class FakeDriver:
|
||||
__name__ = "FakeDriver"
|
||||
|
||||
@staticmethod
|
||||
def connect(ws_url: str, timeout: int | None = None):
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(generic_browser.GenericBrowserIE, "_select_driver", lambda self, ws_url: FakeDriver)
|
||||
|
||||
with pytest.raises(RuntimeError, match="page crashed"):
|
||||
ie._real_extract("https://example.com/watch")
|
||||
|
||||
assert session.close.call_count == 1
|
||||
ie.report_warning.assert_called_once_with(
|
||||
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
"https://example.com/watch",
|
||||
"playwright+ws://browser",
|
||||
"FakeDriver",
|
||||
session.goto.side_effect,
|
||||
)
|
||||
ie.write_debug.assert_any_call("Selected driver FakeDriver for playwright+ws://browser")
|
||||
ie.write_debug.assert_any_call("Loading page https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_logs_non_html_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser")
|
||||
|
||||
session = Mock()
|
||||
session.content.return_value = "plain text body"
|
||||
session.get_requests.return_value = [
|
||||
{"url": "https://cdn.example/video.mp4", "method": "GET", "resourceType": "video"}
|
||||
]
|
||||
session.get_media_requests.return_value = []
|
||||
|
||||
class FakeDriver:
|
||||
__name__ = "FakeDriver"
|
||||
|
||||
@staticmethod
|
||||
def connect(ws_url: str, timeout: int | None = None):
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(generic_browser.GenericBrowserIE, "_select_driver", lambda self, ws_url: FakeDriver)
|
||||
|
||||
result = ie._real_extract("https://example.com/watch")
|
||||
|
||||
assert result["formats"][0]["url"] == "https://cdn.example/video.mp4"
|
||||
ie.write_debug.assert_any_call("Page content did not look like HTML for https://example.com/watch")
|
||||
ie.write_debug.assert_any_call("plain text body")
|
||||
|
||||
|
||||
def test_extract_network_formats_logs_no_media() -> None:
|
||||
ie = _make_ie()
|
||||
ie.__wrapped__ = Mock()
|
||||
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
||||
ie._url = "https://example.com/watch"
|
||||
ie._extract_network_formats = generic_browser.GenericBrowserIE._extract_network_formats.__get__(
|
||||
ie, generic_browser.GenericBrowserIE
|
||||
)
|
||||
|
||||
result = ie._extract_network_formats([], "vid", {"title": "title"})
|
||||
|
||||
assert result == {"id": "fallback"}
|
||||
ie.write_debug.assert_called_with("No media formats found in 0 browser request(s)")
|
||||
ie.report_warning.assert_called_once_with(
|
||||
"Generic browser extractor found no media formats. falling back to generic extractor.",
|
||||
"vid",
|
||||
)
|
||||
|
||||
|
||||
def test_extract_network_formats_playlist_entries_keep_own_urls() -> None:
|
||||
ie = _make_ie()
|
||||
ie._extract_network_formats = generic_browser.GenericBrowserIE._extract_network_formats.__get__(
|
||||
ie, generic_browser.GenericBrowserIE
|
||||
)
|
||||
|
||||
result = ie._extract_network_formats(
|
||||
[
|
||||
{"url": "https://cdn.example/1.mp3", "method": "GET", "resourceType": "audio"},
|
||||
{"url": "https://cdn.example/2.mp3", "method": "GET", "resourceType": "audio"},
|
||||
],
|
||||
"vid",
|
||||
{"title": "Title", "webpage_url": "https://example.com/page", "original_url": "https://example.com/page"},
|
||||
)
|
||||
|
||||
assert result["_type"] == "playlist"
|
||||
assert result["entries"][0]["url"] == "https://cdn.example/1.mp3"
|
||||
assert result["entries"][0]["webpage_url"] == "https://cdn.example/1.mp3"
|
||||
assert result["entries"][0]["original_url"] == "https://cdn.example/1.mp3"
|
||||
assert result["entries"][0]["_old_archive_ids"] == [generic_browser.make_archive_id("generic", "vid-1")]
|
||||
assert result["entries"][0]["direct"] is True
|
||||
assert result["entries"][1]["url"] == "https://cdn.example/2.mp3"
|
||||
assert result["entries"][1]["webpage_url"] == "https://cdn.example/2.mp3"
|
||||
assert result["entries"][1]["original_url"] == "https://cdn.example/2.mp3"
|
||||
assert result["entries"][1]["_old_archive_ids"] == [generic_browser.make_archive_id("generic", "vid-2")]
|
||||
assert result["entries"][1]["direct"] is True
|
||||
|
||||
|
||||
def test_select_driver_selenium(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setattr(generic_browser.SeleniumDriver, "is_available", staticmethod(lambda: True))
|
||||
|
||||
assert ie._select_driver("selenium+http://browser:4444/wd/hub") is generic_browser.SeleniumDriver
|
||||
|
||||
|
||||
def test_select_driver_playwright_ws(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setattr(generic_browser.PlaywrightDriver, "is_available", staticmethod(lambda: True))
|
||||
|
||||
assert ie._select_driver("playwright+ws://playwright:3000/") is generic_browser.PlaywrightDriver
|
||||
|
||||
|
||||
def test_select_driver_playwright_cdp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setattr(generic_browser.PlaywrightDriver, "is_available", staticmethod(lambda: True))
|
||||
|
||||
assert ie._select_driver("playwright+cdp://chrome:9222/") is generic_browser.PlaywrightDriver
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
from yt_dlp.extractor.generic import GenericIE
|
||||
from yt_dlp.utils import ExtractorError, determine_ext, mimetype2ext, traverse_obj
|
||||
from yt_dlp.utils import ExtractorError, determine_ext, make_archive_id, mimetype2ext, traverse_obj
|
||||
from yt_dlp.utils._utils import _request_dump_filename
|
||||
|
||||
MEDIA_EXTENSIONS: set[str] = {
|
||||
|
|
@ -97,6 +98,16 @@ _MEDIA_ELEMENT_JS = """() => {
|
|||
return mediaUrls;
|
||||
}"""
|
||||
|
||||
_PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
||||
"playwright+ws://",
|
||||
"playwright+wss://",
|
||||
"playwright+cdp://",
|
||||
"playwright+cdp+http://",
|
||||
"playwright+cdp+https://",
|
||||
"playwright+cdp+ws://",
|
||||
"playwright+cdp+wss://",
|
||||
)
|
||||
|
||||
|
||||
def _has_possible_media(requests_list: list[dict]) -> bool:
|
||||
for req in requests_list:
|
||||
|
|
@ -186,10 +197,24 @@ class PlaywrightDriver:
|
|||
|
||||
p = sync_playwright().start()
|
||||
|
||||
if ws_url.startswith("pw+"):
|
||||
browser = p.chromium.connect(ws_url[3:], timeout=timeout or 30000)
|
||||
if ws_url.startswith("playwright+ws://"):
|
||||
browser_url = f"ws://{ws_url.removeprefix('playwright+ws://')}"
|
||||
browser = p.chromium.connect(browser_url, timeout=timeout or 30000)
|
||||
elif ws_url.startswith("playwright+wss://"):
|
||||
browser_url = f"wss://{ws_url.removeprefix('playwright+wss://')}"
|
||||
browser = p.chromium.connect(browser_url, timeout=timeout or 30000)
|
||||
elif ws_url.startswith("playwright+cdp://"):
|
||||
browser_url = f"http://{ws_url.removeprefix('playwright+cdp://')}"
|
||||
browser = p.chromium.connect_over_cdp(browser_url, timeout=timeout or 30000)
|
||||
elif ws_url.startswith("playwright+cdp+"):
|
||||
browser_url = ws_url.removeprefix("playwright+cdp+")
|
||||
browser = p.chromium.connect_over_cdp(browser_url, timeout=timeout or 30000)
|
||||
else:
|
||||
browser = p.chromium.connect_over_cdp(ws_url, timeout=timeout or 30000)
|
||||
msg = (
|
||||
"Invalid Playwright browser URL. Use playwright+ws://, playwright+wss://, "
|
||||
"playwright+cdp://, or playwright+cdp+http(s|ws|wss)://"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
|
|
@ -401,35 +426,43 @@ class SeleniumDriver:
|
|||
return Session()
|
||||
|
||||
|
||||
DRIVERS: list[type] = [PlaywrightDriver, SeleniumDriver]
|
||||
|
||||
|
||||
class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||
_WORKING = True
|
||||
_failed: bool = False
|
||||
_remote_browser_failures: dict[str, str] = {}
|
||||
_url: str = ""
|
||||
|
||||
def _get_config(self, name: str, env_name: str) -> str | None:
|
||||
value = self._configuration_arg(name, [None])[0]
|
||||
if value is None:
|
||||
value = os.environ.get(env_name)
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip() or None
|
||||
|
||||
return value
|
||||
|
||||
def _real_extract(self, url: str) -> dict[str, Any]:
|
||||
self._url = url
|
||||
preferred_driver: str | None = self._configuration_arg("driver", [None])[0]
|
||||
|
||||
if not (remote_browser := self._configuration_arg("remote_browser", [None])[0]) or self._failed:
|
||||
if not (browser_url := self._get_config("url", "YTP_BROWSER_URL")) or self._failed:
|
||||
return self.__wrapped__._real_extract(self, url)
|
||||
|
||||
video_id: str = self._generic_id(url)
|
||||
timeout: int | None = self._get_timeout_ms()
|
||||
self.to_screen(f"Using remote browser for {url}")
|
||||
|
||||
if not (driver := self._select_driver(preferred_driver, remote_browser)):
|
||||
available: list[str] = [d.__name__ for d in DRIVERS if d.isAvailable()]
|
||||
if not (driver := self._select_driver(browser_url)):
|
||||
msg: str = (
|
||||
f"No browser driver available. Install playwright or selenium. "
|
||||
f"Available drivers: {available if available else 'none'}"
|
||||
"No matching browser driver available for the configured browser URL. "
|
||||
"Install playwright or selenium as needed."
|
||||
)
|
||||
raise ExtractorError(msg)
|
||||
|
||||
self.write_debug(f"Selected driver {driver.__name__} for {browser_url}")
|
||||
|
||||
try:
|
||||
session = driver.connect(remote_browser, timeout)
|
||||
session = driver.connect(browser_url, timeout)
|
||||
except Exception as e:
|
||||
message = str(e)
|
||||
self._failed = True
|
||||
|
|
@ -438,6 +471,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
|
||||
try:
|
||||
self.report_extraction(url)
|
||||
self.write_debug(f"Loading page {url}")
|
||||
session.goto(url)
|
||||
|
||||
session.wait_for_network_idle(
|
||||
|
|
@ -448,6 +482,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
|
||||
webpage = session.content()
|
||||
requests = self._merge_requests(session.get_requests(), session.get_media_requests())
|
||||
self.write_debug(f"Captured {len(requests)} network requests")
|
||||
|
||||
if self._downloader.params.get("dump_intermediate_pages"):
|
||||
self.to_screen(f"Browser content dump for: {url}")
|
||||
|
|
@ -483,35 +518,51 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
thumbnail = self._og_search_thumbnail(webpage, default=None)
|
||||
if thumbnail:
|
||||
info_dict["thumbnail"] = thumbnail
|
||||
elif webpage:
|
||||
self.write_debug(f"Page content did not look like HTML for {url}")
|
||||
self.write_debug(webpage)
|
||||
|
||||
network_info = self._extract_network_formats(requests, video_id, info_dict)
|
||||
if network_info:
|
||||
self.write_debug(f"Resolved media from browser requests for {url}")
|
||||
if network_info.get("_type") == "playlist" and network_info.get("entries"):
|
||||
return self.playlist_result(network_info["entries"], **info_dict)
|
||||
info_dict.update(network_info)
|
||||
|
||||
return info_dict
|
||||
except Exception as e:
|
||||
self.report_warning(
|
||||
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
url,
|
||||
browser_url,
|
||||
driver.__name__,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
try:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
self.report_warning(
|
||||
"Browser session close failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
url,
|
||||
browser_url,
|
||||
driver.__name__,
|
||||
e,
|
||||
)
|
||||
|
||||
def _select_driver(self, preferred: str | None, ws_url: str):
|
||||
if preferred:
|
||||
for driver in DRIVERS:
|
||||
if driver.__name__.lower().startswith(preferred.lower()):
|
||||
if driver.isAvailable():
|
||||
return driver
|
||||
def _select_driver(self, ws_url: str):
|
||||
if ws_url.startswith(("selenium+http://", "selenium+https://")):
|
||||
return SeleniumDriver if SeleniumDriver.is_available() else None
|
||||
|
||||
self.report_warning(f"Preferred driver {driver.__name__} not available")
|
||||
return None
|
||||
if ws_url.startswith(_PLAYWRIGHT_PREFIXES):
|
||||
return PlaywrightDriver if PlaywrightDriver.is_available() else None
|
||||
|
||||
if ws_url.startswith("pw+") and PlaywrightDriver.is_available():
|
||||
return PlaywrightDriver
|
||||
|
||||
for driver in DRIVERS:
|
||||
if driver.isAvailable():
|
||||
return driver
|
||||
|
||||
return None
|
||||
msg = (
|
||||
"Invalid browser URL. Use selenium+http(s)://, playwright+ws(s)://, "
|
||||
"playwright+cdp://, or playwright+cdp+http(s|ws|wss)://"
|
||||
)
|
||||
raise ExtractorError(msg)
|
||||
|
||||
def _extract_network_formats(
|
||||
self, requests: list[dict], video_id: str, base_info: dict[str, Any]
|
||||
|
|
@ -588,6 +639,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
source_counts["direct"] = source_counts.get("direct", 0) + 1
|
||||
|
||||
if not formats:
|
||||
self.write_debug(f"No media formats found in {len(requests)} browser request(s)")
|
||||
self.report_warning(
|
||||
"Generic browser extractor found no media formats. falling back to generic extractor.", video_id
|
||||
)
|
||||
|
|
@ -597,14 +649,20 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
base_title = (base_info.get("title") or "").strip() or video_id
|
||||
entries = []
|
||||
for index, fmt in enumerate(direct_formats, start=1):
|
||||
entry_url = fmt.get("url")
|
||||
entry_id = f"{video_id}-{index}"
|
||||
entries.append(
|
||||
{
|
||||
"id": f"{video_id}-{index}",
|
||||
"id": entry_id,
|
||||
"title": f"{base_title} ({index})",
|
||||
"_old_archive_ids": [make_archive_id("generic", entry_id)],
|
||||
"formats": [fmt],
|
||||
"url": fmt.get("url"),
|
||||
"url": entry_url,
|
||||
"webpage_url": entry_url,
|
||||
"original_url": entry_url,
|
||||
"ext": fmt.get("ext"),
|
||||
"protocol": fmt.get("protocol"),
|
||||
"direct": True,
|
||||
}
|
||||
)
|
||||
return {"_type": "playlist", "entries": entries}
|
||||
|
|
|
|||
Loading…
Reference in a new issue