refactor: standardize config_update event for UI

This commit is contained in:
arabcoders 2026-01-20 16:24:41 +03:00
parent 8a10627d6a
commit ea1fc59e88
13 changed files with 192 additions and 159 deletions

18
API.md
View file

@ -72,6 +72,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/system/pause](#post-apisystempause)
- [POST /api/system/resume](#post-apisystemresume)
- [POST /api/system/shutdown](#post-apisystemshutdown)
- [POST /api/system/check-updates](#post-apisystemcheck-updates)
- [GET /api/dev/loop](#get-apidevloop)
- [GET /api/dev/pip](#get-apidevpip)
- [GET /api/docs/{file}](#get-apidocsfile)
@ -102,9 +103,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [`cli_post` (Client → Server)](#cli_post-client--server)
- [`cli_output` (Server → Client)](#cli_output-server--client)
- [`cli_close` (Server → Client)](#cli_close-server--client)
- [Configuration Events](#configuration-events)
- [`presets_update` (Server → Client)](#presets_update-server--client)
- [`dlfields_update` (Server → Client)](#dlfields_update-server--client)
- [Error Responses](#error-responses)
---
@ -2087,19 +2086,6 @@ Emitted when CLI command execution completes.
**Data Fields**:
- `exitcode`: Command exit code (0 = success, non-zero = error)
### Configuration Events
#### `presets_update` (Server → Client)
Emitted when download presets are updated or created.
**Data**: Array of preset objects
#### `dlfields_update` (Server → Client)
Emitted when download fields configuration is updated.
**Data**: Array of field objects
```
---
## Error Responses

View file

@ -8,11 +8,12 @@ from pydantic import ValidationError
from app.features.conditions.schemas import Condition, ConditionList, ConditionPatch
from app.features.conditions.service import Conditions
from app.features.core.schemas import Pagination
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
from app.library.cache import Cache
from app.library.config import Config
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__)
@ -136,7 +137,7 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
@route("POST", "api/conditions/", name="condition_add")
async def conditions_add(request: Request, encoder: Encoder) -> Response:
async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
"""
Add Condition.
@ -166,15 +167,14 @@ async def conditions_add(request: Request, encoder: Encoder) -> Response:
)
try:
data = _serialize(await Conditions.get_instance().save(item=item.model_dump()))
saved = _serialize(await Conditions.get_instance().save(item=item.model_dump()))
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
return web.json_response(
data=data,
status=web.HTTPOk.status_code,
dumps=encoder.encode,
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.CREATE, data=saved)
)
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", "api/conditions/{id}", name="condition_get")
@ -200,13 +200,14 @@ async def conditions_get(request: Request, encoder: Encoder) -> Response:
@route("DELETE", "api/conditions/{id}", name="condition_delete")
async def conditions_delete(request: Request, encoder: Encoder) -> Response:
async def conditions_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
"""
Delete Condition.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
@ -216,23 +217,24 @@ async def conditions_delete(request: Request, encoder: Encoder) -> Response:
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
try:
return web.json_response(
data=_serialize(await Conditions.get_instance()._repo.delete(id)),
status=web.HTTPOk.status_code,
dumps=encoder.encode,
deleted = _serialize(await Conditions.get_instance()._repo.delete(id))
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.DELETE, data=deleted)
)
return web.json_response(data=deleted, 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/conditions/{id}", name="condition_patch")
async def conditions_patch(request: Request, encoder: Encoder) -> Response:
async def conditions_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
"""
Patch Condition.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
@ -268,23 +270,22 @@ async def conditions_patch(request: Request, encoder: Encoder) -> Response:
status=web.HTTPConflict.status_code,
)
dct = validated.model_dump(exclude_unset=True)
return web.json_response(
data=_serialize(await service._repo.update(model.id, dct)),
status=web.HTTPOk.status_code,
dumps=encoder.encode,
updated = _serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.UPDATE, data=updated)
)
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("PUT", "api/conditions/{id}", name="condition_update")
async def conditions_update(request: Request, encoder: Encoder) -> Response:
async def conditions_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
"""
Update Condition.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
@ -320,8 +321,8 @@ async def conditions_update(request: Request, encoder: Encoder) -> Response:
status=web.HTTPConflict.status_code,
)
return web.json_response(
data=_serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True))),
status=web.HTTPOk.status_code,
dumps=encoder.encode,
updated = _serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.UPDATE, data=updated)
)
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -1,6 +1,9 @@
from __future__ import annotations
from pydantic import BaseModel
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class Pagination(BaseModel):
@ -10,3 +13,29 @@ class Pagination(BaseModel):
total_pages: int
has_next: bool
has_prev: bool
class CEFeature(str, Enum):
PRESETS = "presets"
DL_FIELDS = "dl_fields"
CONDITIONS = "conditions"
def __str__(self) -> str:
return self.value
class CEAction(str, Enum):
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
REPLACE = "replace"
def __str__(self) -> str:
return self.value
class ConfigEvent(BaseModel):
feature: CEFeature
action: CEAction
data: dict[str, Any] | list[dict[str, Any]]
extras: dict[str, Any] = Field(default_factory=dict)

View file

@ -5,7 +5,7 @@ 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.schemas import CEAction, CEFeature, ConfigEvent, 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
@ -62,7 +62,7 @@ async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) ->
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
notify.emit(Events.DLFIELDS_UPDATE, data=[saved])
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.CREATE, data=saved))
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
@ -79,13 +79,17 @@ async def dl_fields_get(request: Request, encoder: Encoder) -> Response:
@route("DELETE", "api/dl_fields/{id}", "dl_fields_delete")
async def dl_fields_delete(request: Request, encoder: Encoder) -> Response:
async def dl_fields_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
try:
deleted = _serialize(await DLFields.get_instance()._repo.delete(identifier))
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.DELETE, data=deleted)
)
return web.json_response(
data=_serialize(await DLFields.get_instance()._repo.delete(identifier)),
data=deleted,
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@ -94,7 +98,7 @@ async def dl_fields_delete(request: Request, encoder: Encoder) -> Response:
@route("PATCH", "api/dl_fields/{id}", "dl_fields_patch")
async def dl_fields_patch(request: Request, encoder: Encoder) -> Response:
async def dl_fields_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
@ -123,17 +127,19 @@ async def dl_fields_patch(request: Request, encoder: Encoder) -> Response:
status=web.HTTPConflict.status_code,
)
dct = validated.model_dump(exclude_unset=True)
updated = _serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.UPDATE, data=updated)
)
return web.json_response(
data=_serialize(await DLFields.get_instance()._repo.update(model.id, dct)),
data=updated,
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:
async def dl_fields_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
@ -162,8 +168,9 @@ async def dl_fields_update(request: Request, encoder: Encoder) -> Response:
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,
updated = _serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.UPDATE, data=updated)
)
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -25,6 +25,7 @@ class Events:
CONNECTED: str = "connected"
CONFIGURATION: str = "configuration"
CONFIG_UPDATE: str = "config_update"
ACTIVE_QUEUE: str = "active_queue"
LOG_INFO: str = "log_info"
@ -58,17 +59,8 @@ class Events:
TASK_FINISHED: str = "task_finished"
TASK_ERROR: str = "task_error"
PRESETS_ADD: str = "presets_add"
PRESETS_UPDATE: str = "presets_update"
DLFIELDS_ADD: str = "dlfields_add"
DLFIELDS_UPDATE: str = "dlfields_update"
SCHEDULE_ADD: str = "schedule_add"
CONDITIONS_ADD: str = "conditions_add"
CONDITIONS_UPDATE: str = "conditions_update"
SUBSCRIBED: str = "subscribed"
UNSUBSCRIBED: str = "unsubscribed"
@ -94,6 +86,7 @@ class Events:
"""
return [
Events.CONFIGURATION,
Events.CONFIG_UPDATE,
Events.CONNECTED,
Events.ACTIVE_QUEUE,
Events.LOG_INFO,
@ -110,8 +103,6 @@ class Events:
Events.RESUMED,
Events.CLI_CLOSE,
Events.CLI_OUTPUT,
Events.PRESETS_UPDATE,
Events.DLFIELDS_UPDATE,
]
def only_debug() -> list:

View file

@ -8,7 +8,6 @@ from typing import Any
from aiohttp import web
from .config import Config
from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import arg_converter, create_cookies_file, init_class
@ -214,12 +213,6 @@ class Presets(metaclass=Singleton):
LOG.error(f"Default preset '{self._config.default_preset}' not found, using 'default' preset.")
self._config.default_preset = "default"
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add")
def get_all(self) -> list[Preset]:
"""Return the items."""
return sorted(self._default + self._items, key=lambda x: x.priority, reverse=True)

View file

@ -4,6 +4,7 @@ import uuid
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.Presets import Preset, Presets
@ -96,5 +97,12 @@ async def presets_add(request: Request, encoder: Encoder, notify: EventBus) -> R
status=web.HTTPInternalServerError.status_code,
)
notify.emit(Events.PRESETS_UPDATE, data=presets)
notify.emit(
Events.CONFIG_UPDATE,
data=ConfigEvent(
feature=CEFeature.PRESETS,
action=CEAction.REPLACE,
data=[preset.serialize() for preset in presets],
),
)
return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -20,6 +20,7 @@ class TestEvents:
# Connection events
assert Events.CONNECTED == "connected"
assert Events.CONFIG_UPDATE == "config_update"
# Log events
assert Events.LOG_INFO == "log_info"
@ -48,6 +49,7 @@ class TestEvents:
"started",
"shutdown",
"connected",
"config_update",
"log_info",
"log_warning",
"log_error",
@ -85,6 +87,7 @@ class TestEvents:
# Check some expected frontend events
expected_frontend = [
Events.CONNECTED,
Events.CONFIG_UPDATE,
Events.LOG_INFO,
Events.LOG_WARNING,
Events.LOG_ERROR,

View file

@ -103,11 +103,9 @@ class TestPresets:
Presets._reset_singleton()
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_singleton(self, mock_eventbus, mock_config):
def test_presets_singleton(self, mock_config):
"""Test that Presets follows singleton pattern."""
mock_config.get_instance.return_value = Mock(config_path="/tmp", default_preset="default")
mock_eventbus.get_instance.return_value = Mock()
instance1 = Presets.get_instance()
instance2 = Presets.get_instance()
@ -116,13 +114,10 @@ class TestPresets:
assert isinstance(instance1, Presets)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_initialization(self, mock_eventbus, mock_config):
def test_presets_initialization(self, mock_config):
"""Test Presets initialization with default presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -138,12 +133,10 @@ class TestPresets:
assert preset.default is True
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_load_empty_file(self, mock_eventbus, mock_config):
def test_presets_load_empty_file(self, mock_config):
"""Test loading presets from empty or non-existent file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -157,13 +150,11 @@ class TestPresets:
assert len(presets._default) > 0 # Should still have default presets
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.arg_converter")
def test_presets_load_valid_file(self, mock_arg_converter, mock_eventbus, mock_config):
def test_presets_load_valid_file(self, mock_arg_converter, mock_config):
"""Test loading presets from valid JSON file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
test_presets = [
@ -184,13 +175,11 @@ class TestPresets:
assert presets._items[0].name == "test_preset_1"
assert presets._items[1].name == "test_preset_2"
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.Config")
def test_presets_load_invalid_json(self, mock_eventbus, mock_config):
def test_presets_load_invalid_json(self, mock_config):
"""Test loading presets from invalid JSON file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -204,13 +193,11 @@ class TestPresets:
assert len(presets._items) == 0
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.arg_converter")
def test_presets_load_with_format_migration(self, mock_arg_converter, mock_eventbus, mock_config):
def test_presets_load_with_format_migration(self, mock_arg_converter, mock_config):
"""Test loading presets with old format that needs migration."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
# Old format preset with 'format' field instead of 'cli'
@ -233,12 +220,10 @@ class TestPresets:
assert loaded_preset.id is not None
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_validate_valid_preset(self, mock_eventbus, mock_config):
def test_presets_validate_valid_preset(self, mock_config):
"""Test validating a valid preset."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -250,12 +235,10 @@ class TestPresets:
assert result is True
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_validate_invalid_preset(self, mock_eventbus, mock_config):
def test_presets_validate_invalid_preset(self, mock_config):
"""Test validating invalid presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -274,13 +257,11 @@ class TestPresets:
presets.validate("invalid_type")
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.arg_converter")
def test_presets_save(self, mock_arg_converter, mock_eventbus, mock_config):
def test_presets_save(self, mock_arg_converter, mock_config):
"""Test saving presets to file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
test_presets = [
@ -303,12 +284,10 @@ class TestPresets:
assert saved_data[0]["name"] == "test1"
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_all(self, mock_eventbus, mock_config):
def test_presets_get_all(self, mock_config):
"""Test getting all presets (default + custom)."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -325,12 +304,10 @@ class TestPresets:
assert any(p.default is True for p in all_presets)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_by_id(self, mock_eventbus, mock_config):
def test_presets_get_by_id(self, mock_config):
"""Test getting preset by ID."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -347,12 +324,10 @@ class TestPresets:
assert found_preset.name == "test_preset"
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_by_name(self, mock_eventbus, mock_config):
def test_presets_get_by_name(self, mock_config):
"""Test getting preset by name."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -368,12 +343,10 @@ class TestPresets:
assert found_preset.cli == "--test"
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_nonexistent(self, mock_eventbus, mock_config):
def test_presets_get_nonexistent(self, mock_config):
"""Test getting non-existent preset returns None."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -384,12 +357,10 @@ class TestPresets:
assert found_preset is None
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_empty_parameter(self, mock_eventbus, mock_config):
def test_presets_get_empty_parameter(self, mock_config):
"""Test getting preset with empty string returns None."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -399,12 +370,10 @@ class TestPresets:
assert presets.get(None) is None
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_has_method(self, mock_eventbus, mock_config):
def test_presets_has_method(self, mock_config):
"""Test has method for checking preset existence."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -418,12 +387,10 @@ class TestPresets:
assert presets.has("nonexistent") is False
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_clear(self, mock_eventbus, mock_config):
def test_presets_clear(self, mock_config):
"""Test clearing all custom presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -441,12 +408,10 @@ class TestPresets:
assert len(presets._items) == 0
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_file_permissions(self, mock_eventbus, mock_config):
def test_presets_file_permissions(self, mock_config):
"""Test that presets file gets correct permissions."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -483,12 +448,10 @@ class TestPresets:
assert isinstance(preset, Preset)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_attach_method(self, mock_eventbus, mock_config):
def test_presets_attach_method(self, mock_config):
"""Test the attach method for aiohttp integration."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_app = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
@ -501,12 +464,10 @@ class TestPresets:
mock_get.assert_called_once_with("default")
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_attach_default_preset_not_found(self, mock_eventbus, mock_config):
def test_presets_attach_default_preset_not_found(self, mock_config):
"""Test attach method when default preset is not found."""
mock_config_instance = Mock(config_path="/tmp", default_preset="nonexistent")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_app = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
@ -520,12 +481,10 @@ class TestPresets:
@pytest.mark.asyncio
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
async def test_presets_on_shutdown(self, mock_eventbus, mock_config):
async def test_presets_on_shutdown(self, mock_config):
"""Test the on_shutdown method."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_app = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
@ -536,14 +495,12 @@ class TestPresets:
await presets.on_shutdown(mock_app)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.LOG")
@patch("app.library.Presets.arg_converter")
def test_presets_load_invalid_preset_in_file(self, mock_arg_converter, mock_log, mock_eventbus, mock_config):
def test_presets_load_invalid_preset_in_file(self, mock_arg_converter, mock_log, mock_config):
"""Test loading file with some invalid presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
# Mix of valid and invalid presets
@ -570,13 +527,11 @@ class TestPresets:
mock_log.error.assert_called()
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.LOG")
def test_presets_save_with_invalid_preset(self, mock_log, mock_eventbus, mock_config):
def test_presets_save_with_invalid_preset(self, mock_log, mock_config):
"""Test saving presets with some invalid ones."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
# Create preset with invalid CLI that will fail validation
valid_preset = Preset(name="valid", cli="--valid-option")
@ -596,12 +551,10 @@ class TestPresets:
mock_log.error.assert_called()
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_preset_priority_validation_integer(self, mock_eventbus, mock_config):
def test_preset_priority_validation_integer(self, mock_config):
"""Test that priority must be an integer."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -614,12 +567,10 @@ class TestPresets:
presets.validate(invalid_preset)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_preset_priority_validation_negative(self, mock_eventbus, mock_config):
def test_preset_priority_validation_negative(self, mock_config):
"""Test that priority must be >= 0."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -632,12 +583,10 @@ class TestPresets:
presets.validate(invalid_preset)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_preset_priority_sorting(self, mock_eventbus, mock_config):
def test_preset_priority_sorting(self, mock_config):
"""Test that presets are sorted by priority in descending order."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
@ -661,19 +610,16 @@ class TestPresets:
assert non_default[2].priority == 1
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_preset_priority_default_zero(self, mock_eventbus, mock_config):
def test_preset_priority_default_zero(self, mock_config):
"""Test that priority defaults to 0 if not specified."""
preset = Preset(name="test")
assert preset.priority == 0
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_preset_priority_migration(self, mock_eventbus, mock_config):
def test_preset_priority_migration(self, mock_config):
"""Test that old presets without priority get it added on load."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"

View file

@ -452,7 +452,7 @@
<li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul>
</Message>
<Message v-else-if="socket.isConnected" class="is-primary" title="No items" icon="fas fa-exclamation-triangle"
<Message v-else class="is-primary" title="No items" icon="fas fa-exclamation-triangle"
:new-style="true">
<p>Download history is empty.</p>
</Message>

View file

@ -1,5 +1,8 @@
import { useStorage } from '@vueuse/core'
import type { ConfigState } from '~/types/config';
import type { DLField } from '~/types/dl_fields';
import type { Preset } from '~/types/presets';
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({
@ -65,6 +68,7 @@ export const useConfigStore = defineStore('config', () => {
}
return (state as any)[key] ?? defaultValue
}
const isLoaded = () => state.is_loaded
const update = add
@ -84,16 +88,62 @@ export const useConfigStore = defineStore('config', () => {
state.is_loaded = true
}
const isLoaded = () => state.is_loaded
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets']
if (!supportedFeatures.includes(feature)) {
return
}
if ('presets' === feature) {
if ('replace' === action) {
state.presets = data as Array<Preset>
}
return
}
if ('dl_fields' === feature) {
const item = data as DLField
const current = get(feature, []) as Array<DLField>
if ('create' === action) {
current.push(item)
return
}
if ('delete' === action) {
const index = current.findIndex(i => i.id === item.id)
if (-1 !== index) {
current.splice(index, 1)
}
return
}
if ('update' === action) {
const target = current.find(i => i.id === item.id)
if (target) {
Object.assign(target, item)
}
return
}
if ('replace' === action) {
state.dl_fields = data as Array<DLField>
return
}
return
}
}
return {
...toRefs(state), add, get, update, getAll, setAll, isLoaded,
...toRefs(state), add, get, update, getAll, setAll, isLoaded, patch
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
add: typeof add
get: typeof get
update: typeof update
getAll: typeof getAll
setAll: typeof setAll
patch: typeof patch
isLoaded: typeof isLoaded
}
});

View file

@ -3,6 +3,7 @@ import { ref, readonly } from 'vue'
import { defineStore } from 'pinia'
import type { ConfigState } from "~/types/config";
import type { StoreItem } from "~/types/store";
import type { ConfigUpdatePayload } from "~/types/sockets";
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
@ -277,8 +278,13 @@ export const useSocketStore = defineStore('socket', () => {
toast.warning('Download queue paused.', { timeout: 10000 });
}, true);
on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || []));
on('dlfields_update', (data: string) => config.update('dl_fields', JSON.parse(data).data || []));
on('config_update', (stream: string) => {
const json = JSON.parse(stream) as { data: ConfigUpdatePayload }
if (!json?.data) {
return
}
config.patch(json.data.feature, json.data.action, json.data.data)
})
setupVisibilityListener();
}

View file

@ -6,3 +6,16 @@ export type Event = {
message: string | null
data: Record<string, unknown>
}
type ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace'
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions'
type ConfigUpdatePayload<T = unknown> = {
feature: ConfigFeature
action: ConfigUpdateAction
data: T | Array<T>
meta?: Record<string, unknown>
}
export type { ConfigUpdateAction, ConfigFeature, ConfigUpdatePayload }