├── .gitattributes ├── README.md ├── save_livestream.py ├── split.py ├── upload.py └── upload_with_password.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # livestream_scripts 2 | 3 | Collection of scripts for archiving and organizing livestreams (mainly for r/EDMLivestreams). 4 | These should work on Windows, Linux and macOS. 5 | 6 | ### save_livestream.py 7 | 8 | Takes a base filename and twitch link, which can be either a link to a twitch channel or specific video. Automatically adds timestamps to the filename. Loops endlessly and can be left alone to watch for upcoming streams. 9 | 10 | Requires `streamlink` and `python3` to be installed and in your respective PATH environment variable. 11 | 12 | Example: 13 | ``` 14 | $ python3 save_livestream.py "Insomniac" https://twitch.tv/insomniac 15 | 16 | Downloading from Twitch! 17 | Downloading stream... 18 | URL: https://www.twitch.tv/insomniac 19 | Filename : insomniac - 2020-11-04 06-38-04.ts 20 | [cli][info] Found matching plugin twitch for URL https://www.twitch.tv/insomniac 21 | [cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p, 1080p (best) 22 | [cli][info] Opening stream: 1080p (hls) 23 | [plugin.twitch][info] Will skip ad segments 24 | [download][.. 2020-11-04 06-38-04.ts] Written 6.1 MB (10s @ 618.9 KB/s) 25 | 26 | [...] 27 | ``` 28 | 29 | ### split.py 30 | 31 | Splits a livestream recording into mkv and m4a files given a set of timestamps in the form of a simple text file. There's no transcoding involved, the video and audio streams are always just copied in order for them to not lose any quality. 32 | 33 | Requires `python3` and `ffmpeg`. 34 | 35 | Here's what one of those text files might look like: 36 | 37 | ``` 38 | couchlands - 2020-09-25 17-50-14.ts 39 | Couchlands 40 | 00:10:25 01:08:47 Swarm 41 | 01:10:17 02:08:05 Level Up 42 | 02:09:01 03:03:34 Samplifire 43 | 03:04:26 04:04:39 Hydraulix 44 | 04:09:20 05:09:08 Hekler 45 | 05:10:10 06:08:37 Habstrakt 46 | 06:09:10 07:05:53 Modestep 47 | 07:09:47 08:02:01 Kai Wachi 48 | 08:10:00 09:10:02 Barely Alive 49 | 09:14:08 10:10:12 Virtual Riot 50 | 10:11:09 11:33:34 Excision 51 | ``` 52 | 53 | The first line is the filename of the original file, the second line is the name of the event, which will be at the beginning of every filename. Neither can be left out. 54 | 55 | If you want a line to be skipped, just put `#` in front of it like this: 56 | ``` 57 | #09:14:08 10:10:12 Virtual Riot 58 | ``` 59 | 60 | If a line is not skipped but a file with the same name already exists it will be overwritten. 61 | 62 | If you named your timestamps file `timestamps.txt`, make sure your recording is inside the same folder you're running the script from and execute it like this: 63 | ``` 64 | $ python3 split.py timestamps.txt 65 | ``` 66 | The end result will look a little like this: 67 | 68 | ![](https://i.imgur.com/U3bHTIn.png) 69 | 70 | ### upload.py 71 | 72 | Used to upload files to gofile.io. Automatically formats links as markdown for convenience. 73 | 74 | Uses the timestamps file that was used together with the `split.py` script. You can also skip lines by putting `#` in front the line, same procedure as with `split.py`. 75 | 76 | Requires `curl` (which should come pre-installed on Windows/Linux/macOS) and `python3`. 77 | 78 | There's also `upload_with_password.py`, which automatically sets a password for every upload. The password is set to `nosharingallowed` by default, but can be changed. It is located right at the top of the file. If you decide to change it, make sure that it doesn't contain any spaces and only consists of letters and numbers. 79 | 80 | Installation: 81 | ``` 82 | $ python3 -m pip install requests 83 | ``` 84 | 85 | Usage: 86 | ``` 87 | $ python3 upload.py timestamps.txt 88 | ``` 89 | 90 | -------------------------------------------------------------------------------- /save_livestream.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | import sys 3 | import time 4 | import subprocess 5 | import argparse 6 | from shlex import shlex 7 | from time import gmtime, strftime 8 | from random import uniform 9 | 10 | MIN_WAIT = 45 11 | MAX_WAIT = 70 12 | TERM_SEQ = {} 13 | DEFAULT_TERM_SEQ = { 14 | 'el': '\33[K', # clr_eol, clear from the cursor to the end of the line 15 | 'el1': '\33[2K', # clr_bol, clear from the cursor to the beginning of the line 16 | # The parameter has 3 possible values according to 17 | # https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#text-modification 18 | # so technically, this value is actually el2 19 | 'cuu1': '\033[A', # cursor_up, move cursor up by one line 20 | 'cuu2': '\033[2A' # same as above, 2 lines 21 | } 22 | 23 | def get_term_seq(char_type): 24 | """Query terminfo string capabilities with tput for a non-printable 25 | character that can be used by the current terminal (see man 5 terminfo).""" 26 | try: 27 | proc = subprocess.run(["tput", char_type], 28 | capture_output=True, check=True, text=True 29 | ) 30 | return proc.stdout # or stdout.decode() if text=False 31 | except subprocess.CalledProcessError as e: 32 | print(f"Error getting capability \"{char_type}\" for this terminal: {e}") 33 | return "" 34 | 35 | # Build sequences ahead of time 36 | for key, value in DEFAULT_TERM_SEQ.items(): 37 | 38 | # Fallback, because not sure how to query capabilities on Windows 39 | if sys.platform == "win32": 40 | TERM_SEQ[key] = "" 41 | continue 42 | 43 | # Special key. Sequences with parameters (ie. "2") are not retrievable 44 | if key == 'cuu2': 45 | if TERM_SEQ.get('cuu1') is not None: # we don't have this key yet 46 | # Insert "2" parameter in the seq 47 | idx = TERM_SEQ['cuu1'].rfind('A') 48 | if idx == -1: 49 | TERM_SEQ[key] = TERM_SEQ['cuu1'] + TERM_SEQ['cuu1'] 50 | continue 51 | TERM_SEQ['cuu2'] = TERM_SEQ['cuu1'][:idx] + "2" + TERM_SEQ['cuu1'][idx:] 52 | continue 53 | TERM_SEQ[key] = DEFAULT_TERM_SEQ['cuu2'] 54 | continue 55 | 56 | TERM_SEQ[key] = get_term_seq(key) 57 | 58 | 59 | def download(args, extra=None, quality="best"): 60 | current_date_time = strftime("%Y-%m-%d %H-%M-%S", gmtime()) 61 | filename = r"{time:%Y%m%d %H-%M-%S} [" + args.author_name + r"] {title} [" + f"{quality}" + r"][{id}].ts" 62 | cmd = [ 63 | "streamlink", "--twitch-disable-hosting", "--twitch-disable-ads", 64 | "--hls-live-restart", "--stream-segment-timeout", "30", 65 | "--stream-segment-attempts", "10", "-o", filename 66 | ] 67 | 68 | if extra: 69 | cmd.extend(extra) 70 | 71 | cmd.extend([args.URI, quality]) 72 | 73 | full_output = "" 74 | try: 75 | process = subprocess.run(cmd, check=True) 76 | # while True: 77 | # try: 78 | # output = process.stdout.readline() 79 | # if not output: 80 | # break 81 | # if output == '' and process.poll() is not None: 82 | # break 83 | # if output: 84 | # print(output.decode(sys.stdout.encoding), end='') 85 | # full_output += output.decode(sys.stdout.encoding) 86 | # except: 87 | # raise subprocess.CalledProcessError 88 | # process.returncode = process.poll() 89 | 90 | if process.returncode != 0: 91 | raise subprocess.CalledProcessError(process, process.returncode) 92 | 93 | except subprocess.CalledProcessError as e: 94 | # Overwrite the two last lines with current time to reduce backlog clutter 95 | if not sys.platform == "win32": 96 | sys.stdout.write( 97 | TERM_SEQ['cuu2'] 98 | + TERM_SEQ['el1'] 99 | + '\r' 100 | + current_date_time 101 | + " " 102 | + TERM_SEQ['el'] 103 | ) 104 | else: 105 | # We assume that powershell.exe is present on the system 106 | # That way controlling the cursor will work in both cmd and powershell 107 | term_width = int(subprocess.check_output(["powershell.exe", "$Host.UI.RawUI.WindowSize.Width"]).decode(sys.stdout.encoding).rstrip()) 108 | current_y = int(subprocess.check_output(["powershell.exe", "[console]::CursorTop"]).decode(sys.stdout.encoding).rstrip()) 109 | 110 | if " is hosting " in full_output: 111 | ln = 5 112 | else: 113 | ln = 3 114 | 115 | for y in range(1,ln): 116 | subprocess.run(["powershell.exe", f"[console]::setcursorposition(0,{current_y - y})"]) 117 | print(" " * term_width, end='\r') 118 | 119 | 120 | def parse_args(): 121 | parser = argparse.ArgumentParser( 122 | description=( 123 | "Monitor a Twitch channel for any active live stream and record them" 124 | ), 125 | epilog="Any extra positional argument will also be passed to the downloader." 126 | ) 127 | parser.add_argument( 128 | "--author-name", type=str, 129 | help="The name of the channel's author for the output filename", 130 | required=True 131 | ) 132 | parser.add_argument( 133 | "--downloader-args", action="append", type=str, 134 | help=( 135 | "Extra arguments can be passed to streamlink. " 136 | "This is useful to pass login token as to avoid mid-roll ads " 137 | "which may corrupt the final output due to stream discontinuities. " 138 | "Example: \"--twitch-api-header 'Authorization=OAuth '\"" 139 | " although this should ideally be set in your .streamlinkrc but " 140 | " will apply to ALL invocations of streamlink in that case." 141 | ) 142 | ) 143 | parser.add_argument( 144 | "URI", metavar="URI", 145 | help="The URI to the channel to monitor OR video to download", 146 | ) 147 | 148 | args, unknown = parser.parse_known_args() 149 | 150 | # Pre-parse and sanitize extra positional arguments to pass to downloader 151 | pextras = [] 152 | 153 | def parse(arg_list): 154 | for extra_arg in arg_list: 155 | pextra = shlex(extra_arg) 156 | pextra.whitespace_split = True 157 | for _arg in list(pextra): 158 | pextras.append(_arg.strip("'")) 159 | 160 | if args.downloader_args: 161 | parse(args.downloader_args) 162 | 163 | # This is now a bit redundant with "--downloader-args" 164 | if unknown: 165 | parse(unknown) 166 | 167 | return args, pextras 168 | 169 | 170 | def main(): 171 | args, extra = parse_args() 172 | 173 | if "twitch.tv" not in args.URI: 174 | print("Not a twitch.tv URI. Aborting.") 175 | return 1 176 | 177 | if '/videos/' in args.URI: 178 | download(args, extra) 179 | return 0 180 | 181 | while True: 182 | download(args, extra) 183 | try: 184 | time.sleep(uniform(MIN_WAIT, MAX_WAIT)) 185 | except KeyboardInterrupt: 186 | print("Interrupt asked by user. Exiting.") 187 | return 0 188 | 189 | 190 | if __name__ == '__main__': 191 | # Usage: .txt".format(sys.argv[0])) 6 | print("Content of the timestamps file:\n") 7 | print("\tVideo Filename.xyz") 8 | print("\tBase Filename") 9 | print("\t00:11:22.123 00:22:33 Arist Number 1") 10 | print("\t00:33:44.1 00:44:5.09 Arist Number 2") 11 | print("\t[...]") 12 | exit() 13 | 14 | filename_txt = sys.argv[1] 15 | 16 | with open(filename_txt) as f: 17 | file_content = f.read() 18 | 19 | file_content = file_content.split('\n') 20 | file_content = [x for x in file_content if x] 21 | 22 | filename_to_split = file_content[0] 23 | print(len(filename_to_split)) 24 | print("Filename: " + filename_to_split) 25 | del file_content[0] 26 | base_filename = file_content[0] 27 | print("Base filename: " + base_filename) 28 | del file_content[0] 29 | 30 | for x in range(len(file_content)): 31 | if file_content[x][0] != '#': 32 | y = file_content[x].split(" ") 33 | print("Start time: " + y[0]) 34 | print("End time: " + y[1]) 35 | artist = "" 36 | for i in range(len(y)): 37 | if i > 1: 38 | artist += y[i] 39 | if i != (len(y) -1): 40 | artist += " " 41 | print(artist) 42 | os.system("ffmpeg -y -i \"{}\" -ss {} -to {} -c:v copy -c:a copy \"{} - {}.mkv\"".format(filename_to_split, y[0], y[1], base_filename, artist)) 43 | os.system("ffmpeg -y -i \"{} - {}.mkv\" -vn -c:a copy \"{} - {} - Audio.m4a\"".format(base_filename, artist, base_filename, artist)) 44 | else: 45 | print(f"Skipping line {x + 1}: {file_content[x][1:]}") 46 | print("Done!") 47 | -------------------------------------------------------------------------------- /upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | 5 | def upload(filename): 6 | print(filename) 7 | # Check for best available server 8 | best_server = os.popen("curl -s https://apiv2.gofile.io/getServer").read() 9 | j = json.loads(best_server) 10 | best_server = j['data']['server'] 11 | print(f"Best server: {best_server}") 12 | 13 | # Get extension 14 | extension = filename.split('.')[-1] 15 | # Upload file 16 | if extension == 'mkv': 17 | content_type = "video/x-matroska" 18 | if extension == 'm4a': 19 | content_type = "audio/x-m4a" 20 | if extension == 'mp4': 21 | content_type = "video/mp4" 22 | if extension == 'webm': 23 | content_type = "video/webm" 24 | 25 | upload_state = os.popen("curl -F file=\"@{};type={}\" https://{}.gofile.io/uploadFile".format(filename, content_type, best_server)).read() 26 | j = json.loads(upload_state) 27 | print(j) 28 | url = "https://gofile.io/d/" + j['data']['code'] 29 | print(url) 30 | return url 31 | 32 | def main(): 33 | if len(sys.argv) != 2: 34 | print(f"Usage: {sys.argv[0]} ") 35 | exit() 36 | 37 | with open(sys.argv[1], 'r') as f: 38 | content = f.read() 39 | content = content.split('\n') 40 | 41 | # Remove empty elements from list 42 | content = [x for x in content if x] 43 | 44 | 45 | # Remove recording filename 46 | content.pop(0) 47 | # Get prefix for all recordings 48 | prefix = content[0] 49 | # Remove prefix from list, leaving only the artists 50 | content.pop(0) 51 | 52 | # Get list of artists 53 | artists = [] 54 | 55 | for index, line in enumerate(content): 56 | if line[0] != '#': 57 | artists.append(" ".join(line.split(' ')[2:])) 58 | else: 59 | print(f"Skipping line {index + 1}: {line[1:]}") 60 | 61 | # Autodetect audio and video file extensions 62 | full_fn = "" 63 | for file in os.listdir(): 64 | if "- Audio" in file: 65 | audio_file_extension = file.split('.')[-1] 66 | full_fn = file 67 | full_fn = full_fn.split(" - Audio")[0] 68 | break 69 | for file in os.listdir(): 70 | if not "- Audio" in file and full_fn in file: 71 | video_file_extension = file.split('.')[-1] 72 | break 73 | 74 | artist_list = [] 75 | for index, a in enumerate(artists): 76 | a = [a] 77 | a.append(f"{prefix} - {a[0]}.{video_file_extension}") 78 | a.append(f"{prefix} - {a[0]} - Audio.{audio_file_extension}") 79 | artist_list.append(a) 80 | 81 | artists_uploads = [] 82 | 83 | for i in artist_list: 84 | print(i) 85 | x = [i[0]] 86 | video_url = upload(i[1]) 87 | with open('post.txt', 'a+') as f: 88 | f.write(f"{i[0]}: " + "[Video]" + f"({video_url}) | ") 89 | x.append(video_url) 90 | audio_url = upload(i[2]) 91 | with open('post.txt', 'a+') as f: 92 | f.write(f"[Audio]({audio_url})\n\n") 93 | x.append(audio_url) 94 | artists_uploads.append(x) 95 | 96 | 97 | print("\n\n++++++++++++++++\n\n") 98 | 99 | for i in artists_uploads: 100 | print(f"{i[0]}: [Video]({i[1]}) | [Audio]({i[2]})\n") 101 | 102 | if __name__ == '__main__': 103 | main() 104 | -------------------------------------------------------------------------------- /upload_with_password.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | 5 | password = "nosharingallowed" 6 | 7 | def upload(filename): 8 | global password 9 | print(filename) 10 | # Check for best available server 11 | best_server = os.popen("curl -s https://apiv2.gofile.io/getServer").read() 12 | j = json.loads(best_server) 13 | best_server = j['data']['server'] 14 | print(f"Best server: {best_server}") 15 | 16 | # Get extension 17 | extension = filename.split('.')[-1] 18 | # Upload file 19 | if extension == 'mkv': 20 | content_type = "video/x-matroska" 21 | if extension == 'm4a': 22 | content_type = "audio/x-m4a" 23 | if extension == 'mp4': 24 | content_type = "video/mp4" 25 | if extension == 'webm': 26 | content_type = "video/webm" 27 | 28 | upload_state = os.popen("curl -F file=\"@{};type={}\" -F password={} https://{}.gofile.io/uploadFile".format(filename, content_type, password, best_server)).read() 29 | j = json.loads(upload_state) 30 | print(j) 31 | url = "https://gofile.io/d/" + j['data']['code'] 32 | print(url) 33 | return url 34 | 35 | def main(): 36 | if len(sys.argv) != 2: 37 | print(f"Usage: {sys.argv[0]} ") 38 | exit() 39 | 40 | with open(sys.argv[1], 'r') as f: 41 | content = f.read() 42 | content = content.split('\n') 43 | 44 | # Remove empty elements from list 45 | content = [x for x in content if x] 46 | 47 | 48 | # Remove recording filename 49 | content.pop(0) 50 | # Get prefix for all recordings 51 | prefix = content[0] 52 | # Remove prefix from list, leaving only the artists 53 | content.pop(0) 54 | 55 | # Get list of artists 56 | artists = [] 57 | 58 | for index, line in enumerate(content): 59 | if line[0] != '#': 60 | artists.append(" ".join(line.split(' ')[2:])) 61 | else: 62 | print(f"Skipping line {index + 1}: {line[1:]}") 63 | 64 | # Autodetect audio and video file extensions 65 | full_fn = "" 66 | for file in os.listdir(): 67 | if "- Audio" in file: 68 | audio_file_extension = file.split('.')[-1] 69 | full_fn = file 70 | full_fn = full_fn.split(" - Audio")[0] 71 | break 72 | for file in os.listdir(): 73 | if not "- Audio" in file and full_fn in file: 74 | video_file_extension = file.split('.')[-1] 75 | break 76 | 77 | artist_list = [] 78 | for index, a in enumerate(artists): 79 | a = [a] 80 | a.append(f"{prefix} - {a[0]}.{video_file_extension}") 81 | a.append(f"{prefix} - {a[0]} - Audio.{audio_file_extension}") 82 | artist_list.append(a) 83 | 84 | artists_uploads = [] 85 | 86 | for i in artist_list: 87 | print(i) 88 | x = [i[0]] 89 | video_url = upload(i[1]) 90 | x.append(video_url) 91 | audio_url = upload(i[2]) 92 | x.append(audio_url) 93 | artists_uploads.append(x) 94 | 95 | 96 | print("\n\n++++++++++++++++\n\n") 97 | 98 | for i in artists_uploads: 99 | print(f"{i[0]}: [Video]({i[1]}) | [Audio]({i[2]})\n") 100 | 101 | if __name__ == '__main__': 102 | main() 103 | --------------------------------------------------------------------------------