Comskip Update

This commit is contained in:
Dispatcharr 2025-09-04 13:45:25 -05:00
parent c76d68f382
commit f652d2b233
9 changed files with 329 additions and 7 deletions

View file

@ -1683,6 +1683,17 @@ class RecordingViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
@action(detail=True, methods=["post"], url_path="comskip")
def comskip(self, request, pk=None):
"""Trigger comskip processing for this recording."""
from .tasks import comskip_process_recording
rec = get_object_or_404(Recording, pk=pk)
try:
comskip_process_recording.delay(rec.id)
return Response({"success": True, "queued": True})
except Exception as e:
return Response({"success": False, "error": str(e)}, status=400)
@action(detail=True, methods=["get"], url_path="file")
def file(self, request, pk=None):
"""Stream a recorded file with HTTP Range support for seeking."""
@ -1760,7 +1771,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
file_path = cp.get("file_path")
# Perform DB delete first, then try to remove file
response = super().destroy(request, *args, **kwargs)
library_dir = os.environ.get('DISPATCHARR_LIBRARY_DIR', '/library')
library_dir = '/app/data'
allowed_roots = ['/data/', library_dir.rstrip('/') + '/']
if file_path and isinstance(file_path, str) and any(file_path.startswith(root) for root in allowed_roots):
try:

View file

@ -476,8 +476,8 @@ def _build_output_paths(channel, program, start_time, end_time):
Build (final_path, temp_ts_path, final_filename) using DVR templates.
"""
from core.models import CoreSettings
# Root for DVR recordings: prefer DISPATCHARR_RECORDINGS_DIR, fallback to /data/recordings
library_root = os.environ.get('DISPATCHARR_RECORDINGS_DIR', '/data/recordings')
# Root for DVR recordings: fixed to /app/data inside the container
library_root = '/app/data'
is_movie, season, episode, year, sub_title = _parse_epg_tv_movie_info(program)
show = _safe_name(program.get('title') if isinstance(program, dict) else channel.name)
@ -529,9 +529,8 @@ def _build_output_paths(channel, program, start_time, end_time):
# As a last resort for TV
if not is_movie and not rel_path:
rel_path = f"TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
# If template contains a leading "Recordings/" (legacy), drop it because we already root at recordings dir
if rel_path.startswith(('Recordings/', 'recordings/')):
rel_path = rel_path.split('/', 1)[1]
# Keep any leading folder like 'Recordings/' from the template so users can
# structure their library under /app/data as desired.
if not rel_path.lower().endswith('.mkv'):
rel_path = f"{rel_path}.mkv"
@ -1075,6 +1074,14 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
except Exception as e:
logger.debug(f"Unable to finalize Recording metadata: {e}")
# Optionally run comskip post-process
try:
from core.models import CoreSettings
if CoreSettings.get_dvr_comskip_enabled():
comskip_process_recording.delay(recording_id)
except Exception:
pass
@shared_task
def recover_recordings_on_startup():
@ -1133,6 +1140,181 @@ def recover_recordings_on_startup():
except Exception as e:
logger.error(f"Error during DVR recovery: {e}")
return f"Error: {e}"
@shared_task
def comskip_process_recording(recording_id: int):
"""Run comskip on the MKV to remove commercials and replace the file in place.
Safe to call even if comskip is not installed; stores status in custom_properties.comskip.
"""
import shutil
from .models import Recording
# Helper to broadcast status over websocket
def _ws(status: str, extra: dict | None = None):
try:
from core.utils import send_websocket_update
payload = {"success": True, "type": "comskip_status", "status": status, "recording_id": recording_id}
if extra:
payload.update(extra)
send_websocket_update('updates', 'update', payload)
except Exception:
pass
try:
rec = Recording.objects.get(id=recording_id)
except Recording.DoesNotExist:
return "not_found"
cp = rec.custom_properties or {}
file_path = (cp or {}).get("file_path")
if not file_path or not os.path.exists(file_path):
return "no_file"
if isinstance(cp.get("comskip"), dict) and cp["comskip"].get("status") == "completed":
return "already_processed"
comskip_bin = shutil.which("comskip")
if not comskip_bin:
cp["comskip"] = {"status": "skipped", "reason": "comskip_not_installed"}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('skipped', {"reason": "comskip_not_installed"})
return "comskip_missing"
base, _ = os.path.splitext(file_path)
edl_path = f"{base}.edl"
# Notify start
_ws('started', {"title": (cp.get('program') or {}).get('title') or os.path.basename(file_path)})
try:
cmd = [comskip_bin, "--output", os.path.dirname(file_path)]
# Prefer system ini if present to squelch warning and get sane defaults
for ini_path in ("/etc/comskip/comskip.ini", "/app/docker/comskip.ini"):
if os.path.exists(ini_path):
cmd.extend([f"--ini={ini_path}"])
break
cmd.append(file_path)
subprocess.run(cmd, check=True)
except Exception as e:
cp["comskip"] = {"status": "error", "reason": f"comskip_failed: {e}"}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('error', {"reason": str(e)})
return "comskip_failed"
if not os.path.exists(edl_path):
cp["comskip"] = {"status": "error", "reason": "edl_not_found"}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('error', {"reason": "edl_not_found"})
return "no_edl"
# Duration via ffprobe
def _ffprobe_duration(path):
try:
p = subprocess.run([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
return float(p.stdout.strip())
except Exception:
return None
duration = _ffprobe_duration(file_path)
if duration is None:
cp["comskip"] = {"status": "error", "reason": "duration_unknown"}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('error', {"reason": "duration_unknown"})
return "no_duration"
commercials = []
try:
with open(edl_path, "r") as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 2:
try:
s = float(parts[0]); e = float(parts[1])
commercials.append((max(0.0, s), min(duration, e)))
except Exception:
pass
except Exception:
pass
commercials.sort()
keep = []
cur = 0.0
for s, e in commercials:
if s > cur:
keep.append((cur, max(cur, s)))
cur = max(cur, e)
if cur < duration:
keep.append((cur, duration))
if not commercials or sum((e - s) for s, e in commercials) <= 0.5:
cp["comskip"] = {"status": "completed", "skipped": True, "edl": os.path.basename(edl_path)}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('skipped', {"reason": "no_commercials", "commercials": 0})
return "no_commercials"
workdir = os.path.dirname(file_path)
parts = []
try:
for idx, (s, e) in enumerate(keep):
seg = os.path.join(workdir, f"segment_{idx:03d}.mkv")
dur = max(0.0, e - s)
if dur <= 0.01:
continue
subprocess.run([
"ffmpeg", "-y", "-ss", f"{s:.3f}", "-i", file_path, "-t", f"{dur:.3f}",
"-c", "copy", "-avoid_negative_ts", "1", seg
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
parts.append(seg)
if not parts:
raise RuntimeError("no_parts")
list_path = os.path.join(workdir, "concat_list.txt")
with open(list_path, "w") as lf:
for pth in parts:
lf.write(f"file '{pth}'\n")
output_path = os.path.join(workdir, f"{os.path.splitext(os.path.basename(file_path))[0]}.cut.mkv")
subprocess.run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", output_path
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
os.replace(output_path, file_path)
except Exception:
shutil.copy(output_path, file_path)
try:
os.remove(list_path)
except Exception:
pass
for pth in parts:
try: os.remove(pth)
except Exception: pass
cp["comskip"] = {
"status": "completed",
"edl": os.path.basename(edl_path),
"segments_kept": len(parts),
"commercials": len(commercials),
}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('completed', {"commercials": len(commercials), "segments_kept": len(parts)})
return "ok"
except Exception as e:
cp["comskip"] = {"status": "error", "reason": str(e)}
rec.custom_properties = cp
rec.save(update_fields=["custom_properties"])
_ws('error', {"reason": str(e)})
return f"error:{e}"
def _resolve_poster_for_program(channel_name, program):
"""Internal helper that attempts to resolve a poster URL and/or Logo id.
Returns (poster_logo_id, poster_url) where either may be None.

View file

@ -0,0 +1,23 @@
from django.db import migrations
from django.utils.text import slugify
def add_comskip_setting(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
key = slugify("DVR Comskip Enabled")
CoreSettings.objects.get_or_create(
key=key,
defaults={"name": "DVR Comskip Enabled", "value": "false"},
)
class Migration(migrations.Migration):
dependencies = [
("core", "0015_dvr_templates"),
]
operations = [
migrations.RunPython(add_comskip_setting),
]

View file

@ -157,6 +157,7 @@ DVR_SERIES_RULES_KEY = slugify("DVR Series Rules")
DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir")
DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template")
DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template")
DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled")
class CoreSettings(models.Model):
@ -262,6 +263,15 @@ class CoreSettings(models.Model):
except cls.DoesNotExist:
return "Recordings/Movies/{start}.mkv"
@classmethod
def get_dvr_comskip_enabled(cls):
"""Return boolean-like string value ('true'/'false') for comskip enablement."""
try:
val = cls.objects.get(key=DVR_COMSKIP_ENABLED_KEY).value
return str(val).lower() in ("1", "true", "yes", "on")
except cls.DoesNotExist:
return False
@classmethod
def get_dvr_series_rules(cls):
"""Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}"""

6
docker/comskip.ini Normal file
View file

@ -0,0 +1,6 @@
; Minimal default comskip config
edl_out=1
output_edl=1
verbose=0
thread_count=0

View file

@ -170,6 +170,54 @@ export const WebsocketProvider = ({ children }) => {
// Handle standard message format for other event types
switch (parsedEvent.data?.type) {
case 'comskip_status': {
const rid = parsedEvent.data.recording_id;
const id = `comskip-${rid}`;
const status = parsedEvent.data.status;
const title = parsedEvent.data.title || 'Recording';
if (status === 'started') {
notifications.show({
id,
title: 'Removing commercials',
message: `Processing ${title}...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (status === 'completed') {
notifications.update({
id,
title: 'Commercials removed',
message: `${title} — kept ${parsedEvent.data.segments_kept} segments`,
color: 'green.5',
loading: false,
autoClose: 4000,
});
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
} else if (status === 'skipped') {
notifications.update({
id,
title: 'No commercials to remove',
message: parsedEvent.data.reason || '',
color: 'teal',
loading: false,
autoClose: 3000,
});
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
} else if (status === 'error') {
notifications.update({
id,
title: 'Comskip failed',
message: parsedEvent.data.reason || 'Unknown error',
color: 'red',
loading: false,
autoClose: 6000,
});
try { await useChannelsStore.getState().fetchRecordings(); } catch {}
}
break;
}
case 'epg_file':
fetchEPGs();
notifications.show({

View file

@ -1668,6 +1668,20 @@ export default class API {
}
}
static async runComskip(recordingId) {
try {
const resp = await request(`${host}/api/channels/recordings/${recordingId}/comskip/`, {
method: 'POST',
});
// Refresh recordings list to reflect comskip status when done later
// This endpoint just queues the task; the websocket/refresh will update eventually
return resp;
} catch (e) {
errorNotification('Failed to run comskip', e);
throw e;
}
}
// DVR Series Rules
static async listSeriesRules() {
try {

View file

@ -36,6 +36,7 @@ import useChannelsStore from '../store/channels';
import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
import { notifications } from '@mantine/notifications';
import API from '../api';
dayjs.extend(duration);
@ -253,6 +254,12 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
{onWatchRecording && (
<Button size="xs" variant="default" onClick={(e) => { e.stopPropagation?.(); onWatchRecording(); }} disabled={!canWatchRecording}>Watch</Button>
)}
{customProps.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && (
<Button size="xs" variant="light" color="teal" onClick={async (e) => {
e.stopPropagation?.();
try { await API.runComskip(recording.id); notifications.show({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', autoClose: 2000 }); } catch {}
}}>Remove commercials</Button>
)}
</Group>
</Group>
<Text size="sm">{start.format('MMM D, YYYY h:mma')} {end.format('h:mma')}</Text>
@ -344,6 +351,14 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
showVideo(fileUrl, 'vod', { name: recordingName, logo: { url: posterUrl } });
};
const handleRunComskip = async (e) => {
e?.stopPropagation?.();
try {
await API.runComskip(recording.id);
notifications.show({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', autoClose: 2000 });
} catch {}
};
// Cancel handling for series groups
const [cancelOpen, setCancelOpen] = React.useState(false);
const [busy, setBusy] = React.useState(false);
@ -494,6 +509,11 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
</Button>
</Tooltip>
)}
{!isUpcoming && customProps?.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && (
<Button size="xs" variant="light" color="teal" onClick={handleRunComskip}>
Remove commercials
</Button>
)}
</Group>
</Stack>
</Flex>

View file

@ -76,6 +76,7 @@ const SettingsPage = () => {
'dvr-movie-template': '',
'dvr-tv-fallback-template': '',
'dvr-movie-fallback-template': '',
'dvr-comskip-enabled': false,
},
validate: {
@ -422,10 +423,17 @@ const SettingsPage = () => {
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
<Accordion.Item value="dvr-settings">
<Accordion.Control>DVR Recording Paths</Accordion.Control>
<Accordion.Control>DVR</Accordion.Control>
<Accordion.Panel>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="sm">
<Switch
label="Enable Comskip (remove commercials after recording)"
{...form.getInputProps('dvr-comskip-enabled', { type: 'checkbox' })}
key={form.key('dvr-comskip-enabled')}
id={settings['dvr-comskip-enabled']?.id || 'dvr-comskip-enabled'}
name={settings['dvr-comskip-enabled']?.key || 'dvr-comskip-enabled'}
/>
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."