Added initial code to support adding custom fields to NewDownload form.

This commit is contained in:
arabcoders 2025-08-07 18:12:24 +03:00
parent c261cb6e4f
commit 0ea1d80243
6 changed files with 474 additions and 0 deletions

View file

@ -30,6 +30,7 @@
"dateparser",
"daterange",
"defusedxml",
"dlfields",
"dotenv",
"edgechromium",
"engineio",

View file

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

329
app/library/dl_fields.py Normal file
View file

@ -0,0 +1,329 @@
import json
import logging
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"""
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._default + 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 not isinstance(item.get("extras", {}), dict):
msg = "Extras must be a dictionary."
raise ValueError(msg) # noqa: TRY004
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

View file

@ -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
View 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(DLFields, 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)

38
ui/app/types/dl_fields.d.ts vendored Normal file
View file

@ -0,0 +1,38 @@
enum DLFieldType { STRING = "string", TEXT = "text", BOOL = "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 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 };