From 0cd57d1f49dabc48f2daea22a3b1e077f1ceec51 Mon Sep 17 00:00:00 2001 From: 120318 <20685540+getupbuzz@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:07:18 +0800 Subject: [PATCH 1/7] Refine notification events and Telegram delivery --- backend/alembic/versions/0003_drop_alerts.py | 75 ++++++ .../versions/0004_event_acknowledgements.py | 40 ++++ .../versions/0005_prune_operation_events.py | 76 ++++++ .../0006_prune_subscription_run_events.py | 65 ++++++ backend/app/addons/descriptors.py | 14 +- backend/app/api/v1/alerts/__init__.py | 8 - backend/app/api/v1/alerts/acknowledge.py | 15 -- backend/app/api/v1/alerts/center.py | 11 - backend/app/api/v1/api.py | 3 +- backend/app/api/v1/config/test_connection.py | 12 + backend/app/api/v1/events/list.py | 50 ++-- backend/app/clients/__init__.py | 2 - backend/app/core/feature_flags.py | 4 - .../app/db/repositories/alert_repository.py | 139 ----------- .../app/db/repositories/event_repository.py | 52 ++++- backend/app/db/sql/models.py | 39 +--- backend/app/schemas/config.py | 51 +++- backend/app/schemas/constants/event_types.py | 16 -- backend/app/schemas/domain/addon_events.py | 1 + backend/app/schemas/domain/alert.py | 99 -------- backend/app/schemas/domain/event.py | 36 +-- backend/app/schemas/domain/event_meta.py | 5 - backend/app/schemas/exception/exceptions.py | 9 +- .../schemas/runtime/subscription_runtime.py | 11 - .../application/workflows/danmu/service.py | 1 - .../media_server_sync/season_runner.py | 8 - .../workflows/media_server_sync/service.py | 17 -- .../workflows/notifications/service.py | 63 +++-- .../workflows/scheduled_transfer/service.py | 9 + .../workflows/subscription/pilot.py | 19 -- .../application/workflows/subscription/run.py | 50 ---- .../services/audit/event_message_builder.py | 56 ----- .../app/services/audit/event_message_i18n.py | 15 -- backend/app/services/audit/event_service.py | 63 ++++- .../services/audit/workflow_event_emitters.py | 43 ++-- .../config/indexer_client_settings.py | 14 -- .../app/services/config/settings_service.py | 6 + .../app/services/domain/alerts/__init__.py | 3 - backend/app/services/domain/alerts/service.py | 106 --------- .../services/domain/alerts/workflow_alerts.py | 197 ---------------- .../domain/download/downloader_change.py | 4 - .../downloader_change_side_effects.py | 34 +-- .../app/services/domain/download/lifecycle.py | 99 ++++---- .../domain/download/task_runtime_service.py | 16 +- backend/app/services/domain/download/tasks.py | 3 - .../app/services/domain/resource/selection.py | 40 +++- .../domain/subscription/command_service.py | 24 +- .../app/services/domain/transfer/execution.py | 43 +++- .../app/services/domain/transfer/service.py | 62 +---- backend/app/services/i18n/locales/en-US.json | 51 ++-- backend/app/services/i18n/locales/zh-CN.json | 51 ++-- .../integration/notifications/__init__.py | 18 ++ .../channels/telegram/__init__.py | 9 +- .../channels/telegram/channel.py | 60 +---- .../notifications/channels/telegram/client.py | 61 ++++- .../channels/telegram/renderer.py | 218 ++++++++++++++++++ .../platform/notification_channel_service.py | 3 - backend/tests/test_addon_service_contracts.py | 63 ++++- backend/tests/test_alert_service.py | 56 ----- backend/tests/test_config_test_connection.py | 59 +++++ backend/tests/test_danmu_event_levels.py | 73 ++++++ backend/tests/test_download_default_tag.py | 28 +-- backend/tests/test_download_runtime_drift.py | 30 +++ backend/tests/test_event_center.py | 90 ++++++++ backend/tests/test_event_message_builder.py | 38 --- backend/tests/test_event_service.py | 8 +- .../tests/test_indexer_site_health_alerts.py | 31 +-- backend/tests/test_message_renderer.py | 8 +- ...test_scheduled_transfer_command_service.py | 29 +++ .../test_subscription_execution_drift.py | 59 +++++ ...test_subscription_execution_end_current.py | 25 +- backend/tests/test_task_downloader_change.py | 11 +- .../test_telegram_notification_renderer.py | 75 ++++++ backend/tests/test_telegram_notifications.py | 98 ++++++++ backend/tests/test_transfer_error_alert.py | 17 +- backend/tests/test_transfer_service_drift.py | 49 +++- backend/tests/test_workflow_alerts.py | 171 +++++--------- docs/backend-contracts.md | 4 + docs/features.md | 2 +- frontend/src/App.vue | 24 +- frontend/src/api/alerts.js | 5 - frontend/src/api/config.js | 2 +- frontend/src/api/events.js | 4 + frontend/src/components/EventList.vue | 22 +- ...ialog.vue => NotificationCenterDialog.vue} | 147 +++++++----- .../src/components/config/AddonConfig.vue | 3 +- .../src/components/config/SystemConfig.vue | 41 +++- .../config/addons/NotificationsAddonCard.vue | 114 ++++++++- .../addons/notificationChannelsSupport.js | 30 ++- .../components/config/systemConfigSupport.js | 17 +- frontend/src/composables/useAppShell.js | 108 ++++----- frontend/src/constants/eventTypes.js | 23 +- frontend/src/i18n/index.js | 1 + frontend/src/i18n/locales/en-US.js | 87 ++++--- frontend/src/i18n/locales/zh-CN.js | 73 +++--- frontend/src/stores/locale.js | 17 +- ...alert-center.js => notification-center.js} | 63 ++--- frontend/src/stores/operations.js | 6 +- frontend/src/styles/patterns.css | 16 +- frontend/src/utils/http.js | 24 +- 100 files changed, 2292 insertions(+), 1858 deletions(-) create mode 100644 backend/alembic/versions/0003_drop_alerts.py create mode 100644 backend/alembic/versions/0004_event_acknowledgements.py create mode 100644 backend/alembic/versions/0005_prune_operation_events.py create mode 100644 backend/alembic/versions/0006_prune_subscription_run_events.py delete mode 100644 backend/app/api/v1/alerts/__init__.py delete mode 100644 backend/app/api/v1/alerts/acknowledge.py delete mode 100644 backend/app/api/v1/alerts/center.py delete mode 100644 backend/app/db/repositories/alert_repository.py delete mode 100644 backend/app/schemas/domain/alert.py delete mode 100644 backend/app/services/domain/alerts/__init__.py delete mode 100644 backend/app/services/domain/alerts/service.py delete mode 100644 backend/app/services/domain/alerts/workflow_alerts.py create mode 100644 backend/app/services/integration/notifications/channels/telegram/renderer.py delete mode 100644 backend/tests/test_alert_service.py create mode 100644 backend/tests/test_danmu_event_levels.py create mode 100644 backend/tests/test_event_center.py create mode 100644 backend/tests/test_telegram_notification_renderer.py create mode 100644 backend/tests/test_telegram_notifications.py delete mode 100644 frontend/src/api/alerts.js rename frontend/src/components/{AlertCenterDialog.vue => NotificationCenterDialog.vue} (62%) rename frontend/src/stores/{alert-center.js => notification-center.js} (66%) diff --git a/backend/alembic/versions/0003_drop_alerts.py b/backend/alembic/versions/0003_drop_alerts.py new file mode 100644 index 0000000..af0a36c --- /dev/null +++ b/backend/alembic/versions/0003_drop_alerts.py @@ -0,0 +1,75 @@ +"""Drop alerts storage. + +Revision ID: 0003_drop_alerts +Revises: 0002_scope_douban_identity +Create Date: 2026-05-31 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0003_drop_alerts" +down_revision = "0002_scope_douban_identity" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + bind = op.get_bind() + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + if _has_table("alerts"): + op.drop_table("alerts") + + +def downgrade() -> None: + if _has_table("alerts"): + return + op.create_table( + "alerts", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("fingerprint", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("severity", sa.Text(), nullable=False), + sa.Column("category", sa.Text(), nullable=False), + sa.Column("message_key", sa.Text(), nullable=False), + sa.Column("message_params_json", sa.JSON(), nullable=False), + sa.Column("target_type", sa.Text(), nullable=True), + sa.Column("target_id", sa.Text(), nullable=True), + sa.Column("media_id", sa.Text(), nullable=True), + sa.Column("media_season_number", sa.Integer(), nullable=True), + sa.Column("media_title", sa.Text(), nullable=True), + sa.Column("media_year", sa.Integer(), nullable=True), + sa.Column("task_id", sa.Text(), nullable=True), + sa.Column("action_id", sa.Text(), nullable=True), + sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("first_seen_at", sa.Text(), nullable=False), + sa.Column("last_seen_at", sa.Text(), nullable=False), + sa.Column("acknowledged_at", sa.Text(), nullable=True), + sa.Column("resolved_at", sa.Text(), nullable=True), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("updated_at", sa.Text(), nullable=False), + sa.UniqueConstraint("fingerprint", name="uq_alerts_fingerprint"), + ) + op.create_index("ix_alerts_fingerprint", "alerts", ["fingerprint"]) + op.create_index("ix_alerts_status", "alerts", ["status"]) + op.create_index("ix_alerts_severity", "alerts", ["severity"]) + op.create_index("ix_alerts_category", "alerts", ["category"]) + op.create_index("ix_alerts_target_type", "alerts", ["target_type"]) + op.create_index("ix_alerts_target_id", "alerts", ["target_id"]) + op.create_index("ix_alerts_media_id", "alerts", ["media_id"]) + op.create_index("ix_alerts_media_season_number", "alerts", ["media_season_number"]) + op.create_index("ix_alerts_task_id", "alerts", ["task_id"]) + op.create_index("ix_alerts_action_id", "alerts", ["action_id"]) + op.create_index("ix_alerts_first_seen_at", "alerts", ["first_seen_at"]) + op.create_index("ix_alerts_last_seen_at", "alerts", ["last_seen_at"]) + op.create_index("ix_alerts_acknowledged_at", "alerts", ["acknowledged_at"]) + op.create_index("ix_alerts_resolved_at", "alerts", ["resolved_at"]) + op.create_index("ix_alerts_updated_at", "alerts", ["updated_at"]) + op.create_index("ix_alerts_status_ack_severity", "alerts", ["status", "acknowledged_at", "severity"]) + op.create_index("ix_alerts_target", "alerts", ["target_type", "target_id"]) diff --git a/backend/alembic/versions/0004_event_acknowledgements.py b/backend/alembic/versions/0004_event_acknowledgements.py new file mode 100644 index 0000000..274db8f --- /dev/null +++ b/backend/alembic/versions/0004_event_acknowledgements.py @@ -0,0 +1,40 @@ +"""Add event acknowledgement storage. + +Revision ID: 0004_event_acknowledgements +Revises: 0003_drop_alerts +Create Date: 2026-05-31 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0004_event_acknowledgements" +down_revision = "0003_drop_alerts" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + bind = op.get_bind() + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + if _has_table("event_acknowledgements"): + return + op.create_table( + "event_acknowledgements", + sa.Column("event_id", sa.Text(), primary_key=True), + sa.Column("acknowledged_at", sa.Text(), nullable=False), + ) + op.create_index("ix_event_acknowledgements_acknowledged_at", "event_acknowledgements", ["acknowledged_at"]) + + +def downgrade() -> None: + if not _has_table("event_acknowledgements"): + return + op.drop_index("ix_event_acknowledgements_acknowledged_at", table_name="event_acknowledgements") + op.drop_table("event_acknowledgements") diff --git a/backend/alembic/versions/0005_prune_operation_events.py b/backend/alembic/versions/0005_prune_operation_events.py new file mode 100644 index 0000000..ce56598 --- /dev/null +++ b/backend/alembic/versions/0005_prune_operation_events.py @@ -0,0 +1,76 @@ +"""Prune operation-only audit events. + +Revision ID: 0005_prune_operation_events +Revises: 0004_event_acknowledgements +Create Date: 2026-06-01 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0005_prune_operation_events" +down_revision = "0004_event_acknowledgements" +branch_labels = None +depends_on = None + + +OPERATION_EVENT_TYPES = ( + "download.started", + "download.task.downloader_changed", + "download.task.storage_change_started", + "download.task.storage_changed", + "media.import.started", + "media_server_sync.started", + "danmu.generate.started", + "subscription.enabled", + "subscription.disabled", + "subscription.ended.manual", + "follow.enabled", + "follow.disabled", + "pilot.episode.queued", +) + + +def _has_table(table_name: str) -> bool: + bind = op.get_bind() + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + if not _has_table("events"): + return + bind = op.get_bind() + params = {f"type_{index}": value for index, value in enumerate(OPERATION_EVENT_TYPES)} + placeholders = ", ".join(f":type_{index}" for index in range(len(OPERATION_EVENT_TYPES))) + if _has_table("event_dispatches"): + bind.execute( + sa.text( + f""" + DELETE FROM event_dispatches + WHERE event_id IN ( + SELECT id FROM events WHERE type IN ({placeholders}) + ) + """ + ), + params, + ) + if _has_table("event_acknowledgements"): + bind.execute( + sa.text( + f""" + DELETE FROM event_acknowledgements + WHERE event_id IN ( + SELECT id FROM events WHERE type IN ({placeholders}) + ) + """ + ), + params, + ) + bind.execute(sa.text(f"DELETE FROM events WHERE type IN ({placeholders})"), params) + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/0006_prune_subscription_run_events.py b/backend/alembic/versions/0006_prune_subscription_run_events.py new file mode 100644 index 0000000..75f16b8 --- /dev/null +++ b/backend/alembic/versions/0006_prune_subscription_run_events.py @@ -0,0 +1,65 @@ +"""Prune subscription run audit events. + +Revision ID: 0006_prune_subscription_run_events +Revises: 0005_prune_operation_events +Create Date: 2026-06-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0006_prune_subscription_run_events" +down_revision = "0005_prune_operation_events" +branch_labels = None +depends_on = None + + +SUBSCRIPTION_RUN_EVENT_TYPES = ( + "subscription.run.completed", + "subscription.run.failed", +) + + +def _has_table(table_name: str) -> bool: + bind = op.get_bind() + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + if not _has_table("events"): + return + bind = op.get_bind() + params = {f"type_{index}": value for index, value in enumerate(SUBSCRIPTION_RUN_EVENT_TYPES)} + placeholders = ", ".join(f":type_{index}" for index in range(len(SUBSCRIPTION_RUN_EVENT_TYPES))) + if _has_table("event_dispatches"): + bind.execute( + sa.text( + f""" + DELETE FROM event_dispatches + WHERE event_id IN ( + SELECT id FROM events WHERE type IN ({placeholders}) + ) + """ + ), + params, + ) + if _has_table("event_acknowledgements"): + bind.execute( + sa.text( + f""" + DELETE FROM event_acknowledgements + WHERE event_id IN ( + SELECT id FROM events WHERE type IN ({placeholders}) + ) + """ + ), + params, + ) + bind.execute(sa.text(f"DELETE FROM events WHERE type IN ({placeholders})"), params) + + +def downgrade() -> None: + pass diff --git a/backend/app/addons/descriptors.py b/backend/app/addons/descriptors.py index 9082e55..6670b6c 100644 --- a/backend/app/addons/descriptors.py +++ b/backend/app/addons/descriptors.py @@ -1,7 +1,7 @@ from __future__ import annotations from app.addons.registry import AddonDescriptor, AddonJobSpec, AddonRegistry -from app.schemas.config import AddonsConfig +from app.schemas.config import AddonsConfig, NotificationChannelConfig from app.services.application.workflows.danmu import danmu_application_service from app.services.application.workflows.notifications import notification_application_service from app.services.platform.auth_provider_service import auth_provider_service @@ -13,9 +13,15 @@ def _auth_enabled(config: AddonsConfig) -> bool: def _notifications_enabled(config: AddonsConfig) -> bool: - return config.notifications.enabled and any( - notification_channel_service.supports(channel.type) for channel in config.notifications.channels - ) + if not config.notifications.enabled: + return False + return any(_notification_channel_ready(channel) for channel in config.notifications.channels) + + +def _notification_channel_ready(channel: NotificationChannelConfig) -> bool: + if not channel.enabled or not notification_channel_service.supports(channel.type): + return False + return notification_channel_service.get_channel(channel.type).is_configured(channel) def _notification_event_patterns() -> list[str]: diff --git a/backend/app/api/v1/alerts/__init__.py b/backend/app/api/v1/alerts/__init__.py deleted file mode 100644 index 2d171e9..0000000 --- a/backend/app/api/v1/alerts/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import APIRouter - -from app.api.v1.alerts.acknowledge import router as acknowledge_router -from app.api.v1.alerts.center import router as center_router - -router = APIRouter(prefix="/alerts") -router.include_router(center_router) -router.include_router(acknowledge_router) diff --git a/backend/app/api/v1/alerts/acknowledge.py b/backend/app/api/v1/alerts/acknowledge.py deleted file mode 100644 index ef8f567..0000000 --- a/backend/app/api/v1/alerts/acknowledge.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi import APIRouter - -from app.schemas.domain.alert import AlertRecord -from app.schemas.exception.exceptions import ResourceNotFoundException -from app.services.domain.alerts import alert_service - -router = APIRouter() - - -@router.post("/{alert_id}/acknowledge", response_model=AlertRecord) -async def acknowledge_alert(alert_id: str) -> AlertRecord: - alert = alert_service.acknowledge_alert(alert_id) - if not alert: - raise ResourceNotFoundException("backendErrors.alertNotFound") - return alert diff --git a/backend/app/api/v1/alerts/center.py b/backend/app/api/v1/alerts/center.py deleted file mode 100644 index 4b3b95e..0000000 --- a/backend/app/api/v1/alerts/center.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi import APIRouter - -from app.schemas.domain.alert import AlertCenterResponse -from app.services.domain.alerts import alert_service - -router = APIRouter() - - -@router.get("/center", response_model=AlertCenterResponse) -async def get_alert_center() -> AlertCenterResponse: - return alert_service.get_center() diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 74b81e0..3d65a5c 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import actions, alerts, auth, calendar, commands, config, discover, events, addons, library, logs, media, media_management, resource, scheduler, subscription, task +from app.api.v1 import actions, auth, calendar, commands, config, discover, events, addons, library, logs, media, media_management, resource, scheduler, subscription, task from app.api.v1.config.test_directory import router as test_directory_router from app.api.v1.resource import download_history, parser @@ -22,7 +22,6 @@ api_router.include_router(commands.router, tags=["commands"]) api_router.include_router(events.router, tags=["events"]) api_router.include_router(actions.router, tags=["actions"]) -api_router.include_router(alerts.router, tags=["alerts"]) api_router.include_router(logs.router, tags=["logs"]) api_router.include_router(addons.router, tags=["addons"]) api_router.include_router(media_management.router, tags=["media-management"]) diff --git a/backend/app/api/v1/config/test_connection.py b/backend/app/api/v1/config/test_connection.py index 98f3791..2a4a9c0 100644 --- a/backend/app/api/v1/config/test_connection.py +++ b/backend/app/api/v1/config/test_connection.py @@ -8,11 +8,13 @@ PathMapping, ProwlarrConfig, RTorrentConfig, + TelegramNotificationChannelConfig, TMDBConfig, QBittorrentConfig, ) from app.schemas.exception import ServiceTypeException, TestConnectionException from app.services.integration import douban as douban_integration +from app.services.integration import notifications as notifications_integration from app.services.integration import tmdb as tmdb_integration from app.services.integration.download.gateway import download_gateway from app.services.integration.indexer import indexer_gateway @@ -27,6 +29,7 @@ SERVICE_TYPE_JELLYFIN = "jellyfin" SERVICE_TYPE_TMDB = "themoviedb" SERVICE_TYPE_DOUBAN = "douban" +SERVICE_TYPE_TELEGRAM = "telegram" SUPPORTED_SERVICE_TYPES = [ SERVICE_TYPE_QBITTORRENT, SERVICE_TYPE_RTORRENT, @@ -35,6 +38,7 @@ SERVICE_TYPE_JELLYFIN, SERVICE_TYPE_TMDB, SERVICE_TYPE_DOUBAN, + SERVICE_TYPE_TELEGRAM, ] @@ -47,6 +51,8 @@ class TestConnectionConfig(BaseModel): username: str | None = None password: str | None = None api_key: str | None = None + bot_token: str | None = None + chat_id: str | None = None path_mappings: list[PathMapping] = [] @@ -91,6 +97,12 @@ async def test_service_connection(payload: TestServiceConnectionRequest): elif service_type == SERVICE_TYPE_DOUBAN: config = DoubanConfig.model_validate(config_data) success = await douban_integration.test_connection_for_config(config) + elif service_type == SERVICE_TYPE_TELEGRAM: + config = TelegramNotificationChannelConfig.model_validate(config_data) + try: + success = await notifications_integration.test_telegram_connection_for_config(config) + except RuntimeError as exc: + raise TestConnectionException(service_name=service_type, reason=str(exc)) from exc else: raise ServiceTypeException(service_type=service_type, supported_types=SUPPORTED_SERVICE_TYPES) diff --git a/backend/app/api/v1/events/list.py b/backend/app/api/v1/events/list.py index baec895..5216d0b 100644 --- a/backend/app/api/v1/events/list.py +++ b/backend/app/api/v1/events/list.py @@ -2,9 +2,8 @@ from pydantic import BaseModel from app.api.deps import OptionalMediaIDParam -from app.schemas.constants.event_types import EventTypes from app.schemas.media_id import MediaID -from app.schemas.domain.event import Event, EventLevel, EventSource, EventType +from app.schemas.domain.event import Event, EventCenterResponse, EventLevel, EventSource, EventType from app.services.audit.event_service import event_service router = APIRouter() @@ -21,6 +20,11 @@ class EventFilterOptionsResponse(BaseModel): sources: list[str] +class EventAcknowledgeResponse(BaseModel): + ok: bool + acknowledged_count: int = 0 + + @router.get("/", response_model=EventListResponse) async def list_events( limit: int = Query(default=50, ge=1, le=200), @@ -53,6 +57,22 @@ async def list_events( return EventListResponse(total=total, items=items) +@router.get("/center", response_model=EventCenterResponse) +async def get_event_center() -> EventCenterResponse: + return event_service.get_center() + + +@router.post("/center/acknowledge-all", response_model=EventAcknowledgeResponse) +async def acknowledge_event_center() -> EventAcknowledgeResponse: + return EventAcknowledgeResponse(ok=True, acknowledged_count=event_service.acknowledge_attention_events()) + + +@router.post("/{event_id}/acknowledge", response_model=EventAcknowledgeResponse) +async def acknowledge_event(event_id: str) -> EventAcknowledgeResponse: + acknowledged = event_service.acknowledge_event(event_id) + return EventAcknowledgeResponse(ok=acknowledged, acknowledged_count=1 if acknowledged else 0) + + @router.get("/filter-options", response_model=EventFilterOptionsResponse) async def get_event_filter_options( media_id: MediaID | None = Depends(OptionalMediaIDParam), @@ -65,31 +85,7 @@ async def get_event_filter_options( _ = (media_id, season_number, task_id, subscription_id, action_id, keyword) return EventFilterOptionsResponse( levels=[level.value for level in EventLevel], - types=[ - EventTypes.DOWNLOAD_STARTED.value, - EventTypes.DOWNLOAD_COMPLETED.value, - EventTypes.DOWNLOAD_FAILED.value, - EventTypes.MEDIA_IMPORT_STARTED.value, - EventTypes.MEDIA_IMPORT_COMPLETED.value, - EventTypes.MEDIA_IMPORT_FAILED.value, - EventTypes.MEDIA_SERVER_SYNC_STARTED.value, - EventTypes.MEDIA_SERVER_SYNC_COMPLETED.value, - EventTypes.MEDIA_SERVER_SYNC_FAILED.value, - EventTypes.DANMU_GENERATE_STARTED.value, - EventTypes.DANMU_GENERATE_COMPLETED.value, - EventTypes.DANMU_GENERATE_FAILED.value, - EventTypes.MEDIA_DELETED.value, - EventTypes.SUBSCRIPTION_ENABLED.value, - EventTypes.SUBSCRIPTION_DISABLED.value, - EventTypes.FOLLOW_ENABLED.value, - EventTypes.FOLLOW_DISABLED.value, - EventTypes.FOLLOW_RELEASED.value, - EventTypes.FOLLOW_DIGITAL_RELEASED.value, - EventTypes.FOLLOW_PHYSICAL_RELEASED.value, - EventTypes.SUBSCRIPTION_RUN_COMPLETED.value, - EventTypes.SUBSCRIPTION_RUN_FAILED.value, - EventTypes.PILOT_EPISODE_QUEUED.value, - ], + types=[event_type.value for event_type in EventType], sources=[source.value for source in EventSource], ) diff --git a/backend/app/clients/__init__.py b/backend/app/clients/__init__.py index 88a3635..adff201 100644 --- a/backend/app/clients/__init__.py +++ b/backend/app/clients/__init__.py @@ -4,5 +4,3 @@ """ from app.clients.base import BaseClient -from app.clients.factory import ClientFactory, ClientType - diff --git a/backend/app/core/feature_flags.py b/backend/app/core/feature_flags.py index 324018e..2fa5292 100644 --- a/backend/app/core/feature_flags.py +++ b/backend/app/core/feature_flags.py @@ -14,7 +14,3 @@ def env_flag_enabled(name: str, *, default: bool = False) -> bool: def oidc_auth_enabled() -> bool: return env_flag_enabled("AETHERA_EXPERIMENTAL_OIDC_AUTH") - - -def telegram_notifications_enabled() -> bool: - return env_flag_enabled("AETHERA_EXPERIMENTAL_TELEGRAM_NOTIFICATIONS") diff --git a/backend/app/db/repositories/alert_repository.py b/backend/app/db/repositories/alert_repository.py deleted file mode 100644 index 1bfb081..0000000 --- a/backend/app/db/repositories/alert_repository.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from sqlalchemy import desc, select - -from app.db.sql.models import AlertORM -from app.db.sql.session import SessionLocal -from app.schemas.media_id import MediaID -from app.schemas.domain.alert import AlertRecord, AlertStatus -from app.schemas.domain.media import MediaIdentity - - -class AlertRepository: - @staticmethod - def _normalize_params(raw) -> dict[str, str]: - if type(raw) is dict: - return {str(key): str(value) for key, value in raw.items() if value is not None} - return {} - - @staticmethod - def _to_model(row: AlertORM) -> AlertRecord: - media = None - if row.media_id and row.media_title and row.media_year is not None: - media = MediaIdentity( - media_id=MediaID.parse(row.media_id), - season_number=row.media_season_number, - title=row.media_title, - year=row.media_year, - ) - return AlertRecord.model_validate( - { - "id": row.id, - "fingerprint": row.fingerprint, - "status": row.status, - "severity": row.severity, - "category": row.category, - "message_key": row.message_key, - "message_params": AlertRepository._normalize_params(row.message_params_json), - "target_type": row.target_type, - "target_id": row.target_id, - "media": media, - "media_id": row.media_id, - "task_id": row.task_id, - "action_id": row.action_id, - "occurrence_count": row.occurrence_count, - "first_seen_at": row.first_seen_at, - "last_seen_at": row.last_seen_at, - "acknowledged_at": row.acknowledged_at, - "resolved_at": row.resolved_at, - "created_at": row.created_at, - "updated_at": row.updated_at, - } - ) - - @staticmethod - def _values(alert: AlertRecord) -> dict: - media_id = alert.media.media_id if alert.media else alert.media_id - return { - "fingerprint": alert.fingerprint, - "status": alert.status.value, - "severity": alert.severity.value, - "category": alert.category.value, - "message_key": alert.message_key, - "message_params_json": alert.message_params, - "target_type": alert.target_type.value if alert.target_type else None, - "target_id": alert.target_id, - "media_id": str(media_id) if media_id else None, - "media_season_number": alert.media.season_number if alert.media else None, - "media_title": alert.media.title if alert.media else None, - "media_year": alert.media.year if alert.media else None, - "task_id": alert.task_id, - "action_id": alert.action_id, - "occurrence_count": alert.occurrence_count, - "first_seen_at": alert.first_seen_at.isoformat(), - "last_seen_at": alert.last_seen_at.isoformat(), - "acknowledged_at": alert.acknowledged_at.isoformat() if alert.acknowledged_at else None, - "resolved_at": alert.resolved_at.isoformat() if alert.resolved_at else None, - "created_at": alert.created_at.isoformat(), - "updated_at": alert.updated_at.isoformat(), - } - - def find_by_fingerprint(self, fingerprint: str) -> AlertRecord | None: - with SessionLocal() as session: - row = session.execute(select(AlertORM).where(AlertORM.fingerprint == fingerprint)).scalar_one_or_none() - return self._to_model(row) if row else None - - def find_by_id(self, alert_id: str) -> AlertRecord | None: - with SessionLocal() as session: - row = session.get(AlertORM, alert_id) - return self._to_model(row) if row else None - - def upsert(self, alert: AlertRecord) -> AlertRecord: - values = self._values(alert) - with SessionLocal() as session: - row = session.get(AlertORM, alert.id) - if not row: - row = session.execute(select(AlertORM).where(AlertORM.fingerprint == alert.fingerprint)).scalar_one_or_none() - if row: - for key, value in values.items(): - setattr(row, key, value) - else: - session.add(AlertORM(id=alert.id, **values)) - session.commit() - return alert - - def list_active(self, *, include_acknowledged: bool = False) -> list[AlertRecord]: - with SessionLocal() as session: - stmt = select(AlertORM).where(AlertORM.status == AlertStatus.active.value) - if not include_acknowledged: - stmt = stmt.where(AlertORM.acknowledged_at.is_(None)) - rows = session.execute(stmt.order_by(desc(AlertORM.last_seen_at))).scalars().all() - return [self._to_model(row) for row in rows] - - def list_active_all(self) -> list[AlertRecord]: - with SessionLocal() as session: - rows = session.execute( - select(AlertORM) - .where(AlertORM.status == AlertStatus.active.value) - .order_by(desc(AlertORM.last_seen_at)) - ).scalars().all() - return [self._to_model(row) for row in rows] - - def acknowledge(self, alert_id: str, acknowledged_at: datetime) -> AlertRecord | None: - alert = self.find_by_id(alert_id) - if not alert: - return None - alert.acknowledged_at = acknowledged_at - alert.updated_at = acknowledged_at - return self.upsert(alert) - - def resolve(self, fingerprint: str, resolved_at: datetime) -> AlertRecord | None: - alert = self.find_by_fingerprint(fingerprint) - if not alert: - return None - alert.status = AlertStatus.resolved - alert.resolved_at = resolved_at - alert.updated_at = resolved_at - return self.upsert(alert) diff --git a/backend/app/db/repositories/event_repository.py b/backend/app/db/repositories/event_repository.py index d964a44..4b6d8d2 100644 --- a/backend/app/db/repositories/event_repository.py +++ b/backend/app/db/repositories/event_repository.py @@ -3,9 +3,9 @@ import json from datetime import datetime -from sqlalchemy import delete, desc, func, not_, or_, select +from sqlalchemy import delete, desc, func, insert, not_, or_, select -from app.db.sql.models import EventORM +from app.db.sql.models import EventAcknowledgementORM, EventORM from app.db.sql.session import SessionLocal from app.schemas.media_id import MediaID from app.schemas.domain.event import Event, EventLevel, EventSource, EventType @@ -43,6 +43,7 @@ def _build_filtered_stmt( action_id: str | None = None, keyword: str | None = None, excluded_type_prefixes: tuple[str, ...] = (), + acknowledged: bool | None = None, ): stmt = select(EventORM) if media_id: @@ -69,6 +70,13 @@ def _build_filtered_stmt( stmt = stmt.where( not_(or_(*[EventORM.type.like(f"{prefix}%") for prefix in excluded_type_prefixes])) ) + if acknowledged is not None: + ack_exists = ( + select(EventAcknowledgementORM.event_id) + .where(EventAcknowledgementORM.event_id == EventORM.id) + .exists() + ) + stmt = stmt.where(ack_exists if acknowledged else not_(ack_exists)) return stmt @staticmethod @@ -165,6 +173,7 @@ def list_filtered( action_id: str | None = None, keyword: str | None = None, excluded_type_prefixes: tuple[str, ...] = (), + acknowledged: bool | None = None, ) -> list[Event]: with SessionLocal() as session: stmt = self._build_filtered_stmt( @@ -179,6 +188,7 @@ def list_filtered( action_id=action_id, keyword=keyword, excluded_type_prefixes=excluded_type_prefixes, + acknowledged=acknowledged, ).order_by(desc(EventORM.ts)) rows = session.execute(stmt).scalars().all() return [self._to_model(row) for row in rows] @@ -199,6 +209,7 @@ def list_filtered_page( action_id: str | None = None, keyword: str | None = None, excluded_type_prefixes: tuple[str, ...] = (), + acknowledged: bool | None = None, ) -> tuple[int, list[Event]]: with SessionLocal() as session: stmt = self._build_filtered_stmt( @@ -213,6 +224,7 @@ def list_filtered_page( action_id=action_id, keyword=keyword, excluded_type_prefixes=excluded_type_prefixes, + acknowledged=acknowledged, ) total = int( @@ -277,6 +289,42 @@ def get_by_id(self, event_id: str) -> Event | None: row = session.get(EventORM, event_id) return self._to_model(row) if row else None + def acknowledge_event(self, event_id: str) -> bool: + acknowledged_at = datetime.now().isoformat() + with SessionLocal() as session: + event = session.get(EventORM, event_id) + if event is None: + return False + acknowledgement = session.get(EventAcknowledgementORM, event_id) + if acknowledgement is None: + session.add(EventAcknowledgementORM(event_id=event_id, acknowledged_at=acknowledged_at)) + session.commit() + return True + + def acknowledge_attention_events(self) -> int: + acknowledged_at = datetime.now().isoformat() + with SessionLocal() as session: + stmt = ( + select(EventORM.id) + .where(EventORM.level.in_([EventLevel.warning.value, EventLevel.error.value])) + .where( + not_( + select(EventAcknowledgementORM.event_id) + .where(EventAcknowledgementORM.event_id == EventORM.id) + .exists() + ) + ) + ) + event_ids = session.execute(stmt).scalars().all() + if not event_ids: + return 0 + session.execute( + insert(EventAcknowledgementORM), + [{"event_id": event_id, "acknowledged_at": acknowledged_at} for event_id in event_ids], + ) + session.commit() + return len(event_ids) + def prune_to_limit(self, max_records: int) -> int: limit = int(max_records or 0) if limit <= 0: diff --git a/backend/app/db/sql/models.py b/backend/app/db/sql/models.py index 1574332..59367bf 100644 --- a/backend/app/db/sql/models.py +++ b/backend/app/db/sql/models.py @@ -378,38 +378,6 @@ class ActionORM(Base): ) -class AlertORM(Base): - __tablename__ = "alerts" - - id: Mapped[str] = mapped_column(Text, primary_key=True) - fingerprint: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) - status: Mapped[str] = mapped_column(Text, nullable=False, index=True) - severity: Mapped[str] = mapped_column(Text, nullable=False, index=True) - category: Mapped[str] = mapped_column(Text, nullable=False, index=True) - message_key: Mapped[str] = mapped_column(Text, nullable=False) - message_params_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) - target_type: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - target_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - media_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - media_season_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) - media_title: Mapped[str | None] = mapped_column(Text, nullable=True) - media_year: Mapped[int | None] = mapped_column(Integer, nullable=True) - task_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - action_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - occurrence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - first_seen_at: Mapped[str] = mapped_column(Text, nullable=False, index=True) - last_seen_at: Mapped[str] = mapped_column(Text, nullable=False, index=True) - acknowledged_at: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - resolved_at: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) - created_at: Mapped[str] = mapped_column(Text, nullable=False) - updated_at: Mapped[str] = mapped_column(Text, nullable=False, index=True) - - __table_args__ = ( - Index("ix_alerts_status_ack_severity", "status", "acknowledged_at", "severity"), - Index("ix_alerts_target", "target_type", "target_id"), - ) - - class EventORM(Base): __tablename__ = "events" @@ -440,6 +408,13 @@ class EventORM(Base): ) +class EventAcknowledgementORM(Base): + __tablename__ = "event_acknowledgements" + + event_id: Mapped[str] = mapped_column(Text, primary_key=True) + acknowledged_at: Mapped[str] = mapped_column(Text, nullable=False, index=True) + + class SchedulerRuntimeORM(Base): __tablename__ = "scheduler_runtime" diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py index aff54b8..9655476 100644 --- a/backend/app/schemas/config.py +++ b/backend/app/schemas/config.py @@ -386,6 +386,21 @@ class AuthAddonConfig(BaseModel): providers: list[OIDCAuthProviderConfig] = Field(default_factory=list) +DEFAULT_NOTIFICATION_EVENT_PATTERNS = [ + "download.completed", + "download.failed", + "download.task.downloader_change_failed", + "download.task.storage_change_failed", + "media.import.completed", + "media.import.failed", + "library.file.missing", + "follow.*", + "subscription.ended.*", + "media_server_sync.failed", + "danmu.generate.failed", +] + + class NotificationChannelConfig(BaseModel): model_config = ConfigDict(extra='ignore') @@ -394,7 +409,7 @@ class NotificationChannelConfig(BaseModel): name: str = "" enabled: bool = True event_patterns: list[str] = Field( - default_factory=lambda: ["subscription.*", "follow.*", "media.*", "download.*"] + default_factory=lambda: list(DEFAULT_NOTIFICATION_EVENT_PATTERNS) ) levels: list[str] = Field(default_factory=list) @@ -499,7 +514,7 @@ def migrate_legacy_addons(cls, value: object) -> object: "type": "telegram", "name": bot["name"] if type(bot) is dict and "name" in bot else "", "enabled": bool(bot["enabled"]) if type(bot) is dict and "enabled" in bot else True, - "event_patterns": list(bot["event_patterns"]) if type(bot) is dict and "event_patterns" in bot and type(bot["event_patterns"]) is list else ["subscription.*", "follow.*", "media.*", "download.*"], + "event_patterns": list(bot["event_patterns"]) if type(bot) is dict and "event_patterns" in bot and type(bot["event_patterns"]) is list else list(DEFAULT_NOTIFICATION_EVENT_PATTERNS), "levels": list(bot["levels"]) if type(bot) is dict and "levels" in bot and type(bot["levels"]) is list else [], "bot_token": bot["bot_token"] if type(bot) is dict and "bot_token" in bot else "", "chat_id": bot["chat_id"] if type(bot) is dict and "chat_id" in bot else "", @@ -530,6 +545,8 @@ class ServicesConfig(BaseModel): class SystemConfig(BaseModel): model_config = ConfigDict(extra='ignore') + locale: str = "zh-CN" + public_base_url: str = "" cache: CacheConfig = Field(default_factory=CacheConfig) scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig) download: DownloadConfig = Field(default_factory=DownloadConfig) @@ -537,6 +554,24 @@ class SystemConfig(BaseModel): logging: LoggingConfig = Field(default_factory=LoggingConfig) onboarding_enabled: bool = False + @field_validator("locale", mode="before") + @classmethod + def validate_locale(cls, value: object) -> str: + normalized = str(value or "zh-CN").replace("_", "-").strip() + if normalized.lower().startswith("en"): + return "en-US" + return "zh-CN" + + @field_validator("public_base_url", mode="before") + @classmethod + def validate_public_base_url(cls, value: object) -> str: + normalized = str(value or "").strip().rstrip("/") + if not normalized: + return "" + if not (normalized.startswith("http://") or normalized.startswith("https://")): + return "" + return normalized + class AppConfig(BaseModel): model_config = ConfigDict(extra='allow', arbitrary_types_allowed=True) @@ -545,6 +580,8 @@ class AppConfig(BaseModel): browse_source: BrowseSource = BrowseSource.douban douban: DoubanConfig = Field(default_factory=DoubanConfig) themoviedb: TMDBConfig = Field(default_factory=TMDBConfig) + locale: str = "zh-CN" + public_base_url: str = "" # Internal note. indexers: list[IndexerProviderConfig] = Field(default_factory=list) @@ -562,6 +599,16 @@ class AppConfig(BaseModel): default_movie_template_id: str | None = None default_tv_template_id: str | None = None + @field_validator("locale", mode="before") + @classmethod + def validate_locale(cls, value: object) -> str: + return SystemConfig.validate_locale(value) + + @field_validator("public_base_url", mode="before") + @classmethod + def validate_public_base_url(cls, value: object) -> str: + return SystemConfig.validate_public_base_url(value) + # Internal note. cache: CacheConfig = Field(default_factory=CacheConfig) download: DownloadConfig = Field(default_factory=DownloadConfig) diff --git a/backend/app/schemas/constants/event_types.py b/backend/app/schemas/constants/event_types.py index 6e12e08..292a6a8 100644 --- a/backend/app/schemas/constants/event_types.py +++ b/backend/app/schemas/constants/event_types.py @@ -2,29 +2,19 @@ class EventTypes: - DOWNLOAD_STARTED = EventType.DOWNLOAD_STARTED DOWNLOAD_COMPLETED = EventType.DOWNLOAD_COMPLETED DOWNLOAD_FAILED = EventType.DOWNLOAD_FAILED - DOWNLOAD_TASK_DOWNLOADER_CHANGED = EventType.DOWNLOAD_TASK_DOWNLOADER_CHANGED DOWNLOAD_TASK_DOWNLOADER_CHANGE_FAILED = EventType.DOWNLOAD_TASK_DOWNLOADER_CHANGE_FAILED - DOWNLOAD_TASK_STORAGE_CHANGE_STARTED = EventType.DOWNLOAD_TASK_STORAGE_CHANGE_STARTED - DOWNLOAD_TASK_STORAGE_CHANGED = EventType.DOWNLOAD_TASK_STORAGE_CHANGED DOWNLOAD_TASK_STORAGE_CHANGE_FAILED = EventType.DOWNLOAD_TASK_STORAGE_CHANGE_FAILED - MEDIA_IMPORT_STARTED = EventType.MEDIA_IMPORT_STARTED MEDIA_IMPORT_COMPLETED = EventType.MEDIA_IMPORT_COMPLETED MEDIA_IMPORT_FAILED = EventType.MEDIA_IMPORT_FAILED - MEDIA_SERVER_SYNC_STARTED = EventType.MEDIA_SERVER_SYNC_STARTED MEDIA_SERVER_SYNC_COMPLETED = EventType.MEDIA_SERVER_SYNC_COMPLETED MEDIA_SERVER_SYNC_FAILED = EventType.MEDIA_SERVER_SYNC_FAILED - DANMU_GENERATE_STARTED = EventType.DANMU_GENERATE_STARTED DANMU_GENERATE_COMPLETED = EventType.DANMU_GENERATE_COMPLETED DANMU_GENERATE_FAILED = EventType.DANMU_GENERATE_FAILED MEDIA_DELETED = EventType.MEDIA_DELETED LIBRARY_FILE_MISSING = EventType.LIBRARY_FILE_MISSING - SUBSCRIPTION_ENABLED = EventType.SUBSCRIPTION_ENABLED - SUBSCRIPTION_DISABLED = EventType.SUBSCRIPTION_DISABLED - SUBSCRIPTION_ENDED_MANUAL = EventType.SUBSCRIPTION_ENDED_MANUAL SUBSCRIPTION_ENDED_MOVIE_COMPLETED = EventType.SUBSCRIPTION_ENDED_MOVIE_COMPLETED SUBSCRIPTION_ENDED_MOVIE_DOWNLOADING_COMPLETED = EventType.SUBSCRIPTION_ENDED_MOVIE_DOWNLOADING_COMPLETED SUBSCRIPTION_ENDED_MOVIE_TARGET_COMPLETED = EventType.SUBSCRIPTION_ENDED_MOVIE_TARGET_COMPLETED @@ -32,16 +22,10 @@ class EventTypes: SUBSCRIPTION_ENDED_TV_UPGRADE_COMPLETED = EventType.SUBSCRIPTION_ENDED_TV_UPGRADE_COMPLETED SUBSCRIPTION_ENDED_TV_TARGET_COMPLETED = EventType.SUBSCRIPTION_ENDED_TV_TARGET_COMPLETED - FOLLOW_ENABLED = EventType.FOLLOW_ENABLED - FOLLOW_DISABLED = EventType.FOLLOW_DISABLED FOLLOW_RELEASED = EventType.FOLLOW_RELEASED FOLLOW_DIGITAL_RELEASED = EventType.FOLLOW_DIGITAL_RELEASED FOLLOW_PHYSICAL_RELEASED = EventType.FOLLOW_PHYSICAL_RELEASED - SUBSCRIPTION_RUN_COMPLETED = EventType.SUBSCRIPTION_RUN_COMPLETED - SUBSCRIPTION_RUN_FAILED = EventType.SUBSCRIPTION_RUN_FAILED - PILOT_EPISODE_QUEUED = EventType.PILOT_EPISODE_QUEUED - ADDON_RUN_STARTED = EventType.ADDON_RUN_STARTED ADDON_RUN_COMPLETED = EventType.ADDON_RUN_COMPLETED ADDON_RUN_FAILED = EventType.ADDON_RUN_FAILED diff --git a/backend/app/schemas/domain/addon_events.py b/backend/app/schemas/domain/addon_events.py index f6d0a2a..8af059d 100644 --- a/backend/app/schemas/domain/addon_events.py +++ b/backend/app/schemas/domain/addon_events.py @@ -16,6 +16,7 @@ class DownloadTaskEventMeta(BaseModel): torrent_hash: str | None = None progress: float | None = None selected_files: list[int] = Field(default_factory=list) + selected_episodes: list[int] = Field(default_factory=list) total_files: int | None = None diff --git a/backend/app/schemas/domain/alert.py b/backend/app/schemas/domain/alert.py deleted file mode 100644 index ee15969..0000000 --- a/backend/app/schemas/domain/alert.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -import uuid -from datetime import datetime -from enum import Enum - -from pydantic import BaseModel, Field - -from app.schemas.media_id import MediaID -from app.schemas.domain.action import ActionRecord -from app.schemas.domain.media import MediaIdentity - - -class AlertStatus(str, Enum): - active = "active" - resolved = "resolved" - - -class AlertSeverity(str, Enum): - info = "info" - warning = "warning" - error = "error" - - -class AlertCategory(str, Enum): - task_transfer = "task_transfer" - danmu_generate = "danmu_generate" - media_server_sync = "media_server_sync" - notification_send = "notification_send" - indexer_health = "indexer_health" - - -class AlertTargetType(str, Enum): - task = "task" - library_file = "library_file" - notification_channel = "notification_channel" - danmu_sidecar = "danmu_sidecar" - indexer_site = "indexer_site" - - -class AlertBellState(str, Enum): - idle = "idle" - running = "running" - error = "error" - - -class AlertRecord(BaseModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - fingerprint: str - status: AlertStatus = AlertStatus.active - severity: AlertSeverity - category: AlertCategory - message_key: str - message_params: dict[str, str] = Field(default_factory=dict) - target_type: AlertTargetType | None = None - target_id: str | None = None - media: MediaIdentity | None = None - media_id: MediaID | None = None - task_id: str | None = None - action_id: str | None = None - occurrence_count: int = 1 - first_seen_at: datetime = Field(default_factory=datetime.now) - last_seen_at: datetime = Field(default_factory=datetime.now) - acknowledged_at: datetime | None = None - resolved_at: datetime | None = None - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - - -class AlertRaiseRequest(BaseModel): - fingerprint: str - severity: AlertSeverity - category: AlertCategory - message_key: str - message_params: dict[str, str] = Field(default_factory=dict) - target_type: AlertTargetType | None = None - target_id: str | None = None - media: MediaIdentity | None = None - media_id: MediaID | None = None - task_id: str | None = None - action_id: str | None = None - - -class AlertResolveRequest(BaseModel): - fingerprint: str - - -class AlertSummary(BaseModel): - active_count: int = 0 - active_action_count: int = 0 - unacknowledged_error_count: int = 0 - unacknowledged_warning_count: int = 0 - bell_state: AlertBellState = AlertBellState.idle - - -class AlertCenterResponse(BaseModel): - summary: AlertSummary - active_actions: list[ActionRecord] = Field(default_factory=list) - alerts: list[AlertRecord] = Field(default_factory=list) diff --git a/backend/app/schemas/domain/event.py b/backend/app/schemas/domain/event.py index 6d1cd86..7bd83a2 100644 --- a/backend/app/schemas/domain/event.py +++ b/backend/app/schemas/domain/event.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from app.schemas.media_id import MediaID +from app.schemas.domain.action import ActionRecord from app.schemas.domain.media import MediaIdentity @@ -28,42 +29,27 @@ class EventSource(str, Enum): class EventType(str, Enum): - DOWNLOAD_STARTED = "download.started" DOWNLOAD_COMPLETED = "download.completed" DOWNLOAD_FAILED = "download.failed" - DOWNLOAD_TASK_DOWNLOADER_CHANGED = "download.task.downloader_changed" DOWNLOAD_TASK_DOWNLOADER_CHANGE_FAILED = "download.task.downloader_change_failed" - DOWNLOAD_TASK_STORAGE_CHANGE_STARTED = "download.task.storage_change_started" - DOWNLOAD_TASK_STORAGE_CHANGED = "download.task.storage_changed" DOWNLOAD_TASK_STORAGE_CHANGE_FAILED = "download.task.storage_change_failed" - MEDIA_IMPORT_STARTED = "media.import.started" MEDIA_IMPORT_COMPLETED = "media.import.completed" MEDIA_IMPORT_FAILED = "media.import.failed" - MEDIA_SERVER_SYNC_STARTED = "media_server_sync.started" MEDIA_SERVER_SYNC_COMPLETED = "media_server_sync.completed" MEDIA_SERVER_SYNC_FAILED = "media_server_sync.failed" - DANMU_GENERATE_STARTED = "danmu.generate.started" DANMU_GENERATE_COMPLETED = "danmu.generate.completed" DANMU_GENERATE_FAILED = "danmu.generate.failed" MEDIA_DELETED = "media.deleted" LIBRARY_FILE_MISSING = "library.file.missing" - SUBSCRIPTION_ENABLED = "subscription.enabled" - SUBSCRIPTION_DISABLED = "subscription.disabled" - SUBSCRIPTION_ENDED_MANUAL = "subscription.ended.manual" SUBSCRIPTION_ENDED_MOVIE_COMPLETED = "subscription.ended.movie_completed" SUBSCRIPTION_ENDED_MOVIE_DOWNLOADING_COMPLETED = "subscription.ended.movie_downloading_completed" SUBSCRIPTION_ENDED_MOVIE_TARGET_COMPLETED = "subscription.ended.movie_target_completed" SUBSCRIPTION_ENDED_TV_COMPLETED = "subscription.ended.tv_completed" SUBSCRIPTION_ENDED_TV_UPGRADE_COMPLETED = "subscription.ended.tv_upgrade_completed" SUBSCRIPTION_ENDED_TV_TARGET_COMPLETED = "subscription.ended.tv_target_completed" - FOLLOW_ENABLED = "follow.enabled" - FOLLOW_DISABLED = "follow.disabled" FOLLOW_RELEASED = "follow.released" FOLLOW_DIGITAL_RELEASED = "follow.digital_released" FOLLOW_PHYSICAL_RELEASED = "follow.physical_released" - SUBSCRIPTION_RUN_COMPLETED = "subscription.run.completed" - SUBSCRIPTION_RUN_FAILED = "subscription.run.failed" - PILOT_EPISODE_QUEUED = "pilot.episode.queued" ADDON_RUN_STARTED = "addon.run.started" ADDON_RUN_COMPLETED = "addon.run.completed" ADDON_RUN_FAILED = "addon.run.failed" @@ -130,3 +116,23 @@ def media_title(self) -> str | None: @property def media_year(self) -> int | None: return self.media.year if self.media else None + + +class EventCenterBellState(str, Enum): + idle = "idle" + warning = "warning" + error = "error" + running = "running" + + +class EventCenterSummary(BaseModel): + active_action_count: int = 0 + warning_event_count: int = 0 + error_event_count: int = 0 + bell_state: EventCenterBellState = EventCenterBellState.idle + + +class EventCenterResponse(BaseModel): + summary: EventCenterSummary + active_actions: list[ActionRecord] = Field(default_factory=list) + events: list[Event] = Field(default_factory=list) diff --git a/backend/app/schemas/domain/event_meta.py b/backend/app/schemas/domain/event_meta.py index ee76726..8208552 100644 --- a/backend/app/schemas/domain/event_meta.py +++ b/backend/app/schemas/domain/event_meta.py @@ -3,11 +3,6 @@ from pydantic import BaseModel -class SubscriptionRunCompletedEventMeta(BaseModel): - checked: int - added: int - - class SubscriptionEnabledEventMeta(BaseModel): follow_auto_enabled: bool diff --git a/backend/app/schemas/exception/exceptions.py b/backend/app/schemas/exception/exceptions.py index ab9e90b..bd0da5c 100644 --- a/backend/app/schemas/exception/exceptions.py +++ b/backend/app/schemas/exception/exceptions.py @@ -35,11 +35,14 @@ def __init__(self, param: str, value: str): class TestConnectionException(AppException): - def __init__(self, service_name: str): + def __init__(self, service_name: str, reason: str | None = None): + params = {"service": service_name} + if reason: + params["reason"] = reason super().__init__( code=10002, - message_key="backendErrors.testConnectionFailed", - params={"service": service_name}, + message_key="backendErrors.testConnectionFailedWithReason" if reason else "backendErrors.testConnectionFailed", + params=params, ) diff --git a/backend/app/schemas/runtime/subscription_runtime.py b/backend/app/schemas/runtime/subscription_runtime.py index b333b3d..1d994d3 100644 --- a/backend/app/schemas/runtime/subscription_runtime.py +++ b/backend/app/schemas/runtime/subscription_runtime.py @@ -66,17 +66,6 @@ class SubscriptionRunOutcome(BaseModel): correlation_id: str | None = None warnings: list[SubscriptionSearchWarning] = Field(default_factory=list) - @property - def should_emit_failed(self) -> bool: - return self.status in { - SubscriptionRunOutcomeStatus.INVALID, - SubscriptionRunOutcomeStatus.QUEUE_FAILED, - } - - @property - def should_emit_completed(self) -> bool: - return self.status == SubscriptionRunOutcomeStatus.QUEUED and self.response.added > 0 - class SelectedSubscriptionResource(BaseModel): payload_name: str diff --git a/backend/app/services/application/workflows/danmu/service.py b/backend/app/services/application/workflows/danmu/service.py index c6f1546..a1888d7 100644 --- a/backend/app/services/application/workflows/danmu/service.py +++ b/backend/app/services/application/workflows/danmu/service.py @@ -276,7 +276,6 @@ async def _generate_for_video( event_task_id = task_id or (event.task_id if event else None) try: await self._mark_danmu_artifacts(library_file, video_path, config, LibraryFileArtifactStatus.pending) - emit_danmu_generate_event(EventType.DANMU_GENERATE_STARTED, media, video_path, episode_number, action_id, event_task_id) async with asyncio.timeout(DANMU_GENERATION_TIMEOUT_SECONDS): with action_context(action_id): result = await danmu_provider_service.fetch( diff --git a/backend/app/services/application/workflows/media_server_sync/season_runner.py b/backend/app/services/application/workflows/media_server_sync/season_runner.py index 34e1d15..136d25f 100644 --- a/backend/app/services/application/workflows/media_server_sync/season_runner.py +++ b/backend/app/services/application/workflows/media_server_sync/season_runner.py @@ -66,14 +66,6 @@ async def sync_one_season( needs.media_root_dir, LibraryFileArtifactStatus.pending, ) - emit_media_server_sync_events( - EventType.MEDIA_SERVER_SYNC_STARTED, - media, - needs.anchor_file or "", - needs.transfer_results, - media_server.id, - trigger="scheduler", - ) try: await self.apply_updates(media_server, media, needs.anchor_file, needs.transfer_results, needs.media_root_dir) except ValueError as exc: diff --git a/backend/app/services/application/workflows/media_server_sync/service.py b/backend/app/services/application/workflows/media_server_sync/service.py index 0357ed6..6f35298 100644 --- a/backend/app/services/application/workflows/media_server_sync/service.py +++ b/backend/app/services/application/workflows/media_server_sync/service.py @@ -111,15 +111,6 @@ async def handle_import_completed(self, event: Event) -> None: if existing_state is None or not existing_state.last_success_at else MediaServerChangeType.UPDATED ) - emit_media_server_sync_events( - EventType.MEDIA_SERVER_SYNC_STARTED, - media_info, - file_path, - transfer_results, - media_server.id, - trigger="import", - task_id=event.task_id, - ) await media_server_sync_pipeline.run( media_info, file_path, @@ -287,14 +278,6 @@ async def rerun_for_library_files(self, library_files: list[LibraryFile], *, sea str(media_root_dir), LibraryFileArtifactStatus.pending, ) - emit_media_server_sync_events( - EventType.MEDIA_SERVER_SYNC_STARTED, - media, - file_path, - grouped_targets, - media_server.id, - trigger="manual", - ) try: await media_server_sync_pipeline.run( media, diff --git a/backend/app/services/application/workflows/notifications/service.py b/backend/app/services/application/workflows/notifications/service.py index a0240ef..8ed02d2 100644 --- a/backend/app/services/application/workflows/notifications/service.py +++ b/backend/app/services/application/workflows/notifications/service.py @@ -15,12 +15,12 @@ ActionTrigger, ) from app.schemas.domain.action_meta import NotificationSendQueuedActionMeta -from app.schemas.domain.event import Event +from app.schemas.domain.event import Event, EventCreate, EventLevel, EventSource, EventType from app.services.application.events.consumer import event_matches_patterns from app.services.audit.action_catalog import ACTION_NAME_NOTIFICATION_SEND from app.services.audit.action_service import action_service +from app.services.audit.event_service import event_service from app.services.config.settings_service import settings_service -from app.services.domain.alerts.workflow_alerts import raise_notification_alert, resolve_notification_alert from app.services.platform.notification_channel_service import notification_channel_service logger = logging.getLogger("app.application.notifications") @@ -44,21 +44,23 @@ async def handle_event(self, event: Event) -> None: event.type, ) action_service.mark_failed(action.id, error=str(exc)) - raise_notification_alert( - channel_id=channel.id, - channel_name=channel.name, - channel_type=channel.type, - event_type=event.type.value, - event_id=event.id, - media=event.media, - media_id=event.media_id, - task_id=event.task_id, + self._emit_notification_event( + channel=channel, + trigger_event=event, action_id=action.id, - error=str(exc), + event_type=EventType.NOTIFICATION_FAILED, + level=EventLevel.error, + reason=str(exc), ) continue action_service.mark_completed(action.id) - resolve_notification_alert(channel.id) + self._emit_notification_event( + channel=channel, + trigger_event=event, + action_id=action.id, + event_type=EventType.NOTIFICATION_SENT, + level=EventLevel.info, + ) def _should_send(self, channel: NotificationChannelConfig, event: Event) -> bool: if not channel.enabled: @@ -95,5 +97,40 @@ def _create_notify_action(self, channel: NotificationChannelConfig, event: Event ), ) + def _emit_notification_event( + self, + *, + channel: NotificationChannelConfig, + trigger_event: Event, + action_id: str, + event_type: EventType, + level: EventLevel, + reason: str = "", + ) -> None: + channel_label = channel.name or channel.type + try: + event_service.emit( + EventCreate( + type=event_type, + level=level, + task_id=trigger_event.task_id, + subscription_id=trigger_event.subscription_id, + source=EventSource.addon, + addon_id=channel.id, + addon_name="notifications", + correlation_id=trigger_event.correlation_id, + action_id=action_id, + message_params={ + "channel": channel_label, + "channel_type": channel.type, + "trigger_event_id": trigger_event.id, + "trigger_event_type": trigger_event.type.value, + "reason": reason, + }, + ) + ) + except Exception: + logger.exception("Failed to emit notification result event for channel=%s", channel_label) + notification_application_service = NotificationApplicationService() diff --git a/backend/app/services/application/workflows/scheduled_transfer/service.py b/backend/app/services/application/workflows/scheduled_transfer/service.py index 41a846c..92e5747 100644 --- a/backend/app/services/application/workflows/scheduled_transfer/service.py +++ b/backend/app/services/application/workflows/scheduled_transfer/service.py @@ -5,6 +5,7 @@ from app.schemas.domain.download import BatchJobResult, TaskStatus from app.services.application.commands.service import CommandConflictException, command_service from app.services.domain.download import download_service +from app.services.domain.transfer.execution import missing_transfer_source_paths logger = logging.getLogger("app.services.scheduled_transfer_command") @@ -22,6 +23,14 @@ async def enqueue_finished_tasks(self) -> BatchJobResult: for task in finished_tasks: processed += 1 try: + missing_sources = await missing_transfer_source_paths(task) + if missing_sources: + logger.info( + "Scheduled transfer delayed until source files are visible: task=%s missing=%s", + task.id, + missing_sources[:3], + ) + continue await command_service.create_command( CommandCreateRequest( type=CommandType.TASK_TRANSFER, diff --git a/backend/app/services/application/workflows/subscription/pilot.py b/backend/app/services/application/workflows/subscription/pilot.py index f7cd322..4c1918e 100644 --- a/backend/app/services/application/workflows/subscription/pilot.py +++ b/backend/app/services/application/workflows/subscription/pilot.py @@ -4,10 +4,8 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from app.schemas.constants.event_types import EventTypes from app.schemas.domain.command import CommandCreateRequest, CommandInitiator, CommandType, PilotEpisodeCommandRequestPayload from app.schemas.domain.download import DownloadTaskCreateInput -from app.schemas.domain.event import EventActor, EventEntityRef, EventLevel, EventSource, MediaEventCreate from app.schemas.domain.media import MediaExecutionSnapshot, MediaTarget from app.schemas.domain.media_types import MediaType from app.schemas.domain.resource_search import MediaSearchQuery @@ -21,7 +19,6 @@ select_resources as select_download_resources, ) from app.services.application.commands.service import command_service -from app.services.audit.event_service import event_service from app.services.config.settings_service import settings_service from app.services.domain.download import download_service from app.services.domain.library.service import library_service @@ -68,22 +65,6 @@ async def queue( media = command.payload.media if media is None: raise DownloadException("backendErrors.mediaExecutionSnapshotRequired") - event_service.emit_media( - MediaEventCreate( - type=EventTypes.PILOT_EPISODE_QUEUED, - level=EventLevel.info, - message_params={ - "directory_id": effective_config.directory_id or "", - "site_count": str(len(effective_config.sites or [])), - }, - media=media, - actor=EventActor.user, - source=EventSource.base, - entities=[EventEntityRef(type="command", id=command.id)], - correlation_id=command.id, - action_id=command.id, - ), - ) return command async def execute( diff --git a/backend/app/services/application/workflows/subscription/run.py b/backend/app/services/application/workflows/subscription/run.py index 3f53633..e7e9880 100644 --- a/backend/app/services/application/workflows/subscription/run.py +++ b/backend/app/services/application/workflows/subscription/run.py @@ -5,11 +5,8 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from app.schemas.constants.event_types import EventTypes from app.schemas.domain.command import CommandCreateRequest, CommandInitiator from app.schemas.domain.download import DownloadTaskCreateInput -from app.schemas.domain.event import EventActor, EventEntityRef, EventLevel, EventSource, MediaEventCreate -from app.schemas.domain.event_meta import SubscriptionRunCompletedEventMeta from app.schemas.domain.media import MediaTarget from app.schemas.domain.media_subscription_state import MediaSubscriptionState, SubscriptionEndTrigger from app.schemas.domain.resource_search import MediaSearchQuery, Resource @@ -26,7 +23,6 @@ SubscriptionRunPlan, ) from app.schemas.runtime.subscription_lifecycle import EndSubscriptionCommand, ResourceRunSelection, SubscriptionRunRecord -from app.services.audit.event_service import event_service from app.services.domain.media import media_service from app.services.domain.subscription.download_config_service import subscription_download_config_service from app.services.domain.resource.filtering import compute_preference_score @@ -68,7 +64,6 @@ async def run_one(self, sub: Subscription) -> SubscriptionRunResponse: resource_run_plan_service.validate_runtime_subscription(runtime_sub) checked_at = time.time() outcome = await self._run_one_outcome(runtime_sub) - self._emit_run_events(runtime_sub, outcome) upgrade_snapshot = await subscription_upgrade_baseline_service.resolve_for_subscription(runtime_sub) await subscription_store.save_run_record( @@ -307,51 +302,6 @@ async def _queue_selected_resources(self, runtime_sub: Subscription, plan: Subsc added += 1 return added - def _emit_run_events(self, runtime_sub: Subscription, outcome: SubscriptionRunOutcome) -> None: - if outcome.should_emit_failed: - self._emit_run_failed(runtime_sub, outcome) - if outcome.should_emit_completed: - self._emit_run_completed(runtime_sub, outcome) - - def _emit_run_failed(self, runtime_sub: Subscription, outcome: SubscriptionRunOutcome) -> None: - correlation_id = outcome.correlation_id or (outcome.plan.correlation_id if outcome.plan else runtime_sub.sub_id) - event_service.emit_media( - MediaEventCreate( - type=EventTypes.SUBSCRIPTION_RUN_FAILED, - level=EventLevel.error, - message_params={"reason_key": outcome.message_key or "subscriptionRunMessages.failed"}, - media=outcome.plan.media if outcome.plan else runtime_sub.media, - subscription_id=runtime_sub.sub_id, - actor=EventActor.system, - source=EventSource.base, - entities=[ - EventEntityRef(type="subscription", id=runtime_sub.sub_id), - EventEntityRef(type="job_run", id=correlation_id), - ], - correlation_id=correlation_id, - ), - ) - - def _emit_run_completed(self, runtime_sub: Subscription, outcome: SubscriptionRunOutcome) -> None: - if outcome.plan is None: - return - event_service.emit_media( - MediaEventCreate( - type=EventTypes.SUBSCRIPTION_RUN_COMPLETED, - level=EventLevel.info, - media=outcome.plan.media, - subscription_id=runtime_sub.sub_id, - actor=EventActor.system, - source=EventSource.base, - entities=[ - EventEntityRef(type="subscription", id=runtime_sub.sub_id), - EventEntityRef(type="job_run", id=outcome.plan.correlation_id), - ], - correlation_id=outcome.plan.correlation_id, - ), - meta=SubscriptionRunCompletedEventMeta(checked=outcome.response.checked, added=outcome.response.added), - ) - async def run_all(self) -> SubscriptionRunResponse: async with self._acquire_subscription_sweep() as acquired: if not acquired: diff --git a/backend/app/services/audit/event_message_builder.py b/backend/app/services/audit/event_message_builder.py index 813dfa5..4e1f7f7 100644 --- a/backend/app/services/audit/event_message_builder.py +++ b/backend/app/services/audit/event_message_builder.py @@ -75,19 +75,6 @@ def _format_path_name(path: str | None) -> str | None: return Path(normalized).name or normalized -def build_download_started_message(task: TaskData, downloader_name: str | None = None) -> str: - title = _display_name(task.context.resource_title, task.context.media.title) - total_files = len(task.metadata.files) if task.metadata and task.metadata.files else None - hash_prefix = _format_hash_prefix(task.torrent_hash) - details = [ - f"site {task.context.indexer}" if task.context.indexer else "", - f"downloader {downloader_name}" if downloader_name else "", - _format_file_selection(task.context.selected_files, total_files), - f"hash {hash_prefix}" if hash_prefix else "", - ] - return f'Download started for "{title}"{_join_details(details)}' - - def build_download_completed_message(task: TaskData, downloader_name: str | None = None) -> str: title = _display_name( task.context.resource_title, @@ -165,49 +152,6 @@ def build_follow_physical_released_message(media: MediaIdentity, air_date: str | return f"Physical release is available{_join_details(details)}" -def build_subscription_run_completed_message(*, checked: int, added: int) -> str: - return f"Subscription check completed ({checked} candidates matched, {added} download tasks created)" - - -def build_subscription_run_failed_message(reason: str) -> str: - normalized = reason.strip() - if not normalized: - return "Subscription run failed" - if normalized.startswith("Subscription run failed"): - return normalized - return f"Subscription run failed: {normalized}" - - -def build_pilot_episode_queued_message( - media: MediaIdentity, - *, - directory_id: str, - sites: list[str] | None = None, -) -> str: - details = [ - f"directory {directory_id}" if directory_id else "", - f"{len(sites)} sites" if sites else "default sites", - ] - action_label = "download task" if media.media_id.media_type.value == "movie" else "pilot task" - return f"{action_label.capitalize()} submitted{_join_details(details)}" - - -def build_subscription_enabled_message(media: MediaIdentity) -> str: - return "Subscription auto-download enabled" - - -def build_subscription_disabled_message(media: MediaIdentity) -> str: - return "Subscription auto-download disabled" - - -def build_follow_enabled_message(media: MediaIdentity) -> str: - return "Follow reminder enabled" - - -def build_follow_disabled_message(media: MediaIdentity) -> str: - return "Follow reminder disabled" - - def build_subscription_ended_message(reason: SubscriptionEndReason) -> str: if reason == SubscriptionEndReason.MANUAL: return "Current subscription ended" diff --git a/backend/app/services/audit/event_message_i18n.py b/backend/app/services/audit/event_message_i18n.py index 8019313..c607814 100644 --- a/backend/app/services/audit/event_message_i18n.py +++ b/backend/app/services/audit/event_message_i18n.py @@ -9,42 +9,27 @@ EVENT_KEY_BY_TYPE = { - EventType.DOWNLOAD_STARTED: "eventMessages.downloadStarted", EventType.DOWNLOAD_COMPLETED: "eventMessages.downloadCompleted", EventType.DOWNLOAD_FAILED: "eventMessages.downloadFailed", - EventType.DOWNLOAD_TASK_DOWNLOADER_CHANGED: "eventMessages.downloadTaskDownloaderChanged", EventType.DOWNLOAD_TASK_DOWNLOADER_CHANGE_FAILED: "eventMessages.downloadTaskDownloaderChangeFailed", - EventType.DOWNLOAD_TASK_STORAGE_CHANGE_STARTED: "eventMessages.downloadTaskStorageChangeStarted", - EventType.DOWNLOAD_TASK_STORAGE_CHANGED: "eventMessages.downloadTaskStorageChanged", EventType.DOWNLOAD_TASK_STORAGE_CHANGE_FAILED: "eventMessages.downloadTaskStorageChangeFailed", - EventType.MEDIA_IMPORT_STARTED: "eventMessages.mediaImportStarted", EventType.MEDIA_IMPORT_COMPLETED: "eventMessages.mediaImportCompleted", EventType.MEDIA_IMPORT_FAILED: "eventMessages.mediaImportFailed", - EventType.MEDIA_SERVER_SYNC_STARTED: "eventMessages.mediaServerSyncStarted", EventType.MEDIA_SERVER_SYNC_COMPLETED: "eventMessages.mediaServerSyncCompleted", EventType.MEDIA_SERVER_SYNC_FAILED: "eventMessages.mediaServerSyncFailed", - EventType.DANMU_GENERATE_STARTED: "eventMessages.danmuGenerateStarted", EventType.DANMU_GENERATE_COMPLETED: "eventMessages.danmuGenerateCompleted", EventType.DANMU_GENERATE_FAILED: "eventMessages.danmuGenerateFailed", EventType.MEDIA_DELETED: "eventMessages.mediaDeleted", EventType.LIBRARY_FILE_MISSING: "eventMessages.libraryFileMissing", - EventType.SUBSCRIPTION_ENABLED: "eventMessages.subscriptionEnabled", - EventType.SUBSCRIPTION_DISABLED: "eventMessages.subscriptionDisabled", - EventType.SUBSCRIPTION_ENDED_MANUAL: "eventMessages.subscriptionEndedManual", EventType.SUBSCRIPTION_ENDED_MOVIE_COMPLETED: "eventMessages.subscriptionEndedMovieCompleted", EventType.SUBSCRIPTION_ENDED_MOVIE_DOWNLOADING_COMPLETED: "eventMessages.subscriptionEndedMovieDownloadingCompleted", EventType.SUBSCRIPTION_ENDED_MOVIE_TARGET_COMPLETED: "eventMessages.subscriptionEndedMovieTargetCompleted", EventType.SUBSCRIPTION_ENDED_TV_COMPLETED: "eventMessages.subscriptionEndedTvCompleted", EventType.SUBSCRIPTION_ENDED_TV_UPGRADE_COMPLETED: "eventMessages.subscriptionEndedTvUpgradeCompleted", EventType.SUBSCRIPTION_ENDED_TV_TARGET_COMPLETED: "eventMessages.subscriptionEndedTvTargetCompleted", - EventType.FOLLOW_ENABLED: "eventMessages.followEnabled", - EventType.FOLLOW_DISABLED: "eventMessages.followDisabled", EventType.FOLLOW_RELEASED: "eventMessages.followReleased", EventType.FOLLOW_DIGITAL_RELEASED: "eventMessages.followDigitalReleased", EventType.FOLLOW_PHYSICAL_RELEASED: "eventMessages.followPhysicalReleased", - EventType.SUBSCRIPTION_RUN_COMPLETED: "eventMessages.subscriptionRunCompleted", - EventType.SUBSCRIPTION_RUN_FAILED: "eventMessages.subscriptionRunFailed", - EventType.PILOT_EPISODE_QUEUED: "eventMessages.pilotEpisodeQueued", EventType.ADDON_RUN_STARTED: "eventMessages.addonRunStarted", EventType.ADDON_RUN_COMPLETED: "eventMessages.addonRunCompleted", EventType.ADDON_RUN_FAILED: "eventMessages.addonRunFailed", diff --git a/backend/app/services/audit/event_service.py b/backend/app/services/audit/event_service.py index de0970f..299feed 100644 --- a/backend/app/services/audit/event_service.py +++ b/backend/app/services/audit/event_service.py @@ -8,9 +8,21 @@ from app.core.action_context import get_current_action_id from app.db.repositories.event_repository import EventRepository from app.db.sql.serialization import to_jsonable +from app.schemas.domain.action import ActionStatus from app.schemas.media_id import MediaID -from app.schemas.domain.event import Event, EventCreate, EventLevel, EventSource, EventType, MediaEventCreate +from app.schemas.domain.event import ( + Event, + EventCenterBellState, + EventCenterResponse, + EventCenterSummary, + EventCreate, + EventLevel, + EventSource, + EventType, + MediaEventCreate, +) from app.services.application.events.dispatch import event_dispatch_service +from app.services.audit.action_service import action_service from app.services.audit.event_message_i18n import attach_event_message_i18n, event_message_key, event_message_params from app.services.audit.search_text_support import build_event_search_text from pydantic import BaseModel @@ -183,10 +195,51 @@ def list_events( sources=sources, addon_id=addon_id, action_id=action_id, - excluded_type_prefixes=NON_BUSINESS_EVENT_PREFIXES, ) return total, [attach_event_message_i18n(event) for event in events] + def get_center(self) -> EventCenterResponse: + active_action_count, active_actions = action_service.list_actions( + limit=50, + statuses=[ActionStatus.queued, ActionStatus.running], + ) + _attention_total, events = self.repo.list_filtered_page( + limit=50, + offset=0, + levels=[EventLevel.warning, EventLevel.error], + acknowledged=False, + ) + warning_event_count, _warning_sample = self.repo.list_filtered_page( + limit=1, + offset=0, + levels=[EventLevel.warning], + acknowledged=False, + ) + error_event_count, _error_sample = self.repo.list_filtered_page( + limit=1, + offset=0, + levels=[EventLevel.error], + acknowledged=False, + ) + if error_event_count > 0: + bell_state = EventCenterBellState.error + elif warning_event_count > 0: + bell_state = EventCenterBellState.warning + elif active_actions: + bell_state = EventCenterBellState.running + else: + bell_state = EventCenterBellState.idle + return EventCenterResponse( + summary=EventCenterSummary( + active_action_count=active_action_count, + warning_event_count=warning_event_count, + error_event_count=error_event_count, + bell_state=bell_state, + ), + active_actions=active_actions, + events=[attach_event_message_i18n(event) for event in events], + ) + def list_media_events(self, media_id: MediaID, limit: int = 50, season_number: int | None = None) -> list[Event]: events = [ event @@ -197,6 +250,12 @@ def list_media_events(self, media_id: MediaID, limit: int = 50, season_number: i items = events[:limit] if limit else events return [attach_event_message_i18n(event) for event in items] + def acknowledge_event(self, event_id: str) -> bool: + return self.repo.acknowledge_event(event_id) + + def acknowledge_attention_events(self) -> int: + return self.repo.acknowledge_attention_events() + def list_filter_options( self, media_id: MediaID | None = None, diff --git a/backend/app/services/audit/workflow_event_emitters.py b/backend/app/services/audit/workflow_event_emitters.py index 6e0e636..ad8897d 100644 --- a/backend/app/services/audit/workflow_event_emitters.py +++ b/backend/app/services/audit/workflow_event_emitters.py @@ -5,12 +5,19 @@ from app.schemas.domain.media import MediaFullInfo from app.schemas.domain.media_server_sync import MediaServerSyncTargetFile from app.services.audit.event_service import event_service -from app.services.domain.alerts.workflow_alerts import ( - raise_danmu_alert, - raise_media_server_sync_alert, - resolve_danmu_alert, - resolve_media_server_sync_alert, -) + +_INFO_DANMU_FAILURE_KEYS = { + "runtimeReasons.danmuDurationMismatch", + "runtimeReasons.danmuNotFound", +} + + +def _danmu_event_level(event_type: EventType, error_key: str | None) -> EventLevel: + if event_type != EventType.DANMU_GENERATE_FAILED: + return EventLevel.info + if error_key in _INFO_DANMU_FAILURE_KEYS: + return EventLevel.info + return EventLevel.error def emit_danmu_generate_event( @@ -27,22 +34,10 @@ def emit_danmu_generate_event( error: str = "", error_key: str | None = None, ) -> None: - if event_type == EventType.DANMU_GENERATE_FAILED: - raise_danmu_alert( - media=media, - video_path=video_path, - action_id=action_id, - task_id=task_id, - provider=provider, - error=error, - error_key=error_key, - ) - elif event_type == EventType.DANMU_GENERATE_COMPLETED: - resolve_danmu_alert(video_path) event_service.emit_media( MediaEventCreate( type=event_type, - level=EventLevel.error if event_type == EventType.DANMU_GENERATE_FAILED else EventLevel.info, + level=_danmu_event_level(event_type, error_key), media=media, task_id=task_id, actor=EventActor.system, @@ -79,16 +74,6 @@ def emit_media_server_sync_events( ) -> None: target_paths = _sync_event_paths(anchor_file, transfer_results) for path in target_paths: - if event_type == EventType.MEDIA_SERVER_SYNC_FAILED: - raise_media_server_sync_alert( - media=media, - file_path=path, - media_server_id=media_server_id, - task_id=task_id, - error=error, - ) - elif event_type == EventType.MEDIA_SERVER_SYNC_COMPLETED: - resolve_media_server_sync_alert(media=media, file_path=path, media_server_id=media_server_id) event_service.emit_media( MediaEventCreate( type=event_type, diff --git a/backend/app/services/config/indexer_client_settings.py b/backend/app/services/config/indexer_client_settings.py index fb7ec4b..b2009dd 100644 --- a/backend/app/services/config/indexer_client_settings.py +++ b/backend/app/services/config/indexer_client_settings.py @@ -9,10 +9,6 @@ from app.schemas.exception import ConfigurationException from app.schemas.runtime.indexer_runtime import IndexerSiteSearchOutcome from app.schemas.runtime.indexer_site_health import IndexerSiteHealthStatus -from app.services.domain.alerts.workflow_alerts import ( - raise_indexer_site_alert, - resolve_indexer_site_alert, -) class IndexerSiteHealthState: @@ -70,7 +66,6 @@ def record_success( notify_pending=False, client_type=client_type, ) - resolve_indexer_site_alert(indexer_id, site_id) return self._upsert(status) def record_failure( @@ -101,15 +96,6 @@ def record_failure( notify_pending=consecutive_failures >= 3, client_type=client_type, ) - if consecutive_failures >= 3: - raise_indexer_site_alert( - indexer_id=indexer_id, - indexer_name=indexer_name, - site_id=site_id, - site_name=site_name, - consecutive_failures=consecutive_failures, - error=error_message, - ) return self._upsert(status) def list_by_indexer(self, indexer_id: str) -> list[IndexerSiteHealthStatus]: diff --git a/backend/app/services/config/settings_service.py b/backend/app/services/config/settings_service.py index d576576..f1e108c 100644 --- a/backend/app/services/config/settings_service.py +++ b/backend/app/services/config/settings_service.py @@ -326,6 +326,8 @@ def get_system_tab_config(self): config = self._load_base_config() download = config.download.model_copy(update={"default_downloader_id": self.get_default_downloader_id()}) return { + "locale": config.locale, + "public_base_url": config.public_base_url, "auth": { "enabled": bool(config.auth.enabled), "session_ttl_seconds": config.auth.session_ttl_seconds, @@ -490,6 +492,8 @@ def _ensure_base_sections(self) -> None: def _build_system_config(self, config: AppConfig) -> SystemConfig: return SystemConfig( + locale=config.locale, + public_base_url=config.public_base_url, cache=config.cache, scheduler=config.scheduler, download=config.download, @@ -499,6 +503,8 @@ def _build_system_config(self, config: AppConfig) -> SystemConfig: ) def _apply_system_config(self, config: AppConfig, system_config: SystemConfig) -> None: + config.locale = system_config.locale + config.public_base_url = system_config.public_base_url config.cache, config.scheduler = system_config.cache, system_config.scheduler config.download = self._download_config_preserving_default(system_config.download) config.library, config.logging, config.onboarding_enabled = system_config.library, system_config.logging, system_config.onboarding_enabled diff --git a/backend/app/services/domain/alerts/__init__.py b/backend/app/services/domain/alerts/__init__.py deleted file mode 100644 index 10f9e7a..0000000 --- a/backend/app/services/domain/alerts/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from app.services.domain.alerts.service import AlertService, alert_service - -__all__ = ["AlertService", "alert_service"] diff --git a/backend/app/services/domain/alerts/service.py b/backend/app/services/domain/alerts/service.py deleted file mode 100644 index 8205869..0000000 --- a/backend/app/services/domain/alerts/service.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from app.db.repositories.alert_repository import AlertRepository -from app.schemas.domain.action import ActionStatus -from app.schemas.domain.alert import ( - AlertBellState, - AlertCenterResponse, - AlertRaiseRequest, - AlertRecord, - AlertResolveRequest, - AlertSeverity, - AlertStatus, - AlertSummary, -) -from app.services.audit.action_service import action_service - - -class AlertService: - def __init__(self) -> None: - self.repo = AlertRepository() - - def raise_alert(self, request: AlertRaiseRequest) -> AlertRecord: - now = datetime.now() - existing = self.repo.find_by_fingerprint(request.fingerprint) - if existing: - update = { - "status": AlertStatus.active, - "severity": request.severity, - "category": request.category, - "message_key": request.message_key, - "message_params": request.message_params, - "target_type": request.target_type, - "target_id": request.target_id, - "media": request.media, - "media_id": request.media_id, - "task_id": request.task_id, - "action_id": request.action_id, - "occurrence_count": existing.occurrence_count + 1, - "last_seen_at": now, - "resolved_at": None, - "updated_at": now, - } - update["acknowledged_at"] = None - return self.repo.upsert(existing.model_copy(update=update)) - - return self.repo.upsert( - AlertRecord( - fingerprint=request.fingerprint, - severity=request.severity, - category=request.category, - message_key=request.message_key, - message_params=request.message_params, - target_type=request.target_type, - target_id=request.target_id, - media=request.media, - media_id=request.media_id, - task_id=request.task_id, - action_id=request.action_id, - first_seen_at=now, - last_seen_at=now, - created_at=now, - updated_at=now, - ) - ) - - def resolve_alert(self, request: AlertResolveRequest) -> AlertRecord | None: - return self.repo.resolve(request.fingerprint, datetime.now()) - - def acknowledge_alert(self, alert_id: str) -> AlertRecord | None: - return self.repo.acknowledge(alert_id, datetime.now()) - - def get_center(self) -> AlertCenterResponse: - active_action_count, active_actions = action_service.list_actions( - limit=50, - statuses=[ActionStatus.queued, ActionStatus.running], - ) - visible_alerts = self.repo.list_active(include_acknowledged=False) - all_active_alerts = self.repo.list_active_all() - unacknowledged_error_count = sum( - 1 for alert in visible_alerts if alert.severity == AlertSeverity.error - ) - unacknowledged_warning_count = sum( - 1 for alert in visible_alerts if alert.severity == AlertSeverity.warning - ) - if unacknowledged_error_count > 0: - bell_state = AlertBellState.error - elif active_actions: - bell_state = AlertBellState.running - else: - bell_state = AlertBellState.idle - return AlertCenterResponse( - summary=AlertSummary( - active_count=len(all_active_alerts), - active_action_count=active_action_count, - unacknowledged_error_count=unacknowledged_error_count, - unacknowledged_warning_count=unacknowledged_warning_count, - bell_state=bell_state, - ), - active_actions=active_actions, - alerts=visible_alerts, - ) - - -alert_service = AlertService() diff --git a/backend/app/services/domain/alerts/workflow_alerts.py b/backend/app/services/domain/alerts/workflow_alerts.py deleted file mode 100644 index 77df50d..0000000 --- a/backend/app/services/domain/alerts/workflow_alerts.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from app.schemas.domain.alert import AlertCategory, AlertRaiseRequest, AlertResolveRequest, AlertSeverity, AlertTargetType -from app.schemas.domain.media import MediaIdentity -from app.schemas.media_id import MediaID -from app.services.domain.alerts import alert_service - - -def _nested_message_params(params: dict[str, str] | None) -> str: - if not params: - return "" - return json.dumps( - {str(key): str(value) for key, value in params.items() if value is not None}, - ensure_ascii=False, - sort_keys=True, - ) - - -def _reason_params(error: str = "", error_key: str | None = None) -> dict[str, str]: - if error_key: - return {"reason_key": error_key} - if error: - return {"reason": error} - return {"reason_key": "common.unknownError"} - - -def raise_danmu_alert( - *, - media: MediaIdentity, - video_path: Path, - action_id: str | None, - task_id: str | None, - provider: str | None = None, - error: str = "", - error_key: str | None = None, -) -> None: - target = video_path.name or str(video_path) - message_params = { - "target": target, - "video_path_name": target, - "video_path": str(video_path), - "provider": provider or "", - **_reason_params(error, error_key), - } - alert_service.raise_alert( - AlertRaiseRequest( - fingerprint=danmu_alert_fingerprint(video_path), - severity=AlertSeverity.error, - category=AlertCategory.danmu_generate, - message_key="alertMessages.danmuGenerateFailed", - message_params=message_params, - target_type=AlertTargetType.danmu_sidecar, - target_id=str(video_path), - media=media, - media_id=media.media_id, - task_id=task_id, - action_id=action_id, - ) - ) - - -def resolve_danmu_alert(video_path: Path) -> None: - alert_service.resolve_alert(AlertResolveRequest(fingerprint=danmu_alert_fingerprint(video_path))) - - -def danmu_alert_fingerprint(video_path: Path) -> str: - return f"danmu.generate:{video_path}" - - -def raise_media_server_sync_alert( - *, - media: MediaIdentity, - file_path: str, - media_server_id: str, - task_id: str | None, - error: str = "", -) -> None: - target = Path(file_path).name or file_path or str(media.media_id) - alert_service.raise_alert( - AlertRaiseRequest( - fingerprint=media_server_sync_alert_fingerprint(media_server_id, file_path, media.media_id), - severity=AlertSeverity.error, - category=AlertCategory.media_server_sync, - message_key="alertMessages.mediaServerSyncFailed", - message_params={ - "target": target, - "file_path_name": target, - "file_path": file_path, - "reason": error or "", - }, - target_type=AlertTargetType.library_file, - target_id=file_path or str(media.media_id), - media=media, - media_id=media.media_id, - task_id=task_id, - ) - ) - - -def resolve_media_server_sync_alert(*, media: MediaIdentity, file_path: str, media_server_id: str) -> None: - alert_service.resolve_alert( - AlertResolveRequest(fingerprint=media_server_sync_alert_fingerprint(media_server_id, file_path, media.media_id)) - ) - - -def media_server_sync_alert_fingerprint(media_server_id: str, file_path: str, media_id: MediaID) -> str: - target = file_path or str(media_id) - return f"media_server_sync:{media_server_id}:{target}" - - -def raise_notification_alert( - *, - channel_id: str, - channel_name: str, - channel_type: str, - event_type: str, - event_id: str, - media: MediaIdentity | None, - media_id: MediaID | None, - task_id: str | None, - action_id: str | None, - error: str, -) -> None: - target = channel_name or channel_id - alert_service.raise_alert( - AlertRaiseRequest( - fingerprint=notification_alert_fingerprint(channel_id), - severity=AlertSeverity.error, - category=AlertCategory.notification_send, - message_key="alertMessages.notificationSendFailed", - message_params={ - "target": target, - "channel": target, - "channel_type": channel_type, - "event_type": event_type, - "event_id": event_id, - "reason": error, - }, - target_type=AlertTargetType.notification_channel, - target_id=channel_id, - media=media, - media_id=media.media_id if media else media_id, - task_id=task_id, - action_id=action_id, - ) - ) - - -def resolve_notification_alert(channel_id: str) -> None: - alert_service.resolve_alert(AlertResolveRequest(fingerprint=notification_alert_fingerprint(channel_id))) - - -def notification_alert_fingerprint(channel_id: str) -> str: - return f"notification.send:{channel_id}" - - -def raise_indexer_site_alert( - *, - indexer_id: str, - indexer_name: str, - site_id: str, - site_name: str, - consecutive_failures: int, - error: str, -) -> None: - indexer = indexer_name or indexer_id - site = site_name or site_id - alert_service.raise_alert( - AlertRaiseRequest( - fingerprint=indexer_site_alert_fingerprint(indexer_id, site_id), - severity=AlertSeverity.error, - category=AlertCategory.indexer_health, - message_key="alertMessages.indexerSiteFailed", - message_params={ - "target": f"{indexer} / {site}", - "indexer": indexer, - "site": site, - "failures": str(consecutive_failures), - "reason": error or "", - }, - target_type=AlertTargetType.indexer_site, - target_id=f"{indexer_id}:{site_id}", - ) - ) - - -def resolve_indexer_site_alert(indexer_id: str, site_id: str) -> None: - alert_service.resolve_alert( - AlertResolveRequest(fingerprint=indexer_site_alert_fingerprint(indexer_id, site_id)) - ) - - -def indexer_site_alert_fingerprint(indexer_id: str, site_id: str) -> str: - return f"indexer.health:{indexer_id}:{site_id}" diff --git a/backend/app/services/domain/download/downloader_change.py b/backend/app/services/domain/download/downloader_change.py index bf9506b..a896a18 100644 --- a/backend/app/services/domain/download/downloader_change.py +++ b/backend/app/services/domain/download/downloader_change.py @@ -14,8 +14,6 @@ from app.services.domain.download.downloader_change_side_effects import ( create_migration_action, emit_change_failed, - emit_change_started, - emit_change_succeeded, mark_migration_action_completed, ) from app.services.domain.download.downloader_change_file_ops import ( @@ -192,7 +190,6 @@ async def _execute_locked( task.status = TaskStatus.MIGRATING task.updated_at = datetime.now() await self._repo.update_task(task) - emit_change_started(task, migration) preview.ok = True return preview @@ -290,7 +287,6 @@ async def _finalize_migration(self, migration: TaskStorageMigration) -> bool: if self._should_start_target_after_finalize(migration.previous_task_status): if not await target_client.start_torrents([migration.torrent_hash]): logger.warning("Failed to start target torrent after storage migration: task_id=%s hash=%s", task.id, migration.torrent_hash) - emit_change_succeeded(task, migration) return True def _load_task_client(self, task: TaskData) -> DownloadClient | None: diff --git a/backend/app/services/domain/download/downloader_change_side_effects.py b/backend/app/services/domain/download/downloader_change_side_effects.py index b7dde37..60a62f9 100644 --- a/backend/app/services/domain/download/downloader_change_side_effects.py +++ b/backend/app/services/domain/download/downloader_change_side_effects.py @@ -11,6 +11,7 @@ from app.schemas.domain.task_storage_migration import TaskStorageMigration from app.services.audit.action_service import action_service from app.services.audit.event_service import event_service +from app.services.domain.download.task_runtime_service import selected_task_episodes if TYPE_CHECKING: from app.services.domain.download.downloader_change import TaskDownloaderChangePreview @@ -55,38 +56,6 @@ def mark_migration_action_failed(migration: TaskStorageMigration, reason: str) - action_service.mark_failed(migration.action_id, error=reason) -def emit_change_started(task: TaskData, migration: TaskStorageMigration) -> None: - event_service.emit_media( - MediaEventCreate( - type=EventTypes.DOWNLOAD_TASK_STORAGE_CHANGE_STARTED, - message_params=_migration_message_params(migration), - media=task.context.media, - task_id=task.id, - actor=EventActor.user, - source=EventSource.base, - entities=_build_event_entities(task), - action_id=migration.action_id, - ), - meta=_build_event_meta(task), - ) - - -def emit_change_succeeded(task: TaskData, migration: TaskStorageMigration) -> None: - event_service.emit_media( - MediaEventCreate( - type=EventTypes.DOWNLOAD_TASK_STORAGE_CHANGED, - message_params=_migration_message_params(migration), - media=task.context.media, - task_id=task.id, - actor=EventActor.user, - source=EventSource.base, - entities=_build_event_entities(task), - action_id=migration.action_id, - ), - meta=_build_event_meta(task), - ) - - def emit_change_failed(task: TaskData, preview: TaskDownloaderChangePreview, reason: str) -> None: event_service.emit_media( MediaEventCreate( @@ -154,6 +123,7 @@ def _build_event_meta(task: TaskData) -> DownloadTaskEventMeta: torrent_hash=task.torrent_hash, progress=task.progress, selected_files=list(task.context.selected_files or []), + selected_episodes=selected_task_episodes(task), total_files=len(task.metadata.files) if task.metadata and task.metadata.files else None, ) diff --git a/backend/app/services/domain/download/lifecycle.py b/backend/app/services/domain/download/lifecycle.py index 8f160c7..5bbbdad 100644 --- a/backend/app/services/domain/download/lifecycle.py +++ b/backend/app/services/domain/download/lifecycle.py @@ -7,15 +7,11 @@ from typing import TYPE_CHECKING, Protocol from app.schemas.config import DownloaderConfig -from app.schemas.constants.event_types import EventTypes -from app.schemas.domain.addon_events import DownloadTaskEventMeta -from app.schemas.domain.download import DownloadTaskCreateInput, TaskContext, TaskData, TaskStatus -from app.schemas.domain.event import EventActor, EventEntityRef, EventSource, MediaEventCreate +from app.schemas.domain.download import DownloadFileInfo, DownloadTaskCreateInput, TaskContext, TaskData, TaskStatus from app.schemas.domain.resource_search import ResourceSearchResult from app.schemas.domain.torrent import TorrentFileItem from app.schemas.exception import ConfigurationException from app.schemas.exception.exceptions import DownloadException, DownloadTaskAlreadyExistsException, InvalidRequestException -from app.services.audit.event_service import event_service from app.services.domain.directory import directory_service from app.services.config.settings_service import settings_service from app.services.domain.resource.parser import resource_parser @@ -155,14 +151,15 @@ async def _expand_existing_task_selection( merged = sorted(existing | requested) task.context.selected_files = merged task.status = self._task_status_after_selection_expansion(task.status) - task.updated_at = datetime.now() - await self._repo.update_task(task) await self.sync_existing_torrent_selection( client, task.torrent_hash, self._selection_union(hash_tasks, files), files, ) + await self._sync_expanded_task_progress(task=task, client=client, files=files) + task.updated_at = datetime.now() + await self._repo.update_task(task) logger.info( "Expanded existing download task selection: media=%s task=%s hash=%s selected_files=%d", task.media_id, @@ -170,7 +167,6 @@ async def _expand_existing_task_selection( task.torrent_hash, len(merged), ) - self.emit_download_started(task, task.context.media) return task @staticmethod @@ -242,43 +238,6 @@ def resolve_download_tags() -> list[str] | None: tag = settings_service.get_base_system_config().download.default_tag.strip() return [tag] if tag else None - def emit_download_started(self, task: TaskData, media) -> None: - downloader_name = None - if task.downloader_id: - display_map = self.get_downloader_display_map() - downloader_info = ( - display_map[task.downloader_id] - if task.downloader_id in display_map - else self._downloader_display_info_cls(name=task.downloader_id) - ) - downloader_name = downloader_info.name - event_service.emit_media( - MediaEventCreate( - type=EventTypes.DOWNLOAD_STARTED, - message_params={"downloader_id": downloader_name or task.downloader_id or ""}, - media=media, - task_id=task.id, - actor=EventActor.system, - source=EventSource.base, - entities=[ - EventEntityRef(type="task", id=task.id), - EventEntityRef(type="media", id=str(task.media_id)), - ], - ), - meta=DownloadTaskEventMeta( - task_id=task.id, - media_id=task.media_id, - status=task.status, - downloader_id=task.downloader_id, - resource_title=task.context.resource_title, - torrent_name=task.metadata.name if task.metadata else None, - torrent_hash=task.torrent_hash, - progress=task.progress, - selected_files=list(task.context.selected_files or []), - total_files=len(task.metadata.files) if task.metadata and task.metadata.files else None, - ), - ) - @staticmethod async def sync_existing_torrent_selection( client: DownloadClient, @@ -297,6 +256,54 @@ async def sync_existing_torrent_selection( await client.set_file_priority(torrent_hash, unselected, 0) await client.set_file_priority(torrent_hash, valid_selected, 1) + @staticmethod + def _selected_file_progress( + selected_files: list[int], + metadata_files: list[TorrentFileItem] | None, + live_files: list[DownloadFileInfo] | None, + ) -> float | None: + if not selected_files or not live_files: + return None + + live_by_index = {file.index: file for file in live_files} + metadata_size_by_index = {file.index: file.size for file in metadata_files or []} + total_size = 0 + completed_size = 0.0 + + for index in selected_files: + live_file = live_by_index.get(index) + if live_file is None: + return None + size = live_file.size or metadata_size_by_index.get(index) or 0 + if size <= 0: + continue + total_size += size + completed_size += size * live_file.progress + + if total_size <= 0: + return None + return max(0.0, min(1.0, completed_size / total_size)) + + async def _sync_expanded_task_progress( + self, + *, + task: TaskData, + client: DownloadClient, + files: list[TorrentFileItem] | None, + ) -> None: + try: + live_files = await client.get_torrent_files(task.torrent_hash) + except (DownloadException, RuntimeError, ValueError) as exc: + logger.warning("Failed to refresh selected file progress after selection expansion: task=%s error=%s", task.id, exc) + return + + progress = self._selected_file_progress(task.context.selected_files or [], files, live_files) + if progress is None: + return + task.progress = progress + if progress >= 0.999 and task.status in {TaskStatus.PENDING, TaskStatus.DOWNLOADING, TaskStatus.PAUSED}: + task.status = TaskStatus.FINISHED + async def create_download(self, req: DownloadTaskCreateInput, search_result: ResourceSearchResult) -> TaskData: if not search_result or search_result.result_id != req.result_id: raise InvalidRequestException("backendErrors.resultIdInvalidOrExpired") @@ -381,7 +388,6 @@ async def create_download(self, req: DownloadTaskCreateInput, search_result: Res task.torrent_hash, len(req.selected_files or []), ) - self.emit_download_started(task, req.media) return task await self._task_service.ensure_live_torrent_download_path_matches_hash( client, @@ -432,5 +438,4 @@ async def create_download(self, req: DownloadTaskCreateInput, search_result: Res len(req.selected_files or []), "false", ) - self.emit_download_started(task, req.media) return task diff --git a/backend/app/services/domain/download/task_runtime_service.py b/backend/app/services/domain/download/task_runtime_service.py index 713a830..b173381 100644 --- a/backend/app/services/domain/download/task_runtime_service.py +++ b/backend/app/services/domain/download/task_runtime_service.py @@ -18,6 +18,19 @@ logger = logging.getLogger("app.services.download") +def selected_task_episodes(task: TaskData) -> list[int]: + if not task.metadata or not task.metadata.files: + return [] + selected = set(task.context.selected_files or []) if task.context and task.context.selected_files else None + episodes: set[int] = set() + for position, file_item in enumerate(task.metadata.files): + file_index = file_item.index if file_item.index is not None else position + if selected and file_index not in selected: + continue + episodes.update(file_item.get_episodes()) + return sorted(episode for episode in episodes if episode > 0) + + class TaskRuntimeRepository(Protocol): async def find_by_id(self, task_id: str) -> TaskData | None: ... @@ -205,8 +218,9 @@ async def _process_active_task_state( resource_title=task.context.resource_title, torrent_name=task.metadata.name if task.metadata else None, torrent_hash=task.torrent_hash, - progress=task.progress, + progress=progress, selected_files=list(task.context.selected_files or []), + selected_episodes=selected_task_episodes(task), total_files=len(task.metadata.files) if task.metadata and task.metadata.files else None, ), ) diff --git a/backend/app/services/domain/download/tasks.py b/backend/app/services/domain/download/tasks.py index 709d605..f1caac1 100644 --- a/backend/app/services/domain/download/tasks.py +++ b/backend/app/services/domain/download/tasks.py @@ -7,10 +7,8 @@ import qbittorrentapi from app.schemas.domain.download import TaskData, TaskStatus -from app.schemas.domain.alert import AlertResolveRequest from app.schemas.domain.torrent_status import TorrentStatus from app.schemas.exception.exceptions import DownloadException -from app.services.domain.alerts import alert_service from app.services.domain.directory import directory_service from app.services.domain.download.downloader_delete import delete_downloader_task from app.services.domain.library.service import library_service @@ -277,7 +275,6 @@ async def delete_task_unlocked( if not force_delete_record: raise await self._repo.delete_by_id(task.id) - alert_service.resolve_alert(AlertResolveRequest(fingerprint=f"task.transfer:{task.id}")) return True async def get_torrent_status_by_task_ids(self, task_ids: list[str]) -> Mapping[str, TorrentStatus]: diff --git a/backend/app/services/domain/resource/selection.py b/backend/app/services/domain/resource/selection.py index 620bc5b..d9ecec0 100644 --- a/backend/app/services/domain/resource/selection.py +++ b/backend/app/services/domain/resource/selection.py @@ -279,10 +279,37 @@ async def _select_video_file_resources( if not best_resource or not best_known_covered or best_rank is None: break if best_resource.attrs.episodes: - selected_items.append((best_resource, best_known_covered, None)) - needed_episodes -= best_known_covered - candidates.remove(best_resource) - logger.debug("Selected known resource: title=%s episodes=%s", best_resource.resources.title, sorted(best_known_covered)) + try: + payload = await fetch_torrent_payload(best_resource.resources) + except ValueError as exc: + logger.warning("Failed to fetch resource torrent payload: title=%s error=%s", best_resource.resources.title, exc) + candidates.remove(best_resource) + continue + original_best_resource = best_resource + best_resource = _resource_with_metadata_attrs(original_best_resource, payload) + if not match_filters(best_resource, filters, quality_profile): + candidates.remove(original_best_resource) + continue + real_episodes = payload.metadata.get_episodes() + best_upgrade_score = compute_quality_upgrade_score(best_resource, quality_profile) + real_covered = { + ep + for ep in real_episodes & needed_episodes + if not required_scores or best_upgrade_score >= (required_scores[ep] if ep in required_scores else -10**9) + } + if real_covered: + selected_items.append((best_resource, real_covered, payload)) + needed_episodes -= real_covered + logger.debug("Selected known resource: title=%s episodes=%s", best_resource.resources.title, sorted(real_covered)) + else: + logger.debug( + "Discarded known resource after payload verification: title=%s expected=%s actual=%s needed=%s", + best_resource.resources.title, + sorted(best_known_covered), + sorted(real_episodes), + sorted(needed_episodes), + ) + candidates.remove(original_best_resource) continue logger.debug( "Lazy fetching resource candidate: title=%s seeders=%s", @@ -385,6 +412,7 @@ async def _select_disc_package_resources( def _resource_with_metadata_attrs(resource: Resource, payload: TorrentPayload) -> Resource: - if not payload.metadata.attrs: + attrs = payload.metadata.attrs + if not attrs: return resource - return resource.model_copy(update={"attrs": payload.metadata.attrs}) + return resource.model_copy(update={"attrs": attrs}) diff --git a/backend/app/services/domain/subscription/command_service.py b/backend/app/services/domain/subscription/command_service.py index 7863a6b..2815300 100644 --- a/backend/app/services/domain/subscription/command_service.py +++ b/backend/app/services/domain/subscription/command_service.py @@ -424,6 +424,15 @@ async def _ensure_active_profile(state: MediaSubscriptionState | None) -> None: logger.warning("Subscription profile activation failed: media=%s error=%s", state.media_id, exc) async def _emit_event(self, change: SubscriptionChange, intent: SubscriptionLifecycleEventIntent) -> None: + if intent.type in { + SubscriptionLifecycleEventType.SUBSCRIPTION_ENABLED, + SubscriptionLifecycleEventType.SUBSCRIPTION_DISABLED, + SubscriptionLifecycleEventType.FOLLOW_ENABLED, + SubscriptionLifecycleEventType.FOLLOW_DISABLED, + }: + return + if intent.type == SubscriptionLifecycleEventType.SUBSCRIPTION_ENDED and intent.trigger == SubscriptionEndTrigger.MANUAL: + return current = change.current or change.previous if current is None: return @@ -438,7 +447,6 @@ async def _emit_event(self, change: SubscriptionChange, intent: SubscriptionLife if intent.type == SubscriptionLifecycleEventType.SUBSCRIPTION_ENDED: reason = intent.reason or SubscriptionEndReason.MANUAL event_type = { - SubscriptionEndReason.MANUAL: EventTypes.SUBSCRIPTION_ENDED_MANUAL, SubscriptionEndReason.MOVIE_LIBRARY_COMPLETED: EventTypes.SUBSCRIPTION_ENDED_MOVIE_COMPLETED, SubscriptionEndReason.MOVIE_DOWNLOADING_COMPLETED: EventTypes.SUBSCRIPTION_ENDED_MOVIE_DOWNLOADING_COMPLETED, SubscriptionEndReason.MOVIE_TARGET_COMPLETED: EventTypes.SUBSCRIPTION_ENDED_MOVIE_TARGET_COMPLETED, @@ -446,19 +454,9 @@ async def _emit_event(self, change: SubscriptionChange, intent: SubscriptionLife SubscriptionEndReason.TV_UPGRADE_COMPLETED: EventTypes.SUBSCRIPTION_ENDED_TV_UPGRADE_COMPLETED, SubscriptionEndReason.TV_TARGET_COMPLETED: EventTypes.SUBSCRIPTION_ENDED_TV_TARGET_COMPLETED, }[reason] - actor = EventActor.user if intent.trigger == SubscriptionEndTrigger.MANUAL else EventActor.system - elif intent.type == SubscriptionLifecycleEventType.SUBSCRIPTION_ENABLED: - event_type = EventTypes.SUBSCRIPTION_ENABLED - actor = EventActor.user - elif intent.type == SubscriptionLifecycleEventType.SUBSCRIPTION_DISABLED: - event_type = EventTypes.SUBSCRIPTION_DISABLED - actor = EventActor.user - elif intent.type == SubscriptionLifecycleEventType.FOLLOW_ENABLED: - event_type = EventTypes.FOLLOW_ENABLED - actor = EventActor.user + actor = EventActor.system else: - event_type = EventTypes.FOLLOW_DISABLED - actor = EventActor.user + return event_service.emit_media( MediaEventCreate( type=event_type, diff --git a/backend/app/services/domain/transfer/execution.py b/backend/app/services/domain/transfer/execution.py index acf28aa..553a199 100644 --- a/backend/app/services/domain/transfer/execution.py +++ b/backend/app/services/domain/transfer/execution.py @@ -16,12 +16,13 @@ from app.schemas.exception.exceptions import TransferException from app.services.domain.directory import directory_service from app.services.domain.download import download_service +from app.services.domain.library.service import library_service from app.services.domain.library.target_path_policy import library_target_path_policy from app.utils.fs_utils import fs_provider from app.utils.library_paths import build_download_path, build_library_file_path from .materializers import transfer_materializer_registry -from .upgrade import validate_transfer_upgrade_policy +from .upgrade import is_idempotent_transfer_retry, validate_transfer_upgrade_policy class TransferExecutionContext(BaseModel): @@ -123,15 +124,7 @@ async def resolve_source_base_path(task: TaskData) -> Path: def generate_source_path(task: TaskData, file_item: TorrentFileItem, source_base_path: Path) -> Path: - relative = Path(file_item.filename) - root_name = Path(task.metadata.name).name - if task.metadata.uses_root_directory_for(file_item): - if not root_name: - raise TransferException("backendErrors.transferTorrentRootNameMissing", params={"task_id": task.id}) - source_path = source_base_path / relative if relative.parts and relative.parts[0] == root_name else source_base_path / root_name / relative - else: - source_path = source_base_path / relative - + source_path = build_source_path(task, file_item, source_base_path) if fs_provider.exists(source_path): return source_path raise TransferException( @@ -140,10 +133,27 @@ def generate_source_path(task: TaskData, file_item: TorrentFileItem, source_base ) +def build_source_path(task: TaskData, file_item: TorrentFileItem, source_base_path: Path) -> Path: + relative = Path(file_item.filename) + root_name = Path(task.metadata.name).name + if task.metadata.uses_root_directory_for(file_item): + if not root_name: + raise TransferException("backendErrors.transferTorrentRootNameMissing", params={"task_id": task.id}) + return source_base_path / relative if relative.parts and relative.parts[0] == root_name else source_base_path / root_name / relative + return source_base_path / relative + + def collect_present_library_files(library_files: list[LibraryFile]) -> list[LibraryFile]: return [library_file for library_file in library_files if fs_provider.exists(build_library_file_path(library_file.path, library_file.file_name))] +async def should_skip_existing_task_materialization(task: TaskData, transfer_result: TransferFileResult) -> bool: + existing_file = await library_service.find_file_by_path(transfer_result.destination_path) + if not existing_file or not is_idempotent_transfer_retry(task, existing_file, transfer_result): + return False + return fs_provider.exists(build_library_file_path(existing_file.path, existing_file.file_name)) + + def should_skip_retransfer( task: TaskData, existing_library_files: list[LibraryFile], @@ -175,6 +185,17 @@ async def all_transfer_sources_available(task: TaskData) -> bool: return found_any +async def missing_transfer_source_paths(task: TaskData) -> list[str]: + validate_transfer_task(task) + source_base_path = await resolve_source_base_path(task) + missing_paths: list[str] = [] + for _, file_item in iter_selected_files(task.metadata.files, task.context.selected_files if task.context else None): + source_path = build_source_path(task, file_item, source_base_path) + if not fs_provider.exists(source_path): + missing_paths.append(str(source_path)) + return missing_paths + + async def validate_transfer_reentry(task: TaskData, existing_library_files: list[LibraryFile]) -> TransferResult | None: if not existing_library_files: return None @@ -361,6 +382,8 @@ async def execute_transfer(task: TaskData, execution_context: TransferExecutionC source_path = Path(transfer_result.source_path) destination_path = Path(transfer_result.destination_path) try: + if await should_skip_existing_task_materialization(task, transfer_result): + continue await asyncio.to_thread(materializer.materialize, source_path, destination_path) except (TransferException, OSError): raise diff --git a/backend/app/services/domain/transfer/service.py b/backend/app/services/domain/transfer/service.py index 8cb49da..e5762f3 100644 --- a/backend/app/services/domain/transfer/service.py +++ b/backend/app/services/domain/transfer/service.py @@ -6,15 +6,13 @@ from app.schemas.constants.event_types import EventTypes from app.schemas.domain.addon_events import ImportedMediaFile, MediaImportCompletedEventMeta -from app.schemas.domain.addon_events import MediaImportFailedEventMeta, MediaImportStartedEventMeta -from app.schemas.domain.alert import AlertCategory, AlertRaiseRequest, AlertResolveRequest, AlertSeverity, AlertTargetType +from app.schemas.domain.addon_events import MediaImportFailedEventMeta from app.schemas.domain.download import TaskData, TaskErrorStage, TaskStatus, TransferFileResult, TransferResult from app.schemas.domain.event import EventActor, EventEntityRef, EventLevel, EventSource, MediaEventCreate from app.schemas.domain.library import LibraryFile from app.schemas.exception.base import AppException from app.schemas.exception.exceptions import TransferException from app.services.audit.event_service import event_service -from app.services.domain.alerts import alert_service from app.services.domain.download import download_service from app.services.domain.library.service import library_service from app.services.domain.media import media_service @@ -30,18 +28,6 @@ logger = logging.getLogger("app.services.transfer") -def _transfer_alert_fingerprint(task_id: str) -> str: - return f"task.transfer:{task_id}" - - -def _task_alert_title(task: TaskData) -> str: - if task.context and task.context.resource_title: - return task.context.resource_title - if task.metadata and task.metadata.name: - return task.metadata.name - return task.id - - def _nested_message_params(params: dict[str, str] | None) -> str: if not params: return "" @@ -72,7 +58,6 @@ async def _perform_transfer(self, task: TaskData) -> TransferResult: await self._lock_task_status(task) existing_library_files = await library_service.get_files_by_task(task.id) execution_context = await execution.build_transfer_execution_context(task) - await emit_media_import_started(task) transfer_results = await execution.execute_transfer(task, execution_context) replacement_plan = await library_replacement_policy.build_plan(task, transfer_results, execution_context.season_number) await commit_transfer_results( @@ -121,32 +106,6 @@ def _task_import_entities(task: TaskData) -> list[EventEntityRef]: return [EventEntityRef(type="task", id=task.id), EventEntityRef(type="media", id=str(task.media_id))] -async def emit_media_import_started(task: TaskData) -> None: - try: - media = task.context.media - if media is None: - raise TransferException("backendErrors.transferMediaSnapshotMissing", params={"task_id": task.id, "media_id": str(task.media_id)}) - event_service.emit_media( - MediaEventCreate( - type=EventTypes.MEDIA_IMPORT_STARTED, - media=media, - task_id=task.id, - actor=EventActor.system, - source=EventSource.base, - entities=_task_import_entities(task), - ), - meta=MediaImportStartedEventMeta( - task_id=task.id, - directory_id=task.context.directory_id, - media_id=task.media_id, - resource_title=task.context.resource_title, - torrent_name=task.metadata.name if task.metadata else None, - ), - ) - except AppException as exc: - logger.warning("Failed to emit media import started event for task %s: %s", task.id, exc) - - async def emit_media_import_completed(task: TaskData, transfer_results: list[TransferFileResult]) -> None: try: media = task.context.media @@ -230,7 +189,6 @@ async def commit_transfer_results( replacement_files, ) await download_service.update_task_state(task.id, TaskStatus.COMPLETED) - alert_service.resolve_alert(AlertResolveRequest(fingerprint=_transfer_alert_fingerprint(task.id))) cleanup_replaced_library_files(replaced_library_files or existing_library_files, transfer_results) try: await media_service.refresh_profile_safely(task.media_id, execution_context.season_number) @@ -250,24 +208,6 @@ async def handle_transfer_error(task: TaskData, error_key: str, error_params: di error_params=error_params, error_stage=TaskErrorStage.TRANSFER, ) - alert_service.raise_alert( - AlertRaiseRequest( - fingerprint=_transfer_alert_fingerprint(task.id), - severity=AlertSeverity.error, - category=AlertCategory.task_transfer, - message_key="alertMessages.taskTransferFailed", - message_params={ - "task": _task_alert_title(task), - "reason_key": error_key, - "reason_params": _nested_message_params(error_params), - }, - target_type=AlertTargetType.task, - target_id=task.id, - media=task.context.media if task.context else None, - media_id=task.media_id, - task_id=task.id, - ) - ) except AppException as exc: logger.error("Failed to update error status: %s", exc) diff --git a/backend/app/services/i18n/locales/en-US.json b/backend/app/services/i18n/locales/en-US.json index 4476327..fe3959b 100644 --- a/backend/app/services/i18n/locales/en-US.json +++ b/backend/app/services/i18n/locales/en-US.json @@ -28,6 +28,8 @@ "backendErrors.mediaServerLinkUnavailable": "This resource is not bound to an openable media library", "backendErrors.mediaServerAuthenticationFailed": "Media server authentication failed. Check the media server API key", "backendErrors.system": "System error", + "backendErrors.testConnectionFailed": "Connection test failed: {service}", + "backendErrors.testConnectionFailedWithReason": "Connection test failed: {service}. {reason}", "backendErrors.taskBusy": "This task is currently being processed. Try again later", "backendErrors.taskStorageChangeBlocked": "Storage change blocked: {reason}", "backendErrors.libraryFileDirectoryChangeBlocked": "Local resource directory change blocked: {reason}", @@ -126,11 +128,6 @@ "commandMessages.taskTransfer.queued": "Waiting to transfer resource", "commandMessages.taskTransfer.running": "Transferring resource", "commandMessages.taskTransfer.succeeded": "Transfer complete. Processed {transferred_files_count} files", - "alertMessages.danmuGenerateFailed": "Danmu generation failed: {target}. Reason: {reason}", - "alertMessages.mediaServerSyncFailed": "Media server sync failed: {target}. Reason: {reason}", - "alertMessages.notificationSendFailed": "Notification send failed: {channel}. Reason: {reason}", - "alertMessages.indexerSiteFailed": "Indexer site failed repeatedly: {indexer} / {site}. Failed {failures} times. Reason: {reason}", - "alertMessages.taskTransferFailed": "Resource transfer failed: {task}. Reason: {reason}", "commandMessages.taskStorageChange.cancelled": "Storage change cancelled", "commandMessages.taskStorageChange.failed": "Storage change failed", "commandMessages.taskStorageChange.queued": "Storage change queued", @@ -142,43 +139,53 @@ "eventMessages.addonRunStarted": "Addon started", "eventMessages.downloadCompleted": "Download completed for {resource_title}", "eventMessages.downloadFailed": "Failed to create download task for {resource_title}: {error}", - "eventMessages.downloadStarted": "Started downloading {resource_title}", "eventMessages.downloadTaskDownloaderChangeFailed": "Failed to change downloader for {resource_title}: {reason}", - "eventMessages.downloadTaskDownloaderChanged": "Changed downloader for {resource_title}: {source_downloader_id} -> {target_downloader_id}", "eventMessages.downloadTaskStorageChangeFailed": "Failed to change storage for {resource_title}: {reason}", - "eventMessages.downloadTaskStorageChangeStarted": "Started changing storage for {resource_title}: {source_downloader_id} -> {target_downloader_id}", - "eventMessages.downloadTaskStorageChanged": "Changed storage for {resource_title}: {source_downloader_id} -> {target_downloader_id}", "eventMessages.danmuGenerateCompleted": "Danmu generated for {video_path_name}", "eventMessages.danmuGenerateFailed": "Danmu generation failed for {video_path_name}: {error}", - "eventMessages.danmuGenerateStarted": "Started danmu generation for {video_path_name}", "eventMessages.followDigitalReleased": "Digital release on {air_date}", "eventMessages.followPhysicalReleased": "Physical release on {air_date}", - "eventMessages.followDisabled": "Follow reminder disabled", - "eventMessages.followEnabled": "Follow reminder enabled", "eventMessages.followReleased": "Released or aired on {air_date}", "eventMessages.generic": "System event", "eventMessages.libraryFileMissing": "Library file missing: {path}", "eventMessages.mediaImportFailed": "Import failed for {resource_title}: {error}", - "eventMessages.mediaImportStarted": "Started importing {resource_title}", "eventMessages.mediaDeleted": "Resources deleted, {path_count} paths removed, first target {first_target}", "eventMessages.mediaImportCompleted": "Import completed, {file_count} files imported, first file {first_target}", "eventMessages.mediaServerSyncCompleted": "Scraping completed for {file_path_name}", "eventMessages.mediaServerSyncFailed": "Scraping failed for {file_path_name}: {error}", - "eventMessages.mediaServerSyncStarted": "Started scraping {file_path_name}", - "eventMessages.notificationFailed": "Notification failed", - "eventMessages.notificationSent": "Notification sent", - "eventMessages.pilotEpisodeQueued": "Pilot task submitted, directory {directory_id}", - "eventMessages.subscriptionDisabled": "Automatic subscription download disabled", - "eventMessages.subscriptionEnabled": "Automatic subscription download enabled", - "eventMessages.subscriptionEndedManual": "Current subscription ended", + "eventMessages.notificationFailed": "Notification failed: {channel}. Reason: {reason}", + "eventMessages.notificationSent": "Notification sent: {channel}", "eventMessages.subscriptionEndedMovieCompleted": "Current subscription ended, movie is in library", "eventMessages.subscriptionEndedMovieDownloadingCompleted": "Current subscription ended, movie already has a download task", "eventMessages.subscriptionEndedMovieTargetCompleted": "Current subscription ended, movie reached target version", "eventMessages.subscriptionEndedTvCompleted": "Current subscription ended, all episodes are covered", "eventMessages.subscriptionEndedTvTargetCompleted": "Current subscription ended, aired episodes reached target version", "eventMessages.subscriptionEndedTvUpgradeCompleted": "Current subscription ended, upgrade cycle completed", - "eventMessages.subscriptionRunCompleted": "Subscription check completed, {checked} candidates matched, {added} download tasks created", - "eventMessages.subscriptionRunFailed": "Subscription run failed: {reason}", + "telegram.actions.viewDetails": "View details", + "telegram.fields.episodes": "Episode {episodes}", + "telegram.fields.reason": "Reason: {reason}", + "telegram.fields.resource": "Resource: {resource}", + "telegram.fields.season": "Season {season}", + "telegram.fields.system": "System", + "telegram.fields.time": "Time: {time}", + "telegram.testMessage": "[Aethera] Notification test", + "telegram.titles.danmuGenerateCompleted": "Danmu generated", + "telegram.titles.danmuGenerateFailed": "Danmu generation failed", + "telegram.titles.downloadCompleted": "Download completed", + "telegram.titles.downloadFailed": "Download failed", + "telegram.titles.downloadTaskDownloaderChangeFailed": "Downloader change failed", + "telegram.titles.downloadTaskStorageChangeFailed": "Storage change failed", + "telegram.titles.followDigitalReleased": "Digital release reminder", + "telegram.titles.followPhysicalReleased": "Physical release reminder", + "telegram.titles.followReleased": "Release reminder", + "telegram.titles.generic": "System notification", + "telegram.titles.libraryFileMissing": "Library file missing", + "telegram.titles.mediaDeleted": "Resource deleted", + "telegram.titles.mediaImportCompleted": "Import completed", + "telegram.titles.mediaImportFailed": "Import failed", + "telegram.titles.mediaServerSyncCompleted": "Scrape completed", + "telegram.titles.mediaServerSyncFailed": "Scrape failed", + "telegram.titles.subscriptionEnded": "Subscription ended", "subscriptionRunMessages.failed": "Subscription run failed", "subscriptionRunMessages.planMissing": "Subscription run failed: run plan is missing", "subscriptionRunMessages.queueFailed": "Subscription run failed: resource queue failed" diff --git a/backend/app/services/i18n/locales/zh-CN.json b/backend/app/services/i18n/locales/zh-CN.json index b48ade3..e8b96ef 100644 --- a/backend/app/services/i18n/locales/zh-CN.json +++ b/backend/app/services/i18n/locales/zh-CN.json @@ -28,6 +28,8 @@ "backendErrors.mediaServerLinkUnavailable": "该资源未绑定可打开的媒体库", "backendErrors.mediaServerAuthenticationFailed": "媒体服务器认证失败,请检查媒体服务器 API Key", "backendErrors.system": "系统错误", + "backendErrors.testConnectionFailed": "连接测试失败:{service}", + "backendErrors.testConnectionFailedWithReason": "连接测试失败:{service},{reason}", "backendErrors.taskBusy": "当前任务正在处理中,请稍后再试", "backendErrors.taskStorageChangeBlocked": "更换存储被阻止:{reason}", "backendErrors.libraryFileDirectoryChangeBlocked": "更换本地资源目录被阻止:{reason}", @@ -126,11 +128,6 @@ "commandMessages.taskTransfer.queued": "等待转移资源", "commandMessages.taskTransfer.running": "正在转移资源", "commandMessages.taskTransfer.succeeded": "转移完成,共处理 {transferred_files_count} 个文件", - "alertMessages.danmuGenerateFailed": "弹幕生成失败:{target}。原因:{reason}", - "alertMessages.mediaServerSyncFailed": "媒体库刮削失败:{target}。原因:{reason}", - "alertMessages.notificationSendFailed": "消息通知发送失败:{channel}。原因:{reason}", - "alertMessages.indexerSiteFailed": "索引器站点连续失败:{indexer} / {site},已失败 {failures} 次。原因:{reason}", - "alertMessages.taskTransferFailed": "资源转移失败:{task}。原因:{reason}", "commandMessages.taskStorageChange.cancelled": "更换存储已取消", "commandMessages.taskStorageChange.failed": "更换存储失败", "commandMessages.taskStorageChange.queued": "更换存储已排队", @@ -142,43 +139,53 @@ "eventMessages.addonRunStarted": "扩展开始执行", "eventMessages.downloadCompleted": "下载已完成「{resource_title}」", "eventMessages.downloadFailed": "下载任务创建失败「{resource_title}」:{error}", - "eventMessages.downloadStarted": "已开始下载「{resource_title}」", "eventMessages.downloadTaskDownloaderChangeFailed": "更换下载器失败「{resource_title}」:{reason}", - "eventMessages.downloadTaskDownloaderChanged": "已更换下载器「{resource_title}」:{source_downloader_id} -> {target_downloader_id}", "eventMessages.downloadTaskStorageChangeFailed": "更换存储失败「{resource_title}」:{reason}", - "eventMessages.downloadTaskStorageChangeStarted": "已开始更换存储「{resource_title}」:{source_downloader_id} -> {target_downloader_id}", - "eventMessages.downloadTaskStorageChanged": "已完成更换存储「{resource_title}」:{source_downloader_id} -> {target_downloader_id}", "eventMessages.danmuGenerateCompleted": "弹幕生成完成「{video_path_name}」", "eventMessages.danmuGenerateFailed": "弹幕生成失败「{video_path_name}」:{error}", - "eventMessages.danmuGenerateStarted": "开始生成弹幕「{video_path_name}」", "eventMessages.followDigitalReleased": "已网络上映,日期 {air_date}", "eventMessages.followPhysicalReleased": "已发行实体介质,日期 {air_date}", - "eventMessages.followDisabled": "已关闭关注提醒", - "eventMessages.followEnabled": "已开启关注提醒", "eventMessages.followReleased": "已上映或开播,日期 {air_date}", "eventMessages.generic": "系统事件", "eventMessages.libraryFileMissing": "库文件缺失:{path}", "eventMessages.mediaImportFailed": "入库失败「{resource_title}」:{error}", - "eventMessages.mediaImportStarted": "开始入库「{resource_title}」", "eventMessages.mediaDeleted": "资源已删除,共删除 {path_count} 个路径,首个目标「{first_target}」", "eventMessages.mediaImportCompleted": "已完成入库,共导入 {file_count} 个文件,首个文件「{first_target}」", "eventMessages.mediaServerSyncCompleted": "刮削完成「{file_path_name}」", "eventMessages.mediaServerSyncFailed": "刮削失败「{file_path_name}」:{error}", - "eventMessages.mediaServerSyncStarted": "开始刮削「{file_path_name}」", - "eventMessages.notificationFailed": "消息通知发送失败", - "eventMessages.notificationSent": "消息通知发送成功", - "eventMessages.pilotEpisodeQueued": "已提交试播任务,目录 {directory_id}", - "eventMessages.subscriptionDisabled": "已关闭订阅自动下载", - "eventMessages.subscriptionEnabled": "已开启订阅自动下载", - "eventMessages.subscriptionEndedManual": "已结束当前订阅", + "eventMessages.notificationFailed": "消息通知发送失败:{channel},原因:{reason}", + "eventMessages.notificationSent": "消息通知发送成功:{channel}", "eventMessages.subscriptionEndedMovieCompleted": "已结束当前订阅,电影已入库", "eventMessages.subscriptionEndedMovieDownloadingCompleted": "已结束当前订阅,电影已有下载任务", "eventMessages.subscriptionEndedMovieTargetCompleted": "已结束当前订阅,电影已达到目标版本", "eventMessages.subscriptionEndedTvCompleted": "已结束当前订阅,全集已全部覆盖", "eventMessages.subscriptionEndedTvTargetCompleted": "已结束当前订阅,当前已播集已达到目标版本", "eventMessages.subscriptionEndedTvUpgradeCompleted": "已结束当前订阅,当前洗版周期已完成", - "eventMessages.subscriptionRunCompleted": "订阅检查完成,命中 {checked} 个候选资源,已创建 {added} 个下载任务", - "eventMessages.subscriptionRunFailed": "订阅运行失败:{reason}", + "telegram.actions.viewDetails": "查看详情", + "telegram.fields.episodes": "第 {episodes} 集", + "telegram.fields.reason": "原因:{reason}", + "telegram.fields.resource": "资源:{resource}", + "telegram.fields.season": "第 {season} 季", + "telegram.fields.system": "系统", + "telegram.fields.time": "时间:{time}", + "telegram.testMessage": "[Aethera] 通知测试", + "telegram.titles.danmuGenerateCompleted": "弹幕生成完成", + "telegram.titles.danmuGenerateFailed": "弹幕生成失败", + "telegram.titles.downloadCompleted": "下载已完成", + "telegram.titles.downloadFailed": "下载失败", + "telegram.titles.downloadTaskDownloaderChangeFailed": "更换下载器失败", + "telegram.titles.downloadTaskStorageChangeFailed": "更换存储失败", + "telegram.titles.followDigitalReleased": "网络上映提醒", + "telegram.titles.followPhysicalReleased": "实体发行提醒", + "telegram.titles.followReleased": "上映提醒", + "telegram.titles.generic": "系统通知", + "telegram.titles.libraryFileMissing": "库文件缺失", + "telegram.titles.mediaDeleted": "资源已删除", + "telegram.titles.mediaImportCompleted": "入库已完成", + "telegram.titles.mediaImportFailed": "入库失败", + "telegram.titles.mediaServerSyncCompleted": "刮削完成", + "telegram.titles.mediaServerSyncFailed": "刮削失败", + "telegram.titles.subscriptionEnded": "订阅已结束", "subscriptionRunMessages.failed": "订阅运行失败", "subscriptionRunMessages.planMissing": "订阅运行失败:运行计划缺失", "subscriptionRunMessages.queueFailed": "订阅运行失败:资源入队失败" diff --git a/backend/app/services/integration/notifications/__init__.py b/backend/app/services/integration/notifications/__init__.py index 8b13789..fef6261 100644 --- a/backend/app/services/integration/notifications/__init__.py +++ b/backend/app/services/integration/notifications/__init__.py @@ -1 +1,19 @@ +from app.schemas.config import TelegramNotificationChannelConfig +from app.services.integration.notifications.channels.telegram.client import TelegramClient +from app.services.config.settings_service import settings_service +from app.services.i18n.message_renderer import render_message + +async def test_telegram_connection_for_config(config: TelegramNotificationChannelConfig) -> bool: + if not config.bot_token or not config.chat_id: + return False + locale = settings_service.get_base_system_config().locale + await TelegramClient().test_connection_or_raise( + bot_token=config.bot_token, + chat_id=config.chat_id, + text=render_message("telegram.testMessage", locale=locale), + ) + return True + + +__all__ = ["test_telegram_connection_for_config"] diff --git a/backend/app/services/integration/notifications/channels/telegram/__init__.py b/backend/app/services/integration/notifications/channels/telegram/__init__.py index dfa51fa..f207d82 100644 --- a/backend/app/services/integration/notifications/channels/telegram/__init__.py +++ b/backend/app/services/integration/notifications/channels/telegram/__init__.py @@ -1,7 +1,4 @@ -from app.core.feature_flags import telegram_notifications_enabled +from app.services.integration.notifications.channels.telegram.channel import TelegramNotificationChannel +from app.services.platform.notification_channel_service import notification_channel_service -if telegram_notifications_enabled(): - from app.services.integration.notifications.channels.telegram.channel import TelegramNotificationChannel - from app.services.platform.notification_channel_service import notification_channel_service - - notification_channel_service.register(TelegramNotificationChannel()) +notification_channel_service.register(TelegramNotificationChannel()) diff --git a/backend/app/services/integration/notifications/channels/telegram/channel.py b/backend/app/services/integration/notifications/channels/telegram/channel.py index c59772e..177dd2a 100644 --- a/backend/app/services/integration/notifications/channels/telegram/channel.py +++ b/backend/app/services/integration/notifications/channels/telegram/channel.py @@ -1,47 +1,12 @@ from __future__ import annotations -import json - from app.schemas.config import NotificationChannelConfig, TelegramNotificationChannelConfig from app.schemas.domain.event import Event -from app.services.i18n.message_renderer import render_message +from app.services.config.settings_service import settings_service from app.services.platform.notification_channel_service import BaseNotificationChannel from .client import TelegramClient - - -def _escape_markdown(value: str) -> str: - escaped = value or "" - for char in "\\_[]()~`>#+-=|{}.!": - escaped = escaped.replace(char, f"\\{char}") - return escaped - - -def _build_entity_summary(event: Event) -> str: - parts: list[str] = [] - if event.media_id: - parts.append(f"media: `{_escape_markdown(str(event.media_id))}`") - if event.task_id: - parts.append(f"task: `{_escape_markdown(event.task_id)}`") - if event.subscription_id: - parts.append(f"subscription: `{_escape_markdown(event.subscription_id)}`") - if event.addon_name: - parts.append(f"addon: `{_escape_markdown(event.addon_name)}`") - return "\n".join(parts) - - -def _build_meta_summary(event: Event) -> str: - if not event.meta: - return "" - try: - meta_payload = json.loads(event.meta) - except json.JSONDecodeError: - return f"meta: `{_escape_markdown(event.meta)}`" - if type(meta_payload) is not dict: - return f"meta: `{_escape_markdown(str(meta_payload))}`" - keys = sorted(meta_payload.keys())[:6] - pairs = [f"{_escape_markdown(str(key))}: `{_escape_markdown(str(meta_payload[key]))}`" for key in keys] - return "\n".join(pairs) +from .renderer import format_telegram_event class TelegramNotificationChannel(BaseNotificationChannel): @@ -58,22 +23,13 @@ def is_configured(self, config: NotificationChannelConfig) -> bool: async def send(self, config: NotificationChannelConfig, event: Event) -> None: telegram_config = TelegramNotificationChannelConfig.model_validate(config) + system_config = settings_service.get_base_system_config() await self._client.send_message( bot_token=telegram_config.bot_token, chat_id=telegram_config.chat_id, - text=self._format_message(event), + text=format_telegram_event( + event, + locale=system_config.locale, + public_base_url=system_config.public_base_url, + ), ) - - def _format_message(self, event: Event) -> str: - sections = [ - f"*{_escape_markdown(event.level.value.upper())}* · `{_escape_markdown(event.type)}`", - _escape_markdown(render_message(event.message_key, event.message_params)), - ] - entity_summary = _build_entity_summary(event) - if entity_summary: - sections.append(entity_summary) - meta_summary = _build_meta_summary(event) - if meta_summary: - sections.append(meta_summary) - sections.append(f"time: `{_escape_markdown(event.ts.isoformat())}`") - return "\n\n".join(section for section in sections if section) diff --git a/backend/app/services/integration/notifications/channels/telegram/client.py b/backend/app/services/integration/notifications/channels/telegram/client.py index a71311d..52035b1 100644 --- a/backend/app/services/integration/notifications/channels/telegram/client.py +++ b/backend/app/services/integration/notifications/channels/telegram/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping import httpx from pydantic import BaseModel, ConfigDict @@ -16,17 +17,55 @@ class TelegramSendMessageResponse(BaseModel): class TelegramClient: + async def test_connection(self, bot_token: str, chat_id: str, text: str = "[Aethera] Notification test") -> bool: + try: + await self.test_connection_or_raise(bot_token=bot_token, chat_id=chat_id, text=text) + except RuntimeError: + return False + return True + + async def test_connection_or_raise(self, bot_token: str, chat_id: str, text: str = "[Aethera] Notification test") -> None: + self._validate_test_inputs(bot_token=bot_token, chat_id=chat_id) + await self._post_api( + bot_token, + "sendMessage", + { + "chat_id": chat_id, + "text": text, + "disable_web_page_preview": True, + }, + ) + async def send_message(self, bot_token: str, chat_id: str, text: str) -> None: - url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - payload = { - "chat_id": chat_id, - "text": text, - "parse_mode": "MarkdownV2", - "disable_web_page_preview": True, - } + await self._post_api( + bot_token, + "sendMessage", + { + "chat_id": chat_id, + "text": text, + "parse_mode": "MarkdownV2", + "disable_web_page_preview": True, + }, + ) + + async def _post_api(self, bot_token: str, method: str, payload: Mapping[str, str | bool]) -> TelegramSendMessageResponse: + url = f"https://api.telegram.org/bot{bot_token}/{method}" async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post(url, json=payload) - response.raise_for_status() + try: + response = await client.post(url, json=payload) + except httpx.RequestError as exc: + raise RuntimeError("Telegram API request failed") from exc + try: result = TelegramSendMessageResponse.model_validate(response.json()) - if not result.ok: - raise RuntimeError(result.description or "Telegram API returned an error") + except ValueError as exc: + raise RuntimeError("Telegram API returned an invalid response") from exc + if not result.ok: + raise RuntimeError(result.description or "Telegram API returned an error") + return result + + def _validate_test_inputs(self, bot_token: str, chat_id: str) -> None: + token_prefix, separator, token_secret = bot_token.partition(":") + if not separator or not token_prefix.isdigit() or not token_secret: + raise RuntimeError("Telegram bot token format is invalid") + if not chat_id.strip(): + raise RuntimeError("Telegram chat id is required") diff --git a/backend/app/services/integration/notifications/channels/telegram/renderer.py b/backend/app/services/integration/notifications/channels/telegram/renderer.py new file mode 100644 index 0000000..659ce09 --- /dev/null +++ b/backend/app/services/integration/notifications/channels/telegram/renderer.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +from datetime import datetime +from collections.abc import Mapping +from pathlib import Path +from urllib.parse import quote, urlencode + +from pydantic import BaseModel, Field + +from app.schemas.domain.event import Event, EventLevel, EventType +from app.services.i18n.message_renderer import render_message + + +class TelegramImportedFileMeta(BaseModel): + destination_path: str = "" + episode_number: int | None = None + episode_numbers: list[int] = Field(default_factory=list) + + +class TelegramEventMeta(BaseModel): + resource_title: str = "" + torrent_name: str = "" + file_path: str = "" + video_path: str = "" + path: str = "" + reason: str = "" + error: str = "" + error_key: str = "" + error_params: Mapping[str, str] = Field(default_factory=lambda: {}) + selected_episodes: list[int] = Field(default_factory=list) + imported_files: list[TelegramImportedFileMeta] = Field(default_factory=list) + episode_number: int | None = None + + +def escape_markdown(value: str) -> str: + escaped = value or "" + for char in "\\_[]()~`>#+-=|{}.!": + escaped = escaped.replace(char, f"\\{char}") + return escaped + + +def _text(key: str, locale: str, params: Mapping[str, str] | None = None) -> str: + return render_message(key, params or {}, locale=locale) + + +def _meta(event: Event) -> TelegramEventMeta: + if not event.meta: + return TelegramEventMeta() + try: + json.loads(event.meta) + except json.JSONDecodeError: + return TelegramEventMeta() + return TelegramEventMeta.model_validate_json(event.meta) + + +def _path_name(value: str | None) -> str: + normalized = str(value or "").strip() + return Path(normalized).name or normalized + + +def _episode_range(values) -> list[int]: + normalized: set[int] = set() + for value in values or []: + try: + number = int(value) + except (TypeError, ValueError): + continue + if number > 0: + normalized.add(number) + return sorted(normalized) + + +def _format_episode_range(values, locale: str) -> str: + episodes = _episode_range(values) + if not episodes: + return "" + + ranges: list[tuple[int, int]] = [] + start = episodes[0] + end = episodes[0] + for value in episodes[1:]: + if value == end + 1: + end = value + continue + ranges.append((start, end)) + start = value + end = value + ranges.append((start, end)) + + parts = [str(start) if start == end else f"{start}-{end}" for start, end in ranges] + return _text("telegram.fields.episodes", locale, {"episodes": ", ".join(parts)}) + + +def _download_episodes(meta: TelegramEventMeta) -> list[int]: + return _episode_range(meta.selected_episodes) + + +def _imported_episodes(meta: TelegramEventMeta) -> list[int]: + episodes: list[int] = [] + for item in meta.imported_files: + episodes.extend(_episode_range(item.episode_numbers)) + episodes.extend(_episode_range([item.episode_number])) + return episodes + + +def _event_episodes(event: Event, meta: TelegramEventMeta) -> list[int]: + if event.type == EventType.DOWNLOAD_COMPLETED: + return _download_episodes(meta) + if event.type == EventType.MEDIA_IMPORT_COMPLETED: + return _imported_episodes(meta) + if event.type in {EventType.DANMU_GENERATE_COMPLETED, EventType.DANMU_GENERATE_FAILED}: + return _episode_range([meta.episode_number]) + return [] + + +def _media_line(event: Event, meta: TelegramEventMeta, locale: str) -> str: + if not event.media: + return _text("telegram.fields.system", locale) + parts = [event.media.title] + if event.media.season_number: + parts.append(_text("telegram.fields.season", locale, {"season": str(event.media.season_number)})) + episode_text = _format_episode_range(_event_episodes(event, meta), locale) + if episode_text: + parts.append(episode_text) + return " · ".join(parts) + + +def _resource_line(event: Event, meta: TelegramEventMeta, locale: str) -> str: + resource = ( + meta.torrent_name + or meta.resource_title + or _path_name(meta.file_path) + or _path_name(meta.video_path) + or _path_name(meta.path) + or "" + ) + if not resource: + return "" + return _text("telegram.fields.resource", locale, {"resource": str(resource)}) + + +def _message_param(event: Event, name: str) -> str: + if name not in event.message_params: + return "" + return str(event.message_params[name]) + + +def _reason_line(event: Event, meta: TelegramEventMeta, locale: str) -> str: + if event.level == EventLevel.info and not event.type.value.endswith(".failed"): + return "" + reason = meta.error or meta.reason or _message_param(event, "reason") + reason_key = meta.error_key or _message_param(event, "reason_key") + if reason_key: + reason = render_message(str(reason_key), {str(key): str(value) for key, value in meta.error_params.items()}, locale=locale) + if not reason: + return "" + return _text("telegram.fields.reason", locale, {"reason": str(reason)}) + + +def _time_line(ts: datetime, locale: str) -> str: + return _text("telegram.fields.time", locale, {"time": ts.strftime("%Y-%m-%d %H:%M")}) + + +def _detail_url(event: Event, public_base_url: str) -> str: + base = public_base_url.strip().rstrip("/") + if not base or not event.media_id: + return "" + path = f"/media/{quote(str(event.media_id), safe=':')}" + query = "" + if event.media and event.media.season_number: + query = f"?{urlencode({'season_number': event.media.season_number})}" + return f"{base}{path}{query}" + + +def _title_key(event: Event) -> str: + keys = { + EventType.DOWNLOAD_COMPLETED: "telegram.titles.downloadCompleted", + EventType.DOWNLOAD_FAILED: "telegram.titles.downloadFailed", + EventType.DOWNLOAD_TASK_DOWNLOADER_CHANGE_FAILED: "telegram.titles.downloadTaskDownloaderChangeFailed", + EventType.DOWNLOAD_TASK_STORAGE_CHANGE_FAILED: "telegram.titles.downloadTaskStorageChangeFailed", + EventType.MEDIA_IMPORT_COMPLETED: "telegram.titles.mediaImportCompleted", + EventType.MEDIA_IMPORT_FAILED: "telegram.titles.mediaImportFailed", + EventType.MEDIA_SERVER_SYNC_COMPLETED: "telegram.titles.mediaServerSyncCompleted", + EventType.MEDIA_SERVER_SYNC_FAILED: "telegram.titles.mediaServerSyncFailed", + EventType.DANMU_GENERATE_COMPLETED: "telegram.titles.danmuGenerateCompleted", + EventType.DANMU_GENERATE_FAILED: "telegram.titles.danmuGenerateFailed", + EventType.MEDIA_DELETED: "telegram.titles.mediaDeleted", + EventType.LIBRARY_FILE_MISSING: "telegram.titles.libraryFileMissing", + EventType.FOLLOW_RELEASED: "telegram.titles.followReleased", + EventType.FOLLOW_DIGITAL_RELEASED: "telegram.titles.followDigitalReleased", + EventType.FOLLOW_PHYSICAL_RELEASED: "telegram.titles.followPhysicalReleased", + EventType.SUBSCRIPTION_ENDED_MOVIE_COMPLETED: "telegram.titles.subscriptionEnded", + EventType.SUBSCRIPTION_ENDED_MOVIE_DOWNLOADING_COMPLETED: "telegram.titles.subscriptionEnded", + EventType.SUBSCRIPTION_ENDED_MOVIE_TARGET_COMPLETED: "telegram.titles.subscriptionEnded", + EventType.SUBSCRIPTION_ENDED_TV_COMPLETED: "telegram.titles.subscriptionEnded", + EventType.SUBSCRIPTION_ENDED_TV_UPGRADE_COMPLETED: "telegram.titles.subscriptionEnded", + EventType.SUBSCRIPTION_ENDED_TV_TARGET_COMPLETED: "telegram.titles.subscriptionEnded", + } + if event.type in keys: + return keys[event.type] + return "telegram.titles.generic" + + +def format_telegram_event(event: Event, *, locale: str, public_base_url: str) -> str: + meta = _meta(event) + title = f"[Aethera] {_text(_title_key(event), locale)}" + lines = [ + f"*{escape_markdown(title)}*", + escape_markdown(_media_line(event, meta, locale)), + escape_markdown(_resource_line(event, meta, locale)), + escape_markdown(_reason_line(event, meta, locale)), + escape_markdown(_time_line(event.ts, locale)), + ] + detail_url = _detail_url(event, public_base_url) + if detail_url: + lines.append(f"[{escape_markdown(_text('telegram.actions.viewDetails', locale))}]({escape_markdown(detail_url)})") + return "\n".join(line for line in lines if line) diff --git a/backend/app/services/platform/notification_channel_service.py b/backend/app/services/platform/notification_channel_service.py index 6778936..b3b8296 100644 --- a/backend/app/services/platform/notification_channel_service.py +++ b/backend/app/services/platform/notification_channel_service.py @@ -5,7 +5,6 @@ import pkgutil from abc import ABC, abstractmethod -from app.core.feature_flags import telegram_notifications_enabled from app.schemas.exception.exceptions import ConfigurationException from app.schemas.config import NotificationChannelConfig from app.schemas.domain.event import Event @@ -43,8 +42,6 @@ def discover_and_register(self) -> None: for _, module_name, is_pkg in pkgutil.iter_modules([package_path]): if not is_pkg: continue - if module_name == "telegram" and not telegram_notifications_enabled(): - continue importlib.import_module(f"app.services.integration.notifications.channels.{module_name}") def get_channel(self, channel_type: str) -> BaseNotificationChannel: diff --git a/backend/tests/test_addon_service_contracts.py b/backend/tests/test_addon_service_contracts.py index b151532..1541c24 100644 --- a/backend/tests/test_addon_service_contracts.py +++ b/backend/tests/test_addon_service_contracts.py @@ -2,7 +2,8 @@ from app.schemas.constants.event_types import EventTypes from app.addons.registry import AddonDescriptor, AddonJobSpec, addon_service -from app.schemas.exception.exceptions import ConfigurationException +from app.addons.descriptors import _notifications_enabled +from app.schemas.config import AddonsConfig, TelegramNotificationChannelConfig from app.services.platform.auth_provider_service import auth_provider_service from app.services.platform.notification_channel_service import notification_channel_service @@ -12,7 +13,7 @@ def noop(): def dummy_event_patterns(): - return [EventTypes.DOWNLOAD_STARTED] + return [EventTypes.DOWNLOAD_COMPLETED] def dummy_jobs(): @@ -45,20 +46,66 @@ def test_list_addon_jobs_aggregates(self): def test_event_pattern_contracts(self): patterns = build_dummy_addon().subscribed_event_patterns() - self.assertIn(EventTypes.DOWNLOAD_STARTED, patterns) + self.assertIn(EventTypes.DOWNLOAD_COMPLETED, patterns) def test_oidc_provider_discovery_is_disabled_by_default(self): auth_provider_service.discover_and_register() self.assertFalse(auth_provider_service.supports("oidc")) - def test_telegram_channel_discovery_is_disabled_by_default(self): + def test_telegram_channel_discovery_registers_channel_by_default(self): notification_channel_service.discover_and_register() - self.assertFalse(notification_channel_service.supports("telegram")) + self.assertTrue(notification_channel_service.supports("telegram")) - def test_disabled_telegram_channel_is_not_resolvable(self): + def test_telegram_channel_is_resolvable_by_default(self): notification_channel_service.discover_and_register() - with self.assertRaises(ConfigurationException): - notification_channel_service.get_channel("telegram") + self.assertEqual(notification_channel_service.get_channel("telegram").channel_type, "telegram") + + def test_notifications_addon_requires_configured_enabled_channel(self): + notification_channel_service.discover_and_register() + + self.assertFalse( + _notifications_enabled( + AddonsConfig( + notifications={ + "enabled": True, + "channels": [TelegramNotificationChannelConfig(name="Telegram", bot_token="", chat_id="chat")], + } + ) + ) + ) + self.assertFalse( + _notifications_enabled( + AddonsConfig( + notifications={ + "enabled": True, + "channels": [ + TelegramNotificationChannelConfig( + name="Telegram", + enabled=False, + bot_token="token", + chat_id="chat", + ) + ], + } + ) + ) + ) + self.assertTrue( + _notifications_enabled( + AddonsConfig( + notifications={ + "enabled": True, + "channels": [ + TelegramNotificationChannelConfig( + name="Telegram", + bot_token="token", + chat_id="chat", + ) + ], + } + ) + ) + ) diff --git a/backend/tests/test_alert_service.py b/backend/tests/test_alert_service.py deleted file mode 100644 index f84fe00..0000000 --- a/backend/tests/test_alert_service.py +++ /dev/null @@ -1,56 +0,0 @@ -from datetime import datetime - -from app.schemas.domain.alert import ( - AlertCategory, - AlertRaiseRequest, - AlertRecord, - AlertSeverity, - AlertStatus, -) -from app.services.domain.alerts.service import AlertService - - -class _FakeAlertRepository: - def __init__(self, existing: AlertRecord | None = None) -> None: - self.alert = existing - - def find_by_fingerprint(self, fingerprint: str) -> AlertRecord | None: - if self.alert and self.alert.fingerprint == fingerprint: - return self.alert - return None - - def upsert(self, alert: AlertRecord) -> AlertRecord: - self.alert = alert - return alert - - -def _request() -> AlertRaiseRequest: - return AlertRaiseRequest( - fingerprint="task.transfer:task-1", - severity=AlertSeverity.error, - category=AlertCategory.task_transfer, - message_key="alertMessages.taskTransferFailed", - message_params={"task": "Task", "reason_key": "backendErrors.transferFailed"}, - ) - - -def test_repeated_active_alert_clears_acknowledgement(): - service = AlertService() - service.repo = _FakeAlertRepository( - AlertRecord( - fingerprint="task.transfer:task-1", - status=AlertStatus.active, - severity=AlertSeverity.error, - category=AlertCategory.task_transfer, - message_key="alertMessages.taskTransferFailed", - message_params={"task": "Task"}, - acknowledged_at=datetime.now(), - occurrence_count=1, - ) - ) - - alert = service.raise_alert(_request()) - - assert alert.status == AlertStatus.active - assert alert.acknowledged_at is None - assert alert.occurrence_count == 2 diff --git a/backend/tests/test_config_test_connection.py b/backend/tests/test_config_test_connection.py index 4dea784..9745a24 100644 --- a/backend/tests/test_config_test_connection.py +++ b/backend/tests/test_config_test_connection.py @@ -5,6 +5,7 @@ TestConnectionConfig as ConnectionConfigPayload, TestServiceConnectionRequest as ServiceConnectionRequestPayload, ) +from app.schemas.exception import TestConnectionException as AppTestConnectionException @pytest.mark.asyncio @@ -121,3 +122,61 @@ async def fake_test_connection_for_config(config): assert response.ok is True assert response.client_type == "douban" assert seen_discover_lists == [["movie_hot_gaia", "tv_hot", "tv_animation", "tv_variety_show"]] + + +@pytest.mark.asyncio +async def test_test_connection_accepts_telegram_config(monkeypatch): + seen_configs = [] + + async def fake_test_telegram_connection_for_config(config): + seen_configs.append((config.type, config.bot_token, config.chat_id)) + return True + + monkeypatch.setattr( + test_connection.notifications_integration, + "test_telegram_connection_for_config", + fake_test_telegram_connection_for_config, + ) + + response = await test_connection.test_service_connection( + ServiceConnectionRequestPayload( + type="telegram", + config=ConnectionConfigPayload( + bot_token="bot-token", + chat_id="chat-id", + ), + ) + ) + + assert response.ok is True + assert response.client_type == "telegram" + assert seen_configs == [("telegram", "bot-token", "chat-id")] + + +@pytest.mark.asyncio +async def test_test_connection_reports_telegram_failure_reason(monkeypatch): + async def fake_test_telegram_connection_for_config(config): + raise RuntimeError("Telegram bot token format is invalid") + + monkeypatch.setattr( + test_connection.notifications_integration, + "test_telegram_connection_for_config", + fake_test_telegram_connection_for_config, + ) + + with pytest.raises(AppTestConnectionException) as exc_info: + await test_connection.test_service_connection( + ServiceConnectionRequestPayload( + type="telegram", + config=ConnectionConfigPayload( + bot_token="invalid-token", + chat_id="chat-id", + ), + ) + ) + + assert exc_info.value.message_key == "backendErrors.testConnectionFailedWithReason" + assert exc_info.value.params == { + "service": "telegram", + "reason": "Telegram bot token format is invalid", + } diff --git a/backend/tests/test_danmu_event_levels.py b/backend/tests/test_danmu_event_levels.py new file mode 100644 index 0000000..14e9d89 --- /dev/null +++ b/backend/tests/test_danmu_event_levels.py @@ -0,0 +1,73 @@ +from pathlib import Path + +from app.schemas.domain.event import EventLevel, EventType +from app.schemas.domain.media import MediaIdentity +from app.schemas.media_id import MediaID +from app.services.audit import workflow_event_emitters + + +def _media() -> MediaIdentity: + return MediaIdentity(media_id=MediaID.parse("tmdb:tv:1"), season_number=1, title="Test Show", year=2026) + + +def test_danmu_not_found_event_is_info(monkeypatch): + captured = {} + monkeypatch.setattr( + workflow_event_emitters.event_service, + "emit_media", + lambda event, meta=None: captured.setdefault("event", event), + ) + + workflow_event_emitters.emit_danmu_generate_event( + EventType.DANMU_GENERATE_FAILED, + _media(), + Path("/data/Test Show S01E01.mkv"), + 1, + "action-1", + "task-1", + error_key="runtimeReasons.danmuNotFound", + ) + + assert captured["event"].level == EventLevel.info + + +def test_danmu_duration_mismatch_event_is_info(monkeypatch): + captured = {} + monkeypatch.setattr( + workflow_event_emitters.event_service, + "emit_media", + lambda event, meta=None: captured.setdefault("event", event), + ) + + workflow_event_emitters.emit_danmu_generate_event( + EventType.DANMU_GENERATE_FAILED, + _media(), + Path("/data/Test Show S01E01.mkv"), + 1, + "action-1", + "task-1", + error_key="runtimeReasons.danmuDurationMismatch", + ) + + assert captured["event"].level == EventLevel.info + + +def test_danmu_unexpected_failure_event_stays_error(monkeypatch): + captured = {} + monkeypatch.setattr( + workflow_event_emitters.event_service, + "emit_media", + lambda event, meta=None: captured.setdefault("event", event), + ) + + workflow_event_emitters.emit_danmu_generate_event( + EventType.DANMU_GENERATE_FAILED, + _media(), + Path("/data/Test Show S01E01.mkv"), + 1, + "action-1", + "task-1", + error="provider failed", + ) + + assert captured["event"].level == EventLevel.error diff --git a/backend/tests/test_download_default_tag.py b/backend/tests/test_download_default_tag.py index 2ff978a..eff19da 100644 --- a/backend/tests/test_download_default_tag.py +++ b/backend/tests/test_download_default_tag.py @@ -7,7 +7,7 @@ from app.clients.qbittorrent import QBittorrentClient from app.schemas.config import DownloadConfig, QBittorrentConfig, SystemConfig -from app.schemas.domain.download import DownloadTaskCreateInput, TaskContext, TaskData, TaskStatus +from app.schemas.domain.download import DownloadFileInfo, DownloadTaskCreateInput, TaskContext, TaskData, TaskStatus from app.schemas.domain.media import MediaExecutionSnapshot from app.schemas.domain.media_types import MediaType from app.schemas.domain.resource_attributes import ResourceAttributes @@ -172,11 +172,6 @@ async def fake_download_lock(*args, **kwargs): "app.services.domain.download.lifecycle.domain_lock_service.acquire_download_create", fake_download_lock, ) - monkeypatch.setattr( - "app.services.domain.download.lifecycle.event_service.emit_media", - lambda *args, **kwargs: None, - ) - await service.create_download( DownloadTaskCreateInput( media=media, @@ -344,6 +339,7 @@ async def insert(self, task): raise AssertionError("existing task should be reused") async def update_task(self, task): + assert priorities == [("abc123", [0, 1, 2, 3, 4], 1)] updated["task"] = task return True @@ -355,6 +351,15 @@ async def set_file_priority(self, torrent_hash, file_ids, priority): priorities.append((torrent_hash, list(file_ids), priority)) return True + async def get_torrent_files(self, torrent_hash): + return [ + DownloadFileInfo(index=0, name="Test.Show.S01E01.mkv", size=1, progress=1.0, priority=1), + DownloadFileInfo(index=1, name="Test.Show.S01E02.mkv", size=1, progress=1.0, priority=1), + DownloadFileInfo(index=2, name="Test.Show.S01E03.mkv", size=1, progress=1.0, priority=1), + DownloadFileInfo(index=3, name="Test.Show.S01E04.mkv", size=1, progress=0.5, priority=1), + DownloadFileInfo(index=4, name="Test.Show.S01E05.mkv", size=1, progress=0.0, priority=1), + ] + class FakeClientFactory: def get_download_client(self, downloader_id): return FakeClient() @@ -395,11 +400,6 @@ async def fake_download_lock(*args, **kwargs): "app.services.domain.download.lifecycle.domain_lock_service.acquire_download_create", fake_download_lock, ) - monkeypatch.setattr( - "app.services.domain.download.lifecycle.event_service.emit_media", - lambda *args, **kwargs: None, - ) - task = await service.create_download( DownloadTaskCreateInput( media=media, @@ -413,6 +413,7 @@ async def fake_download_lock(*args, **kwargs): assert task.id == "task-1" assert updated["task"].context.selected_files == [0, 1, 2, 3, 4] assert updated["task"].status == TaskStatus.DOWNLOADING + assert updated["task"].progress == pytest.approx(0.7) assert priorities == [("abc123", [0, 1, 2, 3, 4], 1)] @@ -534,11 +535,6 @@ async def fake_download_lock(*args, **kwargs): "app.services.domain.download.lifecycle.settings_service.list_downloaders", lambda enabled_only=False: [], ) - monkeypatch.setattr( - "app.services.domain.download.lifecycle.event_service.emit_media", - lambda *args, **kwargs: None, - ) - task = await service.create_download( DownloadTaskCreateInput( media=season_two_media, diff --git a/backend/tests/test_download_runtime_drift.py b/backend/tests/test_download_runtime_drift.py index 490b0fc..a6e3edc 100644 --- a/backend/tests/test_download_runtime_drift.py +++ b/backend/tests/test_download_runtime_drift.py @@ -175,6 +175,36 @@ async def test_sync_active_downloads_marks_pending_task_as_downloading_when_prog update_task_state.assert_awaited_once_with(task.id, TaskStatus.DOWNLOADING, None, 0.42, None) +@pytest.mark.asyncio +async def test_sync_active_downloads_completed_event_uses_live_torrent_progress(monkeypatch): + task = _task(status=TaskStatus.DOWNLOADING) + task.progress = 0.96 + torrent = TorrentStatus( + hash=task.torrent_hash, + name="Torrent", + size=100, + progress=1.0, + state=TorrentState.SEEDING, + downloader_id="downloader-1", + ) + captured = {} + monkeypatch.setattr( + "app.services.domain.download.task_runtime_service.event_service", + type("FakeEventService", (), {"emit_media": lambda self, event, meta=None: captured.setdefault("meta", meta)})(), + ) + service = TaskRuntimeService(_FakeRepo(task), _FakeClientFactory(torrent_statuses=[torrent])) + update_task_state = AsyncMock(return_value=True) + + result = await service.sync_active_downloads( + get_tasks_by_statuses=AsyncMock(return_value=[task]), + update_task_state=update_task_state, + ) + + assert result.updated == 1 + assert captured["meta"].progress == 1.0 + update_task_state.assert_awaited_once_with(task.id, TaskStatus.FINISHED, None, 1.0, None) + + @pytest.mark.asyncio async def test_recover_stuck_transferring_tasks_force_recovers_all_transferring_tasks(): task = _task(status=TaskStatus.TRANSFERRING) diff --git a/backend/tests/test_event_center.py b/backend/tests/test_event_center.py new file mode 100644 index 0000000..3b70ccd --- /dev/null +++ b/backend/tests/test_event_center.py @@ -0,0 +1,90 @@ +from datetime import datetime +from types import SimpleNamespace + +from app.schemas.domain.action import ActionKind, ActionRecord, ActionStatus +from app.schemas.domain.event import Event, EventCenterBellState, EventLevel, EventType +from app.services.audit.event_service import EventService + + +class _FakeEventRepository: + def __init__(self) -> None: + self.events = [ + Event(id="event-error", type=EventType.MEDIA_IMPORT_FAILED, level=EventLevel.error, ts=datetime(2026, 1, 2)), + Event(id="event-warning", type=EventType.LIBRARY_FILE_MISSING, level=EventLevel.warning, ts=datetime(2026, 1, 1)), + Event(id="event-info", type=EventType.MEDIA_IMPORT_COMPLETED, level=EventLevel.info, ts=datetime(2026, 1, 3)), + ] + self.acknowledged_ids = set() + + def list_filtered_page(self, *, limit, offset, levels=None, acknowledged=None, **_kwargs): + events = [event for event in self.events if not levels or event.level in levels] + if acknowledged is not None: + events = [event for event in events if (event.id in self.acknowledged_ids) is acknowledged] + return len(events), events[offset: offset + limit] + + def acknowledge_event(self, event_id: str) -> bool: + if not any(event.id == event_id for event in self.events): + return False + self.acknowledged_ids.add(event_id) + return True + + def acknowledge_attention_events(self) -> int: + attention_events = [event for event in self.events if event.level in {EventLevel.warning, EventLevel.error}] + unacknowledged_events = [event for event in attention_events if event.id not in self.acknowledged_ids] + self.acknowledged_ids.update(event.id for event in unacknowledged_events) + return len(unacknowledged_events) + + +def test_event_center_uses_warning_error_events_and_running_actions(monkeypatch): + service = EventService() + service.repo = _FakeEventRepository() + monkeypatch.setattr( + "app.services.audit.event_service.action_service", + SimpleNamespace( + list_actions=lambda **_kwargs: ( + 1, + [ActionRecord(id="action-1", kind=ActionKind.addon, action_name="notification.send", status=ActionStatus.running)], + ) + ), + ) + + center = service.get_center() + + assert center.summary.bell_state == EventCenterBellState.error + assert center.summary.error_event_count == 1 + assert center.summary.warning_event_count == 1 + assert center.summary.active_action_count == 1 + assert [event.id for event in center.events] == ["event-error", "event-warning"] + + +def test_event_center_hides_acknowledged_warning_error_events(monkeypatch): + service = EventService() + service.repo = _FakeEventRepository() + monkeypatch.setattr( + "app.services.audit.event_service.action_service", + SimpleNamespace(list_actions=lambda **_kwargs: (0, [])), + ) + + assert service.acknowledge_event("event-error") is True + center = service.get_center() + + assert center.summary.bell_state == EventCenterBellState.warning + assert center.summary.error_event_count == 0 + assert center.summary.warning_event_count == 1 + assert [event.id for event in center.events] == ["event-warning"] + + +def test_event_center_acknowledges_all_attention_events(monkeypatch): + service = EventService() + service.repo = _FakeEventRepository() + monkeypatch.setattr( + "app.services.audit.event_service.action_service", + SimpleNamespace(list_actions=lambda **_kwargs: (0, [])), + ) + + assert service.acknowledge_attention_events() == 2 + center = service.get_center() + + assert center.summary.bell_state == EventCenterBellState.idle + assert center.summary.error_event_count == 0 + assert center.summary.warning_event_count == 0 + assert center.events == [] diff --git a/backend/tests/test_event_message_builder.py b/backend/tests/test_event_message_builder.py index 16910bc..aa2404b 100644 --- a/backend/tests/test_event_message_builder.py +++ b/backend/tests/test_event_message_builder.py @@ -7,10 +7,7 @@ from app.schemas.media_id import MediaID from app.services.audit.event_message_builder import ( build_download_completed_message, - build_download_started_message, build_media_import_completed_message, - build_pilot_episode_queued_message, - build_subscription_run_completed_message, ) @@ -55,16 +52,6 @@ def _task() -> TaskData: ) -def test_build_download_started_message_includes_torrent_and_selection_context(): - message = build_download_started_message(_task(), "qBittorrent") - - assert "Example.Show.S01.1080p" in message - assert "site mteam" in message - assert "downloader qBittorrent" in message - assert "2/3 files selected" in message - assert "hash 12345678" in message - - def test_build_download_completed_message_prefers_resource_title_over_metadata_name(): task = _task() task.metadata.name = "S01E01.mkv" @@ -105,28 +92,3 @@ def test_build_media_import_completed_message_includes_file_count_episode_and_ta assert "episodes 1-2 covered" in message assert "S01E01.mkv" in message - -def test_build_subscription_run_completed_message_is_explicit(): - message = build_subscription_run_completed_message(checked=5, added=2) - - assert message == "Subscription check completed (5 candidates matched, 2 download tasks created)" - - -def test_build_pilot_episode_queued_message_includes_directory_and_site_count(): - media = MediaIdentity(media_id=MediaID.parse("tmdb:tv:1"), title="Sample", year=2024) - - message = build_pilot_episode_queued_message(media, directory_id="tv-default", sites=["mteam", "pter"]) - - assert "Sample" not in message - assert "directory tv-default" in message - assert "2 sites" in message - - -def test_build_pilot_episode_queued_message_uses_download_wording_for_movie(): - media = MediaIdentity(media_id=MediaID.parse("tmdb:movie:1"), title="Sample", year=2024) - - message = build_pilot_episode_queued_message(media, directory_id="movie-default", sites=None) - - assert "Download task submitted" in message - assert "directory movie-default" in message - assert "default sites" in message diff --git a/backend/tests/test_event_service.py b/backend/tests/test_event_service.py index c26004f..2290242 100644 --- a/backend/tests/test_event_service.py +++ b/backend/tests/test_event_service.py @@ -15,7 +15,7 @@ def test_emit_and_list_events_basic(): media_id = MediaID.parse(f"douban:movie:{uuid.uuid4().hex}") ev = svc.emit_media( MediaEventCreate( - type=EventTypes.DOWNLOAD_STARTED, + type=EventTypes.DOWNLOAD_COMPLETED, level=EventLevel.info, message="created", media=MediaIdentity(media_id=media_id, title="text1", year=2024), @@ -34,7 +34,7 @@ def test_emit_media_merges_default_and_explicit_message_params(): media_id = MediaID.parse(f"douban:movie:{uuid.uuid4().hex}") ev = svc.emit_media( MediaEventCreate( - type=EventTypes.DOWNLOAD_STARTED, + type=EventTypes.DOWNLOAD_COMPLETED, level=EventLevel.info, media=MediaIdentity(media_id=media_id, title="text1", year=2024), message_params={"downloader_id": "d1"}, @@ -52,7 +52,7 @@ def test_list_events_filters(): media_id_2 = MediaID.parse(f"douban:movie:{uuid.uuid4().hex}") svc.emit_media( MediaEventCreate( - type=EventTypes.DOWNLOAD_STARTED, + type=EventTypes.DOWNLOAD_FAILED, message="hello", media=MediaIdentity(media_id=media_id_1, title="text1", year=2024), ) @@ -74,7 +74,7 @@ def test_list_events_filters_tv_seasons(): media_id = MediaID.parse(f"tmdb:tv:{uuid.uuid4().int % 100000000}") season_one_event = svc.emit_media( MediaEventCreate( - type=EventTypes.DOWNLOAD_STARTED, + type=EventTypes.DOWNLOAD_FAILED, media=MediaIdentity(media_id=media_id, season_number=1, title="text1", year=2024), ) ) diff --git a/backend/tests/test_indexer_site_health_alerts.py b/backend/tests/test_indexer_site_health_alerts.py index f7d01ae..eca92d4 100644 --- a/backend/tests/test_indexer_site_health_alerts.py +++ b/backend/tests/test_indexer_site_health_alerts.py @@ -1,5 +1,4 @@ from app.schemas.runtime.indexer_site_health import IndexerSiteHealthStatus -from app.services.config import indexer_client_settings from app.services.config.indexer_client_settings import IndexerSiteHealthState @@ -25,10 +24,7 @@ def get_all(self) -> list[IndexerSiteHealthStatus]: return list(self.records.values()) -def test_indexer_site_failure_refreshes_alert_after_threshold(monkeypatch): - raised = [] - monkeypatch.setattr(indexer_client_settings, "raise_indexer_site_alert", lambda **kwargs: raised.append(kwargs)) - monkeypatch.setattr(indexer_client_settings, "resolve_indexer_site_alert", lambda *_args, **_kwargs: None) +def test_indexer_site_failure_marks_notify_pending_after_threshold(): state = IndexerSiteHealthState(repo=_FakeIndexerSiteHealthRepository()) for failure_count in range(1, 5): @@ -42,20 +38,9 @@ def test_indexer_site_failure_refreshes_alert_after_threshold(monkeypatch): assert status.consecutive_failures == 4 assert status.notify_pending is True - assert len(raised) == 2 - assert raised[0]["indexer_id"] == "jackett" - assert raised[0]["site_id"] == "audiences" - assert raised[0]["consecutive_failures"] == 3 - assert raised[0]["error"] == "failure 3" - assert raised[1]["consecutive_failures"] == 4 - assert raised[1]["error"] == "failure 4" - - -def test_indexer_site_success_resolves_alert_and_allows_future_threshold_alert(monkeypatch): - raised = [] - resolved = [] - monkeypatch.setattr(indexer_client_settings, "raise_indexer_site_alert", lambda **kwargs: raised.append(kwargs)) - monkeypatch.setattr(indexer_client_settings, "resolve_indexer_site_alert", lambda *args: resolved.append(args)) + + +def test_indexer_site_success_clears_notify_pending_and_allows_future_threshold(): state = IndexerSiteHealthState(repo=_FakeIndexerSiteHealthRepository()) for _ in range(3): @@ -86,7 +71,7 @@ def test_indexer_site_success_resolves_alert_and_allows_future_threshold_alert(m assert recovered.status == "healthy" assert recovered.consecutive_failures == 0 assert recovered.notify_pending is False - assert resolved == [("jackett", "audiences")] - assert len(raised) == 2 - assert raised[0]["consecutive_failures"] == 3 - assert raised[1]["consecutive_failures"] == 3 + status = state._get_record("jackett", "audiences") + assert status is not None + assert status.consecutive_failures == 3 + assert status.notify_pending is True diff --git a/backend/tests/test_message_renderer.py b/backend/tests/test_message_renderer.py index e78a085..a3324e2 100644 --- a/backend/tests/test_message_renderer.py +++ b/backend/tests/test_message_renderer.py @@ -52,7 +52,7 @@ def test_action_search_text_includes_rendered_command_message(): def test_download_event_message_uses_resource_title(): - event = MediaEventCreate(type=EventTypes.DOWNLOAD_STARTED, media=_media(), task_id="task-1") + event = MediaEventCreate(type=EventTypes.DOWNLOAD_COMPLETED, media=_media(), task_id="task-1") params = event_message_params( event, DownloadTaskEventMeta( @@ -73,7 +73,7 @@ def test_download_event_message_uses_resource_title(): def test_download_event_message_prefers_torrent_name_over_resource_title(): - event = MediaEventCreate(type=EventTypes.DOWNLOAD_STARTED, media=_media(), task_id="task-1") + event = MediaEventCreate(type=EventTypes.DOWNLOAD_COMPLETED, media=_media(), task_id="task-1") params = event_message_params( event, DownloadTaskEventMeta( @@ -113,7 +113,7 @@ def test_media_import_event_message_includes_first_file_name(): def test_media_server_sync_message_includes_target_file_name(): - event = MediaEventCreate(type=EventTypes.MEDIA_SERVER_SYNC_STARTED, media=_media(), task_id="task-1") + event = MediaEventCreate(type=EventTypes.MEDIA_SERVER_SYNC_COMPLETED, media=_media(), task_id="task-1") params = event_message_params( event, MediaServerSyncEventMeta( @@ -130,7 +130,7 @@ def test_media_server_sync_message_includes_target_file_name(): def test_danmu_event_message_includes_video_file_name(): - event = MediaEventCreate(type=EventTypes.DANMU_GENERATE_STARTED, media=_media(), task_id="task-1") + event = MediaEventCreate(type=EventTypes.DANMU_GENERATE_COMPLETED, media=_media(), task_id="task-1") params = event_message_params( event, DanmuGenerateEventMeta( diff --git a/backend/tests/test_scheduled_transfer_command_service.py b/backend/tests/test_scheduled_transfer_command_service.py index e8ec556..3f67cd5 100644 --- a/backend/tests/test_scheduled_transfer_command_service.py +++ b/backend/tests/test_scheduled_transfer_command_service.py @@ -25,6 +25,10 @@ async def test_enqueue_finished_tasks_counts_conflicts_as_skips_and_runtime_fail "app.services.application.workflows.scheduled_transfer.service.download_service.get_tasks", AsyncMock(return_value=tasks), ) + monkeypatch.setattr( + "app.services.application.workflows.scheduled_transfer.service.missing_transfer_source_paths", + AsyncMock(return_value=[]), + ) create_command_mock = AsyncMock( side_effect=[object(), CommandConflictException(), RuntimeError("worker down")] ) @@ -38,3 +42,28 @@ async def test_enqueue_finished_tasks_counts_conflicts_as_skips_and_runtime_fail assert result.processed == 3 assert result.completed == 1 assert result.errors == 1 + + +@pytest.mark.asyncio +async def test_enqueue_finished_tasks_delays_transfer_when_sources_are_not_visible(monkeypatch): + tasks = [SimpleNamespace(id="task-1")] + monkeypatch.setattr( + "app.services.application.workflows.scheduled_transfer.service.download_service.get_tasks", + AsyncMock(return_value=tasks), + ) + monkeypatch.setattr( + "app.services.application.workflows.scheduled_transfer.service.missing_transfer_source_paths", + AsyncMock(return_value=["/downloads/missing.mkv"]), + ) + create_command_mock = AsyncMock() + monkeypatch.setattr( + "app.services.application.workflows.scheduled_transfer.service.command_service.create_command", + create_command_mock, + ) + + result = await scheduled_transfer_command_service.enqueue_finished_tasks() + + assert result.processed == 1 + assert result.completed == 0 + assert result.errors == 0 + create_command_mock.assert_not_awaited() diff --git a/backend/tests/test_subscription_execution_drift.py b/backend/tests/test_subscription_execution_drift.py index f24dc9a..4240cee 100644 --- a/backend/tests/test_subscription_execution_drift.py +++ b/backend/tests/test_subscription_execution_drift.py @@ -419,6 +419,65 @@ async def fake_fetch_payload(result): assert selected[0][2].resources.title == "Show.S01E01.1080p.WEB-DL" +@pytest.mark.asyncio +async def test_select_resources_continues_after_known_candidate_payload_misses_targets(monkeypatch): + stale_pack = _video_resource("Show.S01.2160p.WEB-DL", [45, 46, 47, 48, 49, 50, 51, 52], seeders=50) + episode_45 = _video_resource("Show.S01E45.2160p.WEB-DL", [45], seeders=20) + payloads = { + stale_pack.resources.title: TorrentPayload( + metadata=TorrentMetadata( + hash="hash-stale", + name=stale_pack.resources.title, + size=40, + files=[ + TorrentFileItem( + index=index, + filename=f"Show.S01E{episode:02d}.mkv", + size=1, + attrs=ResourceAttributes( + title=f"Show.S01E{episode:02d}", + seasons=[1], + episodes=[episode], + sources=["WEB-DL"], + resource_form="Video File", + ), + ) + for index, episode in enumerate(range(1, 41)) + ], + attrs=ResourceAttributes( + title=stale_pack.resources.title, + seasons=[1], + episodes=list(range(1, 41)), + sources=["WEB-DL"], + resource_form="Video File", + ), + coverage_kind="exact_episodes", + ), + blob=b"stale", + ), + episode_45.resources.title: TorrentPayload(metadata=_video_metadata(episode_45.resources.title, [45]), blob=b"episode"), + } + + async def fake_fetch_payload(result): + return payloads[result.title] + + monkeypatch.setattr( + "app.services.domain.resource.selection.fetch_torrent_payload", + fake_fetch_payload, + ) + + selected = await select_resources( + [stale_pack, episode_45], + episodes={45}, + filters=SubscriptionFilters(resource_kind=["video_file"]), + episode_mode=True, + ) + + assert len(selected) == 1 + assert selected[0][2].resources.title == episode_45.resources.title + assert selected[0][1] == [0] + + @pytest.mark.asyncio async def test_original_disc_category_only_selects_disc_package(monkeypatch): resources = [_disc_resource("Show.S01.Disc.1.of.1", seeders=20), _video_resource("Show.S01E01.1080p.WEB-DL", [1], seeders=50)] diff --git a/backend/tests/test_subscription_execution_end_current.py b/backend/tests/test_subscription_execution_end_current.py index 095043c..f3c9890 100644 --- a/backend/tests/test_subscription_execution_end_current.py +++ b/backend/tests/test_subscription_execution_end_current.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock import pytest @@ -355,18 +355,14 @@ async def test_run_one_does_not_emit_event_when_no_resources(monkeypatch): "app.services.application.workflows.subscription.run.resource_search_service.search_media", AsyncMock(return_value=[]), ) - emit_media = Mock() - monkeypatch.setattr("app.services.application.workflows.subscription.run.event_service.emit_media", emit_media) - result = await service.run_one(sub) assert result.checked == 0 assert result.added == 0 - emit_media.assert_not_called() @pytest.mark.asyncio -async def test_run_one_emits_failed_event_when_subscription_is_invalid(monkeypatch): +async def test_run_one_does_not_emit_event_when_subscription_is_invalid(monkeypatch): service = SubscriptionRunApplicationService() media = MediaExecutionSnapshot( media_id=MediaID.parse("tmdb:tv:1"), @@ -398,22 +394,14 @@ async def test_run_one_emits_failed_event_when_subscription_is_invalid(monkeypat "app.services.application.workflows.subscription.run.subscription_completion_checker.check", AsyncMock(return_value=None), ) - emit_media = Mock() - monkeypatch.setattr("app.services.application.workflows.subscription.run.event_service.emit_media", emit_media) - result = await service.run_one(sub) assert result.checked == 0 assert result.added == 0 - emit_media.assert_called_once() - event = emit_media.call_args.args[0] - assert event.type.value == "subscription.run.failed" - assert event.message_key is None - assert event.message_params == {"reason_key": "backendErrors.subscriptionRunEpisodeCountMissing"} @pytest.mark.asyncio -async def test_run_one_emits_failed_event_when_all_selected_resources_fail_to_queue(monkeypatch): +async def test_run_one_does_not_emit_event_when_all_selected_resources_fail_to_queue(monkeypatch): service = SubscriptionRunApplicationService() sub = _movie_subscription() resource_result = ResourceSearchResult( @@ -458,17 +446,10 @@ async def test_run_one_emits_failed_event_when_all_selected_resources_fail_to_qu "app.services.application.workflows.subscription.run.command_service.create_command", AsyncMock(side_effect=DownloadException("queue failed")), ) - emit_media = Mock() - monkeypatch.setattr("app.services.application.workflows.subscription.run.event_service.emit_media", emit_media) - result = await service.run_one(sub) assert result.checked == 1 assert result.added == 0 - emit_media.assert_called_once() - event = emit_media.call_args.args[0] - assert event.type.value == "subscription.run.failed" - assert event.message_params == {"reason_key": "subscriptionRunMessages.queueFailed"} @pytest.mark.asyncio diff --git a/backend/tests/test_task_downloader_change.py b/backend/tests/test_task_downloader_change.py index a4e0b49..14563af 100644 --- a/backend/tests/test_task_downloader_change.py +++ b/backend/tests/test_task_downloader_change.py @@ -296,13 +296,7 @@ async def no_library_files(task_id): assert repo.updated.downloader_id == "old" assert service._migration_repo.items[0].target_downloader_id == "new" assert service._migration_repo.items[0].action_id == f"storage-migration:{service._migration_repo.items[0].id}" - assert len(emitted_events) == 1 - event, meta = emitted_events[0] - assert event.type == EventTypes.DOWNLOAD_TASK_STORAGE_CHANGE_STARTED - assert event.action_id == service._migration_repo.items[0].action_id - assert event.message_params["source_downloader_id"] == "old" - assert event.message_params["target_downloader_id"] == "new" - assert meta.task_id == "task-1" + assert emitted_events == [] target_path.joinpath("Movie").touch() target_client.info.progress = 1.0 @@ -317,8 +311,7 @@ async def no_library_files(task_id): assert source_client.deleted == [("abc", False)] assert service._migration_repo.items[0].status == TaskStorageMigrationStatus.FINALIZED assert action_updates == [("completed", service._migration_repo.items[0].action_id)] - assert emitted_events[-1][0].type == EventTypes.DOWNLOAD_TASK_STORAGE_CHANGED - assert emitted_events[-1][0].action_id == service._migration_repo.items[0].action_id + assert emitted_events == [] @pytest.mark.asyncio diff --git a/backend/tests/test_telegram_notification_renderer.py b/backend/tests/test_telegram_notification_renderer.py new file mode 100644 index 0000000..b3fb657 --- /dev/null +++ b/backend/tests/test_telegram_notification_renderer.py @@ -0,0 +1,75 @@ +import json +from datetime import datetime + +from app.schemas.domain.event import Event, EventLevel, EventType +from app.schemas.domain.media import MediaIdentity +from app.schemas.media_id import MediaID +from app.services.integration.notifications.channels.telegram.renderer import format_telegram_event + + +def _media() -> MediaIdentity: + return MediaIdentity( + media_id=MediaID.parse("tmdb:tv:273240"), + title="校园之外", + year=2026, + season_number=1, + ) + + +def test_telegram_download_completed_uses_user_summary_in_zh_cn(): + event = Event( + type=EventType.DOWNLOAD_COMPLETED, + level=EventLevel.info, + media=_media(), + task_id="task-1", + ts=datetime(2026, 6, 2, 20, 30), + meta=json.dumps( + { + "resource_title": "Off.Campus.S01.2160p.AMZN.WEB-DL", + "selected_episodes": [1, 2, 3], + "task_id": "task-1", + }, + ensure_ascii=False, + ), + ) + + message = format_telegram_event(event, locale="zh-CN", public_base_url="https://ae.example.com/") + + assert "\\[Aethera\\]" in message + assert "下载已完成" in message + assert "校园之外" in message + assert "第 1 季" in message + assert "第 1\\-3 集" in message + assert "资源:" in message + assert "时间:2026\\-06\\-02 20:30" in message + assert "查看详情" in message + assert "https://ae\\.example\\.com/media/tmdb:tv:273240?season\\_number\\=1" in message + assert "download.completed" not in message + assert "task-1" not in message + assert "meta:" not in message + + +def test_telegram_failed_event_uses_locale_and_reason(): + event = Event( + type=EventType.MEDIA_IMPORT_FAILED, + level=EventLevel.error, + media=_media(), + ts=datetime(2026, 6, 2, 20, 30), + meta=json.dumps( + { + "resource_title": "Off.Campus.S01.2160p.AMZN.WEB-DL", + "error_key": "backendErrors.transferFailed", + "error_params": {"reason": "source missing"}, + }, + ensure_ascii=False, + ), + ) + + message = format_telegram_event(event, locale="en-US", public_base_url="") + + assert "\\[Aethera\\]" in message + assert "Import failed" in message + assert "Season 1" in message + assert "Reason: Transfer failed: source missing" in message + assert "View details" not in message + assert "media.import.failed" not in message diff --git a/backend/tests/test_telegram_notifications.py b/backend/tests/test_telegram_notifications.py new file mode 100644 index 0000000..45accd1 --- /dev/null +++ b/backend/tests/test_telegram_notifications.py @@ -0,0 +1,98 @@ +import httpx +import pytest + +from app.services.integration.notifications.channels.telegram import client as telegram_client_module +from app.services.integration.notifications.channels.telegram.client import TelegramClient + + +class FakeTelegramResponse: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +class FakeAsyncClient: + response_payload = {"ok": True} + raised_exception = None + seen_requests = [] + + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + async def post(self, url, json): + self.seen_requests.append((url, json)) + if self.raised_exception is not None: + raise self.raised_exception + return FakeTelegramResponse(self.response_payload) + + +@pytest.fixture(autouse=True) +def fake_async_client(monkeypatch): + FakeAsyncClient.response_payload = {"ok": True} + FakeAsyncClient.raised_exception = None + FakeAsyncClient.seen_requests = [] + monkeypatch.setattr(telegram_client_module.httpx, "AsyncClient", FakeAsyncClient) + + +@pytest.mark.asyncio +async def test_telegram_connection_sends_test_message(): + result = await TelegramClient().test_connection(bot_token="123:token", chat_id="chat") + + assert result is True + assert FakeAsyncClient.seen_requests == [ + ( + "https://api.telegram.org/bot123:token/sendMessage", + { + "chat_id": "chat", + "text": "[Aethera] Notification test", + "disable_web_page_preview": True, + }, + ) + ] + + +@pytest.mark.asyncio +async def test_telegram_connection_returns_false_on_api_error(): + FakeAsyncClient.response_payload = {"ok": False, "description": "chat not found"} + + result = await TelegramClient().test_connection(bot_token="123:token", chat_id="missing") + + assert result is False + + +@pytest.mark.asyncio +async def test_telegram_connection_returns_false_on_invalid_token_format(): + result = await TelegramClient().test_connection(bot_token="invalid-token", chat_id="chat") + + assert result is False + assert FakeAsyncClient.seen_requests == [] + + +@pytest.mark.asyncio +async def test_telegram_connection_or_raise_reports_api_error(): + FakeAsyncClient.response_payload = {"ok": False, "description": "chat not found"} + + with pytest.raises(RuntimeError) as exc_info: + await TelegramClient().test_connection_or_raise(bot_token="123:token", chat_id="missing") + + assert str(exc_info.value) == "chat not found" + + +@pytest.mark.asyncio +async def test_telegram_send_message_does_not_leak_bot_token_on_transport_error(): + request = httpx.Request("POST", "https://api.telegram.org/botsecret-token/sendMessage") + FakeAsyncClient.raised_exception = httpx.RequestError("network failed", request=request) + + with pytest.raises(RuntimeError) as exc_info: + await TelegramClient().send_message(bot_token="secret-token", chat_id="chat", text="hello") + + assert str(exc_info.value) == "Telegram API request failed" + assert "secret-token" not in str(exc_info.value) diff --git a/backend/tests/test_transfer_error_alert.py b/backend/tests/test_transfer_error_alert.py index 9749e78..61d87fb 100644 --- a/backend/tests/test_transfer_error_alert.py +++ b/backend/tests/test_transfer_error_alert.py @@ -27,21 +27,13 @@ def _task() -> TaskData: @pytest.mark.asyncio -async def test_handle_transfer_error_serializes_alert_reason_params(monkeypatch): - captured = {} +async def test_handle_transfer_error_updates_task_state(monkeypatch): update_task_state = AsyncMock(return_value=True) - def raise_alert(request): - captured["request"] = request - monkeypatch.setattr( "app.services.domain.transfer.service.download_service.update_task_state", update_task_state, ) - monkeypatch.setattr( - "app.services.domain.transfer.service.alert_service.raise_alert", - raise_alert, - ) await handle_transfer_error( _task(), @@ -56,10 +48,3 @@ def raise_alert(request): error_params={"reason": "disk full"}, error_stage=TaskErrorStage.TRANSFER, ) - request = captured["request"] - assert request.message_params == { - "task": "Test.Movie.2024.2160p", - "reason_key": "backendErrors.transferFailed", - "reason_params": '{"reason": "disk full"}', - } - assert request.message_key == "alertMessages.taskTransferFailed" diff --git a/backend/tests/test_transfer_service_drift.py b/backend/tests/test_transfer_service_drift.py index b329391..e45e3aa 100644 --- a/backend/tests/test_transfer_service_drift.py +++ b/backend/tests/test_transfer_service_drift.py @@ -19,7 +19,7 @@ from app.schemas.domain.torrent import TorrentFileItem, TorrentMetadata from app.services.domain.transfer import transfer_service from app.services.domain.transfer.service import commit_transfer_results -from app.services.domain.transfer.execution import TransferExecutionContext, build_transfer_execution_context, build_transfer_plan, execute_transfer, generate_source_path +from app.services.domain.transfer.execution import TransferExecutionContext, build_transfer_execution_context, build_transfer_plan, execute_transfer, generate_source_path, missing_transfer_source_paths def _task(status: TaskStatus = TaskStatus.COMPLETED) -> TaskData: @@ -67,6 +67,16 @@ def _library_file(file_id: str = "file-1") -> LibraryFile: ) +@pytest.mark.asyncio +async def test_missing_transfer_source_paths_reports_selected_files_not_visible(monkeypatch): + task = _task(status=TaskStatus.FINISHED) + monkeypatch.setattr("app.services.domain.transfer.execution.fs_provider.exists", lambda path: False) + + missing_paths = await missing_transfer_source_paths(task) + + assert missing_paths == ["/data/download/downloads/show/Test.Show.S01.2024.1080p.WEB-DL/Test.Show.S01E01.2024.1080p.WEB-DL.mkv"] + + def _transfer_file_result() -> TransferFileResult: return TransferFileResult( source_path="/downloads/Test.Show.S01E01.2024.1080p.WEB-DL.mkv", @@ -256,6 +266,7 @@ def materialize(self, source_path, destination_path): ) monkeypatch.setattr("app.services.domain.transfer.execution.fs_provider.exists", lambda path: True) monkeypatch.setattr("app.services.domain.transfer.execution.validate_transfer_upgrade_policy", AsyncMock()) + monkeypatch.setattr("app.services.domain.transfer.execution.library_service.find_file_by_path", AsyncMock(return_value=None)) def resolve_materializer(mode): assert mode == TransferMode.COPY @@ -269,6 +280,42 @@ def resolve_materializer(mode): assert calls == [(Path(results[0].source_path), Path(results[0].destination_path))] +@pytest.mark.asyncio +async def test_execute_transfer_skips_existing_same_task_file_materialization(monkeypatch): + class RecordingMaterializer: + @property + def mode(self): + return TransferMode.COPY + + def materialize(self, source_path, destination_path): + calls.append((source_path, destination_path)) + + calls = [] + task = _task(status=TaskStatus.FINISHED) + existing_file = _library_file().model_copy(update={"file_size": 100}) + context = TransferExecutionContext( + source_base_path=Path("/downloads"), + destination_base_path=Path("/library"), + transfer_mode=TransferMode.COPY, + template_config=Template( + dir_template="{title} ({year})/Season {season:00}", + file_template="{title} - S{season:00}E{episode:00}", + ), + title="Test Show", + year=2024, + season_number=1, + ) + monkeypatch.setattr("app.services.domain.transfer.execution.fs_provider.exists", lambda path: True) + monkeypatch.setattr("app.services.domain.transfer.execution.validate_transfer_upgrade_policy", AsyncMock()) + monkeypatch.setattr("app.services.domain.transfer.execution.library_service.find_file_by_path", AsyncMock(return_value=existing_file)) + monkeypatch.setattr("app.services.domain.transfer.execution.transfer_materializer_registry.resolve", lambda mode: RecordingMaterializer()) + + results = await execute_transfer(task, context) + + assert len(results) == 1 + assert calls == [] + + def test_build_transfer_plan_preserves_bdmv_package_layout(monkeypatch): media_id = MediaID.parse("tmdb:movie:1") task = TaskData( diff --git a/backend/tests/test_workflow_alerts.py b/backend/tests/test_workflow_alerts.py index 5c94a19..6a25c17 100644 --- a/backend/tests/test_workflow_alerts.py +++ b/backend/tests/test_workflow_alerts.py @@ -1,142 +1,82 @@ -from pathlib import Path from types import SimpleNamespace import pytest from app.schemas.config import NotificationChannelConfig -from app.schemas.domain.alert import AlertCategory, AlertTargetType from app.schemas.domain.event import Event, EventLevel, EventType from app.schemas.domain.media import MediaIdentity from app.schemas.media_id import MediaID from app.services.application.workflows.notifications.service import NotificationApplicationService -from app.services.domain.alerts import workflow_alerts def _media() -> MediaIdentity: return MediaIdentity(media_id=MediaID.parse("tmdb:tv:1"), season_number=1, title="Test Show", year=2026) -def test_raise_danmu_alert_uses_video_path_fingerprint_and_reason_key(monkeypatch): - captured = {} - monkeypatch.setattr( - workflow_alerts, - "alert_service", - SimpleNamespace(raise_alert=lambda request: captured.setdefault("request", request)), - ) +def test_default_notification_channel_patterns_target_business_results(): + channel = NotificationChannelConfig(id="channel-1", type="fake", name="Fake") - workflow_alerts.raise_danmu_alert( - media=_media(), - video_path=Path("/data/library/Test Show - S01E01.mkv"), - action_id="action-1", - task_id="task-1", - provider="youku", - error_key="runtimeReasons.danmuNotFound", - ) + assert "download.completed" in channel.event_patterns + assert "media.import.completed" in channel.event_patterns + assert "subscription.ended.*" in channel.event_patterns + assert "*" not in channel.event_patterns + assert "subscription.*" not in channel.event_patterns - request = captured["request"] - assert request.fingerprint == "danmu.generate:/data/library/Test Show - S01E01.mkv" - assert request.category == AlertCategory.danmu_generate - assert request.target_type == AlertTargetType.danmu_sidecar - assert request.message_key == "alertMessages.danmuGenerateFailed" - assert request.message_params["target"] == "Test Show - S01E01.mkv" - assert request.message_params["provider"] == "youku" - assert request.message_params["reason_key"] == "runtimeReasons.danmuNotFound" - -def test_media_server_sync_alert_resolves_same_file_fingerprint(monkeypatch): +@pytest.mark.asyncio +async def test_notification_send_failure_emits_event_with_event_type_value(monkeypatch): captured = {} - monkeypatch.setattr( - workflow_alerts, - "alert_service", - SimpleNamespace( - raise_alert=lambda request: captured.setdefault("raise", request), - resolve_alert=lambda request: captured.setdefault("resolve", request), - ), - ) - - workflow_alerts.raise_media_server_sync_alert( - media=_media(), - file_path="/data/library/Test Show - S01E01.mkv", - media_server_id="server-1", - task_id="task-1", - error="write failed", - ) - workflow_alerts.resolve_media_server_sync_alert( - media=_media(), - file_path="/data/library/Test Show - S01E01.mkv", - media_server_id="server-1", - ) + channel = NotificationChannelConfig(id="channel-1", type="fake", name="Fake", event_patterns=["media.*"]) - assert captured["raise"].fingerprint == "media_server_sync:server-1:/data/library/Test Show - S01E01.mkv" - assert captured["raise"].category == AlertCategory.media_server_sync - assert captured["raise"].target_type == AlertTargetType.library_file - assert captured["raise"].message_params["reason"] == "write failed" - assert captured["resolve"].fingerprint == captured["raise"].fingerprint + class FakeNotificationChannel: + def is_configured(self, config): + return True + async def send(self, config, event): + raise RuntimeError("network failed") -def test_notification_alert_uses_channel_fingerprint(monkeypatch): - captured = {} monkeypatch.setattr( - workflow_alerts, - "alert_service", - SimpleNamespace(raise_alert=lambda request: captured.setdefault("request", request)), + "app.services.application.workflows.notifications.service.settings_service", + SimpleNamespace(get_addons_config=lambda: SimpleNamespace(notifications=SimpleNamespace(channels=[channel]))), ) - - workflow_alerts.raise_notification_alert( - channel_id="channel-1", - channel_name="Telegram", - channel_type="telegram", - event_type="media.import.failed", - event_id="event-1", - media=_media(), - media_id=None, - task_id="task-1", - action_id="action-1", - error="network failed", + monkeypatch.setattr( + "app.services.application.workflows.notifications.service.notification_channel_service", + SimpleNamespace(supports=lambda channel_type: True, get_channel=lambda channel_type: FakeNotificationChannel()), ) - - request = captured["request"] - assert request.fingerprint == "notification.send:channel-1" - assert request.category == AlertCategory.notification_send - assert request.target_type == AlertTargetType.notification_channel - assert request.message_key == "alertMessages.notificationSendFailed" - assert request.message_params["channel"] == "Telegram" - assert request.message_params["event_type"] == "media.import.failed" - - -def test_indexer_site_alert_uses_site_fingerprint(monkeypatch): - captured = {} monkeypatch.setattr( - workflow_alerts, - "alert_service", + "app.services.application.workflows.notifications.service.action_service", SimpleNamespace( - raise_alert=lambda request: captured.setdefault("raise", request), - resolve_alert=lambda request: captured.setdefault("resolve", request), + create_action=lambda **kwargs: SimpleNamespace(id="action-1"), + mark_running=lambda *args, **kwargs: None, + mark_failed=lambda *args, **kwargs: None, + mark_completed=lambda *args, **kwargs: None, ), ) + monkeypatch.setattr( + "app.services.application.workflows.notifications.service.event_service", + SimpleNamespace(emit=lambda event, meta=None: captured.setdefault("event", event)), + ) - workflow_alerts.raise_indexer_site_alert( - indexer_id="jackett", - indexer_name="Jackett", - site_id="audiences", - site_name="Audiences", - consecutive_failures=3, - error="login failed", + await NotificationApplicationService().handle_event( + Event( + id="event-1", + type=EventType.MEDIA_IMPORT_FAILED, + level=EventLevel.error, + media=_media(), + task_id="task-1", + ) ) - workflow_alerts.resolve_indexer_site_alert("jackett", "audiences") - assert captured["raise"].fingerprint == "indexer.health:jackett:audiences" - assert captured["raise"].category == AlertCategory.indexer_health - assert captured["raise"].target_type == AlertTargetType.indexer_site - assert captured["raise"].message_key == "alertMessages.indexerSiteFailed" - assert captured["raise"].message_params["indexer"] == "Jackett" - assert captured["raise"].message_params["site"] == "Audiences" - assert captured["raise"].message_params["failures"] == "3" - assert captured["resolve"].fingerprint == captured["raise"].fingerprint + assert captured["event"].type == EventType.NOTIFICATION_FAILED + assert captured["event"].level == EventLevel.error + assert captured["event"].message_params["channel"] == "Fake" + assert captured["event"].message_params["trigger_event_id"] == "event-1" + assert captured["event"].message_params["reason"] == "network failed" + assert captured["event"].action_id == "action-1" @pytest.mark.asyncio -async def test_notification_send_failure_raises_alert_with_event_type_value(monkeypatch): +async def test_notification_send_success_emits_event(monkeypatch): captured = {} channel = NotificationChannelConfig(id="channel-1", type="fake", name="Fake", event_patterns=["media.*"]) @@ -145,7 +85,7 @@ def is_configured(self, config): return True async def send(self, config, event): - raise RuntimeError("network failed") + captured["sent"] = (config.id, event.id) monkeypatch.setattr( "app.services.application.workflows.notifications.service.settings_service", @@ -161,24 +101,27 @@ async def send(self, config, event): create_action=lambda **kwargs: SimpleNamespace(id="action-1"), mark_running=lambda *args, **kwargs: None, mark_failed=lambda *args, **kwargs: None, - mark_completed=lambda *args, **kwargs: None, + mark_completed=lambda *args, **kwargs: captured.setdefault("completed", True), ), ) monkeypatch.setattr( - "app.services.application.workflows.notifications.service.raise_notification_alert", - lambda **kwargs: captured.setdefault("alert", kwargs), + "app.services.application.workflows.notifications.service.event_service", + SimpleNamespace(emit=lambda event, meta=None: captured.setdefault("event", event)), ) - await NotificationApplicationService().handle_event( Event( id="event-1", - type=EventType.MEDIA_IMPORT_FAILED, - level=EventLevel.error, + type=EventType.MEDIA_IMPORT_COMPLETED, + level=EventLevel.info, media=_media(), task_id="task-1", ) ) - assert captured["alert"]["event_type"] == "media.import.failed" - assert captured["alert"]["channel_id"] == "channel-1" - assert captured["alert"]["action_id"] == "action-1" + assert captured["sent"] == ("channel-1", "event-1") + assert captured["completed"] is True + assert captured["event"].type == EventType.NOTIFICATION_SENT + assert captured["event"].level == EventLevel.info + assert captured["event"].message_params["channel"] == "Fake" + assert captured["event"].message_params["trigger_event_type"] == "media.import.completed" + assert captured["event"].action_id == "action-1" diff --git a/docs/backend-contracts.md b/docs/backend-contracts.md index 98e1468..709b81d 100644 --- a/docs/backend-contracts.md +++ b/docs/backend-contracts.md @@ -130,6 +130,10 @@ Fixed rules: - Do not duplicate semantics in `meta` when a top-level field already exists. - Use explicit Pydantic snapshot models for `meta` by default. - Do not put full domain objects, repository records, or ORM objects into `meta`. +- Action and command records own operation lifecycle: user clicks, enqueue, queued, running, cancellation, manual setting toggles, and operation success for a user-initiated command. +- Events own subscribable business facts: automatic domain outcomes, externally interesting completions, warnings, failures, and facts that another workflow must consume. +- Do not emit events for operation-only lifecycle markers such as "started", "queued", manual enable/disable, or storage-change success when an action/command record already carries the state. +- Telegram and other notification channels consume events only; if a record would be strange as an external notification, it usually belongs in action/command history instead of the event stream. - Ordinary events and media events are modeled and emitted separately. - Events with `media_id` must use the media-event entrypoint. Do not keep reusing generic `emit()`. - A media event that carries `media_id` must also carry complete `MediaIdentity`, not fragmented fields. diff --git a/docs/features.md b/docs/features.md index 4734211..adf4532 100644 --- a/docs/features.md +++ b/docs/features.md @@ -44,4 +44,4 @@ The core workflow is: ## Disabled Experimental Integrations -OIDC login and Telegram notifications are currently disabled by default because they are experimental and not release-tested. +OIDC login is currently disabled by default because it is experimental and not release-tested. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ffc86de..80a42d5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -38,12 +38,12 @@
{{ $t('app.footer') }}
-{{ $t('alertCenter.noAlertsTitle') }}
-{{ $t('alertCenter.noAlertsDescription') }}
+{{ $t('notificationCenter.noEventsTitle') }}
+{{ $t('notificationCenter.noEventsDescription') }}