diff --git a/.gitignore b/.gitignore index c453b6b..55977cd 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ build/ *.magnet # Local secrets / overrides -docker-compose.dev.yml docker-compose.dev.override.yml *.conf.local *.config.local diff --git a/aethera.sh b/aethera.sh index baa1b94..a375e5e 100755 --- a/aethera.sh +++ b/aethera.sh @@ -6,13 +6,32 @@ ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$ROOT_DIR/scripts" DEV_COMPOSE="$ROOT_DIR/docker-compose.dev.yml" -if [ ! -f "$DEV_COMPOSE" ]; then - DEV_COMPOSE="$ROOT_DIR/docker-compose.dev.example.yml" +DEV_COMPOSE_FILES=() +DEV_OVERRIDE_COMPOSE="$ROOT_DIR/docker-compose.dev.override.yml" +if [ -f "$DEV_COMPOSE" ]; then + DEV_COMPOSE_FILES=(-f "$DEV_COMPOSE") +fi +if [ ${#DEV_COMPOSE_FILES[@]} -gt 0 ] && [ -f "$DEV_OVERRIDE_COMPOSE" ]; then + DEV_COMPOSE_FILES+=(-f "$DEV_OVERRIDE_COMPOSE") fi PROD_COMPOSE="$ROOT_DIR/compose.yaml" +require_dev_compose() { + if [ ${#DEV_COMPOSE_FILES[@]} -eq 0 ]; then + echo "Missing docker-compose.dev.yml" >&2 + exit 1 + fi +} + +run_dev_compose_if_available() { + if [ ${#DEV_COMPOSE_FILES[@]} -gt 0 ]; then + docker compose "${DEV_COMPOSE_FILES[@]}" "$@" + fi +} + case "$1" in "dev"|"start") + require_dev_compose echo "Starting development environment..." "$SCRIPT_DIR/dev.sh" ;; @@ -22,7 +41,7 @@ case "$1" in ;; "stop") echo "Stopping all services..." - docker compose -f "$DEV_COMPOSE" down 2>/dev/null || true + run_dev_compose_if_available down 2>/dev/null || true docker compose --project-directory "$ROOT_DIR" -f "$PROD_COMPOSE" down 2>/dev/null || true ;; "logs"|"log") @@ -30,35 +49,39 @@ case "$1" in "$SCRIPT_DIR/logs.sh" "$@" ;; "test-backend"|"pytest-backend") + require_dev_compose shift "$SCRIPT_DIR/test_backend.sh" "$@" ;; "coverage-backend") + require_dev_compose shift "$SCRIPT_DIR/test_backend_coverage.sh" "$@" ;; "config-migrate-to-db") + require_dev_compose shift "$SCRIPT_DIR/docker_compose.sh" run --rm --entrypoint python backend scripts/migrate_config_sections_to_db.py "$@" ;; "db-baseline-stamp") + require_dev_compose shift "$SCRIPT_DIR/docker_compose.sh" run --rm --entrypoint python backend scripts/stamp_initial_baseline.py "$@" ;; "status"|"ps") echo "Service status:" docker compose --project-directory "$ROOT_DIR" -f "$PROD_COMPOSE" ps - docker compose -f "$DEV_COMPOSE" ps + run_dev_compose_if_available ps ;; "restart") echo "Restarting services..." docker compose --project-directory "$ROOT_DIR" -f "$PROD_COMPOSE" restart - docker compose -f "$DEV_COMPOSE" restart + run_dev_compose_if_available restart ;; "clean") echo "Cleaning Docker resources..." docker compose --project-directory "$ROOT_DIR" -f "$PROD_COMPOSE" down - docker compose -f "$DEV_COMPOSE" down + run_dev_compose_if_available down docker system prune -f ;; * ) diff --git a/backend/alembic/versions/0004_subscription_search_cadence.py b/backend/alembic/versions/0004_subscription_search_cadence.py new file mode 100644 index 0000000..1fa2b32 --- /dev/null +++ b/backend/alembic/versions/0004_subscription_search_cadence.py @@ -0,0 +1,49 @@ +"""Track subscription active search cadence. + +Revision ID: 0004_subscription_search_cadence +Revises: 0003_notification_center_events +Create Date: 2026-06-04 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0004_subscription_search_cadence" +down_revision = "0003_notification_center_events" +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 _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if not _has_table("media_subscription_cycles") or _has_column("media_subscription_cycles", "last_search_at"): + return + op.add_column("media_subscription_cycles", sa.Column("last_search_at", sa.Float(), nullable=True)) + bind = op.get_bind() + bind.execute( + sa.text( + """ + UPDATE media_subscription_cycles + SET last_search_at = last_checked_at + WHERE last_checked_at IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + if _has_table("media_subscription_cycles") and _has_column("media_subscription_cycles", "last_search_at"): + op.drop_column("media_subscription_cycles", "last_search_at") diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 148f9ff..666ce5c 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -86,6 +86,15 @@ async def search_indexer_torznab( """Internal helper.""" pass + async def fetch_recent_torznab( + self, + indexer: str, + category: str | None = None, + ) -> list[ResourceSearchResult]: + """Internal helper.""" + _ = indexer, category + return [] + async def list_sites(self) -> list[SiteInfo]: return await self.get_indexers() diff --git a/backend/app/clients/jackett.py b/backend/app/clients/jackett.py index e1bd583..33b774b 100644 --- a/backend/app/clients/jackett.py +++ b/backend/app/clients/jackett.py @@ -13,12 +13,20 @@ from urllib.parse import urlencode, urlsplit, urlunsplit import aiohttp + from app.clients.base import IndexerClient +from app.clients.torznab import ( + build_torznab_recent_params, + build_torznab_search_params, + format_size, + parse_torznab_caps_xml, + parse_torznab_xml, + truncate_text, +) from app.schemas.config import IndexerProviderConfig from app.schemas.constants.indexer import SITE_SEARCH_TIMEOUT_SECONDS from app.schemas.domain.resource_search import JackettSearchResponse, JackettSearchResult, ResourceSearchResult from app.schemas.integration.site_models import SiteInfo, SiteSearchCapabilities -from app.clients.torznab import build_torznab_search_params, format_size, parse_torznab_caps_xml, parse_torznab_xml, truncate_text logger = logging.getLogger("app.clients.jackett") @@ -398,6 +406,41 @@ async def search_indexer_torznab( ) raise RuntimeError(message) from exc + async def fetch_recent_torznab( + self, + indexer: str, + category: str | None = None, + ) -> list[ResourceSearchResult]: + self._ensure_session() + params = build_torznab_recent_params(api_key=self.api_key, category=category) + url = f"{self.base_url}/api/v2.0/indexers/{indexer}/results/torznab" + try: + async with self.session.get(url, params=params, timeout=self.search_timeout) as resp: + if resp.status != 200: + body = (await resp.text()).strip() + body_preview = body[:200] if body else "" + detail = f" status={resp.status}" + if body_preview: + detail += f" body={body_preview}" + raise RuntimeError(f"jackett_recent_http_error:{indexer}{detail}") + text = await resp.text() + return [ + result.model_copy(update={"matched_by_id": False}) + for result in self._parse_torznab_xml(text) + ] + except asyncio.TimeoutError as exc: + message = f"jackett_recent_timeout:{indexer}" + logger.warning("Jackett torznab recent timeout: indexer=%s", indexer) + raise RuntimeError(message) from exc + except aiohttp.ClientError as exc: + message = f"jackett_recent_client_error:{indexer}:{exc}" + logger.warning("Jackett torznab recent client error: indexer=%s error=%s", indexer, exc) + raise RuntimeError(message) from exc + except ValueError as exc: + message = f"jackett_recent_parse_error:{indexer}:{exc}" + logger.warning("Jackett torznab recent parse error: indexer=%s error=%s", indexer, exc) + raise RuntimeError(message) from exc + async def search_indexer(self, indexer: str, query: str, category: str | None = None) -> list[JackettSearchResult]: self._ensure_session() params: dict[str, str] = {"apikey": self.api_key, "Query": query} diff --git a/backend/app/clients/prowlarr.py b/backend/app/clients/prowlarr.py index 13305fc..f77fb21 100644 --- a/backend/app/clients/prowlarr.py +++ b/backend/app/clients/prowlarr.py @@ -10,17 +10,18 @@ import aiohttp from app.clients.base import IndexerClient -from app.schemas.config import IndexerProviderConfig -from app.schemas.constants.indexer import SITE_SEARCH_TIMEOUT_SECONDS -from app.schemas.domain.resource_search import ResourceSearchResult -from app.schemas.integration.site_models import SiteInfo, SiteSearchCapabilities -from app.schemas.runtime.indexer_site_health import IndexerSiteHealthStatus from app.clients.torznab import ( + build_torznab_recent_params, build_torznab_search_params, parse_torznab_caps_xml, parse_torznab_xml, resolve_torznab_search_param, ) +from app.schemas.config import IndexerProviderConfig +from app.schemas.constants.indexer import SITE_SEARCH_TIMEOUT_SECONDS +from app.schemas.domain.resource_search import ResourceSearchResult +from app.schemas.integration.site_models import SiteInfo, SiteSearchCapabilities +from app.schemas.runtime.indexer_site_health import IndexerSiteHealthStatus logger = logging.getLogger("app.clients.prowlarr") @@ -355,6 +356,40 @@ async def search_indexer_torznab( ) raise RuntimeError(message) from exc + async def fetch_recent_torznab( + self, + indexer: str, + category: str | None = None, + ) -> list[ResourceSearchResult]: + self._ensure_session() + params = build_torznab_recent_params(api_key=self.api_key, category=category) + url = f"{self.base_url}/{indexer}/api" + try: + async with self.session.get(url, params=params, headers=self._headers(), timeout=self.search_timeout) as resp: + if resp.status != 200: + body = (await resp.text()).strip() + detail = f" status={resp.status}" + if body: + detail += f" body={body[:200]}" + raise RuntimeError(f"prowlarr_recent_http_error:{indexer}{detail}") + text = await resp.text() + return [ + result.model_copy(update={"site": str(indexer), "site_name": str(indexer), "matched_by_id": False}) + for result in parse_torznab_xml(text, default_site=str(indexer)) + ] + except asyncio.TimeoutError as exc: + message = f"prowlarr_recent_timeout:{indexer}" + logger.warning("Prowlarr torznab recent timeout: indexer=%s", indexer) + raise RuntimeError(message) from exc + except aiohttp.ClientError as exc: + message = f"prowlarr_recent_client_error:{indexer}:{exc}" + logger.warning("Prowlarr torznab recent client error: indexer=%s error=%s", indexer, exc) + raise RuntimeError(message) from exc + except ValueError as exc: + message = f"prowlarr_recent_parse_error:{indexer}:{exc}" + logger.warning("Prowlarr torznab recent parse error: indexer=%s error=%s", indexer, exc) + raise RuntimeError(message) from exc + def _torznab_search_type( self, category: str | None, diff --git a/backend/app/clients/torznab.py b/backend/app/clients/torznab.py index 38603b8..e6a967d 100644 --- a/backend/app/clients/torznab.py +++ b/backend/app/clients/torznab.py @@ -59,6 +59,19 @@ def build_torznab_search_params( return params +def build_torznab_recent_params( + *, + api_key: str, + category: str | None = None, + search_type: str = "search", +) -> dict[str, str]: + params: dict[str, str] = {"apikey": api_key, "t": search_type} + category_map = {"movie": "2000", "tv": "5000", "anime": "5070"} + if category in category_map: + params["cat"] = category_map[category] + return params + + def resolve_torznab_search_param(query: str, search_param: str) -> str: if search_param != "auto": return search_param diff --git a/backend/app/db/repositories/action_repository.py b/backend/app/db/repositories/action_repository.py index fe043c3..c802bc9 100644 --- a/backend/app/db/repositories/action_repository.py +++ b/backend/app/db/repositories/action_repository.py @@ -24,6 +24,7 @@ def _apply_filters( kinds: list[ActionKind] | None = None, statuses: list[ActionStatus] | None = None, action_names: list[str] | None = None, + excluded_action_names: tuple[str, ...] = (), triggers: list[ActionTrigger] | None = None, sources: list[ActionSource] | None = None, keyword: str | None = None, @@ -45,6 +46,8 @@ def _apply_filters( stmt = stmt.where(ActionORM.status.in_([status.value for status in statuses])) if action_names: stmt = stmt.where(ActionORM.action_name.in_(action_names)) + if excluded_action_names: + stmt = stmt.where(ActionORM.action_name.not_in(excluded_action_names)) if triggers: stmt = stmt.where(ActionORM.trigger.in_([trigger.value for trigger in triggers])) if sources: @@ -66,6 +69,7 @@ def _build_filtered_stmt( kinds: list[ActionKind] | None = None, statuses: list[ActionStatus] | None = None, action_names: list[str] | None = None, + excluded_action_names: tuple[str, ...] = (), triggers: list[ActionTrigger] | None = None, sources: list[ActionSource] | None = None, keyword: str | None = None, @@ -81,6 +85,7 @@ def _build_filtered_stmt( kinds=kinds, statuses=statuses, action_names=action_names, + excluded_action_names=excluded_action_names, triggers=triggers, sources=sources, keyword=keyword, @@ -259,6 +264,7 @@ def list_filtered( kinds: list[ActionKind] | None = None, statuses: list[ActionStatus] | None = None, action_names: list[str] | None = None, + excluded_action_names: tuple[str, ...] = (), triggers: list[ActionTrigger] | None = None, sources: list[ActionSource] | None = None, keyword: str | None = None, @@ -274,6 +280,7 @@ def list_filtered( kinds=kinds, statuses=statuses, action_names=action_names, + excluded_action_names=excluded_action_names, triggers=triggers, sources=sources, keyword=keyword, @@ -295,6 +302,7 @@ def list_filtered_page( kinds: list[ActionKind] | None = None, statuses: list[ActionStatus] | None = None, action_names: list[str] | None = None, + excluded_action_names: tuple[str, ...] = (), triggers: list[ActionTrigger] | None = None, sources: list[ActionSource] | None = None, keyword: str | None = None, @@ -310,6 +318,7 @@ def list_filtered_page( kinds=kinds, statuses=statuses, action_names=action_names, + excluded_action_names=excluded_action_names, triggers=triggers, sources=sources, keyword=keyword, diff --git a/backend/app/db/repositories/media_subscription_cycle_repository.py b/backend/app/db/repositories/media_subscription_cycle_repository.py index 4ab03a8..e5a2c6d 100644 --- a/backend/app/db/repositories/media_subscription_cycle_repository.py +++ b/backend/app/db/repositories/media_subscription_cycle_repository.py @@ -33,6 +33,7 @@ def _to_model(row: MediaSubscriptionCycleORM) -> MediaSubscriptionCycle: status=row.status, started_at=row.started_at, last_checked_at=row.last_checked_at, + last_search_at=row.last_search_at, ended_at=row.ended_at, ended_reason=row.ended_reason, ended_trigger=row.ended_trigger, diff --git a/backend/app/db/sql/models.py b/backend/app/db/sql/models.py index 59367bf..5e456ba 100644 --- a/backend/app/db/sql/models.py +++ b/backend/app/db/sql/models.py @@ -45,6 +45,7 @@ class MediaSubscriptionCycleORM(Base): status: Mapped[str] = mapped_column(Text, nullable=False, index=True) started_at: Mapped[float] = mapped_column(Float, nullable=False) last_checked_at: Mapped[float | None] = mapped_column(Float, nullable=True) + last_search_at: Mapped[float | None] = mapped_column(Float, nullable=True) ended_at: Mapped[float | None] = mapped_column(Float, nullable=True) ended_reason: Mapped[str | None] = mapped_column(Text, nullable=True) ended_trigger: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py index a08e02c..b4b4293 100644 --- a/backend/app/schemas/config.py +++ b/backend/app/schemas/config.py @@ -238,7 +238,11 @@ class SchedulerConfig(BaseModel): model_config = ConfigDict(extra='ignore') sync_active_downloads_interval_seconds: int = 30 process_completed_tasks_interval_seconds: int = 60 - subscription_sweep_interval_seconds: int = 600 + subscription_sweep_interval_seconds: int = 300 + subscription_search_interval_seconds: int = 600 + subscription_resource_discovery_mode: Literal["search", "rss_with_search_backfill"] = "rss_with_search_backfill" + subscription_search_backfill_interval_seconds: int = 3600 + subscription_search_max_per_sweep: int = 1 schedule_refresh_sweep_interval_seconds: int = 3600 directory_integrity_audit_interval_seconds: int = 21600 cleanup_inactive_managed_media_profiles_interval_seconds: int = 86400 diff --git a/backend/app/schemas/domain/media_subscription_cycle.py b/backend/app/schemas/domain/media_subscription_cycle.py index d82dfb9..c1874fe 100644 --- a/backend/app/schemas/domain/media_subscription_cycle.py +++ b/backend/app/schemas/domain/media_subscription_cycle.py @@ -29,6 +29,7 @@ class MediaSubscriptionCycle(BaseModel): status: SubscriptionCycleStatus = SubscriptionCycleStatus.ACTIVE started_at: float = Field(default_factory=lambda: time.time()) last_checked_at: float | None = None + last_search_at: float | None = None ended_at: float | None = None ended_reason: SubscriptionEndReason | None = None ended_trigger: SubscriptionEndTrigger | None = None @@ -41,6 +42,7 @@ class MediaSubscriptionCycle(BaseModel): class MediaSubscriptionCyclePatch(BaseModel): last_checked_at: float | None = None + last_search_at: float | None = None ended_at: float | None = None ended_reason: SubscriptionEndReason | None = None ended_trigger: SubscriptionEndTrigger | None = None diff --git a/backend/app/schemas/domain/media_subscription_state.py b/backend/app/schemas/domain/media_subscription_state.py index 4e127a1..ecac605 100644 --- a/backend/app/schemas/domain/media_subscription_state.py +++ b/backend/app/schemas/domain/media_subscription_state.py @@ -100,6 +100,7 @@ class MediaSubscriptionState(BaseModel): created_at: float = Field(default_factory=lambda: time.time()) updated_at: float = Field(default_factory=lambda: time.time()) last_run_at: float | None = None + last_search_at: float | None = None follow_reminded_air_date: str | None = None follow_reminded_digital_release_date: str | None = None follow_reminded_physical_release_date: str | None = None @@ -119,6 +120,7 @@ class MediaSubscriptionStatePatch(BaseModel): target_filter_config_id: str | None = None upgrade_completion_snapshot: UpgradeCompletionSnapshot | None = None last_run_at: float | None = None + last_search_at: float | None = None follow_reminded_air_date: str | None = None follow_reminded_digital_release_date: str | None = None follow_reminded_physical_release_date: str | None = None @@ -158,6 +160,7 @@ class MediaSubscriptionStateView(BaseModel): target_filter_config_id: str | None = None last_run_at: float | None = None last_checked_at: float | None = None + last_search_at: float | None = None cycle_status: str | None = None ended_reason: SubscriptionEndReason | None = None ended_at: float | None = None @@ -218,6 +221,7 @@ def build_subscription_state_view( target_filter_config_id=state.target_filter_config_id if state else None, last_run_at=state.last_run_at if state else None, last_checked_at=state.last_run_at if state else None, + last_search_at=state.last_search_at if state else None, cycle_status=cycle.status.value if cycle else None, ended_reason=cycle.ended_reason if cycle else None, ended_at=cycle.ended_at if cycle else None, diff --git a/backend/app/schemas/domain/subscription.py b/backend/app/schemas/domain/subscription.py index d9aabfc..c43a40f 100644 --- a/backend/app/schemas/domain/subscription.py +++ b/backend/app/schemas/domain/subscription.py @@ -42,6 +42,7 @@ class Subscription(BaseModel): active: bool = True created_at: float = Field(default_factory=lambda: time.time()) last_run_at: float | None = None + last_search_at: float | None = None follow_reminded_air_date: str | None = None follow_reminded_digital_release_date: str | None = None follow_reminded_physical_release_date: str | None = None diff --git a/backend/app/schemas/runtime/subscription_lifecycle.py b/backend/app/schemas/runtime/subscription_lifecycle.py index 46a8a56..7122d35 100644 --- a/backend/app/schemas/runtime/subscription_lifecycle.py +++ b/backend/app/schemas/runtime/subscription_lifecycle.py @@ -100,6 +100,7 @@ class SubscriptionRunRecord(BaseModel): sub_id: str target: MediaTarget checked_at: float = Field(default_factory=lambda: time.time()) + searched_at: float | None = None warnings: list[SubscriptionSearchWarning] = Field(default_factory=list) upgrade_snapshot: UpgradeCompletionSnapshot | None = None diff --git a/backend/app/services/application/views/library/overview.py b/backend/app/services/application/views/library/overview.py index 771e2d3..1737d51 100644 --- a/backend/app/services/application/views/library/overview.py +++ b/backend/app/services/application/views/library/overview.py @@ -1,10 +1,12 @@ import asyncio import logging +from typing import Optional from app.schemas.domain.download import TaskStatus -from app.schemas.domain.media import MediaFullInfo, MediaSimpleInfo +from app.schemas.domain.media import MediaExecutionSnapshot, MediaFullInfo, MediaSimpleInfo from app.schemas.domain.media_types import MediaType from app.schemas.domain.schedule import MediaScheduleSummary +from app.schemas.exception import MediaNotFoundException from app.schemas.media_id import MediaID from app.schemas.runtime.library_overview import LibraryOverviewResponse, LibraryOverviewSnapshot, NextEpisodeToAir from app.services.domain.download import download_service @@ -30,18 +32,27 @@ def _as_positive_int(self, value: int | None) -> int | None: return value async def get_overview(self, media_id: MediaID) -> LibraryOverviewResponse: - media = await media_service.cached_info(media_id) + media = None + if media_id.media_type == MediaType.movie: + try: + media = await media_service.resolve_execution_snapshot(media_id) + except MediaNotFoundException: + logger.info("Build library overview without media snapshot: media=%s", media_id) schedule = None - if media and media.media_type == MediaType.movie: - schedule = await media_service.build_schedule_summary_for_media(media) snapshot = await self.build_snapshot(media_id, media, schedule=schedule) return LibraryOverviewResponse(media_id=media_id, **snapshot.model_dump()) - def resolve_full_media_total_episodes(self, media: MediaFullInfo | None) -> int: + def resolve_full_media_total_episodes(self, media: Optional[MediaFullInfo | MediaExecutionSnapshot]) -> int: active_season_number = media.season_number if media and media.media_type == MediaType.tv else None total_episodes = (media.episodes_count or 0) if media else 0 - if media and active_season_number is not None: - matched_season = next((season for season in media.seasons if season.season_number == active_season_number), None) + seasons = [] + if media: + try: + seasons = media.seasons + except AttributeError: + seasons = [] + if seasons and active_season_number is not None: + matched_season = next((season for season in seasons if season.season_number == active_season_number), None) if matched_season and matched_season.episode_count is not None: total_episodes = int(matched_season.episode_count or 0) return total_episodes @@ -52,7 +63,7 @@ def resolve_simple_media_total_episodes(self, media: MediaSimpleInfo | None) -> async def build_snapshot( self, media_id: MediaID, - media: MediaFullInfo | None = None, + media: Optional[MediaFullInfo | MediaExecutionSnapshot] = None, schedule: MediaScheduleSummary | None = None, library_snapshot: MediaLibrarySnapshot | None = None, ) -> LibraryOverviewSnapshot: @@ -67,7 +78,11 @@ async def build_snapshot( else: active_season_number = media.season_number if media.media_type == MediaType.tv else None total_episodes = self.resolve_full_media_total_episodes(media) - schedule = schedule or media.schedule + if schedule is None: + try: + schedule = media.schedule + except AttributeError: + schedule = None snapshot_task = ( self._loaded_library_snapshot(library_snapshot) diff --git a/backend/app/services/application/views/library/resource_list.py b/backend/app/services/application/views/library/resource_list.py index adda4d3..fb8d763 100644 --- a/backend/app/services/application/views/library/resource_list.py +++ b/backend/app/services/application/views/library/resource_list.py @@ -6,9 +6,10 @@ from app.schemas.domain.command import CommandRecord, CommandTargetType, CommandType from app.schemas.config import DanmuAddonConfig, Tag from app.schemas.domain.library import LibraryFile, LibraryPackageSummary -from app.schemas.domain.media import MediaFullInfo, MediaSimpleInfo +from app.schemas.domain.media import MediaExecutionSnapshot, MediaSimpleInfo from app.schemas.domain.media_types import MediaType from app.schemas.domain.resource_attributes import ResourceAttributes +from app.schemas.exception.base import AppException from app.schemas.media_id import MediaID from app.schemas.runtime.library_resource_list import ( LibraryListAttributes, @@ -82,20 +83,25 @@ async def list_resources( if media: media = media_service.apply_season_context(media, season_number) active_season_number = media.season_number if media and media.media_type == MediaType.tv else None - media_with_seasons = None + execution_media = None if active_season_number is not None: phase_started_at = time.perf_counter() - media_with_seasons = await media_service.cached_info(media_id) - self._record_elapsed(timings, "cached_info", phase_started_at) - if media_with_seasons is None: - phase_started_at = time.perf_counter() - media_with_seasons = await media_service.season_detail_for_library_view( + try: + execution_media = await media_service.resolve_execution_snapshot( media_id, season_number=active_season_number, + require_tv_season=True, + require_episode_count=True, + include_schedule_snapshot=True, ) - self._record_elapsed(timings, "season_detail", phase_started_at) - elif media_with_seasons: - media_with_seasons = media_service.apply_season_context(media_with_seasons, active_season_number) + except AppException as exc: + logger.info( + "Build library resource list without execution snapshot: media=%s season=%s error=%s", + media_id, + active_season_number, + exc, + ) + self._record_elapsed(timings, "execution_snapshot", phase_started_at) phase_started_at = time.perf_counter() active_commands, library_files = await asyncio.gather( @@ -111,8 +117,7 @@ async def list_resources( response = await self._build_response( media_id=media_id, active_season_number=active_season_number, - total_episodes=self._resolve_total_episodes(media, media_with_seasons), - full_media=media_with_seasons, + total_episodes=self._resolve_total_episodes(media, execution_media), active_commands=active_commands, library_files=library_files, ) @@ -133,7 +138,6 @@ async def _build_response( media_id: MediaID, active_season_number: int | None, total_episodes: int, - full_media: MediaFullInfo | None, active_commands: list[CommandRecord], library_files: list[LibraryFile], ) -> LibraryListResponse: @@ -145,7 +149,6 @@ async def _build_response( library_files, media_id=media_id, season_number=active_season_number, - full_media=full_media, ) action_context_map = await self._resolve_action_context_map(resources, resource_files_map, action_context) return LibraryListResponse( @@ -164,19 +167,10 @@ async def _build_response( def _resolve_total_episodes( self, simple_media: MediaSimpleInfo | None, - full_media: MediaFullInfo | None, + execution_media: MediaExecutionSnapshot | None, ) -> int: - if full_media is not None: - active_season_number = full_media.season_number if full_media.media_type == MediaType.tv else None - total_episodes = full_media.episodes_count or 0 - if active_season_number is not None: - matched_season = next( - (season for season in full_media.seasons if season.season_number == active_season_number), - None, - ) - if matched_season and matched_season.episode_count is not None: - return int(matched_season.episode_count or 0) - return int(total_episodes) + if execution_media is not None: + return int(execution_media.episodes_count or 0) return int(simple_media.episodes_count or 0) if simple_media else 0 def _build_library_list_attributes(self, attrs: ResourceAttributes, *, tags: list[Tag]) -> LibraryListAttributes: @@ -315,7 +309,6 @@ async def _build_action_availability_context( *, media_id: MediaID, season_number: int | None, - full_media: MediaFullInfo | None, ) -> _LibraryActionAvailabilityContext: task_ids = sorted({file.task_id for file in library_files if file.task_id}) existing_tasks = await download_service.get_tasks_by_ids(task_ids) @@ -344,7 +337,6 @@ async def _build_action_availability_context( danmu_media_available = await self._resolve_danmu_media_available( media_id, season_number=season_number, - full_media=full_media, danmu_enabled_directory_ids=danmu_enabled_directory_ids, danmu_config=danmu_config, ) @@ -361,14 +353,11 @@ async def _resolve_danmu_media_available( media_id: MediaID, *, season_number: int | None, - full_media: MediaFullInfo | None, danmu_enabled_directory_ids: set[str], danmu_config: DanmuAddonConfig, ) -> bool: if not danmu_enabled_directory_ids: return False - if full_media and danmu_source_resolver.has_fetchable_vendor(full_media, danmu_config): - return True resolved = await danmu_source_resolver.media_with_fetchable_source( media_id, season_number=season_number, diff --git a/backend/app/services/application/workflows/follow_reminder/service.py b/backend/app/services/application/workflows/follow_reminder/service.py index a35da1a..833ee5b 100644 --- a/backend/app/services/application/workflows/follow_reminder/service.py +++ b/backend/app/services/application/workflows/follow_reminder/service.py @@ -33,9 +33,10 @@ def _parse_ymd(d: str | None) -> date | None: def _should_emit_reminder(today: date, air_date: date, window_days: int, reminded_air_date: str | None) -> bool: - _ = window_days if air_date > today: return False + if (today - air_date).days > max(0, int(window_days)): + return False if reminded_air_date: prev = _parse_ymd(reminded_air_date) if prev and prev == air_date: @@ -123,7 +124,13 @@ async def run_once(self, window_days: int = 7) -> None: if not mid: continue - media = await media_service.cached_info(mid) + if mid.media_type == MediaType.movie: + media = await media_service.resolve_follow_release_media(mid) + else: + media = await media_service.resolve_execution_snapshot( + mid, + season_number=sub.season_number, + ) if not media: continue diff --git a/backend/app/services/application/workflows/media_resource_deletion/service.py b/backend/app/services/application/workflows/media_resource_deletion/service.py index e20951f..d4423c2 100644 --- a/backend/app/services/application/workflows/media_resource_deletion/service.py +++ b/backend/app/services/application/workflows/media_resource_deletion/service.py @@ -4,6 +4,7 @@ from app.schemas.media_id import MediaID 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 from app.services.platform.domain_lock_service import domain_lock_service @@ -48,7 +49,16 @@ async def delete_media_resources( if deleted: deleted_tasks_count += 1 + if mode == "tasks_and_library" and season_number is not None and not await self._has_remaining_media_resources(media_id): + await library_service.archive_media_entry(media_id) + await media_service.mark_profile_inactive_if_unmanaged(media_id) return deleted_tasks_count, deleted_library_files_count + async def _has_remaining_media_resources(self, media_id: MediaID) -> bool: + if await download_service.get_tasks(media_id=media_id): + return True + snapshot = await library_service.get_media_library_snapshot(media_id) + return bool(snapshot.files or snapshot.present_episodes) + media_resource_deletion_service = MediaResourceDeletionService() diff --git a/backend/app/services/application/workflows/notifications/service.py b/backend/app/services/application/workflows/notifications/service.py index 8ed02d2..19c144c 100644 --- a/backend/app/services/application/workflows/notifications/service.py +++ b/backend/app/services/application/workflows/notifications/service.py @@ -15,11 +15,10 @@ ActionTrigger, ) from app.schemas.domain.action_meta import NotificationSendQueuedActionMeta -from app.schemas.domain.event import Event, EventCreate, EventLevel, EventSource, EventType +from app.schemas.domain.event import Event 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.platform.notification_channel_service import notification_channel_service @@ -44,23 +43,8 @@ async def handle_event(self, event: Event) -> None: event.type, ) action_service.mark_failed(action.id, error=str(exc)) - self._emit_notification_event( - channel=channel, - trigger_event=event, - action_id=action.id, - event_type=EventType.NOTIFICATION_FAILED, - level=EventLevel.error, - reason=str(exc), - ) continue action_service.mark_completed(action.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: @@ -97,40 +81,5 @@ 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/resource_search/service.py b/backend/app/services/application/workflows/resource_search/service.py index 533157a..a7980a2 100644 --- a/backend/app/services/application/workflows/resource_search/service.py +++ b/backend/app/services/application/workflows/resource_search/service.py @@ -266,6 +266,11 @@ def cache_latest_media_results( ) -> None: self.cache.cache_latest_media_results(media_id, results, season_number) + def cache_results(self, results: list[ResourceSearchResult]) -> list[ResourceSearchResult]: + normalized_results = self.cache.normalize_results(results) + self.cache.save_results(normalized_results) + return normalized_results + def _read_site_cache( self, context: IndexerSearchContext, diff --git a/backend/app/services/application/workflows/subscription/run.py b/backend/app/services/application/workflows/subscription/run.py index e7e9880..93d27f9 100644 --- a/backend/app/services/application/workflows/subscription/run.py +++ b/backend/app/services/application/workflows/subscription/run.py @@ -1,15 +1,17 @@ from __future__ import annotations import logging +import re import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from datetime import date, datetime from app.schemas.domain.command import CommandCreateRequest, CommandInitiator from app.schemas.domain.download import DownloadTaskCreateInput 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 +from app.schemas.domain.resource_search import MediaSearchQuery, Resource, ResourceSearchResult from app.schemas.domain.subscription import Subscription from app.schemas.domain.subscription import SubscriptionSearchWarning, SubscriptionSearchWarningType from app.schemas.domain.subscription_run_result import SubscriptionRunResponse @@ -34,9 +36,12 @@ from app.services.domain.subscription.store import subscription_store from app.services.domain.subscription.upgrade_baseline_service import subscription_upgrade_baseline_service from app.services.application.workflows.resource_search import resource_search_service +from app.services.config.settings_service import settings_service +from app.services.integration.indexer.site_scope import split_scoped_site_id from app.services.platform.domain_lock_service import domain_lock_service logger = logging.getLogger("app.services.subscription.run") +RSS_WITH_SEARCH_BACKFILL_MODE = "rss_with_search_backfill" class SubscriptionRunApplicationService: @@ -54,7 +59,13 @@ async def run_one_by_sub_id(self, sub_id: str) -> SubscriptionRunResponse: sub = await self._build_runtime_subscription(state) return await self.run_one(sub) - async def run_one(self, sub: Subscription) -> SubscriptionRunResponse: + async def run_one( + self, + sub: Subscription, + *, + recent_candidates: list[ResourceSearchResult] | None = None, + active_search: bool | None = None, + ) -> SubscriptionRunResponse: async with self._acquire_media_execution_flow(sub.media_id, reason=f"subscription:{sub.sub_id}") as acquired: if not acquired: logger.info("Subscription run skipped because the media resource pipeline is busy: media=%s sub=%s", sub.media_id, sub.sub_id) @@ -63,14 +74,16 @@ async def run_one(self, sub: Subscription) -> SubscriptionRunResponse: runtime_sub = await self._refresh_runtime_media_snapshot(sub) resource_run_plan_service.validate_runtime_subscription(runtime_sub) checked_at = time.time() - outcome = await self._run_one_outcome(runtime_sub) + outcome = await self._run_one_outcome(runtime_sub, recent_candidates=recent_candidates) upgrade_snapshot = await subscription_upgrade_baseline_service.resolve_for_subscription(runtime_sub) + searched_at = checked_at if self._records_active_search(recent_candidates, active_search) else None await subscription_store.save_run_record( SubscriptionRunRecord( sub_id=runtime_sub.sub_id, target=MediaTarget(media_id=runtime_sub.media_id, season_number=runtime_sub.season_number), checked_at=checked_at, + searched_at=searched_at, warnings=outcome.warnings, upgrade_snapshot=upgrade_snapshot, ) @@ -87,7 +100,12 @@ async def run_one(self, sub: Subscription) -> SubscriptionRunResponse: ) return outcome.response - async def _run_one_outcome(self, runtime_sub: Subscription) -> SubscriptionRunOutcome: + async def _run_one_outcome( + self, + runtime_sub: Subscription, + *, + recent_candidates: list[ResourceSearchResult] | None = None, + ) -> SubscriptionRunOutcome: planning_result = await resource_run_plan_service.build_subscription_plan(runtime_sub) if planning_result.status == SubscriptionPlanningStatus.INVALID: return SubscriptionRunOutcome( @@ -111,7 +129,11 @@ async def _run_one_outcome(self, runtime_sub: Subscription) -> SubscriptionRunOu correlation_id=planning_result.correlation_id, ) - selection = await self._search_and_select_resources(subscription=runtime_sub, plan=plan) + selection = await self._search_and_select_resources( + subscription=runtime_sub, + plan=plan, + recent_candidates=recent_candidates, + ) if selection.checked <= 0: logger.debug("Subscription run produced no search results: media=%s sub=%s", plan.media.media_id, runtime_sub.sub_id) return SubscriptionRunOutcome( @@ -179,13 +201,21 @@ async def _search_and_select_resources( *, subscription: Subscription, plan: SubscriptionRunPlan, + recent_candidates: list[ResourceSearchResult] | None = None, ) -> ResourceRunSelection: - search_results = await resource_search_service.search_media( - MediaSearchQuery( - media=plan.media, - indexers=plan.sites, - unmatched_rules=list(subscription.unmatched_rules), - use_cache=False, + query = MediaSearchQuery( + media=plan.media, + indexers=plan.sites, + unmatched_rules=list(subscription.unmatched_rules), + use_cache=False, + ) + search_results = ( + await resource_search_service.search_media(query) + if recent_candidates is None + else self._filter_recent_candidates( + query=query, + plan=plan, + candidates=recent_candidates, ) ) if not search_results: @@ -231,6 +261,62 @@ async def _search_and_select_resources( selected=list(selected or []), ) + def _filter_recent_candidates( + self, + *, + query: MediaSearchQuery, + plan: SubscriptionRunPlan, + candidates: list[ResourceSearchResult], + ) -> list[ResourceSearchResult]: + site_filtered = [result for result in candidates if self._candidate_matches_plan_sites(result, plan.sites)] + matched_candidates = [result for result in site_filtered if self._recent_candidate_matches_media(result, query)] + merged_results = resource_search_service.policy.merge_results(matched_candidates) + media_filtered = resource_search_service.policy.filter_media_results(merged_results, query) + season_filtered = resource_search_service.policy.filter_results_for_active_season(media_filtered, query) + return resource_search_service.cache_results(season_filtered) + + def _candidate_matches_plan_sites(self, result: ResourceSearchResult, sites: list[str] | None) -> bool: + if not sites: + return True + result_indexer_id, result_site_id = split_scoped_site_id(result.site) + for site in sites: + requested_indexer_id, requested_site_id = split_scoped_site_id(site) + if site == result.site: + return True + if requested_indexer_id and requested_indexer_id == result_indexer_id and requested_site_id == result_site_id: + return True + if not requested_indexer_id and requested_site_id == result_site_id: + return True + return False + + def _recent_candidate_matches_media(self, result: ResourceSearchResult, query: MediaSearchQuery) -> bool: + expected_douban_id = resource_search_service.policy.expected_douban_id(query) + expected_imdb_id = resource_search_service.policy.normalize_external_id(query.imdbid) + result_douban_id = resource_search_service.policy.normalize_external_id(result.source_doubanid) + result_imdb_id = resource_search_service.policy.normalize_external_id(result.source_imdbid) + if result_douban_id and expected_douban_id and result_douban_id == expected_douban_id: + return True + if result_imdb_id and expected_imdb_id and result_imdb_id == expected_imdb_id: + return True + return self._recent_title_matches(query.title, result.title) + + @staticmethod + def _recent_title_tokens(value: str | None) -> list[str]: + return re.findall(r"[^\W_]+", str(value or "").lower(), flags=re.UNICODE) + + def _recent_title_matches(self, media_title: str | None, resource_title: str | None) -> bool: + media_tokens = self._recent_title_tokens(media_title) + resource_tokens = self._recent_title_tokens(resource_title) + if not media_tokens or not resource_tokens: + return False + if len(media_tokens) == 1: + return media_tokens[0] in resource_tokens + window_size = len(media_tokens) + return any( + resource_tokens[index:index + window_size] == media_tokens + for index in range(0, len(resource_tokens) - window_size + 1) + ) + def _build_search_warnings( self, *, @@ -308,19 +394,164 @@ async def run_all(self) -> SubscriptionRunResponse: logger.info("Subscription sweep skipped because the previous sweep is still running") return SubscriptionRunResponse() - states = [state for state in await subscription_query_service.list_states() if state.active] - total_checked = 0 - total_added = 0 - for state in states: - try: - sub = await self._build_runtime_subscription(state) - res = await self.run_one(sub) - total_checked += res.checked - total_added += res.added - except (AppException, ValueError) as exc: - logger.warning("Subscription run failed for %s: %s", state.media_id, exc) - logger.info("Subscription sweep completed: subscriptions=%d checked=%d queued=%d", len(states), total_checked, total_added) - return SubscriptionRunResponse(checked=total_checked, added=total_added) + scheduler_config = settings_service.get_scheduler_config() + if scheduler_config.subscription_resource_discovery_mode == RSS_WITH_SEARCH_BACKFILL_MODE: + return await self._run_all_with_recent_feed(scheduler_config) + return await self._run_all_with_search(scheduler_config) + + async def _run_all_with_search(self, scheduler_config=None) -> SubscriptionRunResponse: + states = [state for state in await subscription_query_service.list_states() if state.active] + interval_seconds = ( + scheduler_config.subscription_search_interval_seconds + if scheduler_config is not None + else settings_service.get_scheduler_config().subscription_search_interval_seconds + ) + max_search_count = ( + scheduler_config.subscription_search_max_per_sweep + if scheduler_config is not None + else settings_service.get_scheduler_config().subscription_search_max_per_sweep + ) + due_states = [state for state in states if self._active_search_due(state, interval_seconds)] + search_states = await self._select_active_search_states(due_states, max_search_count) + total_checked = 0 + total_added = 0 + for state in search_states: + try: + sub = await self._build_runtime_subscription(state) + res = await self.run_one(sub, active_search=True) + total_checked += res.checked + total_added += res.added + except (AppException, ValueError) as exc: + logger.warning("Subscription run failed for %s: %s", state.media_id, exc) + logger.info( + "Subscription sweep completed: subscriptions=%d search_due=%d search_run=%d checked=%d queued=%d", + len(states), + len(due_states), + len(search_states), + total_checked, + total_added, + ) + return SubscriptionRunResponse(checked=total_checked, added=total_added) + + async def _run_all_with_recent_feed(self, scheduler_config=None) -> SubscriptionRunResponse: + states = [state for state in await subscription_query_service.list_states() if state.active] + recent_candidates = await self._fetch_recent_candidates_for_sweep() + interval_seconds = ( + scheduler_config.subscription_search_backfill_interval_seconds + if scheduler_config is not None + else settings_service.get_scheduler_config().subscription_search_backfill_interval_seconds + ) + max_search_count = ( + scheduler_config.subscription_search_max_per_sweep + if scheduler_config is not None + else settings_service.get_scheduler_config().subscription_search_max_per_sweep + ) + due_states = [state for state in states if self._active_search_due(state, interval_seconds)] + search_states = await self._select_active_search_states(due_states, max_search_count) + search_sub_ids = {state.sub_id for state in search_states} + total_checked = 0 + total_added = 0 + for state in states: + try: + sub = await self._build_runtime_subscription(state) + if state.sub_id in search_sub_ids: + res = await self.run_one(sub, active_search=True) + else: + res = await self.run_one(sub, recent_candidates=recent_candidates, active_search=False) + total_checked += res.checked + total_added += res.added + except (AppException, ValueError) as exc: + logger.warning("Subscription RSS run failed for %s: %s", state.media_id, exc) + logger.info( + "Subscription RSS sweep completed: subscriptions=%d search_due=%d search_run=%d candidates=%d checked=%d queued=%d", + len(states), + len(due_states), + len(search_states), + len(recent_candidates), + total_checked, + total_added, + ) + return SubscriptionRunResponse(checked=total_checked, added=total_added) + + def _active_search_due(self, state: MediaSubscriptionState, interval_seconds: int) -> bool: + if state.last_search_at is None: + return True + return (time.time() - state.last_search_at) >= max(1, int(interval_seconds or 600)) + + async def _select_active_search_states( + self, + states: list[MediaSubscriptionState], + max_search_count: int, + ) -> list[MediaSubscriptionState]: + limit = max(1, int(max_search_count or 1)) + ordered_states = sorted( + states, + key=lambda state: ( + state.last_search_at if state.last_search_at is not None else 0, + state.created_at, + state.sub_id or "", + ), + ) + selected: list[MediaSubscriptionState] = [] + for state in ordered_states: + if not await self._active_search_needed(state): + continue + selected.append(state) + if len(selected) >= limit: + break + return selected + + async def _active_search_needed(self, state: MediaSubscriptionState) -> bool: + if state.last_search_at is None: + return True + if state.media_id.media_type.value != "tv": + return True + if state.upgrade_policy and state.upgrade_policy.enabled: + return True + try: + sub = await self._build_runtime_subscription(state) + runtime_sub = await self._refresh_runtime_media_snapshot(sub) + planning_result = await resource_run_plan_service.build_subscription_plan(runtime_sub) + except (AppException, ValueError) as exc: + logger.warning("Subscription active search readiness check failed for %s: %s", state.media_id, exc) + return True + if planning_result.status != SubscriptionPlanningStatus.SATISFIED: + return True + return not self._next_episode_air_date_in_future(runtime_sub.media.next_episode_to_air.air_date if runtime_sub.media.next_episode_to_air else None) + + @staticmethod + def _next_episode_air_date_in_future(air_date: str | None) -> bool: + if not air_date: + return False + try: + parsed = datetime.fromisoformat(air_date[:10]).date() + except ValueError: + return False + return parsed > date.today() + + @staticmethod + def _records_active_search( + recent_candidates: list[ResourceSearchResult] | None, + active_search: bool | None, + ) -> bool: + if active_search is not None: + return active_search + return recent_candidates is None + + async def _fetch_recent_candidates_for_sweep(self) -> list[ResourceSearchResult]: + contexts = await resource_search_service.indexer_gateway.list_search_contexts(None) + enabled_contexts = [ + context + for context in contexts + if resource_search_service.indexer_gateway.is_context_enabled(context) + ] + if not enabled_contexts: + return [] + recent_result = await resource_search_service.indexer_gateway.fetch_recent_contexts(enabled_contexts) + settings_service.record_indexer_site_search_outcomes(recent_result.outcomes) + if recent_result.failed: + logger.warning("Subscription RSS sweep completed with indexer site failures") + return recent_result.results async def _build_runtime_subscription(self, state: MediaSubscriptionState) -> Subscription: config = await subscription_download_config_service.resolve_effective_config( diff --git a/backend/app/services/audit/action_service.py b/backend/app/services/audit/action_service.py index d57040a..cfef455 100644 --- a/backend/app/services/audit/action_service.py +++ b/backend/app/services/audit/action_service.py @@ -274,6 +274,7 @@ def list_actions( kinds: list[ActionKind] | None = None, statuses: list[ActionStatus] | None = None, action_names: list[str] | None = None, + excluded_action_names: tuple[str, ...] = (), triggers: list[ActionTrigger] | None = None, sources: list[ActionSource] | None = None, keyword: str | None = None, @@ -294,6 +295,7 @@ def list_actions( kinds=kinds, statuses=statuses, action_names=action_names, + excluded_action_names=excluded_action_names, triggers=triggers, sources=sources, keyword=keyword, diff --git a/backend/app/services/audit/event_service.py b/backend/app/services/audit/event_service.py index 299feed..8fe4b65 100644 --- a/backend/app/services/audit/event_service.py +++ b/backend/app/services/audit/event_service.py @@ -22,6 +22,7 @@ MediaEventCreate, ) from app.services.application.events.dispatch import event_dispatch_service +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_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 @@ -34,6 +35,9 @@ "notification.", "scheduler.", ) +INTERNAL_EVENT_CENTER_ACTION_NAMES = ( + ACTION_NAME_NOTIFICATION_SEND.value, +) def _event_search_blob(ev: Event) -> str: @@ -195,6 +199,7 @@ 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] @@ -202,24 +207,28 @@ def get_center(self) -> EventCenterResponse: active_action_count, active_actions = action_service.list_actions( limit=50, statuses=[ActionStatus.queued, ActionStatus.running], + excluded_action_names=INTERNAL_EVENT_CENTER_ACTION_NAMES, ) _attention_total, events = self.repo.list_filtered_page( limit=50, offset=0, levels=[EventLevel.warning, EventLevel.error], acknowledged=False, + excluded_type_prefixes=NON_BUSINESS_EVENT_PREFIXES, ) warning_event_count, _warning_sample = self.repo.list_filtered_page( limit=1, offset=0, levels=[EventLevel.warning], acknowledged=False, + excluded_type_prefixes=NON_BUSINESS_EVENT_PREFIXES, ) error_event_count, _error_sample = self.repo.list_filtered_page( limit=1, offset=0, levels=[EventLevel.error], acknowledged=False, + excluded_type_prefixes=NON_BUSINESS_EVENT_PREFIXES, ) if error_event_count > 0: bell_state = EventCenterBellState.error @@ -271,6 +280,7 @@ def list_filter_options( task_id=task_id, subscription_id=subscription_id, action_id=action_id, + excluded_type_prefixes=NON_BUSINESS_EVENT_PREFIXES, ) events = [ diff --git a/backend/app/services/audit/workflow_event_emitters.py b/backend/app/services/audit/workflow_event_emitters.py index ad8897d..cd12074 100644 --- a/backend/app/services/audit/workflow_event_emitters.py +++ b/backend/app/services/audit/workflow_event_emitters.py @@ -73,6 +73,7 @@ def emit_media_server_sync_events( error: str = "", ) -> None: target_paths = _sync_event_paths(anchor_file, transfer_results) + nfo_count, image_count = _sync_artifact_counts(media, anchor_file, transfer_results) for path in target_paths: event_service.emit_media( MediaEventCreate( @@ -92,6 +93,8 @@ def emit_media_server_sync_events( media_server_id=media_server_id, file_path=path, file_count=len(target_paths), + nfo_count=nfo_count, + image_count=image_count, trigger=trigger, error=error, ), @@ -103,3 +106,62 @@ def _sync_event_paths(anchor_file: str, transfer_results: list[MediaServerSyncTa if not paths and anchor_file: paths = [anchor_file] return sorted(set(paths)) + + +def _sync_artifact_counts( + media: MediaFullInfo, + anchor_file: str, + transfer_results: list[MediaServerSyncTargetFile], +) -> tuple[int, int]: + if not anchor_file: + return (0, 0) + media_root_dir = _media_root_dir(media, anchor_file) + nfo_paths = _expected_nfo_paths(media, anchor_file, transfer_results, media_root_dir) + image_paths: set[Path] = set() + root = Path(media_root_dir) + if media.poster_path: + image_paths.add(root / "poster.jpg") + if media.backdrop_path: + image_paths.add(root / "fanart.jpg") + if media.logo_path: + image_paths.add(root / "logo.png") + return ( + sum(1 for path in nfo_paths if path.exists()), + sum(1 for path in image_paths if path.exists()), + ) + + +def _media_root_dir(media: MediaFullInfo, anchor_file: str) -> Path: + anchor = Path(anchor_file) + if media.media_type.value != "tv": + return anchor.parent + parent = anchor.parent + if parent.name.lower().startswith("season"): + return parent.parent + return parent + + +def _expected_nfo_paths( + media: MediaFullInfo, + anchor_file: str, + transfer_results: list[MediaServerSyncTargetFile], + media_root_dir: Path, +) -> set[Path]: + anchor = Path(anchor_file) + if media.media_type.value != "tv": + paths = {media_root_dir / "movie.nfo"} + if anchor.suffix and anchor.suffix.lower() not in {".bdmv", ".ifo", ".vob"}: + paths.add(anchor.with_suffix(".nfo")) + return paths + + paths: set[Path] = {media_root_dir / "tvshow.nfo"} + for target in transfer_results: + if not target.destination_path: + continue + target_path = Path(target.destination_path) + season_dir = target_path.parent + if season_dir != media_root_dir: + paths.add(season_dir / "season.nfo") + if target.episode_number: + paths.add(target_path.with_suffix(".nfo")) + return paths diff --git a/backend/app/services/domain/media/execution_snapshot.py b/backend/app/services/domain/media/execution_snapshot.py index 1cee519..65785fc 100644 --- a/backend/app/services/domain/media/execution_snapshot.py +++ b/backend/app/services/domain/media/execution_snapshot.py @@ -16,7 +16,26 @@ class MediaExecutionSnapshotService: def __init__(self, profile_service: MediaProfileService) -> None: self.profile_service = profile_service - def _apply_cached_tv_season_context( + async def _snapshot_from_profile( + self, + media_id: MediaID, + *, + season_number: int | None = None, + ) -> MediaFullInfo | None: + profile = await self.profile_service.profile_repo.find_by_media_id(media_id) + if not profile or not self.profile_service.read_model.has_complete_detail(profile): + return None + scoped_profile, selected_scope = await self.profile_service._profile_scope_context( + profile, + season_number=season_number, + ) + return self.profile_service.read_model.snapshot_to_full( + media_id, + scoped_profile, + selected_scope=selected_scope, + ) + + def _apply_profile_tv_season_context( self, media: MediaFullInfo, season_number: int | None, @@ -45,12 +64,15 @@ async def resolve_execution_snapshot( raise DownloadException("backendErrors.mediaExecutionSnapshotSeasonNumberRequired") use_simple_snapshot = media_id.media_type != MediaType.tv or season_number is None + if include_schedule_snapshot and media_id.media_type == MediaType.tv and season_number is not None: + use_simple_snapshot = False media = await self.profile_service.simple_info(media_id) if ( media is not None and media_id.media_type == MediaType.tv and season_number is not None and media.season_number == season_number + and not include_schedule_snapshot ): use_simple_snapshot = True if media is not None and media_id.media_type == MediaType.tv and use_simple_snapshot: @@ -61,13 +83,16 @@ async def resolve_execution_snapshot( if not include_schedule_snapshot and (media_id.media_type != MediaType.tv or not require_episode_count or simple_snapshot.episodes_count): return simple_snapshot - full_media = await self.profile_service.cached_info(media_id) + full_media = await self._snapshot_from_profile( + media_id, + season_number=season_number if include_schedule_snapshot else None, + ) if full_media is None: if simple_snapshot is not None and (media_id.media_type != MediaType.tv or not require_episode_count or simple_snapshot.episodes_count): return simple_snapshot raise MediaNotFoundException() if media_id.media_type == MediaType.tv: - full_media = self._apply_cached_tv_season_context( + full_media = self._apply_profile_tv_season_context( full_media, season_number, require_episode_count=require_episode_count, diff --git a/backend/app/services/domain/media/profile/lifecycle.py b/backend/app/services/domain/media/profile/lifecycle.py index 2fcdd6a..1bf225e 100644 --- a/backend/app/services/domain/media/profile/lifecycle.py +++ b/backend/app/services/domain/media/profile/lifecycle.py @@ -61,6 +61,9 @@ async def is_managed_media(self, media_id: MediaID) -> bool: return bool(profile and profile.is_active) async def _has_media_references(self, media_id: MediaID) -> bool: + subscriptions = await subscription_query_service.list_states() + if any(subscription.media_id == media_id and (subscription.active or subscription.followed) for subscription in subscriptions): + return True if await self.task_repo.find_by_media_id(media_id): return True if await self.episode_repo.find_by_media_id(media_id): diff --git a/backend/app/services/domain/media/profile/service.py b/backend/app/services/domain/media/profile/service.py index f0fc317..2f84b11 100644 --- a/backend/app/services/domain/media/profile/service.py +++ b/backend/app/services/domain/media/profile/service.py @@ -398,13 +398,6 @@ async def info_with_cache_status( ) return with_cached_season_metadata(enriched, effective_season), "miss" - async def cached_info(self, media_id: MediaID) -> MediaFullInfo | None: - profile = await self.profile_repo.find_by_media_id(media_id) - if not profile or not self.read_model.has_complete_detail(profile): - return None - scoped_profile, selected_scope = await self._profile_scope_context(profile) - return self.read_model.snapshot_to_full(media_id, scoped_profile, selected_scope=selected_scope) - async def info_from_source(self, lookup: MediaSourceLookup) -> MediaFullInfo | None: mapping = self._resolve_source_mapping(lookup) if mapping and mapping.media_id.media_type == lookup.media_type: diff --git a/backend/app/services/domain/media/service.py b/backend/app/services/domain/media/service.py index 7604f29..38832d1 100644 --- a/backend/app/services/domain/media/service.py +++ b/backend/app/services/domain/media/service.py @@ -70,15 +70,12 @@ async def info_with_cache_status( async def info_from_source(self, lookup: MediaSourceLookup) -> MediaFullInfo | None: return await self.profile_service.info_from_source(lookup) - async def cached_info(self, media_id: MediaID) -> MediaFullInfo | None: - return await self.profile_service.cached_info(media_id) - - async def season_detail_for_library_view(self, media_id: MediaID, *, season_number: int) -> MediaFullInfo | None: - return await self.profile_service.info(media_id, season_number=season_number) - async def simple_info(self, media_id: MediaID) -> MediaSimpleInfo | None: return await self.profile_service.simple_info(media_id) + async def resolve_follow_release_media(self, media_id: MediaID) -> MediaFullInfo | None: + return await self.profile_service.info(media_id) + def apply_season_context[T: MediaFullInfo | MediaSimpleInfo](self, media: T, season_number: int | None) -> T: return apply_media_season_context(media, season_number) diff --git a/backend/app/services/domain/resource/selection.py b/backend/app/services/domain/resource/selection.py index d9ecec0..5764bab 100644 --- a/backend/app/services/domain/resource/selection.py +++ b/backend/app/services/domain/resource/selection.py @@ -65,6 +65,21 @@ def automatic_resource_sort_rank( ) +def _resource_selection_rank( + result: Resource, + quality_profile: QualityProfile | None, + covered_count: int, + preference_score: int, +) -> tuple: + rank = automatic_resource_sort_rank(result, quality_profile) + return ( + rank[:5], + covered_count, + preference_score, + rank[5:], + ) + + def has_valid_seeders(result: Resource) -> bool: return int(result.resources.seeders or 0) > 0 @@ -88,7 +103,7 @@ def _size_health_rank(result: Resource, attrs: ResourceAttributes) -> int: return 3 if size_bytes >= 6 * gib: return 2 - if size_bytes >= 3 * gib: + if size_bytes >= 2 * gib: return 1 return 0 if attrs.resolution == "1080p": @@ -267,11 +282,7 @@ async def _select_video_file_resources( } if not covered: continue - rank = ( - automatic_resource_sort_rank(result, quality_profile), - preference_score, - len(covered), - ) + rank = _resource_selection_rank(result, quality_profile, len(covered), preference_score) if best_rank is None or rank > best_rank: best_resource = result best_known_covered = covered diff --git a/backend/app/services/domain/subscription/query_service.py b/backend/app/services/domain/subscription/query_service.py index 31bc216..7ae7dd6 100644 --- a/backend/app/services/domain/subscription/query_service.py +++ b/backend/app/services/domain/subscription/query_service.py @@ -90,6 +90,7 @@ def compose_runtime_subscription(cls, state, config: EffectiveMediaDownloadConfi active=state.active, created_at=state.created_at, last_run_at=state.last_run_at, + last_search_at=state.last_search_at, follow_reminded_air_date=state.follow_reminded_air_date, follow_reminded_digital_release_date=state.follow_reminded_digital_release_date, follow_reminded_physical_release_date=state.follow_reminded_physical_release_date, diff --git a/backend/app/services/domain/subscription/store.py b/backend/app/services/domain/subscription/store.py index 8cd4b2f..e72ba1e 100644 --- a/backend/app/services/domain/subscription/store.py +++ b/backend/app/services/domain/subscription/store.py @@ -183,6 +183,9 @@ async def save_run_record(self, record: SubscriptionRunRecord) -> SubscriptionAg return aggregate cycle = aggregate.active_cycle.model_copy(update={ "last_checked_at": record.checked_at, + "last_search_at": record.searched_at + if record.searched_at is not None + else aggregate.active_cycle.last_search_at, "warnings": list(record.warnings), "completion_snapshot": record.upgrade_snapshot if record.upgrade_snapshot is not None else aggregate.active_cycle.completion_snapshot, "updated_at": time.time(), @@ -272,6 +275,7 @@ def _build_state( [value for value in [settings.updated_at if settings else None, cycle.updated_at if cycle else None, time.time()] if value is not None] ), last_run_at=cycle.last_checked_at if cycle else None, + last_search_at=cycle.last_search_at if cycle else None, follow_reminded_air_date=settings.follow_reminded_air_date if settings else None, follow_reminded_digital_release_date=settings.follow_reminded_digital_release_date if settings else None, follow_reminded_physical_release_date=settings.follow_reminded_physical_release_date if settings else None, diff --git a/backend/app/services/i18n/locales/en-US.json b/backend/app/services/i18n/locales/en-US.json index 0d1b43b..aadf899 100644 --- a/backend/app/services/i18n/locales/en-US.json +++ b/backend/app/services/i18n/locales/en-US.json @@ -152,7 +152,7 @@ "eventMessages.mediaImportFailed": "Import failed for {resource_title}: {error}", "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.mediaServerSyncCompleted": "Scraping completed for {file_path_name}: artifacts NFO {nfo_count}, images {image_count}", "eventMessages.mediaServerSyncFailed": "Scraping failed for {file_path_name}: {error}", "eventMessages.notificationFailed": "Notification failed: {channel}. Reason: {reason}", "eventMessages.notificationSent": "Notification sent: {channel}", diff --git a/backend/app/services/i18n/locales/zh-CN.json b/backend/app/services/i18n/locales/zh-CN.json index 6b4c59f..74cab5d 100644 --- a/backend/app/services/i18n/locales/zh-CN.json +++ b/backend/app/services/i18n/locales/zh-CN.json @@ -152,7 +152,7 @@ "eventMessages.mediaImportFailed": "入库失败「{resource_title}」:{error}", "eventMessages.mediaDeleted": "资源已删除,共删除 {path_count} 个路径,首个目标「{first_target}」", "eventMessages.mediaImportCompleted": "已完成入库,共导入 {file_count} 个文件,首个文件「{first_target}」", - "eventMessages.mediaServerSyncCompleted": "刮削完成「{file_path_name}」", + "eventMessages.mediaServerSyncCompleted": "刮削完成「{file_path_name}」:产物 NFO {nfo_count} 个,图片 {image_count} 个", "eventMessages.mediaServerSyncFailed": "刮削失败「{file_path_name}」:{error}", "eventMessages.notificationFailed": "消息通知发送失败:{channel},原因:{reason}", "eventMessages.notificationSent": "消息通知发送成功:{channel}", diff --git a/backend/app/services/integration/indexer/gateway.py b/backend/app/services/integration/indexer/gateway.py index 00d2880..eda84b3 100644 --- a/backend/app/services/integration/indexer/gateway.py +++ b/backend/app/services/integration/indexer/gateway.py @@ -7,6 +7,7 @@ from app.clients.factory import ClientFactory, ClientType from app.schemas.config import IndexerProviderConfig from app.schemas.domain.media_types import MediaType +from app.schemas.domain.resource_search import ResourceSearchResult from app.schemas.integration.site_models import ( IndexerSiteSetting, SiteInfo, @@ -14,7 +15,7 @@ effective_media_types_from_caps, ) from app.schemas.constants.indexer import SITE_SEARCH_TIMEOUT_SECONDS -from app.schemas.runtime.indexer_runtime import IndexerSearchContext +from app.schemas.runtime.indexer_runtime import IndexerSearchContext, IndexerSiteSearchOutcome from app.schemas.runtime.indexer_site_health import IndexerSiteHealthStatus from app.services.config.settings_service import settings_service from app.services.integration.indexer.site_scope import split_scoped_site_id @@ -99,6 +100,55 @@ async def search_context( capabilities_by_site={context.site.id: context.capabilities}, ) + async def fetch_recent_contexts( + self, + contexts: list[IndexerSearchContext], + *, + media_type: MediaType | None = None, + ) -> IndexerSiteSearchResult: + if not contexts: + return IndexerSiteSearchResult(results=[], failed=False, searched=False, outcomes=[]) + grouped: dict[str, list[IndexerSearchContext]] = {} + for context in contexts: + if not self.is_context_enabled(context): + continue + if not self.context_supports_media_type(context, media_type): + continue + grouped.setdefault(context.indexer_id, []).append(context) + + grouped_results = await asyncio.gather(*( + self._fetch_recent_context_group(indexer_id, indexer_contexts, media_type=media_type) + for indexer_id, indexer_contexts in grouped.items() + )) + results: list[ResourceSearchResult] = [] + outcomes: list[IndexerSiteSearchOutcome] = [] + had_failures = False + searched = False + for item in grouped_results: + results.extend(item.results) + outcomes.extend(item.outcomes) + had_failures = had_failures or item.failed + searched = searched or item.searched + return IndexerSiteSearchResult(results=results, failed=had_failures, searched=searched, outcomes=outcomes) + + async def _fetch_recent_context_group( + self, + indexer_id: str, + contexts: list[IndexerSearchContext], + *, + media_type: MediaType | None, + ) -> IndexerSiteSearchResult: + client = self._client_by_id(indexer_id) + if client is None: + logger.warning("Indexer recent feed skipped because client is unavailable: indexer=%s", indexer_id) + return IndexerSiteSearchResult(results=[], failed=True, searched=False, outcomes=[]) + category = media_type.value if media_type else None + return await self._site_searcher.fetch_recent_sites( + client, + [context.site for context in contexts], + category=category, + ) + async def list_search_contexts( self, requested_sites: list[str] | None, diff --git a/backend/app/services/integration/indexer/site_search.py b/backend/app/services/integration/indexer/site_search.py index fcdbbc3..c7cb940 100644 --- a/backend/app/services/integration/indexer/site_search.py +++ b/backend/app/services/integration/indexer/site_search.py @@ -151,6 +151,103 @@ async def search_site_by_query( error=error_message, ) + async def fetch_recent_sites( + self, + client: IndexerClient, + sites: list[SiteInfo], + *, + category: str | None = None, + ) -> IndexerSiteSearchResult: + if not sites: + return IndexerSiteSearchResult(results=[], failed=False, searched=False, outcomes=[]) + semaphore = asyncio.Semaphore(self._concurrency) + outcomes = await asyncio.gather(*( + self.fetch_recent_site(client, site, semaphore, category=category) + for site in sites + )) + + results: list[ResourceSearchResult] = [] + had_failures = False + for outcome in outcomes: + if outcome.success: + if outcome.results: + results.extend(self._normalize_site_results(client, outcome.site, outcome.results)) + continue + had_failures = True + error_message = outcome.error or "unknown error" + logger.warning( + "Indexer site recent feed failed: indexer=%s site=%s error=%s", + client.config.id, + outcome.site.id, + error_message, + ) + return IndexerSiteSearchResult(results=results, failed=had_failures, searched=True, outcomes=list(outcomes)) + + async def fetch_recent_site( + self, + client: IndexerClient, + site: SiteInfo, + semaphore: asyncio.Semaphore, + *, + category: str | None = None, + ) -> IndexerSiteSearchOutcome: + async with semaphore: + try: + results = await asyncio.wait_for( + client.fetch_recent_torznab(site.id, category=category), + timeout=self._timeout, + ) + results = [ + result.model_copy(update={"matched_by_id": False}) + for result in results + ] + logger.debug( + "Indexer site recent feed result: client=%s site=%s count=%s", + client.config.id, + site.id, + len(results), + ) + return IndexerSiteSearchOutcome( + indexer_id=client.config.id, + indexer_name=client.config.name, + indexer_type=client.config.type, + site=site, + success=True, + results=results, + ) + except asyncio.TimeoutError as exc: + error_message = str(exc).strip() or f"site_recent_timeout:{site.id}" + logger.debug( + "Indexer site recent feed failed: client=%s site=%s error=%s", + client.config.id, + site.id, + error_message, + ) + return IndexerSiteSearchOutcome( + indexer_id=client.config.id, + indexer_name=client.config.name, + indexer_type=client.config.type, + site=site, + success=False, + error=error_message, + ) + except RuntimeError as exc: + error_message = str(exc).strip() or f"site_recent_error:{site.id}" + logger.debug( + "Indexer site recent feed failed: client=%s site=%s error=%s", + client.config.id, + site.id, + error_message, + ) + return IndexerSiteSearchOutcome( + indexer_id=client.config.id, + indexer_name=client.config.name, + indexer_type=client.config.type, + site=site, + success=False, + error=error_message, + ) + def _normalize_site_results( self, client: IndexerClient, diff --git a/backend/app/services/integration/notifications/channels/telegram/renderer.py b/backend/app/services/integration/notifications/channels/telegram/renderer.py index 479b24c..1ffb480 100644 --- a/backend/app/services/integration/notifications/channels/telegram/renderer.py +++ b/backend/app/services/integration/notifications/channels/telegram/renderer.py @@ -6,7 +6,7 @@ from pathlib import Path from urllib.parse import quote, urlencode -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.schemas.domain.event import Event, EventLevel, EventType from app.services.i18n.message_renderer import render_message @@ -32,6 +32,23 @@ class TelegramEventMeta(BaseModel): imported_files: list[TelegramImportedFileMeta] = Field(default_factory=list) episode_number: int | None = None + @field_validator( + "resource_title", + "torrent_name", + "file_path", + "video_path", + "path", + "reason", + "error", + "error_key", + mode="before", + ) + @classmethod + def _none_to_empty(cls, value) -> str: + if value is None: + return "" + return str(value) + def escape_markdown(value: str) -> str: escaped = value or "" @@ -130,6 +147,9 @@ def _resource_line(event: Event, meta: TelegramEventMeta, locale: str) -> str: resource = ( meta.torrent_name or meta.resource_title + or _message_param(event, "torrent_name") + or _message_param(event, "resource_title") + or _indexer_site_resource(event) or _path_name(meta.file_path) or _path_name(meta.video_path) or _path_name(meta.path) @@ -146,6 +166,14 @@ def _message_param(event: Event, name: str) -> str: return str(event.message_params[name]) +def _indexer_site_resource(event: Event) -> str: + if event.type != EventType.INDEXER_SITE_UNHEALTHY: + return "" + indexer = _message_param(event, "indexer_name") or _message_param(event, "indexer_id") + site = _message_param(event, "site_name") or _message_param(event, "site_id") + return " / ".join(part for part in (indexer, site) if part) + + def _reason_line(event: Event, meta: TelegramEventMeta, locale: str) -> str: if event.level == EventLevel.info and not event.type.value.endswith(".failed"): return "" diff --git a/backend/tests/test_event_center.py b/backend/tests/test_event_center.py index 3b70ccd..ca60a6c 100644 --- a/backend/tests/test_event_center.py +++ b/backend/tests/test_event_center.py @@ -3,6 +3,7 @@ from app.schemas.domain.action import ActionKind, ActionRecord, ActionStatus from app.schemas.domain.event import Event, EventCenterBellState, EventLevel, EventType +from app.services.audit.action_service import ActionService from app.services.audit.event_service import EventService @@ -37,18 +38,23 @@ def acknowledge_attention_events(self) -> int: def test_event_center_uses_warning_error_events_and_running_actions(monkeypatch): service = EventService() service.repo = _FakeEventRepository() + captured = {} + + def list_actions(**kwargs): + captured.update(kwargs) + return ( + 1, + [ActionRecord(id="action-1", kind=ActionKind.command, action_name="task.transfer", status=ActionStatus.running)], + ) + 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)], - ) - ), + SimpleNamespace(list_actions=list_actions), ) center = service.get_center() + assert captured["excluded_action_names"] == ("notification.send",) assert center.summary.bell_state == EventCenterBellState.error assert center.summary.error_event_count == 1 assert center.summary.warning_event_count == 1 @@ -56,6 +62,20 @@ def test_event_center_uses_warning_error_events_and_running_actions(monkeypatch) assert [event.id for event in center.events] == ["event-error", "event-warning"] +def test_action_service_forwards_excluded_action_names(): + captured = {} + service = ActionService() + service.repo = SimpleNamespace( + list_filtered_page=lambda **kwargs: (captured.update(kwargs) or (0, [])), + ) + + total, actions = service.list_actions(excluded_action_names=("notification.send",)) + + assert total == 0 + assert actions == [] + assert captured["excluded_action_names"] == ("notification.send",) + + def test_event_center_hides_acknowledged_warning_error_events(monkeypatch): service = EventService() service.repo = _FakeEventRepository() diff --git a/backend/tests/test_follow_reminder_service.py b/backend/tests/test_follow_reminder_service.py index 0bdbc2b..76a8ed5 100644 --- a/backend/tests/test_follow_reminder_service.py +++ b/backend/tests/test_follow_reminder_service.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from datetime import date, timedelta import pytest @@ -12,15 +13,21 @@ pytestmark = pytest.mark.drift +def _ymd(days_from_today: int) -> str: + return (date.today() + timedelta(days=days_from_today)).isoformat() + + def _movie(**updates) -> MediaFullInfo: + theatrical_date = _ymd(-1) + digital_date = _ymd(-2) payload = { "media_id": MediaID.parse("tmdb:movie:1"), "media_type": MediaType.movie, "title": "Sample", - "year": 2026, - "release_date": "2026-01-01", - "theatrical_release_date": "2026-01-01", - "digital_release_date": "2026-01-10", + "year": date.today().year, + "release_date": theatrical_date, + "theatrical_release_date": theatrical_date, + "digital_release_date": digital_date, } payload.update(updates) return MediaFullInfo(**payload) @@ -28,7 +35,8 @@ def _movie(**updates) -> MediaFullInfo: @pytest.mark.asyncio async def test_follow_reminder_emits_movie_theatrical_digital_and_physical_events(monkeypatch): - media = _movie(physical_release_date="2026-01-20") + physical_date = _ymd(-3) + media = _movie(physical_release_date=physical_date) sub = SimpleNamespace( media_id=media.media_id, sub_id="sub-1", @@ -44,13 +52,14 @@ async def fake_list(): return [sub] async def fake_info(media_id): + assert media_id == media.media_id return media async def fake_patch_by_sub_id(sub_id, patch): patches.append((sub_id, patch)) monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.subscription_query_service.list_states", fake_list) - monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.media_service.cached_info", fake_info) + monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.media_service.resolve_follow_release_media", fake_info) monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.event_service.emit_media", lambda event, meta=None: emitted.append((event, meta))) monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.subscription_command_service.patch_settings_by_sub_id", fake_patch_by_sub_id) @@ -66,15 +75,19 @@ async def fake_patch_by_sub_id(sub_id, patch): "eventMessages.followDigitalReleased", "eventMessages.followPhysicalReleased", ] - assert [event_message_params(event, meta)["air_date"] for event, meta in emitted] == ["2026-01-01", "2026-01-10", "2026-01-20"] - assert patches[0][1].follow_reminded_air_date == "2026-01-01" - assert patches[0][1].follow_reminded_digital_release_date == "2026-01-10" - assert patches[0][1].follow_reminded_physical_release_date == "2026-01-20" + assert [event_message_params(event, meta)["air_date"] for event, meta in emitted] == [ + media.theatrical_release_date, + media.digital_release_date, + physical_date, + ] + assert patches[0][1].follow_reminded_air_date == media.theatrical_release_date + assert patches[0][1].follow_reminded_digital_release_date == media.digital_release_date + assert patches[0][1].follow_reminded_physical_release_date == physical_date @pytest.mark.asyncio async def test_follow_reminder_emits_only_available_movie_release(monkeypatch): - media = _movie(release_date=None, theatrical_release_date=None, digital_release_date="2026-01-10") + media = _movie(release_date=None, theatrical_release_date=None, digital_release_date=_ymd(-1)) sub = SimpleNamespace( media_id=media.media_id, sub_id="sub-1", @@ -88,16 +101,58 @@ async def fake_list(): return [sub] async def fake_info(media_id): + assert media_id == media.media_id return media async def fake_patch_by_sub_id(sub_id, patch): return None monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.subscription_query_service.list_states", fake_list) - monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.media_service.cached_info", fake_info) + monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.media_service.resolve_follow_release_media", fake_info) monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.event_service.emit_media", lambda event, meta=None: emitted.append((event, meta))) monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.subscription_command_service.patch_settings_by_sub_id", fake_patch_by_sub_id) await follow_reminder_service.run_once() assert [event.type for event, _ in emitted] == [EventTypes.FOLLOW_DIGITAL_RELEASED] + + +@pytest.mark.asyncio +async def test_follow_reminder_does_not_emit_old_tv_air_date(monkeypatch): + media = MediaFullInfo( + media_id=MediaID.parse("tmdb:tv:76479"), + media_type=MediaType.tv, + title="黑袍纠察队", + year=2019, + season_number=1, + first_air_date="2019-07-25", + ) + sub = SimpleNamespace( + media_id=media.media_id, + season_number=1, + sub_id="sub-1", + followed=True, + follow_reminded_air_date=None, + ) + emitted = [] + patches = [] + + async def fake_list(): + return [sub] + + async def fake_execution_snapshot(media_id, *, season_number=None): + assert season_number == 1 + return media + + async def fake_patch_by_sub_id(sub_id, patch): + patches.append((sub_id, patch)) + + monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.subscription_query_service.list_states", fake_list) + monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.media_service.resolve_execution_snapshot", fake_execution_snapshot) + monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.event_service.emit_media", lambda event, meta=None: emitted.append((event, meta))) + monkeypatch.setattr("app.services.application.workflows.follow_reminder.service.subscription_command_service.patch_settings_by_sub_id", fake_patch_by_sub_id) + + await follow_reminder_service.run_once(window_days=7) + + assert emitted == [] + assert patches == [] diff --git a/backend/tests/test_indexer_site_search_modes.py b/backend/tests/test_indexer_site_search_modes.py index da17aa4..c217fb5 100644 --- a/backend/tests/test_indexer_site_search_modes.py +++ b/backend/tests/test_indexer_site_search_modes.py @@ -4,6 +4,7 @@ import pytest from app.clients.base import IndexerClient +from app.clients.torznab import build_torznab_recent_params from app.schemas.config import JackettConfig from app.schemas.domain.media import MediaExecutionSnapshot from app.schemas.domain.media_types import MediaType @@ -58,6 +59,7 @@ def __init__(self) -> None: ) ) self.calls: list[tuple[str, str]] = [] + self.recent_calls: list[tuple[str, str | None]] = [] async def test_connection(self) -> bool: return True @@ -104,6 +106,29 @@ async def search_indexer_torznab( ) ] + async def fetch_recent_torznab( + self, + indexer: str, + category: str | None = None, + ) -> list[ResourceSearchResult]: + self.recent_calls.append((indexer, category)) + return [ + ResourceSearchResult( + id=f"{indexer}-recent", + title="Example Show S01E02 2160p WEB-DL", + site=indexer, + category=category or "tv", + size="1 GB", + seeders=10, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/download/recent", + detail_url=f"https://example.com/detail/{indexer}/recent", + result_id=f"{indexer}-recent", + matched_by_id=False, + ) + ] + @pytest.mark.asyncio async def test_search_media_respects_site_level_disable_settings(): @@ -123,6 +148,32 @@ async def test_search_media_respects_site_level_disable_settings(): assert client.calls == [("site-a", "q")] +def test_torznab_recent_params_do_not_include_query(): + params = build_torznab_recent_params(api_key="key", category="tv") + + assert params == {"apikey": "key", "t": "search", "cat": "5000"} + assert "q" not in params + assert "imdbid" not in params + + +@pytest.mark.asyncio +async def test_indexer_gateway_fetches_recent_contexts_with_site_metadata(): + client = RecordingIndexerClient() + gateway = IndexerGateway(client=client) + contexts = await gateway.list_search_contexts(None) + + result = await gateway.fetch_recent_contexts(contexts, media_type=MediaType.tv) + + assert result.searched is True + assert result.failed is False + assert client.recent_calls == [("site-a", "tv")] + assert len(result.results) == 1 + assert result.results[0].site == scoped_site_id("fake-indexer", "site-a") + assert result.results[0].site_name == "Site A" + assert result.results[0].indexer_id == "fake-indexer" + assert result.results[0].matched_by_id is False + + class MediaTypeFilteringClient(RecordingIndexerClient): def __init__(self, site_settings=None) -> None: super().__init__() diff --git a/backend/tests/test_library_delete_refresh_event.py b/backend/tests/test_library_delete_refresh_event.py index 67080a6..d2d0cf3 100644 --- a/backend/tests/test_library_delete_refresh_event.py +++ b/backend/tests/test_library_delete_refresh_event.py @@ -1,5 +1,6 @@ import os import uuid +from types import SimpleNamespace from unittest.mock import AsyncMock import pytest @@ -90,10 +91,11 @@ async def test_delete_media_library_files_emits_delete_event_with_file_paths_whe @pytest.mark.asyncio -async def test_delete_media_resources_leaves_profile_lifecycle_to_scheduler(monkeypatch): +async def test_delete_media_resources_refreshes_profile_lifecycle(monkeypatch): media_id = MediaID.parse("tmdb:movie:1") service = MediaResourceDeletionService() archive_mock = AsyncMock() + lifecycle_mock = AsyncMock(return_value=True) monkeypatch.setattr( "app.services.application.workflows.media_resource_deletion.service.library_service.delete_media_library_files", @@ -107,6 +109,10 @@ async def test_delete_media_resources_leaves_profile_lifecycle_to_scheduler(monk "app.services.application.workflows.media_resource_deletion.service.download_service.get_tasks", AsyncMock(return_value=[]), ) + monkeypatch.setattr( + "app.services.application.workflows.media_resource_deletion.service.media_service.mark_profile_inactive_if_unmanaged", + lifecycle_mock, + ) deleted_tasks_count, deleted_library_files_count = await service.delete_media_resources( media_id, @@ -118,3 +124,46 @@ async def test_delete_media_resources_leaves_profile_lifecycle_to_scheduler(monk assert deleted_tasks_count == 0 assert deleted_library_files_count == 1 archive_mock.assert_awaited_once_with(media_id) + lifecycle_mock.assert_awaited_once_with(media_id) + + +@pytest.mark.asyncio +async def test_delete_tv_season_resources_archives_orphan_media_entry(monkeypatch): + media_id = MediaID.parse("tmdb:tv:254486") + service = MediaResourceDeletionService() + archive_mock = AsyncMock() + lifecycle_mock = AsyncMock(return_value=True) + + monkeypatch.setattr( + "app.services.application.workflows.media_resource_deletion.service.library_service.delete_media_library_files", + AsyncMock(return_value=0), + ) + monkeypatch.setattr( + "app.services.application.workflows.media_resource_deletion.service.library_service.archive_media_entry", + archive_mock, + ) + monkeypatch.setattr( + "app.services.application.workflows.media_resource_deletion.service.library_service.get_media_library_snapshot", + AsyncMock(return_value=SimpleNamespace(files=[], present_episodes=set())), + ) + monkeypatch.setattr( + "app.services.application.workflows.media_resource_deletion.service.download_service.get_tasks", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr( + "app.services.application.workflows.media_resource_deletion.service.media_service.mark_profile_inactive_if_unmanaged", + lifecycle_mock, + ) + + deleted_tasks_count, deleted_library_files_count = await service.delete_media_resources( + media_id, + season_number=1, + mode="tasks_and_library", + delete_files=True, + force=False, + ) + + assert deleted_tasks_count == 0 + assert deleted_library_files_count == 0 + archive_mock.assert_awaited_once_with(media_id) + lifecycle_mock.assert_awaited_once_with(media_id) diff --git a/backend/tests/test_library_list.py b/backend/tests/test_library_list.py index 781379d..8410698 100644 --- a/backend/tests/test_library_list.py +++ b/backend/tests/test_library_list.py @@ -10,9 +10,10 @@ LibraryFileMediaServerSyncCommandRecordPayload, ) from app.schemas.domain.library import LibraryFile -from app.schemas.domain.media import MediaFullInfo, MediaSeasonInfo, MediaSimpleInfo, MediaTarget +from app.schemas.domain.media import MediaExecutionSnapshot, MediaFullInfo, MediaSeasonInfo, MediaSimpleInfo, MediaTarget from app.schemas.domain.media_types import MediaType from app.schemas.domain.resource_attributes import ResourceAttributes +from app.schemas.exception import DownloadException from app.schemas.media_id import MediaID from app.schemas.runtime.library_resource_list import LibraryResourceAction from app.services.application.views.library.resource_list import LibraryResourceListService, _LibraryActionAvailabilityContext @@ -64,9 +65,22 @@ async def fake_simple_info(requested_media_id): assert requested_media_id == media_id return simple_media - async def fake_cached_info(requested_media_id): + async def fake_execution_snapshot( + requested_media_id, + *, + season_number=None, + require_tv_season=False, + require_episode_count=False, + include_schedule_snapshot=False, + ): assert requested_media_id == media_id - return full_media + assert season_number == 2 + assert require_tv_season is True + assert require_episode_count is True + assert include_schedule_snapshot is True + return MediaExecutionSnapshot.model_validate( + full_media.model_copy(update={"episodes_count": 8}) + ) async def fake_library_files(requested_media_id, season=None): assert requested_media_id == media_id @@ -81,7 +95,7 @@ def fake_action_context(self, library_files, context): return (False, False, False) monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.simple_info", fake_simple_info) - monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.cached_info", fake_cached_info) + monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.resolve_execution_snapshot", fake_execution_snapshot) monkeypatch.setattr("app.services.application.views.library.resource_list.library_service.get_files_by_media", fake_library_files) monkeypatch.setattr( "app.services.application.views.library.resource_list.command_service.list_media_active_commands", @@ -99,7 +113,68 @@ def fake_action_context(self, library_files, context): @pytest.mark.asyncio -async def test_library_list_fetches_season_detail_when_cached_profile_is_missing(monkeypatch): +async def test_library_list_continues_when_execution_snapshot_is_unavailable(monkeypatch): + media_id = MediaID.parse("douban:tv:123") + simple_media = MediaSimpleInfo( + media_id=media_id, + provider=media_id.provider, + media_type=MediaType.tv, + id=media_id.id, + title="Example Show", + year=2026, + season_number=2, + episodes_count=24, + ) + library_file = LibraryFile( + id="file-1", + task_id="task-1", + directory_id="dir-1", + media_id=media_id, + path="/library/example-s02e01.mkv", + file_name="example-s02e01.mkv", + file_size=1024, + created_at=1.0, + ) + + async def fake_simple_info(requested_media_id): + assert requested_media_id == media_id + return simple_media + + async def fake_execution_snapshot(*args, **kwargs): + raise DownloadException("backendErrors.mediaExecutionSnapshotEpisodeCountMissing") + + async def fake_library_files(requested_media_id, season=None): + assert requested_media_id == media_id + assert season == 2 + return [library_file] + + async def fake_active_commands(*args, **kwargs): + return [] + + def fake_action_context(self, library_files, context): + assert library_files == [library_file] + return (False, False, False) + + monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.simple_info", fake_simple_info) + monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.resolve_execution_snapshot", fake_execution_snapshot) + monkeypatch.setattr("app.services.application.views.library.resource_list.library_service.get_files_by_media", fake_library_files) + monkeypatch.setattr( + "app.services.application.views.library.resource_list.command_service.list_media_active_commands", + fake_active_commands, + ) + monkeypatch.setattr( + "app.services.application.views.library.resource_list.LibraryResourceListService._resolve_action_context", + fake_action_context, + ) + + response = await list_library_resources(media_id, season_number=2) + + assert response.total_episodes == 24 + assert len(response.resources) == 1 + + +@pytest.mark.asyncio +async def test_library_list_fetches_season_execution_snapshot(monkeypatch): media_id = MediaID.parse("douban:tv:123") simple_media = MediaSimpleInfo( media_id=media_id, @@ -140,14 +215,22 @@ async def fake_simple_info(requested_media_id): assert requested_media_id == media_id return simple_media - async def fake_cached_info(requested_media_id): - assert requested_media_id == media_id - return None - - async def fake_info(requested_media_id, *, season_number=None): + async def fake_execution_snapshot( + requested_media_id, + *, + season_number=None, + require_tv_season=False, + require_episode_count=False, + include_schedule_snapshot=False, + ): assert requested_media_id == media_id assert season_number == 2 - return full_media + assert require_tv_season is True + assert require_episode_count is True + assert include_schedule_snapshot is True + return MediaExecutionSnapshot.model_validate( + full_media.model_copy(update={"episodes_count": 8}) + ) async def fake_library_files(requested_media_id, season=None): assert requested_media_id == media_id @@ -162,8 +245,7 @@ def fake_action_context(self, library_files, context): return (False, False, False) monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.simple_info", fake_simple_info) - monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.cached_info", fake_cached_info) - monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.season_detail_for_library_view", fake_info) + monkeypatch.setattr("app.services.application.views.library.resource_list.media_service.resolve_execution_snapshot", fake_execution_snapshot) monkeypatch.setattr("app.services.application.views.library.resource_list.library_service.get_files_by_media", fake_library_files) monkeypatch.setattr( "app.services.application.views.library.resource_list.command_service.list_media_active_commands", @@ -218,7 +300,6 @@ async def fake_action_context(self, library_files, **context): assert library_files == [library_file] assert context["media_id"] == media_id assert context["season_number"] is None - assert context["full_media"] is None return _LibraryActionAvailabilityContext( media_server_open_enabled_directory_ids=set(), media_server_sync_enabled_directory_ids=set(), @@ -292,7 +373,6 @@ async def test_library_list_resolves_movie_danmu_actions_from_simple_media(monke media_id=media_id, active_season_number=None, total_episodes=0, - full_media=None, active_commands=[], library_files=files, ) diff --git a/backend/tests/test_library_overview_service.py b/backend/tests/test_library_overview_service.py index a4534d1..582e834 100644 --- a/backend/tests/test_library_overview_service.py +++ b/backend/tests/test_library_overview_service.py @@ -6,6 +6,7 @@ from app.schemas.domain.media_types import MediaType from app.schemas.domain.resource_attributes import ResourceAttributes from app.schemas.domain.schedule import MediaScheduleSummary, ScheduleEpisode +from app.schemas.exception import MediaNotFoundException from app.schemas.media_id import MediaID from app.services.application.views.library.overview import OVERVIEW_ACTIVE_TASK_STATUSES, LibraryOverviewService from app.services.domain.library.service import MediaLibrarySnapshot @@ -15,6 +16,55 @@ def test_library_overview_active_task_statuses_include_migrating(): assert TaskStatus.MIGRATING in OVERVIEW_ACTIVE_TASK_STATUSES +@pytest.mark.asyncio +async def test_get_overview_continues_when_movie_snapshot_is_missing(monkeypatch): + service = LibraryOverviewService() + media_id = MediaID.parse("douban:movie:456") + library_file = LibraryFile( + id="file-1", + task_id="task-1", + directory_id="dir-1", + media_id=media_id, + path="/library", + file_name="Example.Movie.2026.mkv", + created_at=1, + ) + + async def fake_resolve_execution_snapshot(media_id): + raise MediaNotFoundException() + + async def fake_simple_info(media_id): + return None + + async def fake_library_snapshot(media_id, season=None): + assert season is None + return MediaLibrarySnapshot(files=[library_file], present_episodes=set()) + + async def fake_tasks_for_overview(status, media_id): + return [] + + monkeypatch.setattr( + "app.services.application.views.library.overview.media_service.resolve_execution_snapshot", + fake_resolve_execution_snapshot, + ) + monkeypatch.setattr("app.services.application.views.library.overview.media_service.simple_info", fake_simple_info) + monkeypatch.setattr( + "app.services.application.views.library.overview.library_service.get_media_library_snapshot", + fake_library_snapshot, + ) + monkeypatch.setattr( + "app.services.application.views.library.overview.download_service.list_media_tasks_for_overview", + fake_tasks_for_overview, + ) + + overview = await service.get_overview(media_id) + + assert overview.media_id == media_id + assert overview.library_file_count == 1 + assert overview.collected_count == 1 + assert overview.active_task_count == 0 + + @pytest.mark.asyncio async def test_build_snapshot_preserves_passed_media_schedule(monkeypatch): service = LibraryOverviewService() diff --git a/backend/tests/test_media_execution_context.py b/backend/tests/test_media_execution_context.py index ba94d83..720d7fb 100644 --- a/backend/tests/test_media_execution_context.py +++ b/backend/tests/test_media_execution_context.py @@ -2,7 +2,7 @@ import pytest -from app.schemas.domain.media import MediaFullInfo, MediaSeasonInfo, MediaSimpleInfo +from app.schemas.domain.media import EpisodeInfo, MediaFullInfo, MediaSeasonInfo, MediaSimpleInfo from app.schemas.domain.media_types import MediaType from app.schemas.exception import DownloadException from app.schemas.media_id import MediaID @@ -32,10 +32,10 @@ async def test_resolve_tv_season_snapshot_uses_full_profile_season_episode_count MediaSeasonInfo(season_number=2, episode_count=8), ], ) - cached_info = AsyncMock(return_value=full) + profile_snapshot = AsyncMock(return_value=full) provider_info = AsyncMock(side_effect=AssertionError("execution context must not call provider-backed info")) monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.simple_info", AsyncMock(return_value=simple)) - monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.cached_info", cached_info) + monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service._snapshot_from_profile", profile_snapshot) monkeypatch.setattr("app.services.domain.media.media_service.profile_service.info", provider_info) snapshot = await media_service.resolve_execution_snapshot( @@ -45,7 +45,7 @@ async def test_resolve_tv_season_snapshot_uses_full_profile_season_episode_count require_episode_count=True, ) - cached_info.assert_awaited_once_with(media_id) + profile_snapshot.assert_awaited_once_with(media_id, season_number=None) provider_info.assert_not_awaited() assert snapshot.season_number == 2 assert snapshot.episodes_count == 8 @@ -70,7 +70,7 @@ async def test_resolve_tv_season_snapshot_uses_cached_season_douban_id(monkeypat ], ) monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.simple_info", AsyncMock(return_value=None)) - monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.cached_info", AsyncMock(return_value=full)) + monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service._snapshot_from_profile", AsyncMock(return_value=full)) snapshot = await media_service.resolve_execution_snapshot( media_id, @@ -82,6 +82,48 @@ async def test_resolve_tv_season_snapshot_uses_cached_season_douban_id(monkeypat assert snapshot.douban_id == "36053703" +@pytest.mark.asyncio +async def test_resolve_tv_season_snapshot_includes_cached_schedule_scope(monkeypatch): + media_id = MediaID.parse("tmdb:tv:223911") + simple = MediaSimpleInfo( + media_id=media_id, + title="Test Show", + year=2026, + media_type=MediaType.tv, + episodes_count=200, + seasons_count=1, + aired_episode_count=0, + next_episode_to_air=None, + ) + full = MediaFullInfo( + media_id=media_id, + title="Test Show", + year=2026, + media_type=MediaType.tv, + episodes_count=200, + seasons_count=1, + season_number=1, + seasons=[MediaSeasonInfo(season_number=1, episode_count=200)], + aired_episode_count=143, + next_episode_to_air=EpisodeInfo(season_number=1, episode_number=144, air_date="2026-06-07"), + ) + profile_snapshot = AsyncMock(return_value=full) + monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.simple_info", AsyncMock(return_value=simple)) + monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service._snapshot_from_profile", profile_snapshot) + + snapshot = await media_service.resolve_execution_snapshot( + media_id, + season_number=1, + require_tv_season=True, + include_schedule_snapshot=True, + ) + + profile_snapshot.assert_awaited_once_with(media_id, season_number=1) + assert snapshot.aired_episode_count == 143 + assert snapshot.next_episode_to_air is not None + assert snapshot.next_episode_to_air.episode_number == 144 + + @pytest.mark.asyncio async def test_resolve_tv_season_snapshot_clears_unknown_cached_season_episode_count(monkeypatch): media_id = MediaID.parse("tmdb:tv:233295") @@ -95,7 +137,7 @@ async def test_resolve_tv_season_snapshot_clears_unknown_cached_season_episode_c seasons=[MediaSeasonInfo(season_number=1, episode_count=None, douban_id="36053703")], ) monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.simple_info", AsyncMock(return_value=None)) - monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.cached_info", AsyncMock(return_value=full)) + monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service._snapshot_from_profile", AsyncMock(return_value=full)) snapshot = await media_service.resolve_execution_snapshot( media_id, @@ -120,7 +162,7 @@ async def test_resolve_tv_season_snapshot_rejects_missing_cached_season_without_ seasons=[MediaSeasonInfo(season_number=1, episode_count=10)], ) monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.simple_info", AsyncMock(return_value=None)) - monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service.profile_service.cached_info", AsyncMock(return_value=full)) + monkeypatch.setattr("app.services.domain.media.media_service.execution_snapshot_service._snapshot_from_profile", AsyncMock(return_value=full)) with pytest.raises(DownloadException, match="backendErrors.mediaExecutionSnapshotSeasonMissing"): await media_service.resolve_execution_snapshot( diff --git a/backend/tests/test_media_profile_service_simple_info.py b/backend/tests/test_media_profile_service_simple_info.py index e878be0..a8579c6 100644 --- a/backend/tests/test_media_profile_service_simple_info.py +++ b/backend/tests/test_media_profile_service_simple_info.py @@ -333,11 +333,15 @@ async def test_is_managed_media_only_reads_active_profile(monkeypatch): @pytest.mark.asyncio -async def test_mark_profile_inactive_checks_references_without_subscription_state(monkeypatch): +async def test_mark_profile_inactive_checks_references_when_subscription_is_absent(monkeypatch): service = MediaProfileService(provider_service=None, schedule_service=MediaScheduleService()) media_id = MediaID.parse("tmdb:movie:1") profile = _ready_profile(media_id) + monkeypatch.setattr( + "app.services.domain.media.profile.lifecycle.subscription_query_service.list_states", + AsyncMock(return_value=[]), + ) monkeypatch.setattr(service.profile_repo, "find_by_media_id", AsyncMock(return_value=profile)) monkeypatch.setattr(service.profile_repo, "upsert_profile", AsyncMock(return_value=True)) monkeypatch.setattr(service.lifecycle.task_repo, "find_by_media_id", AsyncMock(return_value=None)) @@ -348,6 +352,24 @@ async def test_mark_profile_inactive_checks_references_without_subscription_stat service.profile_repo.upsert_profile.assert_awaited_once() +@pytest.mark.asyncio +async def test_mark_profile_inactive_keeps_followed_media_active(monkeypatch): + service = MediaProfileService(provider_service=None, schedule_service=MediaScheduleService()) + media_id = MediaID.parse("tmdb:movie:1") + profile = _ready_profile(media_id) + subscription = type("SubscriptionState", (), {"media_id": media_id, "active": False, "followed": True})() + + monkeypatch.setattr( + "app.services.domain.media.profile.lifecycle.subscription_query_service.list_states", + AsyncMock(return_value=[subscription]), + ) + monkeypatch.setattr(service.profile_repo, "find_by_media_id", AsyncMock(return_value=profile)) + monkeypatch.setattr(service.profile_repo, "upsert_profile", AsyncMock(return_value=True)) + + assert await service.mark_profile_inactive_if_unmanaged(media_id) is False + service.profile_repo.upsert_profile.assert_not_awaited() + + @pytest.mark.asyncio async def test_simple_info_does_not_refresh_placeholder_profile(monkeypatch): service = MediaProfileService(provider_service=None, schedule_service=MediaScheduleService()) diff --git a/backend/tests/test_message_renderer.py b/backend/tests/test_message_renderer.py index a3324e2..a7b162f 100644 --- a/backend/tests/test_message_renderer.py +++ b/backend/tests/test_message_renderer.py @@ -120,6 +120,8 @@ def test_media_server_sync_message_includes_target_file_name(): media_id=_media().media_id, media_server_id="jellyfin", file_path="/library/Partner (2026)/Partner.2026.mkv", + nfo_count=2, + image_count=1, trigger="manual", ), ) @@ -127,6 +129,8 @@ def test_media_server_sync_message_includes_target_file_name(): message = render_message(event_message_key(event.type), params, locale="zh-CN") assert "Partner.2026.mkv" in message + assert "NFO 2 个" in message + assert "图片 1 个" in message def test_danmu_event_message_includes_video_file_name(): diff --git a/backend/tests/test_subscription_execution_drift.py b/backend/tests/test_subscription_execution_drift.py index 4240cee..ea5b72e 100644 --- a/backend/tests/test_subscription_execution_drift.py +++ b/backend/tests/test_subscription_execution_drift.py @@ -1,5 +1,7 @@ import os +import time import uuid +from contextlib import asynccontextmanager from datetime import UTC, date, datetime, timedelta from unittest.mock import AsyncMock, Mock @@ -9,22 +11,32 @@ os.environ.setdefault("DATA_PATH", f"/tmp/aethera-test-data-{uuid.uuid4()}") -from app.schemas.media_id import MediaID +from app.schemas.config import SchedulerConfig from app.schemas.domain.download import TaskContext, TaskData, TaskStatus from app.schemas.domain.library import LibraryFile from app.schemas.domain.media import EpisodeInfo, MediaExecutionSnapshot, MediaFullInfo, MediaSeasonInfo +from app.schemas.domain.media_subscription_state import MediaSubscriptionState from app.schemas.domain.media_types import MediaType from app.schemas.domain.resource_attributes import ResourceAttributes -from app.schemas.domain.resource_search import Resource, ResourceSearchResult +from app.schemas.domain.resource_search import MediaSearchQuery, Resource, ResourceSearchResult from app.schemas.domain.subscription import Subscription from app.schemas.domain.subscription_filters import SubscriptionFilters, UpgradePolicy from app.schemas.domain.torrent import TorrentFileItem, TorrentMetadata, TorrentPayload +from app.schemas.media_id import MediaID +from app.schemas.domain.subscription_run_result import SubscriptionRunResponse +from app.schemas.runtime.subscription_runtime import SubscriptionPlanningStatus, SubscriptionRunPlan, SubscriptionRunPlanningResult from app.services.domain.download import download_service from app.services.domain.resource.selection import ResourceSelectionPlan, partition_search_results, select_resources +from app.services.application.workflows.resource_search import resource_search_service from app.services.application.workflows.subscription.run import SubscriptionRunApplicationService from app.services.domain.subscription.resource_run_plan_service import resource_run_plan_service +@asynccontextmanager +async def _acquired_scheduler_lock(): + yield True + + def _subscription() -> Subscription: return Subscription( sub_id="sub-1", @@ -167,6 +179,378 @@ def _video_metadata(title: str, episodes: list[int]) -> TorrentMetadata: ) +@pytest.mark.asyncio +async def test_subscription_sweep_uses_recent_feed_strategy_by_default(monkeypatch): + service = SubscriptionRunApplicationService() + search_run = AsyncMock(return_value=object()) + rss_run = AsyncMock(return_value=object()) + monkeypatch.setattr(service, "_acquire_subscription_sweep", lambda: _acquired_scheduler_lock()) + monkeypatch.setattr(service, "_run_all_with_search", search_run) + monkeypatch.setattr(service, "_run_all_with_recent_feed", rss_run) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.settings_service.get_scheduler_config", + lambda: SchedulerConfig(), + ) + + await service.run_all() + + rss_run.assert_awaited_once() + search_run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_subscription_sweep_uses_recent_feed_first_when_rss_mode_starts(monkeypatch): + service = SubscriptionRunApplicationService() + search_run = AsyncMock(return_value=object()) + rss_run = AsyncMock(return_value=object()) + monkeypatch.setattr(service, "_acquire_subscription_sweep", lambda: _acquired_scheduler_lock()) + monkeypatch.setattr(service, "_run_all_with_search", search_run) + monkeypatch.setattr(service, "_run_all_with_recent_feed", rss_run) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.settings_service.get_scheduler_config", + lambda: SchedulerConfig( + subscription_resource_discovery_mode="rss_with_search_backfill", + subscription_search_backfill_interval_seconds=3600, + ), + ) + + await service.run_all() + + rss_run.assert_awaited_once() + search_run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_search_sweep_only_runs_due_subscriptions(monkeypatch): + service = SubscriptionRunApplicationService() + now = time.time() + due_state = MediaSubscriptionState(sub_id="due", media_id=MediaID.parse("tmdb:tv:1"), media=_media(), active=True, last_search_at=now - 1200) + fresh_state = MediaSubscriptionState(sub_id="fresh", media_id=MediaID.parse("tmdb:tv:2"), media=_media(), active=True, last_search_at=now) + run_one = AsyncMock(return_value=SubscriptionRunResponse(checked=1, added=1)) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.subscription_query_service.list_states", + AsyncMock(return_value=[due_state, fresh_state]), + ) + monkeypatch.setattr(service, "_build_runtime_subscription", AsyncMock(return_value=_subscription())) + monkeypatch.setattr(service, "run_one", run_one) + + result = await service._run_all_with_search(SchedulerConfig(subscription_search_interval_seconds=600)) + + assert result.checked == 1 + assert result.added == 1 + run_one.assert_awaited_once() + assert run_one.await_args.kwargs["active_search"] is True + + +@pytest.mark.asyncio +async def test_search_sweep_limits_due_subscriptions_per_sweep(monkeypatch): + service = SubscriptionRunApplicationService() + now = time.time() + older_state = MediaSubscriptionState( + sub_id="older", + media_id=MediaID.parse("tmdb:tv:1"), + media=_media(), + active=True, + last_search_at=now - 7200, + ) + newer_state = MediaSubscriptionState( + sub_id="newer", + media_id=MediaID.parse("tmdb:tv:2"), + media=_media(), + active=True, + last_search_at=now - 3600, + ) + built_subs = { + "older": _subscription().model_copy(update={"sub_id": "older"}), + "newer": _subscription().model_copy(update={"sub_id": "newer"}), + } + run_one = AsyncMock(return_value=SubscriptionRunResponse(checked=1, added=0)) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.subscription_query_service.list_states", + AsyncMock(return_value=[newer_state, older_state]), + ) + monkeypatch.setattr(service, "_build_runtime_subscription", AsyncMock(side_effect=lambda state: built_subs[state.sub_id])) + monkeypatch.setattr(service, "run_one", run_one) + + await service._run_all_with_search( + SchedulerConfig( + subscription_search_interval_seconds=600, + subscription_search_max_per_sweep=1, + ) + ) + + run_one.assert_awaited_once() + assert run_one.await_args.args[0].sub_id == "older" + assert run_one.await_args.kwargs["active_search"] is True + + +@pytest.mark.asyncio +async def test_search_sweep_runs_new_subscriptions_immediately(monkeypatch): + service = SubscriptionRunApplicationService() + state = MediaSubscriptionState(sub_id="new", media_id=MediaID.parse("tmdb:tv:1"), media=_media(), active=True) + run_one = AsyncMock(return_value=SubscriptionRunResponse(checked=1, added=0)) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.subscription_query_service.list_states", + AsyncMock(return_value=[state]), + ) + monkeypatch.setattr(service, "_build_runtime_subscription", AsyncMock(return_value=_subscription())) + monkeypatch.setattr(service, "run_one", run_one) + + await service._run_all_with_search(SchedulerConfig(subscription_search_interval_seconds=600)) + + run_one.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_search_sweep_skips_due_search_when_future_episode_is_not_aired_and_targets_are_satisfied(monkeypatch): + service = SubscriptionRunApplicationService() + now = time.time() + media = _media( + aired_episode_count=2, + next_episode_to_air=EpisodeInfo(season_number=1, episode_number=3, air_date=_future_date()), + ) + state = MediaSubscriptionState(sub_id="future", media_id=MediaID.parse("tmdb:tv:1"), media=media, active=True, last_search_at=now - 7200) + run_one = AsyncMock(return_value=SubscriptionRunResponse(checked=1, added=0)) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.subscription_query_service.list_states", + AsyncMock(return_value=[state]), + ) + monkeypatch.setattr(service, "_build_runtime_subscription", AsyncMock(return_value=_subscription().model_copy(update={"media": media}))) + monkeypatch.setattr(service, "_refresh_runtime_media_snapshot", AsyncMock(return_value=_subscription().model_copy(update={"media": media}))) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.resource_run_plan_service.build_subscription_plan", + AsyncMock(return_value=SubscriptionRunPlanningResult(status=SubscriptionPlanningStatus.SATISFIED)), + ) + monkeypatch.setattr(service, "run_one", run_one) + + result = await service._run_all_with_search(SchedulerConfig(subscription_search_interval_seconds=600)) + + assert result.checked == 0 + assert result.added == 0 + run_one.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_search_sweep_keeps_due_search_when_future_episode_exists_but_aired_targets_are_missing(monkeypatch): + service = SubscriptionRunApplicationService() + now = time.time() + media = _media( + aired_episode_count=2, + next_episode_to_air=EpisodeInfo(season_number=1, episode_number=3, air_date=_future_date()), + ) + state = MediaSubscriptionState(sub_id="missing", media_id=MediaID.parse("tmdb:tv:1"), media=media, active=True, last_search_at=now - 7200) + plan = SubscriptionRunPlan( + sub_id="missing", + media=media, + season_number=1, + correlation_id="corr", + target_episodes={2}, + ) + run_one = AsyncMock(return_value=SubscriptionRunResponse(checked=1, added=0)) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.subscription_query_service.list_states", + AsyncMock(return_value=[state]), + ) + monkeypatch.setattr(service, "_build_runtime_subscription", AsyncMock(return_value=_subscription().model_copy(update={"sub_id": "missing", "media": media}))) + monkeypatch.setattr(service, "_refresh_runtime_media_snapshot", AsyncMock(return_value=_subscription().model_copy(update={"sub_id": "missing", "media": media}))) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.resource_run_plan_service.build_subscription_plan", + AsyncMock(return_value=SubscriptionRunPlanningResult(status=SubscriptionPlanningStatus.READY, plan=plan)), + ) + monkeypatch.setattr(service, "run_one", run_one) + + await service._run_all_with_search(SchedulerConfig(subscription_search_interval_seconds=600)) + + run_one.assert_awaited_once() + assert run_one.await_args.kwargs["active_search"] is True + + +@pytest.mark.asyncio +async def test_rss_sweep_uses_recent_feed_and_limits_due_searches(monkeypatch): + service = SubscriptionRunApplicationService() + now = time.time() + fresh_state = MediaSubscriptionState(sub_id="fresh", media_id=MediaID.parse("tmdb:tv:1"), media=_media(), active=True, last_search_at=now) + older_due_state = MediaSubscriptionState(sub_id="older-due", media_id=MediaID.parse("tmdb:tv:2"), media=_media(), active=True, last_search_at=now - 7200) + newer_due_state = MediaSubscriptionState(sub_id="newer-due", media_id=MediaID.parse("tmdb:tv:3"), media=_media(), active=True, last_search_at=now - 3700) + recent_candidate = ResourceSearchResult( + id="recent-1", + title="Test Show S01E01", + site="site-a", + category="tv", + size="1 GB", + seeders=1, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/recent-1", + result_id="recent-1", + ) + built_subs = { + "fresh": _subscription().model_copy(update={"sub_id": "fresh"}), + "older-due": _subscription().model_copy(update={"sub_id": "older-due"}), + "newer-due": _subscription().model_copy(update={"sub_id": "newer-due"}), + } + run_one = AsyncMock(return_value=SubscriptionRunResponse(checked=1, added=0)) + monkeypatch.setattr( + "app.services.application.workflows.subscription.run.subscription_query_service.list_states", + AsyncMock(return_value=[fresh_state, newer_due_state, older_due_state]), + ) + monkeypatch.setattr(service, "_fetch_recent_candidates_for_sweep", AsyncMock(return_value=[recent_candidate])) + monkeypatch.setattr(service, "_build_runtime_subscription", AsyncMock(side_effect=lambda state: built_subs[state.sub_id])) + monkeypatch.setattr(service, "run_one", run_one) + + await service._run_all_with_recent_feed( + SchedulerConfig( + subscription_search_backfill_interval_seconds=3600, + subscription_search_max_per_sweep=1, + ) + ) + + assert run_one.await_count == 3 + first_call, second_call, third_call = run_one.await_args_list + assert first_call.args[0].sub_id == "fresh" + assert first_call.kwargs["recent_candidates"] == [recent_candidate] + assert first_call.kwargs["active_search"] is False + assert second_call.args[0].sub_id == "newer-due" + assert second_call.kwargs["recent_candidates"] == [recent_candidate] + assert second_call.kwargs["active_search"] is False + assert third_call.args[0].sub_id == "older-due" + assert third_call.kwargs["active_search"] is True + assert "recent_candidates" not in third_call.kwargs + + +def test_recent_candidates_are_filtered_by_media_title_and_sites(): + service = SubscriptionRunApplicationService() + media = _media(episodes_count=4, aired_episode_count=4) + plan = SubscriptionRunPlan( + sub_id="sub-1", + media=media, + season_number=1, + correlation_id="corr-1", + episode_mode=True, + sites=["indexer-a::site-a"], + filters=None, + quality_profile=None, + target_episodes={1}, + required_scores={}, + existing_disc_numbers=set(), + ) + query = MediaSearchQuery(media=media, indexers=plan.sites) + candidates = [ + ResourceSearchResult( + id="match-title", + title="Test Show S01E01 1080p WEB-DL", + site="indexer-a::site-a", + category="tv", + size="1 GB", + seeders=10, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/match", + result_id="match-title", + matched_by_id=False, + ), + ResourceSearchResult( + id="wrong-title", + title="Other Show S01E01 1080p WEB-DL", + site="indexer-a::site-a", + category="tv", + size="1 GB", + seeders=10, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/wrong-title", + result_id="wrong-title", + matched_by_id=False, + ), + ResourceSearchResult( + id="wrong-site", + title="Test Show S01E01 1080p WEB-DL", + site="indexer-a::site-b", + category="tv", + size="1 GB", + seeders=10, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/wrong-site", + result_id="wrong-site", + matched_by_id=False, + ), + ] + + results = service._filter_recent_candidates(query=query, plan=plan, candidates=candidates) + + assert [result.id for result in results] == ["match-title"] + assert results[0].result_id != "match-title" + cached_result = resource_search_service.get_by_result_id(results[0].result_id) + assert cached_result is not None + assert cached_result.title == "Test Show S01E01 1080p WEB-DL" + + +def test_recent_candidates_do_not_match_short_title_substrings(): + service = SubscriptionRunApplicationService() + media = _media(episodes_count=1, aired_episode_count=1).model_copy(update={"title": "It", "imdb_id": None}) + plan = SubscriptionRunPlan( + sub_id="sub-1", + media=media, + season_number=1, + correlation_id="corr-1", + episode_mode=True, + sites=None, + filters=None, + quality_profile=None, + target_episodes={1}, + required_scores={}, + existing_disc_numbers=set(), + ) + query = MediaSearchQuery(media=media) + candidates = [ + ResourceSearchResult( + id="wrong-substring", + title="Interstellar 2014 1080p WEB-DL", + site="site-a", + category="movie", + size="1 GB", + seeders=10, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/wrong-substring", + result_id="wrong-substring", + matched_by_id=False, + ), + ResourceSearchResult( + id="right-token", + title="It 2017 1080p WEB-DL", + site="site-a", + category="movie", + size="1 GB", + seeders=10, + leechers=0, + publish_date=datetime.now(UTC), + download_url="https://example.com/right-token", + result_id="right-token", + matched_by_id=False, + ), + ] + + results = service._filter_recent_candidates(query=query, plan=plan, candidates=candidates) + + assert [result.title for result in results] == ["It 2017 1080p WEB-DL"] + + +def test_active_search_due_ignores_last_run_when_never_searched(): + service = SubscriptionRunApplicationService() + state = MediaSubscriptionState( + sub_id="new", + media_id=MediaID.parse("tmdb:tv:1"), + media=_media(), + active=True, + last_run_at=time.time(), + last_search_at=None, + ) + + assert service._active_search_due(state, 3600) is True + + @pytest.mark.asyncio async def test_compute_target_episodes_excludes_present_and_downloading_episodes(monkeypatch): service = SubscriptionRunApplicationService() diff --git a/backend/tests/test_subscription_execution_pilot.py b/backend/tests/test_subscription_execution_pilot.py index 3166b5b..c1610f0 100644 --- a/backend/tests/test_subscription_execution_pilot.py +++ b/backend/tests/test_subscription_execution_pilot.py @@ -749,7 +749,7 @@ async def __aexit__(self, exc_type, exc, tb): lambda _media_id: _Lock(), ) monkeypatch.setattr( - "app.services.domain.media.media_service.execution_snapshot_service.profile_service.cached_info", + "app.services.domain.media.media_service.execution_snapshot_service._snapshot_from_profile", AsyncMock(return_value=media_detail), ) monkeypatch.setattr( diff --git a/backend/tests/test_telegram_notification_renderer.py b/backend/tests/test_telegram_notification_renderer.py index ec7e348..2d4fd6e 100644 --- a/backend/tests/test_telegram_notification_renderer.py +++ b/backend/tests/test_telegram_notification_renderer.py @@ -93,3 +93,37 @@ def test_telegram_escapes_markdown_v2_stars(): assert "Star \\* Movie" in message assert "Release \\* Group" in message + + +def test_telegram_accepts_null_resource_title_meta(): + event = Event( + type=EventType.DOWNLOAD_FAILED, + level=EventLevel.error, + ts=datetime(2026, 6, 6, 1, 41), + meta=json.dumps({"resource_title": None, "error": "failed"}, ensure_ascii=False), + ) + + message = format_telegram_event(event, locale="zh-CN", public_base_url="") + + assert "下载失败" in message + assert "原因:failed" in message + + +def test_telegram_indexer_unhealthy_uses_indexer_and_site_names(): + event = Event( + type=EventType.INDEXER_SITE_UNHEALTHY, + level=EventLevel.warning, + ts=datetime(2026, 6, 6, 1, 41), + message_params={ + "indexer_name": "Prowlarr", + "site_name": "OurBits", + "consecutive_failures": "3", + "error": "status=429", + }, + ) + + message = format_telegram_event(event, locale="zh-CN", public_base_url="") + + assert "索引器站点异常" in message + assert "资源:Prowlarr / OurBits" in message + assert "原因:status\\=429" in message diff --git a/backend/tests/test_workflow_alerts.py b/backend/tests/test_workflow_alerts.py index 6a25c17..e803cfc 100644 --- a/backend/tests/test_workflow_alerts.py +++ b/backend/tests/test_workflow_alerts.py @@ -24,7 +24,7 @@ def test_default_notification_channel_patterns_target_business_results(): @pytest.mark.asyncio -async def test_notification_send_failure_emits_event_with_event_type_value(monkeypatch): +async def test_notification_send_failure_marks_action_without_emitting_event(monkeypatch): captured = {} channel = NotificationChannelConfig(id="channel-1", type="fake", name="Fake", event_patterns=["media.*"]) @@ -48,13 +48,14 @@ async def send(self, config, event): SimpleNamespace( create_action=lambda **kwargs: SimpleNamespace(id="action-1"), mark_running=lambda *args, **kwargs: None, - mark_failed=lambda *args, **kwargs: None, + mark_failed=lambda action_id, **kwargs: captured.setdefault("failed", (action_id, kwargs.get("error"))), 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)), + raising=False, ) await NotificationApplicationService().handle_event( @@ -67,16 +68,12 @@ async def send(self, config, event): ) ) - 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" + assert captured["failed"] == ("action-1", "network failed") + assert "event" not in captured @pytest.mark.asyncio -async def test_notification_send_success_emits_event(monkeypatch): +async def test_notification_send_success_marks_action_without_emitting_event(monkeypatch): captured = {} channel = NotificationChannelConfig(id="channel-1", type="fake", name="Fake", event_patterns=["media.*"]) @@ -107,6 +104,7 @@ async def send(self, config, event): monkeypatch.setattr( "app.services.application.workflows.notifications.service.event_service", SimpleNamespace(emit=lambda event, meta=None: captured.setdefault("event", event)), + raising=False, ) await NotificationApplicationService().handle_event( Event( @@ -120,8 +118,4 @@ async def send(self, config, event): 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" + assert "event" not in captured diff --git a/compose.yaml b/compose.yaml index 43771a4..a6d1ef8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,11 @@ services: aethera: image: ${AETHERA_IMAGE:-n120318/aethera}:${AETHERA_TAG:-latest} restart: unless-stopped + logging: + driver: json-file + options: + max-size: ${AETHERA_DOCKER_LOG_MAX_SIZE:-10m} + max-file: ${AETHERA_DOCKER_LOG_MAX_FILES:-3} environment: - TZ=Asia/Shanghai - BACKEND_MODE=prod diff --git a/docker-compose.dev.example.yml b/docker-compose.dev.yml similarity index 90% rename from docker-compose.dev.example.yml rename to docker-compose.dev.yml index 453adb1..c239446 100644 --- a/docker-compose.dev.example.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,10 @@ services: backend: + logging: &aethera-dev-logging + driver: json-file + options: + max-size: ${AETHERA_DOCKER_LOG_MAX_SIZE:-10m} + max-file: ${AETHERA_DOCKER_LOG_MAX_FILES:-3} build: context: ./backend dockerfile: Dockerfile.dev @@ -29,6 +34,7 @@ services: - UVICORN_WORKERS=${AETHERA_DEV_UVICORN_WORKERS:-4} frontend: + logging: *aethera-dev-logging build: context: ./frontend dockerfile: Dockerfile.dev @@ -61,6 +67,7 @@ services: - backend sqlite-web: + logging: *aethera-dev-logging image: coleifer/sqlite-web command: sqlite_web /data/aethera.db --host 0.0.0.0 --port 8080 volumes: diff --git a/docs/dev.md b/docs/dev.md index 74ed622..e7436cc 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -22,11 +22,7 @@ Default URLs: ## Local Environment -The repository tracks `docker-compose.dev.example.yml` as the portable development baseline. To keep machine-specific ports, paths, or reverse-proxy wiring local, copy it to `docker-compose.dev.yml`; that file is ignored by Git: - -```bash -cp docker-compose.dev.example.yml docker-compose.dev.yml -``` +The repository tracks `docker-compose.dev.yml` as the portable development baseline. Keep machine-specific ports, paths, or reverse-proxy wiring in `docker-compose.dev.override.yml`; that override file is ignored by Git and is loaded automatically when present. Generate `.env` from `.env.dev.example` when `PUID`/`PGID` should match the runtime data directories: diff --git a/frontend/src/components/MediaManagementPage.vue b/frontend/src/components/MediaManagementPage.vue index ed6993e..eead478 100644 --- a/frontend/src/components/MediaManagementPage.vue +++ b/frontend/src/components/MediaManagementPage.vue @@ -51,7 +51,7 @@ /> -
+
@@ -181,7 +181,7 @@ const managementTabs = computed(() => [ ]) const { - loading, + initialLoading, summaryLoading, directoryIntegrityResult, directoryIntegrityLoading, diff --git a/frontend/src/components/config/SystemConfig.vue b/frontend/src/components/config/SystemConfig.vue index 5db1e6e..915a2cd 100644 --- a/frontend/src/components/config/SystemConfig.vue +++ b/frontend/src/components/config/SystemConfig.vue @@ -5,62 +5,72 @@

{{ $t('settings.system.general') }}

-
-
- -

{{ $t('settings.system.downloadTagHint') }}

- -
-
- -

{{ $t('settings.system.publicBaseUrlHint') }}

- -
-
-
+
+
+ + +

{{ $t('settings.system.sessionTtlHint') }}

+
-
-
-
-

{{ $t('settings.system.logging') }}

-

{{ $t('settings.system.loggingDescription') }}

+
+ + +
-
-
-
-
-
- -
+
- +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
@@ -69,21 +79,20 @@
-

{{ $t('settings.system.auth') }}

-

{{ $t('settings.system.authDescription') }}

+

{{ $t('settings.system.download') }}

-
-
- - + +

{{ $t('settings.system.downloadTagHint') }}

+ -

{{ $t('settings.system.sessionTtlHint') }}

@@ -91,34 +100,58 @@
-

{{ $t('settings.system.password') }}

-

{{ $t('settings.system.passwordDescription') }}

+

{{ $t('settings.system.subscriptionDiscovery') }}

+

{{ $t('settings.system.subscriptionDiscoveryDescription') }}

-
-
+
+
+ +