diff --git a/alembic/versions/2c6c9c1a4f8d_add_favorite_authors.py b/alembic/versions/2c6c9c1a4f8d_add_favorite_authors.py new file mode 100644 index 00000000..25f70ec9 --- /dev/null +++ b/alembic/versions/2c6c9c1a4f8d_add_favorite_authors.py @@ -0,0 +1,32 @@ +"""add favorite authors + +Revision ID: 2c6c9c1a4f8d +Revises: e4f418be2275 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "2c6c9c1a4f8d" +down_revision: Union[str, None] = "e4f418be2275" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "favoriteauthor", + sa.Column("user_username", sa.String(), nullable=False), + sa.Column("author", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["user_username"], ["user.username"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("user_username", "author"), + ) + + +def downgrade() -> None: + op.drop_table("favoriteauthor") diff --git a/app/internal/audible/search.py b/app/internal/audible/search.py index e3e464e8..2e46d9c7 100644 --- a/app/internal/audible/search.py +++ b/app/internal/audible/search.py @@ -35,6 +35,7 @@ class CacheQuery(BaseModel, frozen=True): num_results: int page: int audible_region: audible_region_type + search_type: str = "all" class CacheResult[T](BaseModel, frozen=True): @@ -135,6 +136,7 @@ async def search_audible_books( num_results: int = 20, page: int = 0, audible_region: audible_region_type | None = None, + search_type: str = "all", ) -> list[Audiobook]: """ https://audible.readthedocs.io/en/latest/misc/external_api.html#get--1.0-catalog-products @@ -149,6 +151,7 @@ async def search_audible_books( num_results=num_results, page=page, audible_region=audible_region, + search_type=search_type, ) cache_result = search_cache.get(cache_key) @@ -161,9 +164,9 @@ async def search_audible_books( params = { "num_results": num_results, "products_sort_by": "Relevance", - "keywords": query, "page": page, "response_groups": ["media"], + "keywords": query, } try: @@ -184,9 +187,20 @@ async def search_audible_books( ) return [] - # do not fetch book results we already have locally books = audible_response.audiobooks() + if search_type == "title": + query_lower = query.lower() + books = [b for b in books if query_lower in b.title.lower()] + elif search_type == "author": + query_lower = query.lower() + books = [ + b + for b in books + if any(query_lower in author.lower() for author in b.authors) + ] + books.sort(key=lambda book: book.release_date, reverse=True) + logger.debug( "Search results fetched", query=query, diff --git a/app/internal/models.py b/app/internal/models.py index a69c137c..ebf6a2f4 100644 --- a/app/internal/models.py +++ b/app/internal/models.py @@ -61,6 +61,15 @@ def is_self(self, username: str): return self.username == username +class FavoriteAuthor(BaseSQLModel, table=True): + user_username: str = Field( + primary_key=True, + foreign_key="user.username", + ondelete="CASCADE", + ) + author: str = Field(primary_key=True) + + class Audiobook(BaseSQLModel, table=True): """A cached Audible audiobook result. Used for both the search results and also linked to via a foreign key for requests.""" diff --git a/app/routers/api/search.py b/app/routers/api/search.py index 3c6d23fe..52188922 100644 --- a/app/routers/api/search.py +++ b/app/routers/api/search.py @@ -28,6 +28,7 @@ async def search_books( num_results: int = 20, page: int = 0, region: audible_region_type | None = None, + search_type: str = "all", ): if region is None: region = get_region_from_settings() @@ -40,6 +41,7 @@ async def search_books( num_results=num_results, page=page, audible_region=region, + search_type=search_type, ) else: results = [] diff --git a/app/routers/pages/__init__.py b/app/routers/pages/__init__.py index a47c0b74..ba9ace8e 100644 --- a/app/routers/pages/__init__.py +++ b/app/routers/pages/__init__.py @@ -2,6 +2,7 @@ from . import ( auth, + favorite_authors, index, init, login, @@ -16,6 +17,7 @@ router = APIRouter() router.include_router(auth.router) +router.include_router(favorite_authors.router) router.include_router(index.router) router.include_router(init.router) router.include_router(login.router) diff --git a/app/routers/pages/favorite_authors.py b/app/routers/pages/favorite_authors.py new file mode 100644 index 00000000..c6ab59c6 --- /dev/null +++ b/app/routers/pages/favorite_authors.py @@ -0,0 +1,24 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Security +from sqlmodel import Session, col, select + +from app.internal.auth.authentication import ABRAuth, DetailedUser +from app.internal.models import FavoriteAuthor +from app.util.db import get_session +from app.util.templates import catalog_response + +router = APIRouter() + + +@router.get("/favorite-authors") +async def favorite_authors( + session: Annotated[Session, Depends(get_session)], + user: Annotated[DetailedUser, Security(ABRAuth())], +): + authors = session.exec( + select(FavoriteAuthor.author) + .where(col(FavoriteAuthor.user_username) == user.username) + .order_by(col(FavoriteAuthor.author)) + ).all() + return catalog_response("FavoriteAuthors.Index", user=user, authors=authors) diff --git a/app/routers/pages/search/__init__.py b/app/routers/pages/search/__init__.py index 1e8a5dd7..d77109b5 100644 --- a/app/routers/pages/search/__init__.py +++ b/app/routers/pages/search/__init__.py @@ -1,12 +1,13 @@ from typing import Annotated +from urllib.parse import quote_plus from aiohttp import ClientSession -from fastapi import APIRouter, Depends, Query, Security -from sqlmodel import Session +from fastapi import APIRouter, Depends, Form, Query, Security +from sqlmodel import Session, col, select from app.internal.audible.types import audible_region_type, get_region_from_settings from app.internal.auth.authentication import ABRAuth, DetailedUser -from app.internal.models import GroupEnum +from app.internal.models import FavoriteAuthor, GroupEnum from app.internal.prowlarr.util import prowlarr_config from app.internal.ranking.quality import quality_config from app.routers.api.search import search_books @@ -14,6 +15,7 @@ from app.util.connection import get_connection from app.util.db import get_session from app.util.log import logger +from app.util.redirect import BaseUrlRedirectResponse from app.util.templates import catalog_response from app.util.toast import ToastException @@ -33,6 +35,7 @@ async def read_search( num_results: int = 20, page: int = 0, region: audible_region_type | None = None, + search_type: str = "all", ): if region is None: region = get_region_from_settings() @@ -45,6 +48,7 @@ async def read_search( num_results=num_results, page=page, region=region, + search_type=search_type, ) prowlarr_configured = prowlarr_config.is_valid(session) @@ -55,10 +59,21 @@ async def read_search( search_term=query or "", search_results=results, selected_region=region, + selected_search_type=search_type, page=page, auto_start_download=quality_config.get_auto_download(session) and user.is_above(GroupEnum.trusted), prowlarr_configured=prowlarr_configured, + is_favorite=bool( + query + and search_type == "author" + and session.exec( + select(FavoriteAuthor).where( + col(FavoriteAuthor.user_username) == user.username, + col(FavoriteAuthor.author) == query, + ) + ).first() + ), ) except Exception as e: @@ -69,6 +84,27 @@ async def read_search( ) from e +@router.post("/favorite-author") +async def favorite_author( + author: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + user: Annotated[DetailedUser, Security(ABRAuth())], +): + author = author.strip() + if ( + author + and not session.exec( + select(FavoriteAuthor).where( + col(FavoriteAuthor.user_username) == user.username, + col(FavoriteAuthor.author) == author, + ) + ).first() + ): + session.add(FavoriteAuthor(user_username=user.username, author=author)) + session.commit() + return BaseUrlRedirectResponse(f"/search?q={quote_plus(author)}&search_type=author") + + @router.get("/hx-suggestions") async def search_suggestions( query: Annotated[str, Query(alias="q")], diff --git a/templates/components/BookCard.jinja b/templates/components/BookCard.jinja index acb4a2fa..5b120d4e 100644 --- a/templates/components/BookCard.jinja +++ b/templates/components/BookCard.jinja @@ -78,7 +78,7 @@