16 Commits

27 changed files with 702 additions and 188 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.env
*.log
data/*
!data/branding/
!data/branding/**
frontend/node_modules/
frontend/.next/
backend/__pycache__/
**/__pycache__/
**/*.pyc

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
.env .env
.venv/ .venv/
data/ data/
!data/branding/
!data/branding/**
backend/__pycache__/ backend/__pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc

122
README.md
View File

@@ -1,8 +1,89 @@
# Magent # Magent
AI-powered request timeline for Jellyseerr + Arr stack. Magent is a friendly, AI-assisted request tracker for Jellyseerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
## Backend (FastAPI) ## How it works
1) Requests are pulled from Jellyseerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
5) Optional AI triage summarizes the likely cause and safest next steps.
## Core features
- Request search by title/year or request ID.
- Recent requests list with posters and status.
- Timeline view across Jellyseerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders.
- Health status for each service in the pipeline.
- Cache and sync controls (full sync, delta sync, scheduled syncs).
- Local database for speed and audit history.
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Docker-first deployment for easy hosting.
## Quick start (Docker - primary)
Docker is the recommended way to run Magent. It includes the backend and frontend with sane defaults.
```bash
docker compose up --build
```
Then open:
- Frontend: http://localhost:3000
- Backend: http://localhost:8000
### Docker setup steps
1) Create `.env` with your service URLs and API keys.
2) Run `docker compose up --build`.
3) Log in at http://localhost:3000.
4) Visit Settings to confirm service health.
### Docker environment variables (sample)
```bash
JELLYSEERR_URL="http://localhost:5055"
JELLYSEERR_API_KEY="..."
SONARR_URL="http://localhost:8989"
SONARR_API_KEY="..."
SONARR_QUALITY_PROFILE_ID="1"
SONARR_ROOT_FOLDER="/tv"
RADARR_URL="http://localhost:7878"
RADARR_API_KEY="..."
RADARR_QUALITY_PROFILE_ID="1"
RADARR_ROOT_FOLDER="/movies"
PROWLARR_URL="http://localhost:9696"
PROWLARR_API_KEY="..."
QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="change-me"
JWT_EXP_MINUTES="720"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="adminadmin"
```
## Screenshots
Add screenshots here once available:
- `docs/screenshots/home.png`
- `docs/screenshots/request-timeline.png`
- `docs/screenshots/settings.png`
- `docs/screenshots/profile.png`
## Local development (secondary)
Use this only when you need to modify code locally.
### Backend (FastAPI)
```bash ```bash
cd backend cd backend
@@ -37,7 +118,7 @@ $env:ADMIN_USERNAME="admin"
$env:ADMIN_PASSWORD="adminadmin" $env:ADMIN_PASSWORD="adminadmin"
``` ```
## Frontend (Next.js) ### Frontend (Next.js)
```bash ```bash
cd frontend cd frontend
@@ -49,20 +130,11 @@ Open http://localhost:3000
Admin panel: http://localhost:3000/admin Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other user you create in SQLite). Login uses the admin credentials above (or any other local user you create in SQLite).
## Docker (Testing)
```bash
docker compose up --build
```
Backend: http://localhost:8000
Frontend: http://localhost:3000
## Public Hosting Notes ## Public Hosting Notes
The frontend now proxies `/api/*` to the backend container. Set: The frontend proxies `/api/*` to the backend container. Set:
- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin) - `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container) - `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
@@ -73,3 +145,25 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
- `GET /requests/{id}/history?limit=10` recent snapshots - `GET /requests/{id}/history?limit=10` recent snapshots
- `GET /requests/{id}/actions?limit=10` recent action logs - `GET /requests/{id}/actions?limit=10` recent action logs
## Troubleshooting
### Login fails
- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
### Services show as down
- Check the URLs and API keys in Settings.
- Verify containers can reach each service (network/DNS).
### No recent requests
- Confirm Jellyseerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
- Run `docker compose up --build` again.
- If needed, run `docker compose down` first, then rebuild.

View File

@@ -5,10 +5,11 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
COPY requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY backend/app ./app
COPY data/branding /app/data/branding
EXPOSE 8000 EXPOSE 8000

View File

@@ -56,6 +56,9 @@ class QBittorrentClient(ApiClient):
async def get_torrents_by_hashes(self, hashes: str) -> Optional[Any]: async def get_torrents_by_hashes(self, hashes: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"hashes": hashes}) return await self._get("/api/v2/torrents/info", params={"hashes": hashes})
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"category": category})
async def get_app_version(self) -> Optional[Any]: async def get_app_version(self) -> Optional[Any]:
return await self._get_text("/api/v2/app/version") return await self._get_text("/api/v2/app/version")
@@ -67,3 +70,9 @@ class QBittorrentClient(ApiClient):
await self._post_form("/api/v2/torrents/start", data={"hashes": hashes}) await self._post_form("/api/v2/torrents/start", data={"hashes": hashes})
return return
raise raise
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
data: Dict[str, Any] = {"urls": url}
if category:
data["category"] = category
await self._post_form("/api/v2/torrents/add", data=data)

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

@@ -458,7 +458,7 @@ def get_request_cache_by_id(request_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT request_id, updated_at SELECT request_id, updated_at, title
FROM requests_cache FROM requests_cache
WHERE request_id = ? WHERE request_id = ?
""", """,
@@ -468,7 +468,7 @@ def get_request_cache_by_id(request_id: int) -> Optional[Dict[str, Any]]:
logger.debug("requests_cache miss: request_id=%s", request_id) logger.debug("requests_cache miss: request_id=%s", request_id)
return None return None
logger.debug("requests_cache hit: request_id=%s updated_at=%s", row[0], row[1]) logger.debug("requests_cache hit: request_id=%s updated_at=%s", row[0], row[1])
return {"request_id": row[0], "updated_at": row[1]} return {"request_id": row[0], "updated_at": row[1], "title": row[2]}
def get_request_cache_payload(request_id: int) -> Optional[Dict[str, Any]]: def get_request_cache_payload(request_id: int) -> Optional[Dict[str, Any]]:
@@ -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],
} }
@@ -545,7 +564,7 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, updated_at SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, updated_at, payload_json
FROM requests_cache FROM requests_cache
ORDER BY updated_at DESC, request_id DESC ORDER BY updated_at DESC, request_id DESC
LIMIT ? LIMIT ?
@@ -554,13 +573,27 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
).fetchall() ).fetchall()
results: list[Dict[str, Any]] = [] results: list[Dict[str, Any]] = []
for row in rows: for row in rows:
title = row[4]
if not title and row[9]:
try:
payload = json.loads(row[9])
if isinstance(payload, dict):
media = payload.get("media") or {}
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")
)
except json.JSONDecodeError:
title = row[4]
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": row[5],
"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

@@ -4,7 +4,7 @@ from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from PIL import Image from PIL import Image, ImageDraw, ImageFont
router = APIRouter(prefix="/branding", tags=["branding"]) router = APIRouter(prefix="/branding", tags=["branding"])
@@ -23,8 +23,52 @@ def _resize_image(image: Image.Image, max_size: int = 300) -> Image.Image:
return image return image
def _load_font(size: int) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
font = _load_font(160)
text = "M"
box = draw.textbbox((0, 0), text, font=font)
text_w = box[2] - box[0]
text_h = box[3] - box[1]
draw.text(
((300 - text_w) / 2, (300 - text_h) / 2 - 6),
text,
font=font,
fill=(255, 255, 255, 255),
)
image.save(_LOGO_PATH, format="PNG")
if not os.path.exists(_FAVICON_PATH):
favicon = Image.open(_LOGO_PATH).copy()
favicon.thumbnail((64, 64))
try:
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
except OSError:
favicon.save(_FAVICON_PATH, format="ICO")
@router.get("/logo.png") @router.get("/logo.png")
async def branding_logo() -> FileResponse: async def branding_logo() -> FileResponse:
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH): if not os.path.exists(_LOGO_PATH):
raise HTTPException(status_code=404, detail="Logo not found") raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "public, max-age=300"} headers = {"Cache-Control": "public, max-age=300"}
@@ -33,6 +77,8 @@ async def branding_logo() -> FileResponse:
@router.get("/favicon.ico") @router.get("/favicon.ico")
async def branding_favicon() -> FileResponse: async def branding_favicon() -> FileResponse:
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH): if not os.path.exists(_FAVICON_PATH):
raise HTTPException(status_code=404, detail="Favicon not found") raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "public, max-age=300"} headers = {"Cache-Control": "public, max-age=300"}

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

@@ -9,6 +9,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -265,6 +266,16 @@ async def _hydrate_title_from_tmdb(
return None, None return None, None
async def _hydrate_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_id:
return None
try:
details = await client.get_media(int(media_id))
except httpx.HTTPStatusError:
return None
return details if isinstance(details, dict) else None
async def _hydrate_artwork_from_tmdb( async def _hydrate_artwork_from_tmdb(
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int] client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
) -> tuple[Optional[str], Optional[str]]: ) -> tuple[Optional[str], Optional[str]]:
@@ -389,6 +400,28 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if not payload.get("title") and payload.get("media_id"):
media_details = await _hydrate_media_details(client, payload.get("media_id"))
if isinstance(media_details, dict):
media_title = media_details.get("title") or media_details.get("name")
if media_title:
payload["title"] = media_title
if not payload.get("year") and media_details.get("year"):
payload["year"] = media_details.get("year")
if not payload.get("tmdb_id") and media_details.get("tmdbId"):
payload["tmdb_id"] = media_details.get("tmdbId")
if not payload.get("media_type") and media_details.get("mediaType"):
payload["media_type"] = media_details.get("mediaType")
if isinstance(item, dict):
existing_media = item.get("media")
if isinstance(existing_media, dict):
merged = dict(media_details)
for key, value in existing_media.items():
if value is not None:
merged[key] = value
item["media"] = merged
else:
item["media"] = media_details
poster_path, backdrop_path = _extract_artwork_paths(item) poster_path, backdrop_path = _extract_artwork_paths(item)
if cache_mode == "cache" and not (poster_path or backdrop_path): if cache_mode == "cache" and not (poster_path or backdrop_path):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
@@ -483,13 +516,35 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(request_id, int): if isinstance(request_id, int):
cached = get_request_cache_by_id(request_id) cached = get_request_cache_by_id(request_id)
incoming_updated = payload.get("updated_at") incoming_updated = payload.get("updated_at")
if cached and incoming_updated and cached.get("updated_at") == incoming_updated: if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
continue continue
if not payload.get("title") or not payload.get("media_id"): if not payload.get("title") or not payload.get("media_id"):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if not payload.get("title") and payload.get("media_id"):
media_details = await _hydrate_media_details(client, payload.get("media_id"))
if isinstance(media_details, dict):
media_title = media_details.get("title") or media_details.get("name")
if media_title:
payload["title"] = media_title
if not payload.get("year") and media_details.get("year"):
payload["year"] = media_details.get("year")
if not payload.get("tmdb_id") and media_details.get("tmdbId"):
payload["tmdb_id"] = media_details.get("tmdbId")
if not payload.get("media_type") and media_details.get("mediaType"):
payload["media_type"] = media_details.get("mediaType")
if isinstance(item, dict):
existing_media = item.get("media")
if isinstance(existing_media, dict):
merged = dict(media_details)
for key, value in existing_media.items():
if value is not None:
merged[key] = value
item["media"] = merged
else:
item["media"] = media_details
poster_path, backdrop_path = _extract_artwork_paths(item) poster_path, backdrop_path = _extract_artwork_paths(item)
if cache_mode == "cache" and not (poster_path or backdrop_path): if cache_mode == "cache" and not (poster_path or backdrop_path):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
@@ -1047,6 +1102,38 @@ async def recent_requests(
allow_remote = mode == "always_js" allow_remote = mode == "always_js"
allow_title_hydrate = mode == "prefer_cache" allow_title_hydrate = mode == "prefer_cache"
allow_artwork_hydrate = allow_remote or allow_title_hydrate allow_artwork_hydrate = allow_remote or allow_title_hydrate
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
async def _jellyfin_available(
title_value: Optional[str], year_value: Optional[int], media_type_value: Optional[str]
) -> bool:
if not jellyfin.configured() or not title_value:
return False
cache_key = f"{media_type_value or ''}:{title_value.lower()}:{year_value or ''}"
cached_value = jellyfin_cache.get(cache_key)
if cached_value is not None:
return cached_value
types = ["Movie"] if media_type_value == "movie" else ["Series"]
try:
search = await jellyfin.search_items(title_value, types)
except Exception:
jellyfin_cache[cache_key] = False
return False
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
for item in items:
if not isinstance(item, dict):
continue
name = item.get("Name") or item.get("title")
year = item.get("ProductionYear") or item.get("Year")
if name and name.strip().lower() == title_value.strip().lower():
if year_value and year and int(year) != int(year_value):
continue
jellyfin_cache[cache_key] = True
return True
jellyfin_cache[cache_key] = False
return False
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")
@@ -1138,6 +1225,11 @@ async def recent_requests(
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True), payload_json=json.dumps(details, ensure_ascii=True),
) )
status_label = _status_label(status)
if status_label == "Working on it":
is_available = await _jellyfin_available(title, year, row.get("media_type"))
if is_available:
status_label = "Available"
results.append( results.append(
{ {
"id": row.get("request_id"), "id": row.get("request_id"),
@@ -1145,7 +1237,7 @@ async def recent_requests(
"year": year, "year": year,
"type": row.get("media_type"), "type": row.get("media_type"),
"status": status, "status": status,
"statusLabel": _status_label(status), "statusLabel": status_label,
"mediaId": row.get("media_id"), "mediaId": row.get("media_id"),
"artwork": { "artwork": {
"poster_url": _artwork_url(poster_path, "w185", cache_mode), "poster_url": _artwork_url(poster_path, "w185", cache_mode),
@@ -1248,13 +1340,10 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
if client.configured(): if client.configured():
await _ensure_request_access(client, int(request_id), user) await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
prowlarr_results: List[Dict[str, Any]] = [] prowlarr_results: List[Dict[str, Any]] = []
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
if prowlarr.configured(): if not prowlarr.configured():
raise HTTPException(status_code=400, detail="Prowlarr not configured")
query = snapshot.title query = snapshot.title
if snapshot.year: if snapshot.year:
query = f"{query} {snapshot.year}" query = f"{query} {snapshot.year}"
@@ -1264,6 +1353,28 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
except httpx.HTTPStatusError: except httpx.HTTPStatusError:
prowlarr_results = [] prowlarr_results = []
await asyncio.to_thread(
save_action,
request_id,
"search_releases",
"Search and choose a download",
"ok",
f"Found {len(prowlarr_results)} releases.",
)
return {"status": "ok", "releases": prowlarr_results}
@router.post("/{request_id}/actions/search_auto")
async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
if snapshot.request_type.value == "tv": if snapshot.request_type.value == "tv":
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured(): if not client.configured():
@@ -1271,12 +1382,11 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
episodes = await client.get_episodes(int(arr_item["id"])) episodes = await client.get_episodes(int(arr_item["id"]))
missing_by_season = _missing_episode_ids_by_season(episodes) missing_by_season = _missing_episode_ids_by_season(episodes)
if not missing_by_season: if not missing_by_season:
return { message = "No missing monitored episodes found."
"status": "ok", await asyncio.to_thread(
"message": "No missing monitored episodes found", save_action, request_id, "search_auto", "Search and auto-download", "ok", message
"searched": [], )
"releases": prowlarr_results, return {"status": "ok", "message": message, "searched": []}
}
responses = [] responses = []
for season_number in sorted(missing_by_season.keys()): for season_number in sorted(missing_by_season.keys()):
episode_ids = missing_by_season[season_number] episode_ids = missing_by_season[season_number]
@@ -1285,32 +1395,22 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
responses.append( responses.append(
{"season": season_number, "episodeCount": len(episode_ids), "response": response} {"season": season_number, "episodeCount": len(episode_ids), "response": response}
) )
result = {"status": "ok", "searched": responses, "releases": prowlarr_results} message = "Search sent to Sonarr."
await asyncio.to_thread( await asyncio.to_thread(
save_action, save_action, request_id, "search_auto", "Search and auto-download", "ok", message
request_id,
"search",
"Re-run search in Sonarr/Radarr",
"ok",
f"Found {len(prowlarr_results)} releases.",
) )
return result return {"status": "ok", "message": message, "searched": responses}
elif snapshot.request_type.value == "movie": if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured") raise HTTPException(status_code=400, detail="Radarr not configured")
response = await client.search(int(arr_item["id"])) response = await client.search(int(arr_item["id"]))
result = {"status": "ok", "response": response, "releases": prowlarr_results} message = "Search sent to Radarr."
await asyncio.to_thread( await asyncio.to_thread(
save_action, save_action, request_id, "search_auto", "Search and auto-download", "ok", message
request_id,
"search",
"Re-run search in Sonarr/Radarr",
"ok",
f"Found {len(prowlarr_results)} releases.",
) )
return result return {"status": "ok", "message": message, "response": response}
else:
raise HTTPException(status_code=400, detail="Unknown request type") raise HTTPException(status_code=400, detail="Unknown request type")
@@ -1507,6 +1607,7 @@ async def action_grab(
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
guid = payload.get("guid") guid = payload.get("guid")
indexer_id = payload.get("indexerId") indexer_id = payload.get("indexerId")
download_url = payload.get("downloadUrl")
if not guid or not indexer_id: if not guid or not indexer_id:
raise HTTPException(status_code=400, detail="Missing guid or indexerId") raise HTTPException(status_code=400, detail="Missing guid or indexerId")
@@ -1518,6 +1619,28 @@ async def action_grab(
try: try:
response = await client.grab_release(str(guid), int(indexer_id)) response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else 502
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr." save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr."
@@ -1530,6 +1653,28 @@ async def action_grab(
try: try:
response = await client.grab_release(str(guid), int(indexer_id)) response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else 502
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr." save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr."

View File

@@ -465,9 +465,14 @@ async def build_snapshot(request_id: str) -> Snapshot:
try: try:
download_ids = _download_ids(_queue_records(arr_queue)) download_ids = _download_ids(_queue_records(arr_queue))
torrent_list: List[Dict[str, Any]] = [] torrent_list: List[Dict[str, Any]] = []
if download_ids and qbittorrent.configured(): if qbittorrent.configured():
if download_ids:
torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids)) torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
torrent_list = torrents if isinstance(torrents, list) else [] torrent_list = torrents if isinstance(torrents, list) else []
else:
category = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_category(category)
torrent_list = torrents if isinstance(torrents, list) else []
summary = _summarize_qbit(torrent_list) summary = _summarize_qbit(torrent_list)
qbit_state = summary.get("state") qbit_state = summary.get("state")
qbit_message = summary.get("message") qbit_message = summary.get("message")
@@ -550,8 +555,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
elif arr_item and arr_state != "available": elif arr_item and arr_state != "available":
actions.append( actions.append(
ActionOption( ActionOption(
id="search", id="search_auto",
label="Search again for releases", label="Search and auto-download",
risk="low",
)
)
actions.append(
ActionOption(
id="search_releases",
label="Search and choose a download",
risk="low", risk="low",
) )
) )

BIN
data/branding/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
data/branding/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

19
docker-compose.hub.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
backend:
image: rephl3xnz/magent-backend:latest
env_file:
- ./.env
ports:
- "8000:8000"
volumes:
- ./data:/app/data
frontend:
image: rephl3xnz/magent-frontend:latest
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

View File

@@ -1,8 +1,8 @@
services: services:
backend: backend:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: backend/Dockerfile
env_file: env_file:
- ./.env - ./.env
ports: ports:

View File

@@ -8,6 +8,7 @@ COPY package.json ./
RUN npm install RUN npm install
COPY app ./app COPY app ./app
COPY public ./public
COPY next-env.d.ts ./next-env.d.ts COPY next-env.d.ts ./next-env.d.ts
COPY next.config.js ./next.config.js COPY next.config.js ./next.config.js
COPY tsconfig.json ./tsconfig.json COPY tsconfig.json ./tsconfig.json
@@ -22,6 +23,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js COPY --from=builder /app/next.config.js ./next.config.js

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

@@ -34,6 +34,7 @@ type ReleaseOption = {
leechers?: number leechers?: number
protocol?: string protocol?: string
infoUrl?: string infoUrl?: string
downloadUrl?: string
} }
type SnapshotHistory = { type SnapshotHistory = {
@@ -484,7 +485,8 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
} }
const baseUrl = getApiBase() const baseUrl = getApiBase()
const actionMap: Record<string, string> = { const actionMap: Record<string, string> = {
search: 'actions/search', search_releases: 'actions/search',
search_auto: 'actions/search_auto',
resume_torrent: 'actions/qbit/resume', resume_torrent: 'actions/qbit/resume',
readd_to_arr: 'actions/readd', readd_to_arr: 'actions/readd',
} }
@@ -493,7 +495,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
setActionMessage('This action is not wired yet.') setActionMessage('This action is not wired yet.')
return return
} }
if (action.id === 'search') { if (action.id === 'search_releases') {
setActionMessage(null) setActionMessage(null)
setReleaseOptions([]) setReleaseOptions([])
setSearchRan(false) setSearchRan(false)
@@ -513,7 +515,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
if (action.id === 'search') { if (action.id === 'search_releases') {
if (Array.isArray(data.releases)) { if (Array.isArray(data.releases)) {
setReleaseOptions(data.releases) setReleaseOptions(data.releases)
} }
@@ -526,6 +528,10 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
setModalMessage('Search complete. Pick an option below if you want to download.') setModalMessage('Search complete. Pick an option below if you want to download.')
} }
setActionMessage(`${action.label} started.`) setActionMessage(`${action.label} started.`)
} else if (action.id === 'search_auto') {
const message = data?.message ?? 'Search sent to Sonarr/Radarr.'
setActionMessage(message)
setModalMessage(message)
} else { } else {
const message = data?.message ?? `${action.label} started.` const message = data?.message ?? `${action.label} started.`
setActionMessage(message) setActionMessage(message)
@@ -565,6 +571,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
<span>{release.seeders ?? 0} seeders · {formatBytes(release.size)}</span> <span>{release.seeders ?? 0} seeders · {formatBytes(release.size)}</span>
<button <button
type="button" type="button"
disabled={!release.guid || !release.indexerId}
onClick={async () => { onClick={async () => {
if (!snapshot || !release.guid || !release.indexerId) { if (!snapshot || !release.guid || !release.indexerId) {
setActionMessage('Missing details to start the download.') setActionMessage('Missing details to start the download.')
@@ -583,6 +590,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
body: JSON.stringify({ body: JSON.stringify({
guid: release.guid, guid: release.guid,
indexerId: release.indexerId, indexerId: release.indexerId,
downloadUrl: release.downloadUrl,
}), }),
} }
) )

View File

@@ -1,17 +1,10 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
export default function BrandingFavicon() { export default function BrandingFavicon() {
useEffect(() => { useEffect(() => {
const baseUrl = getApiBase() const href = '/api/branding/favicon.ico'
const version =
(typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY)) || ''
const versionSuffix = version ? `?v=${encodeURIComponent(version)}` : ''
const href = `${baseUrl}/branding/favicon.ico${versionSuffix}`
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
if (!link) { if (!link) {
link = document.createElement('link') link = document.createElement('link')

View File

@@ -1,36 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
type BrandingLogoProps = { type BrandingLogoProps = {
className?: string className?: string
alt?: string alt?: string
} }
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) { export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
const baseUrl = getApiBase()
const version =
(typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY)) || ''
const versionSuffix = version ? `?v=${encodeURIComponent(version)}` : ''
setSrc(`${baseUrl}/branding/logo.png${versionSuffix}`)
}, [])
if (!src) {
return null
}
return ( return (
<img <img
className={className} className={className}
src={src} src="/api/branding/logo.png"
alt={alt} alt={alt}
onError={() => setSrc(null)}
/> />
) )
} }

View File

@@ -40,19 +40,20 @@ export default function HeaderActions() {
} }
} }
if (!signedIn) {
return null
}
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>
{signedIn && <a href="/profile">My profile</a>} <a href="/profile">My profile</a>
{role === 'admin' && <a href="/admin">Settings</a>} {role === 'admin' && <a href="/admin">Settings</a>}
{signedIn ? (
<button type="button" className="header-link" onClick={signOut}> <button type="button" className="header-link" onClick={signOut}>
Sign out Sign out
</button> </button>
) : (
<a href="/login">Sign in</a>
)}
</div> </div>
) )
} }

View File

@@ -25,8 +25,6 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [passwordInputs, setPasswordInputs] = useState<Record<string, string>>({})
const [passwordStatus, setPasswordStatus] = useState<Record<string, string>>({})
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -105,42 +103,6 @@ export default function UsersPage() {
} }
} }
const updateUserPassword = async (username: string) => {
const newPassword = passwordInputs[username] || ''
if (!newPassword || newPassword.length < 8) {
setPasswordStatus((current) => ({
...current,
[username]: 'Password must be at least 8 characters.',
}))
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setPasswordInputs((current) => ({ ...current, [username]: '' }))
setPasswordStatus((current) => ({
...current,
[username]: 'Password updated.',
}))
} catch (err) {
console.error(err)
setPasswordStatus((current) => ({
...current,
[username]: 'Could not update password.',
}))
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
@@ -197,27 +159,6 @@ export default function UsersPage() {
{user.isBlocked ? 'Allow access' : 'Block access'} {user.isBlocked ? 'Allow access' : 'Block access'}
</button> </button>
</div> </div>
{user.authProvider === 'local' && (
<div className="user-actions">
<input
type="password"
placeholder="New password (min 8 chars)"
value={passwordInputs[user.username] || ''}
onChange={(event) =>
setPasswordInputs((current) => ({
...current,
[user.username]: event.target.value,
}))
}
/>
<button type="button" onClick={() => updateUserPassword(user.username)}>
Set password
</button>
</div>
)}
{passwordStatus[user.username] && (
<div className="meta">{passwordStatus[user.username]}</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs>
<linearGradient id="magentIconGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b2b"/>
<stop offset="100%" stop-color="#ffa84b"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0e1624"/>
<path
d="M18 50V14h8l6 11 6-11h8v36h-8V32l-6 10-6-10v18h-8z"
fill="url(#magentIconGlow)"
/>
</svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
<defs>
<linearGradient id="magentGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b2b"/>
<stop offset="100%" stop-color="#ffa84b"/>
</linearGradient>
</defs>
<rect width="300" height="300" rx="56" fill="#0e1624"/>
<rect x="24" y="24" width="252" height="252" rx="44" fill="#121d31"/>
<path
d="M80 220V80h28l42 70 42-70h28v140h-28v-88l-42 66-42-66v88H80z"
fill="url(#magentGlow)"
/>
</svg>

After

Width:  |  Height:  |  Size: 537 B