Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ build/
*.magnet

# Local secrets / overrides
docker-compose.dev.yml
docker-compose.dev.override.yml
*.conf.local
*.config.local
Expand Down
35 changes: 29 additions & 6 deletions aethera.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
;;
Expand All @@ -22,43 +41,47 @@ 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")
shift
"$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
;;
* )
Expand Down
49 changes: 49 additions & 0 deletions backend/alembic/versions/0004_subscription_search_cadence.py
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 9 additions & 0 deletions backend/app/clients/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
45 changes: 44 additions & 1 deletion backend/app/clients/jackett.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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}
Expand Down
45 changes: 40 additions & 5 deletions backend/app/clients/prowlarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions backend/app/clients/torznab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions backend/app/db/repositories/action_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/app/db/sql/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading