Merge pull request #354 from arabcoders/dev
Added toggle for task handler & convert_url
This commit is contained in:
commit
33fbf4bda8
32 changed files with 1131 additions and 862 deletions
19
.vscode/launch.json
vendored
19
.vscode/launch.json
vendored
|
|
@ -1,7 +1,4 @@
|
||||||
{
|
{
|
||||||
// Use IntelliSense to learn about possible attributes.
|
|
||||||
// Hover to view descriptions of existing attributes.
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
|
|
@ -18,17 +15,17 @@
|
||||||
"cwd": "${workspaceFolder}/ui",
|
"cwd": "${workspaceFolder}/ui",
|
||||||
"env": {
|
"env": {
|
||||||
"NUXT_API_URL": "http://localhost:8081/api/",
|
"NUXT_API_URL": "http://localhost:8081/api/",
|
||||||
"NUXT_PUBLIC_WSS": ":8081/",
|
"NUXT_PUBLIC_WSS": ":8081/"
|
||||||
},
|
},
|
||||||
"console": "internalConsole",
|
"console": "internalConsole",
|
||||||
"outputCapture": "std",
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: main.py",
|
"name": "Python: main.py ",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "app/main.py",
|
"program": "app/main.py",
|
||||||
"console": "internalConsole",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"env": {
|
"env": {
|
||||||
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
|
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
|
||||||
|
|
@ -36,7 +33,9 @@
|
||||||
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
|
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
|
||||||
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||||
"YTP_IGNORE_UI": "true"
|
"YTP_IGNORE_UI": "true"
|
||||||
}
|
},
|
||||||
|
"subProcess": true,
|
||||||
|
"postDebugTask": "kill-debugpy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Node: Generate UI",
|
"name": "Node: Generate UI",
|
||||||
|
|
@ -65,14 +64,14 @@
|
||||||
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
|
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
|
||||||
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
|
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
|
||||||
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
|
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
|
||||||
"YTP_LOG_LEVEL": "DEBUG",
|
"YTP_LOG_LEVEL": "DEBUG"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Attach To Process",
|
"name": "Python: Attach To Process",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "attach",
|
"request": "attach",
|
||||||
"processId": "${command:pickProcess}",
|
"processId": "${command:pickProcess}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Attach To Container",
|
"name": "Python: Attach To Container",
|
||||||
|
|
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -111,7 +111,7 @@
|
||||||
"youtu"
|
"youtu"
|
||||||
],
|
],
|
||||||
"css.styleSheets": [
|
"css.styleSheets": [
|
||||||
"ui/assets/css/*.css"
|
"ui/app/assets/css/*.css"
|
||||||
],
|
],
|
||||||
"spellright.language": [
|
"spellright.language": [
|
||||||
"en"
|
"en"
|
||||||
|
|
|
||||||
22
.vscode/tasks.json
vendored
Normal file
22
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "kill-debugpy",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pkill -f debugpy || true",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"echo": false,
|
||||||
|
"reveal": "never",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "dedicated",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"runOptions": {
|
||||||
|
"runOn": "folderOpen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
API.md
7
API.md
|
|
@ -142,8 +142,9 @@ or an error:
|
||||||
|
|
||||||
**Query Parameters**:
|
**Query Parameters**:
|
||||||
- `url=<video-url>` (required)
|
- `url=<video-url>` (required)
|
||||||
- `preset=<preset-name>` (optional) - The preset to use for extracting info
|
- `preset=<preset-name>` (optional) - The preset to use for extracting info.
|
||||||
- `force=true` (optional) - Force fetch new info instead of using cache
|
- `force=true` (optional) - Force fetch new info instead of using cache.
|
||||||
|
- `args=<yt-dlp-command-opts>` (optional) - The yt-dlp command options to apply to the info extraction.
|
||||||
|
|
||||||
**Response** (example):
|
**Response** (example):
|
||||||
```json
|
```json
|
||||||
|
|
@ -153,6 +154,8 @@ or an error:
|
||||||
"extractor": "youtube",
|
"extractor": "youtube",
|
||||||
"_cached": {
|
"_cached": {
|
||||||
"status": "miss|hit",
|
"status": "miss|hit",
|
||||||
|
"preset": "<preset-name>",
|
||||||
|
"cli_args": "<yt-dlp-command-opts>",
|
||||||
"key": "<hash>",
|
"key": "<hash>",
|
||||||
"ttl": 300,
|
"ttl": 300,
|
||||||
"ttl_left": 299.82,
|
"ttl_left": 299.82,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class Scheduler(metaclass=Singleton):
|
||||||
for job in self._jobs:
|
for job in self._jobs:
|
||||||
try:
|
try:
|
||||||
self._jobs[job].stop()
|
self._jobs[job].stop()
|
||||||
|
LOG.debug(f"Stopped job '{job}'.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.")
|
LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.")
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class Task:
|
||||||
template: str = ""
|
template: str = ""
|
||||||
cli: str = ""
|
cli: str = ""
|
||||||
auto_start: bool = True
|
auto_start: bool = True
|
||||||
|
handler_enabled: bool = True
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
return self.__dict__
|
return self.__dict__
|
||||||
|
|
@ -100,7 +101,8 @@ class Tasks(metaclass=Singleton):
|
||||||
return Tasks._instance
|
return Tasks._instance
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application):
|
async def on_shutdown(self, _: web.Application):
|
||||||
pass
|
self.clear(shutdown=True)
|
||||||
|
self._task_handler.on_shutdown(_)
|
||||||
|
|
||||||
def attach(self, _: web.Application):
|
def attach(self, _: web.Application):
|
||||||
"""
|
"""
|
||||||
|
|
@ -116,6 +118,7 @@ class Tasks(metaclass=Singleton):
|
||||||
lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005
|
lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005
|
||||||
f"{__class__.__name__}.add",
|
f"{__class__.__name__}.add",
|
||||||
)
|
)
|
||||||
|
self._task_handler.load()
|
||||||
|
|
||||||
def get_all(self) -> list[Task]:
|
def get_all(self) -> list[Task]:
|
||||||
"""Return the tasks."""
|
"""Return the tasks."""
|
||||||
|
|
@ -155,7 +158,7 @@ class Tasks(metaclass=Singleton):
|
||||||
|
|
||||||
self._tasks.append(task)
|
self._tasks.append(task)
|
||||||
|
|
||||||
if not task.timer or "[only_handler]" in task.name:
|
if not task.timer:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -361,12 +364,21 @@ class Tasks(metaclass=Singleton):
|
||||||
|
|
||||||
class HandleTask:
|
class HandleTask:
|
||||||
_tasks: Tasks
|
_tasks: Tasks
|
||||||
|
_handlers: list[type]
|
||||||
|
_scheduler: Scheduler
|
||||||
|
_config: Config
|
||||||
|
_task_name: str
|
||||||
|
|
||||||
def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None:
|
def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None:
|
||||||
self._tasks = tasks
|
self._tasks = tasks
|
||||||
|
self._scheduler = scheduler
|
||||||
|
self._config = config
|
||||||
|
self._task_name: str = f"{__class__.__name__}._dispatcher"
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
self._handlers: list[type] = self._discover()
|
self._handlers: list[type] = self._discover()
|
||||||
|
|
||||||
timer = config.tasks_handler_timer
|
timer: str = self._config.tasks_handler_timer
|
||||||
try:
|
try:
|
||||||
from cronsim import CronSim
|
from cronsim import CronSim
|
||||||
|
|
||||||
|
|
@ -375,18 +387,26 @@ class HandleTask:
|
||||||
timer = "15 */1 * * *"
|
timer = "15 */1 * * *"
|
||||||
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
|
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
|
||||||
|
|
||||||
scheduler.add(
|
self._scheduler.add(
|
||||||
timer=timer,
|
timer=timer,
|
||||||
func=self._dispatcher,
|
func=self._dispatcher,
|
||||||
id=f"{__class__.__name__}._dispatcher",
|
id=f"{__class__.__name__}._dispatcher",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_shutdown(self, _: web.Application) -> None:
|
||||||
|
"""
|
||||||
|
Handle shutdown event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_: web.Application: The aiohttp application.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self._scheduler.has(self._task_name):
|
||||||
|
self._scheduler.remove(self._task_name)
|
||||||
|
|
||||||
def _dispatcher(self):
|
def _dispatcher(self):
|
||||||
for task in self._tasks.get_all():
|
for task in self._tasks.get_all():
|
||||||
if "[no_handler]" in task.name:
|
if not task.handler_enabled:
|
||||||
continue
|
|
||||||
|
|
||||||
if not task.timer and "[only_handler]" not in task.name:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -373,14 +373,18 @@ def check_id(file: Path) -> bool | str:
|
||||||
|
|
||||||
id = match.groupdict().get("id")
|
id = match.groupdict().get("id")
|
||||||
|
|
||||||
for f in file.parent.iterdir():
|
try:
|
||||||
if id not in f.stem:
|
for f in file.parent.iterdir():
|
||||||
continue
|
if id not in f.stem:
|
||||||
|
continue
|
||||||
|
|
||||||
if f.suffix != file.suffix:
|
if f.suffix != file.suffix:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return f.absolute()
|
return f.absolute()
|
||||||
|
except OSError as e:
|
||||||
|
LOG.error(f"Error checking file '{file}': {e!s}")
|
||||||
|
return False
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,6 @@ class Main:
|
||||||
self._app,
|
self._app,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
reuse_port="win32" != sys.platform,
|
|
||||||
loop=asyncio.get_event_loop(),
|
loop=asyncio.get_event_loop(),
|
||||||
access_log=HTTP_LOGGER,
|
access_log=HTTP_LOGGER,
|
||||||
print=started,
|
print=started,
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ async def prepare_zip_file(request: Request, config: Config, cache: Cache):
|
||||||
for f in json:
|
for f in json:
|
||||||
if not isinstance(f, str):
|
if not isinstance(f, str):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ref, status = get_file(download_path=config.download_path, file=f)
|
ref, status = get_file(download_path=config.download_path, file=f)
|
||||||
if status == web.HTTPNotFound.status_code:
|
if status == web.HTTPNotFound.status_code:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
||||||
status=web.HTTPBadRequest.status_code,
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
opts = YTDLPOpts.get_instance()
|
||||||
|
|
||||||
preset: str = request.query.get("preset", config.default_preset)
|
preset: str = request.query.get("preset", config.default_preset)
|
||||||
exists: Preset | None = Presets.get_instance().get(preset)
|
exists: Preset | None = Presets.get_instance().get(preset)
|
||||||
if not exists:
|
if not exists:
|
||||||
|
|
@ -114,14 +116,28 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
||||||
status=web.HTTPBadRequest.status_code,
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cli_args := request.query.get("args", None):
|
||||||
|
try:
|
||||||
|
arg_converter(cli_args, dumps=True)
|
||||||
|
opts = opts.add_cli(cli_args, from_user=True)
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e).strip()
|
||||||
|
err = err.split("\n")[-1] if "\n" in err else err
|
||||||
|
err = err.replace("main.py: error: ", "").strip().capitalize()
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key: str = cache.hash(f"{preset}:{url}")
|
key: str = cache.hash(f"{preset}:{url}:{cli_args or ''}")
|
||||||
|
|
||||||
if cache.has(key) and not request.query.get("force", False):
|
if cache.has(key) and not request.query.get("force", False):
|
||||||
data: Any | None = cache.get(key)
|
data: Any | None = cache.get(key)
|
||||||
data["_cached"] = {
|
data["_cached"] = {
|
||||||
"status": "hit",
|
"status": "hit",
|
||||||
"preset": preset,
|
"preset": preset,
|
||||||
|
"cli_args": cli_args,
|
||||||
"key": key,
|
"key": key,
|
||||||
"ttl": data.get("_cached", {}).get("ttl", 300),
|
"ttl": data.get("_cached", {}).get("ttl", 300),
|
||||||
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
|
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
|
||||||
|
|
@ -129,20 +145,18 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
||||||
}
|
}
|
||||||
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
opts: dict = {}
|
|
||||||
|
|
||||||
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
|
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
|
||||||
opts["proxy"] = ytdlp_proxy
|
opts = opts.add({"proxy": ytdlp_proxy})
|
||||||
|
|
||||||
logs: list = []
|
logs: list = []
|
||||||
|
|
||||||
ytdlp_opts: dict = {
|
ytdlp_opts: dict = {
|
||||||
|
**opts.get_all(),
|
||||||
"callback": {
|
"callback": {
|
||||||
"func": lambda _, msg: logs.append(msg),
|
"func": lambda _, msg: logs.append(msg),
|
||||||
"level": logging.WARNING,
|
"level": logging.WARNING,
|
||||||
"name": "callback-logger",
|
"name": "callback-logger",
|
||||||
},
|
},
|
||||||
**YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data = extract_info(
|
data = extract_info(
|
||||||
|
|
@ -177,6 +191,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
||||||
data["_cached"] = {
|
data["_cached"] = {
|
||||||
"status": "miss",
|
"status": "miss",
|
||||||
"preset": preset,
|
"preset": preset,
|
||||||
|
"cli_args": cli_args,
|
||||||
"key": key,
|
"key": key,
|
||||||
"ttl": 300,
|
"ttl": 300,
|
||||||
"ttl_left": 300,
|
"ttl_left": 300,
|
||||||
|
|
|
||||||
|
|
@ -345,3 +345,11 @@ hr {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
text-wrap: auto;
|
text-wrap: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table.is-fixed {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.is-centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control" v-if="item.status != 'finished' || !item.filename">
|
<div class="control" v-if="item.status != 'finished' || !item.filename">
|
||||||
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'"
|
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'"
|
||||||
@click="(event) => retryItem(item, event)">
|
@click="() => retryItem(item, true)">
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,7 +188,8 @@
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isEmbedable(item.url)">
|
<template v-else-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
|
<NuxtLink class="dropdown-item has-text-danger"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string">
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Play video</span>
|
<span>Play video</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -279,7 +280,8 @@
|
||||||
v-if="item.extras?.thumbnail" />
|
v-if="item.extras?.thumbnail" />
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
|
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||||
|
class="play-overlay">
|
||||||
<div class="play-icon embed-icon"></div>
|
<div class="play-icon embed-icon"></div>
|
||||||
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||||
v-if="item.extras?.thumbnail" />
|
v-if="item.extras?.thumbnail" />
|
||||||
|
|
@ -322,7 +324,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-mobile is-multiline">
|
<div class="columns is-mobile is-multiline">
|
||||||
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
|
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
|
||||||
<a class="button is-warning is-fullwidth" @click="(event) => retryItem(item, event)">
|
<a class="button is-warning is-fullwidth" @click="() => retryItem(item, false)">
|
||||||
<span class="icon-text is-block">
|
<span class="icon-text is-block">
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
<span>Retry</span>
|
<span>Retry</span>
|
||||||
|
|
@ -360,7 +362,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="isEmbedable(item.url)">
|
<template v-else-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
|
<NuxtLink class="dropdown-item has-text-danger"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string">
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Play video</span>
|
<span>Play video</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -459,24 +462,22 @@
|
||||||
@cancel="() => dialog_confirm.visible = false" />
|
@cancel="() => dialog_confirm.visible = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
<script setup>
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { StoreItem } from '~/types/store'
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo', 'add_new', 'getItemInfo', 'clear_search'])
|
const emitter = defineEmits<{
|
||||||
|
(e: 'getInfo', url: string, preset: string): void
|
||||||
|
(e: 'add_new', item: Partial<StoreItem>): void
|
||||||
|
(e: 'getItemInfo', id: string): void
|
||||||
|
(e: 'clear_search'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
thumbnails: {
|
thumbnails?: boolean
|
||||||
type: Boolean,
|
query?: string
|
||||||
default: true
|
}>()
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: '',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
|
|
@ -484,19 +485,25 @@ const socket = useSocketStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
|
||||||
const showCompleted = useStorage('showCompleted', true)
|
const showCompleted = useStorage<boolean>('showCompleted', true)
|
||||||
const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
const hideThumbnail = useStorage<boolean>('hideThumbnailHistory', false)
|
||||||
const direction = useStorage('sortCompleted', 'desc')
|
const direction = useStorage<'asc' | 'desc'>('sortCompleted', 'desc')
|
||||||
const display_style = useStorage('display_style', 'cards')
|
const display_style = useStorage<'cards' | 'list'>('display_style', 'cards')
|
||||||
const bg_enable = useStorage('random_bg', true)
|
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||||
|
|
||||||
const selectedElms = ref([])
|
const selectedElms = ref<string[]>([])
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false)
|
||||||
const table_container = ref(false)
|
const table_container = ref(false)
|
||||||
const embed_url = ref('')
|
const embed_url = ref('')
|
||||||
const video_item = ref(null)
|
const video_item = ref<StoreItem | null>(null)
|
||||||
const dialog_confirm = ref({
|
const dialog_confirm = ref<{
|
||||||
|
visible: boolean
|
||||||
|
title: string
|
||||||
|
confirm: (opts?: any) => void
|
||||||
|
message: string
|
||||||
|
options: { key: string; label: string }[]
|
||||||
|
}>({
|
||||||
visible: false,
|
visible: false,
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
confirm: () => { },
|
confirm: () => { },
|
||||||
|
|
@ -506,60 +513,57 @@ const dialog_confirm = ref({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value)
|
const showThumbnails = computed(() => (props.thumbnails || true) && !hideThumbnail.value)
|
||||||
|
|
||||||
const playVideo = item => video_item.value = item
|
const playVideo = (item: StoreItem) => { video_item.value = item }
|
||||||
const closeVideo = () => video_item.value = null
|
const closeVideo = () => { video_item.value = null }
|
||||||
|
|
||||||
const filteredItems = items => !props.query ? items : items.filter(filterItem)
|
const filteredItems = (items: StoreItem[]) => !props.query ? items : items.filter(filterItem)
|
||||||
|
|
||||||
const filterItem = item => {
|
const filterItem = (item: StoreItem) => {
|
||||||
const q = props.query?.toLowerCase()
|
const q = props.query?.toLowerCase()
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return Object.values(item).some(v => typeof v === 'string' && v.toLowerCase().includes(q))
|
return Object.values(item).some(v =>
|
||||||
|
typeof v === 'string' && v.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(masterSelectAll, (value) => {
|
watch(masterSelectAll, (value) => {
|
||||||
for (const key in stateStore.history) {
|
if (value) {
|
||||||
const element = stateStore.history[key]
|
selectedElms.value = Object.values(stateStore.history).map((element: StoreItem) => element._id)
|
||||||
if (value) {
|
} else {
|
||||||
selectedElms.value.push(element._id)
|
selectedElms.value = []
|
||||||
} else {
|
|
||||||
selectedElms.value = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortCompleted = computed(() => {
|
const sortCompleted = computed(() => {
|
||||||
const thisDirection = direction.value
|
const thisDirection = direction.value
|
||||||
return Object.values(stateStore.history).sort((a, b) => {
|
return Object.values(stateStore.history as Record<string, StoreItem>).sort((a, b) => {
|
||||||
if ('asc' === thisDirection) {
|
if ('asc' === thisDirection) {
|
||||||
return new Date(a.datetime) - new Date(b.datetime)
|
return new Date(a.datetime).getTime() - new Date(b.datetime).getTime()
|
||||||
}
|
}
|
||||||
return new Date(b.datetime) - new Date(a.datetime)
|
return new Date(b.datetime).getTime() - new Date(a.datetime).getTime()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasSelected = computed(() => selectedElms.value.length > 0)
|
const hasSelected = computed(() => selectedElms.value.length > 0)
|
||||||
const hasItems = computed(() => filteredItems(sortCompleted.value).length > 0)
|
const hasItems = computed(() => filteredItems(sortCompleted.value as StoreItem[]).length > 0)
|
||||||
|
|
||||||
const showMessage = (item) => {
|
const showMessage = (item: StoreItem) => {
|
||||||
if (!item?.msg || item.msg === item?.error) {
|
if (!item?.msg || item.msg === item?.error) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return (item.msg?.length || 0) > 0
|
||||||
return item.msg.length > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasIncomplete = computed(() => {
|
const hasIncomplete = computed(() => {
|
||||||
if (Object.keys(stateStore.history)?.length < 0) {
|
if (Object.keys(stateStore.history)?.length < 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
const element = stateStore.history[key]
|
const element = stateStore.history[key] as StoreItem
|
||||||
if (element.status !== 'finished') {
|
if (element.status !== 'finished') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -571,9 +575,8 @@ const hasCompleted = computed(() => {
|
||||||
if (Object.keys(stateStore.history)?.length < 0) {
|
if (Object.keys(stateStore.history)?.length < 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
const element = stateStore.history[key]
|
const element = stateStore.history[key] as StoreItem
|
||||||
if (element.status === 'finished') {
|
if (element.status === 'finished') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -585,9 +588,8 @@ const hasDownloaded = computed(() => {
|
||||||
if (Object.keys(stateStore.history)?.length < 0) {
|
if (Object.keys(stateStore.history)?.length < 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
const element = stateStore.history[key]
|
const element = stateStore.history[key] as StoreItem
|
||||||
if (element.status === 'finished' && element.filename) {
|
if (element.status === 'finished' && element.filename) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -600,18 +602,19 @@ const deleteSelectedItems = () => {
|
||||||
toast.error('No items selected.')
|
toast.error('No items selected.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`
|
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`
|
||||||
if (true === config.app.remove_files) {
|
if (true === config.app.remove_files) {
|
||||||
msg += ' This will remove any associated files if they exists.'
|
msg += ' This will remove any associated files if they exists.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false === box.confirm(msg, config.app.remove_files)) {
|
if (false === box.confirm(msg, config.app.remove_files)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in selectedElms.value) {
|
for (const key in selectedElms.value) {
|
||||||
const item = stateStore.history[selectedElms.value[key]]
|
const item_id = selectedElms.value[key]
|
||||||
|
if (!item_id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
socket.emit('archive_item', item)
|
socket.emit('archive_item', item)
|
||||||
}
|
}
|
||||||
|
|
@ -628,10 +631,9 @@ const clearCompleted = () => {
|
||||||
if (false === box.confirm(msg)) {
|
if (false === box.confirm(msg)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
if ('finished' === ag(stateStore.get('history', key, {}), 'status')) {
|
if ('finished' === ag(stateStore.get('history', key, {} as StoreItem), 'status')) {
|
||||||
socket.emit('item_delete', { id: stateStore.history[key]._id, remove_file: false, })
|
socket.emit('item_delete', { id: stateStore.history[key]?._id, remove_file: false, })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -640,18 +642,17 @@ const clearIncomplete = () => {
|
||||||
if (false === box.confirm('Clear all in-complete downloads?')) {
|
if (false === box.confirm('Clear all in-complete downloads?')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
if (stateStore.history[key].status !== 'finished') {
|
if ((stateStore.history[key] as StoreItem).status !== 'finished') {
|
||||||
socket.emit('item_delete', {
|
socket.emit('item_delete', {
|
||||||
id: stateStore.history[key]._id,
|
id: stateStore.history[key]?._id,
|
||||||
remove_file: false,
|
remove_file: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setIcon = item => {
|
const setIcon = (item: StoreItem) => {
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
if (!item.filename) {
|
if (!item.filename) {
|
||||||
return 'fa-solid fa-exclamation'
|
return 'fa-solid fa-exclamation'
|
||||||
|
|
@ -661,73 +662,59 @@ const setIcon = item => {
|
||||||
}
|
}
|
||||||
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
|
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('error' === item.status) {
|
if ('error' === item.status) {
|
||||||
return 'fa-solid fa-circle-xmark'
|
return 'fa-solid fa-circle-xmark'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('cancelled' === item.status) {
|
if ('cancelled' === item.status) {
|
||||||
return 'fa-solid fa-eject'
|
return 'fa-solid fa-eject'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('not_live' === item.status) {
|
if ('not_live' === item.status) {
|
||||||
return item.extras?.is_premiere ? 'fa-solid fa-star' : 'fa-solid fa-headset'
|
return item.extras?.is_premiere ? 'fa-solid fa-star' : 'fa-solid fa-headset'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('skip' === item.status) {
|
if ('skip' === item.status) {
|
||||||
return 'fa-solid fa-ban'
|
return 'fa-solid fa-ban'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'fa-solid fa-circle'
|
return 'fa-solid fa-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
const setIconColor = item => {
|
const setIconColor = (item: StoreItem) => {
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
if (!item.filename) {
|
if (!item.filename) {
|
||||||
return 'has-text-warning'
|
return 'has-text-warning'
|
||||||
}
|
}
|
||||||
return 'has-text-success'
|
return 'has-text-success'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('not_live' === item.status) {
|
if ('not_live' === item.status) {
|
||||||
return 'has-text-info'
|
return 'has-text-info'
|
||||||
}
|
}
|
||||||
|
if ('cancelled' === item.status || "skip" === item.status) {
|
||||||
if ('cancelled' === item.status || "skipped" === item.status) {
|
|
||||||
return 'has-text-warning'
|
return 'has-text-warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'has-text-danger'
|
return 'has-text-danger'
|
||||||
}
|
}
|
||||||
|
|
||||||
const setStatus = item => {
|
const setStatus = (item: StoreItem) => {
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
if (item.extras?.is_premiere) {
|
if (item.extras?.is_premiere) {
|
||||||
return 'Premiered'
|
return 'Premiered'
|
||||||
}
|
}
|
||||||
|
|
||||||
return item.is_live ? 'Streamed' : 'Completed'
|
return item.is_live ? 'Streamed' : 'Completed'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('error' === item.status) {
|
if ('error' === item.status) {
|
||||||
return 'Error'
|
return 'Error'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('cancelled' === item.status) {
|
if ('cancelled' === item.status) {
|
||||||
return 'Cancelled'
|
return 'Cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('not_live' === item.status) {
|
if ('not_live' === item.status) {
|
||||||
if (item.extras?.is_premiere) {
|
if (item.extras?.is_premiere) {
|
||||||
return 'Premiere'
|
return 'Premiere'
|
||||||
}
|
}
|
||||||
return display_style.value === 'cards' ? 'Stream' : 'Live'
|
return display_style.value === 'cards' ? 'Stream' : 'Live'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('skip' === item.status) {
|
if ('skip' === item.status) {
|
||||||
return 'Skipped'
|
return 'Skipped'
|
||||||
}
|
}
|
||||||
|
|
||||||
return item.status
|
return item.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -735,9 +722,8 @@ const retryIncomplete = () => {
|
||||||
if (false === box.confirm('Retry all incomplete downloads?')) {
|
if (false === box.confirm('Retry all incomplete downloads?')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
const item = stateStore.get('history', key, {})
|
const item = stateStore.get('history', key, {} as StoreItem) as StoreItem
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -745,96 +731,85 @@ const retryIncomplete = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addArchiveDialog = (item) => {
|
const addArchiveDialog = (item: StoreItem) => {
|
||||||
dialog_confirm.value.visible = true
|
dialog_confirm.value.visible = true
|
||||||
dialog_confirm.value.title = 'Archive Item'
|
dialog_confirm.value.title = 'Archive Item'
|
||||||
dialog_confirm.value.message = `Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`
|
dialog_confirm.value.message = `Archive '${item.title || item.id || item.url || '??'}'?`
|
||||||
dialog_confirm.value.confirm = opts => archiveItem(item, opts)
|
dialog_confirm.value.confirm = (opts: any) => archiveItem(item, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveItem = async (item, opts = {}) => {
|
const archiveItem = async (item: StoreItem, opts = {}) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const req = await request(`/api/archive/${item._id}`, {
|
const req = await request(`/api/archive/${item._id}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await req.json()
|
const data = await req.json()
|
||||||
|
|
||||||
dialog_confirm.value.visible = false
|
dialog_confirm.value.visible = false
|
||||||
|
|
||||||
if (!req.ok) {
|
if (!req.ok) {
|
||||||
toast.error(data.error)
|
toast.error(data.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
toast.success(data.message ?? `Archived '${item.title || item.id || item.url || '??'}'.`)
|
||||||
toast.success(data.message ?? `Archived '${item.title ?? item.id ?? item.url ?? '??'}'.`)
|
} catch (e: any) {
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
if (!(opts as any)?.remove_history) {
|
||||||
if (!opts?.remove_history) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeItem = item => {
|
const removeItem = (item: StoreItem) => {
|
||||||
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title ?? item.id ?? item.url ?? '??'}'?`
|
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title || item.id || item.url || '??'}'?`
|
||||||
if (item.status === 'finished' && config.app.remove_files) {
|
if (item.status === 'finished' && config.app.remove_files) {
|
||||||
msg += ' This will remove any associated files if they exists.'
|
msg += ' This will remove any associated files if they exists.'
|
||||||
}
|
}
|
||||||
|
if (false === box.confirm(msg, Boolean(item.filename && config.app.remove_files))) {
|
||||||
if (false === box.confirm(msg, item.filename && config.app.remove_files)) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('item_delete', {
|
socket.emit('item_delete', {
|
||||||
id: item._id,
|
id: item._id,
|
||||||
remove_file: config.app.remove_files
|
remove_file: config.app.remove_files
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryItem = (item, re_add = false) => {
|
const retryItem = (item: StoreItem, re_add = false) => {
|
||||||
const item_req = {
|
const item_req: Partial<StoreItem> = {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
preset: item.preset,
|
preset: item.preset,
|
||||||
folder: item.folder,
|
folder: item.folder,
|
||||||
cookies: item.cookies,
|
cookies: item.cookies,
|
||||||
template: item.template,
|
template: item.template,
|
||||||
cli: item?.cli,
|
cli: item?.cli,
|
||||||
extras: toRaw(item.extras ?? {}) ?? {},
|
extras: toRaw(item?.extras || {}) ?? {},
|
||||||
};
|
}
|
||||||
|
|
||||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||||
|
|
||||||
if (true === re_add) {
|
if (true === re_add) {
|
||||||
toast.info('Cleared the item from history, and added it to the new download form.')
|
toast.info('Cleared the item from history, and added it to the new download form.')
|
||||||
emitter('add_new', item_req)
|
emitter('add_new', item_req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('add_url', item_req)
|
socket.emit('add_url', item_req)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
|
const pImg = (e: Event) => {
|
||||||
|
const target = e.target as HTMLImageElement
|
||||||
|
target.naturalHeight > target.naturalWidth && target.classList.add('image-portrait')
|
||||||
|
}
|
||||||
|
|
||||||
watch(video_item, v => {
|
watch(video_item, v => {
|
||||||
if (!bg_enable.value) {
|
if (!bg_enable.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
||||||
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(embed_url, v => {
|
watch(embed_url, v => {
|
||||||
if (!bg_enable.value) {
|
if (!bg_enable.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
||||||
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const downloadSelected = async () => {
|
const downloadSelected = async () => {
|
||||||
|
|
@ -842,100 +817,89 @@ const downloadSelected = async () => {
|
||||||
toast.error('No items selected.')
|
toast.error('No items selected.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let files_list: string[] = []
|
||||||
let files_list = []
|
|
||||||
for (const key in selectedElms.value) {
|
for (const key in selectedElms.value) {
|
||||||
const item = stateStore.history[selectedElms.value[key]]
|
const item_id = selectedElms.value[key]
|
||||||
|
if (!item_id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
|
||||||
if ('finished' !== item.status || !item.filename) {
|
if ('finished' !== item.status || !item.filename) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
|
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/file/download', {
|
const response = await request('/api/file/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(files_list),
|
body: JSON.stringify(files_list),
|
||||||
});
|
})
|
||||||
|
const json = await response.json()
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(json.error || 'Failed to start download.');
|
toast.error(json.error || 'Failed to start download.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = json.token
|
const token = json.token
|
||||||
|
|
||||||
const body = document.querySelector('body')
|
const body = document.querySelector('body')
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a')
|
||||||
link.href = uri(`/api/file/download/${token}`);
|
link.href = uri(`/api/file/download/${token}`)
|
||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank')
|
||||||
body.appendChild(link);
|
body?.appendChild(link)
|
||||||
link.click();
|
link.click()
|
||||||
body.removeChild(link);
|
body?.removeChild(link)
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e)
|
||||||
toast.error(`Error: ${e.message}`);
|
toast.error(`Error: ${e.message}`)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggle_class = e => ['is-text-overflow', 'is-word-break'].forEach(c => e.currentTarget.classList.toggle(c))
|
const toggle_class = (e: Event) => ['is-text-overflow', 'is-word-break'].forEach(c => (e.currentTarget as HTMLElement).classList.toggle(c))
|
||||||
|
|
||||||
const removeFromArchiveDialog = (item) => {
|
const removeFromArchiveDialog = (item: StoreItem) => {
|
||||||
dialog_confirm.value.visible = true
|
dialog_confirm.value.visible = true
|
||||||
dialog_confirm.value.title = 'Remove from Archive'
|
dialog_confirm.value.title = 'Remove from Archive'
|
||||||
dialog_confirm.value.message = `Remove '${item.title ?? item.id ?? item.url ?? '??'}' from archive?`
|
dialog_confirm.value.message = `Remove '${item.title || item.id || item.url || '??'}' from archive?`
|
||||||
dialog_confirm.value.confirm = () => removeFromArchive(item)
|
dialog_confirm.value.confirm = () => removeFromArchive(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFromArchive = async (item, opts) => {
|
const removeFromArchive = async (item: StoreItem, opts?: { remove_history?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const req = await request(`/api/archive/${item._id}`, {
|
const req = await request(`/api/archive/${item._id}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await req.json()
|
const data = await req.json()
|
||||||
|
|
||||||
if (!req.ok) {
|
if (!req.ok) {
|
||||||
toast.error(data.error)
|
toast.error(data.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
toast.success(data.message || `Removed '${item.title || item.id || item.url || '??'}' from archive.`)
|
||||||
toast.success(data.message ?? `Removed '${item.title ?? item.id ?? item.url ?? '??'}' from archive.`)
|
} catch (e: any) {
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error(`Error: ${e.message}`)
|
toast.error(`Error: ${e.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
dialog_confirm.value.visible = false
|
dialog_confirm.value.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts?.remove_history) {
|
if (opts?.remove_history) {
|
||||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const is_queued = item => {
|
const is_queued = (item: StoreItem) => {
|
||||||
if (!item?.status || 'not_live' !== item.status) {
|
if (!item?.status || 'not_live' !== item.status) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
|
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const makePath = item => {
|
const makePath = (item: StoreItem) => {
|
||||||
if (!item?.filename) {
|
if (!item?.filename) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/')
|
const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/')
|
||||||
|
|
||||||
return real_path.replace(config.app.download_path, '').replace(/^\//, '')
|
return real_path.replace(config.app.download_path, '').replace(/^\//, '')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
<template v-for="_, key in form.request.headers" :key="key">
|
<template v-for="_, key in form.request.headers" :key="key">
|
||||||
<div class="column is-5">
|
<div class="column is-5" v-if="form.request.headers[key]">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="text" class="input" v-model="form.request.headers[key].key"
|
<input type="text" class="input" v-model="form.request.headers[key].key"
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
<span>The header key to send with the notification.</span>
|
<span>The header key to send with the notification.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6">
|
<div class="column is-6" v-if="form.request.headers[key]">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="text" class="input" v-model="form.request.headers[key].value"
|
<input type="text" class="input" v-model="form.request.headers[key].value"
|
||||||
|
|
@ -267,11 +267,14 @@
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { notification, notificationImport } from '~/types/notification'
|
||||||
|
|
||||||
|
const emitter = defineEmits(['cancel', 'submit'])
|
||||||
|
const toast = useNotification()
|
||||||
|
const box = useConfirm()
|
||||||
|
|
||||||
const emitter = defineEmits(['cancel', 'submit']);
|
|
||||||
const toast = useNotification();
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
reference: {
|
reference: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
@ -279,11 +282,11 @@ const props = defineProps({
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
allowedEvents: {
|
allowedEvents: {
|
||||||
type: Array,
|
type: Array as () => string[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object as () => notification,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
addInProgress: {
|
addInProgress: {
|
||||||
|
|
@ -293,71 +296,75 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive(props.item);
|
const form = reactive<notification>({ ...props.item })
|
||||||
const requestMethods = ['POST', 'PUT'];
|
const requestMethods = ['POST', 'PUT']
|
||||||
const requestType = ['json', 'form'];
|
const requestType = ['json', 'form']
|
||||||
const showImport = useStorage('showImport', false);
|
const showImport = useStorage('showImport', false)
|
||||||
const import_string = ref('');
|
const import_string = ref('')
|
||||||
const box = useConfirm()
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!form.request.data_key) {
|
if (!form.request.data_key) {
|
||||||
form.request.data_key = 'data';
|
form.request.data_key = 'data'
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const checkInfo = async () => {
|
const checkInfo = async () => {
|
||||||
let required;
|
let required: string[]
|
||||||
|
|
||||||
if (!isApprise.value) {
|
if (!isApprise.value) {
|
||||||
required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key'];
|
required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key']
|
||||||
} else {
|
} else {
|
||||||
required = ['name', 'request.url'];
|
required = ['name', 'request.url']
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of required) {
|
for (const key of required) {
|
||||||
if (key.includes('.')) {
|
if (key.includes('.')) {
|
||||||
const [parent, child] = key.split('.');
|
const [parent, child] = key.split('.') as [keyof typeof form, string]
|
||||||
if (!form[parent][child]) {
|
const parentObj = form[parent] as Record<string, any> | undefined
|
||||||
toast.error(`The field ${parent}.${child} is required.`);
|
|
||||||
return;
|
if (!parentObj || !parentObj[child]) {
|
||||||
|
toast.error(`The field ${parent}.${child} is required.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const value = (form as Record<string, any>)[key]
|
||||||
|
if (!value) {
|
||||||
|
toast.error(`The field ${key} is required.`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else if (!form[key]) {
|
|
||||||
toast.error(`The field ${key} is required.`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isApprise.value) {
|
if (!isApprise.value) {
|
||||||
try {
|
try {
|
||||||
new URL(form.request.url);
|
new URL(form.request.url)
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
toast.error('Invalid URL');
|
toast.error('Invalid URL')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let headers = []
|
const headers = []
|
||||||
|
|
||||||
for (const header of form.request.headers) {
|
for (const header of form.request.headers) {
|
||||||
if (!header.key || !header.value) {
|
if (!header.key || !header.value) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() })
|
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() })
|
||||||
}
|
}
|
||||||
|
form.request.headers = headers
|
||||||
|
|
||||||
form.request.headers = headers;
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) })
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const importItem = async () => {
|
const importItem = async () => {
|
||||||
let val = import_string.value.trim()
|
const val = import_string.value.trim()
|
||||||
if (!val) {
|
if (!val) {
|
||||||
toast.error('The import string is required.')
|
toast.error('The import string is required.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val)
|
const item = decode(val) as notificationImport
|
||||||
|
|
||||||
if ('notification' !== item._type) {
|
if ('notification' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
|
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
|
||||||
|
|
@ -365,7 +372,7 @@ const importItem = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.target) {
|
if (form.name || form.request?.url) {
|
||||||
if (false === box.confirm('Overwrite the current form fields?', true)) {
|
if (false === box.confirm('Overwrite the current form fields?', true)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -375,24 +382,25 @@ const importItem = async () => {
|
||||||
form.name = item.name
|
form.name = item.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.url) {
|
if (!form.request) {
|
||||||
form.url = item.url
|
form.request = {} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.request) {
|
if (item.request) {
|
||||||
form.request = item.request
|
form.request = item.request
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.data_key) {
|
if (item.request?.data_key) {
|
||||||
form.data_key = item.data_key
|
form.request.data_key = item.request.data_key
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.on) {
|
if (item.on) {
|
||||||
form.on = item.on
|
form.on = item.on
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = ''
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error(`Failed to import task. ${e.message}`)
|
toast.error(`Failed to import task. ${e.message}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,46 @@
|
||||||
|
<style scoped>
|
||||||
|
.notification-item {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
border-color: var(--bulma-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
border-color: var(--bulma-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-color: var(--bulma-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-color: var(--bulma-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message.expanded {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
<a class="navbar-link">
|
<a class="navbar-link">
|
||||||
|
|
@ -73,64 +116,21 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
const store = useNotificationStore()
|
const store = useNotificationStore()
|
||||||
const copiedId = ref(null)
|
|
||||||
const expandedId = ref(null)
|
|
||||||
|
|
||||||
const toggleExpand = id => expandedId.value = expandedId.value === id ? null : id
|
const copiedId = ref<string | null>(null)
|
||||||
|
const expandedId = ref<string | null>(null)
|
||||||
|
|
||||||
const copy_text = (id, text) => {
|
const toggleExpand = (id: string) => expandedId.value = expandedId.value === id ? null : id
|
||||||
|
|
||||||
|
const copy_text = (id: string, text: string): void => {
|
||||||
copiedId.value = id
|
copiedId.value = id
|
||||||
copyText(text, false, false)
|
copyText(text, false, false)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (copiedId.value === id) {
|
if (copiedId.value === id) copiedId.value = null
|
||||||
copiedId.value = null
|
|
||||||
}
|
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.notification-item {
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
border-bottom: 1px solid #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-info {
|
|
||||||
border-color: var(--bulma-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-success {
|
|
||||||
border-color: var(--bulma-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-warning {
|
|
||||||
border-color: var(--bulma-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-error {
|
|
||||||
border-color: var(--bulma-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
cursor: pointer;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message.expanded {
|
|
||||||
white-space: normal;
|
|
||||||
word-break: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -230,102 +230,74 @@
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { Preset, PresetImport } from '~/types/presets'
|
||||||
|
|
||||||
const emitter = defineEmits(['cancel', 'submit']);
|
const emitter = defineEmits<{
|
||||||
|
(event: 'cancel'): void
|
||||||
|
(event: 'submit', payload: { reference: string | null, preset: Preset }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
reference: {
|
reference?: string | null
|
||||||
type: String,
|
preset: Partial<Preset>
|
||||||
required: false,
|
addInProgress?: boolean
|
||||||
default: null,
|
presets?: Preset[]
|
||||||
},
|
}>()
|
||||||
preset: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
addInProgress: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
presets: {
|
|
||||||
type: Array,
|
|
||||||
required: false,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const form = reactive(JSON.parse(JSON.stringify(props.preset)))
|
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)))
|
||||||
const import_string = ref('')
|
const import_string = ref<string>('')
|
||||||
const showImport = useStorage('showImport', false)
|
const showImport = useStorage<boolean>('showImport', false)
|
||||||
const box = useConfirm()
|
const selected_preset = ref<string>('')
|
||||||
const selected_preset = ref('')
|
|
||||||
|
|
||||||
onMounted(() => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
if (props.preset?.cli && '' !== props.preset?.cli) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.preset?.args && (typeof props.preset.args === 'object')) {
|
|
||||||
form.args = JSON.stringify(props.preset.args, null, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.preset?.postprocessors && (typeof props.preset.postprocessors === 'object')) {
|
|
||||||
form.postprocessors = JSON.stringify(props.preset.postprocessors, null, 2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkInfo = async () => {
|
|
||||||
for (const key of ['name']) {
|
for (const key of ['name']) {
|
||||||
if (!form[key]) {
|
if (!form[key as keyof Preset]) {
|
||||||
toast.error(`The ${key} field is required.`);
|
toast.error(`The ${key} field is required.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form?.cli && '' !== form.cli) {
|
if (form.cli && '' !== form.cli) {
|
||||||
const options = await convertOptions(form.cli);
|
const options = await convertOptions(form.cli)
|
||||||
if (null === options) {
|
if (null === options) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.cli = form.cli.trim()
|
form.cli = form.cli.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
let copy = JSON.parse(JSON.stringify(form));
|
const copy: Preset = JSON.parse(JSON.stringify(form))
|
||||||
|
let usedName = false
|
||||||
|
const name = String(form.name).trim().toLowerCase()
|
||||||
|
|
||||||
let usedName = false;
|
props.presets?.forEach(p => {
|
||||||
let name = String(form.name).trim().toLowerCase();
|
|
||||||
|
|
||||||
props.presets.forEach(p => {
|
|
||||||
if (p.id === props.reference) {
|
if (p.id === props.reference) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (String(p.name).toLowerCase() === name) {
|
if (String(p.name).toLowerCase() === name) {
|
||||||
usedName = true;
|
usedName = true
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
if (true === usedName) {
|
if (usedName) {
|
||||||
toast.error('The preset name is already in use.');
|
toast.error('The preset name is already in use.')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in copy) {
|
for (const key in copy) {
|
||||||
if (typeof copy[key] !== 'string') {
|
const val = copy[key as keyof Preset]
|
||||||
continue
|
if ('string' === typeof val) {
|
||||||
|
(copy as any)[key] = val.trim()
|
||||||
}
|
}
|
||||||
copy[key] = copy[key].trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), preset: toRaw(copy) });
|
emitter('submit', { reference: toRaw(props.reference ?? null), preset: toRaw(copy) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertOptions = async args => {
|
const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await convertCliOptions(args)
|
const response = await convertCliOptions(args)
|
||||||
|
|
||||||
|
|
@ -337,23 +309,22 @@ const convertOptions = async args => {
|
||||||
form.folder = response.download_path
|
form.folder = response.download_path
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.opts
|
return response.opts as Record<string, any>
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const importItem = async () => {
|
const importItem = async (): Promise<void> => {
|
||||||
let val = import_string.value.trim()
|
const val = import_string.value.trim()
|
||||||
if (!val) {
|
if (!val) {
|
||||||
toast.error('The import string is required.')
|
toast.error('The import string is required.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val)
|
const item = decode(val) as PresetImport
|
||||||
|
|
||||||
if (!item?._type || 'preset' !== item._type) {
|
if (!item?._type || 'preset' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
|
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
|
||||||
|
|
@ -368,16 +339,6 @@ const importItem = async () => {
|
||||||
form.cli = item.cli
|
form.cli = item.cli
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- backwards compatibility for old presets.
|
|
||||||
if (item.format) {
|
|
||||||
if (!item?.cli) {
|
|
||||||
form.cli = `--format '${item.format}'`
|
|
||||||
} else {
|
|
||||||
form.cli = `--format '${item.format}'\n${form.cli}`
|
|
||||||
}
|
|
||||||
form.cli = form.cli.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.template) {
|
if (item.template) {
|
||||||
form.template = item.template
|
form.template = item.template
|
||||||
}
|
}
|
||||||
|
|
@ -392,15 +353,15 @@ const importItem = async () => {
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = ''
|
||||||
showImport.value = false
|
showImport.value = false
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error(`Failed to parse. ${e.message}`)
|
toast.error(`Failed to parse. ${e.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
|
const filter_presets = (flag = true): Preset[] => config.presets.filter(item => item.default === flag)
|
||||||
|
|
||||||
const import_existing_preset = async () => {
|
const import_existing_preset = async (): Promise<void> => {
|
||||||
if (!selected_preset.value) {
|
if (!selected_preset.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
<td>
|
<td>
|
||||||
<div class="progress-bar is-unselectable">
|
<div class="progress-bar is-unselectable">
|
||||||
<div class="progress-percentage">{{ updateProgress(item) }}</div>
|
<div class="progress-percentage">{{ updateProgress(item) }}</div>
|
||||||
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div>
|
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||||
|
|
@ -119,7 +119,8 @@
|
||||||
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
|
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
|
||||||
:button_classes="'is-small'" label="Actions">
|
:button_classes="'is-small'" label="Actions">
|
||||||
<template v-if="isEmbedable(item.url)">
|
<template v-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
|
<NuxtLink class="dropdown-item has-text-danger"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string">
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Play video</span>
|
<span>Play video</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -202,7 +203,8 @@
|
||||||
</header>
|
</header>
|
||||||
<div v-if="showThumbnails" class="card-image">
|
<div v-if="showThumbnails" class="card-image">
|
||||||
<figure class="image is-3by1">
|
<figure class="image is-3by1">
|
||||||
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
|
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||||
|
class="play-overlay">
|
||||||
<div class="play-icon embed-icon"></div>
|
<div class="play-icon embed-icon"></div>
|
||||||
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||||
v-if="item.extras?.thumbnail" />
|
v-if="item.extras?.thumbnail" />
|
||||||
|
|
@ -220,7 +222,7 @@
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="progress-bar is-unselectable">
|
<div class="progress-bar is-unselectable">
|
||||||
<div class="progress-percentage">{{ updateProgress(item) }}</div>
|
<div class="progress-percentage">{{ updateProgress(item) }}</div>
|
||||||
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div>
|
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
|
|
@ -267,7 +269,8 @@
|
||||||
<div class="column is-half-mobile">
|
<div class="column is-half-mobile">
|
||||||
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions">
|
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions">
|
||||||
<template v-if="isEmbedable(item.url)">
|
<template v-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
|
<NuxtLink class="dropdown-item has-text-danger"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string">
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Play video</span>
|
<span>Play video</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -312,26 +315,27 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { StoreItem } from '~/types/store'
|
||||||
|
|
||||||
|
const emitter = defineEmits<{
|
||||||
|
(e: 'getInfo', url: string, preset: string): void
|
||||||
|
(e: 'getItemInfo', id: string): void
|
||||||
|
(e: 'clear_search'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
thumbnails?: boolean
|
||||||
|
query?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo', 'clear_search', 'getItemInfo'])
|
|
||||||
const props = defineProps({
|
|
||||||
thumbnails: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
const toast = useNotification()
|
||||||
|
|
||||||
const showQueue = useStorage('showQueue', true)
|
const showQueue = useStorage('showQueue', true)
|
||||||
const hideThumbnail = useStorage('hideThumbnailQueue', false)
|
const hideThumbnail = useStorage('hideThumbnailQueue', false)
|
||||||
|
|
@ -339,135 +343,116 @@ const display_style = useStorage('display_style', 'cards')
|
||||||
const bg_enable = useStorage('random_bg', true)
|
const bg_enable = useStorage('random_bg', true)
|
||||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
||||||
|
|
||||||
const selectedElms = ref([])
|
const selectedElms = ref<string[]>([])
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false)
|
||||||
const embed_url = ref('')
|
const embed_url = ref('')
|
||||||
const table_container = ref(false)
|
const table_container = ref(false)
|
||||||
|
|
||||||
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value)
|
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value)
|
||||||
|
|
||||||
watch(masterSelectAll, (value) => {
|
watch(masterSelectAll, (value) => {
|
||||||
for (const key in stateStore.queue) {
|
if (value) {
|
||||||
const element = stateStore.queue[key]
|
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id)
|
||||||
if (value) {
|
} else {
|
||||||
selectedElms.value.push(element._id)
|
selectedElms.value = []
|
||||||
} else {
|
|
||||||
selectedElms.value = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed<StoreItem[]>(() => {
|
||||||
const q = props.query?.toLowerCase();
|
const q = props.query?.toLowerCase()
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return Object.values(stateStore.queue)
|
return Object.values(stateStore.queue)
|
||||||
}
|
}
|
||||||
return Object.values(stateStore.queue).filter(i => Object.values(i).some(v => typeof v === 'string' && v.toLowerCase().includes(q)));
|
return Object.values(stateStore.queue).filter((i: StoreItem) =>
|
||||||
});
|
Object.values(i).some(v => typeof v === 'string' && v.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const hasSelected = computed(() => selectedElms.value.length > 0)
|
const hasSelected = computed(() => 0 < selectedElms.value.length)
|
||||||
const hasQueuedItems = computed(() => stateStore.count('queue') > 0)
|
const hasQueuedItems = computed(() => 0 < stateStore.count('queue'))
|
||||||
const hasManualStart = computed(() => {
|
const hasManualStart = computed(() => {
|
||||||
if (stateStore.count('queue') < 0) {
|
if (0 > stateStore.count('queue')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.queue) {
|
for (const key in stateStore.queue) {
|
||||||
const item = stateStore.queue[key]
|
const item = stateStore.queue[key] as StoreItem
|
||||||
if (!item.status && item.auto_start === false) {
|
if (!item.status && false === item.auto_start) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasPausable = computed(() => {
|
const hasPausable = computed(() => {
|
||||||
if (stateStore.count('queue') < 0) {
|
if (0 > stateStore.count('queue')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in stateStore.queue) {
|
for (const key in stateStore.queue) {
|
||||||
const item = stateStore.queue[key]
|
const item = stateStore.queue[key] as StoreItem
|
||||||
if (!item.status && item.auto_start === true) {
|
if (!item.status && true === item.auto_start) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setIcon = (item: StoreItem): string => {
|
||||||
const setIcon = item => {
|
|
||||||
if (!item.auto_start) {
|
if (!item.auto_start) {
|
||||||
return 'fa-hourglass-half'
|
return 'fa-hourglass-half'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('downloading' === item.status && item.is_live) {
|
if ('downloading' === item.status && item.is_live) {
|
||||||
return 'fa-globe fa-spin'
|
return 'fa-globe fa-spin'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('downloading' === item.status) {
|
if ('downloading' === item.status) {
|
||||||
return 'fa-download'
|
return 'fa-download'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('postprocessing' === item.status) {
|
if ('postprocessing' === item.status) {
|
||||||
return 'fa-cog fa-spin'
|
return 'fa-cog fa-spin'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null === item.status && true === config.paused) {
|
if (null === item.status && true === config.paused) {
|
||||||
return 'fa-pause-circle'
|
return 'fa-pause-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.status) {
|
if (!item.status) {
|
||||||
return 'fa-question'
|
return 'fa-question'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'fa-spinner fa-spin'
|
return 'fa-spinner fa-spin'
|
||||||
}
|
}
|
||||||
|
|
||||||
const setStatus = item => {
|
const setStatus = (item: StoreItem): string => {
|
||||||
if (!item.auto_start) {
|
if (!item.auto_start) {
|
||||||
return 'Pending'
|
return 'Pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null === item.status && true === config.paused) {
|
if (null === item.status && true === config.paused) {
|
||||||
return 'Paused'
|
return 'Paused'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('downloading' === item.status && item.is_live) {
|
if ('downloading' === item.status && item.is_live) {
|
||||||
return 'Streaming'
|
return 'Streaming'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('preparing' === item.status) {
|
if ('preparing' === item.status) {
|
||||||
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..';
|
// @ts-ignore
|
||||||
|
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.status) {
|
if (!item.status) {
|
||||||
return 'Unknown...'
|
return 'Unknown...'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ucFirst(item.status)
|
return ucFirst(item.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setIconColor = item => {
|
const setIconColor = (item: StoreItem): string => {
|
||||||
if (item.status === 'downloading') {
|
if ('downloading' === item.status) {
|
||||||
return 'has-text-success'
|
return 'has-text-success'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('postprocessing' === item.status) {
|
if ('postprocessing' === item.status) {
|
||||||
return 'has-text-info'
|
return 'has-text-info'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.auto_start || (null === item.status && true === config.paused)) {
|
if (!item.auto_start || (null === item.status && true === config.paused)) {
|
||||||
return 'has-text-warning'
|
return 'has-text-warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const ETAPipe = value => {
|
const ETAPipe = (value: number | null): string => {
|
||||||
if (value === null || 0 === value) {
|
if (null === value || 0 === value) {
|
||||||
return 'Live'
|
return 'Live'
|
||||||
}
|
}
|
||||||
if (value < 60) {
|
if (value < 60) {
|
||||||
|
|
@ -481,11 +466,10 @@ const ETAPipe = value => {
|
||||||
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
|
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
const speedPipe = value => {
|
const speedPipe = (value: number | null): string => {
|
||||||
if (null === value || 0 === value) {
|
if (null === value || 0 === value) {
|
||||||
return '0KB/s'
|
return '0KB/s'
|
||||||
}
|
}
|
||||||
|
|
||||||
const k = 1024
|
const k = 1024
|
||||||
const dm = 2
|
const dm = 2
|
||||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']
|
||||||
|
|
@ -493,46 +477,39 @@ const speedPipe = value => {
|
||||||
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
const percentPipe = value => {
|
const percentPipe = (value: number | null): string => {
|
||||||
if (value === null || 0 === value) {
|
if (null === value || 0 === value) {
|
||||||
return '00.00'
|
return '00.00'
|
||||||
}
|
}
|
||||||
return parseFloat(value).toFixed(2)
|
return parseFloat(String(value)).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateProgress = (item) => {
|
const updateProgress = (item: StoreItem): string => {
|
||||||
let string = ''
|
let string = ''
|
||||||
|
|
||||||
if (!item.auto_start) {
|
if (!item.auto_start) {
|
||||||
return 'Manual start'
|
return 'Manual start'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null === item.status && true === config.paused) {
|
if (null === item.status && true === config.paused) {
|
||||||
return 'Global Pause'
|
return 'Global Pause'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('postprocessing' === item.status) {
|
if ('postprocessing' === item.status) {
|
||||||
return 'Post-processors are running.'
|
return 'Post-processors are running.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('preparing' === item.status) {
|
if ('preparing' === item.status) {
|
||||||
|
// @ts-ignore
|
||||||
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null != item.status) {
|
if (null != item.status) {
|
||||||
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'
|
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'
|
||||||
}
|
}
|
||||||
|
|
||||||
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'
|
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'
|
||||||
|
|
||||||
if (null != item.status && item.eta) {
|
if (null != item.status && item.eta) {
|
||||||
string += ' - ' + ETAPipe(item.eta)
|
string += ' - ' + ETAPipe(item.eta)
|
||||||
}
|
}
|
||||||
|
return string
|
||||||
return string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmCancel = item => {
|
const confirmCancel = (item: StoreItem) => {
|
||||||
if (true !== box.confirm(`Cancel '${item.title}'?`)) {
|
if (true !== box.confirm(`Cancel '${item.title}'?`)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -542,97 +519,84 @@ const confirmCancel = item => {
|
||||||
|
|
||||||
const cancelSelected = () => {
|
const cancelSelected = () => {
|
||||||
if (true !== box.confirm(`Cancel '${selectedElms.value.length}' selected items?`)) {
|
if (true !== box.confirm(`Cancel '${selectedElms.value.length}' selected items?`)) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
cancelItems(selectedElms.value)
|
cancelItems(selectedElms.value)
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelItems = item => {
|
const cancelItems = (item: string | string[]) => {
|
||||||
const items = []
|
const items: string[] = []
|
||||||
|
if ('object' === typeof item) {
|
||||||
if (typeof item === 'object') {
|
|
||||||
for (const key in item) {
|
for (const key in item) {
|
||||||
items.push(item[key])
|
items.push((item as any)[key])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items.push(item)
|
items.push(item)
|
||||||
}
|
}
|
||||||
|
if (0 > items.length) {
|
||||||
if (items.length < 0) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach(id => socket.emit('item_cancel', id))
|
items.forEach(id => socket.emit('item_cancel', id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const startItem = item => socket.emit('item_start', item._id)
|
const startItem = (item: StoreItem) => socket.emit('item_start', item._id)
|
||||||
const pauseItem = item => socket.emit('item_pause', item._id)
|
const pauseItem = (item: StoreItem) => socket.emit('item_pause', item._id)
|
||||||
|
|
||||||
const startItems = () => {
|
const startItems = () => {
|
||||||
if (selectedElms.value.length < 1) {
|
if (1 > selectedElms.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let filtered: string[] = []
|
||||||
let filtered = []
|
|
||||||
|
|
||||||
selectedElms.value.forEach(id => {
|
selectedElms.value.forEach(id => {
|
||||||
const item = stateStore.get('queue', id)
|
const item = stateStore.get('queue', id) as StoreItem
|
||||||
if (item && !item.auto_start && !item.status) {
|
if (item && !item.auto_start && !item.status) {
|
||||||
filtered.push(id)
|
filtered.push(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
|
if (1 > filtered.length) {
|
||||||
if (filtered.length < 1) {
|
|
||||||
toast.error('No eligible items to start.')
|
toast.error('No eligible items to start.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true !== box.confirm(`Start '${filtered.length}' selected items?`)) {
|
if (true !== box.confirm(`Start '${filtered.length}' selected items?`)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(id => socket.emit('item_start', id))
|
filtered.forEach(id => socket.emit('item_start', id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const pauseSelected = () => {
|
const pauseSelected = () => {
|
||||||
if (selectedElms.value.length < 1) {
|
if (1 > selectedElms.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let filtered: string[] = []
|
||||||
let filtered = []
|
|
||||||
|
|
||||||
selectedElms.value.forEach(id => {
|
selectedElms.value.forEach(id => {
|
||||||
const item = stateStore.get('queue', id)
|
const item = stateStore.get('queue', id) as StoreItem
|
||||||
if (item && item.auto_start && !item.status) {
|
if (item && item.auto_start && !item.status) {
|
||||||
filtered.push(id)
|
filtered.push(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
|
if (1 > filtered.length) {
|
||||||
if (filtered.length < 1) {
|
|
||||||
toast.error('No eligible items to pause.')
|
toast.error('No eligible items to pause.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true !== box.confirm(`Pause '${filtered.length}' selected items?`)) {
|
if (true !== box.confirm(`Pause '${filtered.length}' selected items?`)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(id => socket.emit('item_pause', id))
|
filtered.forEach(id => socket.emit('item_pause', id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
|
const pImg = (e: Event) => {
|
||||||
|
const target = e.target as HTMLImageElement
|
||||||
|
target.naturalHeight > target.naturalWidth ? target.classList.add('image-portrait') : null
|
||||||
|
}
|
||||||
|
|
||||||
watch(embed_url, v => {
|
watch(embed_url, v => {
|
||||||
if (!bg_enable.value) {
|
if (!bg_enable.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="columns mt-2 is-multiline">
|
<main class="columns mt-2 is-multiline">
|
||||||
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)">
|
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)">
|
||||||
<Message title="Warning" class="is-background-warning-80 has-text-dark" icon="fas fa-info-circle">
|
<Message title="Information" class="is-info is-background-info-80 has-text-dark" icon="fas fa-info-circle">
|
||||||
<span>
|
<span>You are using a YouTube link with <b>@handle</b> instead of <b>channel_id</b>. To activate RSS feed
|
||||||
<ul>
|
support for URL click on the <NuxtLink @click="async () => form.url = await convert_url(form.url)"><b>Convert
|
||||||
<li>You are using a YouTube link with handle instead of channel_id. To activate RSS feed support for
|
URL</b></NuxtLink> link.</span>
|
||||||
channel, you need to use
|
|
||||||
the channel ID. For example, <code>https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To get youtube channel_id simply visit the page, click on <b>more about this channel</b>, scroll down to
|
|
||||||
<b>share
|
|
||||||
channel</b>, click on <code>Copy channel id</code>.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
|
|
@ -79,7 +69,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>The name is used to identify this specific task.</span>
|
<span class="is-bold">The name is used to identify this specific task.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,17 +79,20 @@
|
||||||
<label class="label is-inline" for="url">
|
<label class="label is-inline" for="url">
|
||||||
<span class="icon"><i class="fa-solid fa-link" /></span>
|
<span class="icon"><i class="fa-solid fa-link" /></span>
|
||||||
URL
|
URL
|
||||||
|
<template v-if="is_yt_handle(form.url)">
|
||||||
|
- <NuxtLink @click="async () => form.url = await convert_url(form.url)">Convert URL</NuxtLink>
|
||||||
|
</template>
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
|
<input type="url" class="input" id="url" v-model="form.url"
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
:disabled="addInProgress || convertInProgress"
|
||||||
|
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg">
|
||||||
|
<span class="icon is-small is-left"><i class="fa-solid fa-link"
|
||||||
|
:class="{ 'fa-spin': convertInProgress }" /></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>The channel or playlist URL. For youtube there is rss feed support if you use URL with
|
<span class="is-bold">The channel or playlist URL.</span>
|
||||||
channel_id or playlist_id. For example, https://www.youtube.com/<span
|
|
||||||
class="has-text-danger">channel/UCUi3_cffYenmMTuWEsLHzqg</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,7 +123,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Select the preset to use for this URL. <span class="text-has-danger">If the
|
<span class="is-bold">Select the preset to use for this URL. <span class="text-has-danger">If the
|
||||||
<code>-f, --format</code> <span class="has-text-danger">
|
<code>-f, --format</code> <span class="has-text-danger">
|
||||||
argument is present in the command line options, the preset and all
|
argument is present in the command line options, the preset and all
|
||||||
it's options will be ignored.</span></span>
|
it's options will be ignored.</span></span>
|
||||||
|
|
@ -151,7 +144,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span class="is-bold">
|
||||||
The CRON timer expression to use for this task. If not set, the task will be disabled. For more
|
The CRON timer expression to use for this task. If not set, the task will be disabled. For more
|
||||||
information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank">
|
information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank">
|
||||||
crontab.guru</NuxtLink>.
|
crontab.guru</NuxtLink>.
|
||||||
|
|
@ -172,8 +165,8 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Current download folder: <code>{{ get_download_folder() }}</code>. All folders are
|
<span class="is-bold">Current download folder: <code>{{ get_download_folder() }}</code>. All folders
|
||||||
sub-folders of <code>{{ config.app.download_path }}</code>.</span>
|
are sub-folders of <code>{{ config.app.download_path }}</code>.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,14 +183,14 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Current output format: <code>{{ get_output_template() }}</code>.</span>
|
<span class="is-bold">Current output format: <code>{{ get_output_template() }}</code>.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="output_template">
|
<label class="label is-inline" for="auto_start">
|
||||||
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
|
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
|
||||||
Auto Start
|
Auto Start
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -210,7 +203,29 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Whether to automatically start downloading or just queue them in paused state.</span>
|
<span class="is-bold">Whether to automatically start downloading or just queue them in paused
|
||||||
|
state.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-inline" for="handler_enabled">
|
||||||
|
<span class="icon"><i class="fa-solid fa-rss" /></span>
|
||||||
|
Enable Handler
|
||||||
|
</label>
|
||||||
|
<div class="control is-unselectable">
|
||||||
|
<input id="handler_enabled" type="checkbox" v-model="form.handler_enabled" :disabled="addInProgress"
|
||||||
|
class="switch is-success" />
|
||||||
|
<label for="handler_enabled" class="is-unselectable">
|
||||||
|
{{ form.handler_enabled ? 'Yes' : 'No' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="help">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span class="is-bold">Some URLs like YouTube channels/playlists can be monitored using RSS feed,
|
||||||
|
this option works regardless of the timer being set or not.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,7 +243,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>yt-dlp cli arguments. Check <NuxtLink target="_blank"
|
<span class="is-bold">yt-dlp cli arguments. Check <NuxtLink target="_blank"
|
||||||
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
|
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
|
||||||
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
|
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
|
||||||
with caution those arguments can break yt-dlp or the frontend.</span>
|
with caution those arguments can break yt-dlp or the frontend.</span>
|
||||||
|
|
@ -260,22 +275,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message title="Tips" class="is-background-info-80 has-text-dark" icon="fas fa-info-circle">
|
<Message title="Tips" class="is-info is-background-info-80 has-text-dark" icon="fas fa-info-circle">
|
||||||
<span>
|
<span>
|
||||||
<ul>
|
<ul>
|
||||||
<li>To enable YouTube RSS feed monitoring, The task URL must include a <code>channel_id</code> or
|
<li>To enable YouTube RSS feed monitoring, The task URL must include a <code>channel_id</code> or
|
||||||
<code>playlist_id</code>. Other link types won’t work.
|
<code>playlist_id</code>. Other link types won’t work.
|
||||||
</li>
|
</li>
|
||||||
<li>RSS monitoring runs every hour alongside the actual task execution. It checks each feed for new videos
|
<li>RSS monitoring runs every hour alongside the actual task execution. Regardless if the task has timer set
|
||||||
and automatically queues any you haven’t downloaded yet.</li>
|
or not. To opt out of RSS monitoring for a specific task, simply disable the <code>Enable Handler</code>
|
||||||
<li>To opt out of RSS monitoring for a specific task, append <code>[no_handler]</code> to that task’s name.
|
option. To have the task only monitor RSS feed, <b>do not set timer</b>.</li>
|
||||||
</li>
|
|
||||||
<li>To have the task only monitor RSS feed, do not set timer and add <code>[only_handler]</code> to that
|
|
||||||
task’s name.</li>
|
|
||||||
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options
|
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options
|
||||||
for ytdlp.cli, preset or task.</li>
|
for ytdlp.cli, preset or task. If you don't have <code>--download-archive</code> set but
|
||||||
<li>If you don't have <code>--download-archive</code> set but <code>YTP_KEEP_ARCHIVE</code> environment
|
<code>YTP_KEEP_ARCHIVE</code> environment option is set to <code>true</code> which is the default, It will
|
||||||
option is set to <code>true</code> which is the default, It will also work.
|
also work.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -288,52 +300,52 @@
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { CronExpressionParser } from 'cron-parser'
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
|
import type { exported_task, task_item } from '~/types/tasks'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
reference: {
|
reference?: string | null | undefined
|
||||||
type: String,
|
task: task_item
|
||||||
required: false,
|
addInProgress?: boolean
|
||||||
default: null,
|
}>()
|
||||||
},
|
|
||||||
task: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
addInProgress: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emitter = defineEmits(['cancel', 'submit'])
|
const emitter = defineEmits<{
|
||||||
|
(e: 'cancel'): void
|
||||||
|
(e: 'submit', payload: { reference: string | null | undefined, task: task_item }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
const showImport = useStorage('showImport', false)
|
const showImport = useStorage('showImport', false)
|
||||||
|
|
||||||
const import_string = ref('')
|
const convertInProgress = ref<boolean>(false)
|
||||||
|
const import_string = ref<string>('')
|
||||||
|
|
||||||
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))\/?$/;
|
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
||||||
|
|
||||||
const form = reactive(props.task)
|
const form = reactive<task_item>({ ...props.task })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!props.task?.preset || '' === props.task.preset) {
|
if (!props.task?.preset || '' === props.task.preset) {
|
||||||
form.preset = toRaw(config.app.default_preset)
|
form.preset = toRaw(config.app.default_preset)
|
||||||
}
|
}
|
||||||
if (typeof form.auto_start === 'undefined' || form.auto_start === null) {
|
|
||||||
|
if (typeof form.auto_start === 'undefined' || null === form.auto_start) {
|
||||||
form.auto_start = true
|
form.auto_start = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof form.handler_enabled === 'undefined' || null === form.handler_enabled) {
|
||||||
|
form.handler_enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkInfo = async () => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
const required = ['name', 'url']
|
const required = ['name', 'url'] as const
|
||||||
for (const key of required) {
|
for (const key of required) {
|
||||||
if (!form[key]) {
|
if (!form[key]) {
|
||||||
toast.error(`The ${key} field is required.`)
|
toast.error(`The ${key} field is required.`)
|
||||||
|
|
@ -344,7 +356,7 @@ const checkInfo = async () => {
|
||||||
if (form.timer) {
|
if (form.timer) {
|
||||||
try {
|
try {
|
||||||
CronExpressionParser.parse(form.timer)
|
CronExpressionParser.parse(form.timer)
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error(`Invalid CRON expression. ${e.message}`)
|
toast.error(`Invalid CRON expression. ${e.message}`)
|
||||||
return
|
return
|
||||||
|
|
@ -353,31 +365,29 @@ const checkInfo = async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(form.url)
|
new URL(form.url)
|
||||||
} catch (e) {
|
} catch {
|
||||||
toast.error('Invalid URL')
|
toast.error('Invalid URL')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form?.cli && '' !== form.cli) {
|
if (form.cli && '' !== form.cli) {
|
||||||
const options = await convertOptions(form.cli)
|
const options = await convertOptions(form.cli)
|
||||||
if (null === options) {
|
if (null === options) return
|
||||||
return
|
form.cli = form.cli.trim()
|
||||||
}
|
|
||||||
form.cli = form.cli.trim(" ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) })
|
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const importItem = async () => {
|
const importItem = async (): Promise<void> => {
|
||||||
let val = import_string.value.trim()
|
const val = import_string.value.trim()
|
||||||
if (!val) {
|
if (!val) {
|
||||||
toast.error('The import string is required.')
|
toast.error('The import string is required.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val)
|
const item = decode(val) as exported_task
|
||||||
|
|
||||||
if ('task' !== item._type) {
|
if ('task' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
|
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
|
||||||
|
|
@ -391,34 +401,15 @@ const importItem = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.name) {
|
form.name = item.name ?? form.name
|
||||||
form.name = item.name
|
form.url = item.url ?? form.url
|
||||||
}
|
form.template = item.template ?? form.template
|
||||||
|
form.timer = item.timer ?? form.timer
|
||||||
if (item.url) {
|
form.folder = item.folder ?? form.folder
|
||||||
form.url = item.url
|
form.cli = item.cli ?? form.cli
|
||||||
}
|
form.auto_start = item.auto_start ?? true
|
||||||
|
|
||||||
if (item.template) {
|
|
||||||
form.template = item.template
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.timer) {
|
|
||||||
form.timer = item.timer
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.folder) {
|
|
||||||
form.folder = item.folder
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.cli) {
|
|
||||||
form.cli = item.cli
|
|
||||||
}
|
|
||||||
|
|
||||||
form.auto_start = item?.auto_start ?? true
|
|
||||||
|
|
||||||
if (item.preset) {
|
if (item.preset) {
|
||||||
// -- check if the preset exists in config.presets
|
|
||||||
const preset = config.presets.find(p => p.name === item.preset)
|
const preset = config.presets.find(p => p.name === item.preset)
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`)
|
toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`)
|
||||||
|
|
@ -429,13 +420,13 @@ const importItem = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = ''
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error(`Failed to import string. ${e.message}`)
|
toast.error(`Failed to import string. ${e.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertOptions = async args => {
|
const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await convertCliOptions(args)
|
const response = await convertCliOptions(args)
|
||||||
|
|
||||||
|
|
@ -447,52 +438,81 @@ const convertOptions = async args => {
|
||||||
form.folder = response.download_path
|
form.folder = response.download_path
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.opts
|
return response.opts as Record<string, any>
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFormatInConfig = computed(() => {
|
const hasFormatInConfig = computed<boolean>(() => !!form.cli && /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.cli))
|
||||||
if (!form?.cli) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.cli)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
|
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
|
||||||
|
|
||||||
const get_download_folder = () => {
|
const get_download_folder = (): string => {
|
||||||
if (form.preset && !hasFormatInConfig.value) {
|
if (form.preset && false === hasFormatInConfig.value) {
|
||||||
const preset = config.presets.find(p => p.name === form.preset)
|
const preset = config.presets.find(p => p.name === form.preset)
|
||||||
if (preset && preset.folder) {
|
if (preset?.folder) {
|
||||||
return preset.folder.replace(config.app.download_path, '')
|
return preset.folder.replace(config.app.download_path, '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '/'
|
return '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
const get_output_template = () => {
|
const get_output_template = (): string => {
|
||||||
if (form.preset && !hasFormatInConfig.value) {
|
if (form.preset && false === hasFormatInConfig.value) {
|
||||||
const preset = config.presets.find(p => p.name === form.preset)
|
const preset = config.presets.find(p => p.name === form.preset)
|
||||||
if (preset && preset.template) {
|
if (preset?.template) {
|
||||||
return preset.template
|
return preset.template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return config.app.output_template || '%(title)s.%(ext)s'
|
return config.app.output_template || '%(title)s.%(ext)s'
|
||||||
}
|
}
|
||||||
|
|
||||||
function is_yt_handle(url) {
|
const is_yt_handle = (url: string): boolean => {
|
||||||
let m = url.match(CHANNEL_REGEX);
|
if (!url || '' === url) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const m = url.match(CHANNEL_REGEX)
|
||||||
if (m?.groups) {
|
if (m?.groups) {
|
||||||
if (m.groups?.channelId) {
|
return !m.groups.channelId
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const convert_url = async (url: string): Promise<string> => {
|
||||||
|
if (!url || '' === url) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = url.match(CHANNEL_REGEX)
|
||||||
|
if (!m?.groups || !m.groups.handle) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('url', url)
|
||||||
|
params.append('args', '-I0')
|
||||||
|
|
||||||
|
try {
|
||||||
|
convertInProgress.value = true
|
||||||
|
const resp = await request('/api/yt-dlp/url/info?' + params.toString(), { credentials: 'include' })
|
||||||
|
const body = await resp.json()
|
||||||
|
const channel_id = ag(body, 'channel_id', null)
|
||||||
|
console.log('convert_url', { url, channel_id, body })
|
||||||
|
|
||||||
|
if (channel_id) {
|
||||||
|
return url.replace(`/@${m.groups.handle}`, `/channel/${channel_id}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error(`Error: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
convertInProgress.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
|
import type { StoreItem } from '~/types/store'
|
||||||
|
|
||||||
type video_track_element = {
|
type video_track_element = {
|
||||||
file: string,
|
file: string,
|
||||||
|
|
@ -58,7 +59,7 @@ const toast = useNotification()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object as () => StoreItem,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -122,8 +123,8 @@ onMounted(async () => {
|
||||||
if (props.item.extras?.thumbnail) {
|
if (props.item.extras?.thumbnail) {
|
||||||
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
|
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
|
||||||
} else {
|
} else {
|
||||||
if (response?.sidecar?.image && response.sidecar.image.length > 0) {
|
if (response.sidecar?.image?.[0]?.file) {
|
||||||
thumbnail.value = makeDownload(config, { "filename": response.sidecar.image[0]['file'] })
|
thumbnail.value = makeDownload(config, { "filename": response.sidecar.image[0].file })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,9 +192,10 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { FileItem, FileBrowserResponse } from '~/types/filebrowser'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -206,26 +207,32 @@ const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
||||||
const sort_by = useStorage('sort_by', 'name')
|
const sort_by = useStorage('sort_by', 'name')
|
||||||
const sort_order = useStorage('sort_order', 'asc')
|
const sort_order = useStorage('sort_order', 'asc')
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref<boolean>(false)
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref<boolean>(true)
|
||||||
const items = ref([])
|
const items = ref<FileItem[]>([])
|
||||||
const path = ref(`/${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`)
|
const path = ref<string>((() => {
|
||||||
const table_container = ref(false)
|
const slug = route.params.slug
|
||||||
const search = ref('')
|
if (Array.isArray(slug) && slug.length > 0) {
|
||||||
const show_filter = ref(false)
|
return '/' + slug.join('/')
|
||||||
|
}
|
||||||
|
return '/'
|
||||||
|
})())
|
||||||
|
const table_container = ref<boolean>(false)
|
||||||
|
const search = ref<string>('')
|
||||||
|
const show_filter = ref<boolean>(false)
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed<FileItem[]>(() => {
|
||||||
if (!search.value) {
|
if (!search.value) {
|
||||||
return sortedItems(items.value)
|
return sortedItems(items.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchLower = search.value.toLowerCase()
|
const searchLower = search.value.toLowerCase()
|
||||||
return sortedItems(items.value.filter(item => {
|
return sortedItems(items.value.filter((item: FileItem) => {
|
||||||
return item.name.toLowerCase().includes(searchLower)
|
return item.name.toLowerCase().includes(searchLower)
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedItems = items => {
|
const sortedItems = (items: FileItem[]): FileItem[] => {
|
||||||
|
|
||||||
if (!items || items.length < 1) {
|
if (!items || items.length < 1) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -247,7 +254,7 @@ const sortedItems = items => {
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
let aDate = new Date(a.mtime)
|
let aDate = new Date(a.mtime)
|
||||||
let bDate = new Date(b.mtime)
|
let bDate = new Date(b.mtime)
|
||||||
return 'asc' === sort_order.value ? aDate - bDate : bDate - aDate
|
return 'asc' === sort_order.value ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,8 +267,8 @@ const sortedItems = items => {
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
const model_item = ref()
|
const model_item = ref<any>()
|
||||||
const closeModel = () => model_item.value = null
|
const closeModel = (): void => { model_item.value = null }
|
||||||
|
|
||||||
watch(() => config.app.basic_mode, async () => {
|
watch(() => config.app.basic_mode, async () => {
|
||||||
if (!config.app.basic_mode) {
|
if (!config.app.basic_mode) {
|
||||||
|
|
@ -284,7 +291,7 @@ watch(() => socket.isConnected, async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleClick = item => {
|
const handleClick = (item: FileItem): void => {
|
||||||
if ('video' === item.content_type) {
|
if ('video' === item.content_type) {
|
||||||
model_item.value = {
|
model_item.value = {
|
||||||
"type": 'video',
|
"type": 'video',
|
||||||
|
|
@ -325,10 +332,10 @@ const handleClick = item => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location = makeDownload(config, { "filename": item.path, "folder": "", "extras": {} })
|
window.location.href = makeDownload(config, { "filename": item.path, "folder": "", "extras": {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadContent = async (dir = '/', fromMounted = false) => {
|
const reloadContent = async (dir: string = '/', fromMounted: boolean = false): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
|
|
@ -346,11 +353,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
|
||||||
|
|
||||||
items.value = []
|
items.value = []
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json() as FileBrowserResponse
|
||||||
|
|
||||||
if (data.length < 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data?.contents || data.contents.length > 0) {
|
if (!data?.contents || data.contents.length > 0) {
|
||||||
items.value = data.contents
|
items.value = data.contents
|
||||||
|
|
@ -368,7 +371,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({ title: decodeURIComponent(title) })
|
useHead({ title: decodeURIComponent(title) })
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
if (fromMounted) {
|
if (fromMounted) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -379,17 +382,17 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event_handler = e => {
|
const event_handler = (e: Event): void => {
|
||||||
|
const evt = e as PopStateEvent
|
||||||
if (!e.state) {
|
if (!evt.state) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.state.path === path.value || e.state.path === window.location.pathname) {
|
if (evt.state.path === path.value || evt.state.path === window.location.pathname) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadContent(e.state.path, true)
|
reloadContent(evt.state.path, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -397,12 +400,12 @@ onMounted(async () => {
|
||||||
await reloadContent(path.value, true)
|
await reloadContent(path.value, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('popstate', event_handler)
|
document.addEventListener('popstate', event_handler as EventListener)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler))
|
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler as EventListener))
|
||||||
|
|
||||||
const makeBreadCrumb = path => {
|
const makeBreadCrumb = (path: string): { name: string, link: string, path: string }[] => {
|
||||||
const baseLink = '/'
|
const baseLink = '/'
|
||||||
|
|
||||||
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
|
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||||
|
|
@ -428,16 +431,15 @@ const makeBreadCrumb = path => {
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
watch(model_item, v => {
|
watch(model_item, v => {
|
||||||
if (!bg_enable.value) {
|
if (!bg_enable.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const setIcon = item => {
|
const setIcon = (item: FileItem): string => {
|
||||||
if ('link' === item.type) {
|
if ('link' === item.type) {
|
||||||
return 'fa-link'
|
return 'fa-link'
|
||||||
}
|
}
|
||||||
|
|
@ -460,7 +462,7 @@ const setIcon = item => {
|
||||||
return 'fa-file'
|
return 'fa-file'
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeSort = by => {
|
const changeSort = (by: string): void => {
|
||||||
if (!['name', 'size', 'date', 'type'].includes(by)) {
|
if (!['name', 'size', 'date', 'type'].includes(by)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -472,17 +474,17 @@ const changeSort = by => {
|
||||||
sort_order.value = 'asc' === sort_order.value ? 'desc' : 'asc'
|
sort_order.value = 'asc' === sort_order.value ? 'desc' : 'asc'
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFilter = () => {
|
const toggleFilter = (): void => {
|
||||||
show_filter.value = !show_filter.value
|
show_filter.value = !show_filter.value
|
||||||
if (!show_filter.value) {
|
if (!show_filter.value) {
|
||||||
search.value = ''
|
search.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitElement('#search', e => e.focus())
|
awaitElement('#search', (e: Element) => (e as HTMLElement).focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDirectory = async (dir) => {
|
const createDirectory = async (dir: string): Promise<void> => {
|
||||||
if (!config.app.browser_control_enabled) {
|
if (!config.app.browser_control_enabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -497,13 +499,13 @@ const createDirectory = async (dir) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await actionRequest({ path: dir }, 'directory', { new_dir: new_dir }, (item, action, data) => {
|
await actionRequest({ path: dir } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
|
||||||
reloadContent(path.value, true)
|
reloadContent(path.value, true)
|
||||||
toast.success(`Successfully created '${new_dir}'.`)
|
toast.success(`Successfully created '${new_dir}'.`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAction = async (action, item) => {
|
const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
if (!config.app.browser_control_enabled) {
|
if (!config.app.browser_control_enabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -561,7 +563,12 @@ const handleAction = async (action, item) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionRequest = async (item, action, data, cb) => {
|
const actionRequest = async (
|
||||||
|
item: FileItem,
|
||||||
|
action: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
cb: (item: FileItem, action: string, data: any) => void
|
||||||
|
): Promise<any> => {
|
||||||
if (!config.app.browser_control_enabled) {
|
if (!config.app.browser_control_enabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -591,10 +598,9 @@ const actionRequest = async (item, action, data, cb) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error(`Failed to perform action: ${error.message}`)
|
toast.error(`Failed to perform action: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<a class="has-text-primary" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
|
<a class="has-text-info" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</a>
|
</a>
|
||||||
<button @click="cond.raw = !cond.raw">
|
<button @click="cond.raw = !cond.raw">
|
||||||
|
|
@ -240,7 +240,7 @@ const updateItem = async ({ reference, item: updatedItem, }: {
|
||||||
reference: string | null | undefined,
|
reference: string | null | undefined,
|
||||||
item: ConditionItem
|
item: ConditionItem
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
updatedItem = cleanObject(updatedItem, remove_keys)
|
updatedItem = cleanObject(updatedItem, remove_keys) as ConditionItem
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const index = items.value.findIndex(t => t?.id === reference)
|
const index = items.value.findIndex(t => t?.id === reference)
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ watch(() => config.app.file_logging, async v => {
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const raw = query.value.trim().toLowerCase()
|
const raw = query.value.trim().toLowerCase()
|
||||||
const contextMatch = raw.match(/context:(\d+)/)
|
const contextMatch = raw.match(/context:(\d+)/)
|
||||||
const context = contextMatch ? parseInt(contextMatch[1], 10) : 0
|
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0
|
||||||
const searchTerm = raw.replace(/context:\d+/, '').trim()
|
const searchTerm = raw.replace(/context:\d+/, '').trim()
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
|
|
@ -191,7 +191,7 @@ const filteredItems = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => {
|
Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => {
|
||||||
result.push(logs.value[index])
|
result.push(logs.value[index] as log_line)
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -276,21 +276,20 @@ const scrollToBottom = (fast = false) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const log_handler = (data: log_line) => {
|
||||||
|
logs.value.push(data)
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (autoScroll.value && bottomMarker.value) {
|
||||||
|
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchLogs()
|
await fetchLogs()
|
||||||
|
socket.on('log_lines', log_handler)
|
||||||
socket.emit('subscribe', 'log_lines')
|
socket.emit('subscribe', 'log_lines')
|
||||||
|
|
||||||
socket.on('log_lines', (data: any) => {
|
|
||||||
logs.value.push(data)
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
if (autoScroll.value && bottomMarker.value) {
|
|
||||||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
if (bg_enable.value) {
|
if (bg_enable.value) {
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +297,7 @@ onMounted(async () => {
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
socket.emit('unsubscribe', 'log_lines')
|
socket.emit('unsubscribe', 'log_lines')
|
||||||
socket.off('log_lines')
|
socket.off('log_lines', log_handler)
|
||||||
if (bg_enable.value) {
|
if (bg_enable.value) {
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +305,7 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
socket.emit('unsubscribe', 'log_lines')
|
socket.emit('unsubscribe', 'log_lines')
|
||||||
socket.off('log_lines')
|
socket.off('log_lines', log_handler)
|
||||||
if (scrollTimeout) clearTimeout(scrollTimeout)
|
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,3 @@
|
||||||
<style scoped>
|
|
||||||
table.is-fixed {
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.table-container {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.is-centered {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="mt-1 columns is-multiline">
|
||||||
|
|
@ -37,7 +23,15 @@ div.is-centered {
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control" v-if="notifications.length > 0">
|
<p class="control" v-if="notifications.length > 0">
|
||||||
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
|
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||||
|
@click="() => display_style = display_style === 'cards' ? 'list' : 'cards'">
|
||||||
|
<span class="icon"><i class="fa-solid"
|
||||||
|
:class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="control" v-if="notifications.length > 0">
|
||||||
|
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading || notifications.length < 1">
|
:disabled="!socket.isConnected || isLoading || notifications.length < 1">
|
||||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -52,65 +46,126 @@ div.is-centered {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
<div class="column is-12" v-if="toggleForm">
|
||||||
<NotificationForm :addInProgress="addInProgress" :reference="targetRef" :item="target"
|
<NotificationForm :addInProgress="addInProgress" :reference="targetRef as string" :item="target"
|
||||||
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" />
|
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm">
|
<div class="column is-12" v-if="!toggleForm && notifications && notifications.length > 0">
|
||||||
<div class="columns is-multiline" v-if="notifications && notifications.length > 0">
|
<template v-if="'list' === display_style">
|
||||||
<div class="column is-6" v-for="item in notifications" :key="item.id">
|
<div class="table-container">
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
<header class="card-header">
|
style="min-width: 850px; table-layout: fixed;">
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
<thead>
|
||||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
<tr class="has-text-centered is-unselectable">
|
||||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
<th width="80%">
|
||||||
|
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||||
|
<span>Targets</span>
|
||||||
|
</th>
|
||||||
|
<th width="20%">
|
||||||
|
<span class="icon"><i class="fa-solid fa-gear" /></span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in notifications" :key="item.id">
|
||||||
|
<td class="is-text-overflow is-vcentered">
|
||||||
|
<div>
|
||||||
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||||
|
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="is-unselectable">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
||||||
|
<span>On: {{ join_events(item.on) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="is-vcentered is-items-center">
|
||||||
|
<div class="field is-grouped is-grouped-centered">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
|
||||||
|
@click="exportItem(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
|
||||||
|
@click="editItem(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
|
||||||
|
@click="deleteItem(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="columns is-multiline" v-if="notifications && notifications.length > 0">
|
||||||
|
<div class="column is-6" v-for="item in notifications" :key="item.id">
|
||||||
|
<div class="card is-flex is-full-height is-flex-direction-column">
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||||
|
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="card-header-icon">
|
||||||
|
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
|
</a>
|
||||||
|
<button @click="item.raw = !item.raw">
|
||||||
|
<span class="icon"><i class="fa-solid"
|
||||||
|
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="card-content is-flex-grow-1">
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
||||||
|
<span>On: {{ join_events(item.on) }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||||
|
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||||
|
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
<div class="card-content" v-if="item?.raw">
|
||||||
<a class="has-text-primary" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
|
<div class="content">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<pre><code>{{ filterItem(item) }}</code></pre>
|
||||||
</a>
|
</div>
|
||||||
<button @click="item.raw = !item.raw">
|
|
||||||
<span class="icon"><i class="fa-solid"
|
|
||||||
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div class="card-footer mt-auto">
|
||||||
<div class="card-content is-flex-grow-1">
|
<div class="card-footer-item">
|
||||||
<div class="content">
|
<button class="button is-warning is-fullwidth" @click="editItem(item);">
|
||||||
<p>
|
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
<span>Edit</span>
|
||||||
<span>On: {{ join_events(item.on) }}</span>
|
</button>
|
||||||
</p>
|
</div>
|
||||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
<div class="card-footer-item">
|
||||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
||||||
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
</p>
|
<span>Delete</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content" v-if="item?.raw">
|
|
||||||
<div class="content">
|
|
||||||
<pre><code>{{ filterItem(item) }}</code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(item);">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
|
||||||
<span>Edit</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
|
</div>
|
||||||
v-if="!notifications || notifications.length < 1">
|
|
||||||
|
<div class="column is-12" v-if="!toggleForm && (!notifications || notifications.length < 1)">
|
||||||
|
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle">
|
||||||
There are no notifications endpoints configured to receive web notifications.
|
There are no notifications endpoints configured to receive web notifications.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,16 +194,30 @@ div.is-centered {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { notification, notificationImport } from '~/types/notification'
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||||
|
|
||||||
const allowedEvents = ref([])
|
const allowedEvents = ref<string[]>([])
|
||||||
const notifications = ref([])
|
const notifications = ref<notification[]>([])
|
||||||
const target = ref({})
|
const target = ref<notification>({
|
||||||
const targetRef = ref('')
|
name: '',
|
||||||
|
on: [],
|
||||||
|
request: {
|
||||||
|
method: 'POST',
|
||||||
|
url: '',
|
||||||
|
type: 'json',
|
||||||
|
headers: [],
|
||||||
|
data_key: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const targetRef = ref<string | null>('')
|
||||||
const toggleForm = ref(false)
|
const toggleForm = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref(true)
|
||||||
|
|
@ -206,6 +275,7 @@ const resetForm = (closeForm = false) => {
|
||||||
url: '',
|
url: '',
|
||||||
type: 'json',
|
type: 'json',
|
||||||
headers: [],
|
headers: [],
|
||||||
|
data_key: '',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
targetRef.value = null
|
targetRef.value = null
|
||||||
|
|
@ -214,13 +284,13 @@ const resetForm = (closeForm = false) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData = async notifications => {
|
const updateData = async (items: notification[]) => {
|
||||||
const response = await request('/api/notifications', {
|
const response = await request('/api/notifications', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(notifications),
|
body: JSON.stringify(items),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
@ -234,13 +304,13 @@ const updateData = async notifications => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteItem = async item => {
|
const deleteItem = async (item: notification) => {
|
||||||
if (true !== box.confirm(`Delete '${item.name}'?`)) {
|
if (true !== box.confirm(`Delete '${item.name}'?`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = notifications.value.findIndex(i => i?.id === item.id)
|
const index = notifications.value.findIndex(i => i?.id === item.id)
|
||||||
if (index > -1) {
|
if (0 <= index) {
|
||||||
notifications.value.splice(index, 1)
|
notifications.value.splice(index, 1)
|
||||||
} else {
|
} else {
|
||||||
toast.error('Notification target not found.')
|
toast.error('Notification target not found.')
|
||||||
|
|
@ -249,18 +319,21 @@ const deleteItem = async item => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await updateData(notifications.value)
|
const status = await updateData(notifications.value)
|
||||||
|
if (!status) return
|
||||||
if (!status) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Notification target deleted.')
|
toast.success('Notification target deleted.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, item }) => {
|
const updateItem = async ({
|
||||||
|
reference,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
reference: string | null;
|
||||||
|
item: notification;
|
||||||
|
}) => {
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const index = notifications.value.findIndex(i => i?.id === reference)
|
const index = notifications.value.findIndex(i => i?.id === reference)
|
||||||
if (index > -1) {
|
if (0 <= index) {
|
||||||
notifications.value[index] = item
|
notifications.value[index] = item
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -269,10 +342,7 @@ const updateItem = async ({ reference, item }) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await updateData(notifications.value)
|
const status = await updateData(notifications.value)
|
||||||
|
if (!status) return
|
||||||
if (!status) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`Notification target ${reference ? 'updated' : 'added'}.`)
|
toast.success(`Notification target ${reference ? 'updated' : 'added'}.`)
|
||||||
resetForm(true)
|
resetForm(true)
|
||||||
|
|
@ -281,18 +351,19 @@ const updateItem = async ({ reference, item }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterItem = item => {
|
const filterItem = (item: notification) => {
|
||||||
const { raw, ...rest } = item
|
const { raw, ...rest } = item as any
|
||||||
return JSON.stringify(rest, null, 2)
|
return JSON.stringify(rest, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editItem = item => {
|
const editItem = (item: notification) => {
|
||||||
target.value = item
|
target.value = item
|
||||||
targetRef.value = item.id
|
targetRef.value = item.id ?? null
|
||||||
toggleForm.value = true
|
toggleForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
|
const join_events = (events: string[]) =>
|
||||||
|
!events || events.length < 1 ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
|
||||||
|
|
||||||
const sendTest = async () => {
|
const sendTest = async () => {
|
||||||
if (true !== box.confirm('Send test notification?')) {
|
if (true !== box.confirm('Send test notification?')) {
|
||||||
|
|
@ -310,7 +381,7 @@ const sendTest = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Test notification sent.')
|
toast.success('Test notification sent.')
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error(`Failed to send test notification. ${e.message}`)
|
toast.error(`Failed to send test notification. ${e.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -320,23 +391,26 @@ const sendTest = async () => {
|
||||||
|
|
||||||
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
|
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
|
||||||
|
|
||||||
const exportItem = async item => {
|
const exportItem = async (item: notification) => {
|
||||||
let data = JSON.parse(JSON.stringify(item))
|
const data: notificationImport = {
|
||||||
|
...JSON.parse(JSON.stringify(item)),
|
||||||
|
_type: 'notification',
|
||||||
|
_version: '1.0',
|
||||||
|
}
|
||||||
|
|
||||||
const keys = ['id', 'raw']
|
const keys = ['id', 'raw']
|
||||||
keys.forEach(k => {
|
keys.forEach(k => {
|
||||||
if (data.hasOwnProperty(k)) {
|
if (Object.prototype.hasOwnProperty.call(data, k)) {
|
||||||
delete data[k]
|
delete (data as any)[k]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// -- Remove authorization headers
|
if (data.request?.headers?.length) {
|
||||||
if (data.request?.headers && data.request.headers.length > 0) {
|
data.request.headers = data.request.headers.filter(
|
||||||
data.request.headers = data.request.headers.filter(h => h.key.toLowerCase() !== 'authorization')
|
h => 'authorization' !== h.key.toLowerCase(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data['_type'] = 'notification'
|
copyText(encode(data))
|
||||||
data['_version'] = '1.0'
|
|
||||||
|
|
||||||
return copyText(encode(data))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
<style scoped>
|
|
||||||
table.is-fixed {
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.is-centered {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="mt-1 columns is-multiline">
|
||||||
|
|
@ -35,7 +25,7 @@ div.is-centered {
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
|
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
|
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -71,15 +61,19 @@ div.is-centered {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in presetsNoDefault" :key="item.id">
|
<tr v-for="item in presetsNoDefault" :key="item.id">
|
||||||
<td class="is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
<div class="is-text-overflow">
|
<div> {{ item.name }}</div>
|
||||||
{{ item.name }}
|
<div class="is-unselectable">
|
||||||
|
<span class="icon-text" v-if="item.cookies">
|
||||||
|
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||||
|
<span>Cookies</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary is-small is-fullwidth" v-tooltip="'Export'"
|
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
|
||||||
@click="exportItem(item)">
|
@click="exportItem(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -111,7 +105,7 @@ div.is-centered {
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-text="item.name" />
|
<div class="card-header-title is-text-overflow is-block" v-text="item.name" />
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<button class="has-text-primary" v-tooltip="'Export'" @click="exportItem(item)">
|
<button class="has-text-info" v-tooltip="'Export'" @click="exportItem(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="item.raw = !item.raw">
|
<button @click="item.raw = !item.raw">
|
||||||
|
|
@ -210,24 +204,25 @@ div.is-centered {
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { Preset } from '~/types/presets'
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
|
||||||
const display_style = useStorage("preset_display_style", "cards")
|
const display_style = useStorage<string>('preset_display_style', 'cards')
|
||||||
|
|
||||||
const presets = ref([])
|
const presets = ref<Preset[]>([])
|
||||||
const preset = ref({})
|
const preset = ref<Partial<Preset>>({})
|
||||||
const presetRef = ref("")
|
const presetRef = ref<string | null>('')
|
||||||
const toggleForm = ref(false)
|
const toggleForm = ref(false)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref(true)
|
||||||
const addInProgress = ref(false)
|
const addInProgress = ref(false)
|
||||||
const remove_keys = ["raw", "toggle_description"]
|
const remove_keys = ['raw', 'toggle_description']
|
||||||
|
|
||||||
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
|
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
|
||||||
|
|
||||||
|
|
@ -237,7 +232,7 @@ watch(
|
||||||
if (!config.app.basic_mode) {
|
if (!config.app.basic_mode) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await navigateTo("/")
|
await navigateTo('/')
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -251,24 +246,24 @@ watch(() => socket.isConnected, async () => {
|
||||||
const reloadContent = async (fromMounted = false) => {
|
const reloadContent = async (fromMounted = false) => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
const response = await request("/api/presets")
|
const response = await request('/api/presets')
|
||||||
|
|
||||||
if (fromMounted && !response.ok) {
|
if (fromMounted && !response.ok) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data.length < 1) {
|
if (0 === data.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
presets.value = data
|
presets.value = data
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
if (fromMounted) {
|
if (fromMounted) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error("Failed to fetch page content.")
|
toast.error('Failed to fetch page content.')
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -283,14 +278,14 @@ const resetForm = (closeForm = false) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePresets = async (items) => {
|
const updatePresets = async (items: Preset[]): Promise<boolean | undefined> => {
|
||||||
let data
|
let data: any
|
||||||
try {
|
try {
|
||||||
addInProgress.value = true
|
addInProgress.value = true
|
||||||
|
|
||||||
const response = await request("/api/presets", {
|
const response = await request('/api/presets', {
|
||||||
method: "PUT",
|
method: 'PUT',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(items.filter((t) => !t.default)),
|
body: JSON.stringify(items.filter((t) => !t.default)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -304,44 +299,49 @@ const updatePresets = async (items) => {
|
||||||
presets.value = data
|
presets.value = data
|
||||||
resetForm(true)
|
resetForm(true)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to update presets. ${data?.error}. ${e.message}`)
|
toast.error(`Failed to update presets. ${data?.error}. ${e.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
addInProgress.value = false
|
addInProgress.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteItem = async (item) => {
|
const deleteItem = async (item: Preset) => {
|
||||||
if (true !== box.confirm(`Delete preset '${item.name}'?`, true)) {
|
if (true !== box.confirm(`Delete preset '${item.name}'?`, true)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = presets.value.findIndex((t) => t?.id === item.id)
|
const index = presets.value.findIndex((t) => t?.id === item.id)
|
||||||
if (index > -1) {
|
if (-1 === index) {
|
||||||
presets.value.splice(index, 1)
|
toast.error('Preset not found.')
|
||||||
} else {
|
|
||||||
toast.error("Preset not found.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await updatePresets(presets.value)
|
presets.value.splice(index, 1)
|
||||||
|
|
||||||
|
const status = await updatePresets(presets.value)
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Preset deleted.")
|
toast.success('Preset deleted.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, preset }) => {
|
const updateItem = async ({
|
||||||
preset = cleanObject(preset, remove_keys)
|
reference,
|
||||||
|
preset: item,
|
||||||
|
}: {
|
||||||
|
reference: string | null
|
||||||
|
preset: Preset
|
||||||
|
}) => {
|
||||||
|
item = cleanObject(item, remove_keys) as Preset
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const index = presets.value.findIndex((t) => t?.id === reference)
|
const index = presets.value.findIndex((t) => t?.id === reference)
|
||||||
if (index > -1) {
|
if (-1 !== index) {
|
||||||
presets.value[index] = preset
|
presets.value[index] = item
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
presets.value.push(preset)
|
presets.value.push(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await updatePresets(presets.value)
|
const status = await updatePresets(presets.value)
|
||||||
|
|
@ -349,66 +349,56 @@ const updateItem = async ({ reference, preset }) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Preset ${reference ? "updated" : "added"}.`)
|
toast.success(`Preset ${reference ? 'updated' : 'added'}.`)
|
||||||
resetForm(true)
|
resetForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterItem = item => {
|
const filterItem = (item: Preset) => {
|
||||||
const rest = cleanObject(item, remove_keys)
|
const rest = cleanObject(item, remove_keys)
|
||||||
if ("default" in rest) {
|
if ('default' in rest) {
|
||||||
delete rest.default
|
delete rest.default
|
||||||
}
|
}
|
||||||
return JSON.stringify(rest, null, 2)
|
return JSON.stringify(rest, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editItem = item => {
|
const editItem = (item: Preset) => {
|
||||||
preset.value = JSON.parse(filterItem(item))
|
preset.value = JSON.parse(filterItem(item))
|
||||||
presetRef.value = item.id
|
presetRef.value = item.id ?? null
|
||||||
toggleForm.value = true
|
toggleForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => (socket.isConnected ? await reloadContent(true) : ""))
|
onMounted(async () => (socket.isConnected ? await reloadContent(true) : ''))
|
||||||
|
|
||||||
const exportItem = item => {
|
const exportItem = (item: Preset) => {
|
||||||
let data = JSON.parse(JSON.stringify(item))
|
const keys = ['id', 'default', 'raw', 'cookies', 'toggle_description']
|
||||||
const keys = ["id", "default", "raw", "cookies", "toggle_description"]
|
const data = JSON.parse(JSON.stringify(item))
|
||||||
keys.forEach(key => {
|
|
||||||
|
for (const key of keys) {
|
||||||
if (key in data) {
|
if (key in data) {
|
||||||
delete data[key]
|
delete data[key]
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
let userData = {}
|
|
||||||
|
|
||||||
|
const userData: Record<string, any> = {}
|
||||||
for (const key of Object.keys(data)) {
|
for (const key of Object.keys(data)) {
|
||||||
if (!data[key]) {
|
if (data[key]) {
|
||||||
continue
|
userData[key] = data[key]
|
||||||
}
|
}
|
||||||
userData[key] = data[key]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config?.app?.ytdlp_cli) {
|
if (config?.app?.ytdlp_cli) {
|
||||||
const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n`
|
const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n`
|
||||||
if (userData.cli) {
|
userData.cli = userData.cli ? val + '\n' + userData.cli : val
|
||||||
userData.cli = val + "\n" + userData.cli
|
|
||||||
} else {
|
|
||||||
userData.cli = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userData['_type'] = 'preset'
|
userData['_type'] = 'preset'
|
||||||
userData['_version'] = '2.5'
|
userData['_version'] = '2.5'
|
||||||
|
|
||||||
return copyText(encode(userData))
|
copyText(encode(userData))
|
||||||
}
|
}
|
||||||
|
|
||||||
const calcPath = (path) => {
|
const calcPath = (path?: string): string => {
|
||||||
const loc = config.app.download_path || "/downloads"
|
const loc = config.app.download_path || '/downloads'
|
||||||
|
return path ? loc + '/' + sTrim(path, '/') : loc
|
||||||
if (path) {
|
|
||||||
return loc + "/" + sTrim(path, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
return loc
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
<style scoped>
|
|
||||||
table.is-fixed {
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.is-centered {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="mt-1 columns is-multiline">
|
||||||
|
|
@ -66,8 +56,8 @@ div.is-centered {
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="toggleForm">
|
<div class="columns is-multiline" v-if="toggleForm">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task" @cancel="resetForm(true);"
|
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task as task_item"
|
||||||
@submit="updateItem" />
|
@cancel="resetForm(true);" @submit="updateItem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -149,7 +139,7 @@ div.is-centered {
|
||||||
{{ remove_tags(item.name) }}
|
{{ remove_tags(item.name) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="is-unselectable">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i class="fa-solid"
|
||||||
|
|
@ -160,6 +150,11 @@ div.is-centered {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"><i class="fa-solid fa-rss" /></span>
|
||||||
|
<span>{{ item.handler_enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<span class="icon-text" v-if="item.preset">
|
<span class="icon-text" v-if="item.preset">
|
||||||
<span class="icon"><i class="fa-solid fa-tv" /></span>
|
<span class="icon"><i class="fa-solid fa-tv" /></span>
|
||||||
<span>{{ item.preset ?? config.app.default_preset }}</span>
|
<span>{{ item.preset ?? config.app.default_preset }}</span>
|
||||||
|
|
@ -185,7 +180,7 @@ div.is-centered {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary is-small is-fullwidth" v-tooltip="'Export'"
|
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
|
||||||
@click="exportItem(item)">
|
@click="exportItem(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -230,11 +225,17 @@ div.is-centered {
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<span class="icon" v-tooltip="`${item.auto_start ? 'Auto' : 'Manual'} start`">
|
<span class="icon" v-tooltip="`${item.auto_start ? 'Auto' : 'Manual'} start`">
|
||||||
<i class="fa-solid"
|
<i class="fa-solid"
|
||||||
:class="{ 'fa-circle-pause': item.auto_start, 'fa-circle-play': !item.auto_start }" />
|
:class="{ 'fa-circle-pause has-text-success': item.auto_start, 'fa-circle-play has-text-danger': !item.auto_start }" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
|
<span class="icon" v-tooltip="`RSS monitoring is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
|
||||||
|
<i class="fa-solid fa-rss"
|
||||||
|
:class="{ 'has-text-success': item.handler_enabled, 'has-text-danger': !item.handler_enabled }" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="has-text-info" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -544,7 +545,7 @@ const deleteItem = async (item: task_item) => {
|
||||||
toast.success('Task deleted.')
|
toast.success('Task deleted.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, task }: { reference?: string, task: task_item }) => {
|
const updateItem = async ({ reference, task }: { reference?: string | null | undefined, task: task_item }) => {
|
||||||
if (reference) {
|
if (reference) {
|
||||||
// -- find the task index.
|
// -- find the task index.
|
||||||
const index = tasks.value.findIndex((t) => t?.id === reference)
|
const index = tasks.value.findIndex((t) => t?.id === reference)
|
||||||
|
|
|
||||||
126
ui/app/spa-loading-template.html
Normal file
126
ui/app/spa-loading-template.html
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>YTPTube Loading...</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #111;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-container {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.ytptube-loading {
|
||||||
|
width: 420px;
|
||||||
|
height: 105px;
|
||||||
|
stroke: #FF0000;
|
||||||
|
stroke-width: 4.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
fill: none;
|
||||||
|
stroke-dasharray: 700;
|
||||||
|
stroke-dashoffset: 700;
|
||||||
|
animation: stroke-move 3s linear infinite;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
svg.ytptube-loading {
|
||||||
|
stroke: #FF4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stroke-move {
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: -700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
margin: 20px auto 0;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-top-color: #FF0000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1.2s linear infinite;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(255,0,0,0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.spinner {
|
||||||
|
border-top-color: #FF4444;
|
||||||
|
filter: drop-shadow(0 0 8px #FF4444);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.8;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="loader-container" role="img" aria-label="Loading YTPTube">
|
||||||
|
<svg class="ytptube-loading" viewBox="0 0 420 105" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
||||||
|
<!-- Y -->
|
||||||
|
<path d="M10 10 L25 37.5 L10 65" />
|
||||||
|
<path d="M25 37.5 L40 10" />
|
||||||
|
<!-- T -->
|
||||||
|
<path d="M50 10 L90 10" />
|
||||||
|
<path d="M70 10 L70 65" />
|
||||||
|
<!-- P -->
|
||||||
|
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
|
||||||
|
<!-- T -->
|
||||||
|
<path d="M160 10 L200 10" />
|
||||||
|
<path d="M180 10 L180 65" />
|
||||||
|
<!-- U -->
|
||||||
|
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
|
||||||
|
<!-- B -->
|
||||||
|
<path d="M270 10 L270 65" />
|
||||||
|
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
|
||||||
|
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
|
||||||
|
<!-- E -->
|
||||||
|
<path d="M320 10 L320 65" />
|
||||||
|
<path d="M320 10 L360 10" />
|
||||||
|
<path d="M320 37.5 L350 37.5" />
|
||||||
|
<path d="M320 65 L360 65" />
|
||||||
|
</svg>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="subtitle">Loading, please wait...</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -20,7 +20,7 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
event.forEach(e => socket.value?.on(e, (...args) => true === withEvent ? callback(e, ...args) : callback(...args)))
|
event.forEach(e => socket.value?.on(e, (...args) => true === withEvent ? callback(e, ...args) : callback(...args)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const off = (event: string | string[], callback: (...args: any[]) => void): any => {
|
const off = (event: string | string[], callback?: (...args: any[]) => void): any => {
|
||||||
if (!Array.isArray(event)) {
|
if (!Array.isArray(event)) {
|
||||||
event = [event]
|
event = [event]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
ui/app/types/filebrowser.d.ts
vendored
Normal file
20
ui/app/types/filebrowser.d.ts
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
type FileItem = {
|
||||||
|
type: 'file' | 'dir' | 'link'
|
||||||
|
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
mtime: string
|
||||||
|
ctime: string
|
||||||
|
is_dir: boolean
|
||||||
|
is_file: boolean
|
||||||
|
is_symlink: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileBrowserResponse = {
|
||||||
|
path: string
|
||||||
|
contents: FileItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FileItem, FileBrowserResponse }
|
||||||
27
ui/app/types/notification.d.ts
vendored
Normal file
27
ui/app/types/notification.d.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
type notificationRequestHeaderItem = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type notificationRequest = {
|
||||||
|
data_key: string;
|
||||||
|
headers: notificationRequestHeaderItem[];
|
||||||
|
method: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type notification = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
request: notificationRequest;
|
||||||
|
on: string[];
|
||||||
|
raw?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type notificationImport = notification & {
|
||||||
|
_type: 'notification';
|
||||||
|
_version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { notificationRequestHeaderItem, notification, notificationRequest, notificationImport };
|
||||||
19
ui/app/types/presets.d.ts
vendored
Normal file
19
ui/app/types/presets.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
type Preset = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
cli: string
|
||||||
|
cookies: string
|
||||||
|
default: boolean
|
||||||
|
description: string
|
||||||
|
folder: string
|
||||||
|
template: string
|
||||||
|
raw?:boolean
|
||||||
|
toggle_description?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetImport = Preset & {
|
||||||
|
_type: 'preset'
|
||||||
|
_version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Preset, PresetImport }
|
||||||
15
ui/app/types/store.d.ts
vendored
15
ui/app/types/store.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
export type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | null;
|
type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | 'skip' | null;
|
||||||
export type StoreItem = {
|
type StoreItem = {
|
||||||
/** Unique identifier for the item */
|
/** Unique identifier for the item */
|
||||||
_id: string
|
_id: string
|
||||||
/** Error message if available */
|
/** Error message if available */
|
||||||
|
|
@ -54,6 +54,15 @@ export type StoreItem = {
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
/** The uploader of the item if available */
|
/** The uploader of the item if available */
|
||||||
uploader?: string
|
uploader?: string
|
||||||
|
/** Uploader name if available */
|
||||||
|
is_audio?: boolean
|
||||||
|
/** If the item has audio stream */
|
||||||
|
is_video?: boolean
|
||||||
|
/** If the item has video stream */
|
||||||
|
live_in?: string
|
||||||
|
/** Live stream start time if available */
|
||||||
|
is_premiere?: boolean
|
||||||
|
/** If the item is a premiere */
|
||||||
}
|
}
|
||||||
/** The item temporary filename */
|
/** The item temporary filename */
|
||||||
tmpfilename?: string | null
|
tmpfilename?: string | null
|
||||||
|
|
@ -74,3 +83,5 @@ export type StoreItem = {
|
||||||
/** Time remaining for the item download if available */
|
/** Time remaining for the item download if available */
|
||||||
eta?: number | null
|
eta?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { ItemStatus, StoreItem }
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ type task_item = {
|
||||||
timer?: string,
|
timer?: string,
|
||||||
in_progress?: boolean,
|
in_progress?: boolean,
|
||||||
auto_start?: boolean,
|
auto_start?: boolean,
|
||||||
|
handler_enabled?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
type exported_task = task_item & { _type: string, _version: string }
|
type exported_task = task_item & { _type: string, _version: string }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { convert_args_response } from "~/types/responses";
|
import type { convert_args_response } from "~/types/responses";
|
||||||
|
import type { StoreItem } from "~/types/store";
|
||||||
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -437,7 +438,7 @@ const getQueryParams = (url: string = window.location.search): Record<string, st
|
||||||
* @param base - The base endpoint type (default: 'api/download').
|
* @param base - The base endpoint type (default: 'api/download').
|
||||||
* @returns The fully constructed download URI.
|
* @returns The fully constructed download URI.
|
||||||
*/
|
*/
|
||||||
const makeDownload = (config: any, item: { folder?: string; filename: string }, base: string = 'api/download'): string => {
|
const makeDownload = (config: any, item: StoreItem | { folder?: string; filename: string }, base: string = 'api/download'): string => {
|
||||||
let baseDir = 'api/player/m3u8/video/'
|
let baseDir = 'api/player/m3u8/video/'
|
||||||
if ('m3u8' !== base) {
|
if ('m3u8' !== base) {
|
||||||
baseDir = `${base}/`
|
baseDir = `${base}/`
|
||||||
|
|
@ -448,6 +449,10 @@ const makeDownload = (config: any, item: { folder?: string; filename: string },
|
||||||
baseDir += item.folder + '/'
|
baseDir += item.folder + '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!item.filename) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`
|
const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`
|
||||||
return uri('m3u8' === base ? `${url}.m3u8` : url)
|
return uri('m3u8' === base ? `${url}.m3u8` : url)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue