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
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:
commit
5c862eb613
19 changed files with 555 additions and 40 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -141,6 +141,7 @@
|
|||
"sstr",
|
||||
"startswith",
|
||||
"SUPPRESSHELP",
|
||||
"tablehead",
|
||||
"tebibytes",
|
||||
"testpaths",
|
||||
"threadsafe",
|
||||
|
|
|
|||
21
FAQ.md
21
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_<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
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
2
LICENSE
2
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()}'.")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
119
app/routes/api/docs.py
Normal file
119
app/routes/api/docs.py
Normal 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('.', '_')}",
|
||||
)
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@
|
|||
<figure class="image is-3by1">
|
||||
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
|
||||
<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))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<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"
|
||||
class="play-overlay">
|
||||
<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))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<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))" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</template>
|
||||
|
|
|
|||
238
ui/app/components/Markdown.vue
Normal file
238
ui/app/components/Markdown.vue
Normal 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>
|
||||
|
|
@ -203,13 +203,13 @@
|
|||
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||
class="play-overlay">
|
||||
<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))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<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))" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -121,24 +121,28 @@
|
|||
connection is functional.
|
||||
</p>
|
||||
</Message>
|
||||
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
|
||||
<ClientOnly>
|
||||
<Dialog />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
© {{ 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}`">
|
||||
({{ config?.app?.app_version || 'unknown' }})</span>
|
||||
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
||||
<span v-if="!isMobile"> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||
<span> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||
- <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 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">
|
||||
<span class="user-hint"
|
||||
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 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<boolean>(false)
|
||||
const doc = ref<{ file: string }>({ file: '' })
|
||||
|
||||
const applyPreferredColorScheme = (scheme: string) => {
|
||||
if (!scheme || scheme === 'auto') {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue