mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2025-12-30 18:19:34 -06:00
allow configuring what indexers should be used
This commit is contained in:
38
alembic/versions/bc237f8b139d_remove_indexer_table.py
Normal file
38
alembic/versions/bc237f8b139d_remove_indexer_table.py
Normal 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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user