diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index d2d0b923..1336a918 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -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:
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index 664dd723..09404138 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -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.
diff --git a/core/migrations/0016_dvr_comskip_enabled.py b/core/migrations/0016_dvr_comskip_enabled.py
new file mode 100644
index 00000000..429544eb
--- /dev/null
+++ b/core/migrations/0016_dvr_comskip_enabled.py
@@ -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),
+ ]
+
diff --git a/core/models.py b/core/models.py
index 3d8043a0..2823c65e 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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'}"""
diff --git a/docker/comskip.ini b/docker/comskip.ini
new file mode 100644
index 00000000..5dc94fd0
--- /dev/null
+++ b/docker/comskip.ini
@@ -0,0 +1,6 @@
+; Minimal default comskip config
+edl_out=1
+output_edl=1
+verbose=0
+thread_count=0
+
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 768d5ec1..f8bd7f4c 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -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({
diff --git a/frontend/src/api.js b/frontend/src/api.js
index b099a155..348e4b01 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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 {
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
index ca72134c..3dc7663f 100644
--- a/frontend/src/pages/DVR.jsx
+++ b/frontend/src/pages/DVR.jsx
@@ -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 && (
)}
+ {customProps.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && (
+
+ )}
{start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')}
@@ -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 }) => {
)}
+ {!isUpcoming && customProps?.status === 'completed' && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && (
+
+ )}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index c3e6961e..43425fbd 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -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 && (
<>
- DVR Recording Paths
+ DVR