mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2026-02-16 11:40:31 -06:00
redo/add search page
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||

|
||||
|
||||
# Local Development
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ###
|
||||
43
alembic/versions/cafe562e2832_simplify_bookrequests.py
Normal file
43
alembic/versions/cafe562e2832_simplify_bookrequests.py
Normal 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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
86
app/util/book_search.py
Normal 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
BIN
media/search_page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@@ -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
69
templates/wishlist.html
Normal 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>
|
||||
Reference in New Issue
Block a user