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
+