mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2026-01-05 21:20:15 -06:00
wishlist and sources page + download button
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||

|
||||
|
||||
# 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
|
||||
|
||||
37
alembic/versions/6477fe89a011_rename_prowlarr_date_col.py
Normal file
37
alembic/versions/6477fe89a011_rename_prowlarr_date_col.py
Normal 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 ###
|
||||
50
alembic/versions/9a71f7625ec9_add_sources_indexers.py
Normal file
50
alembic/versions/9a71f7625ec9_add_sources_indexers.py
Normal 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 ###
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
126
app/routers/wishlist.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
120
templates/sources.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user