+ + +
++ + {{ field.description }} +
+diff --git a/app/features/conditions/models.py b/app/features/conditions/models.py
index fc0604d2..088cb0b2 100644
--- a/app/features/conditions/models.py
+++ b/app/features/conditions/models.py
@@ -17,7 +17,7 @@ class ConditionModel(Base):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- name: Mapped[str] = mapped_column(String(255), nullable=False)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
filter: Mapped[str] = mapped_column(Text, nullable=False)
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
diff --git a/app/features/conditions/repository.py b/app/features/conditions/repository.py
index 08eee466..32def7dd 100644
--- a/app/features/conditions/repository.py
+++ b/app/features/conditions/repository.py
@@ -19,7 +19,7 @@ from sqlalchemy import delete, func, or_, select
from app.features.conditions.models import ConditionModel
from app.features.core.deps import get_session
-LOG: logging.Logger = logging.getLogger("app.features.conditions.repository")
+LOG: logging.Logger = logging.getLogger(__name__)
class ConditionsRepository(metaclass=Singleton):
diff --git a/app/features/conditions/service.py b/app/features/conditions/service.py
index 716512a3..1ede3af8 100644
--- a/app/features/conditions/service.py
+++ b/app/features/conditions/service.py
@@ -7,7 +7,6 @@ from app.features.conditions.repository import ConditionsRepository
from app.library.Events import EventBus, Events
from app.library.mini_filter import match_str
from app.library.Singleton import Singleton
-from app.library.Utils import arg_converter
LOG: logging.Logger = logging.getLogger("feature.conditions")
@@ -32,62 +31,6 @@ class Conditions(metaclass=Singleton):
async def get_all(self) -> list[ConditionModel]:
return await self._repo.list()
- def validate(self, item: ConditionModel | dict) -> bool:
- if not isinstance(item, dict):
- if not isinstance(item, ConditionModel):
- msg: str = f"Unexpected '{type(item).__name__}' item type."
- raise ValueError(msg)
-
- item = item.serialize()
-
- if not item.get("id"):
- msg: str = "No id found."
- raise ValueError(msg)
-
- if not item.get("name"):
- msg: str = "No name found."
- raise ValueError(msg)
-
- if not item.get("filter"):
- msg: str = "No filter found."
- raise ValueError(msg)
-
- try:
- match_str(item.get("filter"), {})
- except Exception as e:
- msg: str = f"Invalid filter. '{e!s}'."
- raise ValueError(msg) from e
-
- if item.get("cli"):
- try:
- arg_converter(args=item.get("cli"))
- except Exception as e:
- msg = f"Invalid command options for yt-dlp. '{e!s}'."
- raise ValueError(msg) from e
-
- if not isinstance(item.get("extras"), dict):
- msg = "Extras must be a dictionary."
- raise ValueError(msg)
-
- if item.get("enabled") is not None and not isinstance(item.get("enabled"), bool):
- msg = "Enabled must be a boolean."
- raise ValueError(msg)
-
- if item.get("priority") is not None:
- priority = item.get("priority")
- if not isinstance(priority, int):
- msg = "Priority must be an integer."
- raise ValueError(msg)
- if priority < 0:
- msg = "Priority must be >= 0."
- raise ValueError(msg)
-
- if item.get("description") and not isinstance(item.get("description"), str):
- msg = "Description must be a string."
- raise ValueError(msg)
-
- return True
-
async def save(self, item: ConditionModel | dict) -> ConditionModel:
"""
Save the item.
diff --git a/app/features/conditions/tests/test_repository.py b/app/features/conditions/tests/test_conditions_repository.py
similarity index 100%
rename from app/features/conditions/tests/test_repository.py
rename to app/features/conditions/tests/test_conditions_repository.py
diff --git a/app/features/dl_fields/__init__.py b/app/features/dl_fields/__init__.py
new file mode 100644
index 00000000..3a0532ca
--- /dev/null
+++ b/app/features/dl_fields/__init__.py
@@ -0,0 +1 @@
+"""DL Fields Feature"""
diff --git a/app/features/dl_fields/deps.py b/app/features/dl_fields/deps.py
new file mode 100644
index 00000000..3cb9af30
--- /dev/null
+++ b/app/features/dl_fields/deps.py
@@ -0,0 +1,5 @@
+from app.features.dl_fields.repository import DLFieldsRepository
+
+
+def get_dl_fields_repo() -> DLFieldsRepository:
+ return DLFieldsRepository.get_instance()
diff --git a/app/features/dl_fields/migration.py b/app/features/dl_fields/migration.py
new file mode 100644
index 00000000..31c5e2a9
--- /dev/null
+++ b/app/features/dl_fields/migration.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from app.features.core.migration import Migration as FeatureMigration
+from app.features.dl_fields.schemas import DLField
+from app.library.config import Config
+
+if TYPE_CHECKING:
+ from app.features.dl_fields.repository import DLFieldsRepository
+
+LOG: logging.Logger = logging.getLogger(__name__)
+
+
+class Migration(FeatureMigration):
+ name: str = "dl_fields"
+
+ def __init__(self, repo: DLFieldsRepository, config: Config | None = None):
+ self._config: Config = config or Config.get_instance()
+ super().__init__(config=self._config)
+ self._repo: DLFieldsRepository = repo
+ self._source_file: Path = Path(self._config.config_path) / "dl_fields.json"
+
+ async def should_run(self) -> bool:
+ return self._source_file.exists()
+
+ async def migrate(self) -> None:
+ if await self._repo.count() > 0:
+ LOG.warning("DL fields already exist in the database; skipping migration.")
+ await self._move_file(self._source_file)
+ return
+
+ try:
+ items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
+ except Exception as exc:
+ LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
+ await self._move_file(self._source_file)
+ return
+
+ if items is None:
+ LOG.warning("No dl fields found in %s; skipping migration.", self._source_file)
+ await self._move_file(self._source_file)
+ return
+
+ inserted = 0
+ for index, item in enumerate(items):
+ if not (normalized := self._normalize(item, index)):
+ continue
+ try:
+ await self._repo.create(normalized)
+ inserted += 1
+ except Exception as exc:
+ LOG.exception("Failed to insert dl field '%s': %s", normalized["name"], exc)
+
+ LOG.info("Migrated %s dl field(s) from %s.", inserted, self._source_file)
+ await self._move_file(self._source_file)
+
+ def _normalize(self, item: Any, index: int) -> dict[str, Any] | None:
+ if not isinstance(item, dict):
+ LOG.warning("Skipping dl field at index %s due to invalid type.", index)
+ return None
+
+ name: str | None = item.get("name")
+ if not name or not isinstance(name, str):
+ LOG.warning("Skipping dl field at index %s due to missing name.", index)
+ return None
+
+ description: str = item.get("description") if isinstance(item.get("description"), str) else ""
+ field_value: str | None = item.get("field")
+ if not field_value or not isinstance(field_value, str):
+ LOG.warning("Skipping dl field '%s' due to missing field value.", name)
+ return None
+
+ kind: str = item.get("kind") if isinstance(item.get("kind"), str) else "text"
+ icon: str = item.get("icon") if isinstance(item.get("icon"), str) else ""
+ value: str = item.get("value") if isinstance(item.get("value"), str) else ""
+ extras: dict[str, Any] = item.get("extras") if isinstance(item.get("extras"), dict) else {}
+
+ order_value: int = 0
+ if isinstance(item.get("order"), int) and not isinstance(item.get("order"), bool):
+ order_value = item.get("order", 0)
+
+ payload = {
+ "name": name,
+ "description": description,
+ "field": field_value,
+ "kind": kind,
+ "icon": icon,
+ "order": order_value,
+ "value": value,
+ "extras": extras,
+ }
+
+ try:
+ DLField.model_validate(payload)
+ except Exception as exc:
+ LOG.warning("Skipping dl field '%s' due to validation error: %s", name, exc)
+ return None
+
+ return payload
diff --git a/app/features/dl_fields/models.py b/app/features/dl_fields/models.py
new file mode 100644
index 00000000..1c831903
--- /dev/null
+++ b/app/features/dl_fields/models.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from datetime import datetime # noqa: TC003
+
+from sqlalchemy import JSON, Index, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.features.core.models import Base, UTCDateTime, utcnow
+
+
+class DLFieldModel(Base):
+ __tablename__: str = "dl_fields"
+ __table_args__: tuple[Index, ...] = (
+ Index("ix_dl_fields_name", "name"),
+ Index("ix_dl_fields_order", "order"),
+ Index("ix_dl_fields_kind", "kind"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
+ description: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ field: Mapped[str] = mapped_column(String(255), nullable=False)
+ kind: Mapped[str] = mapped_column(String(50), nullable=False, default="text")
+ icon: Mapped[str] = mapped_column(String(255), nullable=False, default="")
+ order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ value: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ extras: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
diff --git a/app/features/dl_fields/repository.py b/app/features/dl_fields/repository.py
new file mode 100644
index 00000000..96502c6e
--- /dev/null
+++ b/app/features/dl_fields/repository.py
@@ -0,0 +1,168 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+from sqlalchemy import delete, func, or_, select
+
+from app.features.core.deps import get_session
+from app.features.dl_fields.migration import Migration
+from app.features.dl_fields.models import DLFieldModel
+from app.library.Singleton import Singleton
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, Iterable
+
+ from sqlalchemy.engine.result import Result
+ from sqlalchemy.ext.asyncio import AsyncSession
+ from sqlalchemy.sql.elements import ColumnElement
+ from sqlalchemy.sql.selectable import Select
+
+LOG: logging.Logger = logging.getLogger(__name__)
+
+
+class DLFieldsRepository(metaclass=Singleton):
+ def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
+ self._migrated = False
+ self.session: AsyncGenerator[AsyncSession] = session or get_session
+
+ async def run_migrations(self) -> None:
+ if self._migrated:
+ return
+
+ self._migrated = True
+ await Migration(repo=self).run()
+
+ @staticmethod
+ def get_instance() -> DLFieldsRepository:
+ return DLFieldsRepository()
+
+ async def list(self) -> list[DLFieldModel]:
+ async with self.session() as session:
+ result: Result[tuple[DLFieldModel]] = await session.execute(
+ select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
+ )
+ return list(result.scalars().all())
+
+ async def list_paginated(self, page: int, per_page: int) -> tuple[list[DLFieldModel], int, int, int]:
+ async with self.session() as session:
+ total: int = await self.count()
+ total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
+
+ if page > total_pages and total > 0:
+ page = total_pages
+
+ query: Select[tuple[DLFieldModel]] = (
+ select(DLFieldModel)
+ .order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
+ .limit(per_page)
+ .offset((page - 1) * per_page)
+ )
+ result: Result[tuple[DLFieldModel]] = await session.execute(query)
+ return list(result.scalars().all()), total, page, total_pages
+
+ async def count(self) -> int:
+ async with self.session() as session:
+ result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(DLFieldModel))
+ return int(result.scalar_one())
+
+ async def get(self, identifier: int | str) -> DLFieldModel | None:
+ async with self.session() as session:
+ if not identifier:
+ return None
+
+ if isinstance(identifier, int):
+ clause: ColumnElement[bool] = DLFieldModel.id == identifier
+ elif isinstance(identifier, str) and identifier.isdigit():
+ clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
+ else:
+ clause = DLFieldModel.name == identifier
+
+ result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
+ return result.scalar_one_or_none()
+
+ async def get_by_name(self, name: str, exclude_id: int | None = None) -> DLFieldModel | None:
+ async with self.session() as session:
+ query: Select[tuple[DLFieldModel]] = select(DLFieldModel).where(DLFieldModel.name == name)
+ if exclude_id is not None:
+ query = query.where(DLFieldModel.id != exclude_id)
+
+ result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1))
+ return result.scalar_one_or_none()
+
+ async def create(self, payload: DLFieldModel | dict) -> DLFieldModel:
+ async with self.session() as session:
+ model: DLFieldModel = DLFieldModel(**payload) if isinstance(payload, dict) else payload
+
+ if await self.get_by_name(name=model.name) is not None:
+ msg: str = f"DL field with name '{model.name}' already exists."
+ raise ValueError(msg)
+
+ session.add(model)
+ await session.commit()
+ await session.refresh(model)
+ return model
+
+ async def update(self, identifier: int | str, payload: dict[str, Any]) -> DLFieldModel:
+ async with self.session() as session:
+ if isinstance(identifier, int):
+ clause: ColumnElement[bool] = DLFieldModel.id == identifier
+ elif isinstance(identifier, str) and identifier.isdigit():
+ clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
+ else:
+ clause = DLFieldModel.name == identifier
+
+ result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
+ model: DLFieldModel | None = result.scalar_one_or_none()
+
+ if not model:
+ msg: str = f"DL field '{identifier}' not found."
+ raise KeyError(msg)
+
+ payload.pop("id", None)
+
+ if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
+ msg = f"DL field with name '{payload['name']}' already exists."
+ raise ValueError(msg)
+
+ for key, value in payload.items():
+ if hasattr(model, key):
+ setattr(model, key, value)
+
+ await session.commit()
+ await session.refresh(model)
+ return model
+
+ async def delete(self, identifier: int | str) -> DLFieldModel:
+ async with self.session() as session:
+ if isinstance(identifier, int):
+ clause: ColumnElement[bool] = DLFieldModel.id == identifier
+ elif isinstance(identifier, str) and identifier.isdigit():
+ clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
+ else:
+ clause = DLFieldModel.name == identifier
+
+ result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
+ model = result.scalar_one_or_none()
+
+ if not model:
+ msg: str = f"DL field '{identifier}' not found."
+ raise KeyError(msg)
+
+ await session.delete(model)
+ await session.commit()
+ return model
+
+ async def replace_all(self, items: Iterable[dict | DLFieldModel]) -> list[DLFieldModel]:
+ async with self.session() as session:
+ try:
+ await session.execute(delete(DLFieldModel))
+ models: list[DLFieldModel] = [
+ DLFieldModel(**item) if isinstance(item, dict) else item for item in items
+ ]
+ session.add_all(models)
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+ return models
diff --git a/app/features/dl_fields/router.py b/app/features/dl_fields/router.py
new file mode 100644
index 00000000..ae5c484a
--- /dev/null
+++ b/app/features/dl_fields/router.py
@@ -0,0 +1,169 @@
+import logging
+from typing import Any
+
+from aiohttp import web
+from aiohttp.web import Request, Response
+from pydantic import ValidationError
+
+from app.features.core.schemas import Pagination
+from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
+from app.features.dl_fields.schemas import DLField, DLFieldList, DLFieldPatch
+from app.features.dl_fields.service import DLFields
+from app.library.encoder import Encoder
+from app.library.Events import EventBus, Events
+from app.library.router import route
+
+LOG: logging.Logger = logging.getLogger(__name__)
+
+
+def _model(model: Any) -> DLField:
+ return DLField.model_validate(model)
+
+
+def _serialize(model: Any) -> dict:
+ return _model(model).model_dump()
+
+
+@route("GET", "api/dl_fields/", "dl_fields")
+async def dl_fields_list(request: Request, encoder: Encoder) -> Response:
+ repo = DLFields.get_instance()._repo
+ page, per_page = normalize_pagination(request)
+ items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
+ return web.json_response(
+ data=DLFieldList(
+ items=[_model(model) for model in items],
+ pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
+ ),
+ status=web.HTTPOk.status_code,
+ dumps=encoder.encode,
+ )
+
+
+@route("POST", "api/dl_fields/", "dl_fields_add")
+async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
+ data = await request.json()
+
+ if not isinstance(data, dict):
+ return web.json_response(
+ {"error": "Invalid request body expecting dict."},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ try:
+ item: DLField = DLField.model_validate(data)
+ except ValidationError as exc:
+ return web.json_response(
+ data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ try:
+ saved = _serialize(await DLFields.get_instance().save(item=item.model_dump()))
+ except ValueError as exc:
+ return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
+
+ notify.emit(Events.DLFIELDS_UPDATE, data=[saved])
+
+ return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
+
+
+@route("GET", "api/dl_fields/{id}", "dl_fields_get")
+async def dl_fields_get(request: Request, encoder: Encoder) -> Response:
+ if not (identifier := request.match_info.get("id")):
+ return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
+
+ if not (model := await DLFields.get_instance().get(identifier)):
+ return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
+
+ return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
+
+
+@route("DELETE", "api/dl_fields/{id}", "dl_fields_delete")
+async def dl_fields_delete(request: Request, encoder: Encoder) -> Response:
+ if not (identifier := request.match_info.get("id")):
+ return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
+
+ try:
+ return web.json_response(
+ data=_serialize(await DLFields.get_instance()._repo.delete(identifier)),
+ status=web.HTTPOk.status_code,
+ dumps=encoder.encode,
+ )
+ except KeyError as exc:
+ return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
+
+
+@route("PATCH", "api/dl_fields/{id}", "dl_fields_patch")
+async def dl_fields_patch(request: Request, encoder: Encoder) -> Response:
+ if not (identifier := request.match_info.get("id")):
+ return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
+
+ if not (model := await DLFields.get_instance().get(identifier)):
+ return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
+
+ data = await request.json()
+
+ if not isinstance(data, dict):
+ return web.json_response(
+ {"error": "Invalid request body expecting dict."},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ try:
+ validated = DLFieldPatch.model_validate(data)
+ except ValidationError as exc:
+ return web.json_response(
+ data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id):
+ return web.json_response(
+ data={"error": f"DL field with name '{validated.name}' already exists."},
+ status=web.HTTPConflict.status_code,
+ )
+
+ dct = validated.model_dump(exclude_unset=True)
+
+ return web.json_response(
+ data=_serialize(await DLFields.get_instance()._repo.update(model.id, dct)),
+ status=web.HTTPOk.status_code,
+ dumps=encoder.encode,
+ )
+
+
+@route("PUT", "api/dl_fields/{id}", "dl_fields_update")
+async def dl_fields_update(request: Request, encoder: Encoder) -> Response:
+ if not (identifier := request.match_info.get("id")):
+ return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
+
+ if not (model := await DLFields.get_instance().get(identifier)):
+ return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
+
+ data = await request.json()
+
+ if not isinstance(data, dict):
+ return web.json_response(
+ {"error": "Invalid request body expecting dict."},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ try:
+ validated = DLField.model_validate(data)
+ except ValidationError as exc:
+ return web.json_response(
+ data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id):
+ return web.json_response(
+ data={"error": f"DL field with name '{validated.name}' already exists."},
+ status=web.HTTPConflict.status_code,
+ )
+
+ return web.json_response(
+ data=_serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True))),
+ status=web.HTTPOk.status_code,
+ dumps=encoder.encode,
+ )
diff --git a/app/features/dl_fields/schemas.py b/app/features/dl_fields/schemas.py
new file mode 100644
index 00000000..666237a4
--- /dev/null
+++ b/app/features/dl_fields/schemas.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from app.features.core.schemas import Pagination
+from app.features.core.utils import parse_int
+
+
+class FieldType(str, Enum):
+ STRING = "string"
+ TEXT = "text"
+ BOOL = "bool"
+
+
+class DLField(BaseModel):
+ model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
+
+ id: int | None = None
+ name: str = Field(min_length=1)
+ description: str = ""
+ field: str = Field(min_length=1)
+ kind: FieldType = FieldType.TEXT
+ icon: str = ""
+ order: int = 0
+ value: str = ""
+ extras: dict[str, Any] = Field(default_factory=dict)
+
+ @field_validator("field")
+ @classmethod
+ def _validate_field(cls, value: str) -> str:
+ if not value.startswith("--"):
+ msg: str = "Field must start with '--'."
+ raise ValueError(msg)
+ if " " in value:
+ msg = "Field must not contain spaces."
+ raise ValueError(msg)
+ return value
+
+ @field_validator("order", mode="before")
+ @classmethod
+ def _normalize_order(cls, value: Any) -> int:
+ return parse_int(value, field="Order", minimum=0)
+
+ @model_validator(mode="after")
+ def _normalize_extras(self) -> DLField:
+ if not isinstance(self.extras, dict):
+ msg: str = "Extras must be a dictionary."
+ raise ValueError(msg)
+ return self
+
+
+class DLFieldPatch(BaseModel):
+ model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
+
+ name: str | None = None
+ description: str | None = None
+ field: str | None = None
+ kind: FieldType | None = None
+ icon: str | None = None
+ order: int | None = None
+ value: str | None = None
+ extras: dict[str, Any] | None = None
+
+ @field_validator("field")
+ @classmethod
+ def _validate_field(cls, value: str | None) -> str | None:
+ if value is None:
+ return value
+ if not value.startswith("--"):
+ msg: str = "Field must start with '--'."
+ raise ValueError(msg)
+ if " " in value:
+ msg = "Field must not contain spaces."
+ raise ValueError(msg)
+ return value
+
+ @field_validator("order", mode="before")
+ @classmethod
+ def _normalize_order(cls, value: Any) -> int | None:
+ if value is None:
+ return None
+ return parse_int(value, field="Order", minimum=0)
+
+ @model_validator(mode="after")
+ def _normalize_extras(self) -> DLFieldPatch:
+ if self.extras is None:
+ return self
+ if not isinstance(self.extras, dict):
+ msg: str = "Extras must be a dictionary."
+ raise ValueError(msg)
+ return self
+
+
+class DLFieldList(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ items: list[DLField] = Field(default_factory=list)
+ pagination: Pagination
diff --git a/app/features/dl_fields/service.py b/app/features/dl_fields/service.py
new file mode 100644
index 00000000..77e2b261
--- /dev/null
+++ b/app/features/dl_fields/service.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+from app.features.dl_fields.models import DLFieldModel
+from app.features.dl_fields.repository import DLFieldsRepository
+from app.features.dl_fields.schemas import DLField
+from app.library.Events import EventBus, Events
+from app.library.Singleton import Singleton
+
+if TYPE_CHECKING:
+ from aiohttp import web
+
+LOG: logging.Logger = logging.getLogger("feature.dl_fields")
+
+
+class DLFields(metaclass=Singleton):
+ def __init__(self):
+ self._repo: DLFieldsRepository = DLFieldsRepository.get_instance()
+
+ @staticmethod
+ def get_instance() -> DLFields:
+ return DLFields()
+
+ async def on_shutdown(self, _: web.Application) -> None:
+ pass
+
+ def attach(self, _: web.Application) -> None:
+ async def handle_event(_, __):
+ await self._repo.run_migrations()
+
+ EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations")
+
+ async def get_all(self) -> list[DLFieldModel]:
+ return await self._repo.list()
+
+ async def get_all_serialized(self) -> list[dict[str, Any]]:
+ items = await self._repo.list()
+ return [DLField.model_validate(item).model_dump() for item in items]
+
+ async def save(self, item: DLField | dict) -> DLFieldModel:
+ try:
+ if not isinstance(item, DLField):
+ item = DLField.model_validate(item)
+ except Exception as exc:
+ msg: str = f"Failed to parse item. '{exc!s}'"
+ raise ValueError(msg) from exc
+
+ try:
+ repo = self._repo
+ if item.id is None or 0 == item.id:
+ model = await repo.create(item.model_dump(exclude_unset=True))
+ else:
+ model = await repo.update(item.id, item.model_dump(exclude_unset=True))
+ except KeyError as exc:
+ raise ValueError(str(exc)) from exc
+
+ return model
+
+ async def get(self, identifier: int | str) -> DLFieldModel | None:
+ return await self._repo.get(identifier)
diff --git a/app/features/dl_fields/tests/test_dl_fields.py b/app/features/dl_fields/tests/test_dl_fields.py
new file mode 100644
index 00000000..9f7c408e
--- /dev/null
+++ b/app/features/dl_fields/tests/test_dl_fields.py
@@ -0,0 +1,76 @@
+"""Tests for dl_fields feature service."""
+
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+
+from app.features.dl_fields.repository import DLFieldsRepository
+from app.features.dl_fields.service import DLFields
+from app.library.sqlite_store import SqliteStore
+
+
+@pytest_asyncio.fixture
+async def repo(tmp_path):
+ """Provide a fresh repository instance with initialized database for each test."""
+ DLFieldsRepository._reset_singleton()
+ DLFields._reset_singleton()
+ SqliteStore._reset_singleton()
+
+ store = SqliteStore(db_path=":memory:")
+ await store.get_connection()
+
+ repository = DLFieldsRepository.get_instance()
+
+ yield repository
+
+ if store._conn:
+ await store._conn.close()
+ if store._engine:
+ await store._engine.dispose()
+
+ DLFieldsRepository._reset_singleton()
+ DLFields._reset_singleton()
+ SqliteStore._reset_singleton()
+
+
+class TestDLFieldsService:
+ """Test suite for DLFields service methods."""
+
+ @pytest.mark.asyncio
+ async def test_save_creates_field(self, repo):
+ """Save should create a new dl field when ID is missing."""
+ service = DLFields.get_instance()
+ payload = {
+ "name": "quality",
+ "description": "Video quality",
+ "field": "--format",
+ "kind": "string",
+ "order": 1,
+ "extras": {"options": ["best", "worst"]},
+ }
+
+ model = await service.save(payload)
+
+ assert model.id is not None, "Should create new dl field"
+ assert model.name == "quality", "Should store name correctly"
+
+ @pytest.mark.asyncio
+ async def test_get_all_serialized(self, repo):
+ """Get all serialized returns list of dictionaries."""
+ service = DLFields.get_instance()
+ await service.save(
+ {
+ "name": "audio_only",
+ "description": "Audio",
+ "field": "--extract-audio",
+ "kind": "bool",
+ "order": 2,
+ }
+ )
+
+ items = await service.get_all_serialized()
+
+ assert len(items) == 1, "Should return one dl field"
+ assert items[0]["name"] == "audio_only", "Should serialize name"
+ assert isinstance(items[0]["id"], int), "Should serialize integer ID"
diff --git a/app/features/dl_fields/tests/test_dl_fields_repository.py b/app/features/dl_fields/tests/test_dl_fields_repository.py
new file mode 100644
index 00000000..031b1d83
--- /dev/null
+++ b/app/features/dl_fields/tests/test_dl_fields_repository.py
@@ -0,0 +1,270 @@
+"""Tests for DLFieldsRepository."""
+
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+
+from app.features.dl_fields.models import DLFieldModel
+from app.features.dl_fields.repository import DLFieldsRepository
+from app.library.sqlite_store import SqliteStore
+
+
+@pytest_asyncio.fixture
+async def repo(tmp_path):
+ """Provide a fresh repository instance with initialized database for each test."""
+ DLFieldsRepository._reset_singleton()
+ SqliteStore._reset_singleton()
+
+ store = SqliteStore(db_path=str(":memory:"))
+ await store.get_connection()
+
+ repository = DLFieldsRepository.get_instance()
+
+ yield repository
+
+ if store._conn:
+ await store._conn.close()
+ if store._engine:
+ await store._engine.dispose()
+
+ DLFieldsRepository._reset_singleton()
+ SqliteStore._reset_singleton()
+
+
+class TestDLFieldsRepository:
+ """Test suite for DLFieldsRepository database operations."""
+
+ @pytest.mark.asyncio
+ async def test_repository_singleton(self, repo):
+ """Verify repository follows singleton pattern."""
+ instance1 = DLFieldsRepository.get_instance()
+ instance2 = DLFieldsRepository.get_instance()
+ assert instance1 is instance2, "Should return same singleton instance"
+
+ @pytest.mark.asyncio
+ async def test_list_empty(self, repo):
+ """List returns empty when no fields exist."""
+ fields = await repo.list()
+ assert fields == [], "Should return empty list when no fields"
+
+ @pytest.mark.asyncio
+ async def test_count_empty(self, repo):
+ """Count returns 0 when no fields exist."""
+ count = await repo.count()
+ assert count == 0, "Should return 0 when no fields exist"
+
+ @pytest.mark.asyncio
+ async def test_create_field(self, repo):
+ """Create field with valid data."""
+ data = {
+ "name": "quality",
+ "description": "Video quality setting",
+ "field": "--format",
+ "kind": "string",
+ "icon": "fa-video",
+ "order": 1,
+ "value": "best",
+ "extras": {"options": ["best", "worst"]},
+ }
+
+ model = await repo.create(data)
+
+ assert model.id is not None, "Should generate ID for new field"
+ assert model.name == "quality", "Should store name correctly"
+ assert model.description == "Video quality setting", "Should store description correctly"
+ assert model.field == "--format", "Should store field correctly"
+ assert model.kind == "string", "Should store kind correctly"
+ assert model.icon == "fa-video", "Should store icon correctly"
+ assert model.order == 1, "Should store order correctly"
+ assert model.value == "best", "Should store value correctly"
+ assert model.extras == {"options": ["best", "worst"]}, "Should store extras as dict"
+
+ @pytest.mark.asyncio
+ async def test_create_with_defaults(self, repo):
+ """Create field with minimal data uses defaults."""
+ data = {
+ "name": "minimal",
+ "description": "Minimal",
+ "field": "--minimal",
+ "kind": "text",
+ }
+
+ model = await repo.create(data)
+
+ assert model.icon == "", "Should default icon to empty string"
+ assert model.order == 0, "Should default order to 0"
+ assert model.value == "", "Should default value to empty string"
+ assert model.extras == {}, "Should default extras to empty dict"
+
+ @pytest.mark.asyncio
+ async def test_get_by_id(self, repo):
+ """Get field by integer ID."""
+ created = await repo.create(
+ {
+ "name": "get_test",
+ "description": "Get test",
+ "field": "--get-test",
+ "kind": "text",
+ }
+ )
+
+ retrieved = await repo.get(created.id)
+
+ assert retrieved is not None, "Should retrieve created field"
+ assert retrieved.id == created.id, "Should retrieve correct field by ID"
+ assert retrieved.name == "get_test", "Should match created field name"
+
+ @pytest.mark.asyncio
+ async def test_get_by_name(self, repo):
+ """Get field by string name."""
+ await repo.create(
+ {
+ "name": "named_test",
+ "description": "Named test",
+ "field": "--named-test",
+ "kind": "text",
+ }
+ )
+
+ retrieved = await repo.get("named_test")
+
+ assert retrieved is not None, "Should retrieve by name"
+ assert retrieved.name == "named_test", "Should match field name"
+
+ @pytest.mark.asyncio
+ async def test_get_nonexistent(self, repo):
+ """Get nonexistent field returns None."""
+ result = await repo.get(99999)
+ assert result is None, "Should return None for nonexistent ID"
+
+ result = await repo.get("nonexistent")
+ assert result is None, "Should return None for nonexistent name"
+
+ @pytest.mark.asyncio
+ async def test_update_field(self, repo):
+ """Update existing field."""
+ created = await repo.create(
+ {
+ "name": "update_test",
+ "description": "Update test",
+ "field": "--update-test",
+ "kind": "text",
+ }
+ )
+
+ updated = await repo.update(
+ created.id,
+ {
+ "name": "updated_name",
+ "order": 5,
+ "extras": {"updated": True},
+ },
+ )
+
+ assert updated.name == "updated_name", "Should update name"
+ assert updated.order == 5, "Should update order"
+ assert updated.extras == {"updated": True}, "Should update extras"
+ assert updated.field == "--update-test", "Should preserve unchanged field"
+
+ @pytest.mark.asyncio
+ async def test_update_nonexistent_raises(self, repo):
+ """Update nonexistent field raises KeyError."""
+ with pytest.raises(KeyError, match="not found"):
+ await repo.update(99999, {"name": "should_fail"})
+
+ @pytest.mark.asyncio
+ async def test_delete_field(self, repo):
+ """Delete existing field."""
+ created = await repo.create(
+ {
+ "name": "delete_test",
+ "description": "Delete test",
+ "field": "--delete-test",
+ "kind": "text",
+ }
+ )
+
+ deleted = await repo.delete(created.id)
+
+ assert deleted.id == created.id, "Should return deleted field"
+
+ result = await repo.get(created.id)
+ assert result is None, "Deleted field should not be retrievable"
+
+ @pytest.mark.asyncio
+ async def test_delete_nonexistent_raises(self, repo):
+ """Delete nonexistent field raises KeyError."""
+ with pytest.raises(KeyError, match="not found"):
+ await repo.delete(99999)
+
+ @pytest.mark.asyncio
+ async def test_list_paginated(self, repo):
+ """List paginated returns correct subset."""
+ for i in range(5):
+ await repo.create(
+ {
+ "name": f"item_{i}",
+ "description": "desc",
+ "field": f"--item-{i}",
+ "kind": "text",
+ "order": i,
+ }
+ )
+
+ items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
+
+ assert len(items) == 2, "Should return 2 items per page"
+ assert total == 5, "Should report total count of 5"
+ assert page == 1, "Should be on page 1"
+ assert total_pages == 3, "Should have 3 pages total"
+
+ @pytest.mark.asyncio
+ async def test_list_ordering(self, repo):
+ """List orders by order asc then name asc."""
+ await repo.create({"name": "b", "description": "b", "field": "--b", "kind": "text", "order": 1})
+ await repo.create({"name": "a", "description": "a", "field": "--a", "kind": "text", "order": 1})
+ await repo.create({"name": "c", "description": "c", "field": "--c", "kind": "text", "order": 0})
+
+ items = await repo.list()
+
+ assert items[0].name == "c", "Lowest order should be first"
+ assert items[1].name == "a", "Same order sorted alphabetically"
+ assert items[2].name == "b", "Same order sorted alphabetically"
+
+ @pytest.mark.asyncio
+ async def test_get_by_name_excludes_id(self, repo):
+ """Get by name can exclude specific ID."""
+ first = await repo.create(
+ {
+ "name": "duplicate",
+ "description": "duplicate",
+ "field": "--duplicate",
+ "kind": "text",
+ }
+ )
+
+ result = await repo.get_by_name("duplicate", exclude_id=first.id)
+ assert result is None, "Should not find when excluding only match"
+
+ result = await repo.get_by_name("duplicate", exclude_id=None)
+ assert result is not None, "Should find without exclusion"
+
+ @pytest.mark.asyncio
+ async def test_replace_all(self, repo):
+ """Replace all fields atomically."""
+ await repo.create({"name": "old_1", "description": "old", "field": "--old-1", "kind": "text"})
+ await repo.create({"name": "old_2", "description": "old", "field": "--old-2", "kind": "text"})
+
+ new_items = [
+ {"name": "new_1", "description": "new", "field": "--new-1", "kind": "text"},
+ {"name": "new_2", "description": "new", "field": "--new-2", "kind": "text"},
+ ]
+
+ result = await repo.replace_all(new_items)
+
+ assert len(result) == 2, "Should create 2 new fields"
+
+ all_items = await repo.list()
+ assert len(all_items) == 2, "Should only have new fields"
+ assert {item.name for item in all_items} == {"new_1", "new_2"}, "Should only have new items"
diff --git a/app/library/dl_fields.py b/app/library/dl_fields.py
deleted file mode 100644
index f901c4d3..00000000
--- a/app/library/dl_fields.py
+++ /dev/null
@@ -1,333 +0,0 @@
-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.
- """
-
- def __init__(self, file: str | Path | None = None, config: Config | None = None):
- self._items: list[DLField] = []
- "The list of items."
- config: Config = config or Config.get_instance()
- "The configuration instance."
- self._file: Path = Path(file) if file else Path(config.config_path).joinpath("dl_fields.json")
- "The path to the file where the items are stored."
-
- if self._file.exists() and "600" != self._file.stat().st_mode:
- try:
- self._file.chmod(0o600)
- except Exception:
- pass
-
- @staticmethod
- def get_instance(file: str | Path | None = None, config: Config | None = None) -> "DLFields":
- """
- Get the instance of the class.
-
- Returns:
- DLFields: The instance of the class
-
- """
- return DLFields(file=file, config=config)
-
- 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()
-
- async def event_handler(_, __):
- msg = "Not implemented"
- raise Exception(msg)
-
- EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
-
- 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)
-
- 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)
-
- 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
diff --git a/app/main.py b/app/main.py
index cb53ca5b..0f5e5b06 100644
--- a/app/main.py
+++ b/app/main.py
@@ -15,10 +15,10 @@ import magic
from aiohttp import web
from app.features.conditions.service import Conditions
+from app.features.dl_fields.service import DLFields
from app.library.BackgroundWorker import BackgroundWorker
from app.library.cache import Cache
from app.library.config import Config
-from app.library.dl_fields import DLFields
from app.library.downloads import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.HttpAPI import HttpAPI
diff --git a/app/migrations/20260119162444_add_dl_fields_table.py b/app/migrations/20260119162444_add_dl_fields_table.py
new file mode 100644
index 00000000..d318976b
--- /dev/null
+++ b/app/migrations/20260119162444_add_dl_fields_table.py
@@ -0,0 +1,40 @@
+"""
+This module contains a db migration.
+
+Migration Name: add_dl_fields_table
+Migration Version: 20260119162444
+"""
+
+from sqlalchemy import text
+
+
+async def upgrade(c):
+ sql: list[str] = [
+ """
+ CREATE TABLE IF NOT EXISTS "dl_fields" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "name" TEXT NOT NULL UNIQUE,
+ "description" TEXT NOT NULL DEFAULT '',
+ "field" TEXT NOT NULL,
+ "kind" TEXT NOT NULL DEFAULT 'text',
+ "icon" TEXT NOT NULL DEFAULT '',
+ "order" INTEGER NOT NULL DEFAULT 0,
+ "value" TEXT NOT NULL DEFAULT '',
+ "extras" JSON NOT NULL DEFAULT '{}',
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ """,
+ 'CREATE INDEX IF NOT EXISTS "ix_dl_fields_name" ON "dl_fields" ("name");',
+ 'CREATE INDEX IF NOT EXISTS "ix_dl_fields_order" ON "dl_fields" ("order");',
+ 'CREATE INDEX IF NOT EXISTS "ix_dl_fields_kind" ON "dl_fields" ("kind");',
+ ]
+ for sql_stmt in sql:
+ await c.execute(text(sql_stmt))
+
+
+async def downgrade(c):
+ sql = """
+ DROP TABLE IF EXISTS "dl_fields";
+ """
+ await c.execute(text(sql))
diff --git a/app/routes/api/dl_fields.py b/app/routes/api/dl_fields.py
index c8b0fac2..5bd99bb4 100644
--- a/app/routes/api/dl_fields.py
+++ b/app/routes/api/dl_fields.py
@@ -1,100 +1,3 @@
-import logging
-import uuid
+"""Migrated API routes for dl_fields feature."""
-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,
- )
-
- notify.emit(Events.DLFIELDS_UPDATE, data=items)
- return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)
+import app.features.dl_fields.router # noqa: F401
diff --git a/app/routes/socket/connection.py b/app/routes/socket/connection.py
index dd761fb9..b5f80a09 100644
--- a/app/routes/socket/connection.py
+++ b/app/routes/socket/connection.py
@@ -5,8 +5,8 @@ from typing import Any
import socketio
+from app.features.dl_fields.service import DLFields
from app.library.config import Config
-from app.library.dl_fields import DLFields
from app.library.downloads import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.Presets import Presets
@@ -28,7 +28,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
data={
"config": config.frontend(),
"presets": Presets.get_instance().get_all(),
- "dl_fields": DLFields.get_instance().get_all(),
+ "dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(),
},
title="Client connected",
diff --git a/app/tests/test_dl_fields.py b/app/tests/test_dl_fields.py
deleted file mode 100644
index ba7a9e6c..00000000
--- a/app/tests/test_dl_fields.py
+++ /dev/null
@@ -1,621 +0,0 @@
-"""
-Tests for dl_fields.py - Download fields management.
-
-This test suite provides comprehensive coverage for the dl_fields module:
-- Tests FieldType enum functionality
-- Tests DLField dataclass functionality
-- Tests DLFields singleton class behavior
-- Tests field loading, saving, and validation
-- Tests field CRUD operations
-- Tests error handling and edge cases
-
-Total test functions: 25+
-All dl_fields management functionality and edge cases are covered.
-"""
-
-import json
-import tempfile
-import uuid
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from app.library.dl_fields import DLField, DLFields, FieldType
-
-
-class TestFieldType:
- """Test the FieldType enum."""
-
- def test_field_type_values(self):
- """Test FieldType enum values."""
- assert FieldType.STRING == "string"
- assert FieldType.TEXT == "text"
- assert FieldType.BOOL == "bool"
-
- def test_field_type_all(self):
- """Test FieldType.all() method."""
- all_types = FieldType.all()
- expected = ["string", "text", "bool"]
- assert all_types == expected
- assert len(all_types) == 3
-
- def test_field_type_from_value_valid(self):
- """Test FieldType.from_value() with valid values."""
- assert FieldType.from_value("string") == FieldType.STRING
- assert FieldType.from_value("text") == FieldType.TEXT
- assert FieldType.from_value("bool") == FieldType.BOOL
-
- def test_field_type_from_value_invalid(self):
- """Test FieldType.from_value() with invalid values."""
- with pytest.raises(ValueError, match="Invalid StoreType value"):
- FieldType.from_value("invalid")
-
- with pytest.raises(ValueError, match="Invalid StoreType value"):
- FieldType.from_value("number")
-
- with pytest.raises(ValueError, match="Invalid StoreType value"):
- FieldType.from_value("")
-
- def test_field_type_str(self):
- """Test FieldType string conversion."""
- assert str(FieldType.STRING) == "string"
- assert str(FieldType.TEXT) == "text"
- assert str(FieldType.BOOL) == "bool"
-
-
-class TestDLField:
- """Test the DLField dataclass."""
-
- def test_dl_field_creation_with_defaults(self):
- """Test creating a DLField with default values."""
- field = DLField(name="test_field", description="Test description", field="--test-option")
-
- assert field.id, "Check that ID is generated"
- assert isinstance(field.id, str)
-
- # Check required fields
- assert field.name == "test_field"
- assert field.description == "Test description"
- assert field.field == "--test-option"
-
- assert field.kind == FieldType.TEXT, "Check defaults"
- assert field.icon == ""
- assert field.order == 0
- assert field.value == ""
- assert field.extras == {}
-
- def test_dl_field_creation_with_all_fields(self):
- """Test creating a DLField with all fields specified."""
- test_id = str(uuid.uuid4())
- extras = {"key": "value", "number": 42}
-
- field = DLField(
- id=test_id,
- name="custom_field",
- description="Custom description",
- field="--custom-option",
- kind=FieldType.BOOL,
- icon="fa-check",
- order=5,
- value="default_value",
- extras=extras,
- )
-
- assert field.id == test_id
- assert field.name == "custom_field"
- assert field.description == "Custom description"
- assert field.field == "--custom-option"
- assert field.kind == FieldType.BOOL
- assert field.icon == "fa-check"
- assert field.order == 5
- assert field.value == "default_value"
- assert field.extras == extras
-
- def test_dl_field_serialize(self):
- """Test DLField serialization."""
- extras = {"key": "value", "none_value": None}
- field = DLField(name="test", description="Test field", field="--test", kind=FieldType.STRING, extras=extras)
-
- serialized = field.serialize()
-
- assert serialized["name"] == "test"
- assert serialized["description"] == "Test field"
- assert serialized["field"] == "--test"
- assert serialized["kind"] == "string"
- assert serialized["extras"] == {"key": "value"} # None values filtered out
-
- def test_dl_field_json(self):
- """Test DLField JSON encoding."""
- field = DLField(name="json_test", description="JSON test field", field="--json-test")
-
- json_str = field.json()
- assert isinstance(json_str, str)
-
- # Should be valid JSON
- parsed = json.loads(json_str)
- assert parsed["name"] == "json_test"
- assert parsed["description"] == "JSON test field"
- assert parsed["field"] == "--json-test"
-
- def test_dl_field_get_method(self):
- """Test DLField get method."""
- field = DLField(name="get_test", description="Get test field", field="--get-test", order=3)
-
- assert field.get("name") == "get_test"
- assert field.get("order") == 3
- assert field.get("nonexistent") is None
- assert field.get("nonexistent", "default") == "default"
-
-
-class TestDLFields:
- """Test the DLFields class."""
-
- def setup_method(self):
- """Set up test fixtures."""
- DLFields._reset_singleton()
-
- @pytest.fixture
- def temp_file(self):
- """Create a temporary file for testing."""
- with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
- temp_path = Path(f.name)
- yield temp_path
- if temp_path.exists():
- temp_path.unlink()
-
- @pytest.fixture
- def sample_fields_data(self):
- """Sample field data for testing."""
- return [
- {
- "id": str(uuid.uuid4()),
- "name": "quality",
- "description": "Video quality setting",
- "field": "--format",
- "kind": "string",
- "icon": "fa-video",
- "order": 1,
- "value": "best",
- "extras": {"options": ["best", "worst", "720p"]},
- },
- {
- "id": str(uuid.uuid4()),
- "name": "audio_only",
- "description": "Extract audio only",
- "field": "--extract-audio",
- "kind": "bool",
- "icon": "fa-music",
- "order": 2,
- "value": "",
- "extras": {},
- },
- ]
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_singleton_behavior(self, mock_config):
- """Test that DLFields follows singleton pattern."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields1 = DLFields()
- fields2 = DLFields()
- assert fields1 is fields2
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_get_instance(self, mock_config):
- """Test DLFields.get_instance() method."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields1 = DLFields.get_instance()
- fields2 = DLFields.get_instance()
- assert fields1 is fields2
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_initialization(self, mock_config):
- """Test DLFields initialization."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- with tempfile.TemporaryDirectory() as temp_dir:
- temp_file = Path(temp_dir) / "test_fields.json"
-
- fields = DLFields(file=str(temp_file))
-
- assert fields._file == temp_file
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_load_empty_file(self, mock_config, temp_file):
- """Test loading from empty/non-existent file."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields(file=str(temp_file))
- result = fields.load()
-
- assert result is fields
- assert fields.get_all() == []
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_load_valid_file(self, mock_config, temp_file, sample_fields_data):
- """Test loading from valid JSON file."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- # Write sample data to temp file
- temp_file.write_text(json.dumps(sample_fields_data, indent=2))
-
- fields = DLFields(file=str(temp_file))
- fields.load()
-
- loaded_fields = fields.get_all()
- assert len(loaded_fields) == 2
-
- # Check first field
- field1 = loaded_fields[0]
- assert field1.name == "quality"
- assert field1.description == "Video quality setting"
- assert field1.field == "--format"
- assert field1.kind == FieldType.STRING
-
- # Check second field
- field2 = loaded_fields[1]
- assert field2.name == "audio_only"
- assert field2.kind == FieldType.BOOL
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_load_invalid_json(self, mock_config, temp_file):
- """Test loading from invalid JSON file."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- # Write invalid JSON
- temp_file.write_text("invalid json content")
-
- fields = DLFields(file=str(temp_file))
- fields.load()
-
- assert fields.get_all() == [], "Should handle error gracefully and return empty list"
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_load_missing_id_auto_generation(self, mock_config, temp_file):
- """Test that missing IDs are auto-generated during load."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- # Sample data without IDs
- data_without_ids = [
- {"name": "test_field", "description": "Test description", "field": "--test", "kind": "string"}
- ]
-
- temp_file.write_text(json.dumps(data_without_ids))
-
- with patch.object(DLFields, "save") as mock_save:
- fields = DLFields(file=str(temp_file))
- fields.load()
-
- # Should auto-generate ID and trigger save
- loaded_fields = fields.get_all()
- assert len(loaded_fields) == 1
- assert loaded_fields[0].id is not None
- mock_save.assert_called_once()
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_clear(self, mock_config):
- """Test clearing all fields."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- # Add some fields manually
- fields._items = [
- DLField(name="test1", description="Test 1", field="--test1"),
- DLField(name="test2", description="Test 2", field="--test2"),
- ]
-
- assert len(fields.get_all()) == 2
-
- result = fields.clear()
- assert result is fields
- assert len(fields.get_all()) == 0
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_clear_empty(self, mock_config):
- """Test clearing when already empty."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
- assert len(fields.get_all()) == 0
-
- result = fields.clear()
- assert result is fields
- assert len(fields.get_all()) == 0
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_valid_field(self, mock_config):
- """Test validation with valid DLField."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- valid_field = DLField(
- name="valid_field", description="Valid description", field="--valid-option", kind=FieldType.STRING
- )
-
- assert fields.validate(valid_field) is True
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_valid_dict(self, mock_config):
- """Test validation with valid dictionary."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- valid_dict = {
- "id": str(uuid.uuid4()),
- "name": "test_field",
- "description": "Test description",
- "field": "--test-field",
- "kind": "text",
- }
-
- assert fields.validate(valid_dict) is True
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_missing_required_fields(self, mock_config):
- """Test validation with missing required fields."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- # Missing name
- invalid_dict = {
- "id": str(uuid.uuid4()),
- "description": "Test description",
- "field": "--test-field",
- "kind": "text",
- }
-
- with pytest.raises(ValueError, match="Missing required key 'name'"):
- fields.validate(invalid_dict)
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_invalid_field_type(self, mock_config):
- """Test validation with invalid field type."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- invalid_dict = {
- "id": str(uuid.uuid4()),
- "name": "test_field",
- "description": "Test description",
- "field": "--test-field",
- "kind": "invalid_type",
- }
-
- with pytest.raises(ValueError, match="Invalid field type"):
- fields.validate(invalid_dict)
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_invalid_yt_dlp_field(self, mock_config):
- """Test validation with invalid yt-dlp field format."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- invalid_dict = {
- "id": str(uuid.uuid4()),
- "name": "test_field",
- "description": "Test description",
- "field": "invalid-field", # Missing --
- "kind": "text",
- }
-
- with pytest.raises(ValueError, match="Invalid yt-dlp option field"):
- fields.validate(invalid_dict)
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_invalid_extras_type(self, mock_config):
- """Test validation with invalid extras type."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- invalid_dict = {
- "id": str(uuid.uuid4()),
- "name": "test_field",
- "description": "Test description",
- "field": "--test-field",
- "kind": "text",
- "extras": "not_a_dict",
- }
-
- with pytest.raises(ValueError, match="Extras must be a dictionary"):
- fields.validate(invalid_dict)
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_invalid_value_type(self, mock_config):
- """Test validation with invalid value type."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- invalid_dict = {
- "id": str(uuid.uuid4()),
- "name": "test_field",
- "description": "Test description",
- "field": "--test-field",
- "kind": "text",
- "value": 123, # Should be string
- }
-
- with pytest.raises(ValueError, match="Value must be a string"):
- fields.validate(invalid_dict)
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_invalid_order_type(self, mock_config):
- """Test validation with invalid order type."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- invalid_dict = {
- "id": str(uuid.uuid4()),
- "name": "test_field",
- "description": "Test description",
- "field": "--test-field",
- "kind": "text",
- "order": "not_an_int",
- }
-
- with pytest.raises(ValueError, match="Order must be an integer"):
- fields.validate(invalid_dict)
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_validate_unexpected_type(self, mock_config):
- """Test validation with unexpected item type."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- with pytest.raises(ValueError, match="Unexpected 'str' type was given"):
- fields.validate("not_a_field_or_dict")
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_save_valid_fields(self, mock_config, temp_file):
- """Test saving valid fields."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields(file=str(temp_file))
-
- test_fields = [DLField(name="test_field", description="Test description", field="--test-option")]
-
- fields.save(test_fields)
-
- assert temp_file.exists(), "Verify file was written"
-
- # Verify content
- saved_data = json.loads(temp_file.read_text())
- assert len(saved_data) == 1
- assert saved_data[0]["name"] == "test_field"
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_save_mixed_types(self, mock_config, temp_file):
- """Test saving mix of DLField objects and dicts."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields(file=str(temp_file))
-
- test_items = [
- DLField(name="field1", description="Field 1", field="--field1"),
- {"id": str(uuid.uuid4()), "name": "field2", "description": "Field 2", "field": "--field2", "kind": "text"},
- ]
-
- fields.save(test_items)
-
- # Verify both items were saved
- saved_data = json.loads(temp_file.read_text())
- assert len(saved_data) == 2
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_get_by_id(self, mock_config):
- """Test getting field by ID."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- test_id = str(uuid.uuid4())
- test_field = DLField(id=test_id, name="test_field", description="Test description", field="--test-option")
-
- fields._items = [test_field]
-
- result = fields.get(test_id)
- assert result is test_field
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_get_by_name(self, mock_config):
- """Test getting field by name."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- test_field = DLField(name="test_field", description="Test description", field="--test-option")
-
- fields._items = [test_field]
-
- result = fields.get("test_field")
- assert result is test_field
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_get_not_found(self, mock_config):
- """Test getting non-existent field."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- result = fields.get("nonexistent")
- assert result is None
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_get_empty_string(self, mock_config):
- """Test getting with empty string."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- result = fields.get("")
- assert result is None
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_has_by_id(self, mock_config):
- """Test checking field existence by ID."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- test_id = str(uuid.uuid4())
- test_field = DLField(id=test_id, name="test_field", description="Test description", field="--test-option")
-
- fields._items = [test_field]
-
- assert fields.has(test_id) is True
- assert fields.has("nonexistent") is False
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_has_by_name(self, mock_config):
- """Test checking field existence by name."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- test_field = DLField(name="test_field", description="Test description", field="--test-option")
-
- fields._items = [test_field]
-
- assert fields.has("test_field") is True
- assert fields.has("nonexistent") is False
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_attach_method(self, mock_config):
- """Test attach method calls load."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
-
- with patch.object(fields, "load") as mock_load:
- mock_app = MagicMock()
- fields.attach(mock_app)
- mock_load.assert_called_once()
-
- @patch("app.library.dl_fields.Config")
- def test_dl_fields_on_shutdown(self, mock_config):
- """Test on_shutdown method."""
- mock_config.get_instance.return_value.config_path = "/tmp"
-
- fields = DLFields()
- mock_app = MagicMock()
-
- # Should not raise any exceptions
- import asyncio
-
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- try:
- loop.run_until_complete(fields.on_shutdown(mock_app))
- finally:
- loop.close()
diff --git a/ui/app/components/DLFieldForm.vue b/ui/app/components/DLFieldForm.vue
new file mode 100644
index 00000000..7e652a1f
--- /dev/null
+++ b/ui/app/components/DLFieldForm.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+
| + + Field + | ++ + Actions + | +
|---|---|
|
+
+ {{ field.name }}
+
+
+
+
+ {{ field.field }}
+
+
+
+
+ Order: {{ field.order }}
+
+
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+ + +
++ + {{ field.description }} +
+No results found for the query: {{ query }}.
Please try a different search term.
+