refactor: migrated dl_fields to db model.
This commit is contained in:
parent
c33827c6f7
commit
8a10627d6a
27 changed files with 2055 additions and 1123 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
app/features/dl_fields/__init__.py
Normal file
1
app/features/dl_fields/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""DL Fields Feature"""
|
||||
5
app/features/dl_fields/deps.py
Normal file
5
app/features/dl_fields/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
|
||||
|
||||
def get_dl_fields_repo() -> DLFieldsRepository:
|
||||
return DLFieldsRepository.get_instance()
|
||||
103
app/features/dl_fields/migration.py
Normal file
103
app/features/dl_fields/migration.py
Normal 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
|
||||
29
app/features/dl_fields/models.py
Normal file
29
app/features/dl_fields/models.py
Normal 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)
|
||||
168
app/features/dl_fields/repository.py
Normal file
168
app/features/dl_fields/repository.py
Normal 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
|
||||
169
app/features/dl_fields/router.py
Normal file
169
app/features/dl_fields/router.py
Normal 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,
|
||||
)
|
||||
101
app/features/dl_fields/schemas.py
Normal file
101
app/features/dl_fields/schemas.py
Normal 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
|
||||
62
app/features/dl_fields/service.py
Normal file
62
app/features/dl_fields/service.py
Normal 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)
|
||||
76
app/features/dl_fields/tests/test_dl_fields.py
Normal file
76
app/features/dl_fields/tests/test_dl_fields.py
Normal 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"
|
||||
270
app/features/dl_fields/tests/test_dl_fields_repository.py
Normal file
270
app/features/dl_fields/tests/test_dl_fields_repository.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
app/migrations/20260119162444_add_dl_fields_table.py
Normal file
40
app/migrations/20260119162444_add_dl_fields_table.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
315
ui/app/components/DLFieldForm.vue
Normal file
315
ui/app/components/DLFieldForm.vue
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
399
ui/app/composables/useDlFields.ts
Normal file
399
ui/app/composables/useDlFields.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
298
ui/app/pages/dl_fields.vue
Normal 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>
|
||||
|
||||
<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>
|
||||
2
ui/app/types/dl_fields.d.ts
vendored
2
ui/app/types/dl_fields.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue