1
+ import re
1
2
import subprocess as sp
2
3
import tempfile
3
4
from functools import partial
4
5
from http .server import HTTPServer , SimpleHTTPRequestHandler
5
- from itertools import chain
6
6
from multiprocessing .dummy import Process
7
7
from pathlib import Path
8
- from urllib .parse import quote
8
+ from urllib .parse import quote , parse_qsl , urlparse
9
9
10
10
import requests
11
11
from utils import find_startswith , sp_args
12
12
13
+ TEMP_DIR = tempfile .TemporaryDirectory (prefix = "ilc-scraper" )
14
+ TEMP_DIR_PATH = Path (TEMP_DIR .name )
15
+
13
16
14
17
class DirServer (Process ):
15
18
"""Serve the given directory using a simple HTTP server on localhost."""
@@ -18,16 +21,16 @@ class DirServer(Process):
18
21
19
22
def __new__ (cls , * args , ** kwargs ):
20
23
if not cls ._dir_server :
21
- cls ._dir_server = super ().__new__ (cls , * args , ** kwargs )
24
+ cls ._dir_server = super ().__new__ (cls )
22
25
return cls ._dir_server
23
26
24
27
def __init__ (self , dir_ = None , * args , ** kwargs ):
25
28
super ().__init__ (* args , ** kwargs )
26
29
self .daemon = True
27
30
self .temp = dir_ is None
28
31
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
31
34
else : # Use given directory
32
35
self .dir = Path (dir_ )
33
36
assert self .dir .exists ()
@@ -89,7 +92,7 @@ def get_angle_playlists(variant_pls):
89
92
headers = pls [:headers_end ]
90
93
angle1_end = find_startswith (pls , "#EXT-X-DISCONTINUITY" )
91
94
if angle1_end is None : # only one angle is present
92
- return {1 : variant_pls }
95
+ return {1 : pls }
93
96
94
97
angle1 = pls [:angle1_end ] + ["#EXT-X-ENDLIST" , "" ]
95
98
@@ -98,12 +101,33 @@ def get_angle_playlists(variant_pls):
98
101
last_key = find_startswith (angle1 , "#EXT-X-KEY" , rev = True )
99
102
angle2 .insert (0 , angle1 [last_key ])
100
103
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 )
102
121
103
122
104
123
def add_inputs (token , cmd , angle_playlists , angle ):
105
124
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
+ )
107
131
if angle > len (angle_playlists ):
108
132
print (
109
133
f"Invalid angle { angle } selected." ,
@@ -114,27 +138,25 @@ def add_inputs(token, cmd, angle_playlists, angle):
114
138
for angle_num , angle_pls in angle_playlists .items ():
115
139
if angle and angle_num != angle :
116
140
continue
141
+ print (f"Extracting encryption keys for angle { angle_num } " )
142
+ extract_enc_keys (angle_pls , token )
117
143
# the -cookies flag is only recognized by ffmpeg when the input is via http
118
144
# 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 ) ))
120
146
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)
129
152
130
153
131
154
def download_stream (token , stream_url , output_file : Path , quality = "720p" , angle = 0 ):
155
+ print ("Processing" , output_file .name )
132
156
cmd = [
133
157
"ffmpeg" ,
134
158
"-loglevel" ,
135
159
"error" ,
136
- "-protocol_whitelist" ,
137
- "file,http,tcp,tls,crypto" ,
138
160
]
139
161
variant_pls = get_variant_playlist (stream_url , quality )
140
162
if not variant_pls :
@@ -145,7 +167,7 @@ def download_stream(token, stream_url, output_file: Path, quality="720p", angle=
145
167
146
168
cmd += ["-c" , "copy" , str (output_file )]
147
169
148
- print ("Downloading" , output_file . name )
170
+ print ("Downloading using ffmpeg" )
149
171
# TODO: Display ffmpeg download progress by parsing output
150
172
proc = sp .run (cmd , text = True , ** sp_args )
151
173
if proc .returncode :
0 commit comments