Skip to content
This repository was archived by the owner on Nov 9, 2022. It is now read-only.

Commit 4b27ad8

Browse files
committed
Manually supply the AES encryption keys to ffmpeg
The Impartus server uses a custom logic to handle the AES key used to decrypt the HLS stream, which caused ffmpeg to fail. Now we extract the key beforehand, apply the same custom logic and supply the modified m3u8 playlist as input to ffmpeg. Fixes #8
1 parent 09730b7 commit 4b27ad8

File tree

1 file changed

+42
-20
lines changed

1 file changed

+42
-20
lines changed

downloader.py

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import re
12
import subprocess as sp
23
import tempfile
34
from functools import partial
45
from http.server import HTTPServer, SimpleHTTPRequestHandler
5-
from itertools import chain
66
from multiprocessing.dummy import Process
77
from pathlib import Path
8-
from urllib.parse import quote
8+
from urllib.parse import quote, parse_qsl, urlparse
99

1010
import requests
1111
from utils import find_startswith, sp_args
1212

13+
TEMP_DIR = tempfile.TemporaryDirectory(prefix="ilc-scraper")
14+
TEMP_DIR_PATH = Path(TEMP_DIR.name)
15+
1316

1417
class DirServer(Process):
1518
"""Serve the given directory using a simple HTTP server on localhost."""
@@ -18,16 +21,16 @@ class DirServer(Process):
1821

1922
def __new__(cls, *args, **kwargs):
2023
if not cls._dir_server:
21-
cls._dir_server = super().__new__(cls, *args, **kwargs)
24+
cls._dir_server = super().__new__(cls)
2225
return cls._dir_server
2326

2427
def __init__(self, dir_=None, *args, **kwargs):
2528
super().__init__(*args, **kwargs)
2629
self.daemon = True
2730
self.temp = dir_ is None
2831
if self.temp: # Create a temp directory
29-
self.temp_dir = tempfile.TemporaryDirectory(prefix="ilc-scraper")
30-
self.dir = Path(self.temp_dir.name)
32+
self.temp_dir = TEMP_DIR
33+
self.dir = TEMP_DIR_PATH
3134
else: # Use given directory
3235
self.dir = Path(dir_)
3336
assert self.dir.exists()
@@ -89,7 +92,7 @@ def get_angle_playlists(variant_pls):
8992
headers = pls[:headers_end]
9093
angle1_end = find_startswith(pls, "#EXT-X-DISCONTINUITY")
9194
if angle1_end is None: # only one angle is present
92-
return {1: variant_pls}
95+
return {1: pls}
9396

9497
angle1 = pls[:angle1_end] + ["#EXT-X-ENDLIST", ""]
9598

@@ -98,12 +101,33 @@ def get_angle_playlists(variant_pls):
98101
last_key = find_startswith(angle1, "#EXT-X-KEY", rev=True)
99102
angle2.insert(0, angle1[last_key])
100103
angle2 = headers + angle2
101-
return {1: "\n".join(angle1), 2: "\n".join(angle2)}
104+
return {1: angle1, 2: angle2}
105+
106+
107+
def extract_enc_keys(angle_pls: list, token):
108+
sess = requests.Session()
109+
sess.cookies.set("Bearer", token)
110+
PAT = re.compile(r'URI="(?P<key_url>.*?)"')
111+
for i, line in enumerate(angle_pls):
112+
if not line.startswith("#EXT-X-KEY"):
113+
continue
114+
key_url = PAT.search(line)["key_url"]
115+
key_info = dict(parse_qsl(urlparse(key_url).query))
116+
orig_key = sess.get(key_url).content
117+
real_key = orig_key[::-1][:16]
118+
tmp_path = TEMP_DIR_PATH / "{ttid}_{keyid}.key".format(**key_info)
119+
tmp_path.write_bytes(real_key)
120+
angle_pls[i] = PAT.sub(f'URI="{tmp_path.resolve().as_uri()}"', line)
102121

103122

104123
def add_inputs(token, cmd, angle_playlists, angle):
105124
cookies_arg = ("-cookies", f"Bearer={token}; path=/") # needed to get auth to work
106-
125+
extra_arg = (
126+
"-allowed_extensions",
127+
"key,m3u8,ts",
128+
"-protocol_whitelist",
129+
"file,http,tcp,tls,crypto",
130+
)
107131
if angle > len(angle_playlists):
108132
print(
109133
f"Invalid angle {angle} selected.",
@@ -114,27 +138,25 @@ def add_inputs(token, cmd, angle_playlists, angle):
114138
for angle_num, angle_pls in angle_playlists.items():
115139
if angle and angle_num != angle:
116140
continue
141+
print(f"Extracting encryption keys for angle {angle_num}")
142+
extract_enc_keys(angle_pls, token)
117143
# the -cookies flag is only recognized by ffmpeg when the input is via http
118144
# so we serve the hls playlist via an http server, and send that as input
119-
cmd += cookies_arg + ("-i", DirServer.get_url(angle_pls))
145+
cmd += cookies_arg + extra_arg + ("-i", DirServer.get_url("\n".join(angle_pls)))
120146

121-
if not angle:
122-
# map all the input audio and video streams into separate tracks in output
123-
cmd += chain.from_iterable(
124-
("-map", f"{i}:v:0") for i in range(len(angle_playlists))
125-
)
126-
else:
127-
cmd.extend(("-map", "0:v:0"))
128-
cmd.extend(("-map", "0:a:0")) # get first audio stream (assuming all are in sync)
147+
# map the input video streams into separate tracks in output
148+
for i in range(bool(angle) or len(angle_playlists)):
149+
cmd += ("-map", f"{i}:v:0")
150+
151+
cmd += ("-map", "0:a:0") # get first audio stream (assuming all are in sync)
129152

130153

131154
def download_stream(token, stream_url, output_file: Path, quality="720p", angle=0):
155+
print("Processing", output_file.name)
132156
cmd = [
133157
"ffmpeg",
134158
"-loglevel",
135159
"error",
136-
"-protocol_whitelist",
137-
"file,http,tcp,tls,crypto",
138160
]
139161
variant_pls = get_variant_playlist(stream_url, quality)
140162
if not variant_pls:
@@ -145,7 +167,7 @@ def download_stream(token, stream_url, output_file: Path, quality="720p", angle=
145167

146168
cmd += ["-c", "copy", str(output_file)]
147169

148-
print("Downloading", output_file.name)
170+
print("Downloading using ffmpeg")
149171
# TODO: Display ffmpeg download progress by parsing output
150172
proc = sp.run(cmd, text=True, **sp_args)
151173
if proc.returncode:

0 commit comments

Comments
 (0)