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 @@
-
pImg(e)"
+
pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
-
pImg(e)" v-if="item.extras?.thumbnail"
+
pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
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.
+ doc.file = ''" :file="doc.file" v-if="doc.file" />
-
+
© {{ 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: {}