Finalizing migration to nuxt3
This commit is contained in:
parent
f614dbc217
commit
c727ab3446
28 changed files with 811 additions and 379 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
112
ui/components/FloatingImage.vue
Normal file
112
ui/components/FloatingImage.vue
Normal 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>
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
137
ui/components/VideoPlayer.vue
Normal file
137
ui/components/VideoPlayer.vue
Normal 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>
|
||||
|
|
@ -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"> ({{ 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"> ({{ 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export default defineNuxtConfig({
|
|||
'@pinia/nuxt',
|
||||
'@vueuse/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
'nuxt3-notifications',
|
||||
],
|
||||
|
||||
nitro: {
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,4 @@
|
|||
|
||||
<script setup>
|
||||
useHead({ title: 'Index' })
|
||||
|
||||
const config = useConfigStore()
|
||||
|
||||
</script>
|
||||
|
|
|
|||
68
ui/pages/tasks.vue
Normal file
68
ui/pages/tasks.vue
Normal 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>
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] ?? {})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
120
ui/yarn.lock
120
ui/yarn.lock
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue