overhauled the tasks page to support adding/editing/deleting tasks from the WebUI directly instead of relying on tasks.json file.
This commit is contained in:
parent
96c79be786
commit
a4827202d8
11 changed files with 854 additions and 125 deletions
46
README.md
46
README.md
|
|
@ -7,16 +7,16 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
|
|||
YTPTube started as a fork of [meTube](https://github.com/alexta69/metube), Since then it went under heavy changes, and it supports many new features.
|
||||
|
||||
# YTPTube Features.
|
||||
* Multi-downloads support.
|
||||
* Handle live streams.
|
||||
* Schedule Channels or Playlists to be downloaded automatically at a specific time.
|
||||
* Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`.
|
||||
* Queue multiple URLs separated by comma.
|
||||
* A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**.
|
||||
* New `/api/add_batch` endpoint that allow multiple links to be sent.
|
||||
* Completely redesigned the frontend UI.
|
||||
* Switched out of binary file storage in favor of SQLite.
|
||||
* Handle live streams.
|
||||
* Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`.
|
||||
* Tasks Runner. It allow you to queue channels for downloading using simple `json` file.
|
||||
* Webhook sender. It allow you to add webhook endpoints that receive events related to downloads using simple `json` file.
|
||||
* Multi-downloads support.
|
||||
* Queue multiple URLs separated by comma.
|
||||
* Basic Authentication support.
|
||||
* Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation)
|
||||
* Support for both advanced and basic mode for WebUI.
|
||||
|
|
@ -238,42 +238,6 @@ The `config/ytdlp.json`, is a json file which can be used to alter the default `
|
|||
}
|
||||
```
|
||||
|
||||
### tasks.json File
|
||||
|
||||
The `config/tasks.json`, is a json file, which can be used to queue URLs for downloading, it's mainly useful if you follow specific channels and you want it downloaded automatically, The schema for the file is as the following, Only the `URL` key is required.
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
// (URL: string) **REQUIRED**, URL to the content.
|
||||
"url": "",
|
||||
// (Name: string) Optional field. Mainly used for logging. If omitted, random GUID will be shown.
|
||||
"name": "My super secret channel",
|
||||
// (Timer: string) Optional field. Using regular cronjob timer, if the field is omitted, it will run every hour in random minute.
|
||||
"timer": "1 */1 * * *",
|
||||
// (yt-dlp cookies: object) Optional field. A JSON cookies exported by flagCookies.
|
||||
"ytdlp_cookies": {},
|
||||
// (yt-dlp config: object) Optional field. A JSON yt-dlp config.
|
||||
"ytdlp_config": {},
|
||||
// (Output Template: string) Optional field. A File output format,
|
||||
"output_template": "",
|
||||
// (Folder: string) Optional field. Where to store the downloads relative to the main download path.
|
||||
"folder":"",
|
||||
// (preset: string) Optional field. The default preset to use for the download. if omitted, it will use the default preset.
|
||||
"preset": ""
|
||||
},
|
||||
{
|
||||
// (URL: string) **REQUIRED**, URL to the content.
|
||||
"url": "https://..." // This is valid config, it will queue the channel for downloading every hour at random minute.
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
The task runner is doing what you are doing when you click the add button on the WebGUI, this just fancy way to automate that.
|
||||
|
||||
**WARNING**: We strongly advice turning on `YTP_KEEP_ARCHIVE` option. Otherwise, you will keep re-downloading the items, and you will eventually get banned from the source or or you will waste space, bandwidth re-downloading content over and over.
|
||||
|
||||
### webhooks.json File
|
||||
|
||||
The `config/webhooks.json`, is a json file, which can be used to add webhook endpoints that would receive events related to the downloads.
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ class Emitter:
|
|||
This class is used to emit events to the registered emitters.
|
||||
"""
|
||||
|
||||
emitters: list[callable] = [] # type: ignore
|
||||
emitters: list[callable] = [] # type: ignore
|
||||
|
||||
def add_emitter(self, emitter: callable): # type: ignore
|
||||
def add_emitter(self, emitter: callable): # type: ignore
|
||||
"""
|
||||
Add an emitter to the list of emitters.
|
||||
|
||||
|
|
@ -44,6 +44,18 @@ class Emitter:
|
|||
async def warning(self, message: str, **kwargs):
|
||||
await self.emit("error", message, **kwargs)
|
||||
|
||||
async def info(self, message: str, data: dict = {}, **kwargs):
|
||||
msg = {"type": "info", "message": message, "data": {}}
|
||||
if data:
|
||||
msg.update({"data": data})
|
||||
await self.emit("log_info", msg, **kwargs)
|
||||
|
||||
async def success(self, message: str, data: dict = {}, **kwargs):
|
||||
msg = {"type": "success", "message": message, "data": {}}
|
||||
if data:
|
||||
msg.update({"data": data})
|
||||
await self.emit("log_success", msg, **kwargs)
|
||||
|
||||
async def emit(self, event: str, data, **kwargs):
|
||||
tasks = []
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import hmac
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import magic
|
||||
|
|
@ -40,7 +42,7 @@ class HttpAPI(common):
|
|||
".ico": "image/x-icon",
|
||||
}
|
||||
|
||||
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
|
||||
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder, load_tasks: callable):
|
||||
super().__init__(queue=queue, encoder=encoder)
|
||||
|
||||
self.rootPath = str(Path(__file__).parent.parent.parent)
|
||||
|
|
@ -52,6 +54,7 @@ class HttpAPI(common):
|
|||
self.emitter = emitter
|
||||
self.queue = queue
|
||||
self.cache = Cache()
|
||||
self.load_tasks = load_tasks
|
||||
|
||||
def route(method: str, path: str):
|
||||
"""
|
||||
|
|
@ -275,15 +278,68 @@ class HttpAPI(common):
|
|||
async def tasks(self, _: Request) -> Response:
|
||||
tasks_file: str = os.path.join(self.config.config_path, "tasks.json")
|
||||
|
||||
if not os.path.exists(tasks_file):
|
||||
return web.json_response({"error": "No tasks defined."}, status=web.HTTPNotFound.status_code)
|
||||
if os.path.exists(tasks_file):
|
||||
try:
|
||||
with open(tasks_file, "r") as f:
|
||||
tasks = json.load(f)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to load tasks."}, status=web.HTTPInternalServerError.status_code
|
||||
)
|
||||
else:
|
||||
tasks = []
|
||||
|
||||
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||
|
||||
@route("PUT", "api/tasks")
|
||||
async def add_tasks(self, request: Request) -> Response:
|
||||
tasks_file: str = os.path.join(self.config.config_path, "tasks.json")
|
||||
|
||||
post = await request.json()
|
||||
if not isinstance(post, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
tasks: list = []
|
||||
|
||||
for item in post:
|
||||
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("url"):
|
||||
return web.json_response(
|
||||
{"error": "url is required.", "data": post}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not item.get("id", None):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
if not item.get("timer", None):
|
||||
item["timer"] = f"{random.randint(1,59)} */1 * * *"
|
||||
|
||||
tasks.append(item)
|
||||
|
||||
try:
|
||||
with open(tasks_file, "r") as f:
|
||||
tasks = json.load(f)
|
||||
with open(tasks_file, "w") as f:
|
||||
json.dump(tasks, f, indent=4)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response({"error": "Failed to load tasks."}, status=web.HTTPInternalServerError.status_code)
|
||||
return web.json_response({"error": "Failed to save tasks."}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
try:
|
||||
self.load_tasks()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to reload tasks.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||
|
||||
|
|
@ -655,6 +711,10 @@ class HttpAPI(common):
|
|||
async def add_cors(self, _: Request) -> Response:
|
||||
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
|
||||
|
||||
@route("OPTIONS", "api/tasks")
|
||||
async def cors_add_tasks(self, _: Request) -> Response:
|
||||
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
|
||||
|
||||
@route("OPTIONS", "api/yt-dlp/convert")
|
||||
async def cors_ytdlp_convert(self, _: Request) -> Response:
|
||||
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
|
||||
|
|
|
|||
|
|
@ -18,72 +18,137 @@ from .version import APP_VERSION
|
|||
|
||||
class Config:
|
||||
config_path: str = "."
|
||||
"""The path to the configuration directory."""
|
||||
|
||||
download_path: str = "."
|
||||
"""The path to the download directory."""
|
||||
|
||||
temp_path: str = "/tmp"
|
||||
"""The path to the temporary directory."""
|
||||
|
||||
temp_keep: bool = False
|
||||
"""Keep temporary files after the download is complete."""
|
||||
|
||||
url_host: str = ""
|
||||
"""The host to bind the server to."""
|
||||
url_prefix: str = ""
|
||||
"""The prefix to use for the server URL."""
|
||||
url_socketio: str = "{url_prefix}socket.io"
|
||||
"""The URL to use for the socket.io server."""
|
||||
|
||||
output_template: str = "%(title)s.%(ext)s"
|
||||
"""The output template to use for the downloaded files."""
|
||||
|
||||
output_template_chapter: str = "%(title)s - %(section_number)s %(section_title)s.%(ext)s"
|
||||
"""The output template to use for the downloaded files with chapters."""
|
||||
|
||||
keep_archive: bool = True
|
||||
"""Keep the download archive file."""
|
||||
|
||||
ytdl_debug: bool = False
|
||||
"""Enable yt-dlp debugging."""
|
||||
|
||||
allow_manifestless: bool = False
|
||||
"""Allow downloading videos without manifest."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
"""The host to bind the server to."""
|
||||
|
||||
port: int = 8081
|
||||
"""The port to bind the server to."""
|
||||
|
||||
log_level: str = "info"
|
||||
"""The log level to use for the application."""
|
||||
|
||||
max_workers: int = 1
|
||||
"""The maximum number of workers to use for downloading."""
|
||||
|
||||
streamer_vcodec: str = "libx264"
|
||||
"""The video codec to use for streaming."""
|
||||
streamer_acodec: str = "aac"
|
||||
"""The audio codec to use for streaming."""
|
||||
|
||||
auth_username: str | None = None
|
||||
"""The username to use for basic authentication."""
|
||||
|
||||
auth_password: str | None = None
|
||||
"""The password to use for basic authentication."""
|
||||
|
||||
remove_files: bool = False
|
||||
"""Remove downloaded files when removing the record."""
|
||||
|
||||
access_log: bool = True
|
||||
"""Enable access logging."""
|
||||
|
||||
debug: bool = False
|
||||
"""Enable debugging."""
|
||||
|
||||
debugpy_port: int = 5678
|
||||
"""The port to use for the debugpy server."""
|
||||
|
||||
socket_timeout: int = 30
|
||||
"""The socket timeout to use for yt-dlp."""
|
||||
|
||||
extract_info_timeout: int = 70
|
||||
"""The timeout to use for extracting video information."""
|
||||
|
||||
db_file: str = "{config_path}/ytptube.db"
|
||||
"""The path to the database file."""
|
||||
|
||||
manual_archive: str = "{config_path}/archive.manual.log"
|
||||
"""The path to the manual archive file."""
|
||||
|
||||
ui_update_title: bool = True
|
||||
"""Update the title of the browser tab with the current status."""
|
||||
|
||||
pip_packages: str = ""
|
||||
"""The pip packages to install."""
|
||||
|
||||
pip_ignore_updates: bool = False
|
||||
"""Ignore pip package updates."""
|
||||
|
||||
# immutable config vars.
|
||||
version: str = APP_VERSION
|
||||
"The version of the application."
|
||||
|
||||
__instance = None
|
||||
"The instance of the class."
|
||||
|
||||
ytdl_options: dict = {}
|
||||
"The options to use for yt-dlp."
|
||||
|
||||
tasks: list = []
|
||||
"The tasks to run."
|
||||
|
||||
new_version_available: bool = False
|
||||
"A new version of the application is available."
|
||||
|
||||
ytdlp_version: str = YTDLP_VERSION
|
||||
"The version of yt-dlp."
|
||||
|
||||
started: int = 0
|
||||
"The time the application was started."
|
||||
|
||||
ignore_ui: bool = False
|
||||
"Ignore the UI and run the application in the background."
|
||||
|
||||
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."
|
||||
|
||||
_manual_vars: tuple = (
|
||||
"temp_path",
|
||||
"config_path",
|
||||
"download_path",
|
||||
)
|
||||
"The variables that are set manually."
|
||||
|
||||
_immutable: tuple = (
|
||||
"version",
|
||||
|
|
@ -95,6 +160,7 @@ class Config:
|
|||
"started",
|
||||
"presets",
|
||||
)
|
||||
"The variables that are immutable."
|
||||
|
||||
_int_vars: tuple = (
|
||||
"port",
|
||||
|
|
@ -103,6 +169,8 @@ class Config:
|
|||
"extract_info_timeout",
|
||||
"debugpy_port",
|
||||
)
|
||||
"The variables that are integers."
|
||||
|
||||
_boolean_vars: tuple = (
|
||||
"keep_archive",
|
||||
"ytdl_debug",
|
||||
|
|
@ -116,6 +184,7 @@ class Config:
|
|||
"pip_ignore_updates",
|
||||
"basic_mode",
|
||||
)
|
||||
"The variables that are booleans."
|
||||
|
||||
_frontend_vars: tuple = (
|
||||
"download_path",
|
||||
|
|
@ -132,8 +201,10 @@ class Config:
|
|||
"basic_mode",
|
||||
"default_preset",
|
||||
)
|
||||
"The variables that are relevant to the frontend."
|
||||
|
||||
_manager: SyncManager | None = None
|
||||
"The manager instance."
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
|
|
|
|||
119
app/main.py
119
app/main.py
|
|
@ -1,17 +1,21 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import TypedDict
|
||||
|
||||
import caribou
|
||||
import magic
|
||||
from aiocron import crontab
|
||||
from aiocron import crontab, Cron
|
||||
from aiohttp import web
|
||||
from library.Utils import load_file
|
||||
from library.config import Config
|
||||
from library.DownloadQueue import DownloadQueue
|
||||
from library.Emitter import Emitter
|
||||
|
|
@ -25,11 +29,17 @@ LOG = logging.getLogger("app")
|
|||
MIME = magic.Magic(mime=True)
|
||||
|
||||
|
||||
class job_item(TypedDict):
|
||||
name: str
|
||||
job: Cron
|
||||
|
||||
|
||||
class Main:
|
||||
config: Config
|
||||
app: web.Application
|
||||
http: HttpAPI
|
||||
socket: HttpSocket
|
||||
cron: list[job_item] = []
|
||||
|
||||
def __init__(self):
|
||||
self.config = Config.get_instance()
|
||||
|
|
@ -51,17 +61,17 @@ class Main:
|
|||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=wal")
|
||||
|
||||
emitter = Emitter()
|
||||
self.emitter = Emitter()
|
||||
|
||||
queue = DownloadQueue(emitter=emitter, connection=connection)
|
||||
queue = DownloadQueue(emitter=self.emitter, connection=connection)
|
||||
self.app.on_startup.append(lambda _: queue.initialize())
|
||||
|
||||
self.http = HttpAPI(queue=queue, emitter=emitter, encoder=self.encoder)
|
||||
self.socket = HttpSocket(queue=queue, emitter=emitter, encoder=self.encoder)
|
||||
self.http = HttpAPI(queue=queue, emitter=self.emitter, encoder=self.encoder, load_tasks=self.load_tasks)
|
||||
self.socket = HttpSocket(queue=queue, emitter=self.emitter, encoder=self.encoder)
|
||||
|
||||
WebhookFile = os.path.join(self.config.config_path, "webhooks.json")
|
||||
if os.path.exists(WebhookFile):
|
||||
emitter.add_emitter(Webhooks(WebhookFile).emit)
|
||||
self.emitter.add_emitter(Webhooks(WebhookFile).emit)
|
||||
|
||||
def checkFolders(self) -> None:
|
||||
try:
|
||||
|
|
@ -105,38 +115,101 @@ class Main:
|
|||
try:
|
||||
taskName = task.get("name", task.get("url"))
|
||||
timeNow = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
started = time.time()
|
||||
url = task.get("url")
|
||||
if not url:
|
||||
LOG.error(f"Invalid task '{task}'. No URL found.")
|
||||
return
|
||||
|
||||
preset: str = str(task.get("preset", self.config.default_preset))
|
||||
folder: str = str(task.get("folder")) if task.get("folder") else ""
|
||||
ytdlp_cookies: str = str(task.get("ytdlp_cookies")) if task.get("ytdlp_cookies") else ""
|
||||
output_template: str = str(task.get("output_template")) if task.get("output_template") else ""
|
||||
|
||||
ytdlp_config = task.get("ytdlp_config")
|
||||
if isinstance(ytdlp_config, str) and ytdlp_config:
|
||||
try:
|
||||
ytdlp_config = json.loads(ytdlp_config)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse json yt-dlp config for '{taskName}'. {str(e)}")
|
||||
return
|
||||
|
||||
await self.emitter.info(f"Started 'Task: {taskName}' at '{timeNow}'.")
|
||||
LOG.info(f"Started 'Task: {taskName}' at '{timeNow}'.")
|
||||
await self.socket.add(
|
||||
url=task.get("url"),
|
||||
preset=task.get("preset", "default"),
|
||||
folder=task.get("folder"),
|
||||
ytdlp_cookies=task.get("ytdlp_cookies"),
|
||||
ytdlp_config=task.get("ytdlp_config"),
|
||||
output_template=task.get("output_template"),
|
||||
url=url,
|
||||
preset=preset,
|
||||
folder=folder,
|
||||
ytdlp_cookies=ytdlp_cookies,
|
||||
ytdlp_config=ytdlp_config,
|
||||
output_template=output_template,
|
||||
)
|
||||
timeNow = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
LOG.info(f"Completed 'Task: {taskName}' at '{timeNow}'.")
|
||||
|
||||
ended = time.time()
|
||||
LOG.info(f"Completed 'Task: {taskName}' at '{timeNow}' ")
|
||||
|
||||
await self.emitter.success(f"Completed 'Task: {taskName}' '{ended - started:.2f}' seconds.")
|
||||
except Exception as e:
|
||||
timeNow = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
LOG.error(f"Failed 'Task: {taskName}' at '{timeNow}'. Error message '{str(e)}'.")
|
||||
await self.emitter.error(f"Failed 'Task: {taskName}' at '{timeNow}'. Error message '{str(e)}'.")
|
||||
|
||||
def load_tasks(self):
|
||||
for task in self.config.tasks:
|
||||
tasksFile = os.path.join(self.config.config_path, "tasks.json")
|
||||
if not os.path.exists(tasksFile):
|
||||
return
|
||||
|
||||
LOG.info(f"Loading tasks from '{tasksFile}'.")
|
||||
try:
|
||||
(tasks, status, error) = load_file(tasksFile, list)
|
||||
if not status:
|
||||
LOG.error(f"Could not load tasks file from '{tasksFile}'. '{error}'.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for job in self.cron:
|
||||
try:
|
||||
LOG.info(f"Stopping job '{job['name']}'.")
|
||||
job["job"].stop()
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to stop job. Error message '{str(e)}'.")
|
||||
LOG.exception(e)
|
||||
|
||||
self.cron.clear()
|
||||
|
||||
if not tasks or len(tasks) < 1:
|
||||
LOG.warning(f"No tasks found in '{tasksFile}'.")
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
for task in tasks:
|
||||
if not task.get("url"):
|
||||
LOG.warning(f"Invalid task '{task}'. No URL found.")
|
||||
continue
|
||||
|
||||
cron_timer: str = task.get("timer", f"{random.randint(1,59)} */1 * * *")
|
||||
try:
|
||||
cron_timer: str = task.get("timer", f"{random.randint(1,59)} */1 * * *")
|
||||
self.cron.append(
|
||||
job_item(
|
||||
name=task.get("name", "??"),
|
||||
job=crontab(
|
||||
spec=cron_timer,
|
||||
func=self.cron_runner,
|
||||
args=(task,),
|
||||
start=True,
|
||||
loop=loop,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
crontab(
|
||||
spec=cron_timer,
|
||||
func=self.cron_runner,
|
||||
args=(task,),
|
||||
start=True,
|
||||
loop=asyncio.get_event_loop(),
|
||||
)
|
||||
LOG.info(f"Queued 'Task: {task.get('name','??')}' to be executed every '{cron_timer}'.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to add 'Task: {task.get('name', '??')}'. Error message '{str(e)}'.")
|
||||
LOG.exception(e)
|
||||
|
||||
LOG.info(f"Added 'Task: {task.get('name', task.get('url'))}' to be executed every '{cron_timer}'.")
|
||||
self.config.tasks = tasks
|
||||
|
||||
def start(self):
|
||||
self.socket.attach(self.app)
|
||||
|
|
|
|||
67
ui/components/Message.vue
Normal file
67
ui/components/Message.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div class="notification" :class="message_class">
|
||||
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose"></button>
|
||||
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
|
||||
</span>
|
||||
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
||||
</div>
|
||||
<div class="notification-title is-unselectable" :class="{ 'is-clickable': useToggle }" v-if="title || icon"
|
||||
@click="useToggle ? $emit('toggle', toggle) : null">
|
||||
<template v-if="icon">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i :class="icon"></i></span>
|
||||
<span>{{ title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ title }}</template>
|
||||
</div>
|
||||
<div class="notification-content content" v-if="false === useToggle || toggle">
|
||||
<template v-if="message">{{ message }}</template>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false
|
||||
},
|
||||
message_class: {
|
||||
type: String,
|
||||
default: 'is-info',
|
||||
required: false
|
||||
},
|
||||
useToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
toggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
useClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['toggle', 'close'])
|
||||
</script>
|
||||
|
|
@ -66,9 +66,9 @@
|
|||
<input type="text" class="input" v-model="output_template" id="output_format"
|
||||
placeholder="Uses default output template naming if empty.">
|
||||
</div>
|
||||
<span class="subtitle is-6">
|
||||
All output template naming options can be found at <NuxtLink target="_blank" class="has-text-danger"
|
||||
href="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.
|
||||
<span class="help">
|
||||
All output template naming options can be found at <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -82,10 +82,10 @@
|
|||
<textarea class="textarea" id="ytdlpConfig" v-model="ytdlpConfig" :disabled="!socket.isConnected"
|
||||
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
|
||||
</div>
|
||||
<span class="subtitle is-6">
|
||||
<span class="help">
|
||||
Some config fields are ignored like cookiefile, path, and output_format etc.
|
||||
Available option can be found at <NuxtLink class="has-text-danger" target="_blank"
|
||||
href="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">
|
||||
Available option can be found at <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">
|
||||
this page</NuxtLink>. Warning: Use with caution some of those options can break yt-dlp or the
|
||||
frontend.
|
||||
</span>
|
||||
|
|
@ -100,9 +100,9 @@
|
|||
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies"
|
||||
:disabled="!socket.isConnected"></textarea>
|
||||
</div>
|
||||
<span class="subtitle is-6">
|
||||
Use something like <NuxtLink target="_blank" class="has-text-danger"
|
||||
href="https://github.com/jrie/flagCookies">flagCookies</NuxtLink> to extract cookies as JSON string.
|
||||
<span class="help">
|
||||
Use <NuxtLink target="_blank" to="https://github.com/jrie/flagCookies">
|
||||
flagCookies</NuxtLink> to extract cookies as JSON string.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
275
ui/components/TaskForm.vue
Normal file
275
ui/components/TaskForm.vue
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<template>
|
||||
<main class="columns mt-2">
|
||||
<div class="column">
|
||||
<form id="taskForm" @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">
|
||||
Task 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-user" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Task name is used to identify the task in the task list.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="url">
|
||||
Channel or Playlist URL
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The YouTube channel or playlist URL</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="folder">
|
||||
Download path
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
|
||||
v-model="form.folder" :disabled="addInProgress" list="folders">
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Downloads are relative to download path, defaults to root path if not set.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="folder">
|
||||
Preset
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="preset" class="is-fullwidth" v-model="form.preset" :disabled="addInProgress">
|
||||
<option v-for="item in config.presets" :key="item.name" :value="item.name">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Select the preset to use for this URL.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="timer">
|
||||
CRON expression timer.
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" id="timer" placeholder="leave empty to run once every hour."
|
||||
v-model="form.timer" :disabled="addInProgress">
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-clock" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The CRON timer expression to use for this task. If not set, the task will run once an hour in a
|
||||
random
|
||||
minute. For more information on CRON expressions, see <NuxtLink to="https://crontab.guru/"
|
||||
target="_blank">crontab.guru</NuxtLink>.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="output_template">
|
||||
Output template
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" id="output_template" placeholder="The output template to use"
|
||||
v-model="form.output_template" :disabled="addInProgress">
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The output template to use, if not set, it will defaults to
|
||||
<code>{{ config.app.output_template }}</code></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="ytdlp_config"
|
||||
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
||||
JSON yt-dlp config or CLI options.
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="ytdlp_config" v-model="form.ytdlp_config" :disabled="addInProgress"
|
||||
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Extends current global yt-dlp config with given options. If CLI options are given, they will be
|
||||
converted pre-saving.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="ytdlp_cookies" v-tooltip="'JSON exported cookies for downloading.'">
|
||||
yt-dlp Cookies
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="ytdlp_cookies" v-model="form.ytdlp_cookies"
|
||||
:disabled="addInProgress"></textarea>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Use <NuxtLink target="_blank" to="https://github.com/jrie/flagCookies">flagCookies</NuxtLink> to
|
||||
extract cookies as JSON string.
|
||||
</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="taskForm">
|
||||
<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>
|
||||
<datalist id="folders" v-if="config?.folders">
|
||||
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
||||
</datalist>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { parseExpression } from 'cron-parser'
|
||||
|
||||
const emitter = defineEmits(['cancel', 'submit']);
|
||||
const toast = useToast();
|
||||
const config = useConfigStore();
|
||||
const addInProgress = ref(false);
|
||||
const props = defineProps({
|
||||
reference: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
task: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const form = reactive(props.task);
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.task?.preset || '' === props.task.preset) {
|
||||
form.preset = toRaw(config.app.default_preset);
|
||||
}
|
||||
})
|
||||
|
||||
const checkInfo = async () => {
|
||||
const required = ['name', 'url'];
|
||||
for (const key of required) {
|
||||
if (!form[key]) {
|
||||
toast.error(`The ${key} field is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (form.timer) {
|
||||
try {
|
||||
parseExpression(form.timer);
|
||||
} catch (e) {
|
||||
toast.error('Invalid CRON expression.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(form.url);
|
||||
} catch (e) {
|
||||
toast.error('Invalid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// -- send request to convert cli options to JSON
|
||||
if (form.ytdlp_config && form.ytdlp_config.length > 2 && !form.ytdlp_config.trim().startsWith('{')) {
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/yt-dlp/convert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ args: form.ytdlp_config }),
|
||||
});
|
||||
|
||||
const data = await response.json()
|
||||
if (200 !== response.status) {
|
||||
toast.error(`Error: (${response.status}): ${data.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
form.ytdlp_config = JSON.stringify(data, null, 4)
|
||||
}
|
||||
|
||||
// -- check cookies syntax
|
||||
if (form.ytdlp_cookies) {
|
||||
try {
|
||||
JSON.parse(form.ytdlp_cookies);
|
||||
} catch (e) {
|
||||
toast.error(`Invalid JSON yt-dlp cookies. ${e.message}`)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//addInProgress.value = true;
|
||||
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) });
|
||||
}
|
||||
</script>
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode && config.tasks.length > 0" v-tooltip.bottom="'Tasks'">
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode" v-tooltip.bottom="'Tasks'">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/tasks">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
</NuxtLink>
|
||||
|
|
|
|||
|
|
@ -6,55 +6,114 @@ table.is-fixed {
|
|||
div.table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.is-centered {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="mt-1 columns is-multiline">
|
||||
<div class="column is-12 is-clearfix">
|
||||
<h1 class="title is-4">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
<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-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
<div class="subtitle is-6 is-unselectable">
|
||||
This page shows the registered tasks, which are scheduled to run at the specific time.
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="resetTask(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">
|
||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
The task runner is simply queuing given urls at the specified time. It's basically doing what you are doing
|
||||
when you click the add button on the WebGUI, this just fancy way to automate that.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-narrow is-hoverable is-striped is-fullwidth is-fixed">
|
||||
<thead class="has-text-centered">
|
||||
<tr>
|
||||
<th class="has-text-centered" width="25%">Name</th>
|
||||
<th class="has-text-centered" width="50%">URL</th>
|
||||
<th class="has-text-centered" width="25%">Next run</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item, key in config.tasks" :key="key">
|
||||
<td class="is-text-overflow has-text-centered" v-if="item.name">{{ item.name }}</td>
|
||||
<td class="has-text-centered" v-else>Not set</td>
|
||||
<td class="is-text-overflow">
|
||||
<a :href="item.url" target="_blank">
|
||||
{{ item.url }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="has-text-centered" v-if="item.timer">
|
||||
<span v-tooltip="'Timer: ' + item.timer">
|
||||
{{ moment(parseExpression(item.timer).next().toISOString()).fromNow() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="has-text-centered" v-else>once an hour</td>
|
||||
</tr>
|
||||
<tr v-if="config.tasks.length < 1">
|
||||
<td colspan="4" class="has-text-centered has-text-danger">
|
||||
No tasks are defined.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<TaskForm :reference="taskRef" :task="task" @cancel="resetTask(true);" @submit="updateItem" />
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm">
|
||||
<div class="columns is-multiline" v-if="tasks && tasks.length > 0">
|
||||
<div class="column is-6" v-for="item in tasks" :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 :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'"
|
||||
@click.prevent="copyText(item.url)">
|
||||
<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-clock" /></span>
|
||||
<span>
|
||||
{{ moment(parseExpression(item.timer).next().toISOString()).fromNow() }} - {{ item.timer }}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="icon"><i class="fa-solid fa-folder" /></span>
|
||||
<span>{{ calcPath(item.folder) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="icon"><i class="fa-solid fa-file" /></span>
|
||||
<span>{{ item.output_template ?? config.app.output_template }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="icon"><i class="fa-solid fa-tv" /></span>
|
||||
<span>{{ item.preset ?? config.app.default_preset }}</span>
|
||||
</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 tasks" message="No tasks are defined." class="is-background-warning-80 has-text-dark"
|
||||
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -63,7 +122,17 @@ div.table-container {
|
|||
<script setup>
|
||||
import moment from 'moment'
|
||||
import { parseExpression } from 'cron-parser'
|
||||
|
||||
const toast = useToast()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
|
||||
const tasks = ref([])
|
||||
const task = ref({})
|
||||
const taskRef = ref('')
|
||||
const toggleForm = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const initialLoad = ref(true)
|
||||
|
||||
watch(() => config.app.basic_mode, async () => {
|
||||
if (!config.app.basic_mode) {
|
||||
|
|
@ -71,4 +140,132 @@ watch(() => config.app.basic_mode, async () => {
|
|||
}
|
||||
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 fetch(config.app.url_host + config.app.url_prefix + 'api/tasks')
|
||||
if (fromMounted && !response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks.value = data
|
||||
} catch (e) {
|
||||
if (fromMounted) {
|
||||
return
|
||||
}
|
||||
console.error(e)
|
||||
toast.error('Failed to fetch tasks.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetTask = (closeForm = false) => {
|
||||
task.value = {}
|
||||
taskRef.value = null
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateTasks = async tasks => {
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/tasks', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tasks),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (200 !== response.status) {
|
||||
toast.error(`Failed to update task. ${data.error}`);
|
||||
return false
|
||||
}
|
||||
|
||||
tasks.value = data
|
||||
return true
|
||||
}
|
||||
|
||||
const deleteItem = async item => {
|
||||
if (true !== confirm(`Are you sure you want to delete (${item.name})?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = tasks.value.findIndex((t) => t?.id === item.id)
|
||||
if (index > -1) {
|
||||
tasks.value.splice(index, 1)
|
||||
} else {
|
||||
toast.error('Task not found')
|
||||
return
|
||||
}
|
||||
|
||||
const status = await updateTasks(tasks.value)
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Task deleted.')
|
||||
}
|
||||
|
||||
const updateItem = async ({ reference, task }) => {
|
||||
if (reference) {
|
||||
// -- find the task index.
|
||||
const index = tasks.value.findIndex((t) => t?.id === reference)
|
||||
if (index > -1) {
|
||||
tasks.value[index] = task
|
||||
}
|
||||
} else {
|
||||
tasks.value.push(task)
|
||||
}
|
||||
|
||||
const status = await updateTasks(tasks.value)
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Task updated')
|
||||
resetTask(true)
|
||||
toggleForm.value = false
|
||||
}
|
||||
|
||||
|
||||
const filterItem = item => {
|
||||
// -- remove the raw key from the item. before showing it to user
|
||||
const { raw, ...rest } = item
|
||||
return JSON.stringify(rest, null, 2)
|
||||
}
|
||||
|
||||
const editItem = item => {
|
||||
task.value = item
|
||||
taskRef.value = item.id
|
||||
toggleForm.value = true
|
||||
}
|
||||
|
||||
const calcPath = path => {
|
||||
if (!path) {
|
||||
return config.app.download_path
|
||||
}
|
||||
|
||||
return config.app.download_path + '/' + sTrim(path, '/')
|
||||
}
|
||||
|
||||
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
toast.error(`${json.data?.id ?? json?.status}: ${json?.message}`);
|
||||
});
|
||||
|
||||
socket.value.on('log_info', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.info(json?.message);
|
||||
});
|
||||
|
||||
socket.value.on('log_success', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.success(json?.message);
|
||||
});
|
||||
|
||||
socket.value.on('completed', stream => {
|
||||
const item = JSON.parse(stream);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue