diff --git a/README.md b/README.md index a19ccde..a0b35f8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +![Search page](media/search_page.png) + # Local Development ## Installation diff --git a/alembic/env.py b/alembic/env.py index 23ed294..2126040 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako index fbc4b07..6ce3351 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -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. diff --git a/alembic/versions/787e0b375062_add_user_group_add_bookrequest.py b/alembic/versions/787e0b375062_add_user_group_add_bookrequest.py new file mode 100644 index 0000000..2e21aa1 --- /dev/null +++ b/alembic/versions/787e0b375062_add_user_group_add_bookrequest.py @@ -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 ### diff --git a/alembic/versions/cafe562e2832_simplify_bookrequests.py b/alembic/versions/cafe562e2832_simplify_bookrequests.py new file mode 100644 index 0000000..5ffc920 --- /dev/null +++ b/alembic/versions/cafe562e2832_simplify_bookrequests.py @@ -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 ### diff --git a/app/db.py b/app/db.py index b6bc885..0bd8249 100644 --- a/app/db.py +++ b/app/db.py @@ -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 diff --git a/app/models.py b/app/models.py index bb63701..06637f5 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/routers/root.py b/app/routers/root.py index d4421af..28677a9 100644 --- a/app/routers/root.py +++ b/app/routers/root.py @@ -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() diff --git a/app/routers/search.py b/app/routers/search.py index 4554504..5b30861 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -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() diff --git a/app/util/auth.py b/app/util/auth.py index 9dfba8d..e4dd045 100644 --- a/app/util/auth.py +++ b/app/util/auth.py @@ -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 diff --git a/app/util/book_search.py b/app/util/book_search.py new file mode 100644 index 0000000..7722c1c --- /dev/null +++ b/app/util/book_search.py @@ -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] diff --git a/media/search_page.png b/media/search_page.png new file mode 100644 index 0000000..4966383 Binary files /dev/null and b/media/search_page.png differ diff --git a/styles/globals.css b/styles/globals.css index f1d8c73..3571db7 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1 +1,2 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; diff --git a/templates/search.html b/templates/search.html index d25e0a8..89200f9 100644 --- a/templates/search.html +++ b/templates/search.html @@ -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; +}; {% endblock %} {% block body %} -
-
-
+
+
+
- -
+
- - - - - - - - - - - - - - {% for book in search_results %} - - - - - - - - - - {% endfor %} - -
TitleIndexerSize (MB)Seed/LeechAge (days)Request
{{ loop.index }} - {{ book.title }} - {{ book.indexer }}{{ book.size }}{{ book.seeders }}/{{ book.leechers }}{{ book.age }} - -
+ {% for book in search_results %} +
+
+ {{ book.title }} + +
+ +
+
+ +
{{ book.title }}
+ {% if book.subtitle %}
{{ book.subtitle }}
{% endif %} +
+ {{ book.authors | join(", ") }} +
+
+ {% endfor %}
+ + {% if search_results %} +
+ + + +
+ {% endif %} + + {% if not search_results %} + + {% endif %}
{% endblock %} diff --git a/templates/wishlist.html b/templates/wishlist.html new file mode 100644 index 0000000..d4862d2 --- /dev/null +++ b/templates/wishlist.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + {% for book in search_results %} + + + + + + + + + + {% endfor %} + +
TitleIndexerSize (MB)Seed/LeechAge (days)Request
{{ loop.index }} + {{ book.title }} + {{ book.indexer }}{{ book.size }}{{ book.seeders }}/{{ book.leechers }}{{ book.age }} + +