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 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: def _updateStoreItem(self, type: str, item: ItemDTO) -> None:
sqlStatement = """ sqlStatement = """
INSERT INTO "history" ("id", "type", "url", "data") INSERT INTO "history" ("id", "type", "url", "data")

View file

@ -8,7 +8,7 @@ import shutil
import yt_dlp import yt_dlp
import hashlib import hashlib
from .AsyncPool import Terminator 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 .ItemDTO import ItemDTO
from .config import Config from .config import Config
from .Emitter import Emitter from .Emitter import Emitter
@ -26,7 +26,6 @@ class Download:
temp_dir: str = None temp_dir: str = None
output_template: str = None output_template: str = None
output_template_chapter: str = None output_template_chapter: str = None
format: str = None
ytdl_opts: dict = None ytdl_opts: dict = None
info: ItemDTO = None info: ItemDTO = None
default_ytdl_opts: dict = None default_ytdl_opts: dict = None
@ -61,7 +60,7 @@ class Download:
"Fields to be extracted from yt-dlp progress hook." "Fields to be extracted from yt-dlp progress hook."
tempKeep: bool = False 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): def __init__(self, info: ItemDTO, info_dict: dict = None, debug: bool = False):
config = Config.get_instance() config = Config.get_instance()
@ -70,8 +69,8 @@ class Download:
self.temp_dir = info.temp_dir self.temp_dir = info.temp_dir
self.output_template_chapter = info.output_template_chapter self.output_template_chapter = info.output_template_chapter
self.output_template = info.output_template self.output_template = info.output_template
self.format = config.ytdl_options.get('format', get_format(info.format, info.quality)) self.preset = info.preset
self.ytdl_opts = get_opts(info.format, info.quality, info.ytdlp_config if info.ytdlp_config else {}) self.ytdl_opts = get_opts(self.preset, info.ytdlp_config if info.ytdlp_config else {})
self.info = info self.info = info
self.id = info._id self.id = info._id
self.default_ytdl_opts = config.ytdl_options self.default_ytdl_opts = config.ytdl_options
@ -110,7 +109,6 @@ class Download:
try: try:
params: dict = { params: dict = {
'color': 'no_color', 'color': 'no_color',
'format': self.format,
'paths': { 'paths': {
'home': self.download_dir, 'home': self.download_dir,
'temp': self.tempPath, 'temp': self.tempPath,
@ -121,12 +119,14 @@ class Download:
}, },
'noprogress': True, 'noprogress': True,
'break_on_existing': True, 'break_on_existing': True,
'ignoreerrors': False,
'progress_hooks': [self._progress_hook], 'progress_hooks': [self._progress_hook],
'postprocessor_hooks': [self._postprocessor_hook], 'postprocessor_hooks': [self._postprocessor_hook],
**mergeConfig(self.default_ytdl_opts, self.ytdl_opts), **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 params['ignoreerrors'] = False
if self.debug: if self.debug:
@ -158,10 +158,10 @@ class Download:
if hasDeletedOptions: if hasDeletedOptions:
LOG.warning( 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( 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) cls = yt_dlp.YoutubeDL(params=params)
@ -292,7 +292,7 @@ class Download:
if self.info.status != 'finished' and self.is_live: if self.info.status != 'finished' and self.is_live:
LOG.warning( 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 return
if not os.path.exists(self.tempPath): if not os.path.exists(self.tempPath):
@ -300,10 +300,10 @@ class Download:
if self.tempPath == self.temp_dir: if self.tempPath == self.temp_dir:
LOG.warning( 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 return
LOG.info(f'Deleting Temp directory: {self.tempPath}') LOG.info(f'Deleting Temp folder: {self.tempPath}')
shutil.rmtree(self.tempPath, ignore_errors=True) shutil.rmtree(self.tempPath, ignore_errors=True)
async def progress_update(self): async def progress_update(self):
@ -341,10 +341,6 @@ class Download:
if os.path.exists(status.get('filename')): if os.path.exists(status.get('filename')):
self.info.file_size = os.path.getsize(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.status = status.get('status', self.info.status)
self.info.msg = status.get('msg') self.info.msg = status.get('msg')
@ -363,7 +359,7 @@ class Download:
self.info.speed = status.get('speed') self.info.speed = status.get('speed')
self.info.eta = status.get('eta') 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: try:
self.info.file_size = os.path.getsize(status.get('filename')) self.info.file_size = os.path.getsize(status.get('filename'))
except FileNotFoundError: except FileNotFoundError:

View file

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

View file

@ -1,11 +1,11 @@
import asyncio
import base64 import base64
from datetime import datetime from datetime import datetime
import functools import functools
import json import json
import os import os
import random
import time import time
import httpx
from .config import Config from .config import Config
from .DownloadQueue import DownloadQueue from .DownloadQueue import DownloadQueue
from aiohttp import web from aiohttp import web
@ -14,9 +14,7 @@ from .Playlist import Playlist
from .M3u8 import M3u8 from .M3u8 import M3u8
from .Segments import Segments from .Segments import Segments
from .Subtitle import Subtitle from .Subtitle import Subtitle
import socketio
import logging import logging
import sqlite3
import magic import magic
from .common import common from .common import common
from pathlib import Path from pathlib import Path
@ -30,16 +28,11 @@ MIME = magic.Magic(mime=True)
class HttpAPI(common): class HttpAPI(common):
config: Config = None config: Config = None
encoder: Encoder = None encoder: Encoder = None
app: web.Application = None
sio: socketio.AsyncServer = None
routes: web.RouteTableDef = None routes: web.RouteTableDef = None
connection: sqlite3.Connection = None
queue: DownloadQueue = None queue: DownloadQueue = None
loop: asyncio.AbstractEventLoop = None
rootPath: str = None rootPath: str = None
staticHolder: dict = {} staticHolder: dict = {}
extToMime: dict = { extToMime: dict = {
'.html': 'text/html', '.html': 'text/html',
'.css': 'text/css', '.css': 'text/css',
@ -54,11 +47,9 @@ class HttpAPI(common):
self.rootPath = str(Path(__file__).parent.parent.parent) self.rootPath = str(Path(__file__).parent.parent.parent)
self.config = Config.get_instance() self.config = Config.get_instance()
self.loop = asyncio.get_event_loop()
self.encoder = encoder
self.routes = web.RouteTableDef() self.routes = web.RouteTableDef()
self.encoder = encoder
self.emitter = emitter self.emitter = emitter
self.queue = queue self.queue = queue
@ -138,10 +129,13 @@ class HttpAPI(common):
self.routes.route(method._http_method, self.config.url_prefix + http_path)(method) self.routes.route(method._http_method, self.config.url_prefix + http_path)(method)
async def on_prepare(request: Request, response: Response): async def on_prepare(request: Request, response: Response):
if 'Server' in response.headers:
del response.headers['Server']
if 'Origin' in request.headers: if 'Origin' in request.headers:
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin'] response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
response.headers['Access-Control-Allow-Headers'] = 'Content-Type' 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 != '/': if self.config.url_prefix != '/':
self.routes.route('GET', '/')(lambda _: web.HTTPFound(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() post = await request.json()
url: str = post.get('url') url: str = post.get('url')
quality: str = post.get('quality') preset: str = post.get('preset', 'default')
if not url: if not url:
raise web.HTTPBadRequest() raise web.HTTPBadRequest()
format: str = post.get('format')
folder: str = post.get('folder') folder: str = post.get('folder')
ytdlp_cookies: str = post.get('ytdlp_cookies') ytdlp_cookies: str = post.get('ytdlp_cookies')
ytdlp_config: dict = post.get('ytdlp_config') ytdlp_config: dict = post.get('ytdlp_config')
@ -209,8 +202,7 @@ class HttpAPI(common):
status = await self.add( status = await self.add(
url=url, url=url,
quality=quality, preset=preset,
format=format,
folder=folder, folder=folder,
ytdlp_cookies=ytdlp_cookies, ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config, ytdlp_config=ytdlp_config,
@ -251,8 +243,7 @@ class HttpAPI(common):
status[item.get('url')] = await self.add( status[item.get('url')] = await self.add(
url=item.get('url'), url=item.get('url'),
quality=item.get('quality'), preset=item.get('preset', 'default'),
format=item.get('format'),
folder=item.get('folder'), folder=item.get('folder'),
ytdlp_cookies=item.get('ytdlp_cookies'), ytdlp_cookies=item.get('ytdlp_cookies'),
ytdlp_config=item.get('ytdlp_config'), ytdlp_config=item.get('ytdlp_config'),
@ -519,9 +510,42 @@ class HttpAPI(common):
@route('GET', '/') @route('GET', '/')
async def index(self, _) -> Response: 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'] data = self.staticHolder['/index.html']
return web.Response( return web.Response(
body=data.get('content'), body=data.get('content'),
content_type=data.get('content_type'), content_type=data.get('content_type'),
charset='utf-8', charset='utf-8',
status=web.HTTPOk.status_code) 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 from aiohttp import web
import socketio import socketio
import logging import logging
import sqlite3
from .config import Config from .config import Config
from .DownloadQueue import DownloadQueue from .DownloadQueue import DownloadQueue
from .common import common from .common import common
@ -24,9 +23,7 @@ class HttpSocket(common):
This class is used to handle WebSocket events. This class is used to handle WebSocket events.
""" """
config: Config = None config: Config = None
app: web.Application = None
sio: socketio.AsyncServer = None sio: socketio.AsyncServer = None
connection: sqlite3.Connection = None
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder): def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
super().__init__(queue=queue, encoder=encoder) super().__init__(queue=queue, encoder=encoder)
@ -149,13 +146,12 @@ class HttpSocket(common):
@ws_event @ws_event
async def add_url(self, sid: str, data: dict): async def add_url(self, sid: str, data: dict):
url: str = data.get('url') url: str = data.get('url')
quality: str = data.get('quality')
if not url: if not url:
self.emitter.warning('No URL provided.', to=sid) self.emitter.warning('No URL provided.', to=sid)
return return
format: str = data.get('format') preset: str = data.get('preset', 'default')
folder: str = data.get('folder') folder: str = data.get('folder')
ytdlp_cookies: str = data.get('ytdlp_cookies') ytdlp_cookies: str = data.get('ytdlp_cookies')
ytdlp_config: dict = data.get('ytdlp_config') ytdlp_config: dict = data.get('ytdlp_config')
@ -165,8 +161,7 @@ class HttpSocket(common):
status = await self.add( status = await self.add(
url=url, url=url,
quality=quality, preset=preset,
format=format,
folder=folder, folder=folder,
ytdlp_cookies=ytdlp_cookies, ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config, ytdlp_config=ytdlp_config,
@ -234,18 +229,17 @@ class HttpSocket(common):
@ws_event @ws_event
async def connect(self, sid: str, _=None): async def connect(self, sid: str, _=None):
data: dict = { data = {
**self.queue.get(), **self.queue.get(),
"config": self.config.frontend(), "config": self.config.frontend(),
"tasks": self.config.tasks, "tasks": self.config.tasks,
"presets": [], "presets": self.config.presets,
"directories": [],
} }
# get directory listing # get download folder listing
dlDir: str = self.config.download_path downloadPath: str = self.config.download_path
data['directories'] = [ data['folders'] = [
name for name in os.listdir(dlDir) if os.path.isdir(os.path.join(dlDir, name)) 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) await self.emitter.emit('initial_data', data, to=sid)

View file

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

View file

@ -6,99 +6,56 @@ import os
import pathlib import pathlib
import re import re
from typing import Any from typing import Any
import uuid
import yt_dlp import yt_dlp
LOG = logging.getLogger('Utils') LOG = logging.getLogger('Utils')
AUDIO_FORMATS: tuple[str] = ('m4a', 'mp3', 'opus', 'wav',)
IGNORED_KEYS: tuple[str] = ('cookiefile', 'paths', 'outtmpl', 'progress_hooks', 'postprocessor_hooks',) IGNORED_KEYS: tuple[str] = ('cookiefile', 'paths', 'outtmpl', 'progress_hooks', 'postprocessor_hooks',)
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None 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: Args:
format (str): format selected preset (str): the name of the preset 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)
ytdl_opts (dict): current options selected ytdl_opts (dict): current options selected
Returns: Returns:
ytdl_opts: Extra options ytdl extra options
""" """
opts = copy.deepcopy(ytdl_opts) opts = copy.deepcopy(ytdl_opts)
if not opts:
opts: dict = {
"postprocessors": [],
}
postprocessors = [] if 'default' == preset:
return opts
if format in AUDIO_FORMATS: from .config import Config
postprocessors.append({ presets = Config.get_instance().presets
"key": "FFmpegExtractAudio",
"preferredcodec": format,
"preferredquality": 0 if quality == "best" else quality,
})
# Audio formats without thumbnail if preset not in presets:
if format not in ("wav") and "writethumbnail" not in opts: LOG.error(f"Preset '{preset}' is not defined in the presets.")
opts["writethumbnail"] = True return opts
postprocessors.append({"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"})
postprocessors.append({"key": "FFmpegMetadata"})
postprocessors.append({"key": "EmbedThumbnail"})
if format == "thumbnail": preset_opts = presets[preset]
opts["skip_download"] = True
opts["writethumbnail"] = True opts['format'] = preset_opts.get('format')
postprocessors.append({"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"})
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 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: 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: Returns:
Download path with base directory factored in. Download path with base folder factored in.
""" """
if not folder: if not folder:
return basePath 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)) download_path = os.path.realpath(os.path.join(basePath, folder))
if not download_path.startswith(realBasePath): 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: if not os.path.isdir(download_path) and createPath:
os.makedirs(download_path, exist_ok=True) os.makedirs(download_path, exist_ok=True)
@ -348,3 +305,57 @@ def checkId(basePath: str, file: pathlib.Path) -> bool | str:
return f.absolute() return f.absolute()
return False 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') LOG = logging.getLogger('Webhooks')
class Webhooks: class Webhooks:
targets: list[dict] = [] targets: list[dict] = []
allowed_events: tuple = ('added', 'completed', 'error', 'not_live',) allowed_events: tuple = ('added', 'completed', 'error', 'not_live',)
@ -17,10 +18,10 @@ class Webhooks:
def load(self, file: str): def load(self, file: str):
try: try:
if os.path.getsize(file) < 2: if os.path.getsize(file) < 3:
raise Exception(f'file is empty.') 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 from .Utils import load_file
(target, status, error) = load_file(file, list) (target, status, error) = load_file(file, list)
if not status: if not status:
@ -28,7 +29,7 @@ class Webhooks:
self.targets = target self.targets = target
except Exception as e: except Exception as e:
LOG.error(f'Error loading webhooks from {file}: {e}') LOG.error(f"Error loading webhooks from '{file}'. '{e}'")
pass pass
async def send(self, event: str, item: ItemDTO) -> list[dict]: async def send(self, event: str, item: ItemDTO) -> list[dict]:
@ -53,7 +54,7 @@ class Webhooks:
from .config import Config from .config import Config
req: dict = target.get('request') req: dict = target.get('request')
try: 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: async with httpx.AsyncClient() as client:
request_type = req.get('type', 'json') request_type = req.get('type', 'json')
@ -84,14 +85,15 @@ class Webhooks:
'text': response.text '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'): if Config.get_instance().debug and respData.get('text'):
msg += f" [Body: {respData.get('text','??')}]" msg += f" body '{respData.get('text','??')}'."
LOG.info(msg) LOG.info(msg)
return respData return respData
except Exception as e: except Exception as e:
LOG.error(f"Error sending event '{event}' id '{item.id}' to '{target.get('name')}'. '{e}'")
return { return {
'url': req.get('url'), 'url': req.get('url'),
'status': 500, 'status': 500,

View file

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

View file

@ -65,11 +65,50 @@ class Config:
ytdlp_version: str = YTDLP_VERSION ytdlp_version: str = YTDLP_VERSION
tasks: list = [] 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',) _manual_vars: tuple = ('temp_path', 'config_path', 'download_path',)
_immutable: tuple = ( _immutable: tuple = (
'version', '__instance', 'ytdl_options', 'tasks', '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',) _int_vars: tuple = ('port', 'max_workers', 'socket_timeout', 'extract_info_timeout', 'debugpy_port',)
@ -77,7 +116,7 @@ class Config:
_frontend_vars: tuple = ( _frontend_vars: tuple = (
'download_path', 'keep_archive', 'output_template', 'download_path', 'keep_archive', 'output_template',
'ytdlp_version', 'url_host', 'url_prefix', 'ytdlp_version', 'version', 'url_host', 'started', 'url_prefix',
) )
@staticmethod @staticmethod
@ -192,6 +231,34 @@ class Config:
except Exception as e: except Exception as e:
pass 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 self.ytdl_options['socket_timeout'] = self.socket_timeout
if self.keep_archive: 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. will call the __dict__ method of an object if it exists.
""" """
def default(self, obj): def default(self, o):
if isinstance(obj, object) and hasattr(obj, '__dict__'): if isinstance(o, object) and hasattr(o, '__dict__'):
return obj.__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" APP_VERSION = "dev-master"
"Application version"

View file

@ -35,7 +35,7 @@ class main:
self.app = web.Application() self.app = web.Application()
self.encoder = Encoder() self.encoder = Encoder()
self.checkDirectories() self.checkFolders()
caribou.upgrade(self.config.db_file, os.path.join(self.rootPath, "migrations")) caribou.upgrade(self.config.db_file, os.path.join(self.rootPath, "migrations"))
connection = sqlite3.connect(database=self.config.db_file, isolation_level=None) connection = sqlite3.connect(database=self.config.db_file, isolation_level=None)
@ -54,7 +54,7 @@ class main:
if os.path.exists(WebhookFile): if os.path.exists(WebhookFile):
emitter.add_emitter(Webhooks(WebhookFile).emit) emitter.add_emitter(Webhooks(WebhookFile).emit)
def checkDirectories(self) -> None: def checkFolders(self) -> None:
try: try:
LOG.debug(f"Checking download folder at '{self.config.download_path}'.") LOG.debug(f"Checking download folder at '{self.config.download_path}'.")
if not os.path.exists(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}'.") LOG.info(f"Started 'Task: {taskName}' at '{timeNow}'.")
await self.socket.add( await self.socket.add(
url=task.get("url"), url=task.get("url"),
quality=task.get("quality", "best"), preset=task.get("preset", "default"),
format=task.get("format", "any"),
folder=task.get("folder"), folder=task.get("folder"),
ytdlp_cookies=task.get("ytdlp_cookies"), ytdlp_cookies=task.get("ytdlp_cookies"),
ytdlp_config=task.get("ytdlp_config"), 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" <div class="card"
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }"> :class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
<header class="card-header has-tooltip"> <header class="card-header has-tooltip">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title"> <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')" <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
@click.prevent="$emit('playItem', item)">
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
</div> </div>
<div class="card-header-icon" v-if="item.filename"> <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> <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> </div>
</header> </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="card-content">
<div class="columns is-mobile is-multiline"> <div class="columns is-mobile is-multiline">
<div class="column is-12" v-if="item.live_in"> <div class="column is-12" v-if="item.live_in">
@ -106,7 +130,7 @@
<div class="column is-12" v-if="showMessage(item)"> <div class="column is-12" v-if="showMessage(item)">
<span class="has-text-danger">{{ item.msg }}</span> <span class="has-text-danger">{{ item.msg }}</span>
</div> </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 v-if="!item.live_in && !item.is_live">
<span class="icon-text"> <span class="icon-text">
<span class="icon" <span class="icon"
@ -126,19 +150,20 @@
</span> </span>
</span> </span>
</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="user-hint" :date-datetime="item.datetime" <span class="user-hint" :date-datetime="item.datetime"
v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')"> v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
{{ moment(item.datetime).fromNow() }} {{ moment(item.datetime).fromNow() }}
</span> </span>
</div> </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" <span :date-datetime="item.datetime"
v-tooltip="'Will start at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')"> v-tooltip="'Will start at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')">
{{ moment(item.live_in).fromNow() }} {{ moment(item.live_in).fromNow() }}
</span> </span>
</div> </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) }} {{ formatBytes(item.file_size) }}
</div> </div>
<div class="column is-half-mobile has-text-centered"> <div class="column is-half-mobile has-text-centered">
@ -176,14 +201,13 @@
</a> </a>
</div> </div>
<div class="column is-half-mobile"> <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-text is-block">
<span class="icon"> <span class="icon"><i class="fa-solid fa-copy" /></span>
<i class="fa-solid fa-up-right-from-square" /> <span>Copy</span>
</span>
<span>Visit Link</span>
</span> </span>
</a> </button>
</div> </div>
</div> </div>
</div> </div>
@ -209,6 +233,15 @@
</span> </span>
</p> </p>
</div> </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> </div>
</template> </template>
@ -225,8 +258,17 @@ const socket = useSocketStore()
const selectedElms = ref([]); const selectedElms = ref([]);
const masterSelectAll = ref(false); const masterSelectAll = ref(false);
const showCompleted = useStorage('showCompleted', true) const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnail', false)
const direction = useStorage('sortCompleted', 'desc') 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) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key]; const element = stateStore.history[key];

View file

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

View file

@ -42,7 +42,21 @@
<div class="card-header-title has-text-centered is-text-overflow is-block"> <div class="card-header-title has-text-centered is-text-overflow is-block">
{{ item.title }} {{ item.title }}
</div> </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> </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="card-content">
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-12"> <div class="column is-12">
@ -51,7 +65,7 @@
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div> <div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div>
</div> </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-text">
<span class="icon" :class="{ 'has-text-success': item.status == 'downloading' }"> <span class="icon" :class="{ 'has-text-success': item.status == 'downloading' }">
<i class="fas" :class="setIcon(item)" /> <i class="fas" :class="setIcon(item)" />
@ -60,13 +74,13 @@
<span v-else>{{ ucFirst(item.status) }}</span> <span v-else>{{ ucFirst(item.status) }}</span>
</span> </span>
</div> </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" <span :data-datetime="item.datetime"
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"> v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.datetime).fromNow() }} {{ moment(item.datetime).fromNow() }}
</span> </span>
</div> </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"> <label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id" <input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id"> :value="item._id">
@ -135,6 +149,7 @@ const socket = useSocketStore();
const selectedElms = ref([]); const selectedElms = ref([]);
const masterSelectAll = ref(false); const masterSelectAll = ref(false);
const showQueue = useStorage('showQueue', true) const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnail', false)
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.queue) { 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>
<div class="navbar-item" v-if="config.tasks.length > 0"> <div class="navbar-item" v-if="config.tasks.length > 0">
<button v-tooltip.bottom="'Toggle Tasks'" class="button is-dark has-tooltip-bottom" <NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
@click="config.showTasks = !config.showTasks">
<span class="icon"><i class="fa-solid fa-tasks" /></span> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span class="is-hidden-mobile">Tasks</span> <span class="is-hidden-mobile">Tasks</span>
</button> </NuxtLink>
</div> </div>
<div class="navbar-item"> <div class="navbar-item">
@ -63,9 +62,9 @@
<div class="columns mt-3 is-mobile"> <div class="columns mt-3 is-mobile">
<div class="column is-8-mobile"> <div class="column is-8-mobile">
<div class="has-text-left" v-if="config.app?.version"> <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> <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> <span class="is-hidden-mobile">&nbsp;({{ config?.app.ytdlp_version || 'unknown' }})</span>
</div> </div>
</div> </div>
@ -78,13 +77,10 @@
</div> </div>
</div> </div>
</div> </div>
<NuxtNotifications position="top right" :speed="800" :ignoreDuplicates="true" :width="340" :pauseOnHover="true" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import 'assets/css/bulma.css' import 'assets/css/bulma.css'
import 'assets/css/style.css' import 'assets/css/style.css'
import 'assets/css/all.css' import 'assets/css/all.css'
@ -93,7 +89,6 @@ import moment from "moment";
const Year = new Date().getFullYear() const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')()) const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')())
const bus = useEventBus('show_form')
const socket = useSocketStore() const socket = useSocketStore()
const config = useConfigStore() const config = useConfigStore()

View file

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

View file

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

View file

@ -1,8 +1,13 @@
<template> <template>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix"> <div class="column is-12 is-clearfix">
<h1 class="title is-4">Terminal</h1> <h1 class="title is-4">
<div class="subtitle is-6"> <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 You can use this terminal window to execute non-interactive commands. The interface is jailed to the
<code>yt-dlp</code> <code>yt-dlp</code>
</div> </div>

View file

@ -7,7 +7,4 @@
<script setup> <script setup>
useHead({ title: 'Index' }) useHead({ title: 'Index' })
const config = useConfigStore()
</script> </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, keep_archive: false,
output_template: '', output_template: '',
ytdlp_version: '', ytdlp_version: '',
version: '',
url_host: '', url_host: '',
url_prefix: '', url_prefix: '',
}, },
presets: [ presets: [
{ {
name: 'Default - Use Predefined yt-dlp Format', 'name': 'Default - Use default yt-dlp format',
format: 'default', 'format': 'default',
'postprocessors': [],
'args': {}
} }
], ],
directories: [], folders: [],
tasks: [], tasks: [],
}; };

View file

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

View file

@ -1,7 +1,6 @@
import { useNotification } from '@kyvg/vue3-notification'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const { notify } = useNotification() const toast = useToast()
const AG_SEPARATOR = '.' const AG_SEPARATOR = '.'
/** /**
@ -94,44 +93,6 @@ const awaitElement = (sel, callback) => {
}, 200) }, 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 * Replace tags in text with values from context
* *
@ -170,12 +131,12 @@ const copyText = (str, notify = true) => {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => { navigator.clipboard.writeText(str).then(() => {
if (notify) { if (notify) {
notification('success', 'Success', 'Text copied to clipboard.') toast.success('Text copied to clipboard.')
} }
}).catch((error) => { }).catch((error) => {
console.error('Failed to copy.', error) console.error('Failed to copy.', error)
if (notify) { if (notify) {
notification('error', 'Error', 'Failed to copy to clipboard.') toast.error('Failed to copy to clipboard.')
} }
}) })
return return
@ -189,7 +150,7 @@ const copyText = (str, notify = true) => {
document.body.removeChild(el) document.body.removeChild(el)
if (notify) { if (notify) {
notification('success', 'Success', 'Text copied to clipboard.') toast.success('Text copied to clipboard.')
} }
} }
@ -420,7 +381,6 @@ export {
ag_set, ag_set,
ag, ag,
awaitElement, awaitElement,
notification,
copyText, copyText,
dEvent, dEvent,
makePagination, makePagination,

View file

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