2 Commits

Author SHA1 Message Date
b20cf0a9d2 Fix cache titles and move feedback link 2026-01-23 19:31:31 +13:00
eab212ea8d Add feedback form and webhook 2026-01-23 19:24:45 +13:00
8 changed files with 232 additions and 20 deletions

View File

@@ -101,5 +101,10 @@ class Settings(BaseSettings):
default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD") default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD")
) )
discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
settings = Settings() settings = Settings()

View File

@@ -500,7 +500,7 @@ def get_cached_requests(
since_iso: Optional[str] = None, since_iso: Optional[str] = None,
) -> list[Dict[str, Any]]: ) -> list[Dict[str, Any]]:
query = """ query = """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, payload_json
FROM requests_cache FROM requests_cache
""" """
params: list[Any] = [] params: list[Any] = []
@@ -525,14 +525,33 @@ def get_cached_requests(
) )
results: list[Dict[str, Any]] = [] results: list[Dict[str, Any]] = []
for row in rows: for row in rows:
title = row[4]
year = row[5]
if (not title or not year) and row[8]:
try:
payload = json.loads(row[8])
if isinstance(payload, dict):
media = payload.get("media") or {}
if not title:
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
if not year:
year = media.get("year") if isinstance(media, dict) else None
year = year or payload.get("year")
except json.JSONDecodeError:
pass
results.append( results.append(
{ {
"request_id": row[0], "request_id": row[0],
"media_id": row[1], "media_id": row[1],
"media_type": row[2], "media_type": row[2],
"status": row[3], "status": row[3],
"title": row[4], "title": title,
"year": row[5], "year": year,
"requested_by": row[6], "requested_by": row[6],
"created_at": row[7], "created_at": row[7],
} }

View File

@@ -17,6 +17,7 @@ from .routers.admin import router as admin_router
from .routers.images import router as images_router from .routers.images import router as images_router
from .routers.branding import router as branding_router from .routers.branding import router as branding_router
from .routers.status import router as status_router from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
@@ -54,3 +55,4 @@ app.include_router(admin_router)
app.include_router(images_router) app.include_router(images_router)
app.include_router(branding_router) app.include_router(branding_router)
app.include_router(status_router) app.include_router(status_router)
app.include_router(feedback_router)

View File

@@ -0,0 +1,38 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = runtime.discord_webhook_url
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")
feedback_type = str(payload.get("type") or "").strip().lower()
if feedback_type not in {"bug", "feature"}:
raise HTTPException(status_code=400, detail="Invalid feedback type")
message = str(payload.get("message") or "").strip()
if not message:
raise HTTPException(status_code=400, detail="Message is required")
if len(message) > 2000:
raise HTTPException(status_code=400, detail="Message is too long")
username = user.get("username") or "unknown"
content = f"**{feedback_type.title()}** from **{username}**\n{message}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(webhook_url, json={"content": content})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"status": "ok"}

View File

@@ -0,0 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type Profile = {
username?: string
}
export default function FeedbackPage() {
const router = useRouter()
const [profile, setProfile] = useState<Profile | null>(null)
const [category, setCategory] = useState('bug')
const [message, setMessage] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
console.error(error)
}
}
void load()
}, [router])
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
if (!message.trim()) {
setStatus('Please write a short message before sending.')
return
}
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: category,
message: message.trim(),
}),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setMessage('')
setStatus('Thanks! Your message has been sent.')
} catch (error) {
console.error(error)
setStatus('That did not send. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<main className="card">
<header className="how-hero">
<p className="eyebrow">Send feedback</p>
<h1>Help us improve Magent</h1>
<p className="lede">
Found a problem or have an idea? Send it here and we will see it right away.
</p>
</header>
<form className="auth-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
<label htmlFor="feedback-type">What is this about?</label>
<select
id="feedback-type"
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="bug">Bug (something is broken)</option>
<option value="feature">Feature idea (new option)</option>
</select>
<label htmlFor="feedback-message">Tell us what happened</label>
<textarea
id="feedback-message"
rows={6}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the details here..."
/>
{status && <div className="status-banner">{status}</div>}
<button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send feedback'}
</button>
</form>
</main>
)
}

View File

@@ -108,7 +108,7 @@ body {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 2 / 3; grid-row: 2 / 3;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-start;
} }
.brand { .brand {
@@ -130,6 +130,7 @@ body {
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%;
} }
.header-actions a { .header-actions a {
@@ -162,6 +163,18 @@ body {
text-align: center; text-align: center;
} }
.header-actions .header-cta {
background: linear-gradient(120deg, rgba(255, 107, 43, 0.95), rgba(255, 168, 75, 0.95));
color: #151515;
border: 1px solid rgba(255, 140, 60, 0.7);
box-shadow: 0 12px 24px rgba(255, 107, 43, 0.35);
font-weight: 700;
}
.header-actions .header-cta--left {
margin-right: auto;
}
.signed-in { .signed-in {
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
@@ -332,6 +345,17 @@ select option {
color: var(--ink); color: var(--ink);
} }
textarea {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--border);
font-size: 16px;
background: var(--input-bg);
color: var(--input-ink);
font-family: inherit;
resize: vertical;
}
button { button {
padding: 12px 18px; padding: 12px 18px;
border-radius: 999px; border-radius: 999px;

View File

@@ -7,8 +7,8 @@ export default function HowItWorksPage() {
<p className="eyebrow">How this works</p> <p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1> <h1>Your request, step by step</h1>
<p className="lede"> <p className="lede">
Think of Magent as a status tracker. It checks a few helper apps that do different jobs, Magent is a friendly status checker. It looks at a few helper apps, then shows you where
then tells you where your request is stuck and what you can safely try next. your request is and what you can safely do next.
</p> </p>
</header> </header>
@@ -17,32 +17,36 @@ export default function HowItWorksPage() {
<h2>Jellyseerr</h2> <h2>Jellyseerr</h2>
<p className="how-title">The request box</p> <p className="how-title">The request box</p>
<p> <p>
This is where you ask for a movie or show. It records your request and keeps track of This is where you ask for a movie or show. It keeps the request and whether it is
approvals. approved.
</p> </p>
</article> </article>
<article className="how-card"> <article className="how-card">
<h2>Sonarr / Radarr</h2> <h2>Sonarr / Radarr</h2>
<p className="how-title">The librarian</p> <p className="how-title">The library manager</p>
<p> <p>
These apps add the item to the library, decide what quality to grab, and look for the These add the request to the library list and decide what quality to look for.
files that match your request.
</p> </p>
</article> </article>
<article className="how-card"> <article className="how-card">
<h2>Prowlarr</h2> <h2>Prowlarr</h2>
<p className="how-title">The search helper</p> <p className="how-title">The search helper</p>
<p> <p>
This one checks your torrent sources and reports back what it found, or if nothing is This checks your search sources and reports back what it finds.
available yet.
</p> </p>
</article> </article>
<article className="how-card"> <article className="how-card">
<h2>qBittorrent</h2> <h2>qBittorrent</h2>
<p className="how-title">The downloader</p> <p className="how-title">The downloader</p>
<p> <p>
If a file is found, this app downloads it. Magent can tell if it is actively This downloads the file. Magent can tell if it is downloading, paused, or finished.
downloading, stalled, or finished. </p>
</article>
<article className="how-card">
<h2>Jellyfin</h2>
<p className="how-title">The place you watch</p>
<p>
When the file is ready, Jellyfin shows it in your library so you can watch it.
</p> </p>
</article> </article>
</section> </section>
@@ -54,13 +58,13 @@ export default function HowItWorksPage() {
<strong>You request a title</strong> in Jellyseerr. <strong>You request a title</strong> in Jellyseerr.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr adds it</strong> to the library list and asks Prowlarr to search. <strong>Sonarr/Radarr adds it</strong> to the library list.
</li> </li>
<li> <li>
<strong>Prowlarr looks for sources</strong> and sends results back. <strong>Prowlarr looks for sources</strong> and sends results back.
</li> </li>
<li> <li>
<strong>qBittorrent downloads</strong> the best match. <strong>qBittorrent downloads</strong> the match.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr imports</strong> it into your library. <strong>Sonarr/Radarr imports</strong> it into your library.
@@ -72,11 +76,10 @@ export default function HowItWorksPage() {
</section> </section>
<section className="how-callout"> <section className="how-callout">
<h2>Why Magent sometimes says "waiting"</h2> <h2>Why Magent sometimes says waiting</h2>
<p> <p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab. If the search helper cannot find a match yet, Magent will say there is nothing to grab.
This does not mean something is broken. It usually means the release is not available That does not mean it is broken. It usually means the release is not available yet.
yet or your search sources do not have it.
</p> </p>
</section> </section>
</main> </main>

View File

@@ -46,6 +46,7 @@ export default function HeaderActions() {
return ( return (
<div className="header-actions"> <div className="header-actions">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a> <a href="/">Requests</a>
<a href="/how-it-works">How it works</a> <a href="/how-it-works">How it works</a>
<a href="/profile">My profile</a> <a href="/profile">My profile</a>