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:
ArabCoders 2025-01-24 23:13:53 +03:00
parent 96c79be786
commit a4827202d8
11 changed files with 854 additions and 125 deletions

View file

@ -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.

View file

@ -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 = []

View file

@ -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)

View file

@ -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():

View file

@ -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
View 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>

View file

@ -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
View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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);