diff --git a/backend/app/api/bandwidth.py b/backend/app/api/bandwidth.py index 35abe42..b1cf3e9 100644 --- a/backend/app/api/bandwidth.py +++ b/backend/app/api/bandwidth.py @@ -22,11 +22,11 @@ class TemporaryLimitRequest(BaseModel): """Request model for setting temporary bandwidth limits.""" download_mbps: Optional[float] = Field(None, ge=0, le=100000, description="Download limit in Mbps") upload_mbps: Optional[float] = Field(None, ge=0, le=100000, description="Upload limit in Mbps") - duration_hours: float = Field( - ..., + duration_hours: Optional[float] = Field( + None, gt=0, le=168, # Max 7 days - description="Duration in hours (min: >0, max: 168 = 7 days). Use 0.5 for 30 minutes." + description="Duration in hours (min: >0, max: 168 = 7 days). Omit for indefinite (until cleared)." ) source: Optional[str] = Field(None, max_length=200, description="Source identifier (e.g., 'Home Assistant - Gaming PC')") @@ -354,10 +354,22 @@ async def get_temporary_limits(request: Request): async with polling_monitor._temporary_limits_lock: temp_limits = getattr(polling_monitor, '_temporary_limits', None) - if temp_limits and temp_limits.get('expires_at'): - expires_at = temp_limits['expires_at'] - now = datetime.now(timezone.utc) + if temp_limits: + expires_at = temp_limits.get('expires_at') + + if expires_at is None: + # Indefinite limit — active until cleared + return TemporaryLimitResponse( + active=True, + download_mbps=temp_limits.get('download_mbps'), + upload_mbps=temp_limits.get('upload_mbps'), + expires_at=None, + remaining_minutes=None, + source=temp_limits.get('source'), + set_by=temp_limits.get('set_by'), + ) + now = datetime.now(timezone.utc) if expires_at > now: remaining = (expires_at - now).total_seconds() / 60 return TemporaryLimitResponse( @@ -391,8 +403,10 @@ async def set_temporary_limits( try: polling_monitor = request.app.state.polling_monitor - # Pydantic validates duration_hours > 0 and <= 168 hours (7 days) - expires_at = datetime.now(timezone.utc) + timedelta(hours=limits.duration_hours) + # Compute expiry: None means indefinite (until cleared) + expires_at = None + if limits.duration_hours is not None: + expires_at = datetime.now(timezone.utc) + timedelta(hours=limits.duration_hours) # Use API key name when authenticated via API key api_key_name = getattr(request.state, 'api_key_name', None) @@ -409,21 +423,22 @@ async def set_temporary_limits( 'source': limits.source, } - remaining = limits.duration_hours * 60 + remaining = limits.duration_hours * 60 if limits.duration_hours is not None else None source_info = f", source='{limits.source}'" if limits.source else "" + duration_info = f"expires in {limits.duration_hours} hours" if limits.duration_hours is not None else "indefinite (until cleared)" logger.info( f"Temporary limits set by {set_by}: " f"download={limits.download_mbps} Mbps, upload={limits.upload_mbps} Mbps, " - f"expires in {limits.duration_hours} hours{source_info}" + f"{duration_info}{source_info}" ) return TemporaryLimitResponse( active=True, download_mbps=limits.download_mbps, upload_mbps=limits.upload_mbps, - expires_at=expires_at.isoformat() + 'Z', - remaining_minutes=round(remaining, 1), + expires_at=expires_at.isoformat() + 'Z' if expires_at else None, + remaining_minutes=round(remaining, 1) if remaining is not None else None, source=limits.source, set_by=set_by, ) diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 9bcd719..d242ef7 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -48,9 +48,18 @@ async def _ensure_authenticated(self): try: data = {"username": self.username, "password": self.password} - - async with self.session.post(f"{self.url}/api/v2/auth/login", data=data) as response: - if response.status == 200: + headers = {"Referer": self.url} + + async with self.session.post( + f"{self.url}/api/v2/auth/login", + data=data, + headers=headers, + ) as response: + # qBittorrent >= 5.2 may return 204 No Content for successful login. + if response.status == 204: + self._authenticated = True + logger.debug("Authenticated with qBittorrent") + elif response.status == 200: text = await response.text() if text.strip() == "Ok.": self._authenticated = True @@ -70,7 +79,7 @@ async def _request(self, method: str, endpoint: str, retry_on_auth_failure: bool response = await self.session.request(method, url, **kwargs) if response.status == 403 and retry_on_auth_failure: - await response.release() + response.release() logger.info("qBittorrent returned 403, re-authenticating...") self._authenticated = False await self._ensure_authenticated() diff --git a/backend/app/services/polling_monitor.py b/backend/app/services/polling_monitor.py index 1d3fdac..89c5518 100644 --- a/backend/app/services/polling_monitor.py +++ b/backend/app/services/polling_monitor.py @@ -379,11 +379,18 @@ async def get_active_temporary_limits(self) -> tuple[Optional[float], Optional[f return None, None expires_at = self._temporary_limits.get('expires_at') - if not expires_at or datetime.now(timezone.utc) > expires_at: + + if expires_at is None: + # Indefinite limit — active until explicitly cleared + return ( + self._temporary_limits.get('download_mbps'), + self._temporary_limits.get('upload_mbps') + ) + + if datetime.now(timezone.utc) > expires_at: # Expired - clear and return None - if self._temporary_limits: - logger.info("Temporary bandwidth limits expired, reverting to normal limits") - self._temporary_limits = None + logger.info("Temporary bandwidth limits expired, reverting to normal limits") + self._temporary_limits = None return None, None return ( diff --git a/backend/app/utils/reset_password.py b/backend/app/utils/reset_password.py index 8ac13e1..f0e7dcf 100644 --- a/backend/app/utils/reset_password.py +++ b/backend/app/utils/reset_password.py @@ -21,9 +21,10 @@ from app.models.user import User from app.api.auth import get_password_hash +from app.config import settings -DATABASE_URL = "sqlite+aiosqlite:///data/speedarr.db" +DATABASE_URL = settings.database_url async def reset_password(username: str, new_password: str) -> bool: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2591e92..07da5ff 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -414,7 +414,7 @@ class ApiClient { async setTemporaryLimits(params: { download_mbps?: number | null; upload_mbps?: number | null; - duration_hours: number; + duration_hours?: number; source?: string; }): Promise<{ active: boolean; diff --git a/frontend/src/components/TemporaryLimits.tsx b/frontend/src/components/TemporaryLimits.tsx index 6b1475c..5569123 100644 --- a/frontend/src/components/TemporaryLimits.tsx +++ b/frontend/src/components/TemporaryLimits.tsx @@ -40,7 +40,7 @@ export const TemporaryLimits: React.FC = () => { // Form state const [downloadMbps, setDownloadMbps] = useState(''); const [uploadMbps, setUploadMbps] = useState(''); - const [durationHours, setDurationHours] = useState('1'); + const [durationHours, setDurationHours] = useState(''); // Login dialog state const [showLoginDialog, setShowLoginDialog] = useState(false); @@ -86,12 +86,12 @@ export const TemporaryLimits: React.FC = () => { setSuccess(''); try { - const parsedDuration = parseFloat(durationHours); + const parsedDuration = durationHours ? parseFloat(durationHours) : null; const parsedDownload = downloadMbps ? parseFloat(downloadMbps) : null; const parsedUpload = uploadMbps ? parseFloat(uploadMbps) : null; // Validate numeric values to prevent NaN - if (isNaN(parsedDuration) || parsedDuration <= 0) { + if (parsedDuration !== null && (isNaN(parsedDuration) || parsedDuration <= 0)) { setError('Duration must be a positive number'); setIsSaving(false); return; @@ -110,10 +110,12 @@ export const TemporaryLimits: React.FC = () => { const params: { download_mbps?: number | null; upload_mbps?: number | null; - duration_hours: number; - } = { - duration_hours: parsedDuration, - }; + duration_hours?: number; + } = {}; + + if (parsedDuration !== null) { + params.duration_hours = parsedDuration; + } if (parsedDownload !== null) { params.download_mbps = parsedDownload; @@ -133,7 +135,7 @@ export const TemporaryLimits: React.FC = () => { // Clear form setDownloadMbps(''); setUploadMbps(''); - setDurationHours('1'); + setDurationHours(''); } catch (err) { setError(getErrorMessage(err)); } finally { @@ -183,7 +185,8 @@ export const TemporaryLimits: React.FC = () => { } }; - const formatRemainingTime = (minutes: number | null): string => { + const formatRemainingTime = (minutes: number | null, expiresAt: string | null): string => { + if (minutes === null && expiresAt === null) return 'Until cleared'; if (minutes === null) return '--'; if (minutes < 1) return 'Less than 1 minute'; if (minutes < 60) return `${Math.round(minutes)} minute${Math.round(minutes) !== 1 ? 's' : ''}`; @@ -272,7 +275,7 @@ export const TemporaryLimits: React.FC = () => {
Remaining: - {formatRemainingTime(limits.remaining_minutes)} + {formatRemainingTime(limits.remaining_minutes, limits.expires_at)}
@@ -324,7 +327,7 @@ export const TemporaryLimits: React.FC = () => { type="number" min="0.5" step="0.5" - placeholder="1" + placeholder="Indefinite" value={durationHours} onChange={(e) => setDurationHours(e.target.value)} disabled={isSaving} @@ -343,7 +346,7 @@ export const TemporaryLimits: React.FC = () => {

- Override normal bandwidth limits temporarily. Leave a field empty to use normal limits for that direction. + Override normal bandwidth limits temporarily. Leave duration empty for indefinite (until cleared). Leave a speed field empty to use normal limits for that direction.

)} diff --git a/frontend/src/components/settings/APIKeysSettings.tsx b/frontend/src/components/settings/APIKeysSettings.tsx index 565b6c5..33320e3 100644 --- a/frontend/src/components/settings/APIKeysSettings.tsx +++ b/frontend/src/components/settings/APIKeysSettings.tsx @@ -28,7 +28,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, AlertCircle, CheckCircle, Key, Plus, Trash2, Copy, Check, BookOpen } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Loader2, AlertCircle, CheckCircle, Key, Plus, Trash2, Copy, Check, BookOpen, ChevronDown } from 'lucide-react'; import { apiClient } from '@/api/client'; import { getErrorMessage } from '@/lib/utils'; import type { APIKeyInfo } from '@/types'; @@ -48,16 +49,30 @@ function copyToClipboard(text: string): void { document.body.removeChild(textarea); } -const CopyableCode: React.FC<{ code: string }> = ({ code }) => { +const CopyableCode: React.FC<{ code: string; highlightPlaceholder?: boolean }> = ({ code, highlightPlaceholder }) => { const [copied, setCopied] = useState(false); const handleCopy = () => { copyToClipboard(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }; + + const renderCode = () => { + if (!highlightPlaceholder || !code.includes('YOUR_API_KEY')) return code; + const parts = code.split('YOUR_API_KEY'); + return parts.map((part, i) => ( + + {part} + {i < parts.length - 1 && ( + YOUR_API_KEY + )} + + )); + }; + return (
-
{code}
+
{renderCode()}
+ + +
+
+

1. Add REST commands to configuration.yaml

+ +
-
-

2. Create automations

-

- Create these automations in Home Assistant via Settings > Automations & Scenes > Create Automation. Switch to YAML mode and paste the following: -

+
+

2. Create automations

+

+ Create these automations in Home Assistant via Settings > Automations & Scenes > Create Automation. Switch to YAML mode and paste the following: +

-
-

Throttle when gaming PC turns on

- -
+
+

Throttle when gaming PC turns on

+ +
-
-

Restore when gaming PC turns off

- -
-
+
+

Restore when gaming PC turns off

+ +
+
+
+
+ + + {/* UnRaid section */} + + + + + +
+

+ Use these scripts to throttle downloads during UnRaid parity checks, mover runs, or any operation. Limits are set without a duration (indefinite) and cleared when the operation finishes. +

+ +
+

Parity Check Monitor — schedule as "At Startup of Array" in UnRaid User Scripts

+ +
+ +
+

Mover Start — schedule as "Before Mover" in UnRaid User Scripts

+ +
+ +
+

Mover Stop — schedule as "After Mover" in UnRaid User Scripts

+ +
+
+
+