refactor: migrated dl_fields to db model.

This commit is contained in:
arabcoders 2026-01-19 18:31:16 +03:00
parent c33827c6f7
commit 8a10627d6a
27 changed files with 2055 additions and 1123 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
"""DL Fields Feature"""

View file

@ -0,0 +1,5 @@
from app.features.dl_fields.repository import DLFieldsRepository
def get_dl_fields_repo() -> DLFieldsRepository:
return DLFieldsRepository.get_instance()

View file

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

View file

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

View file

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

View file

@ -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,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

@ -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()

View file

@ -0,0 +1,315 @@
<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<form autocomplete="off" id="dlFieldForm" @submit.prevent="checkInfo">
<div class="card is-flex is-full-height is-flex-direction-column">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div>
</div>
<div class="card-content is-flex-grow-1">
<div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
<span class="icon"><i class="fa-solid fa-file-import" /></span>
Import string
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
</div>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-cog" /></span>
<span>Field Type</span>
</label>
<div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }">
<select v-model="form.kind" :disabled="addInProgress" class="is-capitalized">
<option v-for="kind in fieldTypes" :key="`kind-${kind}`" :value="kind" v-text="kind" />
</select>
</div>
<span class="help is-bold">
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-font" /></span>
<span>Field Name</span>
</label>
<input type="text" v-model="form.name" class="input" :disabled="addInProgress" />
<span class="help is-bold">
The name of the field, it will be shown in the UI.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-terminal" /></span>
<span>Associated yt-dlp option</span>
</label>
<InputAutocomplete v-model="form.field" :options="ytDlpOptions" :disabled="addInProgress"
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
<span class="help is-bold">
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-info-circle" /></span>
<span>Field Description</span>
</label>
<input type="text" v-model="form.description" class="input" :disabled="addInProgress" />
<span class="help is-bold">
A short description of the field, it will be shown in the UI.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
<span>Field Order</span>
</label>
<input type="number" v-model.number="form.order" class="input" :disabled="addInProgress" />
<span class="help is-bold">
The order of the field, used to sort the fields in the UI. Lower numbers will appear first.
</span>
</div>
</div>
<div class="column is-6 is-12-mobile">
<div class="field">
<label class="label">
<span class="icon"><i class="fas fa-image" /></span>
<span>Field Icon</span>
</label>
<input type="text" v-model="form.icon" class="input" :disabled="addInProgress" />
<span class="help is-bold">
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r"
target="_blank">
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
</span>
</div>
</div>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="dlFieldForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</main>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import InputAutocomplete from '~/components/InputAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete'
import type { DLField } from '~/types/dl_fields'
import type { ImportedItem } from '~/types'
import { decode } from '~/utils'
import { useConfirm } from '~/composables/useConfirm'
const emitter = defineEmits<{
(e: 'cancel'): void
(e: 'submit', payload: { reference: number | null | undefined, item: DLField }): void
}>()
const props = defineProps<{
reference?: number | null
item: DLField
addInProgress?: boolean
}>()
const toast = useNotification()
const box = useConfirm()
const config = useConfigStore()
const fieldTypes = ['string', 'text', 'bool'] as const
const form = reactive<DLField>(JSON.parse(JSON.stringify(props.item)))
const ytDlpOptions = ref<AutoCompleteOptions>([])
const showImport = useStorage('showDlFieldsImport', false)
const import_string = ref('')
if (!form.extras) {
form.extras = {}
}
if (!form.kind) {
form.kind = 'string'
}
if (!form.description) {
form.description = ''
}
if (!form.value) {
form.value = ''
}
if (!form.icon) {
form.icon = ''
}
if (!form.order) {
form.order = 1
}
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
.filter(opt => !opt.ignored)
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--'))
.map(flag => ({ value: flag, description: opt.description || '' }))),
{ immediate: true }
)
const importItem = async (): Promise<void> => {
const val = import_string.value.trim()
if (!val) {
toast.error('The import string is required.')
return
}
try {
const item = decode(val) as DLField & ImportedItem
if (!item._type || item._type !== 'dl_field') {
toast.error(`Invalid import string. Expected type 'dl_field', got '${item._type ?? 'unknown'}'.`)
return
}
if ((form.name || form.field || form.description) && !(await box.confirm('Overwrite the current form fields?'))) {
return
}
if (item.name) {
form.name = item.name
}
if (item.field) {
form.field = item.field
}
if (item.description !== undefined) {
form.description = item.description
}
if (item.kind) {
form.kind = item.kind
}
if (item.icon !== undefined) {
form.icon = item.icon
}
if (item.order !== undefined) {
form.order = item.order
}
if (item.value !== undefined) {
form.value = item.value
}
if (item.extras) {
form.extras = { ...item.extras }
}
import_string.value = ''
showImport.value = false
} catch (e: any) {
console.error(e)
toast.error(`Failed to parse import string. ${e.message}`)
}
}
const checkInfo = (): void => {
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description']
for (const key of required) {
if (!form[key]) {
toast.error(`The ${key} field is required.`)
return
}
}
if (!form.order || form.order < 1) {
toast.error('Order must be a positive number.')
return
}
if (!fieldTypes.includes(form.kind)) {
toast.error(`Invalid field type: ${form.kind}`)
return
}
if (!/^--[a-zA-Z0-9-]+$/.test(form.field)) {
toast.error('Invalid field format, it must start with "--" and contain no spaces.')
return
}
const copy: DLField = JSON.parse(JSON.stringify(form))
const entries = copy as unknown as Record<string, unknown>
for (const key in entries) {
if ('string' !== typeof entries[key]) {
continue
}
entries[key] = entries[key].trim()
}
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
}
</script>

View file

@ -176,7 +176,7 @@
</template>
<div class="column is-12 is-hidden-tablet">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
<NuxtLink class="dropdown-item" @click="() => showFields = true">
<NuxtLink class="dropdown-item" to="/dl_fields">
<span class="icon has-text-purple"><i class="fa-solid fa-plus" /></span>
<span>Custom Fields</span>
</NuxtLink>
@ -208,7 +208,7 @@
<div class="column is-12">
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
<div class="control">
<button type="button" class="button is-purple" @click="() => showFields = true"
<button type="button" class="button is-purple" @click="() => navigateTo('/dl_fields')"
v-tooltip="'Manage custom fields'">
<span class="icon"><i class="fa-solid fa-plus" /></span>
</button>
@ -253,7 +253,7 @@
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
<DLFields v-if="showFields" @cancel="() => showFields = false" />
<Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'">
<YTDLPOptions />
</Modal>
@ -289,7 +289,6 @@ const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const storedCommand = useStorage<string>('console_command', '')
const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(false)
const showOptions = ref<boolean>(false)
const showTestResults = ref<boolean>(false)
const testResultsData = ref<any>(null)

View file

@ -0,0 +1,399 @@
import { ref, readonly } from 'vue'
import { useNotification } from '~/composables/useNotification'
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
import type { DLField, DLFieldRequest } from '~/types/dl_fields'
import type { APIResponse, Pagination } from '~/types/responses'
/**
* List of all dl fields in memory.
*/
const dlFields = ref<Array<DLField>>([])
/**
* Pagination state for dl fields list.
*/
const pagination = ref<Pagination>({
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
})
/**
* Indicates if a request is in progress.
*/
const isLoading = ref<boolean>(false)
/**
* Indicates if an add/update operation is in progress.
*/
const addInProgress = ref<boolean>(false)
/**
* Stores the last error message, if any.
*/
const lastError = ref<string | null>(null)
/**
* If true, methods will throw errors instead of returning null/false (for testing)
*/
const throwInstead = ref(false)
/**
* Notification composable for showing success/error messages.
*/
const notify = useNotification()
/**
* Sorts dl fields by order (ascending), then name (A-Z).
* @param items Array of DLField
* @returns Sorted array of DLField
*/
const sortDlFields = (items: Array<DLField>): Array<DLField> => {
return [...items].sort((a, b) => {
if (a.order === b.order) {
return a.name.localeCompare(b.name)
}
return a.order - b.order
})
}
/**
* Safely reads JSON from a Response, returns null on error.
* @param response Fetch Response object
* @returns Parsed JSON or null
*/
const readJson = async (response: Response): Promise<unknown> => {
try {
const clone = response.clone()
return await clone.json()
}
catch {
return null
}
}
/**
* Throws an error if the response is not OK, using API error message if available.
* @param response Fetch Response object
* @throws Error with message from API or status code
*/
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return
}
const payload = await readJson(response)
const message = await parse_api_error(payload)
throw new Error(message)
}
/**
* Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown
*/
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
lastError.value = message
notify.error(message)
}
/**
* Updates or adds a dl field in the dlFields list, keeping sort order.
* Also increments pagination.total if it's a new dl field.
* @param field DLField to update/add
*/
const updateDlFields = (field: DLField): void => {
const isNew = !dlFields.value.some(item => item.id === field.id)
dlFields.value = sortDlFields([
...dlFields.value.filter(item => item.id !== field.id),
field,
])
if (isNew) {
pagination.value.total++
}
}
/**
* Removes a dl field from the dlFields list by ID.
* Also decrements pagination.total.
* @param id DLField ID
*/
const removeDlField = (id: number) => {
const initialLength = dlFields.value.length
dlFields.value = dlFields.value.filter(item => item.id !== id)
if (dlFields.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1)
}
}
/**
* Loads all dl fields from the API with pagination support.
* Updates dlFields, pagination, and lastError.
* @param page Page number
* @param perPage Items per page
*/
const loadDlFields = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
isLoading.value = true
try {
let url = `/api/dl_fields/?page=${page}`
if (perPage !== undefined) {
url += `&per_page=${perPage}`
}
const response = await request(url)
await ensureSuccess(response)
const json = await response.json()
const { items, pagination: paginationData } = await parse_list_response<DLField>(json)
dlFields.value = sortDlFields(items)
pagination.value = paginationData
lastError.value = null
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/**
* Fetches a single dl field by ID from the API.
* @param id DLField ID
* @returns DLField or null on error
*/
const getDlField = async (id: number): Promise<DLField | null> => {
try {
const response = await request(`/api/dl_fields/${id}`)
await ensureSuccess(response)
const json = await response.json()
const field = await parse_api_response<DLField>(json)
lastError.value = null
return field
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/**
* Creates a new dl field via API.
* @param field DLFieldRequest to create
* @param callback Optional callback with APIResponse result
* @returns Created DLField or null on error
*/
const createDlField = async (
field: DLFieldRequest,
callback?: (response: APIResponse<DLField>) => void,
): Promise<DLField | null> => {
addInProgress.value = true
try {
const response = await request('/api/dl_fields/', {
method: 'POST',
body: JSON.stringify(field),
})
await ensureSuccess(response)
const json = await response.json()
const created = await parse_api_response<DLField>(json)
updateDlFields(created)
notify.success('DL field created.')
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: created })
}
return created
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined })
}
if (throwInstead.value) throw error
return null
}
finally {
addInProgress.value = false
}
}
/**
* Updates an existing dl field via API (PUT - full update).
* @param id DLField ID
* @param field Updated DLField data
* @param callback Optional callback with APIResponse result
* @returns Updated DLField or null on error
*/
const updateDlField = async (
id: number,
field: DLField,
callback?: (response: APIResponse<DLField>) => void,
): Promise<DLField | null> => {
addInProgress.value = true
try {
if (field.id) {
field.id = undefined
}
const response = await request(`/api/dl_fields/${id}`, {
method: 'PUT',
body: JSON.stringify(field),
})
await ensureSuccess(response)
const json = await response.json()
const updated = await parse_api_response<DLField>(json)
updateDlFields(updated)
notify.success(`DL field '${updated.name}' updated.`)
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: updated })
}
return updated
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined })
}
if (throwInstead.value) throw error
return null
}
finally {
addInProgress.value = false
}
}
/**
* Partially updates an existing dl field via API (PATCH).
* @param id DLField ID
* @param patch Partial DLField data to update
* @param callback Optional callback with APIResponse result
* @returns Updated DLField or null on error
*/
const patchDlField = async (
id: number,
patch: Partial<DLField>,
callback?: (response: APIResponse<DLField>) => void,
): Promise<DLField | null> => {
addInProgress.value = true
try {
if (patch.id) {
patch.id = undefined
}
const response = await request(`/api/dl_fields/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
})
await ensureSuccess(response)
const json = await response.json()
const updated = await parse_api_response<DLField>(json)
updateDlFields(updated)
notify.success(`DL field '${updated.name}' updated.`)
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: updated })
}
return updated
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined })
}
if (throwInstead.value) throw error
return null
}
finally {
addInProgress.value = false
}
}
/**
* Deletes a dl field by ID via API.
* @param id DLField ID
* @param callback Optional callback with APIResponse result
* @returns true if deleted, false on error
*/
const deleteDlField = async (
id: number,
callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => {
try {
const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' })
await ensureSuccess(response)
removeDlField(id)
notify.success('DL field deleted.')
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: true })
}
return true
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false })
}
if (throwInstead.value) throw error
return false
}
}
/**
* Clears the last error message.
*/
const clearError = () => lastError.value = null
/**
* useDlFields composable
*
* Returns reactive state and CRUD methods for dl fields.
* @returns Object with state and API methods
*/
export const useDlFields = () => ({
dlFields: readonly(dlFields),
pagination: readonly(pagination),
isLoading: readonly(isLoading),
addInProgress: readonly(addInProgress),
lastError: readonly(lastError),
loadDlFields,
getDlField,
createDlField,
updateDlField,
patchDlField,
deleteDlField,
clearError,
throwInstead,
})

View file

@ -81,7 +81,7 @@
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown" v-if="config.app?.file_logging || config.app?.console_enabled">
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tools" /></span>
<span>Other</span>
@ -99,6 +99,11 @@
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/dl_fields" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Custom fields</span>
</NuxtLink>
</div>
</div>

View file

@ -68,7 +68,7 @@
</div>
<div class="column is-12" v-if="toggleForm">
<ConditionForm :addInProgress="conditions.addInProgress.value" :reference="itemRef" :item="item as Condition"
<ConditionForm :addInProgress="conditions.addInProgress.value" :reference="itemRef" :item="(item as Condition)"
@cancel="resetForm(true)" @submit="updateItem" />
</div>
</div>
@ -385,7 +385,7 @@ const updateItem = async ({ reference, item: updatedItem }: {
}): Promise<void> => {
updatedItem = cleanObject(updatedItem, remove_keys) as Condition
const cb = (resp: APIResponse) => {
if (resp.success){
if (resp.success) {
resetForm(true)
}
}

298
ui/app/pages/dl_fields.vue Normal file
View file

@ -0,0 +1,298 @@
<template>
<div>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }" /></span>
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new field' }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Custom Fields</span>
</template>
</span>
</span>
<div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter"
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter" /></span>
</p>
<p class="control" v-if="items && items.length > 0">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span v-if="!isMobile">Filter</span>
</button>
</p>
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
<span class="icon"><i class="fas fa-add" /></span>
<span v-if="!isMobile">New Field</span>
</button>
</p>
<p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
<span class="icon">
<i class="fa-solid"
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
<span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }}
</span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="async () => await loadContent(page)"
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle">
Custom fields allow you to add new fields to the download form.
</span>
</div>
</div>
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
</div>
<div class="column is-12" v-if="toggleForm">
<DLFieldForm :addInProgress="dlFields.addInProgress.value" :reference="itemRef" :item="(item as DLField)"
@cancel="resetForm(true)" @submit="updateItem" />
</div>
</div>
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && (filteredItems && filteredItems.length > 0)">
<div class="column is-12" v-if="'list' === display_style">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="80%">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Field</span>
</th>
<th width="20%">
<span class="icon"><i class="fa-solid fa-gear" /></span>
<span>Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="field in filteredItems" :key="field.id">
<td class="is-vcentered">
<div class="is-text-overflow is-bold">
{{ field.name }}
</div>
<div class="is-unselectable">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ field.field }}</span>
</span>
&nbsp;
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span>Order: {{ field.order }}</span>
</span>
</div>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(field)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span>
</button>
</div>
<div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(field)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span v-if="!isMobile">Edit</span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(field)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template v-else>
<div class="column is-6" v-for="field in filteredItems" :key="field.id">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="field.name" />
<div class="card-header-icon">
<div class="field is-grouped">
<div class="control">
<span class="tag is-dark">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span v-text="field.order" />
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(field)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
</div>
</div>
</div>
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span v-text="field.field" />
</p>
<p class="is-text-overflow" v-if="field.description">
<span class="icon"><i class="fa-solid fa-comment" /></span>
<span>{{ field.description }}</span>
</p>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(field)">
<span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(field)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)">
<div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait...
</Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
@close="query = ''">
<p>No results found for the query: <code>{{ query }}</code>.</p>
<p>Please try a different search term.</p>
</Message>
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
There are no custom defined fields yet. Click the <span class="icon"><i class="fas fa-add" /></span>
<strong>New Field</strong> button to add your first field.
</Message>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { DLField } from '~/types/dl_fields'
import { useConfirm } from '~/composables/useConfirm'
import { useDlFields } from '~/composables/useDlFields'
import type { APIResponse } from '~/types/responses'
import { copyText, encode } from '~/utils'
const box = useConfirm()
const isMobile = useMediaQuery({ maxWidth: 1024 })
const display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid')
const dlFields = useDlFields()
const route = useRoute()
const items = dlFields.dlFields as Ref<DLField[]>
const paging = dlFields.pagination
const isLoading = dlFields.isLoading
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1)
const item = ref<Partial<DLField>>({})
const itemRef = ref<number | null | undefined>(null)
const toggleForm = ref(false)
const query = ref<string>('')
const toggleFilter = ref(false)
const filteredItems = computed<DLField[]>(() => {
const q = query.value?.toLowerCase()
if (!q) return items.value
return items.value.filter(entry => deepIncludes(entry, q, new WeakSet()))
})
const loadContent = async (pageNumber: number = 1): Promise<void> => {
await dlFields.loadDlFields(pageNumber)
await nextTick()
if (dlFields.pagination.value.total_pages > 1) {
useRouter().replace({ query: { ...route.query, page: pageNumber.toString() } })
}
}
watch(toggleFilter, value => {
if (!value) {
query.value = ''
}
})
const resetForm = (closeForm = false): void => {
item.value = {}
itemRef.value = null
if (closeForm) {
toggleForm.value = false
}
}
const deleteItem = async (field: DLField): Promise<void> => {
if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
return
}
await dlFields.deleteDlField(field.id!)
}
const updateItem = async ({ reference, item: updatedItem }: {
reference: number | null | undefined,
item: DLField
}): Promise<void> => {
const cb = (resp: APIResponse) => {
if (resp.success) {
resetForm(true)
}
}
if (reference) {
await dlFields.patchDlField(reference, updatedItem, cb)
} else {
await dlFields.createDlField(updatedItem, cb)
}
}
const editItem = (field: DLField): void => {
item.value = { ...field }
itemRef.value = field.id
toggleForm.value = true
}
const exportItem = (field: DLField): void => copyText(encode({
...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)),
_type: 'dl_field',
_version: '1.0',
}))
onMounted(async () => await loadContent(page.value))
</script>

View file

@ -2,7 +2,7 @@ type DLFieldType = "string" | "text" | "bool";
type DLField = {
/** The id of the field */
id?: string;
id?: number;
/** The name of the preset */
name: string;