├── .gitignore ├── requirements.txt ├── m3u8parse.py ├── README.md ├── downloader.py └── funimationdl.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | config.json 3 | *.ts -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pycryptodome 2 | pycaption 3 | streamlink -------------------------------------------------------------------------------- /m3u8parse.py: -------------------------------------------------------------------------------- 1 | def parse_playlist(plist): 2 | l = plist.split('\n') 3 | playlist = [] 4 | i = 0 5 | while i < len(l): 6 | if l[i].startswith('#EXT-X-STREAM-INF:'): 7 | url = l[i+1] 8 | res = l[i].rsplit('x', 1)[1] + 'p' 9 | bandwidth = int(l[i].split('BANDWIDTH=')[1].split(',')[0])//1024 10 | playlist.append({'url': url, 'res': res, 'bandwidth': bandwidth}) 11 | i+=1 12 | return playlist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # funimationdl 2 | 3 | A simple python script to download shows from funimation (.ts format) along with srt subtitles 4 | 5 | ## Pre Requisites 6 | All the necessary libraries can be installed using the following command 7 | ``` 8 | $ pip install -r requirements.txt 9 | ``` 10 | 11 | ## Usage: 12 | 13 | ```bash 14 | $ python funimationdl.py 15 | ``` 16 | On first usage, you will have to provide your funimation credentials. You will not have to keep providing these credentials again and again unless you delete the `config.json` file 17 | 18 | Please be patient with the download. This is not yet multiprocessed. 19 | 20 | ## TODO: 21 | - [ ] proxy support 22 | - [ ] automatic mkv conversion using ffmpeg 23 | - [ ] automatic sub muxing 24 | - [ ] future integration with crunchydl for automated duals 25 | -------------------------------------------------------------------------------- /downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import requests 4 | import sys 5 | import subprocess 6 | from Crypto.Cipher import AES 7 | 8 | def decrypt(data, key, iv): 9 | """Decrypt using AES CBC""" 10 | decryptor = AES.new(key, AES.MODE_CBC, iv=iv) 11 | return decryptor.decrypt(data) 12 | 13 | def get_binary(url): 14 | """Get binary data from URL""" 15 | a = requests.get(url, stream=True) 16 | return a.content 17 | 18 | def download_legacy(url, output_folder, epi_name='output', skip_ad=True): 19 | """Main""" 20 | base = url.rsplit('/', 1)[0] 21 | a = requests.get(url) 22 | data = a.text 23 | # make output folder 24 | os.makedirs(output_folder, exist_ok=True) 25 | # download and decrypt chunks 26 | parts = [] 27 | for part_id, sub_data in enumerate(data.split('#UPLYNK-SEGMENT:')): 28 | # skip ad 29 | if skip_ad: 30 | if re.findall('#UPLYNK-SEGMENT:.*,.*,ad', '#UPLYNK-SEGMENT:' + sub_data): 31 | continue 32 | # get key, iv and data 33 | chunks = re.findall('#EXT-X-KEY:METHOD=AES-128,URI="(.*)",IV=(.*)\s.*\s(.*)', sub_data) 34 | for chunk in chunks: 35 | key_url = chunk[0] 36 | iv_val = chunk[1][2:] 37 | data_url = chunk[2] 38 | file_name = os.path.basename(data_url).split('?')[0] 39 | print('Processing "%s"' % file_name) 40 | # download key and data 41 | key = get_binary(base + '/' + key_url) 42 | enc_data = get_binary(base + '/' + data_url) 43 | iv = bytearray.fromhex(iv_val) 44 | # save decrypted data to file 45 | out_file = os.path.join(output_folder, '%s' % file_name) 46 | with open(out_file, 'wb') as f: 47 | dec_data = decrypt(enc_data, key, iv) 48 | f.write(dec_data) 49 | parts.append(out_file) 50 | if os.path.exists(os.path.join(output_folder, epi_name + 'ts')): 51 | os.remove(os.path.join(output_folder, epi_name + 'ts')) 52 | with open(os.path.join(output_folder, epi_name + 'ts'), 'ab') as f: 53 | for i in parts: 54 | with open(i, 'rb') as f2: 55 | f.write(f2.read()) 56 | for i in parts: 57 | os.remove(i) 58 | 59 | def download(url, output_folder, epi_name='output', skip_ad=True): 60 | cmd = ['streamlink', '--force', url, 'live', '-o', os.path.join(output_folder, epi_name+'.ts')] 61 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE) 62 | out, err = p.communicate() 63 | print(out.decode('utf-8')) -------------------------------------------------------------------------------- /funimationdl.py: -------------------------------------------------------------------------------- 1 | import requests, json, os, sys, downloader, m3u8parse, pycaption, io 2 | from urllib.parse import urlencode 3 | 4 | API_ENDPOINT = 'https://prod-api-funimationnow.dadcdigital.com/api' 5 | 6 | def dump_log(x): 7 | with open('log', 'w') as f: 8 | f.write(json.dumps(x, indent=4)) 9 | 10 | def authenticate(username, password): 11 | if not os.path.exists('config.json'): 12 | with open('config.json', 'w') as f: 13 | f.write('{}') 14 | with open('config.json', 'r') as f: 15 | config = json.load(f) 16 | token = login(username, password) 17 | if token: 18 | config['token'] = token 19 | with open('config.json', 'w') as f: 20 | f.write(json.dumps(config, indent=4)) 21 | 22 | def login(username, password): 23 | body = {'username': username, 'password': password} 24 | url = API_ENDPOINT + '/auth/login/' 25 | r = requests.post(url, data=body).json() 26 | if 'token' in r: 27 | return r['token'] 28 | return None 29 | 30 | def get_show(token, id): 31 | x = api_request({'baseUrl': API_ENDPOINT, 'url': f'/source/catalog/title/{id}', 'token': token}) 32 | if 'status' in x: 33 | print(f'[ERROR] Error #{x["status"]}: {x["data"]["errors"][0]["detail"]}') 34 | elif 'items' not in x: 35 | print('[ERROR] Show not found!') 36 | elif len(x['items']) < 1: 37 | print('[ERROR] No items after search!') 38 | show = x['items'][0] 39 | print(f'[#{show["id"]}] {show["title"]} ({show["releaseYear"]})') 40 | qs = {'limit': '-1', 'sort': 'order', 'sort_direction': 'ASC', 'title_id': id} 41 | return api_request({'baseUrl': API_ENDPOINT, 'url': f'/funimation/episodes/', 'qs': qs, 'token': token}) 42 | 43 | def select_episode(show_data): 44 | if not show_data: 45 | return 46 | counter = 1 47 | for i in show_data['items']: 48 | e_num = i['item']['episodeNum'] 49 | if e_num == '': e_num = i['item']['episodeId'] 50 | print(f'{str(counter).zfill(3)} [{e_num}] {i["item"]["episodeName"]}') 51 | counter += 1 52 | x = int(input("Enter episode index to download: ")) 53 | epi = show_data['items'][x - 1] 54 | show_slug = epi['item']['titleSlug'] 55 | epi_slug = epi['item']['episodeSlug'] 56 | return (show_slug, epi_slug) 57 | 58 | def get_episode(token, show_slug, epi_slug, output_folder): 59 | x = api_request({'baseUrl': API_ENDPOINT, 'url': f'/source/catalog/episode/{show_slug}/{epi_slug}', 'token': token}) 60 | if not x: 61 | return 62 | x = x['items'][0] 63 | snum = enum = '?' 64 | if 'seasonNumber' in x['parent']: 65 | snum = x["parent"]["seasonNumber"] 66 | if 'number' in x: 67 | enum = x['number'] 68 | ename = f'{x["parent"]["title"]} - S{snum}E{enum} - {x["title"]}' 69 | print(f'[INFO] {ename}') 70 | media = x['media'] 71 | tracks = [] 72 | uncut = {'Japanese': False, 'English': False} 73 | for m in media: 74 | if m['mediaType'] == 'experience': 75 | if 'uncut' in m['version'].lower(): 76 | uncut[m['language']] = True 77 | tracks.append({'id': m['id'], 'language': m['language'], 'version': m['version'], 'type': m['experienceType'], 'subs': get_subs(m['mediaChildren'])}) 78 | if tracks == []: return 79 | for i in tracks: 80 | print(f'{str(tracks.index(i) + 1).zfill(2)} [{i["id"]}] {i["language"]} - {i["version"]}') 81 | sel_id = tracks[int(input("Select which version you want to download: ")) - 1] 82 | sel_id['name'] = ename 83 | download_episode(token, sel_id, output_folder) 84 | 85 | def download_episode(token, epi, output_folder): 86 | x = api_request({'baseUrl': API_ENDPOINT, 'url': f'/source/catalog/video/{epi["id"]}/signed', 'token': token, 'dinstid': 'Android Phone'}) 87 | if not x: 88 | return 89 | if 'errors' in x: 90 | print(f'[ERROR] Error #{x["errors"][0]["code"]}: {x["errors"][0]["detail"]}') 91 | return 92 | vid_path = None 93 | for i in x["items"]: 94 | if i["videoType"] == 'm3u8': 95 | vid_path = i['src'] 96 | if vid_path == None: 97 | return 98 | a = requests.get(vid_path) 99 | playlist = m3u8parse.parse_playlist(a.text) 100 | print('[INFO] Available qualities:') 101 | for i in range(len(playlist)): 102 | print(f'{str(i+1).zfill(2)} Resolution: {playlist[i]["res"]} [Bandwidth: {playlist[i]["bandwidth"]}KiB/s]') 103 | url = playlist[int(input("Select the stream to download: ")) - 1]['url'] 104 | downloader.download(url, output_folder, epi_name=epi["name"]) 105 | download_subs(epi['subs'], output_folder, epi["name"]) 106 | 107 | def get_subs(m): 108 | for i in m: 109 | fp = i['filePath'] 110 | if fp.split('.')[-1] == 'dfxp': return fp 111 | return False 112 | 113 | def download_subs(link, output_folder, name): 114 | x = requests.get(link, headers={ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0' }) 115 | caption_set = pycaption.DFXPReader().read(x.text) 116 | results = pycaption.SRTWriter().write(caption_set) 117 | with io.open(os.path.join(output_folder, name + '.srt'), 'w', encoding='utf-8') as f: 118 | f.write(results) 119 | 120 | def api_request(args): 121 | url = args['url'] 122 | headers = { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0' } 123 | if 'baseUrl' in args: 124 | url = args['baseUrl'] + args['url'] 125 | if 'qs' in args: 126 | url += '?' + urlencode(args['qs']) 127 | if 'dinstid' in args: 128 | headers['devicetype'] = args['dinstid'] 129 | if 'token' in args: 130 | token = args['token'] 131 | headers['Authorization'] = f'Token {token}' 132 | r = requests.get(url, headers=headers) 133 | try: 134 | x = r.json() 135 | return x 136 | except: 137 | return None 138 | 139 | if __name__ == "__main__": 140 | if len(sys.argv) < 3: 141 | print(f"Invalid usage: Use python {sys.argv[0]} ") 142 | sys.exit(1) 143 | if sys.argv[1].isnumeric(): 144 | show_id = int(sys.argv[1]) 145 | else: 146 | r = requests.get(f'https://www.funimation.com/search/?{urlencode({"q": sys.argv[1]})}') 147 | show_id = int(r.text.split('data-id="')[1].split('"')[0]) 148 | if not os.path.exists('config.json'): 149 | u = input("Enter Username: ") 150 | p = input("Enter Password: ") 151 | authenticate(u, p) 152 | with open('config.json') as f: 153 | cfg = json.load(f) 154 | show = get_show(cfg['token'], show_id) 155 | s, e = select_episode(show) 156 | get_episode(cfg['token'], s, e, sys.argv[2]) --------------------------------------------------------------------------------