Merge pull request #422 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

[FEAT] Add per extractor limits
This commit is contained in:
Abdulmohsen 2025-09-14 22:23:42 +03:00 committed by GitHub
commit 5c862eb613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 555 additions and 40 deletions

View file

@ -141,6 +141,7 @@
"sstr", "sstr",
"startswith", "startswith",
"SUPPRESSHELP", "SUPPRESSHELP",
"tablehead",
"tebibytes", "tebibytes",
"testpaths", "testpaths",
"threadsafe", "threadsafe",

21
FAQ.md
View file

@ -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 # Environment variables
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, 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_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` | | YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` | | 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_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` | | YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` | | 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_TEMP_DISABLED | Disable temp files handling. | `false` |
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` | | 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_<EXTRACTOR_NAME>`.
> 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 # Browser extensions & bookmarklets
## Simple bookmarklet ## 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 `/`, 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 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? # 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. As the container is rootless, we cannot do the necessary changes to the container to enable hardware acceleration.

View file

@ -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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -18,6 +18,7 @@ live streams, and includes features like scheduling downloads, sending notificat
* Schedule channels or playlists to be downloaded automatically. * 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. * 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 per link options.
* Support for limits per extractor and overall global limit.
* Queue multiple URLs at once. * Queue multiple URLs at once.
* Powerful presets system for applying `yt-dlp` options. * Powerful presets system for applying `yt-dlp` options.
* File browser. * File browser.

View file

@ -2,6 +2,7 @@ import asyncio
import functools import functools
import glob import glob
import logging import logging
import os
import time import time
import traceback import traceback
import uuid import uuid
@ -60,6 +61,8 @@ class DownloadQueue(metaclass=Singleton):
"Semaphore to limit the number of concurrent downloads." "Semaphore to limit the number of concurrent downloads."
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
"Semaphore to limit the number of concurrent processors." "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() self.paused = asyncio.Event()
"Event to pause the download queue." "Event to pause the download queue."
self.event = asyncio.Event() self.event = asyncio.Event()
@ -82,6 +85,35 @@ class DownloadQueue(metaclass=Singleton):
""" """
return DownloadQueue() 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: def attach(self, _: web.Application) -> None:
""" """
Attach the download queue to the application. Attach the download queue to the application.
@ -124,7 +156,9 @@ class DownloadQueue(metaclass=Singleton):
""" """
Initialize the download queue. 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") asyncio.create_task(self._download_pool(), name="download_pool")
async def start_items(self, ids: list[str]) -> dict[str, str]: 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. 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 True:
while not self.queue.has_downloads(): while not self.queue.has_downloads():
LOG.info("Waiting for item to download.") LOG.info("Waiting for item to download.")
await self.event.wait() await self.event.wait()
self.event.clear() self.event.clear()
adaptive_sleep = 0.2
if self.is_paused(): if self.is_paused():
LOG.info("Download pool is paused.") LOG.warning("Download pool is paused.")
await self.paused.wait() await self.paused.wait()
LOG.info("Download pool resumed downloading.") LOG.info("Download pool resumed downloading.")
adaptive_sleep = 0.2
items_processed = 0
for _id, entry in list(self.queue.items()): for _id, entry in list(self.queue.items()):
if entry.started() or entry.is_cancelled() or entry.info.auto_start is False: if entry.started() or entry.is_cancelled() or entry.info.auto_start is False:
continue continue
extractor: str = entry.info.get_extractor() or "unknown"
# Live downloads bypass all limits.
if entry.is_live: 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) task.add_done_callback(self._handle_task_exception)
items_processed += 1
else: 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 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.workers.release()
self._handle_task_exception(t) 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: async def _download_live(self, _id: str, entry: Download) -> None:
LOG.info(f"Creating temporary worker for entry '{entry.info.name()}'.") LOG.info(f"Creating temporary worker for entry '{entry.info.name()}'.")

View file

@ -167,11 +167,10 @@ class Item:
return Item(**data) return Item(**data)
def get_archive_id(self) -> str | None: def get_archive_id(self) -> str | None:
if not self.url: return get_archive_id(self.url).get("archive_id") if self.url else None
return None
idDict: dict = get_archive_id(self.url) def get_extractor(self) -> str | None:
return idDict.get("archive_id") return get_archive_id(self.url).get("ie_key") if self.url else None
def get_ytdlp_opts(self) -> YTDLPOpts: def get_ytdlp_opts(self) -> YTDLPOpts:
params: YTDLPOpts = YTDLPOpts.get_instance() params: YTDLPOpts = YTDLPOpts.get_instance()
@ -326,6 +325,15 @@ class ItemDTO:
return self.archive_id 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: def get_ytdlp_opts(self) -> YTDLPOpts:
""" """
Get the yt-dlp options for the item. Get the yt-dlp options for the item.

View file

@ -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") T = TypeVar("T")
"Generic type variable." "Generic type variable."
ARCHIVE_IDS_CACHE: dict[str, dict] = {}
"Cache for archive IDs."
class StreamingError(Exception): class StreamingError(Exception):
"""Raised when an error occurs during streaming.""" """Raised when an error occurs during streaming."""
@ -1114,6 +1116,9 @@ def get_archive_id(url: str) -> dict[str, str | None]:
"archive_id": None, "archive_id": None,
} }
if url in ARCHIVE_IDS_CACHE:
return ARCHIVE_IDS_CACHE[url]
if YTDLP_INFO_CLS is None: if YTDLP_INFO_CLS is None:
YTDLP_INFO_CLS = YTDLP( YTDLP_INFO_CLS = YTDLP(
params={ params={
@ -1146,6 +1151,7 @@ def get_archive_id(url: str) -> dict[str, str | None]:
LOG.exception(e) LOG.exception(e)
LOG.error(f"Error getting archive ID: {e}") LOG.error(f"Error getting archive ID: {e}")
ARCHIVE_IDS_CACHE.update({url: idDict})
return idDict return idDict

View file

@ -69,9 +69,12 @@ class Config(metaclass=Singleton):
base_path: str = "/" base_path: str = "/"
"""The base path to use for the application.""" """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.""" """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 = "" streamer_vcodec: str = ""
"""The video codec to use for streaming. If empty, auto-detect.""" """The video codec to use for streaming. If empty, auto-detect."""
@ -205,6 +208,7 @@ class Config(metaclass=Singleton):
_int_vars: tuple = ( _int_vars: tuple = (
"port", "port",
"max_workers", "max_workers",
"max_workers_per_extractor",
"extract_info_timeout", "extract_info_timeout",
"debugpy_port", "debugpy_port",
"playlist_items_concurrency", "playlist_items_concurrency",
@ -240,6 +244,7 @@ class Config(metaclass=Singleton):
"remove_files", "remove_files",
"ui_update_title", "ui_update_title",
"max_workers", "max_workers",
"max_workers_per_extractor",
"default_preset", "default_preset",
"instance_title", "instance_title",
"console_enabled", "console_enabled",

119
app/routes/api/docs.py Normal file
View file

@ -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('.', '_')}",
)

View file

@ -164,7 +164,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineEmits, ref } from 'vue' import { ref } from 'vue'
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue'
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils'

View file

@ -272,7 +272,7 @@
<figure class="image is-3by1"> <figure class="image is-3by1">
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay"> <span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div> <div class="play-icon"></div>
<img @load="e => pImg(e)" <img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
@ -280,13 +280,13 @@
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" <span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay"> class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" <img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<template v-else> <template v-else>
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail" <img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" /> :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</template> </template>

View file

@ -0,0 +1,238 @@
<style>
.markdown-alert {
padding: 0 1em;
margin-bottom: 16px;
color: inherit;
border-left: 0.25em solid #444c56;
}
.markdown-alert-title {
display: inline-flex;
align-items: center;
font-weight: 500;
text-transform: uppercase;
user-select: none;
}
.markdown-alert-note {
border-left-color: #539bf5;
}
.markdown-alert-tip {
border-left-color: #57ab5a;
}
.markdown-alert-important {
border-left-color: #986ee2;
}
.markdown-alert-warning {
border-left-color: #c69026;
}
.markdown-alert-caution {
border-left-color: #e5534b;
}
.markdown-alert-note>.markdown-alert-title {
color: #539bf5;
}
.markdown-alert-tip>.markdown-alert-title {
color: #57ab5a;
}
.markdown-alert-important>.markdown-alert-title {
color: #986ee2;
}
.markdown-alert-warning>.markdown-alert-title {
color: #c69026;
}
.markdown-alert-caution>.markdown-alert-title {
color: #e5534b;
}
code {
word-break: break-word !important
}
</style>
<template>
<div>
<div class="modal is-active">
<div class="modal-background" @click="emitter('closeModel')"></div>
<div class="modal-content modal-content-max">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin" />
</div>
<div style="position: relative" v-if="!isLoading">
<Message v-if="error" message_class="has-background-warning-90 has-text-dark" title="Error"
icon="fas fa-exclamation" :message="error" />
<div class="card" v-else>
<div class="card-body p-4">
<div class="content" v-html="content" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue'
import { marked } from 'marked'
import { baseUrl } from 'marked-base-url'
import markedAlert from 'marked-alert'
import { gfmHeadingId } from 'marked-gfm-heading-id'
import Message from '~/components/Message.vue'
const props = defineProps<{ file: string }>()
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.png']
const content = ref<string>('')
const error = ref<string>('')
const isLoading = ref<boolean>(true)
const handleClick = async (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null
if (!target) {
return
}
const href = target.getAttribute('data-url')
if (!href) {
return
}
e.preventDefault()
await loader(href)
}
const addListeners = (): void => {
removeListeners()
document.querySelectorAll('.content a').forEach((l: Element): void => {
const href = l.getAttribute('data-url')
if (!href) {
return
}
(l as HTMLElement).addEventListener('click', handleClick)
})
}
const removeListeners = (): void => {
document.querySelectorAll('.content a').forEach((l: Element): void => {
const href = l.getAttribute('data-url')
if (!href) {
return
}
(l as HTMLElement).removeEventListener('click', handleClick)
})
}
const loader = async (file: string) => {
try {
isLoading.value = true
const response = await fetch(`${file}?_=${Date.now()}`)
if (true !== response.ok) {
const err = await response.json()
error.value = err.error.message
return
}
const text = await response.text()
marked.use(gfmHeadingId())
marked.use(baseUrl(window.origin))
marked.use(markedAlert())
marked.use({
gfm: true,
hooks: {
postprocess: (text: string) => text.replace(
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map((n: string) => n.trim()).join(' ')}"></i></span>`
)
},
walkTokens: (token: any) => {
if (token.type !== 'link') {
return
}
if (token.href.startsWith('#')) {
return
}
if (urls.some(l => token.href.includes(l))) {
const name = urls.find(l => token.href.includes(l)) || ''
token._external = false
token.href = `/${name}`
} else {
token._external = true
}
},
renderer: {
link(token: any) {
const text = this.parser.parseInline(token.tokens)
const title = token.title ? ` title="${token.title}"` : ''
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : ''
let local = ''
const name = urls.find(l => token.href.includes(l)) || ''
if (name) {
local = ` data-url="/api/docs/${name}"`
}
return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>`
},
table(token: any) {
// `token.header` and `token.rows` are available
// Use default table output from built-in renderer to get header + body markup
// Then wrap with classed <table>
// We need to generate the inner HTML parts first
const headerHtml = `<thead><tr>${token.header.map((cell: any) => `<th>${this.parser.parseInline(cell.tokens)}</th>`).join('')}</tr></thead>`
const bodyHtml = (token.rows || []).map((row: any[]) => {
const tr = row.map((cell: any) => {
return `<td>${this.parser.parseInline(cell.tokens)}</td>`
}).join('')
return `<tr>${tr}</tr>`
}).join('')
return `<div class="table-container"><table class="table is-striped is-hoverable is-fullwidth is-bordered">\n${headerHtml}\n<tbody>\n${bodyHtml}\n</tbody>\n</table></div>`
},
image(token: any) {
const alt = token.text ? ` alt="${token.text}"` : ' alt=""'
const title = token.title ? ` title="${token.title}"` : ''
const refPolicy = ' referrerpolicy="no-referrer"'
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : ''
const loading = ' loading="lazy"'
return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />`
},
},
})
content.value = String(marked.parse(text))
} catch (e: any) {
console.error(e)
error.value = e.message
} finally {
isLoading.value = false
}
}
onMounted(async () => loader(props.file))
onUpdated(() => addListeners())
onBeforeUnmount(() => removeListeners())
</script>

View file

@ -203,13 +203,13 @@
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" <span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay"> class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" <img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<template v-else> <template v-else>
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail" <img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" /> :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</template> </template>

View file

@ -121,24 +121,28 @@
connection is functional. connection is functional.
</p> </p>
</Message> </Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
<ClientOnly> <ClientOnly>
<Dialog /> <Dialog />
</ClientOnly> </ClientOnly>
</div> </div>
<div class="columns mt-3 is-mobile"> <div class="columns mt-3 is-mobile">
<div class="column is-8-mobile"> <div class="column">
<div class="has-text-left" v-if="config.app?.app_version"> <div class="has-text-left" v-if="config.app?.app_version">
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink> © {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="has-tooltip" v-if="!isMobile" <span class="has-tooltip"
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`"> v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
&nbsp;({{ config?.app?.app_version || 'unknown' }})</span> &nbsp;({{ config?.app?.app_version || 'unknown' }})</span>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink> - <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span v-if="!isMobile">&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span> <span>&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink> - <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
</div> </div>
</div> </div>
<div class="column is-4-mobile" v-if="config.app?.started"> <div class="column is-narrow" v-if="config.app?.started">
<div class="has-text-right"> <div class="has-text-right">
<span class="user-hint" <span class="user-hint"
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')"> v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
@ -161,6 +165,7 @@ import type { YTDLPOption } from '~/types/ytdlp'
import { useDialog } from '~/composables/useDialog' import { useDialog } from '~/composables/useDialog'
import Dialog from '~/components/Dialog.vue' import Dialog from '~/components/Dialog.vue'
import Shutdown from '~/components/shutdown.vue' import Shutdown from '~/components/shutdown.vue'
import Markdown from '~/components/Markdown.vue'
const Year = new Date().getFullYear() const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
@ -174,6 +179,7 @@ const bg_opacity = useStorage('random_bg_opacity', 0.95)
const showMenu = ref(false) const showMenu = ref(false)
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const app_shutdown = ref<boolean>(false) const app_shutdown = ref<boolean>(false)
const doc = ref<{ file: string }>({ file: '' })
const applyPreferredColorScheme = (scheme: string) => { const applyPreferredColorScheme = (scheme: string) => {
if (!scheme || scheme === 'auto') { if (!scheme || scheme === 'auto') {

View file

@ -117,29 +117,29 @@ watch(toggleFilter, () => {
} }
}); });
onMounted(() => { const getTitle = (): string => {
if (!config.app.ui_update_title) { if (!config.app.ui_update_title) {
useHead({ title: 'YTPTube' }) return 'YTPTube'
return
} }
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, () => { watch(() => stateStore.history, () => {
if (!config.app.ui_update_title) { if (!config.app.ui_update_title) {
return 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 }) }, { deep: true })
watch(() => stateStore.queue, () => { watch(() => stateStore.queue, () => {
if (!config.app.ui_update_title) { if (!config.app.ui_update_title) {
return 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 }) }, { deep: true })
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' }) const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' })
const pauseDownload = () => { const pauseDownload = () => {

View file

@ -10,7 +10,8 @@ export const useConfigStore = defineStore('config', () => {
ui_update_title: true, ui_update_title: true,
output_template: '', output_template: '',
ytdlp_version: '', ytdlp_version: '',
max_workers: 1, max_workers: 20,
max_workers_per_extractor: 2,
default_preset: 'default', default_preset: 'default',
instance_title: null, instance_title: null,
console_enabled: false, console_enabled: false,

View file

@ -14,6 +14,8 @@ type AppConfig = {
ytdlp_version: string ytdlp_version: string
/** Maximum number of concurrent download workers */ /** Maximum number of concurrent download workers */
max_workers: number max_workers: number
/** Maximum number of concurrent workers per extractor */
max_workers_per_extractor: number
/** Default preset name, e.g. "default" */ /** Default preset name, e.g. "default" */
default_preset: string default_preset: string
/** Instance title for the app, null if not set */ /** Instance title for the app, null if not set */

View file

@ -33,7 +33,11 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-router": "^4.5.1", "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": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

View file

@ -35,6 +35,18 @@ importers:
hls.js: hls.js:
specifier: ^1.6.12 specifier: ^1.6.12
version: 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: moment:
specifier: ^2.30.1 specifier: ^2.30.1
version: 2.30.1 version: 2.30.1
@ -2402,6 +2414,9 @@ packages:
git-url-parse@16.1.0: git-url-parse@16.1.0:
resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -2762,6 +2777,26 @@ packages:
magicast@0.3.5: magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} 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: mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
@ -6638,6 +6673,8 @@ snapshots:
dependencies: dependencies:
git-up: 8.1.1 git-up: 8.1.1
github-slugger@2.0.0: {}
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@ -6985,6 +7022,21 @@ snapshots:
'@babel/types': 7.28.4 '@babel/types': 7.28.4
source-map-js: 1.2.1 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.0.28: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}