diff --git a/README.md b/README.md
index a19ccde..a0b35f8 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+
+
# 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 %}
-
-
-
+
+
+
+
+
+ {% if search_results %}
+
+
+
+
+
+ {% endif %}
+
+ {% if not search_results %}
+
+
+ {% if search_term %}
+
No results found for "{{ search_term }}"
+ {% else %}
+
Search for a book
+ {% endif %}
+
+ {% 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 @@
+