commit
3f11262afb
14 changed files with 995 additions and 107 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -30,9 +30,11 @@
|
|||
"dateparser",
|
||||
"daterange",
|
||||
"defusedxml",
|
||||
"dlfields",
|
||||
"dotenv",
|
||||
"edgechromium",
|
||||
"engineio",
|
||||
"Errno",
|
||||
"euuo",
|
||||
"faststart",
|
||||
"finaldir",
|
||||
|
|
|
|||
41
FAQ.md
Normal file
41
FAQ.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# I cant download anything
|
||||
|
||||
If you are receiving errors like:
|
||||
- "OSError: [Errno 5] I/O error"
|
||||
- "OSError: [Errno 18] Cross-device link: '/tmp/random_id/name.webm' -> '/downloads/name.webm'
|
||||
- "Operation not permitted: '/downloads/name.webm'
|
||||
|
||||
This indicates an error with your mounts and how they interact with the container. So, the basic solution is to do the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id, for example: "1000:1000"
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- YTP_TEMP_PATH=/downloads/tmp
|
||||
- YTP_DOWNLOAD_PATH=/downloads/files
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/config:rw
|
||||
- ./downloads:/downloads:rw
|
||||
```
|
||||
|
||||
Then run the following command to create the necessary directories and start the container:
|
||||
|
||||
```bash
|
||||
mkdir -p ./config && mkdir -p ./downloads/{tmp,files} && docker compose -f compose.yaml up -d
|
||||
```
|
||||
|
||||
Reference: [Issue #363](https://github.com/arabcoders/ytptube/issues/363)
|
||||
|
||||
# I want to use link with playlist but only download the video not all the videos in the playlist?
|
||||
|
||||
Simply create a preset, and in the `Command options for yt-dlp` field set `--no-playlist`. Then select the preset
|
||||
whenever the link includes a playlist id.
|
||||
|
||||
> [!NOTE]
|
||||
> You can also do the same via advanced options `Command options for yt-dlp` field, but presets are more convenient.
|
||||
|
|
@ -58,6 +58,9 @@ class Events:
|
|||
PRESETS_ADD: str = "presets_add"
|
||||
PRESETS_UPDATE: str = "presets_update"
|
||||
|
||||
DLFIELDS_ADD: str = "dlfields_add"
|
||||
DLFIELDS_UPDATE: str = "dlfields_update"
|
||||
|
||||
SCHEDULE_ADD: str = "schedule_add"
|
||||
|
||||
CONDITIONS_ADD: str = "conditions_add"
|
||||
|
|
@ -104,6 +107,7 @@ class Events:
|
|||
Events.CLI_CLOSE,
|
||||
Events.CLI_OUTPUT,
|
||||
Events.PRESETS_UPDATE,
|
||||
Events.DLFIELDS_UPDATE,
|
||||
]
|
||||
|
||||
def only_debug() -> list:
|
||||
|
|
|
|||
344
app/library/dl_fields.py
Normal file
344
app/library/dl_fields.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .config import Config
|
||||
from .encoder import Encoder
|
||||
from .Events import EventBus, Events
|
||||
from .Singleton import Singleton
|
||||
from .Utils import init_class
|
||||
|
||||
LOG = logging.getLogger("DLFields")
|
||||
|
||||
|
||||
class FieldType(str, Enum):
|
||||
STRING = "string"
|
||||
TEXT = "text"
|
||||
BOOL = "bool"
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> list[str]:
|
||||
return [member.value for member in cls]
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value: str) -> "FieldType":
|
||||
"""
|
||||
Returns the FieldType enum member corresponding to the given value.
|
||||
|
||||
Args:
|
||||
value (str): The value to match against the enum members.
|
||||
|
||||
Returns:
|
||||
StoreType: The enum member that matches the value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value does not match any member.
|
||||
|
||||
"""
|
||||
for member in cls:
|
||||
if member.value == value:
|
||||
return member
|
||||
|
||||
msg = f"Invalid StoreType value: {value}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DLField:
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""The id of the field."""
|
||||
|
||||
name: str
|
||||
"""The name of the preset."""
|
||||
|
||||
description: str
|
||||
"""The description of the preset."""
|
||||
|
||||
field: str
|
||||
"""The yt-dlp field to use in long format."""
|
||||
|
||||
kind: FieldType = FieldType.TEXT
|
||||
"""The kind of the field. i.e. string, bool"""
|
||||
|
||||
icon: str = ""
|
||||
"""The icon of the field, it can be a font-awesome icon"""
|
||||
|
||||
order: int = 0
|
||||
"""The order of the field, used to sort the fields in the UI."""
|
||||
|
||||
value: str = ""
|
||||
"""The default value of the field, It's currently unused."""
|
||||
|
||||
extras: dict = field(default_factory=dict)
|
||||
"""Additional options for the field."""
|
||||
|
||||
def serialize(self) -> dict:
|
||||
dct = self.__dict__
|
||||
dct["kind"] = str(self.kind)
|
||||
dct["extras"] = {k: v for k, v in self.extras.items() if v is not None}
|
||||
return dct
|
||||
|
||||
def json(self) -> str:
|
||||
return Encoder().encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.serialize().get(key, default)
|
||||
|
||||
|
||||
class DLFields(metaclass=Singleton):
|
||||
"""
|
||||
This class is used to manage the DLFields.
|
||||
"""
|
||||
|
||||
_items: list[DLField] = []
|
||||
"""The list of items."""
|
||||
|
||||
_instance = None
|
||||
"""The instance of the class."""
|
||||
|
||||
_config: Config = None
|
||||
"""The config instance."""
|
||||
|
||||
def __init__(self, file: str | Path | None = None, config: Config | None = None):
|
||||
DLFields._instance = self
|
||||
|
||||
self._config = config or Config.get_instance()
|
||||
|
||||
self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("dl_fields.json")
|
||||
|
||||
if self._file.exists() and "600" != self._file.stat().st_mode:
|
||||
try:
|
||||
self._file.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def event_handler(_, __):
|
||||
msg = "Not implemented"
|
||||
raise Exception(msg)
|
||||
|
||||
EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> "DLFields":
|
||||
"""
|
||||
Get the instance of the class.
|
||||
|
||||
Returns:
|
||||
DLFields: The instance of the class
|
||||
|
||||
"""
|
||||
if not DLFields._instance:
|
||||
DLFields._instance = DLFields()
|
||||
|
||||
return DLFields._instance
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
"""
|
||||
Attach the class to the aiohttp application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp application.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
self.load()
|
||||
|
||||
def get_all(self) -> list[DLField]:
|
||||
"""Return the items."""
|
||||
return self._items
|
||||
|
||||
def load(self) -> "DLFields":
|
||||
"""
|
||||
Load the items.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
has: int = len(self._items)
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
|
||||
items: dict = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||
return self
|
||||
|
||||
if not items or len(items) < 1:
|
||||
return self
|
||||
|
||||
need_save = False
|
||||
|
||||
for i, item in enumerate(items):
|
||||
try:
|
||||
if "id" not in item:
|
||||
item["id"] = str(uuid.uuid4())
|
||||
need_save = True
|
||||
|
||||
item: DLField = init_class(DLField, item)
|
||||
|
||||
self._items.append(item)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
|
||||
continue
|
||||
|
||||
if need_save:
|
||||
LOG.info(f"Saving '{self._file}'.")
|
||||
self.save(self._items)
|
||||
|
||||
return self
|
||||
|
||||
def clear(self) -> "DLFields":
|
||||
"""
|
||||
Clear all items.
|
||||
|
||||
Returns:
|
||||
DLFields: The current instance.
|
||||
|
||||
"""
|
||||
if len(self._items) < 1:
|
||||
return self
|
||||
|
||||
self._items.clear()
|
||||
|
||||
return self
|
||||
|
||||
def validate(self, item: DLField | dict) -> bool:
|
||||
"""
|
||||
Validate the item.
|
||||
|
||||
Args:
|
||||
item (DLField|dict): The item to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if valid
|
||||
|
||||
Raises:
|
||||
ValueError: If the item is not valid.
|
||||
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
if not isinstance(item, DLField):
|
||||
msg = f"Unexpected '{type(item).__name__}' type was given."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
|
||||
item = item.serialize()
|
||||
|
||||
for key in ["id", "name", "description", "field", "kind"]:
|
||||
if key not in item:
|
||||
msg = f"Missing required key '{key}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("kind") not in FieldType.all():
|
||||
msg = f"Invalid field type '{item.get('kind')}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("extras") and not isinstance(item.get("extras"), dict):
|
||||
msg = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("value") and not isinstance(item.get("value"), str):
|
||||
msg = "Value must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("order") is not None and not isinstance(item.get("order"), int):
|
||||
msg = "Order must be an integer."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not isinstance(item.get("extras", {}), dict):
|
||||
msg = "Extras must be a dictionary."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
|
||||
if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
|
||||
msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
def save(self, items: list[DLField | dict]) -> "DLFields":
|
||||
"""
|
||||
Save the items.
|
||||
|
||||
Args:
|
||||
items (list[DLField]): The items to save.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
for i, item in enumerate(items):
|
||||
try:
|
||||
if not isinstance(item, DLField):
|
||||
item: DLField = init_class(DLField, item)
|
||||
items[i] = item
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
|
||||
continue
|
||||
|
||||
try:
|
||||
self.validate(item)
|
||||
except ValueError as e:
|
||||
LOG.error(f"Failed to validate item '{i}: {item.name}'. '{e}'.")
|
||||
continue
|
||||
|
||||
try:
|
||||
self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4))
|
||||
LOG.info(f"Saved '{self._file}'.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
|
||||
|
||||
return self
|
||||
|
||||
def has(self, id_or_name: str) -> bool:
|
||||
"""
|
||||
Check if the item exists by id or name.
|
||||
|
||||
Args:
|
||||
id_or_name (str): The id or name of the item.
|
||||
|
||||
Returns:
|
||||
bool: True if exists, False otherwise.
|
||||
|
||||
"""
|
||||
return self.get(id_or_name) is not None
|
||||
|
||||
def get(self, id_or_name: str) -> DLField | None:
|
||||
"""
|
||||
Get the item by id or name.
|
||||
|
||||
Args:
|
||||
id_or_name (str): The id or name of the item.
|
||||
|
||||
Returns:
|
||||
Preset|None: The item if found, None otherwise.
|
||||
|
||||
"""
|
||||
if not id_or_name:
|
||||
return None
|
||||
|
||||
for item in self.get_all():
|
||||
if id_or_name not in (item.id, item.name):
|
||||
continue
|
||||
|
||||
return item
|
||||
|
||||
return None
|
||||
|
|
@ -19,6 +19,7 @@ from aiohttp import web
|
|||
from app.library.BackgroundWorker import BackgroundWorker
|
||||
from app.library.conditions import Conditions
|
||||
from app.library.config import Config
|
||||
from app.library.dl_fields import DLFields
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.HttpAPI import HttpAPI
|
||||
|
|
@ -127,6 +128,7 @@ class Main:
|
|||
Presets.get_instance().attach(self._app)
|
||||
Notification.get_instance().attach(self._app)
|
||||
Conditions.get_instance().attach(self._app)
|
||||
DLFields.get_instance().attach(self._app)
|
||||
self._background_worker.attach(self._app)
|
||||
|
||||
EventBus.get_instance().sync_emit(
|
||||
|
|
|
|||
100
app/routes/api/dl_fields.py
Normal file
100
app/routes/api/dl_fields.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.dl_fields import DLField, DLFields
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
from app.library.Utils import init_class, validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/dl_fields/", "dl_fields")
|
||||
async def dl_fields(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Get the dl_fields.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data: list[DLField] = DLFields.get_instance().get_all()
|
||||
filter_fields: str | None = request.query.get("filter", None)
|
||||
|
||||
if filter_fields:
|
||||
fields: list[str] = [field.strip() for field in filter_fields.split(",")]
|
||||
data = [{key: value for key, value in preset.serialize().items() if key in fields} for preset in data]
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", "api/dl_fields/", "dl_fields_add")
|
||||
async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Add presets.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
items: list[DLField] = []
|
||||
|
||||
cls = DLFields.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("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
items.append(init_class(DLField, item))
|
||||
|
||||
try:
|
||||
items = cls.save(items=items).load().get_all()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to save download fields.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
await notify.emit(Events.DLFIELDS_UPDATE, data=items)
|
||||
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
|||
import socketio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.dl_fields import DLFields
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.Presets import Presets
|
||||
|
|
@ -26,6 +27,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
|
|||
**queue.get(),
|
||||
"config": config.frontend(),
|
||||
"presets": Presets.get_instance().get_all(),
|
||||
"dl_fields": DLFields.get_instance().get_all(),
|
||||
"paused": queue.is_paused(),
|
||||
}
|
||||
|
||||
|
|
|
|||
276
ui/app/components/DLFields.vue
Normal file
276
ui/app/components/DLFields.vue
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<template>
|
||||
<form class="modal is-active" @submit.prevent="updateItems">
|
||||
<div class="modal-background" @click="cancel" />
|
||||
<div class="modal-card" style="min-width:calc(100vw - 30vw);">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Manage Custom Fields</p>
|
||||
<button type="button" class="delete" aria-label="close" @click="cancel" />
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="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="fas fa-plus" /></span>
|
||||
<span>Custom Fields</span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button type="button" class="button is-primary" @click="addNewField" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-add" /></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button type="button" class="button is-info" @click="loadContent()"
|
||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">The idea behind the custom fields is to allow you to add new fields to the download
|
||||
form to automate some of the yt-dlp options.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-for="(item, index) in sortedDLFields" :key="item.id || index">
|
||||
<div class="box">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-12-mobile">
|
||||
<NuxtLink @click="deleteField(index)" :disabled="isLoading" class="has-text-danger">
|
||||
<span class="icon"><i class="fas fa-trash" /></span>
|
||||
<span>Delete Field</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
<span>Field Type</span>
|
||||
</label>
|
||||
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
|
||||
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
|
||||
<option v-for="kind in Object.values(FieldTypes)" :key="`kind-${kind}`" :value="kind"
|
||||
v-text="kind" />
|
||||
</select>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-font" /></span>
|
||||
<span>Field Name</span>
|
||||
</label>
|
||||
<input type="text" v-model="item.name" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
The name of the field, it will be shown in the UI.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||
<span>Associated yt-dlp option</span>
|
||||
</label>
|
||||
<input type="text" v-model="item.field" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
||||
<span>Field Description</span>
|
||||
</label>
|
||||
<input type="text" v-model="item.description" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
A short description of the field, it will be shown in the UI.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
|
||||
<span>Field Order</span>
|
||||
</label>
|
||||
<input type="number" v-model="item.order" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
The order of the field, used to sort the fields in the UI. Lower numbers will appear first.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-image" /></span>
|
||||
<span>Field Icon</span>
|
||||
</label>
|
||||
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
The icon of the field, must be from <NuxtLink
|
||||
href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
|
||||
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-warning-90 has-text-dark" v-if="items.length === 0">
|
||||
<span class="icon">
|
||||
<i class="fas fa-exclamation-circle" />
|
||||
</span>
|
||||
<span>
|
||||
No custom fields found, you can add new fields using the button above.
|
||||
</span>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot p-5">
|
||||
<div class="field is-grouped" style="width:100%">
|
||||
<div class="control is-expanded">
|
||||
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-save" /></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<button type="button" class="button is-fullwidth is-danger" @click="cancel" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-times" /></span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, ref } from 'vue'
|
||||
import type { DLField } from '~/types/dl_fields'
|
||||
|
||||
const emitter = defineEmits<{ (e: 'cancel'): void }>()
|
||||
|
||||
const toast = useNotification()
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const items = ref<DLField[]>([])
|
||||
|
||||
const FieldTypes = {
|
||||
STRING: 'string',
|
||||
TEXT: 'text',
|
||||
BOOL: 'bool'
|
||||
}
|
||||
|
||||
const cancel = () => emitter('cancel')
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request('/api/dl_fields')
|
||||
|
||||
const data = await response.json()
|
||||
if (0 === data.length) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value = data
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error('Failed to fetch page content.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateItems = async () => {
|
||||
|
||||
for (const item of items.value) {
|
||||
if (validateItem(item, items.value.indexOf(item) + 1)) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await request('/api/dl_fields', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(items.value)
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const error = await resp.json()
|
||||
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Fields updated successfully.')
|
||||
emitter('cancel')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addNewField = () => items.value.push({
|
||||
name: '',
|
||||
description: '',
|
||||
kind: 'string',
|
||||
field: '',
|
||||
value: '',
|
||||
icon: '',
|
||||
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
|
||||
extras: {}
|
||||
})
|
||||
|
||||
const deleteField = (index: number) => items.value.splice(index, 1)
|
||||
|
||||
const validateItem = (item: DLField, index: number): boolean => {
|
||||
|
||||
const requiredFields = ['name', 'field', 'kind', 'description']
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!item[field as keyof DLField]) {
|
||||
toast.error(`${item.name || index}: Field ${field} is required.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.order || item.order < 1) {
|
||||
toast.error(`${item.name || index}: Order must be a positive number.`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!Object.values(FieldTypes).includes(item.kind)) {
|
||||
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^--[a-zA-Z0-9\-]+$/.test(item.field)) {
|
||||
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => await loadContent())
|
||||
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||
</script>
|
||||
47
ui/app/components/DLInput.vue
Normal file
47
ui/app/components/DLInput.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="field">
|
||||
<label :for="`dlf-${id}`" class="label is-unselectable">
|
||||
<span v-if="icon" class="icon"><i :class="icon" /></span>
|
||||
<span v-tooltip="field ? `yt-dlp option: ${field}` : null" :class="{ 'has-tooltip': field }">{{ label }}</span>
|
||||
</label>
|
||||
<div class="control is-expanded" v-if="'string' === type">
|
||||
<input :id="`dlf-${id}`" :type="type" class="input" v-model="model" :placeholder="placeholder"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
<div class="control is-expanded" v-if="'text' === type">
|
||||
<textarea class="textarea is-pre" :id="`dlf-${id}`" v-model="model" :placeholder="placeholder"
|
||||
:disabled="disabled"></textarea>
|
||||
</div>
|
||||
<div class="control is-expanded" v-if="'bool' === type">
|
||||
<input type="checkbox" :id="`dlf-${id}`" v-model="model" class="switch is-success" :disabled="disabled" />
|
||||
<label :for="`dlf-${id}`" class="label is-unselectable">
|
||||
{{ model ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
</div>
|
||||
<span class="help is-bold is-unselectable">
|
||||
<template v-if="description">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span v-text="description" />
|
||||
</template>
|
||||
<template v-if="$slots.help">
|
||||
<slot name="help"></slot>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ModelRef } from 'vue';
|
||||
import type { DLFieldType } from '~/types/dl_fields';
|
||||
defineProps<{
|
||||
id: string,
|
||||
label: string,
|
||||
field?: string,
|
||||
type: DLFieldType,
|
||||
description?: string
|
||||
icon?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const model = defineModel() as ModelRef<string>
|
||||
</script>
|
||||
|
|
@ -88,96 +88,82 @@
|
|||
</div>
|
||||
|
||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
||||
<div class="column is-3-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||
<div class="field">
|
||||
<input id="force_download" type="checkbox" class="switch is-danger" :checked="forceDownload"
|
||||
@change="forceDownload = !forceDownload" :disabled="addInProgress" />
|
||||
<label for="force_download" class="is-unselectable">Force download</label>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span class="is-bold">Ignore archive and re-download.</span>
|
||||
</span>
|
||||
<div class="column is-3-tablet is-12-mobile">
|
||||
<DLInput id="force_download" type="bool" label="Force download"
|
||||
v-model="dlFields['--no-download-archive']" icon="fa-solid fa-download"
|
||||
:disabled="!socket.isConnected || addInProgress" description="Ignore archive and re-download." />
|
||||
</div>
|
||||
|
||||
<div class="column is-3-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||
<div class="field">
|
||||
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
|
||||
class="switch is-success" />
|
||||
<label for="auto_start" class="is-unselectable">Auto start</label>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span class="is-bold">Whether to start the download automatically.</span>
|
||||
</span>
|
||||
<div class="column is-3-tablet is-12-mobile">
|
||||
<DLInput id="auto_start" type="bool" label="Auto start" v-model="auto_start" icon="fa-solid fa-play"
|
||||
:disabled="!socket.isConnected || addInProgress"
|
||||
description="Whether to start the download automatically." />
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<label for="output_format" class="button is-static is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-file" /></span>
|
||||
<span>Template</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" v-model="form.template" id="output_format"
|
||||
:disabled="!socket.isConnected || addInProgress" :placeholder="get_output_template()">
|
||||
</div>
|
||||
</div>
|
||||
<span class="help is-bold is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>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>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline is-unselectable" for="cli_options">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
Command options for yt-dlp
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options"
|
||||
:disabled="!socket.isConnected || addInProgress"
|
||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
||||
</div>
|
||||
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this page
|
||||
</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported <NuxtLink
|
||||
target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26-L48">some are
|
||||
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.</span>
|
||||
<DLInput id="output_template" type="string" label="Output template" v-model="form.template"
|
||||
icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress"
|
||||
:placeholder="get_output_template()">
|
||||
<template #help>
|
||||
<span class="help is-bold is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>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>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DLInput>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline is-unselectable" for="ytdlpCookies">
|
||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||
Cookies
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="form.cookies"
|
||||
:disabled="!socket.isConnected || addInProgress" />
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Use the <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
|
||||
Recommended addon</NuxtLink> by yt-dlp to export cookies. <span class="has-text-danger">The
|
||||
cookies MUST be in Netscape HTTP Cookie format.</span>
|
||||
<DLInput id="cli_options" type="text" label="Command options for yt-dlp" v-model="form.cli"
|
||||
icon="fa-solid fa-terminal" :disabled="!socket.isConnected || addInProgress">
|
||||
<template #help>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this
|
||||
page</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported
|
||||
<NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26-L48">some are
|
||||
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DLInput>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<DLInput id="ytdlpCookies" type="text" label="Cookies for yt-dlp" v-model="form.cookies"
|
||||
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress">
|
||||
<template #help>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Use the <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
|
||||
Recommended addon</NuxtLink> by yt-dlp to export cookies. <span class="has-text-danger">The
|
||||
cookies MUST be in Netscape HTTP Cookie format.</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</DLInput>
|
||||
</div>
|
||||
<template v-if="config.dl_fields.length > 0">
|
||||
<div class="column is-6-tablet is-12-mobile" v-for="(fi, index) in sortedDLFields"
|
||||
:key="fi.id || `dlf-${index}`">
|
||||
<DLInput :id="fi?.id || `dlf-${index}`" :type="fi.kind" :description="fi.description" :label="fi.name"
|
||||
:icon="fi.icon" v-model="dlFields[fi.field]" :field="fi.field"
|
||||
:disabled="!socket.isConnected || addInProgress" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="column is-12 is-hidden-tablet">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||
<NuxtLink class="dropdown-item" @click="() => showFields = true">
|
||||
<span class="icon has-text-purple"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Custom Fields</span>
|
||||
</NuxtLink>
|
||||
<hr class="dropdown-divider" />
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset)">
|
||||
<span class="icon has-text-info"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp Information</span>
|
||||
|
|
@ -197,6 +183,13 @@
|
|||
</div>
|
||||
<div class="column is-12">
|
||||
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
|
||||
<div class="control">
|
||||
<button type="button" class="button is-purple" @click="() => showFields = true"
|
||||
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
|
||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Custom Fields</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
|
||||
:class="{ 'is-loading': !socket.isConnected }"
|
||||
|
|
@ -235,6 +228,8 @@
|
|||
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
|
||||
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
||||
@cancel="() => dialog_confirm.visible = false" />
|
||||
|
||||
<DLFields v-if="showFields" @cancel="() => showFields = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
|
@ -259,6 +254,9 @@ const auto_start = useStorage<boolean>('auto_start', true)
|
|||
const show_description = useStorage<boolean>('show_description', true)
|
||||
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const showFields = ref<boolean>(false)
|
||||
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
|
||||
const dlFieldsExtra = ['--no-download-archive']
|
||||
|
||||
const form = useStorage<item_request>('local_config_v1', {
|
||||
id: null,
|
||||
|
|
@ -271,6 +269,7 @@ const form = useStorage<item_request>('local_config_v1', {
|
|||
extras: {},
|
||||
}) as Ref<item_request>
|
||||
|
||||
|
||||
const dialog_confirm = ref({
|
||||
visible: false,
|
||||
title: 'Confirm Action',
|
||||
|
|
@ -278,13 +277,48 @@ const dialog_confirm = ref({
|
|||
message: '',
|
||||
options: [],
|
||||
})
|
||||
const FORCE_FLAG = '--no-download-archive'
|
||||
|
||||
const addDownload = async () => {
|
||||
if (form.value?.cli && '' !== form.value.cli) {
|
||||
const options = await convertOptions(form.value.cli)
|
||||
if (null === options) {
|
||||
return
|
||||
let form_cli = (form.value?.cli || '').trim()
|
||||
|
||||
const is_valid = (dl_field: string): boolean => {
|
||||
if (dlFieldsExtra.includes(dl_field)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (config.dl_fields && config.dl_fields.length > 0) {
|
||||
return config.dl_fields.some(f => f.field === dl_field)
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (false === config.app.basic_mode) {
|
||||
if (dlFields.value && Object.keys(dlFields.value).length > 0) {
|
||||
const joined = []
|
||||
for (const [key, value] of Object.entries(dlFields.value)) {
|
||||
if (false === is_valid(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ([undefined, null, '', false].includes(value as any)) {
|
||||
continue
|
||||
}
|
||||
|
||||
joined.push(true === value ? `${key}` : `${key} ${value}`)
|
||||
}
|
||||
|
||||
if (joined.length > 0) {
|
||||
form_cli = form_cli ? `${form_cli} ${joined.join(' ')}` : joined.join(' ')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (form_cli && form_cli.trim()) {
|
||||
const options = await convertOptions(form_cli)
|
||||
if (null === options) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +335,7 @@ const addDownload = async () => {
|
|||
folder: config.app.basic_mode ? null : form.value.folder,
|
||||
template: config.app.basic_mode ? null : form.value.template,
|
||||
cookies: config.app.basic_mode ? '' : form.value.cookies,
|
||||
cli: config.app.basic_mode ? null : form.value.cli,
|
||||
cli: config.app.basic_mode ? null : form_cli,
|
||||
auto_start: config.app.basic_mode ? true : auto_start.value
|
||||
} as item_request
|
||||
|
||||
|
|
@ -326,15 +360,20 @@ const addDownload = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
let had_errors = false
|
||||
|
||||
data.forEach((item: Record<string, any>) => {
|
||||
if (false !== item.status) {
|
||||
return
|
||||
}
|
||||
toast.error(`Error: ${item.msg || 'Failed to add download.'}`)
|
||||
had_errors = true
|
||||
})
|
||||
|
||||
form.value.url = ''
|
||||
emitter('clear_form')
|
||||
if (false === had_errors) {
|
||||
form.value.url = ''
|
||||
emitter('clear_form')
|
||||
}
|
||||
}
|
||||
catch (e: any) {
|
||||
console.error(e)
|
||||
|
|
@ -360,6 +399,7 @@ const reset_config = () => {
|
|||
folder: '',
|
||||
extras: {},
|
||||
} as item_request
|
||||
dlFields.value = {}
|
||||
|
||||
showAdvanced.value = false
|
||||
|
||||
|
|
@ -465,26 +505,5 @@ const get_output_template = () => {
|
|||
return config.app.output_template || '%(title)s.%(ext)s'
|
||||
}
|
||||
|
||||
const forceDownload = computed({
|
||||
get(): boolean {
|
||||
return new RegExp(`(^|\\s)${FORCE_FLAG}(\\s|$)`).test(form.value.cli || '')
|
||||
},
|
||||
set(val: boolean): void {
|
||||
const cli = form.value.cli || ''
|
||||
|
||||
if (val) {
|
||||
if (!cli.includes(FORCE_FLAG)) {
|
||||
form.value.cli = cli.trim() + (cli.trim() ? ` ${FORCE_FLAG}` : FORCE_FLAG)
|
||||
}
|
||||
} else {
|
||||
form.value.cli = cli
|
||||
.replace(new RegExp(`(\\s*)${FORCE_FLAG}(\\s*)`, 'g'), (match, before, after) => {
|
||||
return before && after ? ' ' : ''
|
||||
})
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/^[ \t]+|[ \t]+$/g, '')
|
||||
.trim()
|
||||
}
|
||||
},
|
||||
})
|
||||
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
'default': true
|
||||
}
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { io } from "socket.io-client";
|
||||
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
|
||||
import type { ManagerOptions } from "socket.io-client";
|
||||
import type { ConfigState } from "~/types/config";
|
||||
import type { StoreItem } from "~/types/store";
|
||||
|
||||
export const useSocketStore = defineStore('socket', () => {
|
||||
|
|
@ -55,8 +56,9 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
tasks: json.data.tasks,
|
||||
folders: json.data.folders,
|
||||
presets: json.data.presets,
|
||||
dl_fields: json.data.dl_fields,
|
||||
paused: Boolean(json.data.paused)
|
||||
})
|
||||
} as Partial<ConfigState>)
|
||||
|
||||
stateStore.addAll('queue', json.data.queue || {})
|
||||
stateStore.addAll('history', json.data.done || {})
|
||||
|
|
@ -177,6 +179,7 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
}, true);
|
||||
|
||||
on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || []));
|
||||
on('dlfields_update', (data: string) => config.update('dl_fields', JSON.parse(data).data || []));
|
||||
}
|
||||
|
||||
if (false === isConnected.value) {
|
||||
|
|
|
|||
3
ui/app/types/config.d.ts
vendored
3
ui/app/types/config.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
|||
import type { DLField } from "./dl_fields"
|
||||
|
||||
type AppConfig = {
|
||||
/** Path where downloaded files will be saved */
|
||||
|
|
@ -72,6 +73,8 @@ type ConfigState = {
|
|||
app: AppConfig
|
||||
/** List of presets */
|
||||
presets: Preset[]
|
||||
/** List of custom download fields */
|
||||
dl_fields: DLField[]
|
||||
/** List of folders where files can be saved */
|
||||
folders: string[]
|
||||
/** Indicates if downloads are currently paused */
|
||||
|
|
|
|||
44
ui/app/types/dl_fields.d.ts
vendored
Normal file
44
ui/app/types/dl_fields.d.ts
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
type DLFieldType = "string" | "text" | "bool";
|
||||
|
||||
type DLField = {
|
||||
/** The id of the field */
|
||||
id?: string;
|
||||
|
||||
/** The name of the preset */
|
||||
name: string;
|
||||
|
||||
/** The description of the preset */
|
||||
description: string;
|
||||
|
||||
/** The yt-dlp field to use in long format */
|
||||
field: string;
|
||||
|
||||
/** The kind of the field. i.e. string, bool */
|
||||
kind: DLFieldType;
|
||||
|
||||
/** The icon of the field, it can be a font-awesome icon */
|
||||
icon?: string;
|
||||
|
||||
/** The order of the field, used to sort the fields in the UI. */
|
||||
order: number = 1;
|
||||
|
||||
/** The default value of the field, It's currently unused */
|
||||
value: string;
|
||||
|
||||
/** Additional options for the field */
|
||||
extras: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for creating/updating DLField
|
||||
*/
|
||||
type DLFieldRequest = {
|
||||
name: string;
|
||||
description: string;
|
||||
field: string;
|
||||
kind: DLFieldType;
|
||||
value?: string;
|
||||
extras?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type { DLField, DLFieldRequest, DLFieldType };
|
||||
Loading…
Reference in a new issue