diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bad7b61..0a195795 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Channel edit form reorganized into three semantic columns.** Fields are now grouped as Identity (name, number, group, logo), Guide Data (TVG-ID, Gracenote StationId, EPG picker, current program preview), and Behavior/Access (stream profile, user level, mature content, hidden). The EPG section having its own column gives the `ProgramPreview` card enough width to truncate long titles correctly rather than expanding the column.
- **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.
diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx
index 9f7eb25c..c904641c 100644
--- a/frontend/src/components/forms/Channel.jsx
+++ b/frontend/src/components/forms/Channel.jsx
@@ -528,7 +528,8 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
)}
-
+ {/* Col 1: Identity - Channel Name, Number, Group, Logo */}
+ {
style={{ flex: 1 }}
/>
+
+ setValue(
+ 'channel_number',
+ getProviderFormValue(channel, 'channel_number'),
+ { shouldDirty: true }
+ )
+ }
+ />
+ }
+ value={watch('channel_number')}
+ onChange={(value) => setValue('channel_number', value)}
+ error={errors.channel_number?.message}
+ size="xs"
+ step={0.1}
+ precision={1}
+ />
+
{
-
-
-
-
- {
>
Upload or Create Logo
-
-
-
- setValue('is_adult', event.currentTarget.checked)
- }
- size="md"
- />
-
-
-
-
-
- setValue(
- 'hidden_from_output',
- event.currentTarget.checked
- )
- }
- size="md"
- />
-
-
- {channel?.auto_created && hasAnyOverride && (
-
-
-
- )}
-
-
- setValue(
- 'channel_number',
- getProviderFormValue(channel, 'channel_number'),
- { shouldDirty: true }
- )
- }
- />
- }
- value={watch('channel_number')}
- onChange={(value) => setValue('channel_number', value)}
- error={errors.channel_number?.message}
- size="xs"
- step={0.1} // Add step prop to allow decimal inputs
- precision={1} // Specify decimal precision
- />
-
+ {/* Col 2: Guide Data - TVG-ID, Gracenote StationId, EPG, Program Preview */}
+ {
{(isLoadingProgram || hasFetchedProgram || currentProgram) && (
-
+ {
)}
+
+
+
+ {/* Col 3: Behavior/Access - Stream Profile, User Level, Mature Content, Hidden */}
+
+ {
+ acc[p.id] = p;
+ return acc;
+ }, {})
+ )}
+ onReset={() =>
+ setValue(
+ 'stream_profile_id',
+ getProviderFormValue(channel, 'stream_profile_id')
+ )
+ }
+ />
+ }
+ value={watch('stream_profile_id')}
+ onChange={(value) => {
+ setValue('stream_profile_id', value);
+ }}
+ error={errors.stream_profile_id?.message}
+ data={[{ value: '0', label: '(use default)' }].concat(
+ streamProfiles.map((option) => ({
+ value: `${option.id}`,
+ label: option.name,
+ }))
+ )}
+ size="xs"
+ />
+
+ {
+ return {
+ label: USER_LEVEL_LABELS[value],
+ value: `${value}`,
+ };
+ })}
+ value={watch('user_level')}
+ onChange={(value) => {
+ setValue('user_level', value);
+ }}
+ error={errors.user_level?.message}
+ />
+
+
+
+
+ setValue('is_adult', event.currentTarget.checked)
+ }
+ size="md"
+ />
+
+
+
+
+
+ setValue(
+ 'hidden_from_output',
+ event.currentTarget.checked
+ )
+ }
+ size="md"
+ />
+
+
+ {channel?.auto_created && hasAnyOverride && (
+
+
+
+ )}
+