mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Enhancement: add Comskip mode and hardware acceleration settings to DVR configuration. Update default comskip config.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
This commit is contained in:
parent
594596d4f5
commit
f84265f2a5
8 changed files with 224 additions and 10 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option:
|
||||
- **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut.
|
||||
- **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this.
|
||||
- **Comskip hardware acceleration setting.** DVR Settings now includes a "Hardware acceleration" option that passes a hardware-decode flag to the comskip binary, reducing CPU load during commercial detection on capable hosts:
|
||||
- **None** (default): software decode.
|
||||
- **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container.
|
||||
- **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container.
|
||||
- **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted:
|
||||
- `/hdhr/output_profile/<id>/lineup.json` - output profile only
|
||||
- `/hdhr/<channel_profile>/output_profile/<id>/lineup.json` - channel profile + output profile
|
||||
|
|
@ -37,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations.
|
||||
- **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected.
|
||||
- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs.
|
||||
- **Stream Settings descriptions added.** Default User Agent, Default Stream Profile, Default Output Format, M3U Hash Key, and HDHR Default Output Profile all now have inline description text below their labels explaining their purpose and effect.
|
||||
- **System Settings descriptions added.** Maximum System Events, Preferred Region, and Auto-Import Mapped Files now have inline description text. The redundant description paragraph that duplicated the accordion header text has been removed.
|
||||
|
|
@ -91,6 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Fixed
|
||||
|
||||
- **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead.
|
||||
- **`_cleanup_local_resources` skipped `ClientManager.stop()` on channel removal.** `ProxyServer._cleanup_local_resources` deleted each channel's `ClientManager` entry with `del`, which removed it from the dict but gave the object no signal to terminate. The per-channel heartbeat greenlet inside the manager continued running until it next checked its running flag and found the channel absent from Redis. The entry is now removed with `pop()` and `stop()` is called on the captured manager before it is discarded, terminating the heartbeat immediately on channel cleanup.
|
||||
- **XC profile `exp_date` not updating on account refresh.** `refresh_account_profiles` saved the freshly-fetched `custom_properties` with `update_fields=['custom_properties']`, which excluded `exp_date` from the SQL `UPDATE`. The model's `save()` method parses the new expiry from `custom_properties` and assigns it to `self.exp_date`, but that value was silently dropped because the column was not listed in `update_fields`. Added `'exp_date'` to the `update_fields` list so both columns are written together.
|
||||
- **~25-second transcode startup delay and worker freeze when starting ffmpeg under gevent+uWSGI.** Enabling gevent cooperative multitasking (see Changed above) exposed a deadlock: `fork()` hangs indefinitely in gevent's `_before_fork` pthread_atfork handler when called from any thread while gevent is running - including from real OS threads. `subprocess.Popen`, which all three ffmpeg spawn sites used, calls `fork()` internally, stalling the uWSGI worker for ~25 seconds or freezing it entirely and blocking all other clients on that worker.
|
||||
|
|
|
|||
|
|
@ -3180,7 +3180,11 @@ def comskip_process_recording(recording_id: int):
|
|||
_ws('started', {"title": (cp.get('program') or {}).get('title') or os.path.basename(file_path)})
|
||||
|
||||
try:
|
||||
comskip_mode = CoreSettings.get_dvr_comskip_mode()
|
||||
hw_accel = CoreSettings.get_dvr_comskip_hw_accel()
|
||||
cmd = [comskip_bin, "--output", os.path.dirname(file_path)]
|
||||
if hw_accel != "none":
|
||||
cmd.insert(1, f"--{hw_accel}")
|
||||
# Prefer user-specified INI, fall back to known defaults
|
||||
ini_candidates = []
|
||||
try:
|
||||
|
|
@ -3300,6 +3304,19 @@ def comskip_process_recording(recording_id: int):
|
|||
_ws('skipped', {"reason": "no_commercials", "commercials": 0})
|
||||
return "no_commercials"
|
||||
|
||||
if comskip_mode == "mark":
|
||||
cp["comskip"] = {
|
||||
"status": "completed",
|
||||
"mode": "mark",
|
||||
"edl": os.path.basename(edl_path),
|
||||
"commercials": len(commercials),
|
||||
}
|
||||
if selected_ini:
|
||||
cp["comskip"]["ini_path"] = selected_ini
|
||||
_persist_custom_properties()
|
||||
_ws('completed', {"commercials": len(commercials), "mode": "mark"})
|
||||
return "ok"
|
||||
|
||||
workdir = os.path.dirname(file_path)
|
||||
parts = []
|
||||
try:
|
||||
|
|
@ -3340,6 +3357,10 @@ def comskip_process_recording(recording_id: int):
|
|||
for pth in parts:
|
||||
try: os.remove(pth)
|
||||
except Exception: pass
|
||||
try:
|
||||
os.remove(edl_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cp["comskip"] = {
|
||||
"status": "completed",
|
||||
|
|
|
|||
|
|
@ -313,6 +313,8 @@ class CoreSettings(models.Model):
|
|||
"movie_fallback_template": "Movies/{start}.mkv",
|
||||
"comskip_enabled": False,
|
||||
"comskip_custom_path": "",
|
||||
"comskip_mode": "cut",
|
||||
"comskip_hw_accel": "none",
|
||||
"pre_offset_minutes": 0,
|
||||
"post_offset_minutes": 0,
|
||||
"series_rules": [],
|
||||
|
|
@ -342,6 +344,16 @@ class CoreSettings(models.Model):
|
|||
def get_dvr_comskip_enabled(cls):
|
||||
return bool(cls.get_dvr_settings().get("comskip_enabled", False))
|
||||
|
||||
@classmethod
|
||||
def get_dvr_comskip_mode(cls):
|
||||
mode = cls.get_dvr_settings().get("comskip_mode", "cut")
|
||||
return mode if mode in ("cut", "mark") else "cut"
|
||||
|
||||
@classmethod
|
||||
def get_dvr_comskip_hw_accel(cls):
|
||||
hw = cls.get_dvr_settings().get("comskip_hw_accel", "none")
|
||||
return hw if hw in ("none", "cuvid", "qsv") else "none"
|
||||
|
||||
@classmethod
|
||||
def get_dvr_comskip_custom_path(cls):
|
||||
return cls.get_dvr_settings().get("comskip_custom_path", "")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,101 @@
|
|||
; Minimal default comskip config
|
||||
edl_out=1
|
||||
output_edl=1
|
||||
; comskip.ini - tuned for recorded live TV
|
||||
; Source reference: https://github.com/erikkaashoek/Comskip/blob/master/comskip.c
|
||||
|
||||
[Main Settings]
|
||||
; Detection method bitmask - add the values together for the methods you want:
|
||||
; 1 = black/uniform frame 2 = logo detection
|
||||
; 4 = scene change 8 = resolution change
|
||||
; 16 = closed captions 32 = aspect ratio change
|
||||
; 64 = silence 255 = all methods
|
||||
; Comskip default (no ini): 107 (1+2+8+32+64, no scene change or CC)
|
||||
; Recommended for recorded TV: 127 (all seven methods)
|
||||
detect_method=127
|
||||
|
||||
; Verbosity level. 0=silent, 5=moderate, 10=maximum. Use 10 temporarily to diagnose misses.
|
||||
verbose=0
|
||||
|
||||
; Number of CPU threads to use. 0 = use all available cores.
|
||||
thread_count=0
|
||||
|
||||
[Output]
|
||||
; Write an EDL cut-list file. Dispatcharr reads this to drive FFmpeg, which
|
||||
; permanently cuts the commercials out of the recording file and replaces it in place.
|
||||
output_edl=1
|
||||
|
||||
; Action code written in the third column of each EDL line.
|
||||
; The format originates from MPlayer (values 0-1); Kodi adopted it and extended it (values 2-3).
|
||||
; 0 = Cut - player removes the section entirely; total duration shrinks (MPlayer + Kodi)
|
||||
; 1 = Mute - audio muted, video keeps playing (MPlayer + Kodi)
|
||||
; 2 = Scene Marker - marks a point of interest; used for chapter-style navigation (Kodi only)
|
||||
; 3 = Commercial Break - auto-skipped once on first playthrough; user can rewind into it (Kodi only)
|
||||
; Dispatcharr's cut mode ignores this value (only timestamps matter for FFmpeg cutting).
|
||||
; For mark mode the file is kept and handed to the player, so 3 is correct for Kodi.
|
||||
; Default in comskip is 0; without this override Kodi would treat ranges as hard cuts.
|
||||
edl_skip_field=3
|
||||
|
||||
; Suppress the default .txt stats file comskip writes alongside the EDL.
|
||||
output_default=0
|
||||
|
||||
[Commercial Break Timing]
|
||||
; Maximum total length in seconds for one commercial break (source default: 600)
|
||||
max_commercialbreak=600
|
||||
|
||||
; Minimum total length in seconds for a break to be marked (source default: 20)
|
||||
; 25s is a safe floor for US broadcast TV - avoids marking short interstitials
|
||||
min_commercialbreak=25
|
||||
|
||||
; Maximum length in seconds for a single spot within a break (source default: 120)
|
||||
max_commercial_size=120
|
||||
|
||||
; Minimum length in seconds for a single spot (source default: 4)
|
||||
min_commercial_size=5
|
||||
|
||||
; Minimum show segment in seconds; blocks shorter than this are suspect (source default: 120)
|
||||
min_show_segment_length=120
|
||||
|
||||
[Black Frame Detection]
|
||||
; A frame is NOT black if any sampled pixel exceeds this brightness (0-255, source default: 60)
|
||||
max_brightness=60
|
||||
|
||||
; Secondary check: if any pixel exceeds this value, comskip also checks the average (source default: 40)
|
||||
test_brightness=40
|
||||
|
||||
; Maximum average brightness for a frame to be classified as black/dim (0-255, source default: 19)
|
||||
max_avg_brightness=19
|
||||
|
||||
; Uniform-color (non-black slate) detection sensitivity. Lower = stricter. (source default: 500)
|
||||
; Solid-color slates at break boundaries are also used as cut points.
|
||||
non_uniformity=500
|
||||
|
||||
[Logo Detection]
|
||||
; Minimum fraction of frames in the show that must carry the logo to enable logo detection (source default: 0.40)
|
||||
; Lower this if the station watermark disappears often during the program itself.
|
||||
logo_fraction=0.40
|
||||
|
||||
; Upper bound: if more than this fraction of frames has a logo, detection is disabled (source default: 0.92)
|
||||
logo_percentile=0.92
|
||||
|
||||
; Edge-match similarity threshold for confirming the logo is present (0.0-1.0, source default: 0.80)
|
||||
; Lower slightly (e.g. 0.75) if comskip fails to track the logo through compression artifacts.
|
||||
logo_threshold=0.75
|
||||
|
||||
; Minimum fraction of a block that must contain the logo for it to score as show content (source default: 0.25)
|
||||
logo_percentage_threshold=0.25
|
||||
|
||||
; Maximum fraction of screen area the logo may occupy - larger regions are rejected (source default: 0.12)
|
||||
logo_max_percentage_of_screen=0.12
|
||||
|
||||
; Temporal smoothing filter for logo presence. 0 = off. (source default: 0)
|
||||
; Set to 4 if brief compression artifacts cause the logo to flicker in/out.
|
||||
logo_filter=0
|
||||
|
||||
[Silence Detection]
|
||||
; Frames with audio volume below this value are treated as silence (source default: 100)
|
||||
; Raise this if quiet dialogue or music beds are triggering false cut points.
|
||||
max_silence=100
|
||||
|
||||
[Live TV]
|
||||
; Set to 1 only if comskip is processing the file WHILE it is still being recorded.
|
||||
; For post-recording analysis (the normal Dispatcharr use case) keep this at 0.
|
||||
live_tv=0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import useSettingsStore from '../../../store/settings.jsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
getChangedSettings,
|
||||
parseSettings,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Flex,
|
||||
Group,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
|
|
@ -34,6 +35,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
|||
path: '',
|
||||
exists: false,
|
||||
});
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
|
|
@ -45,7 +47,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
|||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
if (settings && !isSavingRef.current) {
|
||||
const formValues = parseSettings(settings);
|
||||
|
||||
form.setValues(formValues);
|
||||
|
|
@ -114,17 +116,27 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
|||
|
||||
const onSubmit = async () => {
|
||||
setSaved(false);
|
||||
isSavingRef.current = true;
|
||||
|
||||
const changedSettings = getChangedSettings(form.getValues(), settings);
|
||||
|
||||
// Update each changed setting in the backend (create if missing)
|
||||
try {
|
||||
await saveChangedSettings(settings, changedSettings);
|
||||
|
||||
isSavingRef.current = false;
|
||||
const latestSettings = useSettingsStore.getState().settings;
|
||||
if (latestSettings) {
|
||||
const formValues = parseSettings(latestSettings);
|
||||
form.setValues(formValues);
|
||||
if (formValues['comskip_custom_path']) {
|
||||
setComskipConfig((prev) => ({
|
||||
path: formValues['comskip_custom_path'],
|
||||
exists: prev.exists,
|
||||
}));
|
||||
}
|
||||
}
|
||||
setSaved(true);
|
||||
} catch (error) {
|
||||
// Error notifications are already shown by API functions
|
||||
// Just don't show the success message
|
||||
isSavingRef.current = false;
|
||||
console.error('Error saving settings:', error);
|
||||
}
|
||||
};
|
||||
|
|
@ -136,13 +148,39 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
|||
<Alert variant="light" color="green" title="Saved Successfully" />
|
||||
)}
|
||||
<Switch
|
||||
label="Enable Comskip (remove commercials after recording)"
|
||||
label="Enable Comskip (commercial detection after recording)"
|
||||
{...form.getInputProps('comskip_enabled', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
id="comskip_enabled"
|
||||
name="comskip_enabled"
|
||||
/>
|
||||
<Select
|
||||
label="Comskip mode"
|
||||
description="Cut: permanently removes commercials from the file. Mark: keeps the file intact and writes an EDL file for players that support EDL-based commercial skipping."
|
||||
data={[
|
||||
{ value: 'cut', label: 'Cut (remove commercials from file)' },
|
||||
{
|
||||
value: 'mark',
|
||||
label: 'Mark (store timestamps, keep file intact)',
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps('comskip_mode')}
|
||||
id="comskip_mode"
|
||||
name="comskip_mode"
|
||||
/>
|
||||
<Select
|
||||
label="Hardware acceleration"
|
||||
description="Offloads video decoding to a hardware decoder. Requires the corresponding driver/device to be available inside the container."
|
||||
data={[
|
||||
{ value: 'none', label: 'None (software decode)' },
|
||||
{ value: 'cuvid', label: 'NVIDIA NVDEC (--cuvid)' },
|
||||
{ value: 'qsv', label: 'Intel Quick Sync (--qsv)' },
|
||||
]}
|
||||
{...form.getInputProps('comskip_hw_accel')}
|
||||
id="comskip_hw_accel"
|
||||
name="comskip_hw_accel"
|
||||
/>
|
||||
<TextInput
|
||||
label="Custom comskip.ini path"
|
||||
description="Leave blank to use the built-in defaults."
|
||||
|
|
|
|||
|
|
@ -79,6 +79,22 @@ vi.mock('@mantine/core', () => ({
|
|||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
Select: ({ label, id, name, data, ...rest }) => (
|
||||
<select
|
||||
data-testid={id}
|
||||
id={id}
|
||||
name={name}
|
||||
aria-label={label}
|
||||
value={rest.value ?? ''}
|
||||
onChange={(e) => rest.onChange?.(e.target.value)}
|
||||
>
|
||||
{(data || []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
TextInput: ({ label, id, name, placeholder, ...rest }) => (
|
||||
<input
|
||||
|
|
@ -115,6 +131,8 @@ import {
|
|||
const mockFormValues = {
|
||||
comskip_enabled: false,
|
||||
comskip_custom_path: '',
|
||||
comskip_mode: 'cut',
|
||||
comskip_hw_accel: 'none',
|
||||
pre_offset_minutes: 0,
|
||||
post_offset_minutes: 0,
|
||||
tv_template: '',
|
||||
|
|
@ -199,6 +217,20 @@ describe('DvrSettingsForm', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders the comskip_mode select', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comskip_mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the comskip_hw_accel select', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comskip_hw_accel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the file input for comskip.ini upload', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export const getDvrSettingsFormInitialValues = () => {
|
|||
movie_fallback_template: '',
|
||||
comskip_enabled: false,
|
||||
comskip_custom_path: '',
|
||||
comskip_mode: 'cut',
|
||||
comskip_hw_accel: 'none',
|
||||
pre_offset_minutes: 0,
|
||||
post_offset_minutes: 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'movie_fallback_template',
|
||||
'comskip_enabled',
|
||||
'comskip_custom_path',
|
||||
'comskip_mode',
|
||||
'comskip_hw_accel',
|
||||
'pre_offset_minutes',
|
||||
'post_offset_minutes',
|
||||
'series_rules',
|
||||
|
|
@ -306,6 +308,8 @@ export const parseSettings = (settings) => {
|
|||
? dvrSettings.comskip_enabled
|
||||
: Boolean(dvrSettings.comskip_enabled);
|
||||
parsed.comskip_custom_path = dvrSettings.comskip_custom_path;
|
||||
parsed.comskip_mode = dvrSettings.comskip_mode || 'cut';
|
||||
parsed.comskip_hw_accel = dvrSettings.comskip_hw_accel || 'none';
|
||||
parsed.pre_offset_minutes =
|
||||
typeof dvrSettings.pre_offset_minutes === 'number'
|
||||
? dvrSettings.pre_offset_minutes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue