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
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
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"
|
<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];
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
||||||
<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"> ({{ config.app?.version || 'unknown' }})</span>
|
<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>
|
<span class="is-hidden-mobile"> ({{ 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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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": {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
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,
|
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: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'] ?? {})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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"
|
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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue