2 Commits

Author SHA1 Message Date
d045dd0b07 Build 0202261541: allow FQDN service URLs 2026-02-02 15:43:08 +13:00
138069590b Build 3001262148: single container 2026-01-30 21:54:25 +13:00
9 changed files with 191 additions and 43 deletions

View File

@@ -1 +1 @@
271261539 0202261541

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
FROM node:20-slim AS frontend-builder
WORKDIR /frontend
ENV NODE_ENV=production \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
COPY frontend/package.json ./
RUN npm install
COPY frontend/app ./app
COPY frontend/public ./public
COPY frontend/next-env.d.ts ./next-env.d.ts
COPY frontend/next.config.js ./next.config.js
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --from=frontend-builder /frontend/public /app/frontend/public
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
EXPOSE 3000 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]

View File

@@ -1,2 +1,2 @@
BUILD_NUMBER = "2901262244" BUILD_NUMBER = "0202261541"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users' CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -1,6 +1,8 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import ipaddress
import os import os
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -64,6 +66,16 @@ SENSITIVE_KEYS = {
"qbittorrent_password", "qbittorrent_password",
} }
URL_SETTING_KEYS = {
"jellyseerr_base_url",
"jellyfin_base_url",
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
}
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"jellyseerr_base_url", "jellyseerr_base_url",
"jellyseerr_api_key", "jellyseerr_api_key",
@@ -107,6 +119,49 @@ def _normalize_username(value: str) -> str:
normalized = normalized.split("@", 1)[0] normalized = normalized.split("@", 1)[0]
return normalized return normalized
def _is_ip_host(host: str) -> bool:
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def _normalize_service_url(value: str) -> str:
raw = value.strip()
if not raw:
raise ValueError("URL cannot be empty.")
candidate = raw
if "://" not in candidate:
authority = candidate.split("/", 1)[0].strip()
if authority.startswith("["):
closing = authority.find("]")
host = authority[1:closing] if closing > 0 else authority.strip("[]")
else:
host = authority.split(":", 1)[0]
host = host.strip().lower()
default_scheme = "http" if host in {"localhost"} or _is_ip_host(host) or "." not in host else "https"
candidate = f"{default_scheme}://{candidate}"
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if not parsed.netloc:
raise ValueError("URL must include a host.")
if parsed.query or parsed.fragment:
raise ValueError("URL must not include query params or fragments.")
if not parsed.hostname:
raise ValueError("URL must include a valid host.")
normalized_path = parsed.path.rstrip("/")
normalized = parsed._replace(path=normalized_path, params="", query="", fragment="")
result = urlunparse(normalized).rstrip("/")
if not result:
raise ValueError("URL is invalid.")
return result
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
if not isinstance(folders, list): if not isinstance(folders, list):
return [] return []
@@ -203,7 +258,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
delete_setting(key) delete_setting(key)
updates += 1 updates += 1
continue continue
set_setting(key, str(value)) value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1 updates += 1
if key in {"log_level", "log_file"}: if key in {"log_level", "log_file"}:
touched_logging = True touched_logging = True

View File

@@ -1,19 +1,10 @@
services: services:
backend: magent:
image: rephl3xnz/magent-backend:latest image: rephl3xnz/magent:latest
env_file: env_file:
- ./.env - ./.env
ports: ports:
- "3000:3000"
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./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,25 +1,12 @@
services: services:
backend: magent:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: Dockerfile
args:
BUILD_NUMBER: ${BUILD_NUMBER}
env_file: env_file:
- ./.env - ./.env
ports: ports:
- "3000:3000"
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

28
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[program:backend]
directory=/app
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
[program:frontend]
directory=/app/frontend
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20

View File

@@ -34,6 +34,15 @@ const SECTION_LABELS: Record<string, string> = {
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const URL_SETTINGS = new Set([
'jellyseerr_base_url',
'jellyfin_base_url',
'jellyfin_public_url',
'sonarr_base_url',
'radarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
@@ -330,26 +339,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
const settingDescriptions: Record<string, string> = { const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.', jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.', jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.', jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.', jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.', jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.', jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.', artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking.', sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.', sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.', sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.', radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.', radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.', radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.', radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.', radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.', prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.', prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.', qbittorrent_base_url:
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.', qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.', qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
@@ -371,6 +385,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_changelog: 'One update per line for the public changelog.', site_changelog: 'One update per line for the public changelog.',
} }
const settingPlaceholders: Record<string, string> = {
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
}
const buildSelectOptions = ( const buildSelectOptions = (
currentValue: string, currentValue: string,
options: { id: number; label: string; path?: string }[], options: { id: number; label: string; path?: string }[],
@@ -982,6 +1006,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isRadarrProfile = setting.key === 'radarr_quality_profile_id' const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder' const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key) const isBoolSetting = BOOL_SETTINGS.has(setting.key)
const isUrlSetting = URL_SETTINGS.has(setting.key)
const inputPlaceholder = setting.sensitive && setting.isSet
? 'Configured (enter to replace)'
: settingPlaceholders[setting.key] ?? ''
if (isBoolSetting) { if (isBoolSetting) {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
@@ -1312,9 +1340,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input <input
name={setting.key} name={setting.key}
type={setting.sensitive ? 'password' : 'text'} type={setting.sensitive ? 'password' : 'text'}
placeholder={ placeholder={inputPlaceholder}
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : '' autoComplete={isUrlSetting ? 'url' : undefined}
}
value={value} value={value}
onChange={(event) => onChange={(event) =>
setFormValues((current) => ({ setFormValues((current) => ({

View File

@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "2901262244", "version": "0202261541",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",