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",
|
"dateparser",
|
||||||
"daterange",
|
"daterange",
|
||||||
"defusedxml",
|
"defusedxml",
|
||||||
|
"dlfields",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"edgechromium",
|
"edgechromium",
|
||||||
"engineio",
|
"engineio",
|
||||||
|
"Errno",
|
||||||
"euuo",
|
"euuo",
|
||||||
"faststart",
|
"faststart",
|
||||||
"finaldir",
|
"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_ADD: str = "presets_add"
|
||||||
PRESETS_UPDATE: str = "presets_update"
|
PRESETS_UPDATE: str = "presets_update"
|
||||||
|
|
||||||
|
DLFIELDS_ADD: str = "dlfields_add"
|
||||||
|
DLFIELDS_UPDATE: str = "dlfields_update"
|
||||||
|
|
||||||
SCHEDULE_ADD: str = "schedule_add"
|
SCHEDULE_ADD: str = "schedule_add"
|
||||||
|
|
||||||
CONDITIONS_ADD: str = "conditions_add"
|
CONDITIONS_ADD: str = "conditions_add"
|
||||||
|
|
@ -104,6 +107,7 @@ class Events:
|
||||||
Events.CLI_CLOSE,
|
Events.CLI_CLOSE,
|
||||||
Events.CLI_OUTPUT,
|
Events.CLI_OUTPUT,
|
||||||
Events.PRESETS_UPDATE,
|
Events.PRESETS_UPDATE,
|
||||||
|
Events.DLFIELDS_UPDATE,
|
||||||
]
|
]
|
||||||
|
|
||||||
def only_debug() -> list:
|
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.BackgroundWorker import BackgroundWorker
|
||||||
from app.library.conditions import Conditions
|
from app.library.conditions import Conditions
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
|
from app.library.dl_fields import DLFields
|
||||||
from app.library.DownloadQueue import DownloadQueue
|
from app.library.DownloadQueue import DownloadQueue
|
||||||
from app.library.Events import EventBus, Events
|
from app.library.Events import EventBus, Events
|
||||||
from app.library.HttpAPI import HttpAPI
|
from app.library.HttpAPI import HttpAPI
|
||||||
|
|
@ -127,6 +128,7 @@ class Main:
|
||||||
Presets.get_instance().attach(self._app)
|
Presets.get_instance().attach(self._app)
|
||||||
Notification.get_instance().attach(self._app)
|
Notification.get_instance().attach(self._app)
|
||||||
Conditions.get_instance().attach(self._app)
|
Conditions.get_instance().attach(self._app)
|
||||||
|
DLFields.get_instance().attach(self._app)
|
||||||
self._background_worker.attach(self._app)
|
self._background_worker.attach(self._app)
|
||||||
|
|
||||||
EventBus.get_instance().sync_emit(
|
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
|
import socketio
|
||||||
|
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
|
from app.library.dl_fields import DLFields
|
||||||
from app.library.DownloadQueue import DownloadQueue
|
from app.library.DownloadQueue import DownloadQueue
|
||||||
from app.library.Events import EventBus, Events
|
from app.library.Events import EventBus, Events
|
||||||
from app.library.Presets import Presets
|
from app.library.Presets import Presets
|
||||||
|
|
@ -26,6 +27,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
|
||||||
**queue.get(),
|
**queue.get(),
|
||||||
"config": config.frontend(),
|
"config": config.frontend(),
|
||||||
"presets": Presets.get_instance().get_all(),
|
"presets": Presets.get_instance().get_all(),
|
||||||
|
"dl_fields": DLFields.get_instance().get_all(),
|
||||||
"paused": queue.is_paused(),
|
"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>
|
||||||
|
|
||||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
<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="column is-3-tablet is-12-mobile">
|
||||||
<div class="field">
|
<DLInput id="force_download" type="bool" label="Force download"
|
||||||
<input id="force_download" type="checkbox" class="switch is-danger" :checked="forceDownload"
|
v-model="dlFields['--no-download-archive']" icon="fa-solid fa-download"
|
||||||
@change="forceDownload = !forceDownload" :disabled="addInProgress" />
|
:disabled="!socket.isConnected || addInProgress" description="Ignore archive and re-download." />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="column is-3-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
<div class="column is-3-tablet is-12-mobile">
|
||||||
<div class="field">
|
<DLInput id="auto_start" type="bool" label="Auto start" v-model="auto_start" icon="fa-solid fa-play"
|
||||||
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
|
:disabled="!socket.isConnected || addInProgress"
|
||||||
class="switch is-success" />
|
description="Whether to start the download automatically." />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<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">
|
<DLInput id="output_template" type="string" label="Output template" v-model="form.template"
|
||||||
<div class="field">
|
icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress"
|
||||||
<label class="label is-inline is-unselectable" for="cli_options">
|
:placeholder="get_output_template()">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<template #help>
|
||||||
Command options for yt-dlp
|
<span class="help is-bold is-unselectable">
|
||||||
</label>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<div class="control">
|
<span>All output template naming options can be found at <NuxtLink target="_blank"
|
||||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options"
|
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.</span>
|
||||||
: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>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</template>
|
||||||
</div>
|
</DLInput>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<DLInput id="cli_options" type="text" label="Command options for yt-dlp" v-model="form.cli"
|
||||||
<label class="label is-inline is-unselectable" for="ytdlpCookies">
|
icon="fa-solid fa-terminal" :disabled="!socket.isConnected || addInProgress">
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
<template #help>
|
||||||
Cookies
|
<span class="help is-bold">
|
||||||
</label>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<div class="control">
|
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this
|
||||||
<textarea class="textarea is-pre" id="ytdlpCookies" v-model="form.cookies"
|
page</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported
|
||||||
:disabled="!socket.isConnected || addInProgress" />
|
<NuxtLink target="_blank"
|
||||||
</div>
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26-L48">some are
|
||||||
<span class="help is-bold">
|
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
</span>
|
||||||
<span>Use the <NuxtLink target="_blank"
|
</span>
|
||||||
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>
|
||||||
</span>
|
</template>
|
||||||
</div>
|
</DLInput>
|
||||||
</div>
|
</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">
|
<div class="column is-12 is-hidden-tablet">
|
||||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
<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)">
|
<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 class="icon has-text-info"><i class="fa-solid fa-info" /></span>
|
||||||
<span>yt-dlp Information</span>
|
<span>yt-dlp Information</span>
|
||||||
|
|
@ -197,6 +183,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
|
<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">
|
<div class="control">
|
||||||
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
|
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
|
||||||
:class="{ 'is-loading': !socket.isConnected }"
|
:class="{ 'is-loading': !socket.isConnected }"
|
||||||
|
|
@ -235,6 +228,8 @@
|
||||||
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
|
<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"
|
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
||||||
@cancel="() => dialog_confirm.visible = false" />
|
@cancel="() => dialog_confirm.visible = false" />
|
||||||
|
|
||||||
|
<DLFields v-if="showFields" @cancel="() => showFields = false" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -259,6 +254,9 @@ const auto_start = useStorage<boolean>('auto_start', true)
|
||||||
const show_description = useStorage<boolean>('show_description', true)
|
const show_description = useStorage<boolean>('show_description', true)
|
||||||
|
|
||||||
const addInProgress = ref<boolean>(false)
|
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', {
|
const form = useStorage<item_request>('local_config_v1', {
|
||||||
id: null,
|
id: null,
|
||||||
|
|
@ -271,6 +269,7 @@ const form = useStorage<item_request>('local_config_v1', {
|
||||||
extras: {},
|
extras: {},
|
||||||
}) as Ref<item_request>
|
}) as Ref<item_request>
|
||||||
|
|
||||||
|
|
||||||
const dialog_confirm = ref({
|
const dialog_confirm = ref({
|
||||||
visible: false,
|
visible: false,
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
|
|
@ -278,13 +277,48 @@ const dialog_confirm = ref({
|
||||||
message: '',
|
message: '',
|
||||||
options: [],
|
options: [],
|
||||||
})
|
})
|
||||||
const FORCE_FLAG = '--no-download-archive'
|
|
||||||
|
|
||||||
const addDownload = async () => {
|
const addDownload = async () => {
|
||||||
if (form.value?.cli && '' !== form.value.cli) {
|
let form_cli = (form.value?.cli || '').trim()
|
||||||
const options = await convertOptions(form.value.cli)
|
|
||||||
if (null === options) {
|
const is_valid = (dl_field: string): boolean => {
|
||||||
return
|
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,
|
folder: config.app.basic_mode ? null : form.value.folder,
|
||||||
template: config.app.basic_mode ? null : form.value.template,
|
template: config.app.basic_mode ? null : form.value.template,
|
||||||
cookies: config.app.basic_mode ? '' : form.value.cookies,
|
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
|
auto_start: config.app.basic_mode ? true : auto_start.value
|
||||||
} as item_request
|
} as item_request
|
||||||
|
|
||||||
|
|
@ -326,15 +360,20 @@ const addDownload = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let had_errors = false
|
||||||
|
|
||||||
data.forEach((item: Record<string, any>) => {
|
data.forEach((item: Record<string, any>) => {
|
||||||
if (false !== item.status) {
|
if (false !== item.status) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.error(`Error: ${item.msg || 'Failed to add download.'}`)
|
toast.error(`Error: ${item.msg || 'Failed to add download.'}`)
|
||||||
|
had_errors = true
|
||||||
})
|
})
|
||||||
|
|
||||||
form.value.url = ''
|
if (false === had_errors) {
|
||||||
emitter('clear_form')
|
form.value.url = ''
|
||||||
|
emitter('clear_form')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -360,6 +399,7 @@ const reset_config = () => {
|
||||||
folder: '',
|
folder: '',
|
||||||
extras: {},
|
extras: {},
|
||||||
} as item_request
|
} as item_request
|
||||||
|
dlFields.value = {}
|
||||||
|
|
||||||
showAdvanced.value = false
|
showAdvanced.value = false
|
||||||
|
|
||||||
|
|
@ -465,26 +505,5 @@ const get_output_template = () => {
|
||||||
return config.app.output_template || '%(title)s.%(ext)s'
|
return config.app.output_template || '%(title)s.%(ext)s'
|
||||||
}
|
}
|
||||||
|
|
||||||
const forceDownload = computed({
|
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||||
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()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
'default': true
|
'default': true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
dl_fields: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
paused: false,
|
paused: false,
|
||||||
is_loaded: false,
|
is_loaded: false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
|
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
|
||||||
import type { ManagerOptions } from "socket.io-client";
|
import type { ManagerOptions } from "socket.io-client";
|
||||||
|
import type { ConfigState } from "~/types/config";
|
||||||
import type { StoreItem } from "~/types/store";
|
import type { StoreItem } from "~/types/store";
|
||||||
|
|
||||||
export const useSocketStore = defineStore('socket', () => {
|
export const useSocketStore = defineStore('socket', () => {
|
||||||
|
|
@ -55,8 +56,9 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
tasks: json.data.tasks,
|
tasks: json.data.tasks,
|
||||||
folders: json.data.folders,
|
folders: json.data.folders,
|
||||||
presets: json.data.presets,
|
presets: json.data.presets,
|
||||||
|
dl_fields: json.data.dl_fields,
|
||||||
paused: Boolean(json.data.paused)
|
paused: Boolean(json.data.paused)
|
||||||
})
|
} as Partial<ConfigState>)
|
||||||
|
|
||||||
stateStore.addAll('queue', json.data.queue || {})
|
stateStore.addAll('queue', json.data.queue || {})
|
||||||
stateStore.addAll('history', json.data.done || {})
|
stateStore.addAll('history', json.data.done || {})
|
||||||
|
|
@ -177,6 +179,7 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || []));
|
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) {
|
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 = {
|
type AppConfig = {
|
||||||
/** Path where downloaded files will be saved */
|
/** Path where downloaded files will be saved */
|
||||||
|
|
@ -72,6 +73,8 @@ type ConfigState = {
|
||||||
app: AppConfig
|
app: AppConfig
|
||||||
/** List of presets */
|
/** List of presets */
|
||||||
presets: Preset[]
|
presets: Preset[]
|
||||||
|
/** List of custom download fields */
|
||||||
|
dl_fields: DLField[]
|
||||||
/** List of folders where files can be saved */
|
/** List of folders where files can be saved */
|
||||||
folders: string[]
|
folders: string[]
|
||||||
/** Indicates if downloads are currently paused */
|
/** 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