wishlist and sources page + download button

This commit is contained in:
Markbeep
2025-02-16 17:41:30 +01:00
parent 9982ff0e90
commit 2c15ff6cd0
15 changed files with 646 additions and 138 deletions

View File

@@ -1,5 +1,13 @@
![Search page](media/search_page.png)
# Rough TODO
- [ ] Navbar
- [ ] Add option to remove requests on wishlist page
- [ ] Settings page
- [ ] Fix DaisyUI themes overwriting attributes (text-size, shadows, etc.) and choose theme
- [ ] Docker
# Local Development
## Installation

View File

@@ -0,0 +1,37 @@
"""rename prowlarr date col
Revision ID: 6477fe89a011
Revises: 9a71f7625ec9
Create Date: 2025-02-16 16:50:35.801378
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '6477fe89a011'
down_revision: Union[str, None] = '9a71f7625ec9'
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('prowlarrsource', schema=None) as batch_op:
batch_op.add_column(sa.Column('publish_date', sa.DateTime(), nullable=False))
batch_op.drop_column('publishDate')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('prowlarrsource', schema=None) as batch_op:
batch_op.add_column(sa.Column('publishDate', sa.DATETIME(), nullable=False))
batch_op.drop_column('publish_date')
# ### end Alembic commands ###

View File

@@ -0,0 +1,50 @@
"""add sources & indexers
Revision ID: 9a71f7625ec9
Revises: cafe562e2832
Create Date: 2025-02-16 16:47:52.131857
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '9a71f7625ec9'
down_revision: Union[str, None] = 'cafe562e2832'
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('indexer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('privacy', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('prowlarrsource',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('guid', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('indexer_id', sa.Integer(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('seeders', sa.Integer(), nullable=False),
sa.Column('leechers', sa.Integer(), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('publishDate', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['indexer_id'], ['indexer.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('prowlarrsource')
op.drop_table('indexer')
# ### end Alembic commands ###

View File

@@ -5,12 +5,13 @@ from sqlalchemy import func
from sqlmodel import select
from app.db import get_session
from app.models import User
from app.routers import root, search
from app.routers import root, search, wishlist
app = FastAPI()
app.include_router(root.router)
app.include_router(search.router)
app.include_router(wishlist.router)
user_exists = False

View File

@@ -1,4 +1,6 @@
# pyright: reportUnknownVariableType=false
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
@@ -18,6 +20,9 @@ class User(BaseModel, table=True):
admin: Can approve or deny requests, change settings, etc.
"""
def is_admin(self):
return self.group == "admin"
class BookRequest(BaseModel, table=True):
asin: str = Field(primary_key=True)
@@ -26,8 +31,26 @@ class BookRequest(BaseModel, table=True):
)
class Indexer(BaseModel):
id: int
# TODO: do we even need this?
class ProwlarrSource(BaseModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
"""
ProwlarrSources are not unique by their guid. We could have multiple books all in the same source.
https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/
"""
guid: str
indexer_id: int = Field(
foreign_key="indexer.id", nullable=False, ondelete="CASCADE"
)
title: str
seeders: int
leechers: int
size: int # in bytes
publish_date: datetime = Field(default_factory=datetime.now)
class Indexer(BaseModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
enabled: bool
privacy: str

View File

@@ -8,7 +8,7 @@ from sqlmodel import Session
from app.db import get_session
from app.models import User
from app.util.auth import create_user, get_user
from app.util.auth import create_user, get_authenticated_user
router = APIRouter()
@@ -21,7 +21,10 @@ def read_globals_css():
@router.get("/")
def read_root(request: Request, user: Annotated[User, Depends(get_user)]):
def read_root(
request: Request,
user: Annotated[User, Depends(get_authenticated_user())],
):
return templates.TemplateResponse(
"root.html", {"request": request, "username": user.username}
)

View File

@@ -8,7 +8,7 @@ from sqlmodel import Session, col, select
from app.db import get_session
from app.models import BookRequest, User
from app.util.auth import get_user
from app.util.auth import get_authenticated_user
from app.util.book_search import (
list_audible_books,
audible_regions,
@@ -25,7 +25,7 @@ templates = Jinja2Blocks(directory="templates")
@router.get("")
async def read_search(
request: Request,
user: Annotated[User, Depends(get_user)],
user: Annotated[User, Depends(get_authenticated_user())],
client_session: Annotated[ClientSession, Depends(get_connection)],
session: Annotated[Session, Depends(get_session)],
q: Optional[str] = None,
@@ -77,7 +77,7 @@ async def read_search(
@router.post("/request", status_code=201)
async def add_request(
asin: str,
user: Annotated[User, Depends(get_user)],
user: Annotated[User, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
):
req = BookRequest(
@@ -94,7 +94,7 @@ async def add_request(
@router.delete("/request", status_code=204)
async def delete_request(
asin: str,
user: Annotated[User, Depends(get_user)],
user: Annotated[User, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
):
book = session.exec(

126
app/routers/wishlist.py Normal file
View File

@@ -0,0 +1,126 @@
import asyncio
from typing import Annotated
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlalchemy import func
from sqlmodel import Session, col, select
from app.db import get_session
from app.models import BookRequest, Indexer, User
from app.util.auth import get_authenticated_user
from app.util.book_search import get_audnexus_book
from app.util.connection import get_connection
from app.util.prowlarr import get_indexers, query_prowlarr, start_download
router = APIRouter(prefix="/wishlist")
templates = Jinja2Blocks(directory="templates")
@router.get("")
async def wishlist(
request: Request,
user: Annotated[User, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
book_requests = session.exec(
select(
BookRequest.asin, func.count(col(BookRequest.user_username)).label("count")
)
.select_from(BookRequest)
.group_by(BookRequest.asin)
).all()
async def get_book(asin: str, count: int):
book = await get_audnexus_book(client_session, asin)
if book:
book.amount_requested = count
return book
coros = [get_book(asin, count) for (asin, count) in book_requests]
books = [b for b in await asyncio.gather(*coros) if b]
return templates.TemplateResponse(
"wishlist.html",
{"request": request, "books": books, "is_admin": user.is_admin()},
)
@router.post("")
async def refresh_request(
user: Annotated[User, Depends(get_authenticated_user("trusted"))],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
return {"message": "Refreshed"}
@router.get("/sources/{asin}")
async def list_sources(
request: Request,
asin: str,
admin_user: Annotated[User, Depends(get_authenticated_user("admin"))],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
book = session.exec(select(BookRequest).where(BookRequest.asin == asin)).first()
if not book:
raise HTTPException(status_code=404, detail="Book not found")
book = await get_audnexus_book(client_session, asin)
if not book:
raise HTTPException(status_code=500, detail="Book asin error")
query = book.title + " " + " ".join(book.authors)
sources = await query_prowlarr(query)
if len(sources) > 0:
indexers = session.exec(select(Indexer)).all()
indexers = {indexer.id: indexer for indexer in indexers}
if len(indexers) == 0:
indexers = await get_indexers(client_session)
for indexer in indexers.values():
session.add(indexer)
session.commit()
else:
indexers = {}
sources = sorted(
[s for s in sources if s.indexer_id in indexers],
key=lambda x: x.seeders,
reverse=True,
)
return templates.TemplateResponse(
"sources.html",
{
"request": request,
"book": book,
"sources": sources,
"indexers": indexers,
},
)
@router.post("/sources/{asin}")
async def download_book(
asin: str,
guid: str,
indexer_id: int,
admin_user: Annotated[User, Depends(get_authenticated_user("admin"))],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
resp = await start_download(client_session, guid, indexer_id)
if not resp.ok:
raise HTTPException(status_code=500, detail="Failed to start download")
book = session.exec(select(BookRequest).where(BookRequest.asin == asin)).all()
for b in book:
session.delete(b)
session.commit()

View File

@@ -22,33 +22,45 @@ def create_user(
return User(username=username, password=password_hash, group=group)
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()
def get_authenticated_user(
lowest_allowed_group: Literal["admin", "trusted", "untrusted"] = "untrusted",
):
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()
if not user:
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
if not user:
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
try:
ph.verify(user.password, credentials.password)
except VerifyMismatchError:
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
try:
ph.verify(user.password, credentials.password)
except VerifyMismatchError:
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
if ph.check_needs_rehash(user.password):
user.password = ph.hash(credentials.password)
session.add(user)
session.commit()
if ph.check_needs_rehash(user.password):
user.password = ph.hash(credentials.password)
session.add(user)
session.commit()
return user
if lowest_allowed_group == "admin":
if user.group != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
elif lowest_allowed_group == "trusted":
if user.group not in ["admin", "trusted"]:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return get_user

View File

@@ -1,4 +1,5 @@
import asyncio
from datetime import datetime
from typing import Literal, Optional
from urllib.parse import urlencode
from aiohttp import ClientSession
@@ -13,7 +14,14 @@ class BookResult(pydantic.BaseModel):
authors: list[str]
narrators: list[str]
cover_image: Optional[str]
release_date: str
runtime_length_hrs: float
already_requested: bool = False
"""If a book was already requested by a user"""
amount_requested: int = 0
"""How many times a book was requested (wishlist page)"""
@alru_cache(ttl=300)
@@ -32,6 +40,8 @@ async def get_audnexus_book(session: ClientSession, asin: str) -> Optional[BookR
authors=[author["name"] for author in book["authors"]],
narrators=[narrator["name"] for narrator in book["narrators"]],
cover_image=book.get("image"),
release_date=datetime.fromisoformat(book["releaseDate"]).strftime("%B %Y"),
runtime_length_hrs=round(book["runtimeLengthMin"] / 60, 1),
)

View File

@@ -1,38 +1,47 @@
import logging
import os
from datetime import datetime
from typing import Any, Optional
from urllib.parse import quote_plus, urljoin
from urllib.parse import urlencode, urljoin
import aiohttp
from aiohttp import ClientResponse, ClientSession
from async_lru import alru_cache
from app.models import Indexer
from app.models import Indexer, ProwlarrSource
logger = logging.getLogger(__name__)
prowlarr_base_url = os.getenv("PROWLARR_BASE_URL", "")
prowlarr_api_key = os.getenv("PROWLARR_API_KEY", "")
async def start_download(guid: str, indexer_id: int) -> int:
async def start_download(
session: ClientSession, guid: str, indexer_id: int
) -> ClientResponse:
url = prowlarr_base_url + "/api/v1/search"
async with aiohttp.ClientSession() as session:
async with session.post(
url,
json={"guid": guid, "indexer_id": indexer_id},
headers={"X-Api-Key": prowlarr_api_key},
) as response:
return response.status
logger.debug("Starting download for %s", guid)
async with session.post(
url,
json={"guid": guid, "indexerId": indexer_id},
headers={"X-Api-Key": prowlarr_api_key},
) as response:
if not response.ok:
print(response)
logger.error("Failed to start download for %s: %s", guid, response)
else:
logger.debug("Download successfully started for %s", guid)
return response
async def get_indexers() -> dict[int, Indexer]:
async def get_indexers(session: ClientSession) -> dict[int, Indexer]:
url = prowlarr_base_url + "/api/v1/indexer"
async with aiohttp.ClientSession() as session:
async with session.get(
url,
headers={"X-Api-Key": prowlarr_api_key},
) as response:
indexers = await response.json()
async with session.get(
url,
headers={"X-Api-Key": prowlarr_api_key},
) as response:
indexers = await response.json()
return {
i["id"]: Indexer(
@@ -46,22 +55,43 @@ async def get_indexers() -> dict[int, Indexer]:
@alru_cache(ttl=300)
async def query_prowlarr(query: Optional[str]) -> list[dict[Any, Any]]:
async def query_prowlarr(
query: Optional[str], indexer_ids: Optional[list[int]] = None
) -> list[ProwlarrSource]:
if not query:
return []
url = urljoin(
prowlarr_base_url,
f"/api/v1/search?query={quote_plus(query)}&categories=3000&type=search&limit=100&offset=0",
)
params: dict[str, Any] = {
"query": query,
"categories": 3000,
"type": "search",
"limit": 100,
"offset": 0,
}
if indexer_ids is not None:
params["indexerIds"] = indexer_ids
async with aiohttp.ClientSession() as session:
url = urljoin(prowlarr_base_url, f"/api/v1/search?{urlencode(params, doseq=True)}")
logger.info("Querying prowlarr: %s", url)
async with ClientSession() as session:
async with session.get(
url,
headers={"X-Api-Key": prowlarr_api_key},
) as response:
search_results = await response.json()
sources: list[ProwlarrSource] = []
for result in search_results:
result["size"] = round(result["size"] / 1e6, 1)
result["age"] = round(result["age"] / 24, 1)
return search_results
sources.append(
ProwlarrSource(
guid=result["guid"],
indexer_id=result["indexerId"],
title=result["title"],
seeders=result["seeders"],
leechers=result["leechers"],
size=round(result["size"] / 1e6, 1),
publish_date=datetime.fromisoformat(result["publishDate"]),
)
)
return sources

View File

@@ -12,7 +12,7 @@
/>
<link rel="stylesheet" href="/globals.css" />
</head>
<body class="w-screen min-h-screen">
<body class="w-screen min-h-screen overflow-x-hidden">
{% block body %} {% endblock %}
</body>
</html>

View File

@@ -24,8 +24,8 @@ const onPageChange = (page) => {
};
</script>
{% endblock %} {% block body %}
<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">
<div class="w-screen min-h-screen flex items-center justify-center p-8 overflow-x-hidden gap-4">
<div class="flex flex-col gap-4 justify-start items-center">
<form class="flex items-start gap-2 w-full" onsubmit="onSearch();">
<input
name="q"
@@ -48,7 +48,7 @@ const onPageChange = (page) => {
<span id="search-spinner" class="loading" style="display: none"></span>
</button>
</form>
<div
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"
>
@@ -57,12 +57,24 @@ const onPageChange = (page) => {
<div
class="relative w-[10rem] h-[10rem] rounded-md overflow-hidden shadow-md shadow-white"
>
{% if book.cover_image %}
<img
class="object-cover w-full h-full"
src="{{ book.cover_image }}"
alt="{{ book.title }}"
/>
{% else %}
<div class="flex items-center justify-center w-full h-full bg-neutral text-neutral-content opacity-30">
<svg 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="M15 8h.01"></path>
<path d="M7 3h11a3 3 0 0 1 3 3v11m-.856 3.099a2.991 2.991 0 0 1 -2.144 .901h-12a3 3 0 0 1 -3 -3v-12c0 -.845 .349 -1.608 .91 -2.153"></path>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"></path>
<path d="M16.33 12.338c.574 -.054 1.155 .166 1.67 .662l3 3"></path>
<path d="M3 3l18 18"></path>
</svg>
</div>
{% endif %}
<div class="absolute top-0 right-0 bg-black/50 size-6 rounded-bl-md">
<label class="swap swap-flip">
<input
@@ -107,7 +119,7 @@ const onPageChange = (page) => {
</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">
@@ -127,7 +139,7 @@ const onPageChange = (page) => {
<button class="join-item btn" onclick="onPageChange('{{ page+1 }}')">»</button>
</div>
{% endif %}
{% if not search_results %}
<div role="alert" class="alert">
<svg

120
templates/sources.html Normal file
View File

@@ -0,0 +1,120 @@
{% extends "base.html" %} {% block head %}
<title>Sources</title>
<script>
const onDownload = (index, path) => {
const checkbox = document.getElementById(`checkbox-${index}`);
checkbox.disabled = true;
fetch(path, {
method: "POST",
}).then(resp => {
if (resp.ok) {
window.location.href = "/wishlist";
}
});
};
</script>
{% endblock %} {% block body %}
<div class="w-screen p-2 md:p-4 lg:p-8 min-h-screen flex flex-col gap-2">
<h1 class="text-3xl font-bold">Sources for {{ book.title }}</h1>
<tbody>
{% if not sources %}
<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>
<span
>No results found for "{{ book.title }}" by
{{book.authors|join(",")}}. Might have to be looked up
manually.</span
>
</div>
{% endif %} {% for source in sources %}
<div class="overflow-x-auto h-[90vh] rounded-md outline outline-primary">
<table class="table table-zebra table-pin-rows min-w-[60rem]">
<thead>
<tr>
<th></th>
<th>title</th>
<th>indexer</th>
<th>seed / leech</th>
<th>size (MB)</th>
<th>publish date</th>
<th></th>
</tr>
</thead>
<tr class="text-xs lg:text-sm">
<th>{{ loop.index }}</th>
<td>{{ source.title }}</td>
<td>{{ indexers[source.indexer_id].name }}</td>
<td>{{ source.seeders }} / {{ source.leechers }}</td>
<td>{{ source.size }}</td>
<td>{{ source.publish_date.strftime("%d. %b %Y") }}</td>
<td>
<label
id="form-{{ loop.index }}"
class="swap swap-flip"
title="Send to download client"
>
<input
id="checkbox-{{ loop.index }}"
type="checkbox"
onclick="onDownload('{{ loop.index }}','/wishlist/sources/{{ book.asin }}?indexer_id={{ source.indexer_id }}&guid={{ source.guid }}')"
/>
<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="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 11l5 5l5 -5"></path>
<path d="M12 4l0 12"></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>
</div>
</div>
{% endblock %}

View File

@@ -1,69 +1,145 @@
<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>
{% extends "base.html" %} {% block head %}
<title>Wishlist</title>
{% endblock %} {% block body %}
<div class="w-screen p-2 md:p-4 lg:p-8 min-h-screen gap-2">
<h1 class="text-3xl font-bold">Wishlist</h1>
<div class="overflow-x-auto h-[90vh] rounded-md outline outline-primary">
<table class="table table-zebra table-pin-rows min-w-[60rem]">
<thead>
<tr>
<th></th>
<th></th>
<th>Title</th>
<th>Author</th>
<th>Narrator</th>
<th>Release</th>
<th>Length (hrs)</th>
<th># Requested</th>
<th></th>
</tr>
</thead>
<tbody>
{% for book in books %}
<tr class="text-xs lg:text-sm">
<th>{{ loop.index }}</th>
<td>
<div class="size-[4rem] lg:size-[6rem]">
{% if book.cover_image %}
<img
class="object-cover w-full h-full"
src="{{ book.cover_image }}"
alt="{{ book.title }}"
/>
{% else %}
<div
class="flex items-center justify-center w-full h-full bg-neutral text-neutral-content opacity-30"
>
<svg
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="M15 8h.01"></path>
<path
d="M7 3h11a3 3 0 0 1 3 3v11m-.856 3.099a2.991 2.991 0 0 1 -2.144 .901h-12a3 3 0 0 1 -3 -3v-12c0 -.845 .349 -1.608 .91 -2.153"
></path>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"></path>
<path
d="M16.33 12.338c.574 -.054 1.155 .166 1.67 .662l3 3"
></path>
<path d="M3 3l18 18"></path>
</svg>
</div>
{% endif %}
</div>
</td>
<td>
<div class="flex flex-col">
{% if is_admin %}
<a
href="/wishlist/sources/{{ book.asin }}"
class="font-bold text-primary line-clamp-4"
title="{{ book.title }}"
>{{ book.title }}</a
>
{% else %}
<span
class="font-bold text-primary line-clamp-4"
title="{{ book.title }}"
>{{ book.title }}</span
>
{% endif %} {% if book.subtitle %}<span
class="font-semibold line-clamp-4"
title="{{ book.subtitle }}"
>{{ book.subtitle }}</span
>{% endif %}
</div>
</td>
<td>{{ book.authors|join(', ') }}</td>
<td>{{ book.narrators|join(', ') }}</td>
<td class="hidden lg:table-cell">{{ book.release_date }}</td>
<td class="lg:hidden">{{ book.release_date.split(" ")[1] }}</td>
<td>{{ book.runtime_length_hrs }}</td>
<td>{{ book.amount_requested }}</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>
</div>
</div>
{% endblock %}