Added initial code to support adding custom fields to NewDownload form.
This commit is contained in:
parent
c261cb6e4f
commit
0ea1d80243
6 changed files with 474 additions and 0 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -30,6 +30,7 @@
|
||||||
"dateparser",
|
"dateparser",
|
||||||
"daterange",
|
"daterange",
|
||||||
"defusedxml",
|
"defusedxml",
|
||||||
|
"dlfields",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"edgechromium",
|
"edgechromium",
|
||||||
"engineio",
|
"engineio",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
329
app/library/dl_fields.py
Normal file
329
app/library/dl_fields.py
Normal 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
|
||||||
|
|
@ -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(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
38
ui/app/types/dl_fields.d.ts
vendored
Normal 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 };
|
||||||
Loading…
Reference in a new issue