commit
48c849da5a
16 changed files with 967 additions and 135 deletions
|
|
@ -13,6 +13,9 @@ class Events:
|
|||
The events that can be emitted.
|
||||
"""
|
||||
|
||||
STARTUP = "startup"
|
||||
SHUTDOWN = "shutdown"
|
||||
|
||||
ADDED = "added"
|
||||
UPDATED = "updated"
|
||||
COMPLETED = "completed"
|
||||
|
|
@ -41,7 +44,9 @@ 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"
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
@ -640,7 +720,7 @@ class HttpAPI(Common):
|
|||
|
||||
"""
|
||||
return web.json_response(
|
||||
data=Tasks.get_instance().get_tasks(), status=web.HTTPOk.status_code, dumps=self.encoder.encode
|
||||
data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode
|
||||
)
|
||||
|
||||
@route("PUT", "api/tasks")
|
||||
|
|
@ -705,7 +785,7 @@ class HttpAPI(Common):
|
|||
tasks.append(Task(**item))
|
||||
|
||||
try:
|
||||
tasks = ins.save(tasks=tasks).load().get_tasks()
|
||||
tasks = ins.save(tasks=tasks).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
app/library/Scheduler.py
Normal file
136
app/library/Scheduler.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiocron import Cron
|
||||
from aiohttp import web
|
||||
|
||||
from .EventsSubscriber import Event, Events, EventsSubscriber
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG = logging.getLogger("scheduler")
|
||||
|
||||
|
||||
class Scheduler(metaclass=Singleton):
|
||||
"""
|
||||
This class is used to manage the schedule.
|
||||
"""
|
||||
|
||||
_jobs: dict[str, Cron] = {}
|
||||
"""The scheduled jobs."""
|
||||
|
||||
_instance = None
|
||||
"""The instance of the class."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop | None = None):
|
||||
Scheduler._instance = self
|
||||
|
||||
self._loop = loop or asyncio.get_event_loop()
|
||||
|
||||
def handle_event(_, e: Event):
|
||||
self.add(**e.data)
|
||||
|
||||
EventsSubscriber.get_instance().subscribe(Events.SCHEDULE_ADD, f"{__class__}.add", handle_event)
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> "Scheduler":
|
||||
"""
|
||||
Get the instance of the class.
|
||||
|
||||
Returns:
|
||||
Scheduler: The instance of the class
|
||||
|
||||
"""
|
||||
if not Scheduler._instance:
|
||||
Scheduler._instance = Scheduler()
|
||||
return Scheduler._instance
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
"""
|
||||
Do any jobs before shutdown.
|
||||
|
||||
Args:
|
||||
_: The aiohttp application.
|
||||
|
||||
"""
|
||||
LOG.debug("Shutting down the scheduler.")
|
||||
|
||||
for job in self._jobs:
|
||||
try:
|
||||
job.stop()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.")
|
||||
|
||||
self._jobs = {}
|
||||
|
||||
LOG.debug("Scheduler has been shut down.")
|
||||
|
||||
def attach(self, app: web.Application):
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
def get_all(self) -> dict[str, Cron]:
|
||||
"""Return the jobs."""
|
||||
return self._jobs
|
||||
|
||||
def get(self, id: str) -> Cron | None:
|
||||
"""Return the job by id."""
|
||||
return self._jobs.get(id)
|
||||
|
||||
def add(self, timer: str, func: callable, args: tuple = (),
|
||||
kwargs: dict | None = None, id: str | None = None) -> str:
|
||||
"""
|
||||
Add a job to the schedule.
|
||||
|
||||
Args:
|
||||
timer (str): The timer for the job.
|
||||
func (callable): The function to call.
|
||||
args (tuple): The arguments to pass to the function.
|
||||
kwargs (dict): The keyword arguments to pass to the function.
|
||||
id (str): The id of the job.
|
||||
|
||||
Returns:
|
||||
str: The id of the job
|
||||
|
||||
"""
|
||||
if id and id in self._jobs:
|
||||
self.remove(id)
|
||||
|
||||
job = Cron(spec=timer, func=func, args=args, kwargs=kwargs, uuid=id, start=True, loop=self._loop)
|
||||
|
||||
job_id = str(job.uuid)
|
||||
|
||||
self._jobs[job_id] = job
|
||||
|
||||
LOG.debug(f"Added job {job_id} to the schedule.")
|
||||
|
||||
return job_id
|
||||
|
||||
def remove(self, id: str | list[str]) -> bool:
|
||||
"""
|
||||
Remove a job from the schedule.
|
||||
|
||||
Args:
|
||||
id (str|list[str]): The id of the job to remove.
|
||||
|
||||
Returns:
|
||||
bool: True if the job was removed, False otherwise
|
||||
|
||||
"""
|
||||
if isinstance(id, list):
|
||||
for i in id:
|
||||
self.remove(i)
|
||||
return True
|
||||
|
||||
if id in self._jobs:
|
||||
try:
|
||||
self._jobs[id].stop()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to stop job '{id}'. Error message '{e!s}'.")
|
||||
return False
|
||||
|
||||
del self._jobs[id]
|
||||
LOG.debug(f"Removed job {id} from the schedule.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -8,13 +8,13 @@ from datetime import UTC, datetime
|
|||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from aiocron import Cron, crontab
|
||||
from aiohttp import web
|
||||
|
||||
from .config import Config
|
||||
from .Emitter import Emitter
|
||||
from .encoder import Encoder
|
||||
from .EventsSubscriber import Event, Events, EventsSubscriber
|
||||
from .Scheduler import Scheduler
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG = logging.getLogger("tasks")
|
||||
|
|
@ -42,21 +42,13 @@ class Task:
|
|||
return self.serialize().get(key, default)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Job:
|
||||
id: str
|
||||
name: str
|
||||
task: Task
|
||||
job: Cron
|
||||
|
||||
|
||||
class Tasks(metaclass=Singleton):
|
||||
"""
|
||||
This class is used to manage the tasks.
|
||||
"""
|
||||
|
||||
_jobs: list[Job] = []
|
||||
"""The jobs for the tasks."""
|
||||
_tasks: list[Task] = []
|
||||
"""The tasks."""
|
||||
|
||||
_instance = None
|
||||
"""The instance of the Tasks."""
|
||||
|
|
@ -69,6 +61,7 @@ class Tasks(metaclass=Singleton):
|
|||
config: Config | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
scheduler: Scheduler | None = None,
|
||||
):
|
||||
Tasks._instance = self
|
||||
|
||||
|
|
@ -76,11 +69,12 @@ class Tasks(metaclass=Singleton):
|
|||
|
||||
self._debug = config.debug
|
||||
self._default_preset = config.default_preset
|
||||
self._file: str = file or os.path.join(config.config_path, "tasks.json")
|
||||
self._client: httpx.AsyncClient = client or httpx.AsyncClient()
|
||||
self._encoder: Encoder = encoder or Encoder()
|
||||
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
|
||||
self._emitter: Emitter = emitter or Emitter.get_instance()
|
||||
self._file = file or os.path.join(config.config_path, "tasks.json")
|
||||
self._client = client or httpx.AsyncClient()
|
||||
self._encoder = encoder or Encoder()
|
||||
self._loop = loop or asyncio.get_event_loop()
|
||||
self._emitter = emitter or Emitter.get_instance()
|
||||
self._scheduler = scheduler or Scheduler.get_instance()
|
||||
|
||||
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
|
||||
try:
|
||||
|
|
@ -96,27 +90,19 @@ class Tasks(metaclass=Singleton):
|
|||
@staticmethod
|
||||
def get_instance() -> "Tasks":
|
||||
"""
|
||||
Get the instance of the Tasks.
|
||||
Get the instance of the class.
|
||||
|
||||
Returns:
|
||||
Tasks: The instance of the Tasks
|
||||
Tasks: The instance of the class.
|
||||
|
||||
"""
|
||||
if not Tasks._instance:
|
||||
Tasks._instance = Tasks()
|
||||
|
||||
return Tasks._instance
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
"""
|
||||
Shutdown the socket server.
|
||||
|
||||
Args:
|
||||
_: The aiohttp application.
|
||||
|
||||
"""
|
||||
LOG.debug("Shutting down tasks runner.")
|
||||
self.clear(shutdown=True)
|
||||
LOG.debug("Tasks runner has been shut down.")
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
"""
|
||||
|
|
@ -125,15 +111,12 @@ class Tasks(metaclass=Singleton):
|
|||
Args:
|
||||
_ (web.Application): The aiohttp application.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
self.load()
|
||||
|
||||
def get_tasks(self) -> list[Task]:
|
||||
def get_all(self) -> list[Task]:
|
||||
"""Return the tasks."""
|
||||
return [job.task for job in self._jobs]
|
||||
return self._tasks
|
||||
|
||||
def load(self) -> "Tasks":
|
||||
"""
|
||||
|
|
@ -168,18 +151,13 @@ class Tasks(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
try:
|
||||
self._jobs.append(
|
||||
Job(
|
||||
id=task.id,
|
||||
name=task.name,
|
||||
task=task,
|
||||
job=crontab(spec=task.timer, func=self._runner, args=(task,), start=True, loop=self._loop),
|
||||
)
|
||||
)
|
||||
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task.id)
|
||||
self._tasks.append(task)
|
||||
|
||||
LOG.info(f"Task '{i}: {task.name}' queued to be executed every '{task.timer}'.")
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to queue task '{i}: {task['name']}'. '{e!s}'.")
|
||||
LOG.error(f"Failed to queue task '{i}: {task.name}'. '{e!s}'.")
|
||||
|
||||
return self
|
||||
|
||||
|
|
@ -191,19 +169,19 @@ class Tasks(metaclass=Singleton):
|
|||
Tasks: The current instance.
|
||||
|
||||
"""
|
||||
if len(self._jobs) < 1:
|
||||
if len(self._tasks) < 1:
|
||||
return self
|
||||
|
||||
for task in self._jobs:
|
||||
for task in self._tasks:
|
||||
try:
|
||||
LOG.info(f"Stopping job '{task.id}: {task.name}'.")
|
||||
task.job.stop()
|
||||
LOG.info(f"Stopping task '{task.id}: {task.name}'.")
|
||||
self._scheduler.remove(task.id)
|
||||
except Exception as e:
|
||||
if not shutdown:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to stop job '{task.id}: {task.name}'. '{e!s}'.")
|
||||
LOG.error(f"Failed to stop task '{task.id}: {task.name}'. '{e!s}'.")
|
||||
|
||||
self._jobs.clear()
|
||||
self._tasks.clear()
|
||||
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ from aiohttp import web
|
|||
from library.config import Config
|
||||
from library.DownloadQueue import DownloadQueue
|
||||
from library.Emitter import Emitter
|
||||
from library.EventsSubscriber import EventsSubscriber
|
||||
from library.EventsSubscriber import Events, EventsSubscriber
|
||||
from library.HttpAPI import HttpAPI
|
||||
from library.HttpSocket import HttpSocket
|
||||
from library.Notifications import Notification
|
||||
from library.PackageInstaller import PackageInstaller
|
||||
from library.Presets import Presets
|
||||
from library.Scheduler import Scheduler
|
||||
from library.Tasks import Tasks
|
||||
|
||||
LOG = logging.getLogger("app")
|
||||
|
|
@ -93,9 +94,12 @@ class Main:
|
|||
"""
|
||||
Start the application.
|
||||
"""
|
||||
EventsSubscriber.get_instance().emit(Events.STARTUP, data={"app": self._app})
|
||||
|
||||
self._socket.attach(self._app)
|
||||
self._http.attach(self._app)
|
||||
self._queue.attach(self._app)
|
||||
Scheduler.get_instance().attach(self._app)
|
||||
Tasks.get_instance().attach(self._app)
|
||||
Presets.get_instance().attach(self._app)
|
||||
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ hr {
|
|||
padding-top: 0.5em;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
.play-overlay, .is-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
|
|||
362
ui/components/PresetForm.vue
Normal file
362
ui/components/PresetForm.vue
Normal 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>
|
||||
|
|
@ -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
255
ui/pages/presets.vue
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue