mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 18:54:58 +00:00
Comskip Update
This commit is contained in:
parent
c76d68f382
commit
f652d2b233
9 changed files with 329 additions and 7 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
core/migrations/0016_dvr_comskip_enabled.py
Normal file
23
core/migrations/0016_dvr_comskip_enabled.py
Normal 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),
|
||||
]
|
||||
|
||||
|
|
@ -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
6
docker/comskip.ini
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
; Minimal default comskip config
|
||||
edl_out=1
|
||||
output_edl=1
|
||||
verbose=0
|
||||
thread_count=0
|
||||
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue