mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2026-05-07 00:39:24 -05:00
added search page
This commit is contained in:
+2
-1
@@ -5,11 +5,12 @@ from sqlalchemy import func
|
||||
from sqlmodel import select
|
||||
from app.db import get_session
|
||||
from app.models import User
|
||||
from app.routers import root
|
||||
from app.routers import root, search
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(root.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
user_exists = False
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# pyright: reportUnknownVariableType=false
|
||||
from datetime import datetime
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
@@ -9,3 +10,21 @@ class BaseModel(SQLModel):
|
||||
class User(BaseModel, table=True):
|
||||
username: str = Field(primary_key=True)
|
||||
password: str
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Indexer(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
enabled: bool
|
||||
privacy: str
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from app.util.prowlarr import query_prowlarr
|
||||
|
||||
|
||||
router = APIRouter(prefix="/search")
|
||||
|
||||
templates = Jinja2Blocks(directory="templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def read_search(
|
||||
request: Request,
|
||||
q: Optional[str] = None,
|
||||
):
|
||||
search_results = await query_prowlarr(q)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"search.html",
|
||||
{"request": request, "search_term": q or "", "search_results": search_results},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/request")
|
||||
async def add_request(request: Request, guid: str): ...
|
||||
|
||||
|
||||
@router.delete("/request")
|
||||
async def delete_request(request: Request, guid: str): ...
|
||||
@@ -0,0 +1,6 @@
|
||||
import aiohttp
|
||||
|
||||
|
||||
async def get_connection():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
import aiohttp
|
||||
from async_lru import alru_cache
|
||||
|
||||
from app.models import Indexer
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
async def get_indexers() -> 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()
|
||||
|
||||
return {
|
||||
i["id"]: Indexer(
|
||||
id=i["id"],
|
||||
name=i["name"],
|
||||
enabled=i["enable"],
|
||||
privacy=i["privacy"],
|
||||
)
|
||||
for i in indexers
|
||||
}
|
||||
|
||||
|
||||
@alru_cache(ttl=300)
|
||||
async def query_prowlarr(query: Optional[str]) -> list[dict[Any, Any]]:
|
||||
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",
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url,
|
||||
headers={"X-Api-Key": prowlarr_api_key},
|
||||
) as response:
|
||||
search_results = await response.json()
|
||||
|
||||
for result in search_results:
|
||||
result["size"] = round(result["size"] / 1e6, 1)
|
||||
result["age"] = round(result["age"] / 24, 1)
|
||||
return search_results
|
||||
@@ -47,3 +47,4 @@ alembic==1.14.1
|
||||
jinja2-fragments==1.7.0
|
||||
pytailwindcss==0.2.0
|
||||
argon2-cffi==23.1.0
|
||||
async-lru==2.0.4
|
||||
|
||||
+1
-1
@@ -5,12 +5,12 @@
|
||||
{% block head %}
|
||||
<title>AudioBookRequest</title>
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" href="globals.css" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<link rel="stylesheet" href="/globals.css" />
|
||||
</head>
|
||||
<body class="w-screen min-h-screen">
|
||||
{% block body %} {% endblock %}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %} {% block head %}
|
||||
<title>Search</title>
|
||||
<script>
|
||||
const onSearch = () => {
|
||||
const search_term = document.querySelector("input").value;
|
||||
document.getElementById("search").disabled = true;
|
||||
document.getElementById("search-text").style.display = "none";
|
||||
document.getElementById("search-spinner").style.display = "inline-block";
|
||||
window.location.href = `/search?q=${encodeURIComponent(search_term)}`;
|
||||
};
|
||||
const onRequest = (index, guid, indexerId) => {
|
||||
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",
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
</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">
|
||||
<input
|
||||
class="input input-bordered"
|
||||
placeholder="Book name..."
|
||||
value="{{ search_term }}"
|
||||
/>
|
||||
<button id="search" class="btn btn-primary" onclick="onSearch();">
|
||||
<span id="search-text">Search</span>
|
||||
<span id="search-spinner" class="loading" style="display: none"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-w-[80vw] overflow-y-scroll outline outline-primary/50 rounded-lg"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user