Merge pull request #1308 from sv-dispatcharr:feat/disable-public-ip

feat: blur public IP display, add IP lookup toggle with env var override, make lookup non-blocking
This commit is contained in:
SergeantPanda 2026-05-31 18:54:55 -05:00 committed by GitHub
commit ac2eb2e3ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 260 additions and 95 deletions

View file

@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Public IP display in the sidebar is now blurred by default and reveals on click.** Prevents accidental exposure in screenshots and screen shares. A new toggle in Settings > System Settings lets users disable IP and geolocation fetching entirely. A `DISPATCHARR_ENABLE_IP_LOOKUP` environment variable provides a container-level override; when set to `false` the toggle is hidden from the UI and cannot be changed. (Closes #1302) — Thanks [@sethwv](https://github.com/sethwv)
- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv)
- **VOD basic sync now stores additional metadata from the provider's stream list.** When a provider includes `director`, `cast`, `release_date`, or `trailer`/`youtube_trailer` in its `get_vod_streams` response, Dispatcharr now captures and stores those fields in `custom_properties` during the initial sync pass. Previously, this data was discarded and only populated if an advanced per-movie refresh was triggered. - Thanks [@nemesbak](https://github.com/nemesbak)
### Changed
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
@ -34,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **IP lookup no longer blocks the settings page load.** The `environment` endpoint previously made up to three sequential HTTP calls (ipify, ipapi.co, ip-api.com) with 5s timeouts each, blocking the page for up to 15s if any were unreachable. The lookups now run in a background thread on first request and results are cached in Redis for 1 hour, so the endpoint returns immediately on every call. — Thanks [@sethwv](https://github.com/sethwv)
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)

View file

@ -1,6 +1,5 @@
# core/api_views.py
import json
import ipaddress
import logging
from django.conf import settings as django_settings
@ -8,11 +7,9 @@ from django.db import models
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators import api_view, permission_classes, action
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from .models import (
UserAgent,
StreamProfile,
@ -32,8 +29,10 @@ from .serializers import (
)
import socket
import threading
import requests
import os
from django.core.cache import cache
from core.tasks import rehash_streams
from apps.accounts.permissions import (
Authenticated,
@ -287,6 +286,73 @@ class ProxySettingsViewSet(viewsets.ViewSet):
_IP_CACHE_KEY = "dispatcharr:ip_lookup_result"
_IP_CACHE_TTL = 3600 # 1 hour
_IP_LOCK_KEY = "dispatcharr:ip_lookup_lock"
def _perform_ip_lookup():
"""Run IP and geolocation lookups in a background thread and cache the result."""
public_ip = None
local_ip = None
country_code = None
country_name = None
city = None
try:
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
r.raise_for_status()
public_ip = r.json().get("ip")
except requests.RequestException:
pass
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("203.0.113.1", 80))
local_ip = s.getsockname()[0]
finally:
s.close()
except Exception:
pass
try:
ipaddress.ip_address(public_ip)
except (ValueError, TypeError):
public_ip = None
if public_ip:
try:
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("country_code")
country_name = geo.get("country_name")
city = geo.get("city")
else:
r = requests.get("http://ip-api.com/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("countryCode")
country_name = geo.get("country")
city = geo.get("city")
except Exception as e:
logger.error(f"Error during geo lookup: {e}")
result = {
"public_ip": public_ip,
"local_ip": local_ip,
"country_code": country_code,
"country_name": country_name,
"city": city,
}
cache.set(_IP_CACHE_KEY, result, _IP_CACHE_TTL)
cache.delete(_IP_LOCK_KEY)
from core.utils import send_websocket_update
send_websocket_update("updates", "update", {"type": "ip_lookup_complete", **result})
@extend_schema(
description="Endpoint for environment details",
)
@ -297,55 +363,27 @@ def environment(request):
local_ip = None
country_code = None
country_name = None
city = None
ip_lookup_pending = False
# 1) Get the public IP from ipify.org API
try:
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
r.raise_for_status()
public_ip = r.json().get("ip")
except requests.RequestException as e:
public_ip = f"Error: {e}"
ip_lookup_env_disabled = not getattr(django_settings, "ENABLE_IP_LOOKUP", True)
ip_lookup_db_enabled = CoreSettings.get_system_settings().get("enable_ip_lookup", True)
ip_lookup_enabled = not ip_lookup_env_disabled and ip_lookup_db_enabled
# 2) Get the local IP by connecting to a public DNS server
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# connect to a "public" address so the OS can determine our local interface
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
except Exception as e:
local_ip = f"Error: {e}"
if ip_lookup_enabled:
cached = cache.get(_IP_CACHE_KEY)
if cached is not None:
public_ip = cached.get("public_ip")
local_ip = cached.get("local_ip")
country_code = cached.get("country_code")
country_name = cached.get("country_name")
city = cached.get("city")
else:
if cache.add(_IP_LOCK_KEY, True, 30):
threading.Thread(target=_perform_ip_lookup, daemon=True).start()
ip_lookup_pending = True
# 3) Get geolocation data from ipapi.co or ip-api.com
if public_ip and "Error" not in public_ip:
try:
# Attempt to get geo information from ipapi.co first
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("country_code") # e.g. "US"
country_name = geo.get("country_name") # e.g. "United States"
else:
# If ipapi.co fails, fallback to ip-api.com
# only supports http requests for free tier
r = requests.get("http://ip-api.com/json/", timeout=5)
if r.status_code == requests.codes.ok:
geo = r.json()
country_code = geo.get("countryCode") # e.g. "US"
country_name = geo.get("country") # e.g. "United States"
else:
raise Exception("Geo lookup failed with both services")
except Exception as e:
logger.error(f"Error during geo lookup: {e}")
country_code = None
country_name = None
# 4) Get environment mode and TLS status from settings
# Get environment mode and TLS status from settings
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
return Response(
@ -355,6 +393,10 @@ def environment(request):
"local_ip": local_ip,
"country_code": country_code,
"country_name": country_name,
"city": city,
"ip_lookup_enabled": ip_lookup_enabled,
"ip_lookup_env_disabled": ip_lookup_env_disabled,
"ip_lookup_pending": ip_lookup_pending,
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
"redis_tls": {
"enabled": getattr(django_settings, "REDIS_SSL", False),

View file

@ -407,6 +407,7 @@ class CoreSettings(models.Model):
"max_system_events": 100,
"preferred_region": None,
"auto_import_mapped_files": True,
"enable_ip_lookup": True,
})
@classmethod

View file

@ -59,6 +59,8 @@ if REDIS_SSL:
else:
print("Redis TLS: disabled")
ENABLE_IP_LOOKUP = os.environ.get("DISPATCHARR_ENABLE_IP_LOOKUP", "true").lower() == "true"
# Set DEBUG to True for development, False for production
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
DEBUG = True

View file

@ -177,7 +177,7 @@ export POSTGRES_DIR=/data/db
variables=(
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY

View file

@ -965,6 +965,12 @@ export const WebsocketProvider = ({ children }) => {
break;
}
case 'ip_lookup_complete': {
const { type: _t, ...ipData } = parsedEvent.data;
useSettingsStore.getState().setEnvironmentFields(ipData);
break;
}
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`

View file

@ -1,4 +1,4 @@
import React, { useRef, useState, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
@ -18,10 +18,10 @@ import {
Box,
Text,
UnstyledButton,
TextInput,
ActionIcon,
AppShellNavbar,
ScrollArea,
Skeleton,
Tooltip,
} from '@mantine/core';
import logo from '../images/logo.png';
@ -161,10 +161,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const getNavOrder = useAuthStore((s) => s.getNavOrder);
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
const publicIPRef = useRef(null);
const [userFormOpen, setUserFormOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [ipRevealed, setIpRevealed] = useState(false);
const closeUserForm = () => setUserFormOpen(false);
@ -289,35 +288,106 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}}
>
{isAuthenticated && (
<Stack gap="sm">
{!collapsed && (
<TextInput
label="Public IP"
ref={publicIPRef}
value={environment.public_ip}
readOnly={true}
leftSection={
environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={environment.country_name || environment.country_code}
title={
environment.country_name || environment.country_code
}
/>
)
}
rightSection={
<ActionIcon
variant="transparent"
color="gray.9"
onClick={copyPublicIP}
<Stack gap="sm" style={{ width: '100%' }}>
{!collapsed &&
environment.ip_lookup_enabled !== false &&
environment.ip_lookup_pending && (
<Box>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Skeleton height={36} radius="sm" />
</Box>
)}
{!collapsed &&
environment.ip_lookup_enabled !== false &&
!environment.ip_lookup_pending &&
environment.public_ip &&
!environment.public_ip.startsWith('Error') && (
<Box
onClick={() => setIpRevealed((v) => !v)}
style={{ cursor: 'pointer' }}
>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Box
style={{
display: 'flex',
alignItems: 'center',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'var(--mantine-color-dark-6)',
height: '36px',
paddingLeft: '10px',
gap: '8px',
}}
>
<Copy />
</ActionIcon>
}
/>
)}
{environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={
environment.country_name || environment.country_code
}
title={[
environment.country_name || environment.country_code,
environment.city,
]
.filter(Boolean)
.join(', ')}
style={{ flexShrink: 0 }}
/>
)}
<Box style={{ flex: 1, overflow: 'hidden' }}>
<span
style={{
display: 'block',
whiteSpace: 'nowrap',
fontSize: 'var(--mantine-font-size-sm)',
color: 'var(--mantine-color-text)',
}}
>
{(() => {
const ip = environment.public_ip;
const isIPv6 = ip.includes(':');
const sep = isIPv6 ? ':' : '.';
const parts = ip.split(sep);
const splitAt = isIPv6 ? 4 : 2;
const visible =
parts.slice(0, splitAt).join(sep) + sep;
const hidden = parts.slice(splitAt).join(sep);
return (
<>
{visible}
<span
style={{
filter: ipRevealed ? 'none' : 'blur(5px)',
transition: 'filter 0.15s',
userSelect: ipRevealed ? 'text' : 'none',
}}
>
{hidden}
</span>
</>
);
})()}
</span>
</Box>
<ActionIcon
variant="transparent"
color="gray.9"
onClick={(e) => {
e.stopPropagation();
copyPublicIP();
}}
style={{ flexShrink: 0 }}
>
<Copy />
</ActionIcon>
</Box>
</Box>
)}
{!collapsed && authUser && (
<Group
@ -336,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
</Group>
)}
{collapsed && (
<Group gap="xs">
<Group justify="center" style={{ width: '100%' }}>
<Avatar src="" radius="xl" />
</Group>
)}

View file

@ -96,14 +96,6 @@ vi.mock('@mantine/core', async () => {
</Component>
);
},
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
<div>
{label && <label>{label}</label>}
{leftSection}
<input value={value} onChange={onChange} />
{rightSection}
</div>
),
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
@ -122,6 +114,7 @@ vi.mock('@mantine/core', async () => {
</nav>
),
ScrollArea: ({ children }) => <div>{children}</div>,
Skeleton: ({ height }) => <div data-testid="skeleton" style={{ height }} />,
Tooltip: ({ children }) => <>{children}</>,
};
});
@ -290,8 +283,7 @@ describe('Sidebar', () => {
it('should render public IP with country flag', () => {
renderSidebar();
const ipInput = screen.getByDisplayValue('192.168.1.1');
expect(ipInput).toBeInTheDocument();
expect(screen.getByText('Public IP')).toBeInTheDocument();
const flag = screen.getByAltText('United States');
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
@ -492,7 +484,7 @@ describe('Sidebar', () => {
});
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.getByText('Public IP')).toBeInTheDocument();
expect(
screen.queryByRole('img', { name: /flag/i })
).not.toBeInTheDocument();

View file

@ -26,6 +26,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const isModular =
useSettingsStore((s) => s.environment.env_mode) === 'modular';
const ipLookupEnvDisabled = useSettingsStore(
(s) => s.environment.ip_lookup_env_disabled
);
const [saved, setSaved] = useState(false);
@ -108,6 +111,23 @@ const SystemSettingsForm = React.memo(({ active }) => {
id="auto_import_mapped_files"
/>
</Group>
{!ipLookupEnvDisabled && (
<Group justify="space-between" pt={5}>
<div>
<Text size="sm" fw={500}>
Enable IP Lookup
</Text>
<Text size="xs" c="dimmed">
Fetch and display the instance's public IP and country flag in the
sidebar.
</Text>
</div>
<Switch
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
id="enable_ip_lookup"
/>
</Group>
)}
{isModular && (
<>
<Divider my="md" label="Connection Security" labelPosition="left" />

View file

@ -87,7 +87,11 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
city: '',
env_mode: 'aio',
ip_lookup_enabled: true,
ip_lookup_env_disabled: false,
ip_lookup_pending: false,
});
});

View file

@ -8,7 +8,11 @@ const useSettingsStore = create((set, get) => ({
public_ip: '',
country_code: '',
country_name: '',
city: '',
env_mode: 'aio',
ip_lookup_enabled: true,
ip_lookup_env_disabled: false,
ip_lookup_pending: false,
},
version: {
version: '',
@ -40,7 +44,11 @@ const useSettingsStore = create((set, get) => ({
public_ip: '',
country_code: '',
country_name: '',
city: '',
env_mode: 'aio',
ip_lookup_enabled: true,
ip_lookup_env_disabled: false,
ip_lookup_pending: false,
},
};
@ -78,6 +86,15 @@ const useSettingsStore = create((set, get) => ({
}
},
setEnvironmentFields: (fields) =>
set((state) => ({
environment: {
...state.environment,
...fields,
ip_lookup_pending: false,
},
})),
updateSetting: (setting) =>
set((state) => ({
settings: { ...state.settings, [setting.key]: setting },

View file

@ -3,5 +3,6 @@ export const getSystemSettingsFormInitialValues = () => {
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
};
};

View file

@ -11,6 +11,7 @@ describe('SystemSettingsFormUtils', () => {
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
});
});

View file

@ -67,6 +67,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'max_system_events',
'preferred_region',
'auto_import_mapped_files',
'enable_ip_lookup',
];
for (const formKey in changedSettings) {
@ -133,6 +134,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'comskip_enabled',
'schedule_enabled',
'auto_import_mapped_files',
'enable_ip_lookup',
];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
@ -358,6 +360,10 @@ export const parseSettings = (settings) => {
typeof systemSettings.auto_import_mapped_files === 'boolean'
? systemSettings.auto_import_mapped_files
: true;
parsed.enable_ip_lookup =
typeof systemSettings.enable_ip_lookup === 'boolean'
? systemSettings.enable_ip_lookup
: true;
}
// Proxy and network access are already grouped objects