diff --git a/plugins/dispatchwrapparr/README.md b/plugins/dispatchwrapparr/README.md index 97edafb..861f638 100644 --- a/plugins/dispatchwrapparr/README.md +++ b/plugins/dispatchwrapparr/README.md @@ -76,7 +76,11 @@ Important notes about fragment options: ### 🧑‍💻 Using the 'clearkey' URL fragment for DRM decryption -To use a clearkey for a particular stream using a URL fragment, simply create a custom m3u8 file that places the #clearkey= fragment at the end of the stream URL. +Most DRM implementations apply consistent encryption across both audio and video streams by using the same key. + +In this instance, you can simply just supply a clearkey. The HLSDRM and DASHDRM plugins will ignore any Key ID (KID) supplied to it. + +To use a single clearkey for a particular stream using a URL fragment, simply create a custom m3u8 file that places the #clearkey= fragment at the end of the stream URL. Below is an example that could be used for Channel 4 (UK): @@ -86,12 +90,25 @@ Below is an example that could be used for Channel 4 (UK): https://olsp.live.dash.c4assets.com/dash_iso_sp_tl/live/channel(c4)/manifest.mpd#clearkey=5ce85f1aa5771900b952f0ba58857d7a ``` -You can also add the cleakey fragment to the end of a URL of a channel that you add manually into Dispatcharr. - More channels can be added to the same m3u8 file, and may also contain a mixture of DRM and non-DRM encrypted streams. Simply upload your m3u8 file into Dispatcharr, select a Dispatchwrapparr stream profile, and it'll do the rest. +You can also add a cleakey fragment to the end of a URL of a stream being manually entered into Dispatcharr rather than administering a custom m3u8 file. This can be useful in testing. + +For more complex streams that contain different clearkeys for video and audio, two keys can be provided in the order of 'video,audio'. + +Streams which contain separate audio and video feeds, where only one is encrypted can specify 'none' as the key at the position. + +The below table provides real world examples of how the DASHDRM and HLSDRM streamlink plugins process clearkeys. + +| Example Fragment | Clearkey applied to VIDEO stream | Clearkey applied to AUDIO stream | +| :--- | :--- | :--- | +| #clearkey=a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 | a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 | a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 | +| #clearkey=a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0,b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 | a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 | b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 | +| #clearkey=a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0,none | a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 | None (Unencrypted) | +| #clearkey=none,b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 | None (Unencrypted) | b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 | + ### ▶️ Using the 'stream' URL fragment for manual stream variant/quality selection The `#stream` fragment allows you to manually select a stream variant. Sometimes there may be occasions where you may want to manually select various stream variants depending on your preferences. diff --git a/plugins/dispatchwrapparr/dashdrm.py b/plugins/dispatchwrapparr/dashdrm.py index 8c02374..907edb5 100644 --- a/plugins/dispatchwrapparr/dashdrm.py +++ b/plugins/dispatchwrapparr/dashdrm.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) -__version__ = "1.7.4" +__version__ = "1.7.5" ''' DASHDRM plugin for Dispatchwrapparr & Streamlink @@ -163,34 +163,53 @@ def _process_keys(self): Based on work by Titus-AU: https://github.com/titus-au (Thank you!!) ''' keys = self.get_option('decryption-key') - # if a colon separated key is given, assume its kid:key and take the - # last component after the colon return_keys = [] + for k in keys: + if k.lower() == "none": + return_keys.append(None) + continue + key = k.split(':') - key_len = len(key[-1]) - log.debug("MPEGDASHDRM: Decryption Key %s has %s digits", key[-1], key_len) - if key_len in (21, 22, 23, 24): - # key len of 21-24 may mean a base64 key was provided, so we - # try and decode it - log.debug("MPEGDASHDRM: Decryption key length is too short to be hex and looks like it might be base64, so we'll try and decode it..") - b64_string = key[-1] - padding = 4 - (len(b64_string) % 4) - b64_string = b64_string + ("=" * padding) - b64_key = base64.urlsafe_b64decode(b64_string).hex() - if b64_key: - key = [b64_key] - key_len = len(b64_key) - log.debug("MPEGDASHDRM: Decryption Key (post base64 decode) is %s and has %s digits", key[-1], key_len) + key_val = key[-1] + key_len = len(key_val) + log.debug("MPEGDASHDRM: Decryption Key %s has %s digits", key_val, key_len) + + is_valid_hex = False if key_len == 32: - # sanity check that it's a valid hex string try: - int(key[-1], 16) - except ValueError as err: - raise FatalPluginError(f"MPEGDASHDRM: Expecting 128bit key in 32 hex digits, but the key contains invalid hex.") - elif key_len != 32: - raise FatalPluginError(f"MPEGDASHDRM: Expecting 128bit key in 32 hex digits.") - return_keys.append(key[-1]) + int(key_val, 16) + is_valid_hex = True + except ValueError: + pass + + if not is_valid_hex: + try: + padding = 4 - (key_len % 4) + b64_string = key_val + ("=" * padding) if padding != 4 else key_val + decoded_bytes = base64.urlsafe_b64decode(b64_string) + + if len(decoded_bytes) == 16: + # Handle base64 encoded raw bytes + key_val = decoded_bytes.hex() + elif len(decoded_bytes) == 32: + # Handle base64 encoded hex strings (e.g. YmQ3ZWVh...) + key_val = decoded_bytes.decode('utf-8') + int(key_val, 16) # Validate it's hex + else: + raise ValueError + except Exception: + raise FatalPluginError("MPEGDASHDRM: Expecting 128bit key in 32 hex digits, or base64 equivalent.") + + if len(key_val) != 32: + raise FatalPluginError("MPEGDASHDRM: Expecting 128bit key in 32 hex digits.") + + return_keys.append(key_val) + + # Duplicate if only a single key is provided + if len(return_keys) == 1: + return_keys.append(return_keys[0]) + return return_keys class FFMPEGMuxerDRM(FFMPEGMuxer): @@ -201,12 +220,8 @@ class FFMPEGMuxerDRM(FFMPEGMuxer): @classmethod def _get_keys(cls, session): - keys=[] - if session.options.get("decryption-key"): - keys = session.options.get("decryption-key") - # If only 1 key is given, then we use that also for all remaining streams - if len(keys) == 1: - keys.extend(keys) + keys = session.options.get("decryption-key") or [] + if keys: log.debug("FFMPEGMuxerDRM: Decryption Keys %s", keys) return keys @@ -220,6 +235,7 @@ def __init__(self, session, *streams, **options): # begin building a new ffmpeg command list old_cmd = self._cmd.copy() self._cmd = [] + while len(old_cmd) > 0: cmd = old_cmd.pop(0) if cmd == "-i": @@ -228,8 +244,10 @@ def __init__(self, session, *streams, **options): self._cmd.extend(['-thread_queue_size', '5120']) # generate presentation timestamps from dts self._cmd.extend(['-fflags', '+genpts']) + if keys: - self._cmd.extend(["-decryption_key", keys[key]]) + if keys[key] is not None: + 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): @@ -237,6 +255,7 @@ def __init__(self, session, *streams, **options): 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() # ffmpeg output options here if needed diff --git a/plugins/dispatchwrapparr/dispatchwrapparr.py b/plugins/dispatchwrapparr/dispatchwrapparr.py index f0f202f..1f03bb8 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.4" +__version__ = "1.7.5" def parse_args(): # Initial wrapper arguments @@ -160,7 +160,6 @@ def open(self): cmd.extend([ "-re", # read at native rate "-readrate_initial_burst", "20", # initial burst of 20 seconds for fast startup - "-copyts", "-start_at_zero", # copy timestamps but start them at zero so it syncs with video stream "-i", self.url, "-f", "lavfi", "-re", # read at native rate @@ -508,10 +507,13 @@ def parse_fragment_headers(raw_header_values: str | list[str] | None) -> dict[st return parsed_headers -def detect_streams(session, url, clearkey=None): +def detect_streams(session, url, options): """ - Performs extended plugin matching for Streamlink - Returns a dict of possible streams + 1. Performs extended plugin matching for Streamlink + 2. Creates a dict of possible streams + 3. Selects the best stream from that dict (streams) + 4. Performs stream variant checking to determine if a stream is audio or video only + 5. Returns a stream for opening """ def invoke_drm_plugin(session, url, type, clearkey): @@ -526,7 +528,7 @@ def invoke_drm_plugin(session, url, type, clearkey): plugin_options = Options() if clearkey: # Set decryption keys for HLS/DASH DRM plugins - plugin_options.set("decryption-key", [clearkey]) + plugin_options.set("decryption-key", clearkey.split(",")) # Set plugin matcher URL's for matching if type == "dash": # By default, we'll ignore minimumUpdatePeriod and calculate availabilityStartTime from epoch if necessary @@ -572,121 +574,171 @@ def find_by_mime_type(session, url): else: stream_type = None return stream_type + + def create_silent_audio(session, ffmpeg, ffmpeg_loglevel) -> Stream: + """ + Return a Streamlink-compatible Stream that produces continuous silent AAC audio. + Uses ffmpeg with anullsrc. + """ + + cmd = [ + ffmpeg, + "-loglevel", ffmpeg_loglevel, + "-f", "lavfi", + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-c:a", "aac", + "-f", "adts", + "pipe:1" + ] + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=sys.stderr) + + class SilentAudioStream(Stream): + def open(self, *args, **kwargs): + return process.stdout + + def close(self): + if process.poll() is None: + process.kill() + + return SilentAudioStream(session) + + def check_stream_variant(stream, session=None): + """ Checks for different stream variants: + Eg. Audio Only streams or Video streams with no audio + + Can be disabled by using the -nocheckvariant argument + + Returns integer: + 0 = Normal Audio/Video + 1 = Audio Only Stream (Radio streams) + 2 = Video Only Stream (Cameras or other livestreams with no audio) + """ + + log.debug("Starting Stream Variant Checks...") + # HLSStream case + if isinstance(stream, HLSStream) and getattr(stream, "multivariant", None): + log.debug("Variant Check: HLSStream Selected") + # Find the playlist attributes by "best" selected url + selected_playlist = None + for playlist in stream.multivariant.playlists: + if playlist.uri == stream.url: + selected_playlist = playlist + break + + if selected_playlist: + codecs = selected_playlist.stream_info.codecs or [] + log.debug(f"Stream Codecs: {codecs}") + # Check for audio/video presence + has_video = any(c.startswith(("avc", "hev", "vp")) for c in codecs) + has_audio = any(c.startswith(("mp4a", "aac")) for c in codecs) + + if has_audio and not has_video: + log.debug("Detected Audio Only Stream") + return 1 + elif has_video and not has_audio: + log.debug("Detected Video Only Stream") + return 2 + else: + log.debug("Detected Audio+Video Stream") + return 0 + + # HTTPStream case + if isinstance(stream, HTTPStream): + log.debug("Variant Check: HTTPStream Selected") + if session: + try: + with session.http.get(stream.url, stream=True, timeout=5) as r: + ctype = r.headers.get("Content-Type", "").lower() + if ctype.startswith("audio/") or ctype.endswith("/ogg"): + log.debug(f"Detected Audio Only Stream by Content-Type: {ctype}") + return 1 + if ctype.startswith("video/"): + log.debug(f"Detected Video+Audio Stream by Content-Type: {ctype}") + return 0 + except Exception: + # Ignore errors (405, timeout, etc.) + return 0 + # Default/fallback + return 0 try: log.debug("First pass plugin matching with Streamlink Plugin Resolver...") plugin_name, plugin_cls, url = session.resolve_url(url) plugin = plugin_cls(session, url) if plugin_name == "dash": - streams = invoke_drm_plugin(session, url, plugin_name, clearkey) + streams = invoke_drm_plugin(session, url, plugin_name, options.clearkey) elif plugin_name == "hls": - streams = invoke_drm_plugin(session, url, plugin_name, clearkey) + streams = invoke_drm_plugin(session, url, plugin_name, options.clearkey) else: log.debug(f"Plugin '{plugin_name}' matched via resolver") streams = plugin.streams() - return streams except NoPluginError: log.debug("Second pass plugin matching via MIME Type Resolver...") plugin_name = find_by_mime_type(session, url) if plugin_name == "dash": log.debug("DASH DRM matched via MIME Type Resolver") - streams = invoke_drm_plugin(session, url, plugin_name, clearkey) + streams = invoke_drm_plugin(session, url, plugin_name, options.clearkey) elif plugin_name == "hls": log.debug("HLS DRM matched via MIME Type Resolver") - streams = invoke_drm_plugin(session, url, plugin_name, clearkey) + streams = invoke_drm_plugin(session, url, plugin_name, options.clearkey) elif plugin_name == "http": log.debug("HTTP Stream Detected via MIME Type Resolver") streams = {"live": HTTPStream(session, url)} else: raise PluginError("Could not detect stream type or no suitable plugin found.") - return streams - -def check_stream_variant(stream, session=None): - """ Checks for different stream variants: - Eg. Audio Only streams or Video streams with no audio - Can be disabled by using the -nocheckvariant argument + if not streams: + log.error("No playable streams found.") + return None + + log.info(f"Available streams: {', '.join(streams.keys())}") - Returns integer: - 0 = Normal Audio/Video - 1 = Audio Only Stream (Radio streams) - 2 = Video Only Stream (Cameras or other livestreams with no audio) - """ + # Logic for either manual or automatic stream selection + if options.stream: + # 'stream' fragment found. Select stream based on that selection. + log.info(f"Stream Selection: Manually specifying {options.stream}") + stream = streams.get(options.stream) + else: + log.info("Stream Selection: Automatic") + stream = streams.get("best") or streams.get("live") or next(iter(streams.values()), None) - log.debug("Starting Stream Variant Checks...") - # HLSStream case - if isinstance(stream, HLSStream) and getattr(stream, "multivariant", None): - log.debug("Variant Check: HLSStream Selected") - # Find the playlist attributes by "best" selected url - selected_playlist = None - for playlist in stream.multivariant.playlists: - if playlist.uri == stream.url: - selected_playlist = playlist - break - - if selected_playlist: - codecs = selected_playlist.stream_info.codecs or [] - log.debug(f"Stream Codecs: {codecs}") - # Check for audio/video presence - has_video = any(c.startswith(("avc", "hev", "vp")) for c in codecs) - has_audio = any(c.startswith(("mp4a", "aac")) for c in codecs) - - if has_audio and not has_video: - log.debug("Detected Audio Only Stream") - return 1 - elif has_video and not has_audio: - log.debug("Detected Video Only Stream") - return 2 - else: - log.debug("Detected Audio+Video Stream") - return 0 + # Stream not available, log error and exit + if not stream: + log.error("Stream selection not available.") + return - # HTTPStream case - if isinstance(stream, HTTPStream): - log.debug("Variant Check: HTTPStream Selected") - if session: - try: - with session.http.get(stream.url, stream=True, timeout=5) as r: - ctype = r.headers.get("Content-Type", "").lower() - if ctype.startswith("audio/") or ctype.endswith("/ogg"): - log.debug(f"Detected Audio Only Stream by Content-Type: {ctype}") - return 1 - if ctype.startswith("video/"): - log.debug(f"Detected Video+Audio Stream by Content-Type: {ctype}") - return 0 - except Exception: - # Ignore errors (405, timeout, etc.) - return 0 - # Default/fallback - return 0 - -def create_silent_audio(session, ffmpeg, ffmpeg_loglevel) -> Stream: - """ - Return a Streamlink-compatible Stream that produces continuous silent AAC audio. - Uses ffmpeg with anullsrc. - """ + if options.novideo is False and options.noaudio is False and options.novariantcheck is False and options.clearkey is None: + # Attempt to detect stream variant automatically (Eg. Video Only or Audio Only) + log.info("Checking stream variant") + variant = check_stream_variant(stream,session) + if variant == 1: + log.info("Stream detected as audio only/no video") + options.novideo = True + if variant == 2: + log.info("Stream detected as video only/no audio") + options.noaudio = True + else: + log.info("Skipping stream variant check") - cmd = [ - ffmpeg, - "-loglevel", ffmpeg_loglevel, - "-f", "lavfi", - "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", - "-c:a", "aac", - "-f", "adts", - "pipe:1" - ] - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=sys.stderr) + if options.noaudio and not options.novideo and not options.clearkey: + log.info("No Audio: Muxing silent audio into supplied video stream") + audio_stream = create_silent_audio(session,options.ffmpeg,options.ffmpeg_loglevel) + video_stream = stream + stream = MuxedStream(session, video_stream, audio_stream) - class SilentAudioStream(Stream): - def open(self, *args, **kwargs): - return process.stdout + if not options.noaudio and options.novideo and not options.clearkey: + log.info("No Video: Muxing blank video into supplied audio stream") + stream_type = None + if options.nosonginfo is False: + if isinstance(stream, HLSStream): + stream_type = "hls" + elif isinstance(stream, HTTPStream): + stream_type = "icy" + stream = PlayRadio(url, session.options.get("ffmpeg-ffmpeg"), options.ffmpeg_loglevel, headers=None, cookies=None, stream_type=stream_type) - def close(self): - if process.poll() is None: - process.kill() + return stream - return SilentAudioStream(session) def main(): # Set log as global var @@ -774,8 +826,6 @@ def main(): # Set generic session options for Streamlink session.set_option("stream-segment-threads", 2) - # Start HLS stream further in from the live edge - session.set_option("hls-live-edge", 6) # Increase the size of the Streamlink Ringbuffer to 64MiB session.set_option("ringbuffer-size", 67108864) # If cli -proxy argument supplied @@ -797,7 +847,7 @@ def main(): FFmpeg Options that apply to all streams should they require muxing """ - # Check for -ffmpeg cli option + # Check for -ffmpeg cli option for custom paths if dw_opts.ffmpeg: session.set_option("ffmpeg-ffmpeg", dw_opts.ffmpeg) log.info(f"FFmpeg: Location '{dw_opts.ffmpeg}'") @@ -811,12 +861,15 @@ def main(): else: # set global ffmpeg if no other found session.set_option("ffmpeg-ffmpeg", "ffmpeg") + # Set copy timestamps for ffmpeg muxing based on user input + if dw_opts.ffmpeg_nocopyts: + session.set_option("ffmpeg-copyts", False) + log.info("FFmpeg: Copying of timestamps disabled by ffmpeg-nocopyts option") + else: + session.set_option("ffmpeg-copyts", True) if dw_opts.ffmpeg_transcode_audio: session.set_option("ffmpeg-audio-transcode", dw_opts.ffmpeg_transcode_audio) log.info(f"FFmpeg: Transcode audio to '{dw_opts.ffmpeg_transcode_audio}'") - # Set copy timestamps for ffmpeg muxing based on user input - session.set_option("ffmpeg-copyts", not dw_opts.ffmpeg_nocopyts) - log.info(f"FFmpeg: Set copy timestamps to '{not dw_opts.ffmpeg_nocopyts}'") # Convert current python loglevel in an equivalent ffmpeg loglevel dw_opts.ffmpeg_loglevel = get_ffmpeg_loglevel(dw_opts.loglevel) session.set_option("ffmpeg-loglevel", dw_opts.ffmpeg_loglevel) # Set ffmpeg loglevel @@ -828,76 +881,13 @@ def main(): Stream detection and plugin loading """ - try: - # Pass stream detection off to the detect_streams function. Returns a dict of available streams in varying quality. - streams = detect_streams(session, url, dw_opts.clearkey) - except Exception as e: - log.error(f"Stream setup failed: {e}") - return - - # No streams found, log and error and exit - if not streams: - log.error("No playable streams found.") - return - - # Send a list of available streams to log output - log.info(f"Available streams: {', '.join(streams.keys())}") + # Pass streamlink session, url and dispatchwrapparr options into detect_streams function + stream = detect_streams(session, url, dw_opts) """ - Select the best stream(s) from the list of streams + Start the stream """ - # Logic for either manual or automatic stream selection - if dw_opts.stream: - # 'stream' fragment found. Select stream based on that selection. - log.info(f"Stream Selection: Manually specifying {dw_opts.stream}") - stream = streams.get(dw_opts.stream) - else: - log.info("Stream Selection: Automatic") - stream = streams.get("best") or streams.get("live") or next(iter(streams.values()), None) - - # Stream not available, log error and exit - if not stream: - log.error("Stream selection not available.") - return - - """ - Check the chosen stream for nuances such as video-only or audio-only feeds - """ - - # Do a variant check only if novideo, noaudio and novariantcheck are False and there dw_opts.clearkey is None - if dw_opts.novideo is False and dw_opts.noaudio is False and dw_opts.novariantcheck is False and dw_opts.clearkey is None: - # Attempt to detect stream variant automatically (Eg. Video Only or Audio Only) - log.debug("Checking stream variation...") - variant = check_stream_variant(stream,session) - if variant == 1: - log.info("Stream detected as audio only/no video") - dw_opts.novideo = True - if variant == 2: - log.info("Stream detected as video only/no audio") - dw_opts.noaudio = True - else: - log.info("Skipping stream variant check") - - if dw_opts.noaudio and not dw_opts.novideo and not dw_opts.clearkey: - log.info("No Audio: Muxing silent audio into supplied video stream") - audio_stream = create_silent_audio(session,dw_opts.ffmpeg,dw_opts.ffmpeg_loglevel) - video_stream = stream - stream = MuxedStream(session, video_stream, audio_stream) - - elif not dw_opts.noaudio and dw_opts.novideo and not dw_opts.clearkey: - log.info("No Video: Muxing blank video into supplied audio stream") - stream_type = None - if dw_opts.nosonginfo is False: - if isinstance(stream, HLSStream): - stream_type = "hls" - elif isinstance(stream, HTTPStream): - stream_type = "icy" - stream = PlayRadio(url, session.options.get("ffmpeg-ffmpeg"), dw_opts.ffmpeg_loglevel, headers=None, cookies=None, stream_type=stream_type) - - elif dw_opts.noaudio and dw_opts.novideo: - log.warning("Both 'noaudio' and 'novideo' specified. Ignoring both.") - try: log.info("Starting stream...") # MPEG-TS packet size diff --git a/plugins/dispatchwrapparr/hlsdrm.py b/plugins/dispatchwrapparr/hlsdrm.py index c77f725..ce977db 100644 --- a/plugins/dispatchwrapparr/hlsdrm.py +++ b/plugins/dispatchwrapparr/hlsdrm.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) -__version__ = "1.7.4" +__version__ = "1.7.5" ''' HLSDRM plugin for Dispatchwrapparr & Streamlink @@ -99,34 +99,51 @@ def _process_keys(self): Based on work by Titus-AU: https://github.com/titus-au (Thank you!!) ''' keys = self.get_option('decryption-key') - # if a colon separated key is given, assume its kid:key and take the - # last component after the colon return_keys = [] + for k in keys: + if k.lower() == "none": + return_keys.append(None) + continue + key = k.split(':') - key_len = len(key[-1]) - log.debug("HLSDRM: Decryption Key %s has %s digits", key[-1], key_len) - if key_len in (21, 22, 23, 24): - # key len of 21-24 may mean a base64 key was provided, so we - # try and decode it - log.debug("HLSDRM: Decryption key length is too short to be hex and looks like it might be base64, so we'll try and decode it..") - b64_string = key[-1] - padding = 4 - (len(b64_string) % 4) - b64_string = b64_string + ("=" * padding) - b64_key = base64.urlsafe_b64decode(b64_string).hex() - if b64_key: - key = [b64_key] - key_len = len(b64_key) - log.debug("HLSDRM: Decryption Key (post base64 decode) is %s and has %s digits", key[-1], key_len) + key_val = key[-1] + key_len = len(key_val) + log.debug("HLSDRM: Decryption Key %s has %s digits", key_val, key_len) + + is_valid_hex = False if key_len == 32: - # sanity check that it's a valid hex string try: - int(key[-1], 16) - except ValueError as err: - raise FatalPluginError(f"HLSDRM: Expecting 128bit key in 32 hex digits, but the key contains invalid hex.") - elif key_len != 32: - raise FatalPluginError(f"HLSDRM: Expecting 128bit key in 32 hex digits.") - return_keys.append(key[-1]) + int(key_val, 16) + is_valid_hex = True + except ValueError: + pass + + if not is_valid_hex: + try: + padding = 4 - (key_len % 4) + b64_string = key_val + ("=" * padding) if padding != 4 else key_val + decoded_bytes = base64.urlsafe_b64decode(b64_string) + + if len(decoded_bytes) == 16: + key_val = decoded_bytes.hex() + elif len(decoded_bytes) == 32: + key_val = decoded_bytes.decode('utf-8') + int(key_val, 16) + else: + raise ValueError + except Exception: + raise FatalPluginError("HLSDRM: Expecting 128bit key in 32 hex digits, or base64 equivalent.") + + if len(key_val) != 32: + raise FatalPluginError("HLSDRM: Expecting 128bit key in 32 hex digits.") + + return_keys.append(key_val) + + # Duplicate if only a single key is provided + if len(return_keys) == 1: + return_keys.append(return_keys[0]) + return return_keys class FFMPEGMuxerDRM(FFMPEGMuxer): @@ -137,12 +154,7 @@ class FFMPEGMuxerDRM(FFMPEGMuxer): @classmethod def _get_keys(cls, session): - keys=[] - if session.options.get("decryption-key"): - keys = session.options.get("decryption-key") - # If only 1 key is given, then we use that also for all remaining streams - if len(keys) == 1: - keys.extend(keys) + keys = session.options.get("decryption-key") or [] return keys def __init__(self, session, *streams, **options): @@ -154,8 +166,8 @@ def __init__(self, session, *streams, **options): 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 = [] @@ -176,7 +188,9 @@ def __init__(self, session, *streams, **options): # 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]]) + # check for keys and only provide -decryption_key if not None + if keys[key] is not None: + self._cmd.extend(["-decryption_key", keys[key]]) key += 1 if key == len(keys): key = 1 @@ -252,6 +266,7 @@ def open(self): fmt = self.session.options.get("ffmpeg-fout") or "mpegts" copyts = self.session.options.get("ffmpeg-copyts") if copyts is None: copyts = True + log.debug("Forcing Muxing for single") muxer = FFMPEGMuxerDRM(self.session, reader, format=fmt, copyts=copyts) return muxer.open() diff --git a/plugins/dispatchwrapparr/plugin.json b/plugins/dispatchwrapparr/plugin.json index d4b2935..f638de9 100644 --- a/plugins/dispatchwrapparr/plugin.json +++ b/plugins/dispatchwrapparr/plugin.json @@ -1,6 +1,6 @@ { "name": "Dispatchwrapparr", - "version": "1.7.4", + "version": "1.7.5", "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 9082353..cf2d99d 100644 --- a/plugins/dispatchwrapparr/plugin.py +++ b/plugins/dispatchwrapparr/plugin.py @@ -10,7 +10,7 @@ class Plugin: name = "Dispatchwrapparr" - version = "1.7.4" + version = "1.7.5" description = "An intelligent DRM/Clearkey capable stream profile for Dispatcharr" profile_name = "Dispatchwrapparr" # Directory where dispatchwrapparr will be copied to