redo/add search page

This commit is contained in:
Markbeep
2025-02-16 13:38:39 +01:00
parent 4ebeeadd52
commit 9982ff0e90
15 changed files with 508 additions and 127 deletions

View File

@@ -1,3 +1,5 @@
![Search page](media/search_page.png)
# Local Development
## Installation

View File

@@ -61,7 +61,11 @@ def run_migrations_online() -> None:
os.makedirs("data")
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.

View File

@@ -0,0 +1,63 @@
"""add user group, add BookRequest
Revision ID: 787e0b375062
Revises: 939af2c2c9ea
Create Date: 2025-02-16 12:56:48.250739
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '787e0b375062'
down_revision: Union[str, None] = '939af2c2c9ea'
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.create_table('bookrequest',
sa.Column('asin', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('subtitle', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('authors', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('narrators', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('cover_image', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('user_username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['user_username'], ['user.username'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('asin')
)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('group', sqlmodel.sql.sqltypes.AutoString(), server_default='untrusted', nullable=False))
batch_op.alter_column('username',
existing_type=sa.BLOB(),
type_=sqlmodel.sql.sqltypes.AutoString(),
existing_nullable=False)
batch_op.alter_column('password',
existing_type=sa.BLOB(),
type_=sqlmodel.sql.sqltypes.AutoString(),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password',
existing_type=sqlmodel.sql.sqltypes.AutoString(),
type_=sa.BLOB(),
existing_nullable=False)
batch_op.alter_column('username',
existing_type=sqlmodel.sql.sqltypes.AutoString(),
type_=sa.BLOB(),
existing_nullable=False)
batch_op.drop_column('group')
op.drop_table('bookrequest')
# ### end Alembic commands ###

View File

@@ -0,0 +1,43 @@
"""simplify BookRequests
Revision ID: cafe562e2832
Revises: 787e0b375062
Create Date: 2025-02-16 13:22:22.576727
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'cafe562e2832'
down_revision: Union[str, None] = '787e0b375062'
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! ###
with op.batch_alter_table('bookrequest', schema=None) as batch_op:
batch_op.drop_column('cover_image')
batch_op.drop_column('subtitle')
batch_op.drop_column('authors')
batch_op.drop_column('narrators')
batch_op.drop_column('title')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('bookrequest', schema=None) as batch_op:
batch_op.add_column(sa.Column('title', sa.VARCHAR(), nullable=False))
batch_op.add_column(sa.Column('narrators', sa.VARCHAR(), nullable=False))
batch_op.add_column(sa.Column('authors', sa.VARCHAR(), nullable=False))
batch_op.add_column(sa.Column('subtitle', sa.VARCHAR(), nullable=True))
batch_op.add_column(sa.Column('cover_image', sa.VARCHAR(), nullable=True))
# ### end Alembic commands ###

View File

@@ -1,6 +1,6 @@
from os import getenv
from sqlalchemy import create_engine
from sqlmodel import Session
from sqlmodel import Session, text
sqlite_path = getenv("SQLITE_PATH", "data/db.sqlite")
@@ -9,4 +9,5 @@ engine = create_engine(f"sqlite+pysqlite:///{sqlite_path}")
def get_session():
with Session(engine) as session:
session.execute(text("PRAGMA foreign_keys=ON")) # pyright: ignore[reportDeprecated]
yield session

View File

@@ -1,5 +1,4 @@
# pyright: reportUnknownVariableType=false
from datetime import datetime
from sqlmodel import Field, SQLModel
@@ -10,17 +9,21 @@ class BaseModel(SQLModel):
class User(BaseModel, table=True):
username: str = Field(primary_key=True)
password: str
group: str = Field(
default="untrusted", sa_column_kwargs={"server_default": "untrusted"}
)
"""
untrusted: Requests need to be manually reviewed
trusted: Requests are automatically downloaded if possible
admin: Can approve or deny requests, change settings, etc.
"""
class BookRequest(BaseModel, table=True):
guid: str = Field(primary_key=True)
title: str
indexerId: int
download_url: str
publishDate: datetime
# TODO: Remove seeders/leechers when we have a way of getting them dynamically
seeders: int
leechers: int
asin: str = Field(primary_key=True)
user_username: str = Field(
foreign_key="user.username", nullable=False, ondelete="CASCADE"
)
class Indexer(BaseModel):

View File

@@ -7,7 +7,8 @@ from sqlmodel import Session
from app.db import get_session
from app.util.auth import create_user, get_username
from app.models import User
from app.util.auth import create_user, get_user
router = APIRouter()
@@ -20,9 +21,9 @@ def read_globals_css():
@router.get("/")
def read_root(request: Request, username: Annotated[str, Depends(get_username)]):
def read_root(request: Request, user: Annotated[User, Depends(get_user)]):
return templates.TemplateResponse(
"root.html", {"request": request, "username": username}
"root.html", {"request": request, "username": user.username}
)
@@ -46,14 +47,12 @@ def create_init(
if password != confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
print(username, password, confirm_password, validate_password_regex.match(password))
if not validate_password_regex.match(password):
raise HTTPException(
status_code=400,
detail="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number",
)
user = create_user(username, password)
user = create_user(username, password, "admin")
session.add(user)
session.commit()

View File

@@ -1,9 +1,20 @@
from typing import Optional
from fastapi import APIRouter, Request
from sqlite3 import IntegrityError
from typing import Annotated, Optional
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlmodel import Session, col, select
from app.util.prowlarr import query_prowlarr
from app.db import get_session
from app.models import BookRequest, User
from app.util.auth import get_user
from app.util.book_search import (
list_audible_books,
audible_regions,
audible_region_type,
)
from app.util.connection import get_connection
router = APIRouter(prefix="/search")
@@ -14,19 +25,84 @@ templates = Jinja2Blocks(directory="templates")
@router.get("")
async def read_search(
request: Request,
user: Annotated[User, Depends(get_user)],
client_session: Annotated[ClientSession, Depends(get_connection)],
session: Annotated[Session, Depends(get_session)],
q: Optional[str] = None,
num_results: int = 20,
page: int = 0,
region: audible_region_type = "us",
):
search_results = await query_prowlarr(q)
if audible_regions.get(region) is None:
raise HTTPException(status_code=400, detail="Invalid region")
if q:
search_results = await list_audible_books(
session=client_session,
query=q,
num_results=num_results,
page=page,
audible_region=region,
)
else:
search_results = []
# check what books are already requested by the user
asins = [book.asin for book in search_results]
requested_books = set(
session.exec(
select(BookRequest.asin).where(
col(BookRequest.asin).in_(asins),
BookRequest.user_username == user.username,
)
).all()
)
for book in search_results:
if book.asin in requested_books:
book.already_requested = True
return templates.TemplateResponse(
"search.html",
{"request": request, "search_term": q or "", "search_results": search_results},
{
"request": request,
"search_term": q or "",
"search_results": search_results,
"regions": list(audible_regions.keys()),
"selected_region": region,
"page": page,
"num_results": num_results,
},
)
@router.post("/request")
async def add_request(request: Request, guid: str): ...
@router.post("/request", status_code=201)
async def add_request(
asin: str,
user: Annotated[User, Depends(get_user)],
session: Annotated[Session, Depends(get_session)],
):
req = BookRequest(
asin=asin,
user_username=user.username,
)
try:
session.add(req)
session.commit()
except IntegrityError:
pass
@router.delete("/request")
async def delete_request(request: Request, guid: str): ...
@router.delete("/request", status_code=204)
async def delete_request(
asin: str,
user: Annotated[User, Depends(get_user)],
session: Annotated[Session, Depends(get_session)],
):
book = session.exec(
select(BookRequest).where(
BookRequest.asin == asin, BookRequest.user_username == user.username
)
).one_or_none()
if book:
session.delete(book)
session.commit()

View File

@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Literal
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
@@ -13,15 +13,19 @@ security = HTTPBasic()
ph = PasswordHasher()
def create_user(username: str, password: str) -> User:
def create_user(
username: str,
password: str,
group: Literal["admin", "trusted", "untrusted"] = "untrusted",
) -> User:
password_hash = ph.hash(password)
return User(username=username, password=password_hash)
return User(username=username, password=password_hash, group=group)
def get_username(
def get_user(
session: Annotated[Session, Depends(get_session)],
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
) -> User:
user = session.exec(
select(User).where(User.username == credentials.username)
).one_or_none()
@@ -47,4 +51,4 @@ def get_username(
session.add(user)
session.commit()
return credentials.username
return user

86
app/util/book_search.py Normal file
View File

@@ -0,0 +1,86 @@
import asyncio
from typing import Literal, Optional
from urllib.parse import urlencode
from aiohttp import ClientSession
from async_lru import alru_cache
import pydantic
class BookResult(pydantic.BaseModel):
asin: str
title: str
subtitle: Optional[str]
authors: list[str]
narrators: list[str]
cover_image: Optional[str]
already_requested: bool = False
@alru_cache(ttl=300)
async def get_audnexus_book(session: ClientSession, asin: str) -> Optional[BookResult]:
"""
https://audnex.us/#tag/Books/operation/getBookById
"""
async with session.get(f"https://api.audnex.us/books/{asin}") as response:
if not response.ok:
return None
book = await response.json()
return BookResult(
asin=book["asin"],
title=book["title"],
subtitle=book.get("subtitle"),
authors=[author["name"] for author in book["authors"]],
narrators=[narrator["name"] for narrator in book["narrators"]],
cover_image=book.get("image"),
)
audible_region_type = Literal[
"us", "ca", "uk", "au", "fr", "de", "jp", "it", "in", "es"
]
audible_regions: dict[audible_region_type, str] = {
"us": ".com",
"ca": ".ca",
"uk": ".co.uk",
"au": ".com.au",
"fr": ".fr",
"de": ".de",
"jp": ".co.jp",
"it": ".it",
"in": ".in",
"es": ".es",
}
@alru_cache(ttl=300)
async def list_audible_books(
session: ClientSession,
query: str,
num_results: int = 20,
page: int = 0,
audible_region: audible_region_type = "us",
) -> list[BookResult]:
"""
https://audible.readthedocs.io/en/latest/misc/external_api.html#get--1.0-catalog-products
"""
params = {
"num_results": num_results,
"products_sort_by": "Relevance",
"keywords": query,
"page": page,
}
base_url = (
f"https://api.audible{audible_regions[audible_region]}/1.0/catalog/products?"
)
url = base_url + urlencode(params)
async with session.get(url) as response:
response.raise_for_status()
books_json = await response.json()
coros = [
get_audnexus_book(session, asin_obj["asin"])
for asin_obj in books_json["products"]
]
books = await asyncio.gather(*coros)
return [b for b in books if b]

BIN
media/search_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1 +1,2 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";

View File

@@ -8,117 +8,146 @@
document.getElementById("search-spinner").style.display = "inline-block";
window.location.href = `/search?q=${encodeURIComponent(search_term)}`;
};
const onRequest = (index, guid, indexerId) => {
const onRequest = (index, asin) => {
const checkbox = document.getElementById(`checkbox-${index}`);
if (checkbox.checked) {
fetch(
`/search/request?guid=${encodeURIComponent(
guid,
)}&indexerId=${indexerId}`,
{
method: "DELETE",
},
);
} else {
fetch(
`/search/request?guid=${encodeURIComponent(
guid,
)}&indexerId${indexerId}`,
{
method: "POST",
},
);
}
fetch(
`/search/request?asin=${encodeURIComponent(asin)}`,
{
method: checkbox.checked ? "POST" : "DELETE",
},
);
};
const onPageChange = (page) => {
const url = new URL(window.location);
url.searchParams.set("page", page);
window.location = url;
};
</script>
{% endblock %} {% block body %}
<div class="w-full h-screen flex items-center justify-center py-8">
<div class="flex flex-col h-full gap-4">
<div class="flex items-start gap-2">
<div class="w-full h-screen flex items-center justify-center py-8 overflow-x-hidden">
<div class="flex flex-col h-full gap-4 items-center">
<form class="flex items-start gap-2 w-full" onsubmit="onSearch();">
<input
name="q"
class="input input-bordered"
placeholder="Book name..."
value="{{ search_term }}"
/>
<button id="search" class="btn btn-primary" onclick="onSearch();">
<select class="select" name="region">
{% for region in regions %}
<option
value="{{ region }}"
{% if region == selected_region %}selected="selected"{% endif %}
>
{{ region }}
</option>
{% endfor %}
</select>
<button id="search" class="btn btn-primary" type="submit">
<span id="search-text">Search</span>
<span id="search-spinner" class="loading" style="display: none"></span>
</button>
</div>
</form>
<div
class="max-w-[80vw] overflow-y-scroll outline outline-primary/50 rounded-lg"
class="max-w-[80vw] grid gap-1 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"
>
<table class="table table-zebra table-pin-rows">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Indexer</th>
<th>Size (MB)</th>
<th>Seed/Leech</th>
<th>Age (days)</th>
<th>Request</th>
</tr>
</thead>
<tbody>
{% for book in search_results %}
<tr>
<th>{{ loop.index }}</th>
<td title="{{ book.title }}">
<a class="link" href="{{ book.infoUrl }}">{{ book.title }}</a>
</td>
<td>{{ book.indexer }}</td>
<td>{{ book.size }}</td>
<td>{{ book.seeders }}/{{ book.leechers }}</td>
<td title="{{ book.publishDate }}">{{ book.age }}</td>
<td>
<label class="swap swap-flip">
<input
id="checkbox-{{ loop.index }}"
type="checkbox"
onclick="onRequest('{{ loop.index }}', '{{ book.guid }}', '{{ book.indexerId }}');"
/>
<svg
class="swap-off"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
width="24"
height="24"
stroke-width="2"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
>
<path d="M12 5l0 14"></path>
<path d="M5 12l14 0"></path>
</svg>
<svg
class="swap-on text-success"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
width="24"
height="24"
stroke-width="2"
>
<path d="M5 12l5 5l10 -10"></path>
</svg>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for book in search_results %}
<div class="flex flex-col">
<div
class="relative w-[10rem] h-[10rem] rounded-md overflow-hidden shadow-md shadow-white"
>
<img
class="object-cover w-full h-full"
src="{{ book.cover_image }}"
alt="{{ book.title }}"
/>
<div class="absolute top-0 right-0 bg-black/50 size-6 rounded-bl-md">
<label class="swap swap-flip">
<input
id="checkbox-{{ loop.index }}"
type="checkbox"
onclick="onRequest('{{ loop.index }}','{{ book.asin }}');"
{% if book.already_requested %}checked{% endif %}
/>
<svg
class="swap-off"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
width="24"
height="24"
stroke-width="2"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
>
<path d="M12 5l0 14"></path>
<path d="M5 12l14 0"></path>
</svg>
<svg
class="swap-on text-success"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
width="24"
height="24"
stroke-width="2"
>
<path d="M5 12l5 5l10 -10"></path>
</svg>
</label>
</div>
</div>
<div class="text-sm text-white" title="Title">{{ book.title }}</div>
{% if book.subtitle %}<div class="text-neutral-content text-xs" title="Subtitle">{{ book.subtitle }}</div>{% endif %}
<div class="text-neutral-content text-xs" title="Authors">
{{ book.authors | join(", ") }}
</div>
</div>
{% endfor %}
</div>
{% if search_results %}
<div class="join">
<button class="join-item btn" onclick="onPageChange('{{ page-1 }}')" {% if page==0 %}disabled{% endif %}>«</button>
<button class="join-item btn flex flex-col" onclick="onPageChange('{{ 0 }}')" {% if page==0 %}disabled{% endif %} style="gap: 0;">
Page {{ page+1 }}
<span class="text-[0.5rem]">back to first</span>
</button>
<button class="join-item btn" onclick="onPageChange('{{ page+1 }}')">»</button>
</div>
{% endif %}
{% if not search_results %}
<div role="alert" class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% if search_term %}
<span>No results found for "{{ search_term }}"</span>
{% else %}
<span>Search for a book</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

69
templates/wishlist.html Normal file
View File

@@ -0,0 +1,69 @@
<table class="table table-zebra table-pin-rows">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Indexer</th>
<th>Size (MB)</th>
<th>Seed/Leech</th>
<th>Age (days)</th>
<th>Request</th>
</tr>
</thead>
<tbody>
{% for book in search_results %}
<tr>
<th>{{ loop.index }}</th>
<td title="{{ book.title }}">
<a class="link" href="{{ book.infoUrl }}">{{ book.title }}</a>
</td>
<td>{{ book.indexer }}</td>
<td>{{ book.size }}</td>
<td>{{ book.seeders }}/{{ book.leechers }}</td>
<td title="{{ book.publishDate }}">{{ book.age }}</td>
<td>
<label class="swap swap-flip">
<input
id="checkbox-{{ loop.index }}"
type="checkbox"
onclick="onRequest('{{ loop.index }}', '{{ book.guid }}', '{{ book.indexerId }}');"
/>
<svg
class="swap-off"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
width="24"
height="24"
stroke-width="2"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
>
<path d="M12 5l0 14"></path>
<path d="M5 12l14 0"></path>
</svg>
<svg
class="swap-on text-success"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
width="24"
height="24"
stroke-width="2"
>
<path d="M5 12l5 5l10 -10"></path>
</svg>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>