added search page

This commit is contained in:
Markbeep
2025-02-16 00:20:43 +01:00
parent 180d00d5b1
commit 4ebeeadd52
8 changed files with 252 additions and 2 deletions
+2 -1
View File
@@ -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
+19
View File
@@ -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
+32
View File
@@ -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): ...
+6
View File
@@ -0,0 +1,6 @@
import aiohttp
async def get_connection():
async with aiohttp.ClientSession() as session:
yield session
+67
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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 %}
+124
View File
@@ -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 %}