Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions backend/app/api/bandwidth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)
Expand Down
17 changes: 13 additions & 4 deletions backend/app/clients/qbittorrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
15 changes: 11 additions & 4 deletions backend/app/services/polling_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
3 changes: 2 additions & 1 deletion backend/app/utils/reset_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 15 additions & 12 deletions frontend/src/components/TemporaryLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const TemporaryLimits: React.FC = () => {
// Form state
const [downloadMbps, setDownloadMbps] = useState<string>('');
const [uploadMbps, setUploadMbps] = useState<string>('');
const [durationHours, setDurationHours] = useState<string>('1');
const [durationHours, setDurationHours] = useState<string>('');

// Login dialog state
const [showLoginDialog, setShowLoginDialog] = useState(false);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -133,7 +135,7 @@ export const TemporaryLimits: React.FC = () => {
// Clear form
setDownloadMbps('');
setUploadMbps('');
setDurationHours('1');
setDurationHours('');
} catch (err) {
setError(getErrorMessage(err));
} finally {
Expand Down Expand Up @@ -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' : ''}`;
Expand Down Expand Up @@ -272,7 +275,7 @@ export const TemporaryLimits: React.FC = () => {
<div>
<span className="text-muted-foreground">Remaining:</span>
<span className="ml-1 font-medium">
{formatRemainingTime(limits.remaining_minutes)}
{formatRemainingTime(limits.remaining_minutes, limits.expires_at)}
</span>
</div>
</div>
Expand Down Expand Up @@ -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}
Expand All @@ -343,7 +346,7 @@ export const TemporaryLimits: React.FC = () => {
</Button>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</>
)}
Expand Down
Loading
Loading