Skip to content
Open
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
32 changes: 32 additions & 0 deletions alembic/versions/2c6c9c1a4f8d_add_favorite_authors.py
Original file line number Diff line number Diff line change
@@ -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")
18 changes: 16 additions & 2 deletions app/internal/audible/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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:
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions app/internal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions app/routers/api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -40,6 +41,7 @@ async def search_books(
num_results=num_results,
page=page,
audible_region=region,
search_type=search_type,
)
else:
results = []
Expand Down
2 changes: 2 additions & 0 deletions app/routers/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from . import (
auth,
favorite_authors,
index,
init,
login,
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions app/routers/pages/favorite_authors.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 39 additions & 3 deletions app/routers/pages/search/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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
from app.routers.api.search import search_suggestions as api_search_suggestions
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

Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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")],
Expand Down
2 changes: 1 addition & 1 deletion templates/components/BookCard.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<div class="text-xs font-semibold line-clamp-1"
title="Authors: {{ book.authors | join(", ") }}">
{% for author in book.authors[:2] %}
<a href="{{ base_url }}/search?q={{ author }}"
<a href="{{ base_url }}/search?q={{ author | urlencode }}&search_type=author"
title="Search for {{ author }}"
class="hover:underline">
{{ author }}
Expand Down
6 changes: 6 additions & 0 deletions templates/layouts/BaseLayout.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@
<icons.Gift />
</span>
</a>
<a preload
href="{{ base_url }}/favorite-authors"
class="btn btn-ghost btn-square"
title="Favorite authors">
<span class="text-lg">★</span>
</a>
</div>
<div class="flex-none flex pr-4">
<button class="btn btn-ghost btn-square light-dark-toggle"
Expand Down
21 changes: 21 additions & 0 deletions templates/pages/FavoriteAuthors/Index.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{#def
user: DetailedUser,
authors: list[str],
#}

<BaseLayout title="Favorite authors" user={{ user }}>
<main class="p-6 sm:p-8">
<h1 class="text-3xl font-bold">Favorite authors</h1>
<ul class="mt-4 list-disc pl-6">
{% for author in authors %}
<li>
<a class="link link-primary"
preload
href="{{ base_url }}/search?q={{ author | urlencode }}&search_type=author">{{ author }}</a>
</li>
{% else %}
<li class="list-none opacity-60">No favorite authors saved.</li>
{% endfor %}
</ul>
</main>
</BaseLayout>
24 changes: 22 additions & 2 deletions templates/pages/Search/Index.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@
user: DetailedUser,
search_term: str,
selected_region: audible_region_type,
selected_search_type: str = "all",
search_results: list[AudiobookSearchResult],
page: int,
auto_start_download: bool,
prowlarr_configured: bool,
is_favorite: bool = False,
suggestions: list[str] = [],
#}

<BaseLayout title="Search" user={{ user }}>
<script>
const onSearch = () => {
const search_term = document.querySelector("input").value;
const form = document.querySelector("form");
const search_term = form.querySelector("input").value;
const search_type = form.querySelector("select[name='search_type']").value;
const region = form.querySelector("select[name='region']").value;
document.getElementById("search").disabled = true;
document.getElementById("search-text").style.display = "none";
document.getElementById("search-spinner").style.display = "inline-block";
window.location.href = `/search?q=${encodeURIComponent(search_term)}`;
window.location.href = `/search?q=${encodeURIComponent(search_term)}&search_type=${encodeURIComponent(search_type)}&region=${encodeURIComponent(region)}`;
};
const onPageChange = page => {
const url = new URL(window.location);
Expand Down Expand Up @@ -54,6 +59,13 @@

<Search.Suggestions id="search-suggestions" suggestions={{ suggestions }} />

<select class="select join-item max-w-24 sm:max-w-28 focus:z-10"
name="search_type">
<option value="all" {% if selected_search_type.__eq__("all") %}selected="selected"{% endif %}>All</option>
<option value="title" {% if selected_search_type.__eq__("title") %}selected="selected"{% endif %}>Title</option>
<option value="author" {% if selected_search_type.__eq__("author") %}selected="selected"{% endif %}>Author</option>
</select>

<select class="select join-item max-w-16 sm:max-w-20 focus:z-10"
name="region">
{% for region in audible_regions.keys() %}
Expand All @@ -68,6 +80,14 @@
<span id="search-spinner" class="loading hidden"></span>
</button>
</form>
{% if search_term and selected_search_type == "author" %}
<form method="post" action="{{ base_url }}/search/favorite-author">
<input type="hidden" name="author" value="{{ search_term }}" />
<button class="btn btn-sm" type="submit" {% if is_favorite %}disabled{% endif %}>
{{ "Author saved" if is_favorite else "Save author as favorite" }}
</button>
</form>
{% endif %}
{% block book_results %}
<div id="book-results"
class="min-w-[60vw] max-w-[90vw] sm:max-w-[80vw] h-full grid gap-1 gap-y-2 sm:gap-y-4 sm:gap-2 p-1 grid-flow-row grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7">
Expand Down