From 6d2c79fb0930129524a1f3b1f9a7e386a6f65efb Mon Sep 17 00:00:00 2001 From: 3l3m3nt Date: Sat, 6 Jun 2026 14:03:48 +1200 Subject: [PATCH] [dispatchwrapparr]: Bump version to 1.7.2 --- plugins/dispatchwrapparr/README.md | 15 +- plugins/dispatchwrapparr/dashdrm.py | 2 +- plugins/dispatchwrapparr/dispatchwrapparr.py | 10 +- plugins/dispatchwrapparr/hlsdrm.py | 145 +++++++++++++++---- plugins/dispatchwrapparr/plugin.json | 2 +- plugins/dispatchwrapparr/plugin.py | 2 +- 6 files changed, 132 insertions(+), 44 deletions(-) diff --git a/plugins/dispatchwrapparr/README.md b/plugins/dispatchwrapparr/README.md index 2198c74..97edafb 100644 --- a/plugins/dispatchwrapparr/README.md +++ b/plugins/dispatchwrapparr/README.md @@ -15,6 +15,7 @@ ✅ **Extended Stream Type Detection** — Fallback option that checks MIME type of stream URL for streamlink plugin selection\ ✅ **Streaming Radio Support with Song Information** — Play streaming radio to your TV with song information displayed on your screen for ICY and HLS stream types\ ✅ **Automated Stream Variant Detection** — Detects streams with no video or no audio and muxes in the missing components for compatibility with most players\ +✅ **Packed audio support for HLS streams** — Automatically extracts timestamp data from Apple ID3 metadata in muxed HLS streams to ensure correct playback\ ✅ **Support for SSAI/DAI** — Supports streams using SCTE-35 type discontinuities for Server-Side or Dynamic Ad Injection --- @@ -172,7 +173,7 @@ For streams where the video and audio use different clearkeys, place them in a c You do not need to use Dispatchwrapparr in order to use the DASH and HLS DRM plugins. If you wish, you can use the `dashdrm.py` and `hlsdrm.py` plugins on their own with Streamlink. -Credit: A huge thanks to [titus-au](https://github.com/titus-au/streamlink-plugin-dashdrm) whose dashdrm plugin +Credit to [titus-au](https://github.com/titus-au/streamlink-plugin-dashdrm) whose work with DASH DRM and streamlink provided the basis by which the Dispatchwrapparr plugins are created. ## ‼️ Troubleshooting @@ -182,21 +183,15 @@ In Jellyfin there are a number of settings related to m3u8 manifests. Make sure that all options ("Allow fMP4 transcoding container", "Allow stream sharing", "Auto-loop live streams", "Ignore DTS (decoding timestamp)", and "Read input at native frame rate") are unticked/disabled. -### My stream only plays audio or won't start - -Sometimes broadcasters don't include timestamps in the audio stream. Since version 1.6.2, Dispatchwrapparr tells ffmpeg to copy timestamps by default when muxing. There may be occasions where you may need to disable this feature. - -See the `-ffmpeg_nocopyts` option or `#ffmpeg_nocopyts=true` url fragment options for more details. - ### My streams stop on ad breaks, why? This is a technology called SCTE-35 (aka. SSAI or DAI) which injects ads/commercial breaks into streams based on parameters such as geolocation and demographics etc. While dispatchwrapparr has had some success in dealing with these types of streams, due to the way that some broadcasters implement SCTE-35 it may not always be stable. -### Can I use a custom Streamlink plugin? +### Can I use a custom Streamlink plugin? (ie. one not included in Streamlink by default) -Yes, maybe. Pass the `-streamlink_plugins` option to Dispatchwrapparr, specifying a custom directory to look for plugins in. In some circumstances, plugins may require Chromium based browsers for session tokens, and/or require additional arguments which Dispatchwrapparr will not pass through. The best option here is to just use Streamlink directly. +Yes, maybe, but it depends on if you need to pass any custom arguments to it. Pass the `-streamlink_plugins` option to Dispatchwrapparr, specifying a custom directory to look for plugins in. In some circumstances, plugins may require Chromium based browsers for session tokens, and/or require additional arguments which Dispatchwrapparr will not pass through. The best option here is to just use Streamlink directly. --- @@ -213,4 +208,4 @@ This script was made possible thanks to many wonderful python libraries and open - [matthuisman](https://github.com/matthuisman) this guy is a local streaming legend in New Zealand. His code and work with streams has taught me heaps! ## ⚖️ License -This project is licensed under the [MIT License](LICENSE). \ No newline at end of file +This project is licensed under the [MIT License](LICENSE). diff --git a/plugins/dispatchwrapparr/dashdrm.py b/plugins/dispatchwrapparr/dashdrm.py index c117230..9f40dee 100644 --- a/plugins/dispatchwrapparr/dashdrm.py +++ b/plugins/dispatchwrapparr/dashdrm.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) -__version__ = "1.7.1" +__version__ = "1.7.2" ''' DASHDRM plugin for Dispatchwrapparr & Streamlink diff --git a/plugins/dispatchwrapparr/dispatchwrapparr.py b/plugins/dispatchwrapparr/dispatchwrapparr.py index 9cf30e6..f5fb8fd 100755 --- a/plugins/dispatchwrapparr/dispatchwrapparr.py +++ b/plugins/dispatchwrapparr/dispatchwrapparr.py @@ -30,7 +30,7 @@ from streamlink.stream.stream import Stream from streamlink.options import Options -__version__ = "1.7.1" +__version__ = "1.7.2" def parse_args(): # Initial wrapper arguments @@ -534,6 +534,7 @@ def invoke_drm_plugin(session, url, type, clearkey): url = f"dashdrm://{url}" elif type == "hls": url = f"hlsdrm://{url}" + plugin_options.set("packed-audio", True) # Match plugin through new URL plugin_name, plugin_cls, url = session.resolve_url(url) plugin = plugin_cls(session, url, options=plugin_options) @@ -585,6 +586,9 @@ def find_by_mime_type(session, url): elif plugin_name == "dash": # Use our own DASH handler for dash period change and pacing support streams = invoke_drm_plugin(session, url, plugin_name, None) + elif plugin_name == "hls": + # Use our own DASH handler for dash period change and pacing support + streams = invoke_drm_plugin(session, url, plugin_name, None) return streams else: log.debug(f"Plugin '{plugin_name}' matched via resolver") @@ -612,8 +616,10 @@ def find_by_mime_type(session, url): elif stream_type == "hls": log.debug("HLS Stream Detected via MIME Type Resolver") + streams = invoke_drm_plugin(session, url, stream_type, None) + return streams streams = HLSStream.parse_variant_playlist(session, url) - return streams or {"live": HLSStream(session, url)} + #return streams or {"live": HLSStream(session, url)} elif stream_type == "http": log.debug("HTTP Stream Detected via MIME Type Resolver") diff --git a/plugins/dispatchwrapparr/hlsdrm.py b/plugins/dispatchwrapparr/hlsdrm.py index ed5caab..35f19c4 100644 --- a/plugins/dispatchwrapparr/hlsdrm.py +++ b/plugins/dispatchwrapparr/hlsdrm.py @@ -3,6 +3,7 @@ import re import logging import base64 +import struct from streamlink.exceptions import FatalPluginError from streamlink.plugin import Plugin, pluginmatcher, pluginargument @@ -14,7 +15,7 @@ log = logging.getLogger(__name__) -__version__ = "1.7.1" +__version__ = "1.7.2" ''' HLSDRM plugin for Dispatchwrapparr & Streamlink @@ -29,32 +30,34 @@ In case of an HLS stream where normally muxing is not required, we force muxing using our own class so that we can again get ffmpeg to decrypt the stream with supplied clearkey(s). +This plugin also contains experimental support for HLS muxed streams where PTS timestamps are extracted during a preload for insertion into the FFmpeg muxer. +Seeks to address the following issue: https://github.com/streamlink/streamlink/issues/4721 +To activate, pass the --hlsdrm-packed-audio argument. Off by default. + Thanks to Titus-AU, whose code is used as a reference and who laid a lot of a groundwork for DRM handling in Streamlink: https://github.com/titus-au ''' - HLSDRM_OPTIONS = [ "decryption-key", + "packed-audio" ] -@pluginmatcher( - re.compile(r"hlsdrm(?:variant)?://(?P\S+)(?:\s(?P.+))?$"), -) +@pluginmatcher(re.compile(r"hlsdrm(?:variant)?://(?P\S+)(?:\s(?P.+))?$")) @pluginmatcher( priority=LOW_PRIORITY, - pattern=re.compile( - # URL with explicit scheme, or URL with implicit HTTPS scheme and a path - r"(?P[^/]+/\S+\.m3u8(?:\?\S*)?)(?:\s(?P.+))?$", - re.IGNORECASE, - ), + pattern=re.compile(r"(?P[^/]+/\S+\.m3u8(?:\?\S*)?)(?:\s(?P.+))?$", re.IGNORECASE) ) - @pluginargument( "decryption-key", type="comma_list", help="Decryption key(s) to be passed to ffmpeg." ) +@pluginargument( + "packed-audio", + action="store_true", + help="Prereads muxed HLS audio streams to extract PTS values from Apple ID3 tags." +) class HLSDRM(Plugin): def _get_streams(self): @@ -62,12 +65,12 @@ def _get_streams(self): url = update_scheme("https://", data.get("url"), force=False) params = parse_params(data.get("params")) log.debug(f"HLSDRM: URL={url}; params={params}") - # Set streamlink to pass through encrypted - self.session.set_option("stream-passthrough-encrypted", True) - # Process and store plugin options + # Process and store plugin options for option in HLSDRM_OPTIONS: if option == 'decryption-key' and self.get_option('decryption-key'): self.session.options[option] = self._process_keys() + # If decryption key provided, set Streamlink session option 'stream-passthrough-encrypted' + self.session.set_option("stream-passthrough-encrypted", True) elif self.get_option(option): self.session.options[option] = self.get_option(option) @@ -76,15 +79,17 @@ def _get_streams(self): if not streams: streams = {"live": HLSStream(self.session, url, **params)} - # Wrap the returned streams to force them through our DRM Muxer wrapped_streams = {} for name, stream in streams.items(): if isinstance(stream, MuxedStream): - # If it's a multi-track HLS (separate audio/video), wrap the substreams + # muxed stream passed to MuxedStreamDRM class regardless of whether or not clearkey(s) provided wrapped_streams[name] = MuxedStreamDRM(self.session, stream) - else: - # If it's a single-track HLS, force it into FFmpeg so we can apply the key + elif isinstance(stream, HLSStream) and self.session.options.get("decryption-key"): + # if a single stream which normally isn't muxed, and decryption-key(s) provided, force muxing for decryption wrapped_streams[name] = SingleStreamDRM(self.session, stream) + else: + # single stream with no decryption-key(s) provided. Just a dumb stream - pass through without muxing + wrapped_streams = streams return wrapped_streams @@ -138,16 +143,19 @@ def _get_keys(cls, session): # If only 1 key is given, then we use that also for all remaining streams if len(keys) == 1: keys.extend(keys) - log.debug("FFMPEGMuxerDRM: Decryption Keys %s", keys) return keys def __init__(self, session, *streams, **options): + self.audio_pts = options.pop("audio_pts", None) super().__init__(session, *streams, **options) # if a decryption key is set, we rebuild the ffmpeg command list # to include the key before specifying the input streams # after that we append our inputs + keys = self._get_keys(session) key = 0 + # input counter + input = 0 # begin building a new ffmpeg command list old_cmd = self._cmd.copy() self._cmd = [] @@ -159,26 +167,82 @@ def __init__(self, session, *streams, **options): self._cmd.extend(['-thread_queue_size', '5120']) # generate presentation timestamps from dts self._cmd.extend(['-fflags', '+genpts']) + if input == 1: + # input is audio (always second) + if self.audio_pts is not None: + # set default 90KHz clock for Apple HLS streams + self.audio_clock = 90000 + log.debug(f"FFMPEGMuxerDRM: Applying itsoffset of {self.audio_pts/self.audio_clock} to audio input stream") + # apply timestamp offset for packed audio input + self._cmd.extend(['-itsoffset', f'{self.audio_pts/self.audio_clock}']) if keys: self._cmd.extend(["-decryption_key", keys[key]]) key += 1 - # If we had more streams than keys, start with the first audio key again if key == len(keys): key = 1 + input += 1 self._cmd.extend([cmd, _]) else: self._cmd.append(cmd) - # pop the last argument (the output pipe, e.g., "pipe:1") + output_pipe = self._cmd.pop() - # put any output ffmpeg options here if ever needed - # append the output pipe back to the very end self._cmd.append(output_pipe) log.debug("FFMPEGMuxerDRM: Updated ffmpeg command %s", self._cmd) +class PreReadStream: + """ + A wrapper class that returns the PTS for wrapped audio streams + by reading the Apple HLS ID3 tag by pre-reading the stream bytes before + falling back to the original file descriptor + """ + + def __init__(self, fd, pre_data): + self.fd = fd + self.pre_data = pre_data + self.pts = self._extract_pts() + + def _extract_pts(self): + # scans the byte buffer for the Apple HLS ID3 tag and extracts the PTS for use in the muxer later + if not self.pre_data: + return None + + marker = b"com.apple.streaming.transportStreamTimestamp\x00" + idx = self.pre_data.find(marker) + + if idx == -1: + return None + + start_idx = idx + len(marker) + if start_idx + 8 > len(self.pre_data): + return None + + pts_bytes = self.pre_data[start_idx : start_idx + 8] + pts = struct.unpack(">Q", pts_bytes)[0] + + # mask the upper 31 bits per the RFC + return pts & 0x1FFFFFFFF + + def read(self, size=-1): + if self.pre_data: + if size == -1 or size >= len(self.pre_data): + data = self.pre_data + self.pre_data = b"" + return data + else: + data = self.pre_data[:size] + self.pre_data = self.pre_data[size:] + return data + return self.fd.read(size) + + def close(self): + if hasattr(self.fd, 'close'): + self.fd.close() + class SingleStreamDRM(Stream): """ Wrapper for forcing the DRM FFmpeg muxer for single-track hls streams """ + def __init__(self, session, stream): super().__init__(session) self.stream = stream @@ -187,30 +251,53 @@ def open(self): reader = self.stream.open() fmt = self.session.options.get("ffmpeg-fout") or "mpegts" copyts = self.session.options.get("ffmpeg-copyts") - if copyts is None: - copyts = True + if copyts is None: copyts = True muxer = FFMPEGMuxerDRM(self.session, reader, format=fmt, copyts=copyts) return muxer.open() - class MuxedStreamDRM(Stream): """ Wrapper for invoking the DRM FFmpeg muxer for multi-track hls streams + Includes support for extracting PTS value from Apple ID3 tags by prereading the stream + before ffmpeg muxer invoked. """ + def __init__(self, session, muxed_stream): super().__init__(session) self.substreams = muxed_stream.substreams def open(self): - fds = [substream.open() for substream in self.substreams] - + # initialise audio_pts variable + audio_pts = None + + if self.session.options.get("packed-audio"): + # If packed-audio option specified, open the streams, + # read the first 2KB, and let the wrapper extract the PTS + # from the id3 tag + fds = [] + for substream in self.substreams: + fd = substream.open() + try: + chunk = fd.read(2048) + pre_stream = PreReadStream(fd, chunk) + + if pre_stream.pts is not None: + log.debug(f"HLSDRM: Successfully intercepted Audio PTS from raw data: {pre_stream.pts}") + audio_pts = pre_stream.pts + + fds.append(pre_stream) + except Exception as e: + log.debug(f"HLSDRM: Failed to pre-read stream data: {e}") + fds.append(fd) + else: + fds = [substream.open() for substream in self.substreams] fmt = self.session.options.get("ffmpeg-fout") or "mpegts" copyts = self.session.options.get("ffmpeg-copyts") if copyts is None: copyts = True - muxer = FFMPEGMuxerDRM(self.session, *fds, format=fmt, copyts=copyts) + muxer = FFMPEGMuxerDRM(self.session, *fds, format=fmt, copyts=copyts, audio_pts=audio_pts) return muxer.open() __plugin__ = HLSDRM \ No newline at end of file diff --git a/plugins/dispatchwrapparr/plugin.json b/plugins/dispatchwrapparr/plugin.json index 3ef5515..2aeeb9d 100644 --- a/plugins/dispatchwrapparr/plugin.json +++ b/plugins/dispatchwrapparr/plugin.json @@ -1,6 +1,6 @@ { "name": "Dispatchwrapparr", - "version": "1.7.1", + "version": "1.7.2", "description": "An intelligent DRM/Clearkey capable stream profile for Dispatcharr", "author": "jordandalley", "maintainers": ["michaelmurfy"], diff --git a/plugins/dispatchwrapparr/plugin.py b/plugins/dispatchwrapparr/plugin.py index 0ae0891..e8e8875 100644 --- a/plugins/dispatchwrapparr/plugin.py +++ b/plugins/dispatchwrapparr/plugin.py @@ -10,7 +10,7 @@ class Plugin: name = "Dispatchwrapparr" - version = "1.7.1" + version = "1.7.2" description = "An intelligent DRM/Clearkey capable stream profile for Dispatcharr" profile_name = "Dispatchwrapparr" # Directory where dispatchwrapparr will be copied to