diff --git a/.vscode/settings.json b/.vscode/settings.json index 3113d396..5c75efb6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -141,6 +141,7 @@ "sstr", "startswith", "SUPPRESSHELP", + "tablehead", "tebibytes", "testpaths", "threadsafe", diff --git a/FAQ.md b/FAQ.md index 16d6dcdc..e3dbcef2 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,9 +1,3 @@ -# The origin of the project. - -The project first started as a fork [meTube](https://github.com/alexta69/metube), since then it has been completely -rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the -project and what i wanted to achieve with it. - # Environment variables Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, @@ -17,7 +11,8 @@ or the `environment:` section in `compose.yaml` file. | YTP_INSTANCE_TITLE | The title of the instance | `empty string` | | YTP_FILE_LOGGING | Whether to log to file | `false` | | YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` | -| YTP_MAX_WORKERS | How many works to use for downloads | `1` | +| YTP_MAX_WORKERS | The maximum number of workers to use for downloading | `20` | +| YTP_MAX_WORKERS_PER_EXTRACTOR | The maximum number of concurrent downloads per extractor | `2` | | YTP_AUTH_USERNAME | Username for basic authentication | `empty string` | | YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` | | YTP_CONSOLE_ENABLED | Whether to enable the console | `false` | @@ -50,6 +45,12 @@ or the `environment:` section in `compose.yaml` file. | YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` | +> [!NOTE] +> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_`. +> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download. +> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored. + + # Browser extensions & bookmarklets ## Simple bookmarklet @@ -317,6 +318,12 @@ If you prefer, you can bypass YTPTube `download_path` and set it to `/` and comp please be aware that the file browser feature will expose whatever `download_path` is set to. **So, if you set it to `/`, the file browser will expose the entire container filesystem.** +# The origin of the project. + +The project first started as a fork [meTube](https://github.com/alexta69/metube), since then it has been completely +rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the +project and what i wanted to achieve with it. + # How to use hardware acceleration for video transcoding? As the container is rootless, we cannot do the necessary changes to the container to enable hardware acceleration. diff --git a/LICENSE b/LICENSE index 8e9af564..c8f664a0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 ArabCoders +Copyright (c) 2025 ArabCoders Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 986571dc..ead86449 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ live streams, and includes features like scheduling downloads, sending notificat * Schedule channels or playlists to be downloaded automatically. * Send notification to targets based on selected events. includes [Apprise](https://github.com/caronc/apprise?tab=readme-ov-file#readme) support. * Support per link options. +* Support for limits per extractor and overall global limit. * Queue multiple URLs at once. * Powerful presets system for applying `yt-dlp` options. * File browser. diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 0aac9c68..06d5cd9f 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -2,6 +2,7 @@ import asyncio import functools import glob import logging +import os import time import traceback import uuid @@ -60,6 +61,8 @@ class DownloadQueue(metaclass=Singleton): "Semaphore to limit the number of concurrent downloads." self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) "Semaphore to limit the number of concurrent processors." + self.limits: dict[str, asyncio.Semaphore] = {} + "Per-extractor semaphores to limit concurrent downloads per extractor." self.paused = asyncio.Event() "Event to pause the download queue." self.event = asyncio.Event() @@ -82,6 +85,35 @@ class DownloadQueue(metaclass=Singleton): """ return DownloadQueue() + def _get_limit(self, extractor: str) -> asyncio.Semaphore: + """ + Get or create a semaphore for the given extractor. + + Args: + extractor (str): The extractor name. + + Returns: + asyncio.Semaphore: The semaphore for the extractor. + + """ + if extractor not in self.limits: + env_limit: str | None = os.environ.get(f"YTP_MAX_WORKERS_FOR_{extractor.upper()}") + + # Determine effective limit + if env_limit and env_limit.isdigit() and 1 <= int(env_limit): + limit: int = min(int(env_limit), self.config.max_workers) + else: + if env_limit: + LOG.warning(f"Invalid extractor limit '{env_limit}' for '{extractor}', using default limit.") + limit = self.config.max_workers_per_extractor + + limit = min(limit, self.config.max_workers) + + self.limits[extractor] = asyncio.Semaphore(limit) + LOG.info(f"Created limits container for extractor '{extractor}': {limit}") + + return self.limits[extractor] + def attach(self, _: web.Application) -> None: """ Attach the download queue to the application. @@ -124,7 +156,9 @@ class DownloadQueue(metaclass=Singleton): """ Initialize the download queue. """ - LOG.info(f"Using '{self.config.max_workers}' workers for downloading.") + LOG.info( + f"Using '{self.config.max_workers}' workers for downloading and '{self.config.max_workers_per_extractor}' per extractor." + ) asyncio.create_task(self._download_pool(), name="download_pool") async def start_items(self, ids: list[str]) -> dict[str, str]: @@ -983,36 +1017,67 @@ class DownloadQueue(metaclass=Singleton): """ Create a pool of workers to download the files. """ + adaptive_sleep = 0.2 # Start with base sleep + max_sleep = 5.0 # Maximum sleep to avoid excessive delays + while True: while not self.queue.has_downloads(): LOG.info("Waiting for item to download.") await self.event.wait() self.event.clear() + adaptive_sleep = 0.2 if self.is_paused(): - LOG.info("Download pool is paused.") + LOG.warning("Download pool is paused.") await self.paused.wait() LOG.info("Download pool resumed downloading.") + adaptive_sleep = 0.2 + + items_processed = 0 for _id, entry in list(self.queue.items()): if entry.started() or entry.is_cancelled() or entry.info.auto_start is False: continue + extractor: str = entry.info.get_extractor() or "unknown" + + # Live downloads bypass all limits. if entry.is_live: - task = asyncio.create_task(self._download_live(_id, entry), name=f"download_live_{_id}") + task: asyncio.Task[None] = asyncio.create_task( + self._download_live(_id, entry), name=f"download_live_{extractor}_{_id}" + ) task.add_done_callback(self._handle_task_exception) + items_processed += 1 else: + _limit: asyncio.Semaphore = self._get_limit(extractor) + + # Skip this item in this iteration if no slots are available. + if self.workers.locked() or _limit.locked(): + continue + await self.workers.acquire() + await _limit.acquire() - task = asyncio.create_task(self._download_file(_id, entry), name=f"download_file_{_id}") + task: asyncio.Task[None] = asyncio.create_task( + self._download_file(_id, entry), name=f"download_file_{extractor}_{_id}" + ) - def _release_semaphore(t: asyncio.Task): + def _release(t: asyncio.Task, sem=_limit) -> None: + sem.release() self.workers.release() self._handle_task_exception(t) - task.add_done_callback(_release_semaphore) + task.add_done_callback(_release) + items_processed += 1 - await asyncio.sleep(0.5) + # No items could be processed, back off a bit to avoid busy-waiting. + if 0 == items_processed: + adaptive_sleep: float = min(adaptive_sleep * 1.5, max_sleep) + LOG.info(f"No download slots available. Backing off for {adaptive_sleep:.2f}s before next attempt.") + else: + adaptive_sleep = 0.2 + + await asyncio.sleep(adaptive_sleep) async def _download_live(self, _id: str, entry: Download) -> None: LOG.info(f"Creating temporary worker for entry '{entry.info.name()}'.") diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index b1282f42..12449961 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -167,11 +167,10 @@ class Item: return Item(**data) def get_archive_id(self) -> str | None: - if not self.url: - return None + return get_archive_id(self.url).get("archive_id") if self.url else None - idDict: dict = get_archive_id(self.url) - return idDict.get("archive_id") + def get_extractor(self) -> str | None: + return get_archive_id(self.url).get("ie_key") if self.url else None def get_ytdlp_opts(self) -> YTDLPOpts: params: YTDLPOpts = YTDLPOpts.get_instance() @@ -326,6 +325,15 @@ class ItemDTO: return self.archive_id + def get_extractor(self) -> str | None: + if self.archive_id: + return self.archive_id.split(" ")[0] + + idDict: dict[str, str | None] = get_archive_id(self.url) + self.archive_id = idDict.get("archive_id") + + return idDict.get("ie_key") if self.url else None + def get_ytdlp_opts(self) -> YTDLPOpts: """ Get the yt-dlp options for the item. diff --git a/app/library/Utils.py b/app/library/Utils.py index 65ada868..8411827b 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -87,6 +87,8 @@ DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} T = TypeVar("T") "Generic type variable." +ARCHIVE_IDS_CACHE: dict[str, dict] = {} +"Cache for archive IDs." class StreamingError(Exception): """Raised when an error occurs during streaming.""" @@ -1114,6 +1116,9 @@ def get_archive_id(url: str) -> dict[str, str | None]: "archive_id": None, } + if url in ARCHIVE_IDS_CACHE: + return ARCHIVE_IDS_CACHE[url] + if YTDLP_INFO_CLS is None: YTDLP_INFO_CLS = YTDLP( params={ @@ -1146,6 +1151,7 @@ def get_archive_id(url: str) -> dict[str, str | None]: LOG.exception(e) LOG.error(f"Error getting archive ID: {e}") + ARCHIVE_IDS_CACHE.update({url: idDict}) return idDict diff --git a/app/library/config.py b/app/library/config.py index f5268f65..d3f099ec 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -69,9 +69,12 @@ class Config(metaclass=Singleton): base_path: str = "/" """The base path to use for the application.""" - max_workers: int = 1 + max_workers: int = 20 """The maximum number of workers to use for downloading.""" + max_workers_per_extractor: int = 2 + """The maximum number of concurrent downloads per extractor.""" + streamer_vcodec: str = "" """The video codec to use for streaming. If empty, auto-detect.""" @@ -205,6 +208,7 @@ class Config(metaclass=Singleton): _int_vars: tuple = ( "port", "max_workers", + "max_workers_per_extractor", "extract_info_timeout", "debugpy_port", "playlist_items_concurrency", @@ -240,6 +244,7 @@ class Config(metaclass=Singleton): "remove_files", "ui_update_title", "max_workers", + "max_workers_per_extractor", "default_preset", "instance_title", "console_enabled", diff --git a/app/routes/api/docs.py b/app/routes/api/docs.py new file mode 100644 index 00000000..fe0eccf9 --- /dev/null +++ b/app/routes/api/docs.py @@ -0,0 +1,119 @@ +import logging +import time +from datetime import UTC, datetime +from typing import Any + +import httpx +from aiohttp import web +from aiohttp.web import Request, Response +from yt_dlp.utils.networking import random_user_agent + +from app.library.cache import Cache +from app.library.config import Config +from app.library.router import add_route, route +from app.library.YTDLPOpts import YTDLPOpts + +LOG: logging.Logger = logging.getLogger(__name__) + +STATIC_FILES = ["README.md", "FAQ.md", "API.md", "sc_short.png"] +EXT_TO_MIME: dict = { + ".md": "text/markdown", + ".png": "image/png", +} + + +@route("GET", "api/docs/{file}", name="get_doc") +async def get_doc(request: Request, config: Config, cache: Cache) -> Response: + """ + Get the thumbnail. + + Args: + request (Request): The request object. + config (Config): The configuration object. + cache (Cache): The cache object. + + Returns: + Response: The response object. + + """ + if not (file := request.path): + return web.json_response( + data={ + "error": "Doc file is is required.", + "matcher": request.match_info, + }, + status=web.HTTPForbidden.status_code, + ) + + file = file.removeprefix("/api/docs/") if file.startswith("/api/docs/") else file.removeprefix("/") + if file not in STATIC_FILES: + return web.json_response( + data={ + "error": "Doc file not found.", + "file": file, + "st": STATIC_FILES, + }, + status=web.HTTPNotFound.status_code, + ) + + cache_key = f"doc:{file}" + if dct := cache.get(cache_key): + LOG.debug(f"Serving doc '{file}' from cache.") + return web.Response(**dct) + + url = f"https://raw.githubusercontent.com/arabcoders/ytptube/refs/heads/dev/{file}" + + try: + ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() + opts: dict[str, Any] = { + "headers": { + "User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), + }, + } + if proxy := ytdlp_args.get("proxy"): + opts["proxy"] = proxy + + try: + from httpx_curl_cffi import AsyncCurlTransport, CurlOpt + + opts["transport"] = AsyncCurlTransport( + impersonate="chrome", + default_headers=True, + curl_options={CurlOpt.FRESH_CONNECT: True}, + ) + opts.pop("headers", None) + except Exception: + pass + + async with httpx.AsyncClient(**opts) as client: + LOG.debug(f"Fetching doc from '{url}'.") + response = await client.request(method="GET", url=url, follow_redirects=True) + dct = { + "body": response.content, + "headers": { + "Content-Type": EXT_TO_MIME.get(file[file.rfind(".") :], "text/plain"), + "Pragma": "public", + "Access-Control-Allow-Origin": "*", + "Cache-Control": f"public, max-age={time.time() + 3600}", + "Expires": time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", + datetime.fromtimestamp(time.time() + 3600, tz=UTC).timetuple(), + ), + }, + } + + cache.set(cache_key, dct, ttl=3600) + + return web.Response(**dct) + except Exception as e: + LOG.error(f"Failed to request doc from '{url}'.'. '{e!s}'.") + return web.json_response(data={"error": "Failed to get doc."}, status=web.HTTPInternalServerError.status_code) + + +for file in STATIC_FILES: + add_route( + method="GET", + path=f"{file}", + handler=get_doc, + name=f"get_{file.replace('.', '_')}", + ) diff --git a/ui/app/components/DLFields.vue b/ui/app/components/DLFields.vue index 64464b14..ab8b9b48 100644 --- a/ui/app/components/DLFields.vue +++ b/ui/app/components/DLFields.vue @@ -164,7 +164,7 @@ diff --git a/ui/app/components/Queue.vue b/ui/app/components/Queue.vue index 50ab04f4..145d1835 100644 --- a/ui/app/components/Queue.vue +++ b/ui/app/components/Queue.vue @@ -203,13 +203,13 @@
-
diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue index 7c4b79bf..4ef125bc 100644 --- a/ui/app/layouts/default.vue +++ b/ui/app/layouts/default.vue @@ -121,24 +121,28 @@ connection is functional.

+
-
+
© {{ Year }} - YTPTube -  ({{ config?.app?.app_version || 'unknown' }}) - yt-dlp -  ({{ config?.app?.ytdlp_version || 'unknown' }}) +  ({{ config?.app?.ytdlp_version || 'unknown' }}) - CHANGELOG + - FAQ + - README + - API
-
+
@@ -161,6 +165,7 @@ import type { YTDLPOption } from '~/types/ytdlp' import { useDialog } from '~/composables/useDialog' import Dialog from '~/components/Dialog.vue' import Shutdown from '~/components/shutdown.vue' +import Markdown from '~/components/Markdown.vue' const Year = new Date().getFullYear() const selectedTheme = useStorage('theme', 'auto') @@ -174,6 +179,7 @@ const bg_opacity = useStorage('random_bg_opacity', 0.95) const showMenu = ref(false) const isMobile = useMediaQuery({ maxWidth: 1024 }) const app_shutdown = ref(false) +const doc = ref<{ file: string }>({ file: '' }) const applyPreferredColorScheme = (scheme: string) => { if (!scheme || scheme === 'auto') { diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index 3edf9907..a14fd5c1 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -117,29 +117,29 @@ watch(toggleFilter, () => { } }); -onMounted(() => { +const getTitle = (): string => { if (!config.app.ui_update_title) { - useHead({ title: 'YTPTube' }) - return + return 'YTPTube' } - useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` }) -}) + return `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers}:${config.app.max_workers_per_extractor} | ${Object.keys(stateStore.history).length || 0} )` +} + +onMounted(() => useHead({ title: getTitle() })) watch(() => stateStore.history, () => { if (!config.app.ui_update_title) { return } - useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` }) + useHead({ title: getTitle() }) }, { deep: true }) watch(() => stateStore.queue, () => { if (!config.app.ui_update_title) { return } - useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` }) + useHead({ title: getTitle() }) }, { deep: true }) - const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' }) const pauseDownload = () => { diff --git a/ui/app/stores/ConfigStore.ts b/ui/app/stores/ConfigStore.ts index c21c6bae..86ff8627 100644 --- a/ui/app/stores/ConfigStore.ts +++ b/ui/app/stores/ConfigStore.ts @@ -10,7 +10,8 @@ export const useConfigStore = defineStore('config', () => { ui_update_title: true, output_template: '', ytdlp_version: '', - max_workers: 1, + max_workers: 20, + max_workers_per_extractor: 2, default_preset: 'default', instance_title: null, console_enabled: false, diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index 5bf7beb6..91a10443 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -14,6 +14,8 @@ type AppConfig = { ytdlp_version: string /** Maximum number of concurrent download workers */ max_workers: number + /** Maximum number of concurrent workers per extractor */ + max_workers_per_extractor: number /** Default preset name, e.g. "default" */ default_preset: string /** Instance title for the app, null if not set */ diff --git a/ui/package.json b/ui/package.json index 04e67121..358ef69f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -33,7 +33,11 @@ "socket.io-client": "^4.8.1", "vue": "^3.5.21", "vue-router": "^4.5.1", - "vue-toastification": "2.0.0-rc.5" + "vue-toastification": "2.0.0-rc.5", + "marked": "^16.2.1", + "marked-alert": "^2.1.2", + "marked-base-url": "^1.1.7", + "marked-gfm-heading-id": "^4.1.2" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 8ac03892..d3cd7f05 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -35,6 +35,18 @@ importers: hls.js: specifier: ^1.6.12 version: 1.6.12 + marked: + specifier: ^16.2.1 + version: 16.2.1 + marked-alert: + specifier: ^2.1.2 + version: 2.1.2(marked@16.2.1) + marked-base-url: + specifier: ^1.1.7 + version: 1.1.7(marked@16.2.1) + marked-gfm-heading-id: + specifier: ^4.1.2 + version: 4.1.2(marked@16.2.1) moment: specifier: ^2.30.1 version: 2.30.1 @@ -2402,6 +2414,9 @@ packages: git-url-parse@16.1.0: resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2762,6 +2777,26 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + marked-alert@2.1.2: + resolution: {integrity: sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==} + peerDependencies: + marked: '>=7.0.0' + + marked-base-url@1.1.7: + resolution: {integrity: sha512-CJOfpG2/XOEp8UuI5H0tbELxuS1v8Ud705jamEIpWBQDdkda1i+LrafxLn41rlxhGEeJqo27b/hBFVYHWOYccw==} + peerDependencies: + marked: '>= 4 < 17' + + marked-gfm-heading-id@4.1.2: + resolution: {integrity: sha512-EQ1WiEGHJh0C8viU+hbXbhHyWTDgEia2i96fiSemm2wdYER6YBw/9QI5TB6YFTqFfmMOxBFXPcPJtlgD0fVV2w==} + peerDependencies: + marked: '>=13 <17' + + marked@16.2.1: + resolution: {integrity: sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==} + engines: {node: '>= 20'} + hasBin: true + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -6638,6 +6673,8 @@ snapshots: dependencies: git-up: 8.1.1 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6985,6 +7022,21 @@ snapshots: '@babel/types': 7.28.4 source-map-js: 1.2.1 + marked-alert@2.1.2(marked@16.2.1): + dependencies: + marked: 16.2.1 + + marked-base-url@1.1.7(marked@16.2.1): + dependencies: + marked: 16.2.1 + + marked-gfm-heading-id@4.1.2(marked@16.2.1): + dependencies: + github-slugger: 2.0.0 + marked: 16.2.1 + + marked@16.2.1: {} + mdn-data@2.0.28: {} mdn-data@2.12.2: {}