Finalizing migration to nuxt3

This commit is contained in:
ArabCoders 2024-12-17 17:07:52 +03:00
parent f614dbc217
commit c727ab3446
28 changed files with 811 additions and 379 deletions

View file

@ -110,6 +110,10 @@ class DataStore:
return None
async def test(self) -> bool:
await self.connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
return True
def _updateStoreItem(self, type: str, item: ItemDTO) -> None:
sqlStatement = """
INSERT INTO "history" ("id", "type", "url", "data")

View file

@ -8,7 +8,7 @@ import shutil
import yt_dlp
import hashlib
from .AsyncPool import Terminator
from .Utils import get_format, get_opts, jsonCookie, mergeConfig
from .Utils import get_opts, jsonCookie, mergeConfig
from .ItemDTO import ItemDTO
from .config import Config
from .Emitter import Emitter
@ -26,7 +26,6 @@ class Download:
temp_dir: str = None
output_template: str = None
output_template_chapter: str = None
format: str = None
ytdl_opts: dict = None
info: ItemDTO = None
default_ytdl_opts: dict = None
@ -61,7 +60,7 @@ class Download:
"Fields to be extracted from yt-dlp progress hook."
tempKeep: bool = False
"Keep temp directory after download."
"Keep temp folder after download."
def __init__(self, info: ItemDTO, info_dict: dict = None, debug: bool = False):
config = Config.get_instance()
@ -70,8 +69,8 @@ class Download:
self.temp_dir = info.temp_dir
self.output_template_chapter = info.output_template_chapter
self.output_template = info.output_template
self.format = config.ytdl_options.get('format', get_format(info.format, info.quality))
self.ytdl_opts = get_opts(info.format, info.quality, info.ytdlp_config if info.ytdlp_config else {})
self.preset = info.preset
self.ytdl_opts = get_opts(self.preset, info.ytdlp_config if info.ytdlp_config else {})
self.info = info
self.id = info._id
self.default_ytdl_opts = config.ytdl_options
@ -110,7 +109,6 @@ class Download:
try:
params: dict = {
'color': 'no_color',
'format': self.format,
'paths': {
'home': self.download_dir,
'temp': self.tempPath,
@ -121,12 +119,14 @@ class Download:
},
'noprogress': True,
'break_on_existing': True,
'ignoreerrors': False,
'progress_hooks': [self._progress_hook],
'postprocessor_hooks': [self._postprocessor_hook],
**mergeConfig(self.default_ytdl_opts, self.ytdl_opts),
}
if 'format' not in params and self.default_ytdl_opts.get('format', None):
params['format'] = self.default_ytdl_opts['format']
params['ignoreerrors'] = False
if self.debug:
@ -158,10 +158,10 @@ class Download:
if hasDeletedOptions:
LOG.warning(
f'Live stream detected for [{self.info.title}], The following opts [{deletedOpts=}] have been deleted which are known to cause issues with live stream and post stream manifestless mode.')
f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted which are known to cause issues with live stream and post stream manifestless mode.")
LOG.info(
f'Downloading {os.getpid()=} id="{self.info.id}" title="{self.info.title}" format="{self.format}" fq="{self.info.format}/{self.info.quality}".')
f'Downloading pid: {os.getpid()} id="{self.info.id}" title="{self.info.title}" preset="{self.preset}".')
cls = yt_dlp.YoutubeDL(params=params)
@ -292,7 +292,7 @@ class Download:
if self.info.status != 'finished' and self.is_live:
LOG.warning(
f'Keeping live temp directory [{self.tempPath}], as the reported status is not finished [{self.info.status}].')
f'Keeping live temp folder [{self.tempPath}], as the reported status is not finished [{self.info.status}].')
return
if not os.path.exists(self.tempPath):
@ -300,10 +300,10 @@ class Download:
if self.tempPath == self.temp_dir:
LOG.warning(
f'Attempted to delete video temp directory: {self.tempPath}, but it is the same as main temp directory.')
f'Attempted to delete video temp folder: {self.tempPath}, but it is the same as main temp folder.')
return
LOG.info(f'Deleting Temp directory: {self.tempPath}')
LOG.info(f'Deleting Temp folder: {self.tempPath}')
shutil.rmtree(self.tempPath, ignore_errors=True)
async def progress_update(self):
@ -341,10 +341,6 @@ class Download:
if os.path.exists(status.get('filename')):
self.info.file_size = os.path.getsize(status.get('filename'))
# Set correct file extension for thumbnails
if self.info.format == 'thumbnail':
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
self.info.status = status.get('status', self.info.status)
self.info.msg = status.get('msg')
@ -363,7 +359,7 @@ class Download:
self.info.speed = status.get('speed')
self.info.eta = status.get('eta')
if self.info.status == 'finished' and 'filename' in status and self.info.format != 'thumbnail':
if self.info.status == 'finished' and 'filename' in status:
try:
self.info.file_size = os.path.getsize(status.get('filename'))
except FileNotFoundError:

View file

@ -10,7 +10,7 @@ from .config import Config
from .Download import Download
from .ItemDTO import ItemDTO
from .DataStore import DataStore
from .Utils import calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from .Utils import ag, calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from .AsyncPool import AsyncPool
from .Emitter import Emitter
@ -26,19 +26,17 @@ class DownloadQueue:
done: DataStore = None
event: asyncio.Event = None
pool: AsyncPool = None
connection: Connection = None
def __init__(self, emitter: Emitter, connection: Connection):
self.config = Config.get_instance()
self.emitter = emitter
self.connection = connection
self.done = DataStore(type=TYPE_DONE, connection=self.connection)
self.queue = DataStore(type=TYPE_QUEUE, connection=self.connection)
self.done = DataStore(type=TYPE_DONE, connection=connection)
self.queue = DataStore(type=TYPE_QUEUE, connection=connection)
self.done.load()
self.queue.load()
async def test(self) -> bool:
self.connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
await self.done.test()
return True
async def initialize(self):
@ -53,8 +51,7 @@ class DownloadQueue:
async def __add_entry(
self,
entry: dict,
quality: str,
format: str,
preset: str,
folder: str,
ytdlp_config: dict = {},
ytdlp_cookies: str = '',
@ -74,9 +71,9 @@ class DownloadQueue:
entries = entry['entries']
playlist_index_digits = len(str(len(entries)))
results = []
for index, etr in enumerate(entries, start=1):
for i, etr in enumerate(entries, start=1):
etr['playlist'] = entry.get('id')
etr['playlist_index'] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
etr['playlist_index'] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(i)
for property in ('id', 'title', 'uploader', 'uploader_id'):
if property in entry:
etr[f'playlist_{property}'] = entry.get(property)
@ -84,8 +81,7 @@ class DownloadQueue:
results.append(
await self.__add_entry(
entry=etr,
quality=quality,
format=format,
preset=preset,
folder=folder,
ytdlp_config=ytdlp_config,
ytdlp_cookies=ytdlp_cookies,
@ -108,19 +104,19 @@ class DownloadQueue:
else:
error = entry.get('msg', None)
LOG.debug(f"entry: {entry.get('id', None)} - {entry.get('webpage_url', None)} - {entry.get('url', None)}")
LOG.debug(
f"Entry id '{entry.get('id', None)}' url '{entry.get('webpage_url', None)} - {entry.get('url', None)}'."
)
if self.done.exists(key=entry['id'], url=entry.get('webpage_url') or entry.get('url')):
item = self.done.get(key=entry['id'], url=entry.get('webpage_url') or entry['url'])
LOG.debug(f'Item [{item.info.title}] already downloaded. Removing from history.')
LOG.warning(f"Item '{item.info.id}' - '{item.info.title}' already downloaded. Removing from history.")
await self.clear([item.info._id])
try:
item = self.queue.get(key=entry.get('id'), url=entry.get('webpage_url') or entry.get('url'))
if item is not None:
err_message = f'Item [{item.info.id}: {item.info.title}] already in download queue.'
err_message = f"Item ID '{item.info.id}' - '{item.info.title}' already in download queue."
LOG.info(err_message)
return {'status': 'error', 'msg': err_message}
except KeyError:
@ -142,8 +138,8 @@ class DownloadQueue:
id=entry.get('id'),
title=entry.get('title'),
url=entry.get('webpage_url') or entry.get('url'),
quality=quality,
format=format,
preset=preset,
thumbnail=entry.get('thumbnail', None),
folder=folder,
download_dir=download_dir,
temp_dir=self.config.temp_path,
@ -170,7 +166,7 @@ class DownloadQueue:
NotifyEvent = 'completed'
elif self.config.allow_manifestless is False and is_manifestless is True:
dlInfo.info.status = 'error'
dlInfo.info.error = 'Video is in Post-Live Manifestless mode.'
dlInfo.info.error = 'Video is in post-live manifestless mode.'
itemDownload = self.done.put(dlInfo)
NotifyEvent = 'completed'
else:
@ -188,8 +184,7 @@ class DownloadQueue:
elif eventType.startswith('url'):
return await self.add(
url=entry.get('url'),
quality=quality,
format=format,
preset=preset,
folder=folder,
ytdlp_config=ytdlp_config,
ytdlp_cookies=ytdlp_cookies,
@ -205,8 +200,7 @@ class DownloadQueue:
async def add(
self,
url: str,
quality: str,
format: str,
preset: str,
folder: str,
ytdlp_config: dict = {},
ytdlp_cookies: str = '',
@ -215,7 +209,8 @@ class DownloadQueue:
):
ytdlp_config = ytdlp_config if ytdlp_config else {}
LOG.info(f'Adding {url=} {quality=} {format=} {folder=} {output_template=} {ytdlp_cookies=} {ytdlp_config=}')
LOG.info(f"Adding url '{url}' to folder '{folder}' with the following options 'Preset: {preset=}' 'Naming: {output_template}', 'Cookies: {ytdlp_cookies}' 'YTConfig: {ytdlp_config}'.")
if isinstance(ytdlp_config, str):
try:
ytdlp_config = json.loads(ytdlp_config)
@ -226,7 +221,7 @@ class DownloadQueue:
already = set() if already is None else already
if url in already:
LOG.info('recursion detected, skipping.')
LOG.warning(f"Recursion detected with url '{url}' skipping.")
return {'status': 'ok'}
already.add(url)
@ -234,7 +229,7 @@ class DownloadQueue:
try:
downloaded, id_dict = self.isDownloaded(url)
if downloaded is True:
message = f"[ { id_dict.get('id') } ]: has been downloaded already."
message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive."
LOG.info(message)
return {'status': 'error', 'msg': message}
@ -254,7 +249,8 @@ class DownloadQueue:
if not entry:
return {'status': 'error', 'msg': 'Unable to extract info check logs.'}
LOG.debug(f'extract_info: for [{url=}] is done in {time.perf_counter() - started}. Length: {len(entry)}')
LOG.debug(
f"extract_info: for 'URL: {url}' is done in '{time.perf_counter() - started}'. Length: '{len(entry)}'.")
except yt_dlp.utils.ExistingVideoReached as exc:
LOG.error(f'Video has been downloaded already and recorded in archive.log file. {str(exc)}')
return {'status': 'error', 'msg': 'Video has been downloaded already and recorded in archive.log file.'}
@ -267,8 +263,7 @@ class DownloadQueue:
return await self.__add_entry(
entry=entry,
quality=quality,
format=format,
preset=preset,
folder=folder,
ytdlp_config=ytdlp_config,
ytdlp_cookies=ytdlp_cookies,
@ -277,8 +272,8 @@ class DownloadQueue:
)
async def cancel(self, ids: list[str]) -> dict[str, str]:
status: dict[str, str] = {"status": "ok"}
for id in ids:
try:
item = self.queue.get(key=id)
@ -400,7 +395,8 @@ class DownloadQueue:
await self.__downloadFile(id, entry)
async def __downloadFile(self, id: str, entry: Download):
LOG.info(f'Queuing {id=} - {entry.info.title=} - {entry.info.url=} - {entry.info.folder=}.')
LOG.info(
f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'folder: {entry.info.folder}'.")
try:
await entry.start(self.emitter)

View file

@ -1,11 +1,11 @@
import asyncio
import base64
from datetime import datetime
import functools
import json
import os
import random
import time
import httpx
from .config import Config
from .DownloadQueue import DownloadQueue
from aiohttp import web
@ -14,9 +14,7 @@ from .Playlist import Playlist
from .M3u8 import M3u8
from .Segments import Segments
from .Subtitle import Subtitle
import socketio
import logging
import sqlite3
import magic
from .common import common
from pathlib import Path
@ -30,16 +28,11 @@ MIME = magic.Magic(mime=True)
class HttpAPI(common):
config: Config = None
encoder: Encoder = None
app: web.Application = None
sio: socketio.AsyncServer = None
routes: web.RouteTableDef = None
connection: sqlite3.Connection = None
queue: DownloadQueue = None
loop: asyncio.AbstractEventLoop = None
rootPath: str = None
staticHolder: dict = {}
extToMime: dict = {
'.html': 'text/html',
'.css': 'text/css',
@ -54,11 +47,9 @@ class HttpAPI(common):
self.rootPath = str(Path(__file__).parent.parent.parent)
self.config = Config.get_instance()
self.loop = asyncio.get_event_loop()
self.encoder = encoder
self.routes = web.RouteTableDef()
self.encoder = encoder
self.emitter = emitter
self.queue = queue
@ -138,10 +129,13 @@ class HttpAPI(common):
self.routes.route(method._http_method, self.config.url_prefix + http_path)(method)
async def on_prepare(request: Request, response: Response):
if 'Server' in response.headers:
del response.headers['Server']
if 'Origin' in request.headers:
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
response.headers['Access-Control-Allow-Methods'] = 'PUT, POST, DELETE'
response.headers['Access-Control-Allow-Methods'] = 'PATCH, PUT, POST, DELETE'
if self.config.url_prefix != '/':
self.routes.route('GET', '/')(lambda _: web.HTTPFound(self.config.url_prefix))
@ -194,12 +188,11 @@ class HttpAPI(common):
post = await request.json()
url: str = post.get('url')
quality: str = post.get('quality')
preset: str = post.get('preset', 'default')
if not url:
raise web.HTTPBadRequest()
format: str = post.get('format')
folder: str = post.get('folder')
ytdlp_cookies: str = post.get('ytdlp_cookies')
ytdlp_config: dict = post.get('ytdlp_config')
@ -209,8 +202,7 @@ class HttpAPI(common):
status = await self.add(
url=url,
quality=quality,
format=format,
preset=preset,
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
@ -251,8 +243,7 @@ class HttpAPI(common):
status[item.get('url')] = await self.add(
url=item.get('url'),
quality=item.get('quality'),
format=item.get('format'),
preset=item.get('preset', 'default'),
folder=item.get('folder'),
ytdlp_cookies=item.get('ytdlp_cookies'),
ytdlp_config=item.get('ytdlp_config'),
@ -519,9 +510,42 @@ class HttpAPI(common):
@route('GET', '/')
async def index(self, _) -> Response:
if '/index.html' not in self.staticHolder:
LOG.error("Static frontend files not found.")
return web.json_response({"error": "File not found.", "file": '/index.html'}, status=404)
data = self.staticHolder['/index.html']
return web.Response(
body=data.get('content'),
content_type=data.get('content_type'),
charset='utf-8',
status=web.HTTPOk.status_code)
@route('GET', '/thumbnail')
async def get_thumbnail(self, request: Request) -> dict:
url = request.query.get('url')
if not url:
return web.json_response({"error": "URL is required."}, status=400)
try:
opts = {
'proxy': self.config.ytdl_options.get('proxy', None),
'headers': {
'User-Agent': self.config.ytdl_options.get(
'user_agent',
request.headers.get('User-Agent', f"YTPTube/{self.config.version}")),
},
}
async with httpx.AsyncClient(**opts) as client:
LOG.info(f"Fetching thumbnail from '{url}'.")
response = await client.request(method='GET', url=url)
return web.Response(body=response.content, headers={
'Content-Type': response.headers.get('Content-Type'),
'Pragma': 'public',
'Access-Control-Allow-Origin': '*',
'Cache-Control': f"public, max-age={time.time() + 31536000}",
'Expires': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(time.time() + 31536000).timetuple()),
})
except Exception as e:
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'")
return web.json_response({"error": str(e)}, status=500)

View file

@ -8,7 +8,6 @@ import shlex
from aiohttp import web
import socketio
import logging
import sqlite3
from .config import Config
from .DownloadQueue import DownloadQueue
from .common import common
@ -24,9 +23,7 @@ class HttpSocket(common):
This class is used to handle WebSocket events.
"""
config: Config = None
app: web.Application = None
sio: socketio.AsyncServer = None
connection: sqlite3.Connection = None
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
super().__init__(queue=queue, encoder=encoder)
@ -149,13 +146,12 @@ class HttpSocket(common):
@ws_event
async def add_url(self, sid: str, data: dict):
url: str = data.get('url')
quality: str = data.get('quality')
if not url:
self.emitter.warning('No URL provided.', to=sid)
return
format: str = data.get('format')
preset: str = data.get('preset', 'default')
folder: str = data.get('folder')
ytdlp_cookies: str = data.get('ytdlp_cookies')
ytdlp_config: dict = data.get('ytdlp_config')
@ -165,8 +161,7 @@ class HttpSocket(common):
status = await self.add(
url=url,
quality=quality,
format=format,
preset=preset,
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
@ -234,18 +229,17 @@ class HttpSocket(common):
@ws_event
async def connect(self, sid: str, _=None):
data: dict = {
data = {
**self.queue.get(),
"config": self.config.frontend(),
"tasks": self.config.tasks,
"presets": [],
"directories": [],
"presets": self.config.presets,
}
# get directory listing
dlDir: str = self.config.download_path
data['directories'] = [
name for name in os.listdir(dlDir) if os.path.isdir(os.path.join(dlDir, name))
# get download folder listing
downloadPath: str = self.config.download_path
data['folders'] = [
name for name in os.listdir(downloadPath) if os.path.isdir(os.path.join(downloadPath, name))
]
await self.emitter.emit('initial_data', data, to=sid)

View file

@ -13,8 +13,9 @@ class ItemDTO:
id: str
title: str
url: str
quality: str
format: str
quality: str = None
format: str = None
thumbnail: str = None
preset: str = "default"
folder: str
download_dir: str = None

View file

@ -6,99 +6,56 @@ import os
import pathlib
import re
from typing import Any
import uuid
import yt_dlp
LOG = logging.getLogger('Utils')
AUDIO_FORMATS: tuple[str] = ('m4a', 'mp3', 'opus', 'wav',)
IGNORED_KEYS: tuple[str] = ('cookiefile', 'paths', 'outtmpl', 'progress_hooks', 'postprocessor_hooks',)
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
def get_format(format: str, quality: str) -> str:
def get_opts(preset: str, ytdl_opts: dict) -> dict:
"""
Returns format for download
Returns ytdlp options download options
Args:
format (str): format selected
quality (str): quality selected
Raises:
Exception: unknown quality, unknown format
Returns:
dl_format: Formatted download string
"""
format = format or "any"
if format.startswith("custom:"):
return format[7:]
if format == "thumbnail":
# Quality is irrelevant in this case since we skip the download
return "bestaudio/best"
if format in AUDIO_FORMATS:
# Audio quality needs to be set post-download, set in opts
return "bestaudio/best"
if format in ('mp4', 'any'):
if quality == 'audio':
return "bestaudio/best"
videoFormat, audioFormat = ('[ext=mp4]', '[ext=m4a]') if format == "mp4" else ('', '')
videoResolution = f'[height<={quality}]' if quality != 'best' else ''
videoCombo = videoResolution + videoFormat
return f'bestvideo{videoCombo}+bestaudio{audioFormat}/best{videoCombo}'
raise Exception(f"Unknown format {format}")
def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
"""
Returns extra download options
Mostly postprocessing options
Args:
format (str): format selected
quality (str): quality of format selected (needed for some formats)
preset (str): the name of the preset selected.
ytdl_opts (dict): current options selected
Returns:
ytdl_opts: Extra options
ytdl extra options
"""
opts = copy.deepcopy(ytdl_opts)
if not opts:
opts: dict = {
"postprocessors": [],
}
postprocessors = []
if 'default' == preset:
return opts
if format in AUDIO_FORMATS:
postprocessors.append({
"key": "FFmpegExtractAudio",
"preferredcodec": format,
"preferredquality": 0 if quality == "best" else quality,
})
from .config import Config
presets = Config.get_instance().presets
# Audio formats without thumbnail
if format not in ("wav") and "writethumbnail" not in opts:
opts["writethumbnail"] = True
postprocessors.append({"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"})
postprocessors.append({"key": "FFmpegMetadata"})
postprocessors.append({"key": "EmbedThumbnail"})
if preset not in presets:
LOG.error(f"Preset '{preset}' is not defined in the presets.")
return opts
if format == "thumbnail":
opts["skip_download"] = True
opts["writethumbnail"] = True
postprocessors.append({"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"})
preset_opts = presets[preset]
opts['format'] = preset_opts.get('format')
if 'postprocessors' in preset_opts:
if 'postprocessors' not in opts:
opts['postprocessors'] = []
opts['postprocessors'].extend(preset_opts['postprocessors'])
if 'args' in preset_opts:
for ignored_key in IGNORED_KEYS:
for key, value in preset_opts['args'].items():
if key == ignored_key:
continue
opts[key] = value
opts["postprocessors"] = postprocessors + (opts["postprocessors"] if "postprocessors" in opts else [])
return opts
@ -116,10 +73,10 @@ def getVideoInfo(url: str, ytdlp_opts: dict = None) -> (Any | dict[str, Any] | N
def calcDownloadPath(basePath: str, folder: str = None, createPath: bool = True) -> str:
"""Calculates download path and prevents directory traversal.
"""Calculates download path and prevents folder traversal.
Returns:
Download path with base directory factored in.
Download path with base folder factored in.
"""
if not folder:
return basePath
@ -128,7 +85,7 @@ def calcDownloadPath(basePath: str, folder: str = None, createPath: bool = True)
download_path = os.path.realpath(os.path.join(basePath, folder))
if not download_path.startswith(realBasePath):
raise Exception(f'Folder "{folder}" must resolve inside the base download directory "{realBasePath}".')
raise Exception(f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".')
if not os.path.isdir(download_path) and createPath:
os.makedirs(download_path, exist_ok=True)
@ -348,3 +305,57 @@ def checkId(basePath: str, file: pathlib.Path) -> bool | str:
return f.absolute()
return False
def get_value(value):
return value() if callable(value) else value
def ag(array: dict | list, path: list[str | int] | str | int, default: Any = None, separator: str = '.') -> Any:
"""
dict/array getter: Retrieve a value from a nested dict or object using a path.
:param array_or_object: dict-like or object from which to retrieve values
:param path: string, list, or None. Represents the path to retrieve:
- If None or empty string, returns the entire structure.
- If list, tries each path and returns the first found.
- If string, navigates through nested dict keys separated by `separator`.
:param default: Value (or callable) returned if nothing is found.
:param separator: Separator for nested paths in strings.
:return: The found value or the default if not found.
"""
if path is None or path == '':
return array
if not isinstance(array, dict):
try:
array = vars(array)
except TypeError:
array = dict(array)
if isinstance(path, list):
randomValue = str(uuid.uuid4())
for key in path:
val = ag(array, key, randomValue, separator)
if val != randomValue:
return val
return get_value(default)
if path in array and array[path] is not None:
return array[path]
if separator not in path:
return array.get(path, get_value(default))
keys = path.split(separator)
current = array
for segment in keys:
if isinstance(current, dict) and segment in current:
current = current[segment]
else:
return get_value(default)
return current

View file

@ -7,6 +7,7 @@ from .version import APP_VERSION
LOG = logging.getLogger('Webhooks')
class Webhooks:
targets: list[dict] = []
allowed_events: tuple = ('added', 'completed', 'error', 'not_live',)
@ -17,10 +18,10 @@ class Webhooks:
def load(self, file: str):
try:
if os.path.getsize(file) < 2:
if os.path.getsize(file) < 3:
raise Exception(f'file is empty.')
LOG.info(f'Loading webhooks from {file}')
LOG.info(f"Loading webhooks from '{file}'.")
from .Utils import load_file
(target, status, error) = load_file(file, list)
if not status:
@ -28,7 +29,7 @@ class Webhooks:
self.targets = target
except Exception as e:
LOG.error(f'Error loading webhooks from {file}: {e}')
LOG.error(f"Error loading webhooks from '{file}'. '{e}'")
pass
async def send(self, event: str, item: ItemDTO) -> list[dict]:
@ -53,7 +54,7 @@ class Webhooks:
from .config import Config
req: dict = target.get('request')
try:
LOG.info(f"Sending {event=} {item.id=} to [{target.get('name')}]")
LOG.info(f"Sending event '{event}' id '{item.id}' to '{target.get('name')}'.")
async with httpx.AsyncClient() as client:
request_type = req.get('type', 'json')
@ -84,14 +85,15 @@ class Webhooks:
'text': response.text
}
msg = f"[{target.get('name')}] Response to [{event=} {item.id=}] [status: {response.status_code}]."
msg = f"Webhook target '{target.get('name')}' Responded to event '{event}' id '{item.id}' with status '{response.status_code}'."
if Config.get_instance().debug and respData.get('text'):
msg += f" [Body: {respData.get('text','??')}]"
msg += f" body '{respData.get('text','??')}'."
LOG.info(msg)
return respData
except Exception as e:
LOG.error(f"Error sending event '{event}' id '{item.id}' to '{target.get('name')}'. '{e}'")
return {
'url': req.get('url'),
'status': 500,

View file

@ -17,16 +17,8 @@ class common():
self.queue = queue
self.encoder = encoder
async def add(
self,
url: str,
quality: str,
format: str,
folder: str,
ytdlp_cookies: str,
ytdlp_config: dict,
output_template: str
) -> dict[str, str]:
async def add(self, url: str, preset: str, folder: str, ytdlp_cookies: str, ytdlp_config: dict,
output_template: str) -> dict[str, str]:
if ytdlp_config is None:
ytdlp_config = {}
@ -35,8 +27,7 @@ class common():
status = await self.queue.add(
url=url,
format=format if format else 'any',
quality=quality if quality else 'best',
preset=preset if preset else 'default',
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,

View file

@ -65,11 +65,50 @@ class Config:
ytdlp_version: str = YTDLP_VERSION
tasks: list = []
presets: list = [
{
'name': 'Default - Use default yt-dlp format',
'format': 'default',
'postprocessors': [],
'args': {}
},
# {
# 'name': 'Audio - Best audio [MP3]',
# 'format': 'bestaudio',
# 'postprocessors': [
# {'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': 'best'},
# {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"},
# {'key': 'FFmpegMetadata'},
# {'key': 'EmbedThumbnail'},
# ],
# 'args': {
# 'writethumbnail': True
# }
# },
{
'name': 'Video - 1080p h264/acc',
'format': 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]',
'args': {
'format_sort': [
'vcodec:h264'
],
},
},
{
'name': 'Video - 720p h264/acc',
'format': 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]',
'args': {
'format_sort': [
'vcodec:h264'
],
},
},
]
_manual_vars: tuple = ('temp_path', 'config_path', 'download_path',)
_immutable: tuple = (
'version', '__instance', 'ytdl_options', 'tasks',
'new_version_available', 'ytdlp_version', 'started',
'new_version_available', 'ytdlp_version', 'started', 'presets',
)
_int_vars: tuple = ('port', 'max_workers', 'socket_timeout', 'extract_info_timeout', 'debugpy_port',)
@ -77,7 +116,7 @@ class Config:
_frontend_vars: tuple = (
'download_path', 'keep_archive', 'output_template',
'ytdlp_version', 'url_host', 'url_prefix',
'ytdlp_version', 'version', 'url_host', 'started', 'url_prefix',
)
@staticmethod
@ -192,6 +231,34 @@ class Config:
except Exception as e:
pass
presetsFile = os.path.join(self.config_path, 'presets.json')
if os.path.exists(presetsFile) and os.path.getsize(presetsFile) > 0:
LOG.info(f"Loading extra presets from '{presetsFile}'.")
try:
(presets, status, error) = load_file(presetsFile, list)
if not status:
LOG.error(f"Could not load presets file from '{presetsFile}'. '{error}'.")
sys.exit(1)
for preset in presets:
if 'name' not in preset:
LOG.error(f"Missing 'name' key in preset '{preset}'.")
continue
if 'format' not in preset:
LOG.error(f"Missing 'format' key in preset '{preset}'.")
continue
if 'args' not in preset:
preset['args'] = {}
if 'postprocessors' not in preset:
preset['postprocessors'] = []
self.presets.append(preset)
except Exception as e:
pass
self.ytdl_options['socket_timeout'] = self.socket_timeout
if self.keep_archive:

View file

@ -8,8 +8,8 @@ class Encoder(json.JSONEncoder):
will call the __dict__ method of an object if it exists.
"""
def default(self, obj):
if isinstance(obj, object) and hasattr(obj, '__dict__'):
return obj.__dict__
def default(self, o):
if isinstance(o, object) and hasattr(o, '__dict__'):
return o.__dict__
return json.JSONEncoder.default(self, obj)
return json.JSONEncoder.default(self, o)

View file

@ -1 +1,6 @@
"""
Version of the application,
This file is updated by the CI/CD pipeline, do not edit it manually.
"""
APP_VERSION = "dev-master"
"Application version"

View file

@ -35,7 +35,7 @@ class main:
self.app = web.Application()
self.encoder = Encoder()
self.checkDirectories()
self.checkFolders()
caribou.upgrade(self.config.db_file, os.path.join(self.rootPath, "migrations"))
connection = sqlite3.connect(database=self.config.db_file, isolation_level=None)
@ -54,7 +54,7 @@ class main:
if os.path.exists(WebhookFile):
emitter.add_emitter(Webhooks(WebhookFile).emit)
def checkDirectories(self) -> None:
def checkFolders(self) -> None:
try:
LOG.debug(f"Checking download folder at '{self.config.download_path}'.")
if not os.path.exists(self.config.download_path):
@ -101,8 +101,7 @@ class main:
LOG.info(f"Started 'Task: {taskName}' at '{timeNow}'.")
await self.socket.add(
url=task.get("url"),
quality=task.get("quality", "best"),
format=task.get("format", "any"),
preset=task.get("preset", "default"),
folder=task.get("folder"),
ytdlp_cookies=task.get("ytdlp_cookies"),
ytdlp_config=task.get("ytdlp_config"),

View file

@ -0,0 +1,112 @@
<template>
<vTooltip @show="loadContent" @hide="stopTimer">
<slot />
<template #popper>
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin"></i></span>
<template v-else>
<div>
<h1 v-if="props.title" class="is-4">{{ props.title }}</h1>
<img :src="url" style="width:720px; height: auto" class="card-image" :alt="props.title" @error="clearCache"
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
</div>
</template>
</template>
</vTooltip>
</template>
<script setup>
const props = defineProps({
image: {
type: String,
required: false,
},
title: {
type: String,
required: false
},
loader: {
Type: Function,
required: false,
},
privacy: {
type: Boolean,
required: false,
default: true
}
});
const cache = useSessionCache()
const toast = useToast()
const config = useConfigStore()
const url = ref()
const error = ref(false)
const isPreloading = ref(false)
let loadTimer = null;
const cancelRequest = new AbortController();
const defaultLoader = async () => {
try {
if (cache.has(props.image)) {
url.value = cache.get(props.image)
return
}
const response = await fetch(config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(props.image), {
signal: cancelRequest.signal
})
if (200 !== response.status) {
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`)
return;
}
const objUrl = URL.createObjectURL(await response.blob());
cache.set(props.image, objUrl)
url.value = objUrl;
} catch (e) {
if ('not_needed' === e) {
return
}
console.error(e)
toast.error(`ImageView Request failure. ${e}`)
} finally {
isPreloading.value = false
}
}
const stopTimer = async () => {
if (error.value) {
return
}
if (url.value) {
isPreloading.value = false
url.value = null;
return;
}
await awaiter(() => isPreloading.value)
clearTimeout(loadTimer)
isPreloading.value = false
url.value = null;
cancelRequest.abort('not_needed')
}
const loadContent = async () => {
if (props.loader) {
return props.loader()
}
return defaultLoader()
}
const clearCache = async () => {
cache.remove(props.image)
url.value = '';
return loadContent()
}
</script>

View file

@ -79,20 +79,44 @@
<div class="card"
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
<header class="card-header has-tooltip">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<a v-if="item.filename" referrerpolicy="no-referrer" :href="makeDownload(config, item, 'm3u8')"
@click.prevent="$emit('playItem', item)">
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
<div class="card-header-icon" v-if="item.filename">
<a :href="makeDownload(config, item)" :download="item.filename?.split('/').reverse()[0]"
class="has-text-primary" v-tooltip="'Download item.'">
<NuxtLink :href="makeDownload(config, item)" :download="item.filename?.split('/').reverse()[0]"
class="has-text-primary" v-tooltip="'Download item.'" v-if="item.status === 'finished'">
<span class="icon"><i class="fa-solid fa-download" /></span>
</a>
</NuxtLink>
<button @click="hideThumbnail = !hideThumbnail">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button>
</div>
</header>
<div v-if="item.thumbnail && false === hideThumbnail" class="card-image">
<figure class="image is-3by1">
<template v-if="item.status === 'finished'">
<NuxtLink v-tooltip="`Play: ${item.title}`" :href="makeDownload(config, item, 'm3u8')"
@click.prevent="playVideo(item)">
<img
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.thumbnail)"
:alt="item.title" />
</NuxtLink>
</template>
<template v-else>
<NuxtLink target="_blank" :href="item.url" v-tooltip="`Open: ${item.title} link`">
<img
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.thumbnail)"
:alt="item.title" />
</NuxtLink>
</template>
</figure>
</div>
<div class="card-content">
<div class="columns is-mobile is-multiline">
<div class="column is-12" v-if="item.live_in">
@ -106,7 +130,7 @@
<div class="column is-12" v-if="showMessage(item)">
<span class="has-text-danger">{{ item.msg }}</span>
</div>
<div class="column is-half-mobile has-text-centered">
<div class="column is-half-mobile has-text-centered is-text-overflow">
<span v-if="!item.live_in && !item.is_live">
<span class="icon-text">
<span class="icon"
@ -126,19 +150,20 @@
</span>
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<div class="column is-half-mobile has-text-centered is-text-overflow">
<span class="user-hint" :date-datetime="item.datetime"
v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
{{ moment(item.datetime).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered" v-if="item.live_in && item.status != 'finished'">
<div class="column is-half-mobile has-text-centered is-text-overflow"
v-if="item.live_in && item.status != 'finished'">
<span :date-datetime="item.datetime"
v-tooltip="'Will start at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')">
{{ moment(item.live_in).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered" v-if="item.file_size">
<div class="column is-half-mobile has-text-centered is-text-overflow" v-if="item.file_size">
{{ formatBytes(item.file_size) }}
</div>
<div class="column is-half-mobile has-text-centered">
@ -176,14 +201,13 @@
</a>
</div>
<div class="column is-half-mobile">
<a referrerpolicy="no-referrer" class="button is-link is-fullwidth" target="_blank" :href="item.url">
<button class="button is-link is-fullwidth" target="_blank" @click="copyText(item.url)"
v-tooltip="'Copy url to clipboard.'">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-up-right-from-square" />
</span>
<span>Visit Link</span>
<span class="icon"><i class="fa-solid fa-copy" /></span>
<span>Copy</span>
</span>
</a>
</button>
</div>
</div>
</div>
@ -209,6 +233,15 @@
</span>
</p>
</div>
<div class="modal is-active" v-if="video_link">
<div class="modal-background"></div>
<div class="modal-content">
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
:title="video_title" class="is-fullwidth" @closeModel="video_link = ''; video_title = ''" />
</div>
<button class="modal-close is-large" aria-label="close" @click="video_link = ''"></button>
</div>
</div>
</template>
@ -225,8 +258,17 @@ const socket = useSocketStore()
const selectedElms = ref([]);
const masterSelectAll = ref(false);
const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnail', false)
const direction = useStorage('sortCompleted', 'desc')
const video_link = ref('')
const video_title = ref('')
const playVideo = item => {
video_link.value = makeDownload(config, item, 'm3u8');
video_title.value = item.title;
}
watch(masterSelectAll, (value) => {
for (const key in stateStore.history) {
const element = stateStore.history[key];

View file

@ -29,13 +29,13 @@
</div>
</div>
<div class="column is-5">
<div class="field has-addons" v-tooltip="'Download path relative to ' + config.app.download_path">
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
<div class="control">
<a href="#" class="button is-static">Download Path</a>
<a href="#" class="button is-static">Save in</a>
</div>
<div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="downloadPath" placeholder="Default"
:disabled="!socket.isConnected" list="directories">
:disabled="!socket.isConnected" list="folders">
</div>
</div>
</div>
@ -64,11 +64,11 @@
</label>
<div class="control">
<input type="text" class="input" v-model="output_template" id="output_format"
placeholder="Uses default format if non is given.">
placeholder="Uses default output format naming if empty.">
</div>
<span class="subtitle is-6 has-text-info">
All format options can be found at <a class="has-text-danger" target="_blank"
referrerpolicy="no-referrer" href="https://github.com/yt-dlp/yt-dlp#output-template">this page</a>.
<span class="subtitle is-6">
All output format naming options can be found at <NuxtLink target="_blank" class="has-text-danger"
href="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.
</span>
</div>
</div>
@ -82,11 +82,12 @@
<textarea class="textarea" id="ytdlpConfig" v-model="ytdlpConfig"
:disabled="!socket.isConnected"></textarea>
</div>
<span class="subtitle is-6 has-text-info">
<span class="subtitle is-6">
Some config fields are ignored like cookiefile, path, and output_format etc.
Available option can be found at <a class="has-text-danger" target="_blank" referrerpolicy="no-referrer"
href="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">this
page</a>.
Available option can be found at <NuxtLink class="has-text-danger" target="_blank"
href="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">
this page</NuxtLink>. Warning: Use with caution some of those options can break yt-dlp or the
frontend.
</span>
</div>
</div>
@ -99,9 +100,9 @@
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies"
:disabled="!socket.isConnected"></textarea>
</div>
<span class="subtitle is-6 has-text-info">
Use something like <a class="has-text-danger" href="https://github.com/jrie/flagCookies">flagCookies</a>
to extract cookies as JSON string.
<span class="subtitle is-6">
Use something like <NuxtLink target="_blank" class="has-text-danger"
href="https://github.com/jrie/flagCookies">flagCookies</NuxtLink> to extract cookies as JSON string.
</span>
</div>
</div>
@ -121,8 +122,8 @@
</div>
</div>
</div>
<datalist id="directories" v-if="config?.directories">
<option v-for="dir in config.directories" :key="dir" :value="dir" />
<datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
</main>
</template>
@ -134,20 +135,18 @@ const config = useConfigStore();
const socket = useSocketStore();
const toast = useToast();
const selectedFormat = useStorage('selectedFormat', 'any')
const selectedPreset = useStorage('selectedPreset', 'default')
const selectedQuality = useStorage('selectedQuality', '')
const ytdlpConfig = useStorage('ytdlp_config', '')
const ytdlpCookies = useStorage('ytdlp_cookies', '')
const output_template = useStorage('output_template', null)
const downloadPath = useStorage('downloadPath', null)
const url = useStorage('downloadUrl', null)
const showAdvanced = useStorage('show_advanced', false)
const addInProgress = ref(false)
const addDownload = () => {
addInProgress.value = true;
socket.emit('add_url', {
url: url.value,
preset: selectedPreset.value,
@ -159,15 +158,19 @@ const addDownload = () => {
}
const resetConfig = () => {
if (!confirm('Are you sure you want to reset the local configuration? this will NOT delete any downloads or server configuration.')) {
if (true !== confirm('Reset your local configuration?')) {
return;
}
selectedPreset.value = 'default';
ytdlpConfig.value = '';
ytdlpCookies.value = '';
output_template.value = null;
url.value = '';
downloadPath.value = '';
showAdvanced.value = false;
toast.success('Local configuration has been reset.');
}
const statusHandler = async data => {

View file

@ -42,7 +42,21 @@
<div class="card-header-title has-text-centered is-text-overflow is-block">
{{ item.title }}
</div>
<div class="card-header-icon" v-if="item.filename">
<button @click="hideThumbnail = !hideThumbnail">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button>
</div>
</header>
<div v-if="item.thumbnail && false === hideThumbnail" class="card-image">
<figure class="image is-3by1">
<NuxtLink v-tooltip="item.title" :href="item.url" target="_blank">
<img :src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.thumbnail)"
:alt="item.title" />
</NuxtLink>
</figure>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
@ -51,7 +65,7 @@
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div>
</div>
</div>
<div class="column is-half-mobile has-text-centered">
<div class="column is-half-mobile has-text-centered is-text-overflow">
<span class="icon-text">
<span class="icon" :class="{ 'has-text-success': item.status == 'downloading' }">
<i class="fas" :class="setIcon(item)" />
@ -60,13 +74,13 @@
<span v-else>{{ ucFirst(item.status) }}</span>
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<div class="column is-half-mobile has-text-centered is-text-overflow">
<span :data-datetime="item.datetime"
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.datetime).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<div class="column is-half-mobile has-text-centered is-text-overflow">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
@ -135,6 +149,7 @@ const socket = useSocketStore();
const selectedElms = ref([]);
const masterSelectAll = ref(false);
const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnail', false)
watch(masterSelectAll, (value) => {
for (const key in stateStore.queue) {

View file

@ -0,0 +1,137 @@
<style>
:root {
--plyr-captions-background: rgba(0, 0, 0, 0.6);
--plyr-captions-text-color: #f3db4d;
--webkit-text-track-display: none;
}
.plyr__caption {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
font-size: 140%;
font-weight: bold;
}
.plyr--full-ui ::-webkit-media-text-track-container {
display: var(--webkit-text-track-display);
}
</style>
<template>
<div>
<video ref="video" :poster="previewImageLink" :controls="isControls" :title="title" playsinline>
<source :src="link" type="application/x-mpegURL" />
</video>
</div>
</template>
<script setup>
import { onMounted, onUpdated, ref, defineProps, defineEmits, onUnmounted } from 'vue'
import Hls from 'hls.js'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const props = defineProps({
previewImageLink: {
type: String,
default: ''
},
link: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
isControls: {
type: Boolean,
default: true
},
})
const emitter = defineEmits(['closeModel'])
const video = ref(null)
let player = null;
let hls = null;
const eventFunc = e => {
if (e.key === 'Escape') {
emitter('closeModel')
}
}
onMounted(() => {
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
}
prepareVideoPlayer()
window.addEventListener('keydown', eventFunc)
})
onUpdated(() => prepareVideoPlayer())
onUnmounted(() => {
if (player) {
player.destroy()
}
if (hls) {
hls.destroy()
}
window.removeEventListener('keydown', eventFunc)
if (props.title) {
window.document.title = 'YTPTube'
}
})
const prepareVideoPlayer = () => {
player = new Plyr(video.value, {
debug: false,
clickToPlay: true,
keyboard: { focused: true, global: true },
controls: [
'play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'
],
fullscreen: {
enabled: true,
fallback: true,
iosNative: true,
},
storage: {
enabled: true,
key: 'plyr'
},
title: props.title,
mediaMetadata: {
title: props.title
},
captions: {
update: true,
}
});
hls = new Hls({
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
fragLoadingTimeOut: 200000,
});
hls.loadSource(props.link)
if (video.value) {
hls.attachMedia(video.value)
}
if (props.title) {
window.document.title = `YTPTube - Playing: ${props.title}`
}
}
</script>

View file

@ -31,11 +31,10 @@
</div>
<div class="navbar-item" v-if="config.tasks.length > 0">
<button v-tooltip.bottom="'Toggle Tasks'" class="button is-dark has-tooltip-bottom"
@click="config.showTasks = !config.showTasks">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span class="is-hidden-mobile">Tasks</span>
</button>
</NuxtLink>
</div>
<div class="navbar-item">
@ -63,9 +62,9 @@
<div class="columns mt-3 is-mobile">
<div class="column is-8-mobile">
<div class="has-text-left" v-if="config.app?.version">
© {{ Year }} - <a href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</a>
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="is-hidden-mobile">&nbsp;({{ config.app?.version || 'unknown' }})</span>
- <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span class="is-hidden-mobile">&nbsp;({{ config?.app.ytdlp_version || 'unknown' }})</span>
</div>
</div>
@ -78,13 +77,10 @@
</div>
</div>
</div>
<NuxtNotifications position="top right" :speed="800" :ignoreDuplicates="true" :width="340" :pauseOnHover="true" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import 'assets/css/bulma.css'
import 'assets/css/style.css'
import 'assets/css/all.css'
@ -93,7 +89,6 @@ import moment from "moment";
const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')())
const bus = useEventBus('show_form')
const socket = useSocketStore()
const config = useConfigStore()

View file

@ -44,7 +44,6 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@vueuse/nuxt',
'floating-vue/nuxt',
'nuxt3-notifications',
],
nitro: {

View file

@ -1,32 +1,33 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cronstrue": "^2.49.0",
"floating-vue": "^5.2.2",
"moment": "^2.30.1",
"nuxt": "^3.11.2",
"nuxt3-notifications": "^1.2.0",
"pinia": "^2.1.7",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"vue-toastification": "^2.0.0-rc.5"
},
"devDependencies": {}
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cronstrue": "^2.49.0",
"floating-vue": "^5.2.2",
"moment": "^2.30.1",
"nuxt": "^3.11.2",
"pinia": "^2.1.7",
"plyr": "^3.7.8",
"hls.js": "^1.4.12",
"socket.io-client": "^4.7.2",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"vue-toastification": "^2.0.0-rc.5",
"cron-parser": "^4.9.0"
},
"devDependencies": {}
}

View file

@ -1,8 +1,13 @@
<template>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix">
<h1 class="title is-4">Terminal</h1>
<div class="subtitle is-6">
<h1 class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Terminal</span>
</span>
</h1>
<div class="subtitle is-6 is-unselectable">
You can use this terminal window to execute non-interactive commands. The interface is jailed to the
<code>yt-dlp</code>
</div>

View file

@ -7,7 +7,4 @@
<script setup>
useHead({ title: 'Index' })
const config = useConfigStore()
</script>

68
ui/pages/tasks.vue Normal file
View file

@ -0,0 +1,68 @@
<template>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix">
<h1 class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span>
</span>
</h1>
<div class="subtitle is-6 is-unselectable">
This page shows the registered tasks, which are scheduled to run at the specific time.
</div>
</div>
<div class="column is-12">
<div class="table-container">
<table class="table is-bordered is-narrow is-hoverable is-striped is-fullwidth is-fixed">
<thead class="has-text-centered">
<tr>
<th class="has-text-centered" width="25%">Name</th>
<th class="has-text-centered" width="50%">URL</th>
<th class="has-text-centered" width="25%">Next run</th>
</tr>
</thead>
<tbody>
<tr v-for="item, key in config.tasks" :key="key">
<td class="is-text-overflow has-text-centered" v-if="item.name">{{ item.name }}</td>
<td class="has-text-centered" v-else>Not set</td>
<td class="is-text-overflow">
<a :href="item.url" target="_blank">
{{ item.url }}
</a>
</td>
<td class="has-text-centered" v-if="item.timer">
<span v-tooltip="'Timer: ' + item.timer">
{{ moment(parseExpression(item.timer).next().toISOString()).fromNow() }}
</span>
</td>
<td class="has-text-centered" v-else>once an hour</td>
</tr>
<tr v-if="config.tasks.length < 1">
<td colspan="4" class="has-text-centered has-text-danger">
No tasks are defined.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import moment from 'moment'
import { parseExpression } from 'cron-parser'
const config = useConfigStore()
console.log(config.tasks)
</script>
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
</style>

View file

@ -5,16 +5,19 @@ const CONFIG_KEYS = {
keep_archive: false,
output_template: '',
ytdlp_version: '',
version: '',
url_host: '',
url_prefix: '',
},
presets: [
{
name: 'Default - Use Predefined yt-dlp Format',
format: 'default',
'name': 'Default - Use default yt-dlp format',
'format': 'default',
'postprocessors': [],
'args': {}
}
],
directories: [],
folders: [],
tasks: [],
};

View file

@ -22,7 +22,8 @@ export const useSocketStore = defineStore('socket', () => {
config.setAll({
app: initialData['config'],
tasks: initialData['tasks'],
directories: initialData['directories'],
folders: initialData['folders'],
presets: initialData['presets'],
})
stateStore.addAll('queue', initialData['queue'] ?? {})

View file

@ -1,7 +1,6 @@
import { useNotification } from '@kyvg/vue3-notification'
import { useStorage } from '@vueuse/core'
const { notify } = useNotification()
const toast = useToast()
const AG_SEPARATOR = '.'
/**
@ -94,44 +93,6 @@ const awaitElement = (sel, callback) => {
}, 200)
}
/**
* Display a notification
*
* @param {string} type The type of the notification.
* @param {string} title The title of the notification.
* @param {string} text The text of the notification.
* @param {number} duration The duration of the notification.
*
* @returns {void}
*/
const notification = (type, title, text, duration = 3000) => {
let classes = ''
const notificationType = type.toLowerCase()
switch (notificationType) {
case 'info':
default:
classes = 'has-background-info has-text-white'
break
case 'success':
classes = 'has-background-success has-text-white'
break
case 'warning':
classes = 'has-background-warning has-text-white'
break
case 'error':
case 'crit':
classes = 'has-background-danger has-text-white'
if (3000 === duration) {
duration = 10000
}
break
}
return notify({ title, text, type: classes, duration })
}
/**
* Replace tags in text with values from context
*
@ -170,12 +131,12 @@ const copyText = (str, notify = true) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => {
if (notify) {
notification('success', 'Success', 'Text copied to clipboard.')
toast.success('Text copied to clipboard.')
}
}).catch((error) => {
console.error('Failed to copy.', error)
if (notify) {
notification('error', 'Error', 'Failed to copy to clipboard.')
toast.error('Failed to copy to clipboard.')
}
})
return
@ -189,7 +150,7 @@ const copyText = (str, notify = true) => {
document.body.removeChild(el)
if (notify) {
notification('success', 'Success', 'Text copied to clipboard.')
toast.success('Text copied to clipboard.')
}
}
@ -420,7 +381,6 @@ export {
ag_set,
ag,
awaitElement,
notification,
copyText,
dEvent,
makePagination,

View file

@ -29,7 +29,7 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.3.tgz#99488264a56b2aded63983abd6a417f03b92ed02"
integrity sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==
"@babel/core@^7.23.0", "@babel/core@^7.25.7", "@babel/core@^7.26.0":
"@babel/core@^7.23.0", "@babel/core@^7.26.0":
version "7.26.0"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.0.tgz#d78b6023cc8f3114ccf049eb219613f74a747b40"
integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==
@ -231,7 +231,7 @@
"@babel/helper-skip-transparent-expression-wrappers" "^7.25.9"
"@babel/plugin-syntax-typescript" "^7.25.9"
"@babel/standalone@^7.25.7":
"@babel/standalone@^7.26.4":
version "7.26.4"
resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.26.4.tgz#440d8046929174b463e2c6294fc8f6d49fdea550"
integrity sha512-SF+g7S2mhTT1b7CHyfNjDkPU1corxg4LPYsyP0x5KuCl+EbtBQHRLqr9N3q7e7+x7NQ5LYxQf8mJ2PmzebLr0A==
@ -258,7 +258,7 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/types@^7.25.4", "@babel/types@^7.25.6", "@babel/types@^7.25.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3":
"@babel/types@^7.25.4", "@babel/types@^7.25.6", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3":
version "7.26.3"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.3.tgz#37e79830f04c2b5687acc77db97fbc75fb81f3c0"
integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==
@ -603,11 +603,6 @@
resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919"
integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==
"@kyvg/vue3-notification@^3.2.1":
version "3.4.1"
resolved "https://registry.yarnpkg.com/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz#0a80b2db784cc41ba6279c208a9e653ce70abaad"
integrity sha512-WhTWCbF36JHLJR5UdKmJF7KXGOGVy4tLeaJuKTHZhwttZWnbF9w1/c2d32tvCSwY9CdeX/n9uoaKWLMKK3vOyg==
"@mapbox/node-pre-gyp@^2.0.0-rc.0":
version "2.0.0-rc.0"
resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0-rc.0.tgz#4390439d79e30bba0a4dccb230723e359505c8b7"
@ -998,9 +993,9 @@
integrity sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==
"@rollup/plugin-commonjs@^28.0.1":
version "28.0.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz#e2138e31cc0637676dc3d5cae7739131f7cd565e"
integrity sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==
version "28.0.2"
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.2.tgz#193d7a86470f112b56927c1d821ee45951a819ea"
integrity sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==
dependencies:
"@rollup/pluginutils" "^5.0.1"
commondir "^1.0.1"
@ -1027,9 +1022,9 @@
"@rollup/pluginutils" "^5.1.0"
"@rollup/plugin-node-resolve@^15.3.0":
version "15.3.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz#efbb35515c9672e541c08d59caba2eff492a55d5"
integrity sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==
version "15.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz#66008953c2524be786aa319d49e32f2128296a78"
integrity sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==
dependencies:
"@rollup/pluginutils" "^5.0.1"
"@types/resolve" "1.20.2"
@ -1038,9 +1033,9 @@
resolve "^1.22.1"
"@rollup/plugin-replace@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-6.0.1.tgz#547e238f7db994ebe63dd5329ec46ffccf696029"
integrity sha512-2sPh9b73dj5IxuMmDAsQWVFT7mR+yoHweBaXG2W/R8vQ+IWZlnaI7BR7J6EguVQUp1hd8Z7XuozpDjEKQAAC2Q==
version "6.0.2"
resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz#2f565d312d681e4570ff376c55c5c08eb6f1908d"
integrity sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==
dependencies:
"@rollup/pluginutils" "^5.0.1"
magic-string "^0.30.3"
@ -1055,9 +1050,9 @@
terser "^5.17.4"
"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0", "@rollup/pluginutils@^5.1.2", "@rollup/pluginutils@^5.1.3":
version "5.1.3"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.3.tgz#3001bf1a03f3ad24457591f2c259c8e514e0dbdf"
integrity sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==
version "5.1.4"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz#bb94f1f9eaaac944da237767cdfee6c5b2262d4a"
integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==
dependencies:
"@types/estree" "^1.0.0"
estree-walker "^2.0.2"
@ -1751,9 +1746,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001688:
version "1.0.30001688"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001688.tgz#f9d3ede749f083ce0db4c13db9d828adaf2e8d0a"
integrity sha512-Nmqpru91cuABu/DTCXbM2NSRHzM2uVHfPnhJ/1zEAJx/ILBRVmz3pzH4N7DZqbdG0gWClsCC05Oj0mJ/1AWMbA==
version "1.0.30001689"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz#67ca960dd5f443903e19949aeacc9d28f6e10910"
integrity sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==
chalk@^4.1.1:
version "4.1.2"
@ -1784,9 +1779,9 @@ chokidar@^3.5.1, chokidar@^3.6.0:
fsevents "~2.3.2"
chokidar@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41"
integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==
version "4.0.2"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.2.tgz#97b9562c9f59de559177f069eadf5dcc67d24798"
integrity sha512-/b57FK+bblSU+dfewfFe0rT1YjVDfOmeLQwCAuC+vwvgLkXboATqqmy+Ipux6JrF6L5joe5CBnFOw+gLWH6yKg==
dependencies:
readdirp "^4.0.1"
@ -1958,6 +1953,13 @@ create-require@^1.1.1:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cron-parser@^4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5"
integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==
dependencies:
luxon "^3.2.1"
croner@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/croner/-/croner-9.0.0.tgz#1db62160142cf32eb22622e9ae27ba29156883f7"
@ -2249,9 +2251,9 @@ ee-first@1.1.1:
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
electron-to-chromium@^1.5.73:
version "1.5.73"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.73.tgz#f32956ce40947fa3c8606726a96cd8fb5bb5f720"
integrity sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==
version "1.5.74"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.74.tgz#cb886b504a6467e4c00bea3317edb38393c53413"
integrity sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw==
emoji-regex@^8.0.0:
version "8.0.0"
@ -2729,6 +2731,11 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
hls.js@^1.4.12:
version "1.5.17"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.17.tgz#8a4a9bae2df4dab23168156f5f7d0f39a71026fd"
integrity sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==
hookable@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d"
@ -3002,7 +3009,7 @@ jiti@^1.21.6:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268"
integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==
jiti@^2.1.2, jiti@^2.3.0, jiti@^2.3.1, jiti@^2.4.0:
jiti@^2.1.2, jiti@^2.3.0, jiti@^2.4.0, jiti@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.1.tgz#4de9766ccbfa941d9b6390d2b159a4b295a52e6b"
integrity sha512-yPBThwecp1wS9DmoA4x4KR2h3QoslacnDR8ypuFM962kI4/456Iy1oHx2RAgh4jfZNdn0bctsdadceiBUgpU1g==
@ -3063,10 +3070,10 @@ klona@^2.0.6:
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
knitwork@^1.0.0, knitwork@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/knitwork/-/knitwork-1.1.0.tgz#d8c9feafadd7ee744ff64340b216a52c7199c417"
integrity sha512-oHnmiBUVHz1V+URE77PNot2lv3QiYU2zQf1JjOVkMt3YDKGbu8NAFr+c4mcNOhdsGrB/VpVbRwPwhiXrPhxQbw==
knitwork@^1.0.0, knitwork@^1.1.0, knitwork@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/knitwork/-/knitwork-1.2.0.tgz#3cc92e76249aeb35449cfbed3f31c6df8444db3f"
integrity sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==
kolorist@^1.8.0:
version "1.8.0"
@ -3172,6 +3179,11 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
luxon@^3.2.1:
version "3.5.0"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20"
integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==
magic-string-ast@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/magic-string-ast/-/magic-string-ast-0.6.3.tgz#99684592d00b382fafcc47d290dd79fa4a688925"
@ -3180,9 +3192,9 @@ magic-string-ast@^0.6.3:
magic-string "^0.30.13"
magic-string@^0.30.11, magic-string@^0.30.12, magic-string@^0.30.13, magic-string@^0.30.14, magic-string@^0.30.15, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.8:
version "0.30.15"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.15.tgz#d5474a2c4c5f35f041349edaba8a5cb02733ed3c"
integrity sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
@ -3516,15 +3528,6 @@ nuxi@^3.15.0:
resolved "https://registry.yarnpkg.com/nuxi/-/nuxi-3.16.0.tgz#6773ee9b3f128ed8ec46596a84bf64f2d7fc4889"
integrity sha512-t9m4zTq44R0/icuzQXJHEyPRM3YbgTPMpytyb6YW2LOL/3mwZ3Bmte1FIlCoigzDvxBJRbcchZGc689+Syyu8w==
nuxt3-notifications@^1.2.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/nuxt3-notifications/-/nuxt3-notifications-1.2.3.tgz#db9a16950b29d83a05e2e2e83609b6d9a6ecf442"
integrity sha512-kDtHoL+8eFB6DgjPU/SYSwzZx5bDl1gRM60DHfvzAUgC7noBRkIuqUS5JNGczwQvusSJKR12y35ARJwUsC2gfA==
dependencies:
"@kyvg/vue3-notification" "^3.2.1"
defu "^6.1.4"
scule "^1.0.0"
nuxt@^3.11.2:
version "3.14.1592"
resolved "https://registry.yarnpkg.com/nuxt/-/nuxt-3.14.1592.tgz#0f94132b7e0ffe9087b37392f295e2c7d5d05ee3"
@ -4267,7 +4270,7 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
scule@^1.0.0, scule@^1.3.0:
scule@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/scule/-/scule-1.3.0.tgz#6efbd22fd0bb801bdcc585c89266a7d2daa8fbd3"
integrity sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==
@ -4692,9 +4695,9 @@ type-fest@^0.21.3:
integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
type-fest@^4.18.2, type-fest@^4.7.1:
version "4.30.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.30.1.tgz#120b9e15177310ec4e9d5d6f187d86c0f4b55e0e"
integrity sha512-ojFL7eDMX2NF0xMbDwPZJ8sb7ckqtlAi1GsmgsFXvErT9kFTk1r0DuQKvrCh73M6D4nngeHJmvogF9OluXs7Hw==
version "4.30.2"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.30.2.tgz#d94429edde1f7deacf554741650aab394197a4cc"
integrity sha512-UJShLPYi1aWqCdq9HycOL/gwsuqda1OISdBO3t8RlXQC4QvtuIz4b5FCfe2dQIWEpmlRExKmcTBfP1r9bhY7ig==
ufo@^1.1.2, ufo@^1.5.4:
version "1.5.4"
@ -4847,16 +4850,17 @@ untun@^0.1.3:
pathe "^1.1.1"
untyped@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/untyped/-/untyped-1.5.1.tgz#2ccf3ee09419d59a44c21a192877ab45aa98361a"
integrity sha512-reBOnkJBFfBZ8pCKaeHgfZLcehXtM6UTxc+vqs1JvCps0c4amLNp3fhdGBZwYp+VLyoY9n3X5KOP7lCyWBUX9A==
version "1.5.2"
resolved "https://registry.yarnpkg.com/untyped/-/untyped-1.5.2.tgz#36e892fab34172a9bc1d31004332ac2173b9d694"
integrity sha512-eL/8PlhLcMmlMDtNPKhyyz9kEBDS3Uk4yMu/ewlkT2WFbtzScjHWPJLdQLmaGPUKjXzwe9MumOtOgc4Fro96Kg==
dependencies:
"@babel/core" "^7.25.7"
"@babel/standalone" "^7.25.7"
"@babel/types" "^7.25.7"
"@babel/core" "^7.26.0"
"@babel/standalone" "^7.26.4"
"@babel/types" "^7.26.3"
citty "^0.1.6"
defu "^6.1.4"
jiti "^2.3.1"
mri "^1.2.0"
jiti "^2.4.1"
knitwork "^1.2.0"
scule "^1.3.0"
unwasm@^0.3.9: