commit
48c849da5a
16 changed files with 967 additions and 135 deletions
|
|
@ -13,6 +13,9 @@ class Events:
|
||||||
The events that can be emitted.
|
The events that can be emitted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
STARTUP = "startup"
|
||||||
|
SHUTDOWN = "shutdown"
|
||||||
|
|
||||||
ADDED = "added"
|
ADDED = "added"
|
||||||
UPDATED = "updated"
|
UPDATED = "updated"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
|
|
@ -41,7 +44,9 @@ class Events:
|
||||||
TASK_FINISHED = "task_finished"
|
TASK_FINISHED = "task_finished"
|
||||||
TASK_ERROR = "task_error"
|
TASK_ERROR = "task_error"
|
||||||
|
|
||||||
PRESETS_ADD = "preset_add"
|
PRESETS_ADD = "presets_add"
|
||||||
|
PRESETS_UPDATE = "presets_update"
|
||||||
|
SCHEDULE_ADD = "schedule_add"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ from .ffprobe import ffprobe
|
||||||
from .M3u8 import M3u8
|
from .M3u8 import M3u8
|
||||||
from .Notifications import Notification, NotificationEvents
|
from .Notifications import Notification, NotificationEvents
|
||||||
from .Playlist import Playlist
|
from .Playlist import Playlist
|
||||||
from .Presets import Presets
|
from .Presets import Preset, Presets
|
||||||
from .Segments import Segments
|
from .Segments import Segments
|
||||||
from .Subtitle import Subtitle
|
from .Subtitle import Subtitle
|
||||||
from .Tasks import Task, Tasks
|
from .Tasks import Task, Tasks
|
||||||
|
|
@ -317,7 +317,10 @@ class HttpAPI(Common):
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
||||||
if request.path.startswith("/api/download"):
|
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:
|
if web.HTTPFound.status_code == status:
|
||||||
return Response(
|
return Response(
|
||||||
status=status,
|
status=status,
|
||||||
|
|
@ -627,6 +630,83 @@ class HttpAPI(Common):
|
||||||
data=Presets.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode
|
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")
|
@route("GET", "api/tasks")
|
||||||
async def tasks(self, _: Request) -> Response:
|
async def tasks(self, _: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
|
|
@ -640,7 +720,7 @@ class HttpAPI(Common):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return web.json_response(
|
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")
|
@route("PUT", "api/tasks")
|
||||||
|
|
@ -705,7 +785,7 @@ class HttpAPI(Common):
|
||||||
tasks.append(Task(**item))
|
tasks.append(Task(**item))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tasks = ins.save(tasks=tasks).load().get_tasks()
|
tasks = ins.save(tasks=tasks).load().get_all()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from .DownloadQueue import DownloadQueue
|
||||||
from .Emitter import Emitter
|
from .Emitter import Emitter
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .EventsSubscriber import Event, Events, EventsSubscriber
|
from .EventsSubscriber import Event, Events, EventsSubscriber
|
||||||
|
from .Presets import Presets
|
||||||
from .Utils import arg_converter, is_downloaded
|
from .Utils import arg_converter, is_downloaded
|
||||||
|
|
||||||
LOG = logging.getLogger("socket_api")
|
LOG = logging.getLogger("socket_api")
|
||||||
|
|
@ -266,7 +267,7 @@ class HttpSocket(Common):
|
||||||
data = {
|
data = {
|
||||||
**self.queue.get(),
|
**self.queue.get(),
|
||||||
"config": self.config.frontend(),
|
"config": self.config.frontend(),
|
||||||
"presets": self.config.presets,
|
"presets": Presets.get_instance().get_all(),
|
||||||
"paused": self.queue.is_paused(),
|
"paused": self.queue.is_paused(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -18,6 +18,9 @@ LOG = logging.getLogger("presets")
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
class Preset:
|
class Preset:
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
"""The id of the preset."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
"""The name of the preset."""
|
"""The name of the preset."""
|
||||||
|
|
||||||
|
|
@ -30,6 +33,8 @@ class Preset:
|
||||||
postprocessors: list | None = field(default_factory=list)
|
postprocessors: list | None = field(default_factory=list)
|
||||||
"""The postprocessors of the preset."""
|
"""The postprocessors of the preset."""
|
||||||
|
|
||||||
|
default: bool = False
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
return self.__dict__
|
return self.__dict__
|
||||||
|
|
||||||
|
|
@ -51,19 +56,14 @@ class Presets(metaclass=Singleton):
|
||||||
_instance = None
|
_instance = None
|
||||||
"""The instance of the class."""
|
"""The instance of the class."""
|
||||||
|
|
||||||
def __init__(
|
_default_presets: list[Preset] = []
|
||||||
self,
|
|
||||||
file: str | None = None,
|
def __init__(self, file: str | None = None, emitter: Emitter | None = None, config: Config | None = None):
|
||||||
emitter: Emitter | None = None,
|
|
||||||
loop: asyncio.AbstractEventLoop | None = None,
|
|
||||||
config: Config | None = None,
|
|
||||||
):
|
|
||||||
Presets._instance = self
|
Presets._instance = self
|
||||||
|
|
||||||
config = config or Config.get_instance()
|
config = config or Config.get_instance()
|
||||||
|
|
||||||
self._file: str = file or os.path.join(config.config_path, "presets.json")
|
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()
|
self._emitter: Emitter = emitter or Emitter.get_instance()
|
||||||
|
|
||||||
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
|
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):
|
def handle_event(_, e: Event):
|
||||||
self.save(**e.data)
|
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)
|
EventsSubscriber.get_instance().subscribe(Events.PRESETS_ADD, f"{__class__}.save", handle_event)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -109,7 +112,7 @@ class Presets(metaclass=Singleton):
|
||||||
|
|
||||||
def get_all(self) -> list[Preset]:
|
def get_all(self) -> list[Preset]:
|
||||||
"""Return the presets."""
|
"""Return the presets."""
|
||||||
return self._presets
|
return self._default_presets + self._presets
|
||||||
|
|
||||||
def load(self) -> "Presets":
|
def load(self) -> "Presets":
|
||||||
"""
|
"""
|
||||||
|
|
@ -136,14 +139,24 @@ class Presets(metaclass=Singleton):
|
||||||
LOG.info(f"No presets were defined in '{self._file}'.")
|
LOG.info(f"No presets were defined in '{self._file}'.")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
needSaving = False
|
||||||
|
|
||||||
for i, preset in enumerate(presets):
|
for i, preset in enumerate(presets):
|
||||||
try:
|
try:
|
||||||
|
if "id" not in preset:
|
||||||
|
preset["id"] = str(uuid.uuid4())
|
||||||
|
needSaving = True
|
||||||
|
|
||||||
preset = Preset(**preset)
|
preset = Preset(**preset)
|
||||||
self._presets.append(preset)
|
self._presets.append(preset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.")
|
LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if needSaving:
|
||||||
|
LOG.info("Saving presets due to missing ids.")
|
||||||
|
self.save(self._presets)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def clear(self) -> "Presets":
|
def clear(self) -> "Presets":
|
||||||
|
|
@ -179,6 +192,10 @@ class Presets(metaclass=Singleton):
|
||||||
|
|
||||||
preset = preset.serialize()
|
preset = preset.serialize()
|
||||||
|
|
||||||
|
if not preset.get("id"):
|
||||||
|
msg = "No id found."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
if not preset.get("name"):
|
if not preset.get("name"):
|
||||||
msg = "No name found."
|
msg = "No name found."
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
@ -232,3 +249,27 @@ class Presets(metaclass=Singleton):
|
||||||
LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.")
|
LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.")
|
||||||
|
|
||||||
return self
|
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
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from aiocron import Cron, crontab
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .Emitter import Emitter
|
from .Emitter import Emitter
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .EventsSubscriber import Event, Events, EventsSubscriber
|
from .EventsSubscriber import Event, Events, EventsSubscriber
|
||||||
|
from .Scheduler import Scheduler
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
|
|
||||||
LOG = logging.getLogger("tasks")
|
LOG = logging.getLogger("tasks")
|
||||||
|
|
@ -42,21 +42,13 @@ class Task:
|
||||||
return self.serialize().get(key, default)
|
return self.serialize().get(key, default)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class Job:
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
task: Task
|
|
||||||
job: Cron
|
|
||||||
|
|
||||||
|
|
||||||
class Tasks(metaclass=Singleton):
|
class Tasks(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
This class is used to manage the tasks.
|
This class is used to manage the tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_jobs: list[Job] = []
|
_tasks: list[Task] = []
|
||||||
"""The jobs for the tasks."""
|
"""The tasks."""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
"""The instance of the Tasks."""
|
"""The instance of the Tasks."""
|
||||||
|
|
@ -69,6 +61,7 @@ class Tasks(metaclass=Singleton):
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
encoder: Encoder | None = None,
|
encoder: Encoder | None = None,
|
||||||
client: httpx.AsyncClient | None = None,
|
client: httpx.AsyncClient | None = None,
|
||||||
|
scheduler: Scheduler | None = None,
|
||||||
):
|
):
|
||||||
Tasks._instance = self
|
Tasks._instance = self
|
||||||
|
|
||||||
|
|
@ -76,11 +69,12 @@ class Tasks(metaclass=Singleton):
|
||||||
|
|
||||||
self._debug = config.debug
|
self._debug = config.debug
|
||||||
self._default_preset = config.default_preset
|
self._default_preset = config.default_preset
|
||||||
self._file: str = file or os.path.join(config.config_path, "tasks.json")
|
self._file = file or os.path.join(config.config_path, "tasks.json")
|
||||||
self._client: httpx.AsyncClient = client or httpx.AsyncClient()
|
self._client = client or httpx.AsyncClient()
|
||||||
self._encoder: Encoder = encoder or Encoder()
|
self._encoder = encoder or Encoder()
|
||||||
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
|
self._loop = loop or asyncio.get_event_loop()
|
||||||
self._emitter: Emitter = emitter or Emitter.get_instance()
|
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:]:
|
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
|
||||||
try:
|
try:
|
||||||
|
|
@ -96,27 +90,19 @@ class Tasks(metaclass=Singleton):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_instance() -> "Tasks":
|
def get_instance() -> "Tasks":
|
||||||
"""
|
"""
|
||||||
Get the instance of the Tasks.
|
Get the instance of the class.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tasks: The instance of the Tasks
|
Tasks: The instance of the class.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not Tasks._instance:
|
if not Tasks._instance:
|
||||||
Tasks._instance = Tasks()
|
Tasks._instance = Tasks()
|
||||||
|
|
||||||
return Tasks._instance
|
return Tasks._instance
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application):
|
async def on_shutdown(self, _: web.Application):
|
||||||
"""
|
pass
|
||||||
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.")
|
|
||||||
|
|
||||||
def attach(self, _: web.Application):
|
def attach(self, _: web.Application):
|
||||||
"""
|
"""
|
||||||
|
|
@ -125,15 +111,12 @@ class Tasks(metaclass=Singleton):
|
||||||
Args:
|
Args:
|
||||||
_ (web.Application): The aiohttp application.
|
_ (web.Application): The aiohttp application.
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
def get_tasks(self) -> list[Task]:
|
def get_all(self) -> list[Task]:
|
||||||
"""Return the tasks."""
|
"""Return the tasks."""
|
||||||
return [job.task for job in self._jobs]
|
return self._tasks
|
||||||
|
|
||||||
def load(self) -> "Tasks":
|
def load(self) -> "Tasks":
|
||||||
"""
|
"""
|
||||||
|
|
@ -168,18 +151,13 @@ class Tasks(metaclass=Singleton):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._jobs.append(
|
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task.id)
|
||||||
Job(
|
self._tasks.append(task)
|
||||||
id=task.id,
|
|
||||||
name=task.name,
|
|
||||||
task=task,
|
|
||||||
job=crontab(spec=task.timer, func=self._runner, args=(task,), start=True, loop=self._loop),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
LOG.info(f"Task '{i}: {task.name}' queued to be executed every '{task.timer}'.")
|
LOG.info(f"Task '{i}: {task.name}' queued to be executed every '{task.timer}'.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(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
|
return self
|
||||||
|
|
||||||
|
|
@ -191,19 +169,19 @@ class Tasks(metaclass=Singleton):
|
||||||
Tasks: The current instance.
|
Tasks: The current instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if len(self._jobs) < 1:
|
if len(self._tasks) < 1:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
for task in self._jobs:
|
for task in self._tasks:
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Stopping job '{task.id}: {task.name}'.")
|
LOG.info(f"Stopping task '{task.id}: {task.name}'.")
|
||||||
task.job.stop()
|
self._scheduler.remove(task.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not shutdown:
|
if not shutdown:
|
||||||
LOG.exception(e)
|
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
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,28 +52,22 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
||||||
LOG.debug("Using default preset.")
|
LOG.debug("Using default preset.")
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
from .config import Config
|
from .Presets import Presets
|
||||||
|
|
||||||
presets = Config.get_instance().presets
|
p = Presets.get_instance().get(name=preset)
|
||||||
|
if not p:
|
||||||
found = False
|
LOG.error(f"Preset '{preset}' is not defined as preset.")
|
||||||
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.")
|
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
opts["format"] = preset_opts.get("format")
|
opts["format"] = p.get("format")
|
||||||
|
|
||||||
if "postprocessors" in preset_opts:
|
postprocessors = p.get("postprocessors", [])
|
||||||
opts["postprocessors"] = preset_opts["postprocessors"]
|
if isinstance(postprocessors, list) and len(postprocessors) > 0:
|
||||||
|
opts["postprocessors"] = postprocessors
|
||||||
|
|
||||||
if "args" in preset_opts:
|
args = p.get("args", {})
|
||||||
for key, value in preset_opts["args"].items():
|
if isinstance(args, dict) and len(args) > 0:
|
||||||
|
for key, value in args.items():
|
||||||
opts[key] = value
|
opts[key] = value
|
||||||
|
|
||||||
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class Common:
|
||||||
|
|
||||||
return await self.queue.add(
|
return await self.queue.add(
|
||||||
url=url,
|
url=url,
|
||||||
preset=preset if preset else "default",
|
preset=preset if preset else self.config.default_preset,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
config=config if isinstance(config, dict) else {},
|
config=config if isinstance(config, dict) else {},
|
||||||
|
|
|
||||||
|
|
@ -129,11 +129,6 @@ class Config:
|
||||||
basic_mode: bool = False
|
basic_mode: bool = False
|
||||||
"Run the frontend in basic mode."
|
"Run the frontend in basic mode."
|
||||||
|
|
||||||
presets: list = [
|
|
||||||
{"name": "default", "format": "default"},
|
|
||||||
]
|
|
||||||
"The presets to use for downloading."
|
|
||||||
|
|
||||||
default_preset: str = "default"
|
default_preset: str = "default"
|
||||||
"The default preset to use when no preset is specified."
|
"The default preset to use when no preset is specified."
|
||||||
|
|
||||||
|
|
@ -161,7 +156,6 @@ class Config:
|
||||||
"new_version_available",
|
"new_version_available",
|
||||||
"ytdlp_version",
|
"ytdlp_version",
|
||||||
"started",
|
"started",
|
||||||
"presets",
|
|
||||||
)
|
)
|
||||||
"The variables that are immutable."
|
"The variables that are immutable."
|
||||||
|
|
||||||
|
|
@ -323,45 +317,6 @@ class Config:
|
||||||
else:
|
else:
|
||||||
LOG.info(f"No yt-dlp custom options found at '{optsFile}'.")
|
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
|
self.ytdl_options["socket_timeout"] = self.socket_timeout
|
||||||
|
|
||||||
if self.keep_archive:
|
if self.keep_archive:
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,40 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Best video and audio",
|
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
|
||||||
"format": "bv+ba/b"
|
"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",
|
"name": "1080p H264/m4a or best available",
|
||||||
"format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
|
"format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
|
||||||
"args": {
|
"args": {
|
||||||
"format_sort": [
|
"format_sort": [
|
||||||
"vcodec:h264"
|
"vcodec:h264"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"default": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
|
||||||
"name": "720p h264/m4a or best available",
|
"name": "720p h264/m4a or best available",
|
||||||
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
|
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
|
||||||
"args": {
|
"args": {
|
||||||
"format_sort": [
|
"format_sort": [
|
||||||
"vcodec:h264"
|
"vcodec:h264"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"default": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
|
||||||
"name": "Audio only",
|
"name": "Audio only",
|
||||||
"format": "bestaudio/best",
|
"format": "bestaudio/best",
|
||||||
"args": {
|
"args": {
|
||||||
|
|
@ -49,6 +62,7 @@
|
||||||
"only_multi_video": true,
|
"only_multi_video": true,
|
||||||
"when": "playlist"
|
"when": "playlist"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"default": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@ from aiohttp import web
|
||||||
from library.config import Config
|
from library.config import Config
|
||||||
from library.DownloadQueue import DownloadQueue
|
from library.DownloadQueue import DownloadQueue
|
||||||
from library.Emitter import Emitter
|
from library.Emitter import Emitter
|
||||||
from library.EventsSubscriber import EventsSubscriber
|
from library.EventsSubscriber import Events, EventsSubscriber
|
||||||
from library.HttpAPI import HttpAPI
|
from library.HttpAPI import HttpAPI
|
||||||
from library.HttpSocket import HttpSocket
|
from library.HttpSocket import HttpSocket
|
||||||
from library.Notifications import Notification
|
from library.Notifications import Notification
|
||||||
from library.PackageInstaller import PackageInstaller
|
from library.PackageInstaller import PackageInstaller
|
||||||
from library.Presets import Presets
|
from library.Presets import Presets
|
||||||
|
from library.Scheduler import Scheduler
|
||||||
from library.Tasks import Tasks
|
from library.Tasks import Tasks
|
||||||
|
|
||||||
LOG = logging.getLogger("app")
|
LOG = logging.getLogger("app")
|
||||||
|
|
@ -93,9 +94,12 @@ class Main:
|
||||||
"""
|
"""
|
||||||
Start the application.
|
Start the application.
|
||||||
"""
|
"""
|
||||||
|
EventsSubscriber.get_instance().emit(Events.STARTUP, data={"app": self._app})
|
||||||
|
|
||||||
self._socket.attach(self._app)
|
self._socket.attach(self._app)
|
||||||
self._http.attach(self._app)
|
self._http.attach(self._app)
|
||||||
self._queue.attach(self._app)
|
self._queue.attach(self._app)
|
||||||
|
Scheduler.get_instance().attach(self._app)
|
||||||
Tasks.get_instance().attach(self._app)
|
Tasks.get_instance().attach(self._app)
|
||||||
Presets.get_instance().attach(self._app)
|
Presets.get_instance().attach(self._app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ hr {
|
||||||
padding-top: 0.5em;
|
padding-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-overlay {
|
.play-overlay, .is-pointer {
|
||||||
cursor: 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>
|
</NuxtLink>
|
||||||
</div>
|
</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">
|
<div class="navbar-item" v-if="!config.app.basic_mode">
|
||||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console" v-tooltip.bottom="'Terminal'">
|
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console" v-tooltip.bottom="'Terminal'">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
|
|
@ -56,7 +62,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-item">
|
<div class="navbar-item is-hidden-mobile">
|
||||||
<button class="button is-dark" @click="reloadPage">
|
<button class="button is-dark" @click="reloadPage">
|
||||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||||
</button>
|
</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);
|
const on = (event, callback) => socket.value.on(event, callback);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue