Make it possible to manage presets via webui

This commit is contained in:
ArabCoders 2025-03-04 02:11:17 +03:00
parent d88ee15060
commit 49bc28edc0
13 changed files with 794 additions and 84 deletions

View file

@ -44,7 +44,8 @@ class Events:
TASK_FINISHED = "task_finished"
TASK_ERROR = "task_error"
PRESETS_ADD = "preset_add"
PRESETS_ADD = "presets_add"
PRESETS_UPDATE = "presets_update"
SCHEDULE_ADD = "schedule_add"

View file

@ -30,7 +30,7 @@ from .ffprobe import ffprobe
from .M3u8 import M3u8
from .Notifications import Notification, NotificationEvents
from .Playlist import Playlist
from .Presets import Presets
from .Presets import Preset, Presets
from .Segments import Segments
from .Subtitle import Subtitle
from .Tasks import Task, Tasks
@ -317,7 +317,10 @@ class HttpAPI(Common):
@web.middleware
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
if request.path.startswith("/api/download"):
realFile, status = get_file(download_path=download_path, file=request.path.replace("/api/download/", ""))
realFile, status = get_file(
download_path=download_path,
file=request.path.replace("/api/download/", ""),
)
if web.HTTPFound.status_code == status:
return Response(
status=status,
@ -627,6 +630,83 @@ class HttpAPI(Common):
data=Presets.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode
)
@route("PUT", "api/presets")
async def presets_add(self, request: Request) -> Response:
"""
Add presets.
Args:
request (Request): The request object.
Returns:
Response: The response object
"""
data = await request.json()
if not isinstance(data, list):
return web.json_response(
{"error": "Invalid request body expecting list with dicts."},
status=web.HTTPBadRequest.status_code,
)
presets: list = []
cls = Presets.get_instance()
for item in data:
if not isinstance(item, dict):
return web.json_response(
{"error": "Invalid request body expecting list with dicts."},
status=web.HTTPBadRequest.status_code,
)
if not item.get("name"):
return web.json_response(
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("format"):
return web.json_response(
{"error": "format is required.", "data": item}, status=web.HTTPBadRequest.status_code
)
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4())
if not item.get("args", None) or str(item.get("args")).strip() == "":
item["config"] = {}
if item.get("args", None) and isinstance(item.get("args"), str):
item["args"] = json.loads(item.get("args"))
if not item.get("postprocessors", None) or str(item.get("postprocessors")).strip() == "":
item["postprocessors"] = []
if item.get("postprocessors", None) and isinstance(item.get("postprocessors"), str):
item["postprocessors"] = json.loads(item.get("postprocessors"))
try:
cls.validate(item)
except ValueError as e:
return web.json_response(
{"error": f"Failed to validate preset '{item.get('name')}'. '{e!s}'"},
status=web.HTTPBadRequest.status_code,
)
presets.append(Preset(**item))
try:
presets = cls.save(presets=presets).load().get_all()
except Exception as e:
LOG.exception(e)
return web.json_response(
{"error": "Failed to save presets.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
await self.emitter.emit(Events.PRESETS_UPDATE, presets)
return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "api/tasks")
async def tasks(self, _: Request) -> Response:
"""

View file

@ -19,6 +19,7 @@ from .DownloadQueue import DownloadQueue
from .Emitter import Emitter
from .encoder import Encoder
from .EventsSubscriber import Event, Events, EventsSubscriber
from .Presets import Presets
from .Utils import arg_converter, is_downloaded
LOG = logging.getLogger("socket_api")
@ -266,7 +267,7 @@ class HttpSocket(Common):
data = {
**self.queue.get(),
"config": self.config.frontend(),
"presets": self.config.presets,
"presets": Presets.get_instance().get_all(),
"paused": self.queue.is_paused(),
}

View file

@ -1,7 +1,7 @@
import asyncio
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any
@ -18,6 +18,9 @@ LOG = logging.getLogger("presets")
@dataclass(kw_only=True)
class Preset:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
"""The id of the preset."""
name: str
"""The name of the preset."""
@ -30,6 +33,8 @@ class Preset:
postprocessors: list | None = field(default_factory=list)
"""The postprocessors of the preset."""
default: bool = False
def serialize(self) -> dict:
return self.__dict__
@ -51,19 +56,14 @@ class Presets(metaclass=Singleton):
_instance = None
"""The instance of the class."""
def __init__(
self,
file: str | None = None,
emitter: Emitter | None = None,
loop: asyncio.AbstractEventLoop | None = None,
config: Config | None = None,
):
_default_presets: list[Preset] = []
def __init__(self, file: str | None = None, emitter: Emitter | None = None, config: Config | None = None):
Presets._instance = self
config = config or Config.get_instance()
self._file: str = file or os.path.join(config.config_path, "presets.json")
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
self._emitter: Emitter = emitter or Emitter.get_instance()
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
@ -75,6 +75,9 @@ class Presets(metaclass=Singleton):
def handle_event(_, e: Event):
self.save(**e.data)
with open(os.path.join(os.path.dirname(__file__), "presets.json")) as f:
self._default_presets = [Preset(**preset) for preset in json.load(f)]
EventsSubscriber.get_instance().subscribe(Events.PRESETS_ADD, f"{__class__}.save", handle_event)
@staticmethod
@ -109,7 +112,7 @@ class Presets(metaclass=Singleton):
def get_all(self) -> list[Preset]:
"""Return the presets."""
return self._presets
return self._default_presets + self._presets
def load(self) -> "Presets":
"""
@ -136,14 +139,24 @@ class Presets(metaclass=Singleton):
LOG.info(f"No presets were defined in '{self._file}'.")
return self
needSaving = False
for i, preset in enumerate(presets):
try:
if "id" not in preset:
preset["id"] = str(uuid.uuid4())
needSaving = True
preset = Preset(**preset)
self._presets.append(preset)
except Exception as e:
LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.")
continue
if needSaving:
LOG.info("Saving presets due to missing ids.")
self.save(self._presets)
return self
def clear(self) -> "Presets":
@ -179,6 +192,10 @@ class Presets(metaclass=Singleton):
preset = preset.serialize()
if not preset.get("id"):
msg = "No id found."
raise ValueError(msg)
if not preset.get("name"):
msg = "No name found."
raise ValueError(msg)
@ -232,3 +249,27 @@ class Presets(metaclass=Singleton):
LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.")
return self
def get(self, id: str | None = None, name: str | None = None) -> Preset | None:
"""
Get the preset by id or name.
Args:
id (str|None): The id of the preset.
name (str|None): The name of the preset.
Returns:
Preset|None: The preset if found, None otherwise.
"""
if not id and not name:
return None
for preset in self.get_all():
if preset.id == id:
return preset
if preset.name == name:
return preset
return None

View file

@ -52,28 +52,22 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
LOG.debug("Using default preset.")
return opts
from .config import Config
from .Presets import Presets
presets = Config.get_instance().presets
found = False
for _preset in presets:
if _preset["name"] == preset:
found = True
preset_opts = _preset
break
if not found:
LOG.error(f"Preset '{preset}' is not defined in the presets.")
p = Presets.get_instance().get(name=preset)
if not p:
LOG.error(f"Preset '{preset}' is not defined as preset.")
return opts
opts["format"] = preset_opts.get("format")
opts["format"] = p.get("format")
if "postprocessors" in preset_opts:
opts["postprocessors"] = preset_opts["postprocessors"]
postprocessors = p.get("postprocessors", [])
if isinstance(postprocessors, list) and len(postprocessors) > 0:
opts["postprocessors"] = postprocessors
if "args" in preset_opts:
for key, value in preset_opts["args"].items():
args = p.get("args", {})
if isinstance(args, dict) and len(args) > 0:
for key, value in args.items():
opts[key] = value
LOG.debug(f"Using preset '{preset}', altered options: {opts}")

View file

@ -50,7 +50,7 @@ class Common:
return await self.queue.add(
url=url,
preset=preset if preset else "default",
preset=preset if preset else self.config.default_preset,
folder=folder,
cookies=cookies,
config=config if isinstance(config, dict) else {},

View file

@ -129,11 +129,6 @@ class Config:
basic_mode: bool = False
"Run the frontend in basic mode."
presets: list = [
{"name": "default", "format": "default"},
]
"The presets to use for downloading."
default_preset: str = "default"
"The default preset to use when no preset is specified."
@ -161,7 +156,6 @@ class Config:
"new_version_available",
"ytdlp_version",
"started",
"presets",
)
"The variables that are immutable."
@ -323,45 +317,6 @@ class Config:
else:
LOG.info(f"No yt-dlp custom options found at '{optsFile}'.")
# Load default presets.
with open(os.path.join(os.path.dirname(__file__), "presets.json")) as f:
self.presets.extend(json.load(f))
# Load user defined presets.
presetsFile = os.path.join(self.config_path, "presets.json")
if os.path.exists(presetsFile) and os.path.getsize(presetsFile) > 0:
LOG.info(f"Loading user presets from '{presetsFile}'.")
try:
(presets, status, error) = load_file(presetsFile, list)
if not status:
LOG.error(f"Could not load presets file from '{presetsFile}'. '{error}'.")
sys.exit(1)
if not isinstance(presets, list):
LOG.error(f"Invalid presets file '{presetsFile}'. It's expected to be a list of objects.")
sys.exit(1)
for preset in presets:
if "name" not in preset:
LOG.error(f"Missing 'name' key in preset '{preset}'.")
continue
if "format" not in preset:
LOG.error(f"Missing 'format' key in preset '{preset}'.")
continue
if "args" in preset and not isinstance(preset["args"], dict):
LOG.error(f"Invalid 'args' key in preset '{preset}' it's expected to be dict.")
continue
if "postprocessors" in preset and not isinstance(preset["postprocessors"], list):
LOG.error(f"Invalid 'postprocessors' key in preset '{preset}' it's expected to be list.")
continue
self.presets.append(preset)
except Exception:
pass
self.ytdl_options["socket_timeout"] = self.socket_timeout
if self.keep_archive:

View file

@ -1,27 +1,40 @@
[
{
"name": "Best video and audio",
"format": "bv+ba/b"
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
"name": "default",
"format": "default",
"default": true
},
{
"id": "5bf9c42b-8852-468a-99f5-915622dfba25",
"name": "Best video and audio",
"format": "bv+ba/b",
"default": true
},
{
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
"name": "1080p H264/m4a or best available",
"format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": [
"vcodec:h264"
]
}
},
"default": true
},
{
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
"name": "720p h264/m4a or best available",
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": [
"vcodec:h264"
]
}
},
"default": true
},
{
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
"name": "Audio only",
"format": "bestaudio/best",
"args": {
@ -49,6 +62,7 @@
"only_multi_video": true,
"when": "playlist"
}
]
],
"default": true
}
]

View file

@ -221,7 +221,7 @@ hr {
padding-top: 0.5em;
}
.play-overlay {
.play-overlay, .is-pointer {
cursor: pointer;
}

View file

@ -0,0 +1,362 @@
<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<h1 class="is-pointer is-unselectable title is-5" @click="importExpanded = !importExpanded">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="importExpanded ? 'fa-arrow-up' : 'fa-arrow-down'" /></span>
<span>Import preset from JSON string.</span>
</span>
</h1>
<form id="importForm" @submit.prevent="importPreset()" v-if="importExpanded">
<div class="box">
<label class="label" for="json_preset">
JSON encoded preset.
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="json_preset" v-model="json_preset">
<span class="icon is-small is-left"><i class="fa-solid fa-j" /></span>
</div>
<div class="control">
<button form="importForm" class="button is-primary" :disabled="!json_preset" type="submit">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
</div>
</form>
</div>
<div class="column is-12">
<h1 class="is-unselectable is-pointer title is-5" @click="convertExpanded = !convertExpanded">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="convertExpanded ? 'fa-arrow-up' : 'fa-arrow-down'" /></span>
<span>Convert yt-dlp cli options.</span>
</span>
</h1>
<form id="convertOpts" @submit.prevent="convertOptions()" v-if="convertExpanded">
<div class="box">
<label class="label" for="opts">
yt-dlp CLI options
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="opts" v-model="opts"
placeholder="-x --audio-format mp3 -f bestaudio">
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
</div>
<div class="control">
<button form="convertOpts" class="button is-primary" :disabled="convertInProgress || !opts" type="submit"
:class="{ 'is-loading': convertInProgress }">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Convert</span>
</button>
</div>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Convert yt-dlp CLI options to JSON format. This will overwrite the current form fields.</span>
</p>
</div>
</form>
</div>
<div class="column is-12">
<form id="presetForm" @submit.prevent="checkInfo()">
<div class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</h1>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
Preset name
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The name to refers to this custom settings.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format">
Format
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-f" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress" placeholder="{}" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="postprocessors" v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Post-processing operations, refer to <NuxtLink
href="https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor" target="blank">this url
</NuxtLink> for more info. It's easier for you to use the <b>Convert CLI options</b> to get what you
want and it will auto-populate the fields if necessary.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field is-grouped is-grouped-right">
<p class="control">
<button class="button is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="presetForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="control">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
</div>
</div>
</div>
</div>
</form>
</div>
</main>
</template>
<script setup>
const emitter = defineEmits(['cancel', 'submit']);
const props = defineProps({
reference: {
type: String,
required: false,
default: null,
},
preset: {
type: Object,
required: true,
},
addInProgress: {
type: Boolean,
required: false,
default: false,
},
presets: {
type: Array,
required: false,
default: () => [],
},
})
const toast = useToast();
const convertInProgress = ref(false);
const form = reactive(props.preset);
const opts = ref('');
const json_preset = ref('');
const importExpanded = ref(false);
const convertExpanded = ref(false);
onMounted(() => {
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 () => {
const required = ['name', 'format'];
for (const key of required) {
if (!form[key]) {
toast.error(`The ${key} field is required.`);
return;
}
}
let copy = JSON.parse(JSON.stringify(form));
let usedName = false;
let name = String(form.name).trim().toLowerCase();
props.presets.forEach(p => {
if (p.id === props.reference) {
return;
}
if (String(p.name).toLowerCase() === name) {
usedName = true;
}
});
if (true === usedName) {
toast.error('The preset name is already in use.');
return;
}
if (typeof copy.args === 'object') {
copy.config = JSON.stringify(copy.args, null, 2);
}
if (typeof copy.postprocessors === 'object') {
copy.postprocessors = JSON.stringify(copy.postprocessors, null, 2);
}
if (copy.args) {
try {
copy.args = JSON.parse(copy.args)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
}
}
if (copy.postprocessors) {
try {
copy.postprocessors = JSON.parse(copy.postprocessors)
} catch (e) {
toast.error(`Invalid JSON yt-dlp Post-Processors. ${e.message}`)
return;
}
}
emitter('submit', { reference: toRaw(props.reference), preset: toRaw(copy) });
}
const convertOptions = async () => {
if (convertInProgress.value) {
return
}
if (form.format || form.args || form.postprocessors) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
try {
convertInProgress.value = true
const response = await convertCliOptions(opts.value)
if (!response.opts) {
toast.error('Failed to convert options.')
return
}
if (response.opts.format) {
form.format = response.opts.format
delete response.opts.format
}
if (response.opts.postprocessors) {
form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2)
delete response.opts.postprocessors
}
form.args = JSON.stringify(response.opts, null, 2)
opts.value = ''
} catch (e) {
toast.error(e.message)
} finally {
convertInProgress.value = false
}
}
const importPreset = async () => {
if (!json_preset) {
return
}
if (form.format || form.args || form.postprocessors) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
try {
const preset = JSON.parse(json_preset.value)
if (preset.name) {
form.name = preset.name
}
if (preset.format) {
form.format = preset.format
}
if (preset.args) {
form.args = JSON.stringify(preset.args, null, 2)
}
if (preset.postprocessors) {
form.postprocessors = JSON.stringify(preset.postprocessors, null, 2)
}
json_preset.value = ''
} catch (e) {
toast.error(`Failed to import preset. ${e.message}`)
}
}
</script>

View file

@ -23,6 +23,12 @@
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/presets" v-tooltip.bottom="'Presets'">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console" v-tooltip.bottom="'Terminal'">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
@ -56,7 +62,7 @@
</button>
</div>
<div class="navbar-item">
<div class="navbar-item is-hidden-mobile">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>

255
ui/pages/presets.vue Normal file
View file

@ -0,0 +1,255 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
div.is-centered {
justify-content: center;
}
</style>
<template>
<div>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</span>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm">
<span class="icon"><i class="fas fa-add"></i></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Custom presets. The presets are simply pre-defined yt-dlp settings that you want to
apply to given download.</span>
</div>
</div>
<div class="column is-12" v-if="toggleForm">
<PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true);"
@submit="updateItem" :presets="presets" />
</div>
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="presetsNoDefault && presetsNoDefault.length > 0">
<div class="column is-6" v-for="item in presetsNoDefault" :key="item.id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<a class="has-text-primary" v-tooltip="'Copy preset.'"
@click.prevent="copyText(JSON.stringify(item))">
<span class="icon"><i class="fa-solid fa-copy" /></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">
<div class="content">
<p>
<span class="icon"><i class="fa-solid fa-f" /></span>
<span v-text="item.format" />
</p>
</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">
<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>
<Message title="No presets" message="There are no custom defined presets."
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
v-if="!presets || presets.length < 1" />
</div>
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
const toast = useToast()
const config = useConfigStore()
const socket = useSocketStore()
const presets = ref([])
const preset = ref({})
const presetRef = ref('')
const toggleForm = ref(false)
const isLoading = ref(false)
const initialLoad = ref(true)
const addInProgress = ref(false)
const presetsNoDefault = computed(() => presets.value.filter(t => !t.default))
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo('/')
})
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
initialLoad.value = false
}
})
const reloadContent = async (fromMounted = false) => {
try {
isLoading.value = true
const response = await request('/api/presets')
if (fromMounted && !response.ok) {
return
}
const data = await response.json()
if (data.length < 1) {
return
}
presets.value = data
} catch (e) {
if (fromMounted) {
return
}
console.error(e)
toast.error('Failed to fetch tasks.')
} finally {
isLoading.value = false
}
}
const resetForm = (closeForm = false) => {
preset.value = {}
presetRef.value = null
addInProgress.value = false
if (closeForm) {
toggleForm.value = false
}
}
const updatePresets = async items => {
let data;
try {
addInProgress.value = true
const response = await request('/api/presets', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(items.filter(t => !t.default)),
})
data = await response.json()
if (200 !== response.status) {
toast.error(`Failed to update presets. ${data.error}`);
return false
}
presets.value = data
resetForm(true)
return true
} catch (e) {
toast.error(`Failed to update presets. ${data?.error}. ${e.message}`);
} finally {
addInProgress.value = false
}
}
const deleteItem = async item => {
if (true !== confirm(`Delete preset '${item.name}'?`)) {
return
}
const index = presets.value.findIndex(t => t?.id === item.id)
if (index > -1) {
presets.value.splice(index, 1)
} else {
toast.error('Preset not found.')
return
}
const status = await updatePresets(presets.value)
if (!status) {
return
}
toast.success('Preset deleted.')
}
const updateItem = async ({ reference, preset }) => {
if (reference) {
const index = presets.value.findIndex((t) => t?.id === reference)
if (index > -1) {
presets.value[index] = preset
}
} else {
presets.value.push(preset)
}
const status = await updatePresets(presets.value)
if (!status) {
return
}
toast.success(`Preset ${reference ? 'updated' : 'added'}.`)
resetForm(true)
}
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = item => {
preset.value = item
presetRef.value = item.id
toggleForm.value = true
}
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
</script>

View file

@ -124,6 +124,7 @@ export const useSocketStore = defineStore('socket', () => {
});
});
socket.value.on('presets_update', data => config.update('presets', JSON.parse(data)));
}
const on = (event, callback) => socket.value.on(event, callback);