allow configuring what indexers should be used

This commit is contained in:
Markbeep
2025-04-13 00:06:56 +02:00
parent 4d9d1152de
commit 1ee93b5d34
10 changed files with 214 additions and 10 deletions

View File

@@ -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 ###

View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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(

View File

@@ -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))

View File

@@ -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))

View File

@@ -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]

View File

@@ -172,13 +172,14 @@
{% for n in notifications %}
<template x-if="edit === '{{n.id}}'">
<form
x-data="{ name: '{{n.name}}', event: '{{n.event.value}}', apprise_url: '{{n.apprise_url}}', headers: '{{n.headers}}', title_template: '{{n.title_template}}', body_template: '{{n.body_template}}' }"
x-data="{ name: {{n.name|toJSstring}}, event: {{n.event.value|toJSstring}}, apprise_url: {{n.apprise_url|toJSstring}}, headers: {{n.serialized_headers|toJSstring}}, title_template: {{n.title_template|toJSstring}}, body_template: {{n.body_template|toJSstring}} }"
class="flex flex-col gap-2"
hx-put="{{base_url}}/settings/notification/{{n.id}}"
hx-target="#notification-list"
hx-swap="outerHTML"
id="edit-notification-form"
>
{{ n.name }}
<label for="name">Name<span class="text-error">*</span></label>
<input
id="name"

View File

@@ -106,6 +106,55 @@
Save
</button>
</form>
{% endblock %} {% block indexer %}
<form
id="indexer-select"
class="flex flex-col gap-1"
x-data="{ selectedIndexers: [{{ selected_indexers|join(',') }}].sort(), indexers: {{ indexers }}, dirty: false }"
hx-put="{{base_url}}/settings/prowlarr/indexers"
hx-disabled-elt="#indexer-submit-button"
hx-target="this"
hx-swap="outerHTML"
>
<label for="indexers">Indexers</label>
<p class="text-xs opacity-60">
Select the indexers to use with Prowlarr. If none are selected, all
indexers will be used.
</p>
<div class="flex flex-wrap gap-1">
<template x-for="indexerId in selectedIndexers">
<div
class="badge badge-secondary badge-sm w-[13rem] justify-between h-fit"
>
<input type="hidden" name="i" x-bind:value="indexerId" />
<span x-text="indexers[indexerId].name"></span>
<button
class="cursor-pointer [&>svg]:size-4 hover:opacity-70 transition-opacity duration-150"
x-on:click="selectedIndexers = selectedIndexers.filter((itemId) => itemId !== indexerId); dirty = true"
type="button"
>
{% include 'icons/xmark.html' %}
</button>
</div>
</template>
</div>
<select name="group" class="select w-full">
<template x-for="indexerId in Object.keys(indexers)">
<template x-if="!selectedIndexers.includes(indexerId)">
<option
x-text="indexers[indexerId].name"
x-on:click="selectedIndexers = [...selectedIndexers, indexerId].sort(); dirty = true"
></option>
</template>
</template>
</select>
<button id="indexer-submit-button" x-bind:disabled="!dirty" class="btn">
Save
</button>
</form>
{% endblock %}
</div>
{% endblock %}