From 9b00c13d19b0c66043ea9a77f49fa6f428707e94 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 09:18:01 -0700 Subject: [PATCH 01/13] code style fixes --- boomstream.py | 48 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/boomstream.py b/boomstream.py index 919c540..9c33bb9 100755 --- a/boomstream.py +++ b/boomstream.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 +import argparse +import json import os import re import sys -import json -import time -import argparse + from base64 import b64decode from lxml.html import fromstring - import requests XOR_KEY = 'bla_bla_bla' @@ -26,7 +25,8 @@ 'sec-fetch-dest': 'document', 'accept-language': 'en-US,en;q=0.9,ru;q=0.8,es;q=0.7,de;q=0.6'} -class App(): + +class App(object): def __init__(self): parser = argparse.ArgumentParser(description='boomstream.com downloader') @@ -111,8 +111,7 @@ def extract_chunklist_urls(self, playlist): def get_chunklist(self, playlist): all_chunklists = self.extract_chunklist_urls(playlist) - print("This video is available in the following resolutions: %s" % \ - ", ".join(i[0] for i in all_chunklists)) + print(f"This video is available in the following resolutions: {', '.join(i[0] for i in all_chunklists)}") if self.args.resolution is not None: url = None @@ -127,7 +126,7 @@ def get_chunklist(self, playlist): # If the resolution is not specified in args, pick the best one url = sorted(all_chunklists, key=lambda x: x[2])[-1][1] - print("URL: %s" % url) + print(f"URL: {url}") if url is None: raise Exception("Could not find chunklist in playlist data") @@ -171,7 +170,7 @@ def encrypt(self, source_text, key): key += key for i in range(0, len(source_text)): - result += '%0.2x' % (ord(source_text[i]) ^ ord(key[i])) + result += f'{ord(source_text[i]) ^ ord(key[i]):02x}' return result @@ -180,20 +179,20 @@ def get_aes_key(self, xmedia_ready): Returns IV and 16-byte key which will be used to decrypt video chunks """ decr = self.decrypt(xmedia_ready, XOR_KEY) - print('Decrypted X-MEDIA-READY: %s' % decr) + print(f'Decrypted X-MEDIA-READY: {decr}') key = None - iv = ''.join(['%0.2x' % ord(c) for c in decr[20:36]]) + iv = ''.join([f'{ord(c):02x}' for c in decr[20:36]]) key_url = 'https://play.boomstream.com/api/process/' + \ self.encrypt(decr[0:20] + self.token, XOR_KEY) - print('key url = %s' % key_url) + print(f'key url = {key_url}') r = requests.get(key_url, headers=headers) key = r.text - print("IV = %s" % iv) - print("Key = %s" % key) + print(f"IV = {iv}") + print(f"Key = {key}") return iv, key def download_chunks(self, chunklist, iv, key): @@ -203,19 +202,18 @@ def download_chunks(self, chunklist, iv, key): os.mkdir(key) # Convert the key to format suitable for openssl command-line tool - hex_key = ''.join(['%0.2x' % ord(c) for c in key]) + hex_key = ''.join([f'{ord(c):02x}' for c in key]) for line in chunklist.split('\n'): if not line.startswith('https://'): continue - outf = os.path.join(key, "%0.5d" % i) + ".ts" + outf = os.path.join(key, f"{i:05d}.ts") if os.path.exists(outf): i += 1 - print("Chunk #%s exists [%s]" % (i, outf)) + print(f"Chunk #{i} exists [{outf}]") continue - print("Downloading chunk #%s" % i) - os.system('curl -s "%s" | openssl aes-128-cbc -K "%s" -iv "%s" -d > %s' % \ - (line, hex_key, iv, outf)) + print(f"Downloading chunk #{i}") + os.system(f'curl -s "{line}" | openssl aes-128-cbc -K "{hex_key}" -iv "{iv}" -d > {outf}') i += 1 def merge_chunks(self, key): @@ -223,9 +221,9 @@ def merge_chunks(self, key): Merges all chunks into one file and encodes it to MP4 """ print("Merging chunks...") - os.system("cat %s/*.ts > %s.ts" % (key, key,)) + os.system(f"cat {key}/*.ts > {key}.ts") print("Encoding to MP4") - os.system('ffmpeg -i %s.ts -c copy "%s".mp4' % (key, self.get_title(),)) + os.system(f'ffmpeg -i {key}.ts -c copy "{self.get_title()}".mp4') def get_title(self): return self.config['entity']['title'] @@ -253,15 +251,15 @@ def run(self): self.token = self.get_token() self.m3u8_url = self.get_m3u8_url() - print("Token = %s" % self.token) - print("Playlist: %s" % self.m3u8_url) + print(f"Token = {self.token}") + print(f"Playlist: {self.m3u8_url}") playlist = self.get_playlist(self.m3u8_url) chunklist = self.get_chunklist(playlist) xmedia_ready = self.get_xmedia_ready(chunklist) - print('X-MEDIA-READY: %s' % xmedia_ready) + print(f'X-MEDIA-READY: {xmedia_ready}') iv, key = self.get_aes_key(xmedia_ready) self.download_chunks(chunklist, iv, key) self.merge_chunks(key) From 478c0185f035fe834f4439e8b3234db644cf86d5 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 09:20:20 -0700 Subject: [PATCH 02/13] close files --- boomstream.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/boomstream.py b/boomstream.py index 9c33bb9..4d7f442 100755 --- a/boomstream.py +++ b/boomstream.py @@ -73,7 +73,8 @@ def get_boomstream_config(self, page): def get_playlist(self, url): if self.args.use_cache and os.path.exists('boomstream.playlist.m3u8'): - return open('boomstream.playlist.m3u8').read() + with open('boomstream.playlist.m3u8') as f: + return f.read() r = requests.get(url, headers=headers) @@ -132,7 +133,8 @@ def get_chunklist(self, playlist): raise Exception("Could not find chunklist in playlist data") if self.args.use_cache and os.path.exists('boomstream.chunklist.m3u8'): - return open('boomstream.chunklist.m3u8').read() + with open('boomstream.chunklist.m3u8') as f: + return f.read() r = requests.get(url, headers=headers) From c32d1ec97b4ac064c8661c1afb13755f49f1a461 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 09:27:33 -0700 Subject: [PATCH 03/13] use a separate path for output files --- .gitignore | 2 ++ boomstream.py | 51 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dd0da6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +output diff --git a/boomstream.py b/boomstream.py index 4d7f442..346dee3 100755 --- a/boomstream.py +++ b/boomstream.py @@ -4,6 +4,7 @@ import json import os import re +import string import sys from base64 import b64decode @@ -12,6 +13,10 @@ XOR_KEY = 'bla_bla_bla' +OUTPUT_PATH = "output" + +VALID_FILENAME_CHARS = set(f" -_.(){string.ascii_letters}{string.digits}") + headers = { 'authority': 'play.boomstream.com', 'pragma': 'no-cache', @@ -25,6 +30,16 @@ 'sec-fetch-dest': 'document', 'accept-language': 'en-US,en;q=0.9,ru;q=0.8,es;q=0.7,de;q=0.6'} +def valid_filename(s): + return ''.join(c for c in s if c in VALID_FILENAME_CHARS or c.isalpha()) + +def output_path(path): + return os.path.join(OUTPUT_PATH, path) + +def ensure_folder_exists(path): + if not os.path.exists(path): + os.mkdir(path) + class App(object): @@ -65,20 +80,20 @@ def get_boomstream_config(self, page): if result is None: raise Exception("Could not get boomstreamConfig from the main page") - with open('boomstream.config.json', 'wt') as f: + with open(output_path('boomstream.config.json'), 'wt') as f: del result["translations"] f.write(json.dumps(result, ensure_ascii=False, indent=4)) return result def get_playlist(self, url): - if self.args.use_cache and os.path.exists('boomstream.playlist.m3u8'): - with open('boomstream.playlist.m3u8') as f: + if self.args.use_cache and os.path.exists(output_path('boomstream.playlist.m3u8')): + with open(output_path('boomstream.playlist.m3u8')) as f: return f.read() r = requests.get(url, headers=headers) - with open('boomstream.playlist.m3u8', 'wt') as f: + with open(output_path('boomstream.playlist.m3u8'), 'wt') as f: f.write(r.text) return r.text @@ -132,13 +147,13 @@ def get_chunklist(self, playlist): if url is None: raise Exception("Could not find chunklist in playlist data") - if self.args.use_cache and os.path.exists('boomstream.chunklist.m3u8'): - with open('boomstream.chunklist.m3u8') as f: + if self.args.use_cache and os.path.exists(output_path('boomstream.chunklist.m3u8')): + with open(output_path('boomstream.chunklist.m3u8')) as f: return f.read() r = requests.get(url, headers=headers) - with open('boomstream.chunklist.m3u8', 'wt') as f: + with open(output_path('boomstream.chunklist.m3u8'), 'wt') as f: f.write(r.text) return r.text @@ -198,18 +213,16 @@ def get_aes_key(self, xmedia_ready): return iv, key def download_chunks(self, chunklist, iv, key): - i = 0 - - if not os.path.exists(key): - os.mkdir(key) + ensure_folder_exists(output_path(key)) # Convert the key to format suitable for openssl command-line tool hex_key = ''.join([f'{ord(c):02x}' for c in key]) + i = 0 for line in chunklist.split('\n'): if not line.startswith('https://'): continue - outf = os.path.join(key, f"{i:05d}.ts") + outf = output_path(os.path.join(key, f"{i:05d}.ts")) if os.path.exists(outf): i += 1 print(f"Chunk #{i} exists [{outf}]") @@ -223,20 +236,24 @@ def merge_chunks(self, key): Merges all chunks into one file and encodes it to MP4 """ print("Merging chunks...") - os.system(f"cat {key}/*.ts > {key}.ts") + os.system(f"cat {output_path(key)}/*.ts > {output_path(key)}.ts") print("Encoding to MP4") - os.system(f'ffmpeg -i {key}.ts -c copy "{self.get_title()}".mp4') + os.system(f'ffmpeg -i {output_path(key)}.ts -c copy "{output_path(valid_filename(self.get_title()))}".mp4') def get_title(self): return self.config['entity']['title'] def run(self): - if self.args.use_cache and os.path.exists('result.html'): - page = open('result.html').read() + ensure_folder_exists(OUTPUT_PATH) + + result_path = output_path('result.html') + + if self.args.use_cache and os.path.exists(result_path): + page = open(result_path).read() else: r = requests.get(self.args.url, headers=headers) - with open('result.html', 'wt') as f: + with open(result_path, 'wt') as f: f.write(r.text) page = r.text From 7df618aa9e3a64fb39cd475fe37d6a7f639245d6 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 09:35:16 -0700 Subject: [PATCH 04/13] support videos protected with pin code --- README.md | 9 +++++---- boomstream.py | 21 +++++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5b94c58..fba79d6 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,17 @@ using session token and the second part of `#EXT-X-MEDIA-READY`. ## Usage -Spicify `--url` and `--pin` in command line arguments: +Spicify `--entity` and `--pin` in command line arguments: ```bash -https://play.boomstream.com/TiAR7aDs?ppv=EswAWlFa --pin 123-456-789 +--entity TiAR7aDs --pin 123-456-789 ``` +`entity` value can be found in URL like https://play.boomstream.com/TiAR7aDs You can also specify a resolution using `--resolution` command line argument: ```bash -https://play.boomstream.com/TiAR7aDs?ppv=EswAWlFa --pin 123-456-789 --resolution "640x360" +--entity TiAR7aDs --pin 123-456-789 --resolution "640x360" ``` If resolution is not specified, the video with a highest one will be dowloaded. @@ -37,4 +38,4 @@ If resolution is not specified, the video with a highest one will be dowloaded. As the script was written and tested in Linux (specifically Ubuntu 18.04.4 LTS) it uses GNU/Linux `cat` tool to merge the video pieces into one single file. I think this is the only thing that prevents -it from running in Windows. If you have time to make a PR to fix that I will really appreciate. \ No newline at end of file +it from running in Windows. If you have time to make a PR to fix that I will really appreciate. diff --git a/boomstream.py b/boomstream.py index 346dee3..54009bf 100755 --- a/boomstream.py +++ b/boomstream.py @@ -45,7 +45,7 @@ class App(object): def __init__(self): parser = argparse.ArgumentParser(description='boomstream.com downloader') - parser.add_argument('--url', type=str, required=True) + parser.add_argument('--entity', type=str, required=True) parser.add_argument('--pin', type=str, required=True) parser.add_argument('--use-cache', action='store_true', required=False) parser.add_argument('--resolution', type=str, required=False) @@ -243,15 +243,24 @@ def merge_chunks(self, key): def get_title(self): return self.config['entity']['title'] + def get_access_cookies(self): + r = requests.post("https://play.boomstream.com/api/subscriptions/recovery", + headers={'content-type': 'application/json;charset=UTF-8'}, + data=f'{{"entity":"{self.args.entity}","code":"{self.args.pin}"}}') + cookie = json.loads(r.text)["data"]["cookie"] + return {cookie["name"]: cookie["value"]} + def run(self): ensure_folder_exists(OUTPUT_PATH) + cookies = self.get_access_cookies() + result_path = output_path('result.html') if self.args.use_cache and os.path.exists(result_path): page = open(result_path).read() else: - r = requests.get(self.args.url, headers=headers) + r = requests.get(f'https://play.boomstream.com/{self.args.entity}', headers=headers, cookies=cookies) with open(result_path, 'wt') as f: f.write(r.text) @@ -259,14 +268,6 @@ def run(self): page = r.text self.config = self.get_boomstream_config(page) - if len(self.config['mediaData']['records']) == 0: - print("Video record is not available. Probably, the live streaming" \ - "has not finished yet. Please, try to download once the translation" \ - "is finished." \ - "If you're sure that translation is finished, please create and issue" \ - "in project github tracker and attach your boomstream.config.json file") - return 1 - self.token = self.get_token() self.m3u8_url = self.get_m3u8_url() From 466787ec9b9432faf39301f5bc2fde4688cc5ab5 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 13:12:26 -0700 Subject: [PATCH 05/13] exit on keyboard interrupt --- boomstream.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/boomstream.py b/boomstream.py index 54009bf..2307c7f 100755 --- a/boomstream.py +++ b/boomstream.py @@ -5,6 +5,7 @@ import os import re import string +import subprocess import sys from base64 import b64decode @@ -40,6 +41,11 @@ def ensure_folder_exists(path): if not os.path.exists(path): os.mkdir(path) +def run_bash(command): + exit_code, output = subprocess.getstatusoutput(command) + if exit_code != 0: + print(output) + raise ValueError(f'failed with exit code {exit_code}') class App(object): @@ -228,7 +234,7 @@ def download_chunks(self, chunklist, iv, key): print(f"Chunk #{i} exists [{outf}]") continue print(f"Downloading chunk #{i}") - os.system(f'curl -s "{line}" | openssl aes-128-cbc -K "{hex_key}" -iv "{iv}" -d > {outf}') + run_bash(f'curl -s "{line}" | openssl aes-128-cbc -K "{hex_key}" -iv "{iv}" -d > {outf}') i += 1 def merge_chunks(self, key): @@ -236,9 +242,9 @@ def merge_chunks(self, key): Merges all chunks into one file and encodes it to MP4 """ print("Merging chunks...") - os.system(f"cat {output_path(key)}/*.ts > {output_path(key)}.ts") + run_bash(f"cat {output_path(key)}/*.ts > {output_path(key)}.ts") print("Encoding to MP4") - os.system(f'ffmpeg -i {output_path(key)}.ts -c copy "{output_path(valid_filename(self.get_title()))}".mp4') + run_bash(f'ffmpeg -i {output_path(key)}.ts -c copy "{output_path(valid_filename(self.get_title()))}".mp4') def get_title(self): return self.config['entity']['title'] From 9f8f593c2344a8221362b38005290c9d3bde431c Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 13:18:33 -0700 Subject: [PATCH 06/13] refetch zero parts --- boomstream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boomstream.py b/boomstream.py index 2307c7f..fb846e6 100755 --- a/boomstream.py +++ b/boomstream.py @@ -229,7 +229,7 @@ def download_chunks(self, chunklist, iv, key): if not line.startswith('https://'): continue outf = output_path(os.path.join(key, f"{i:05d}.ts")) - if os.path.exists(outf): + if os.path.exists(outf) and os.path.getsize(outf) > 0: i += 1 print(f"Chunk #{i} exists [{outf}]") continue From 2256cc558bbe32a395d850cc7c58eaa3e652b48b Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 13:23:17 -0700 Subject: [PATCH 07/13] use list of files to merge --- boomstream.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/boomstream.py b/boomstream.py index fb846e6..056323e 100755 --- a/boomstream.py +++ b/boomstream.py @@ -224,11 +224,14 @@ def download_chunks(self, chunklist, iv, key): # Convert the key to format suitable for openssl command-line tool hex_key = ''.join([f'{ord(c):02x}' for c in key]) + filenames = [] + i = 0 for line in chunklist.split('\n'): if not line.startswith('https://'): continue outf = output_path(os.path.join(key, f"{i:05d}.ts")) + filenames.append(outf) if os.path.exists(outf) and os.path.getsize(outf) > 0: i += 1 print(f"Chunk #{i} exists [{outf}]") @@ -236,13 +239,14 @@ def download_chunks(self, chunklist, iv, key): print(f"Downloading chunk #{i}") run_bash(f'curl -s "{line}" | openssl aes-128-cbc -K "{hex_key}" -iv "{iv}" -d > {outf}') i += 1 + return filenames - def merge_chunks(self, key): + def merge_chunks(self, filenames, key): """ Merges all chunks into one file and encodes it to MP4 """ print("Merging chunks...") - run_bash(f"cat {output_path(key)}/*.ts > {output_path(key)}.ts") + run_bash(f"cat {' '.join(filenames)} > {output_path(key)}.ts") print("Encoding to MP4") run_bash(f'ffmpeg -i {output_path(key)}.ts -c copy "{output_path(valid_filename(self.get_title()))}".mp4') @@ -287,8 +291,8 @@ def run(self): print(f'X-MEDIA-READY: {xmedia_ready}') iv, key = self.get_aes_key(xmedia_ready) - self.download_chunks(chunklist, iv, key) - self.merge_chunks(key) + filenames = self.download_chunks(chunklist, iv, key) + self.merge_chunks(filenames, key) if __name__ == '__main__': app = App() From 9ede86afba167e0b83eba11a7db4f1317f9bf21b Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 13:49:42 -0700 Subject: [PATCH 08/13] write to a separate dir --- boomstream.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/boomstream.py b/boomstream.py index 056323e..d719865 100755 --- a/boomstream.py +++ b/boomstream.py @@ -248,7 +248,9 @@ def merge_chunks(self, filenames, key): print("Merging chunks...") run_bash(f"cat {' '.join(filenames)} > {output_path(key)}.ts") print("Encoding to MP4") - run_bash(f'ffmpeg -i {output_path(key)}.ts -c copy "{output_path(valid_filename(self.get_title()))}".mp4') + ensure_folder_exists(output_path("results")) + result_filename = output_path(os.path.join("results", f"{valid_filename(self.get_title())}.mp4")) + run_bash(f'ffmpeg -i {output_path(key)}.ts -c copy "{result_filename}"') def get_title(self): return self.config['entity']['title'] From 605e2e06ad95079866a1a2f78ceadad09ec16096 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 14:36:10 -0700 Subject: [PATCH 09/13] check result duration; overwrite converted file if exists --- boomstream.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/boomstream.py b/boomstream.py index d719865..e630d90 100755 --- a/boomstream.py +++ b/boomstream.py @@ -46,6 +46,7 @@ def run_bash(command): if exit_code != 0: print(output) raise ValueError(f'failed with exit code {exit_code}') + return output class App(object): @@ -241,16 +242,25 @@ def download_chunks(self, chunklist, iv, key): i += 1 return filenames - def merge_chunks(self, filenames, key): + def merge_chunks(self, filenames, key, expected_result_duration): """ Merges all chunks into one file and encodes it to MP4 """ print("Merging chunks...") run_bash(f"cat {' '.join(filenames)} > {output_path(key)}.ts") print("Encoding to MP4") + run_bash(f'ffmpeg -nostdin -y -i {output_path(key)}.ts -c copy {output_path(key)}.mp4') + + result_format = run_bash(f'ffprobe -i {output_path(key)}.mp4 -show_format') + result_duration = float([line[len("duration="):] for line in result_format.split('\n') if line.startswith("duration=")][0]) + print(f"Result duration: {result_duration:.2f}") + print(f"Expected duration: {expected_result_duration:.2f}") + if abs(result_duration - expected_result_duration) > 1: + raise ValueError(f"unexpected result duration: {expected_result_duration:.2f} != {result_duration:.2f}") + ensure_folder_exists(output_path("results")) result_filename = output_path(os.path.join("results", f"{valid_filename(self.get_title())}.mp4")) - run_bash(f'ffmpeg -i {output_path(key)}.ts -c copy "{result_filename}"') + os.rename(f'{output_path(key)}.mp4', result_filename) def get_title(self): return self.config['entity']['title'] @@ -282,6 +292,7 @@ def run(self): self.config = self.get_boomstream_config(page) self.token = self.get_token() self.m3u8_url = self.get_m3u8_url() + self.expected_result_duration = float(self.config['mediaData']['duration']) print(f"Token = {self.token}") print(f"Playlist: {self.m3u8_url}") @@ -294,7 +305,7 @@ def run(self): print(f'X-MEDIA-READY: {xmedia_ready}') iv, key = self.get_aes_key(xmedia_ready) filenames = self.download_chunks(chunklist, iv, key) - self.merge_chunks(filenames, key) + self.merge_chunks(filenames, key, self.expected_result_duration) if __name__ == '__main__': app = App() From 80fd30b19bf4813797d681a909b4f2d94e1360e4 Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Mon, 19 Apr 2021 14:38:17 -0700 Subject: [PATCH 10/13] remove unused code --- boomstream.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/boomstream.py b/boomstream.py index e630d90..a0c156e 100755 --- a/boomstream.py +++ b/boomstream.py @@ -59,16 +59,10 @@ def __init__(self): self.args = parser.parse_args() def get_token(self): - if 'records' in self.config['mediaData'] and len(self.config['mediaData']['records']) > 0: - return b64decode(self.config['mediaData']['records'][0]['token']).decode('utf-8') - else: - return b64decode(self.config['mediaData']['token']).decode('utf-8') + return b64decode(self.config['mediaData']['token']).decode('utf-8') def get_m3u8_url(self): - if 'records' in self.config['mediaData'] and len(self.config['mediaData']['records']) > 0: - return b64decode(self.config['mediaData']['records'][0]['links']['hls']).decode('utf-8') - else: - return b64decode(self.config['mediaData']['links']['hls']).decode('utf-8') + return b64decode(self.config['mediaData']['links']['hls']).decode('utf-8') def get_boomstream_config(self, page): """ From ab54b602cee04820cf4bb0c5b1fe63488a49320d Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Fri, 23 Apr 2021 19:55:09 -0700 Subject: [PATCH 11/13] increase duration check 1->2 seconds diff --- boomstream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boomstream.py b/boomstream.py index a0c156e..bd0b2c2 100755 --- a/boomstream.py +++ b/boomstream.py @@ -249,7 +249,7 @@ def merge_chunks(self, filenames, key, expected_result_duration): result_duration = float([line[len("duration="):] for line in result_format.split('\n') if line.startswith("duration=")][0]) print(f"Result duration: {result_duration:.2f}") print(f"Expected duration: {expected_result_duration:.2f}") - if abs(result_duration - expected_result_duration) > 1: + if abs(result_duration - expected_result_duration) > 2: raise ValueError(f"unexpected result duration: {expected_result_duration:.2f} != {result_duration:.2f}") ensure_folder_exists(output_path("results")) From 8acb0c2c6e51a2b532475dfe5181d1d2d7f6b60f Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Sat, 24 Apr 2021 05:20:29 -0700 Subject: [PATCH 12/13] handle authorization error --- boomstream.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/boomstream.py b/boomstream.py index bd0b2c2..7ab3a60 100755 --- a/boomstream.py +++ b/boomstream.py @@ -263,7 +263,13 @@ def get_access_cookies(self): r = requests.post("https://play.boomstream.com/api/subscriptions/recovery", headers={'content-type': 'application/json;charset=UTF-8'}, data=f'{{"entity":"{self.args.entity}","code":"{self.args.pin}"}}') - cookie = json.loads(r.text)["data"]["cookie"] + response = json.loads(r.text) + if "data" not in response or "cookie" not in response["data"]: + if "errors" not in response or "code" not in response: + raise ValueError(f"unexpected response on authorization: {r.text}") + else: + raise ValueError(f"authorization failed: {response['code']} {response['errors']}") + cookie = response["data"]["cookie"] return {cookie["name"]: cookie["value"]} def run(self): From c6b9b02648c0202420df24ea96a44fbbc678a9eb Mon Sep 17 00:00:00 2001 From: Andrey Siunov Date: Sat, 24 Apr 2021 08:29:05 -0700 Subject: [PATCH 13/13] make pin code optional --- README.md | 14 ++++++-------- boomstream.py | 12 ++++++++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fba79d6..cabf754 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,19 @@ using session token and the second part of `#EXT-X-MEDIA-READY`. ## Usage -Spicify `--entity` and `--pin` in command line arguments: +Command line arguments: -```bash ---entity TiAR7aDs --pin 123-456-789 -``` -`entity` value can be found in URL like https://play.boomstream.com/TiAR7aDs +`--entity` (required) - value can be found in URL like https://play.boomstream.com/TiAR7aDs -You can also specify a resolution using `--resolution` command line argument: +`--pin` - required only for content protected with a pin code +`--resolution` - video resolution. If not specified, the video with a highest one will be downloaded + +Excample: ```bash --entity TiAR7aDs --pin 123-456-789 --resolution "640x360" ``` -If resolution is not specified, the video with a highest one will be dowloaded. - ## Requirements * openssl diff --git a/boomstream.py b/boomstream.py index 7ab3a60..6a9d6f2 100755 --- a/boomstream.py +++ b/boomstream.py @@ -53,7 +53,7 @@ class App(object): def __init__(self): parser = argparse.ArgumentParser(description='boomstream.com downloader') parser.add_argument('--entity', type=str, required=True) - parser.add_argument('--pin', type=str, required=True) + parser.add_argument('--pin', type=str, required=False) parser.add_argument('--use-cache', action='store_true', required=False) parser.add_argument('--resolution', type=str, required=False) self.args = parser.parse_args() @@ -260,9 +260,12 @@ def get_title(self): return self.config['entity']['title'] def get_access_cookies(self): + pin = self.args.pin + if pin is None: + return {} r = requests.post("https://play.boomstream.com/api/subscriptions/recovery", headers={'content-type': 'application/json;charset=UTF-8'}, - data=f'{{"entity":"{self.args.entity}","code":"{self.args.pin}"}}') + data=f'{{"entity":"{self.args.entity}","code":"{pin}"}}') response = json.loads(r.text) if "data" not in response or "cookie" not in response["data"]: if "errors" not in response or "code" not in response: @@ -290,6 +293,11 @@ def run(self): page = r.text self.config = self.get_boomstream_config(page) + if "mediaData" not in self.config or "duration" not in self.config['mediaData']: + raise ValueError( + "Video config is not available. Probably, the live streaming has not finished yet, or you use " + "an incorrect pin code. If you're sure that translation is finished and pin code is correct, please " + "create an issue in project github tracker and attach your boomstream.config.json file.") self.token = self.get_token() self.m3u8_url = self.get_m3u8_url() self.expected_result_duration = float(self.config['mediaData']['duration'])