diff --git a/alembic/versions/bc237f8b139d_remove_indexer_table.py b/alembic/versions/bc237f8b139d_remove_indexer_table.py new file mode 100644 index 0000000..6f7e5ed --- /dev/null +++ b/alembic/versions/bc237f8b139d_remove_indexer_table.py @@ -0,0 +1,38 @@ +"""remove indexer table + +Revision ID: bc237f8b139d +Revises: 873737d287d3 +Create Date: 2025-04-12 23:52:29.137291 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "bc237f8b139d" +down_revision: Union[str, None] = "873737d287d3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("indexer") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "indexer", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("name", sa.VARCHAR(), nullable=False), + sa.Column("enabled", sa.BOOLEAN(), nullable=False), + sa.Column("privacy", sa.VARCHAR(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### diff --git a/app/internal/indexers/configuration.py b/app/internal/indexers/configuration.py index 7aadb49..2669586 100644 --- a/app/internal/indexers/configuration.py +++ b/app/internal/indexers/configuration.py @@ -108,6 +108,6 @@ def create_valued_configuration( except ValueError: raise InvalidTypeException(f"Configuration {key} must be a float") elif value.type is bool: - setattr(valued, key, bool(config_value)) + setattr(valued, key, config_value == "1") return valued diff --git a/app/internal/models.py b/app/internal/models.py index d7b2636..e9fb6c6 100644 --- a/app/internal/models.py +++ b/app/internal/models.py @@ -1,9 +1,11 @@ # pyright: reportUnknownVariableType=false +import json import uuid from datetime import datetime from enum import Enum from typing import Annotated, Literal, Optional, Union +import pydantic from sqlmodel import JSON, Column, DateTime, Field, SQLModel, UniqueConstraint, func @@ -178,10 +180,10 @@ ProwlarrSource = Annotated[ ] -class Indexer(BaseModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) +class Indexer(pydantic.BaseModel, frozen=True): + id: int name: str - enabled: bool + enable: bool privacy: str @@ -205,3 +207,7 @@ class Notification(BaseModel, table=True): title_template: str body_template: str enabled: bool + + @property + def serialized_headers(self): + return json.dumps(self.headers) diff --git a/app/internal/prowlarr/prowlarr.py b/app/internal/prowlarr/prowlarr.py index 15d8faf..7280c18 100644 --- a/app/internal/prowlarr/prowlarr.py +++ b/app/internal/prowlarr/prowlarr.py @@ -12,6 +12,7 @@ from app.internal.indexers.abstract import SessionContainer from app.internal.models import ( BookRequest, EventEnum, + Indexer, ProwlarrSource, TorrentSource, UsenetSource, @@ -32,6 +33,7 @@ ProwlarrConfigKey = Literal[ "prowlarr_base_url", "prowlarr_source_ttl", "prowlarr_categories", + "prowlarr_indexers", ] @@ -78,13 +80,24 @@ class ProwlarrConfig(StringConfigCache[ProwlarrConfigKey]): def set_categories(self, session: Session, categories: list[int]): self.set(session, "prowlarr_categories", json.dumps(categories)) + def get_indexers(self, session: Session) -> list[int]: + indexers = self.get(session, "prowlarr_indexers") + if indexers is None: + return [] + return json.loads(indexers) + + def set_indexers(self, session: Session, indexers: list[int]): + self.set(session, "prowlarr_indexers", json.dumps(indexers)) + prowlarr_config = ProwlarrConfig() prowlarr_source_cache = SimpleCache[list[ProwlarrSource]]() +prowlarr_indexer_cache = SimpleCache[Indexer]() def flush_prowlarr_cache(): prowlarr_source_cache.flush() + prowlarr_indexer_cache.flush() async def start_download( @@ -231,3 +244,39 @@ async def query_prowlarr( prowlarr_source_cache.set(sources, query) return sources + + +async def get_indexers( + session: Session, client_session: ClientSession +) -> dict[int, Indexer]: + """Fetch the list of all indexers from Prowlarr.""" + base_url = prowlarr_config.get_base_url(session) + api_key = prowlarr_config.get_api_key(session) + assert base_url is not None and api_key is not None + source_ttl = prowlarr_config.get_source_ttl(session) + + indexers = prowlarr_indexer_cache.get_all(source_ttl).values() + if len(indexers) > 0: + return {indexer.id: indexer for indexer in indexers} + + url = posixpath.join(base_url, "api/v1/indexer") + logger.info("Fetching indexers from Prowlarr: %s", url) + + async with client_session.get( + url, + headers={"X-Api-Key": api_key}, + ) as response: + if not response.ok: + logger.error("Failed to fetch indexers: %s", response) + return {} + + json_response = await response.json() + + for indexer in json_response: + indexer_obj = Indexer.model_validate(indexer) + prowlarr_indexer_cache.set(indexer_obj, str(indexer_obj.id)) + + return { + indexer.id: indexer + for indexer in prowlarr_indexer_cache.get_all(source_ttl).values() + } diff --git a/app/internal/query.py b/app/internal/query.py index 6d90cec..f1693da 100644 --- a/app/internal/query.py +++ b/app/internal/query.py @@ -69,6 +69,7 @@ async def query_sources( book, force_refresh=force_refresh, only_return_if_cached=only_return_if_cached, + indexer_ids=prowlarr_config.get_indexers(session), ) if sources is None: return QueryResult( diff --git a/app/routers/settings.py b/app/routers/settings.py index e318cc6..2ad7d3a 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -23,7 +23,11 @@ from app.internal.indexers.indexer_util import IndexerContext, get_indexer_conte from app.internal.models import EventEnum, GroupEnum, Notification, User from app.internal.notifications import send_notification from app.internal.prowlarr.indexer_categories import indexer_categories -from app.internal.prowlarr.prowlarr import flush_prowlarr_cache, prowlarr_config +from app.internal.prowlarr.prowlarr import ( + flush_prowlarr_cache, + get_indexers, + prowlarr_config, +) from app.internal.ranking.quality import IndexerFlag, QualityRange, quality_config from app.util.connection import get_connection from app.util.db import get_session @@ -206,17 +210,21 @@ def update_user( @router.get("/prowlarr") -def read_prowlarr( +async def read_prowlarr( request: Request, admin_user: Annotated[ DetailedUser, Depends(get_authenticated_user(GroupEnum.admin)) ], session: Annotated[Session, Depends(get_session)], + client_session: Annotated[ClientSession, Depends(get_connection)], prowlarr_misconfigured: Optional[Any] = None, ): prowlarr_base_url = prowlarr_config.get_base_url(session) prowlarr_api_key = prowlarr_config.get_api_key(session) selected = set(prowlarr_config.get_categories(session)) + indexers = await get_indexers(session, client_session) + indexers = {id: indexer.model_dump() for id, indexer in indexers.items()} + selected_indexers = set(prowlarr_config.get_indexers(session)) return template_response( "settings_page/prowlarr.html", @@ -228,6 +236,8 @@ def read_prowlarr( "prowlarr_api_key": prowlarr_api_key, "indexer_categories": indexer_categories, "selected_categories": selected, + "indexers": json.dumps(indexers), + "selected_indexers": selected_indexers, "prowlarr_misconfigured": True if prowlarr_misconfigured else False, }, ) @@ -283,6 +293,36 @@ def update_indexer_categories( ) +@router.put("/prowlarr/indexers") +async def update_selected_indexers( + request: Request, + admin_user: Annotated[ + DetailedUser, Depends(get_authenticated_user(GroupEnum.admin)) + ], + session: Annotated[Session, Depends(get_session)], + client_session: Annotated[ClientSession, Depends(get_connection)], + indexer_ids: Annotated[list[int], Form(alias="i")] = [], +): + prowlarr_config.set_indexers(session, indexer_ids) + + indexers = await get_indexers(session, client_session) + indexers = {id: indexer.model_dump() for id, indexer in indexers.items()} + selected_indexers = set(prowlarr_config.get_indexers(session)) + flush_prowlarr_cache() + + return template_response( + "settings_page/prowlarr.html", + request, + admin_user, + { + "indexers": json.dumps(indexers), + "selected_indexers": selected_indexers, + "success": "Categories updated", + }, + block_name="indexer", + ) + + @router.get("/download") def read_download( request: Request, @@ -842,9 +882,7 @@ async def update_indexers( ) continue if context.type is bool: - indexer_configuration_cache.set( - session, key, "true" if value == "on" else "" - ) + indexer_configuration_cache.set_bool(session, key, value == "on") else: indexer_configuration_cache.set(session, key, str(value)) diff --git a/app/util/cache.py b/app/util/cache.py index 81057c5..de9835b 100644 --- a/app/util/cache.py +++ b/app/util/cache.py @@ -19,6 +19,15 @@ class SimpleCache[T]: return None return sources + def get_all(self, source_ttl: int) -> dict[tuple[str, ...], T]: + now = int(time.time()) + + return { + query: sources + for query, (cached_at, sources) in self._cache.items() + if cached_at + source_ttl > now + } + def set(self, sources: T, *query: str): self._cache[query] = (int(time.time()), sources) @@ -83,3 +92,15 @@ class StringConfigCache[L: str](ABC): def set_int(self, session: Session, key: L, value: int): self.set(session, key, str(value)) + + def get_bool(self, session: Session, key: L) -> Optional[bool]: + try: + val = self.get_int(session, key) + except ValueError: # incase if the db has an old bool string instead of an int + return False + if val is not None: + return val != 0 + return None + + def set_bool(self, session: Session, key: L, value: bool): + self.set_int(session, key, int(value)) diff --git a/app/util/templates.py b/app/util/templates.py index 227270c..6fb2ee5 100644 --- a/app/util/templates.py +++ b/app/util/templates.py @@ -9,6 +9,7 @@ from app.internal.env_settings import Settings templates = Jinja2Blocks(directory="templates") templates.env.filters["zfill"] = lambda val, num: str(val).zfill(num) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportUnknownArgumentType] +templates.env.filters["toJSstring"] = lambda val: f"'{str(val).replace("'", "\\'")}'" # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportUnknownArgumentType] templates.env.globals["vars"] = vars # pyright: ignore[reportUnknownMemberType] templates.env.globals["getattr"] = getattr # pyright: ignore[reportUnknownMemberType] templates.env.globals["version"] = Settings().app.version # pyright: ignore[reportUnknownMemberType] diff --git a/templates/settings_page/notifications.html b/templates/settings_page/notifications.html index 5bb4325..218c013 100644 --- a/templates/settings_page/notifications.html +++ b/templates/settings_page/notifications.html @@ -172,13 +172,14 @@ {% for n in notifications %}