This commit is contained in:
Piero 2022-12-15 13:14:44 -05:00
commit 5d7fceef45
13 changed files with 217 additions and 199 deletions

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
# Environments
.env
# Tests
test.py
test.py
a.py
subprocess_time.py

View file

@ -20,6 +20,9 @@ OAUTH-PRIVATE-TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # optional to record without
```
8. if you want to enable/disable more available options, edit `twitch-archive.py`
9. run `Python twitch-archive.py` or for multiple streamers `Python twitch-archive.py -u streamer`
[...](https://github.com/piero0920/Twitch-Archive/blob/main/extra.md)
<!---
## Features
- Auto records the live stream | [Streamlink](https://streamlink.github.io/)

BIN
bin/TwitchDownloaderCLI (Stored with Git LFS)

Binary file not shown.

BIN
bin/TwitchDownloaderCLI.exe (Stored with Git LFS)

Binary file not shown.

View file

@ -1,7 +0,0 @@
@echo off
set vodid=%1
set json=%2
set mp4=%3
CD %~dp0
TwitchDownloaderCLI.exe chatdownload --id %vodid% -o %json% -E
TwitchDownloaderCLI.exe chatrender -i %json% -o %mp4% --background-color #FF111111 -w 500 -h 1080 --outline true -f Arial --font-size 22 --update-rate 1.0 --offline --ffmpeg-path ./ffmpeg.exe --temp-path ./temp

View file

@ -1,3 +0,0 @@
#!/bin/sh
$(dirname "$0")/TwitchDownloaderCLI -m ChatDownload --id $1 -o $2 --embed-emotes
$(dirname "$0")/TwitchDownloaderCLI -m ChatRender -i $2 -o $3 --background-color "#FF111111" -w 500 -h 1080 --outline true -f Arial --font-size 22 --update-rate 1.0 --ffmpeg-path $(dirname "$0")/ffmpeg --temp-path $(dirname "$0")/temp

Binary file not shown.

View file

@ -1,8 +0,0 @@
@echo off
set root_path=%1
set user=%2
if "%root_path:~-1%" == "\" set "root_path_1=%root_path:~0,-1%"
for %%f in ("%root_path_1%") do set "root_path_name=%%~nxf"
FOR /F "eol=# tokens=*" %%i in (%~dp0\..\.env) do SET %%i
CD %~dp0
rclone.exe copy %root_path%/%user% %remote%/%root_path_name%/%user% --progress --include-from %~dp0/temp/upload.txt

View file

@ -1,2 +0,0 @@
#!/bin/sh
rclone copy $1/$2 gd:VODS/$(basename $1)/$2 --progress --include-from $(dirname "$0")/temp/upload.txt

View file

@ -1,4 +0,0 @@
C:\Users\piero\Documents\GitHub\Twitch-Archive\archive\piero_fn\chat\CHAT_20221209_12h32m34s.mp4
C:\Users\piero\Documents\GitHub\Twitch-Archive\archive\piero_fn\metadata\METADA_20221209_12h32m34s.json
C:\Users\piero\Documents\GitHub\Twitch-Archive\archive\piero_fn\video\LIVE_20221209_12h26m03s.mp4
C:\Users\piero\Documents\GitHub\Twitch-Archive\archive\piero_fn\video\VOD_20221209_12h32m34s.mp4

View file

@ -1,5 +1,4 @@
## Linux
- use [twitch-archive](https://github.com/piero0920/Twitch-Archive/blob/main/twitch-archive)
- install and configure rclone
```
cd bin

View file

@ -1,11 +1,10 @@
import requests, os, time, json, sys, subprocess, getopt, pathlib, locale
import requests, os, time, json, sys, subprocess, getopt, pathlib, locale, re
from colorama import Fore, Style
from datetime import datetime, timedelta
locale.setlocale(locale.LC_TIME, "es_ES")
from pytz import timezone
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv())
class TwitchArchive:
def __init__(self):
# user configuration
@ -16,12 +15,23 @@ class TwitchArchive:
self.refresh = 60 # Time between checking (5.0 is recommended), avoid less than 1.0
self.downloadVOD = 1 # 0 - disable VOD downloading after stream finished, 1 - enable VOD downloading after stream finished (this option downloads the latest public vod)
self.downloadCHAT = 1 # 0 - disable chat downloading and rendering, 1 - enable chat downloading and rendering
self.downloadClips = 1
self.downloadMuted = 1
self.downloadChatHTML = 1
self.uploadCloud = 1 # 0 - disable upload to remote cloud, 1 - enable upload to remote cloud
self.deleteFiles = 0 # 0 - disable the deleting of files from current seccion after being uploaded to the cloud, 1 - enable the deleting files of files from current seccion after being uploaded to the cloud (BE CAREFUL WITH THIS OPTION)
self.hls_segmentsVOD = 10 # 1-10 for downloading vod, it's possible to use multiple threads to potentially increase the throughput
def run(self):
self.os = self.get_OS()
if load_dotenv(find_dotenv()): load_dotenv(find_dotenv())
else:
print(f'{Fore.RED}\033[1mCREATE .env file with variables{Style.RESET_ALL}')
quit()
self.correct_user()
print('Twitch-Archive --- ONLY VOD/CHAT')
print('Configuration:')
print(f'Root path: {Fore.GREEN}' + str(pathlib.Path(self.root_path).resolve()) + f'{Style.RESET_ALL}')
@ -36,21 +46,8 @@ class TwitchArchive:
else: print(f'{Fore.GREEN}'+'\033[1m'+f'Files will NOT be deleted{Style.RESET_ALL}')
if self.uploadCloud == 0 and self.deleteFiles == 1: print(f'{Fore.RED}'+'\033[1m'+f'FILES WILL BE DELETED AND NO UPLOADED {Style.RESET_ALL}{Fore.GREEN}\n"CTRL + C"{Style.RESET_ALL}{Fore.RED}'+'\033[1m'+f' TO STOP AND CHANGED CONFIGURATION{Style.RESET_ALL}')
self.oauth_token = self.get_oauth_token()
self.channel_id = self.get_channel_id()
self.temp_path = str(pathlib.Path(os.path.join("VODS",self.root_path,self.username,"vod", "temp")).absolute())
self.vod_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, "vod")).absolute())
self.json_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, "chat", "json")).absolute())
self.chat_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, "chat")).absolute())
if(os.path.isdir(self.temp_path) is False): os.makedirs(self.temp_path)
if(os.path.isdir(self.vod_path) is False): os.makedirs(self.vod_path)
if(os.path.isdir(self.json_path) is False): os.makedirs(self.json_path)
if(os.path.isdir(self.chat_path) is False): os.makedirs(self.chat_path)
if not os.path.exists(os.path.join('VODS',self.root_path, ".log")):
with open(os.path.join('VODS',self.root_path, ".log"), 'w'): pass
if not os.path.exists(os.path.join(str(pathlib.Path(__file__).parent.resolve())+'/bin/temp/', ".log")):
with open(os.path.join(str(pathlib.Path(__file__).parent.resolve())+'/bin/temp/', ".log"), 'w'): pass
print(f"Checking for {Fore.GREEN}{self.username}{Style.RESET_ALL} every {Fore.GREEN}{self.refresh}{Style.RESET_ALL} seconds to download VOD/CHAT")
self.loopcheck()
@ -64,68 +61,56 @@ class TwitchArchive:
print('OS no supported')
return
def get_oauth_token(self):
try:
return requests.post(f"https://id.twitch.tv/oauth2/token?client_id={os.getenv('CLIENT-ID')}&client_secret={os.getenv('CLIENT-SECRET')}&grant_type=client_credentials").json()['access_token']
except:
print(f'{Fore.RED}\033[1mCheck your client-id and secret{Style.RESET_ALL}')
quit()
def correct_user(self):
try:
url = f'https://api.twitch.tv/helix/users?login={self.username}'
response = requests.get(url,headers = {"Authorization" : "Bearer " + self.get_oauth_token(), "Client-ID": os.getenv('CLIENT-ID')}, timeout = 15).json()
if response['data'] == []:
print(f'{Fore.RED}\033[1mUse a correct username{Style.RESET_ALL}')
quit()
except requests.exceptions.RequestException as e:
print(e)
def check_user(self):
client_id = "kimne78kx3ncx6brgo4mv6wki5h1ko"
query = '''
query {
user(login: "''' + self.username + '''") {
stream {
archiveVideo{
id
}
title
createdAt
}
}
}
'''
url = 'https://gql.twitch.tv/gql'
response = requests.post(url,json={'query': query},headers={"Client-ID": client_id})
return json.loads(response.text)
query = 'query{user(login: "' + self.username + '") {stream{archiveVideo{id}title createdAt}}}'
try:
response = requests.post('https://gql.twitch.tv/gql',json={'query': query},headers={"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko"})
return json.loads(response.text)
except requests.exceptions.RequestException as e:
print(e)
quit()
def get_vod(self):
client_id = "kimne78kx3ncx6brgo4mv6wki5h1ko"
query = '''
query {
user(login: "''' + self.username + '''") {
videos(first: 1) {
edges {
node {
id
scope
title
description
recordedAt
lengthSeconds
animatedPreviewURL
previewThumbnailURL(height: 1280, width: 720)
thumbnailURLs(height: 1280, width: 720)
}
}
}
}
}
'''
url = 'https://gql.twitch.tv/gql'
response = requests.post(url, json={'query': query}, headers={"Client-ID": client_id})
return json.loads(response.text)
query = 'query {user(login: "' + self.username + '"){videos(first: 1){edges {node {id title recordedAt lengthSeconds tags muteInfo{ mutedSegmentConnection{ nodes{ duration offset }}} topClips(first: 10) { edges{ node{ id slug viewCount title createdAt curator { displayName } durationSeconds url thumbnailURL(width: 480, height: 272)}}}}}}}}'
try:
response = requests.post('https://gql.twitch.tv/gql', json={'query': query}, headers={"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko"})
return json.loads(response.text)
except requests.exceptions.RequestException as e:
print(e)
def loopcheck(self):
while True:
is_live = self.check_user()['data']['user']['stream']
if is_live is not None:
is_live_ready = self.check_user()['data']['user']['stream']['title']
is_live_ready = self.check_user()['data']['user']['stream']['archiveVideo']
if is_live_ready is not None:
live_date = datetime.strptime(is_live["createdAt"],'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone('UTC')).astimezone(tz=None).replace(tzinfo=None)
live_temp_path = os.path.join(self.temp_path, "live_temp.ts")
bin_path = str(pathlib.Path(__file__).parent.resolve())+"/bin"
live_temp_path = os.path.join(bin_path+'/temp/', "live_temp.ts")
with open(os.path.join(self.root_path, ".log")) as logs:
with open(os.path.join(bin_path+'/temp/', ".log")) as logs:
logs = logs.read()
log_id = is_live["createdAt"] + " - " + self.username + " - " + is_live["title"]
if log_id in logs:
time.sleep(self.refresh)
with open(os.path.join(self.root_path, ".log"), "r+") as logs:
with open(os.path.join(bin_path+'/temp/', ".log"), "r+") as logs:
log_id = is_live["createdAt"] + " - " + self.username + " - " + is_live["title"]
for line in logs:
if log_id in line:
@ -133,21 +118,37 @@ class TwitchArchive:
else:
logs.write(is_live["createdAt"] + " - " + self.username + " - " + is_live["title"] +"\n")
subprocess.call(['streamlink', 'twitch.tv/'+ self.username, self.quality, '--twitch-api-header', 'Authorization=OAuth ' + os.getenv('OAUTH-PRIVATE-TOKEN'), '--hls-segment-threads', str(self.hls_segments), '--hls-live-restart', '--retry-streams', str(self.refresh), '--twitch-disable-reruns', '-o', live_temp_path])
subprocess.call(['streamlink', 'twitch.tv/'+ self.username, self.quality, '--twitch-api-header', 'Authorization=OAuth ' + os.getenv('OAUTH-PRIVATE-TOKEN'), '--hls-live-restart', '--retry-streams', str(self.refresh), '--twitch-disable-reruns', '-o', live_temp_path])
os.remove(live_temp_path)
current_vod = self.get_vod()['data']['user']['videos']['edges'][0]['node']
vod_date = datetime.strptime(current_vod["recordedAt"],'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone('UTC')).astimezone(tz=None).replace(tzinfo=None)
vod_raw_filename = datetime.strftime(vod_date,'%Y%m%d_%Hh%Mm%Ss')
live_date_min = live_date - timedelta(minutes=1)
live_date_max = live_date + timedelta(minutes=1)
if live_date_min <= vod_date <= live_date_max:
print('VOD AND CHAT AVAILABLE')
vod_raw_path = os.path.join(self.temp_path, "vod_temp.ts")
if is_live_ready['id'] == current_vod['id']:
print('VOD AND CHAT AVAILABLE')
self.vod_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename ,"vod")).absolute())
self.clips_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename , "clips")).absolute())
self.muted_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename , "muted")).absolute())
self.json_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename ,"chat", "json")).absolute())
self.chat_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename , "chat")).absolute())
self.html_path = str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename ,"chat", "html")).absolute())
if(os.path.isdir(self.vod_path) is False): os.makedirs(self.vod_path)
if(os.path.isdir(self.clips_path) is False): os.makedirs(self.clips_path)
if(os.path.isdir(self.muted_path) is False): os.makedirs(self.muted_path)
if(os.path.isdir(self.json_path) is False): os.makedirs(self.json_path)
if(os.path.isdir(self.chat_path) is False): os.makedirs(self.chat_path)
if(os.path.isdir(self.html_path) is False): os.makedirs(self.html_path)
vod_raw_path = os.path.join(bin_path+'/temp', "vod_temp.ts")
vod_proc_path = os.path.join(self.vod_path, vod_raw_filename + ".mp4")
chat_json_path = os.path.join(self.json_path, vod_raw_filename + ".json")
chat_video_path = os.path.join(self.chat_path, vod_raw_filename + ".mp4")
with open(os.path.join(str(pathlib.Path(os.path.join("VODS",self.root_path, self.username, vod_raw_filename)).absolute()), 'metadata.json'), 'w', encoding='utf-8') as f:
json.dump(current_vod, f, ensure_ascii=False, indent=4)
if self.username == 'KalathrasLolweapon':
file_date = datetime.strptime(vod_raw_filename, '%Y%m%d_%Hh%Mm%Ss').date()
@ -165,37 +166,76 @@ class TwitchArchive:
chat_path = str(pathlib.Path(os.path.join("Chat",chat_year,chat_month)).absolute())
if(os.path.isdir(vod_path) is False): os.makedirs(vod_path)
if(os.path.isdir(chat_path) is False): os.makedirs(chat_path)
clean_vod_title = re.sub(r'[/\\:*?"<>|]', '_', current_vod['title'])
if len(clean_vod_title) > 202:
dif = len(clean_vod_title) - 202
clean_vod_title[:-dif]
chat_video_path = os.path.join(chat_path, vod_raw_filename + ".mp4")
vod_proc_path = os.path.join(vod_path, vod_raw_filename + ".mp4")
vod_proc_path = os.path.join(vod_path, vod_raw_filename + '_'+ clean_vod_title + ".mp4")
if self.downloadVOD == 1:
print('Downloading VOD: ' + current_vod["title"])
try:
subprocess.call(['streamlink', 'twitch.tv/videos/' + current_vod["id"], self.quality, "--hls-segment-threads", str(self.hls_segmentsVOD), "-o", vod_raw_path])
if(os.path.exists(vod_raw_path) is True):
if self.os == 'windows':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/ffmpeg.exe', '-y', '-i', vod_raw_path, '-analyzeduration', '2147483647', '-probesize', '2147483647', '-c:v', 'copy', '-c:a', 'copy', '-start_at_zero', '-copyts', vod_proc_path], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
elif self.os == 'linux':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/ffmpeg', '-y', '-i', vod_raw_path, '-analyzeduration', '2147483647', '-probesize', '2147483647', '-c:v', 'copy', '-c:a', 'copy', '-start_at_zero', '-copyts', vod_proc_path], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
os.remove(vod_raw_path)
else:
print("Skip fixing. File not found.")
subprocess.call([bin_path+"/TwitchDownloaderCLI.exe", 'videoDownload', '-u', str(current_vod["id"]), '-q', self.quality, "-t", str(self.hls_segmentsVOD), "--ffmpeg-path", bin_path +"/ffmpeg.exe", '--temp-path', bin_path+"/temp", "-o", vod_proc_path])
except Exception as e:
print('Error', 'A ERROR has ocurred and the VOD will not be downloaded.\n')
if self.downloadMuted == 1:
if current_vod['muteInfo']['mutedSegmentConnection'] is not None:
mutedSegments = current_vod['muteInfo']['mutedSegmentConnection']['nodes']
print('Downloading ' + len(mutedSegments) + ' muted segmes')
for muted in mutedSegments:
beg = time.strftime('%Hh%Mm%Ss', time.gmtime(muted['offset']))
end = time.strftime('%Hh%Mm%Ss', time.gmtime(muted['offset'] + muted['duration']))
muted_filename_path = os.path.join(self.muted_path, beg + '-' + end + ".mp4")
subprocess.call([bin_path+"/TwitchDownloaderCLI.exe", 'videoDownload', '-u', current_vod['id'], '-q', self.quality, '-b', str(muted['offset']), '-e', str(muted['offset'] + muted['duration']), "-t", str(self.hls_segmentsVOD), "--ffmpeg-path", bin_path+"/ffmpeg.exe", '--temp-path', bin_path+"/temp" '-o', muted_filename_path])
else:
print('The VOD has no muted segments')
if self.downloadClips == 1:
topClips = current_vod['topClips']['edges']
if topClips != []:
print('Downloading Clips')
for clips in topClips:
clip = clips['node']
clean_title = re.sub(r'[/\\:*?"<>|]', '_', clip['title'])
clip_filename_path = os.path.join(self.clips_path, clean_title + '_'+ clip['id'] +".mp4")
subprocess.call([bin_path+"/TwitchDownloaderCLI.exe", 'clipDownload', '-u', clip['slug'], '-q', self.quality, '-o', clip_filename_path])
else:
print('No Clips has being made during the stream')
if self.downloadCHAT == 1:
print('Downloading and rendering CHAT: ' + current_vod["title"])
print('Downloading CHAT: ' + current_vod["title"])
chat_settings = ["--background-color", "#FF111111", "-w", "500", "-h", "1080", "--outline", "true", "-f", "Arial", "--font-size", "22", "--update-rate", "1.0", "--offline", "--ffmpeg-path", f"{bin_path}/ffmpeg.exe", "--temp-path", f"{bin_path}/temp"]
try:
if self.os == 'windows':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+"/bin/chat.bat", current_vod["id"], chat_json_path, chat_video_path])
elif self.os == 'linux':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+"/bin/chat.sh", current_vod["id"], chat_json_path, chat_video_path])
if self.os == 'windows':
subprocess.call([bin_path + '/TwitchDownloaderCLI.exe', 'chatdownload', '--id', current_vod["id"], '-o', chat_json_path, '-E'])
print('Rendering CHAT: ' + current_vod["title"])
subprocess.call([bin_path + '/TwitchDownloaderCLI.exe', 'chatrender', '-i', chat_json_path, '-o', chat_video_path] + chat_settings)
elif self.os == 'linux':
subprocess.call([bin_path + '/TwitchDownloaderCLI', 'chatdownload', '--id', current_vod["id"], '-o', chat_json_path, '-E'])
subprocess.call([bin_path + '/TwitchDownloaderCLI', 'chatrender', '-i', chat_json_path, '-o', chat_video_path] + chat_settings)
except Exception as e:
print("A ERROR has ocurred and chat will need to be downloaded and rendered manually\n")
if self.downloadChatHTML == 1:
print('Downloading chat to html format')
chat_html_path = os.path.join(self.html_path, vod_raw_filename + ".html")
try:
if self.os == 'windows': subprocess.call([bin_path+"/TwitchDownloaderCLI.exe", "chatupdate", "-i", chat_json_path, "-o", chat_html_path, "-E", "--temp-path", f"{bin_path}/temp"])
elif self.os == 'linux': subprocess.call([bin_path+"/TwitchDownloaderCLI", "chatupdate", "-i", chat_json_path, "-o", chat_html_path, "-E", "--temp-path", f"{bin_path}/temp"])
if self.username == 'KalathrasLolweapon':
print('Uploading html chat to b2 bucket')
subprocess.call(['rclone', 'copy', chat_html_path, 'b2:kala-help/chat_html', '--progress'])
except Exception as e:
print('A ERROR has ocurred and chat will need to be updated to html manually')
if self.uploadCloud == 1:
print('Uploading files:')
if self.os == 'windows':
if self.username == 'KalathrasLolweapon':
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'gd:VODS', '--progress'])
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/Chat', 'gd:Chat', '--progress'])
else:subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/upload.bat', str(pathlib.Path(self.root_path).resolve()),self.username])
elif self.os == 'linux':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/upload.sh', str(pathlib.Path(self.root_path).resolve()),self.username])
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress'])
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/Chat', 'GD:Chat', '--progress'])
else:subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress'])
elif self.os == 'linux':subprocess.call([bin_path+'/upload.sh', str(pathlib.Path(self.root_path).resolve()),self.username])
if self.deleteFiles == 1:
print(f'{Fore.RED}DELETING FILES{Style.RESET_ALL}')

View file

@ -1,11 +1,10 @@
import requests, os, time, json, sys, subprocess, getopt, smtplib, pathlib, glob
import requests, os, time, json, sys, subprocess, getopt, smtplib, pathlib, socket
from colorama import Fore, Style
from datetime import datetime, timedelta
from pytz import timezone
from dotenv import load_dotenv, find_dotenv
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
load_dotenv(find_dotenv())
class TwitchArchive:
def __init__(self):
# user configuration
@ -13,19 +12,28 @@ class TwitchArchive:
self.quality = "best" # Qualities options: best/source high/720p medium/540p low/360p
# global configuration
self.root_path = r"archive" # Path where this script saves everything (livestream,VODs,chat,metadata)
self.rclone_path = "remote:path" # Path to rclone remote storage
self.refresh = 5.0 # Time between checking (5.0 is recommended), avoid less than 1.0
self.notifications = 1 # 0 - disable email notification of current seccion, 1 - enable email notification of current seccion
self.notifications = 0 # 0 - disable email notification of current seccion, 1 - enable email notification of current seccion
self.downloadMETADATA = 1 # 0 - disable metadata downloading, 1 - enable metadata downloading
self.downloadVOD = 1 # 0 - disable VOD downloading after stream finished, 1 - enable VOD downloading after stream finished (this option downloads the latest public vod)
self.downloadCHAT = 1 # 0 - disable chat downloading and rendering, 1 - enable chat downloading and rendering
self.uploadCloud = 1 # 0 - disable upload to remote cloud, 1 - enable upload to remote cloud
self.deleteFiles = 1 # 0 - disable the deleting of files from current seccion after being uploaded to the cloud, 1 - enable the deleting files of files from current seccion after being uploaded to the cloud (BE CAREFUL WITH THIS OPTION)
self.deleteFiles = 0 # 0 - disable the deleting of files from current seccion after being uploaded to the cloud, 1 - enable the deleting files of files from current seccion after being uploaded to the cloud (BE CAREFUL WITH THIS OPTION)
self.cleanRaw = 1 # 0 - disable the deleting of raw (.ts) files, 1 - enable the deleteing of raw (.ts) files (if upload enable they will be deleted before)
self.hls_segments = 3 # 1-10 for live stream, it's possible to use multiple threads to potentially increase the throughput. 2-3 is enough
self.hls_segmentsVOD = 10 # 1-10 for downloading vod, it's possible to use multiple threads to potentially increase the throughput
def run(self):
self.os = self.get_OS()
if load_dotenv(find_dotenv()): load_dotenv(find_dotenv())
else:
print(f'{Fore.RED}\033[1mCREATE .env file with variables{Style.RESET_ALL}')
quit()
self.correct_user()
print('Twitch-Archive')
print('Configuration:')
print(f'Root path: {Fore.GREEN}' + str(pathlib.Path(self.root_path).resolve()) + f'{Style.RESET_ALL}')
@ -55,7 +63,7 @@ class TwitchArchive:
if(os.path.isdir(self.chatJSON_path) is False): os.makedirs(self.chatJSON_path)
if(os.path.isdir(self.chatMP4_path) is False): os.makedirs(self.chatMP4_path)
if(os.path.isdir(self.metadata_path) is False): os.makedirs(self.metadata_path)
if not os.path.exists(os.path.join(self.root_path, ".log")):
if not os.path.exists(os.path.join(self.root_path, ".log")):
with open(os.path.join(self.root_path, ".log"), 'w'): pass
print(f"Checking for {Fore.GREEN}{self.username}{Style.RESET_ALL} every {Fore.GREEN}{self.refresh}{Style.RESET_ALL} seconds. Record with {Fore.GREEN}{self.quality}{Style.RESET_ALL} quality.")
@ -68,72 +76,62 @@ class TwitchArchive:
elif sys.platform.startswith('linux'):
return 'linux'
else:
print('OS no supported')
return
print(f'{Fore.RED}\033[1mOS no supported{Style.RESET_ALL}')
quit()
def get_oauth_token(self):
try:
return requests.post(f"https://id.twitch.tv/oauth2/token?client_id={os.getenv('CLIENT-ID')}&client_secret={os.getenv('CLIENT-SECRET')}&grant_type=client_credentials").json()['access_token']
except:
print(f'{Fore.RED}\033[1mCheck your client-id and secret{Style.RESET_ALL}')
quit()
def correct_user(self):
try:
url = f'https://api.twitch.tv/helix/users?login={self.username}'
response = requests.get(url,headers = {"Authorization" : "Bearer " + self.get_oauth_token(), "Client-ID": os.getenv('CLIENT-ID')}, timeout = 15).json()
if response['data'] == []:
print(f'{Fore.RED}\033[1mUse a correct username{Style.RESET_ALL}')
quit()
except requests.exceptions.RequestException as e:
print(e)
def check_user(self):
client_id = "kimne78kx3ncx6brgo4mv6wki5h1ko"
query = '''
query {
user(login: "''' + self.username + '''") {
stream {
archiveVideo{
id
}
title
createdAt
}
}
}
'''
url = 'https://gql.twitch.tv/gql'
response = requests.post(url,json={'query': query},headers={"Client-ID": client_id})
return json.loads(response.text)
query = 'query{user(login: "' + self.username + '") {stream{archiveVideo{id}title createdAt}}}'
try:
response = requests.post('https://gql.twitch.tv/gql',json={'query': query},headers={"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko"})
return json.loads(response.text)
except requests.exceptions.RequestException as e:
print(e)
quit()
def get_vod(self):
client_id = "kimne78kx3ncx6brgo4mv6wki5h1ko"
query = '''
query {
user(login: "''' + self.username + '''") {
videos(first: 1) {
edges {
node {
id
scope
title
description
recordedAt
lengthSeconds
animatedPreviewURL
previewThumbnailURL(height: 1280, width: 720)
thumbnailURLs(height: 1280, width: 720)
}
}
}
}
}
'''
url = 'https://gql.twitch.tv/gql'
response = requests.post(url, json={'query': query}, headers={"Client-ID": client_id})
return json.loads(response.text)
query = 'query {user(login: "' + self.username + '") {videos(first: 1) {edges {node {id title description recordedAt lengthSeconds animatedPreviewURL previewThumbnailURL(height: 1280, width: 720) thumbnailURLs(height: 1280, width: 720)}}}}}'
try:
response = requests.post('https://gql.twitch.tv/gql', json={'query': query}, headers={"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko"})
return json.loads(response.text)
except requests.exceptions.RequestException as e:
print(e)
def sendNotif(self, subject, content):
if self.notifications == 1:
sender = os.getenv("SENDER")
receiver = os.getenv("RECEIVER")
msg = MIMEMultipart()
msg['From'] = sender
msg['To'] = receiver
msg['Subject'] = self.username + " _ " + subject
body = "Current seccion is for " + self.username + "\n\n\n\n" + content
msg.attach(MIMEText((body), 'plain'))
server = smtplib.SMTP('smtp.gmail.com', 587)
server.starttls()
server.login(sender, os.getenv("PASSWD"))
txt = msg.as_string()
server.sendmail(sender, receiver, txt)
server.quit()
try:
sender = os.getenv("SENDER")
receiver = os.getenv("RECEIVER")
msg = MIMEMultipart()
msg['From'] = sender
msg['To'] = receiver
msg['Subject'] = self.username + " _ " + subject
body = "Current seccion is for " + self.username + "\n\n\n\n" + content
msg.attach(MIMEText((body), 'plain'))
server = smtplib.SMTP('smtp.gmail.com', 587)
server.starttls()
server.login(sender, os.getenv("PASSWD"))
txt = msg.as_string()
server.sendmail(sender, receiver, txt)
server.quit()
except socket.error as e:
print(e)
def loopcheck(self):
while True:
@ -141,19 +139,19 @@ class TwitchArchive:
if is_live is not None:
is_live_ready = self.check_user()['data']['user']['stream']['title']
if is_live_ready is not None:
bin_path = str(pathlib.Path(__file__).parent.resolve())+"/bin"
live_date = datetime.strptime(is_live["createdAt"],'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone('UTC')).astimezone(tz=None).replace(tzinfo=None)
live_raw_filename = datetime.strftime(live_date,'%Y%m%d_%Hh%Mm%Ss')
live_raw_filename = datetime.strftime(live_date,'%Y%m%d_%Hh%Mm%Ss')
live_raw_path = os.path.join(self.raw_path, "LIVE_" + live_raw_filename + ".ts")
live_proc_path = os.path.join(self.video_path, "LIVE_" + live_raw_filename + ".mp4")
with open(os.path.join(self.root_path, ".log")) as logs:
with open(os.path.join(self.root_path, ".log"), encoding="utf-8") as logs:
logs = logs.read()
log_id = is_live["createdAt"] + " - " + self.username + " - " + is_live["title"]
if log_id in logs:
time.sleep(self.refresh)
with open(os.path.join(self.root_path, ".log"), "r+") as logs:
with open(os.path.join(self.root_path, ".log"), "r+", encoding="utf-8") as logs:
log_id = is_live["createdAt"] + " - " + self.username + " - " + is_live["title"]
for line in logs:
if log_id in line:
@ -176,9 +174,10 @@ class TwitchArchive:
vod_date = datetime.strptime(current_vod["recordedAt"],'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone('UTC')).astimezone(tz=None).replace(tzinfo=None)
if live_date_min <= vod_date <= live_date_max:
vod_raw_path = os.path.join(self.raw_path, "VOD_" + live_raw_filename + ".ts")
vod_proc_path = os.path.join(self.video_path, "VOD_" + live_raw_filename + ".mp4")
vod_proc_path = os.path.join(self.video_path, live_raw_filename + ".mp4")
chat_json_path = os.path.join(self.chatJSON_path, live_raw_filename + ".json")
chat_video_path = os.path.join(self.chatMP4_path, live_raw_filename + ".mp4")
if self.downloadMETADATA == 1:
self.sendNotif('Metadata - ' + live_raw_filename,'Downloading and saving metadata:\n' + json.dumps(current_vod, indent=4))
with open(os.path.join(self.metadata_path, "METADA_" + live_raw_filename + ".json"), 'w', encoding='utf-8') as f:
@ -188,12 +187,9 @@ class TwitchArchive:
print('Downloading VOD: ' + current_vod["title"])
self.sendNotif('VOD - ' + live_raw_filename,'Downloading VOD: ' + current_vod["title"])
try:
subprocess.call(['streamlink', 'twitch.tv/videos/' + str(current_vod["id"]), self.quality, '--twitch-api-header', 'Authorization=OAuth ' + os.getenv('OAUTH-PRIVATE-TOKEN'), "--hls-segment-threads", str(self.hls_segmentsVOD), "-o", vod_raw_path])
if(os.path.exists(vod_raw_path) is True):
if self.os == 'windows':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/ffmpeg.exe', '-y', '-i', vod_raw_path, '-analyzeduration', '2147483647', '-probesize', '2147483647', '-c:v', 'copy', '-c:a', 'copy', '-start_at_zero', '-copyts', vod_proc_path], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
elif self.os == 'linux':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/ffmpeg', '-y', '-i', vod_raw_path, '-analyzeduration', '2147483647', '-probesize', '2147483647', '-c:v', 'copy', '-c:a', 'copy', '-start_at_zero', '-copyts', vod_proc_path], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
else:
print("Skip fixing. File not found.")
if self.os == 'windows':subprocess.call([bin_path+"/TwitchDownloaderCLI.exe", 'videoDownload', '-u', str(current_vod["id"]), '-q', self.quality, "-t", str(self.hls_segmentsVOD), "--ffmpeg-path", bin_path +"/ffmpeg.exe", '--temp-path', bin_path+"/temp", "-o", vod_proc_path])
elif self.os == 'linux':subprocess.call([bin_path+"/TwitchDownloaderCLI", 'videoDownload', '-u', str(current_vod["id"]), '-q', self.quality, "-t", str(self.hls_segmentsVOD), "--ffmpeg-path", bin_path +"/ffmpeg.exe", '--temp-path', bin_path+"/temp", "-o", vod_proc_path])
except Exception as e:
print('Error', 'A ERROR has ocurred and the VOD will not be downloaded.\n')
self.sendNotif('ERROR - ' + live_raw_filename, 'A ERROR has ocurred and the VOD will not be downloaded.\n')
@ -201,9 +197,15 @@ class TwitchArchive:
if self.downloadCHAT == 1:
print('Downloading and rendering CHAT: ' + current_vod["title"])
self.sendNotif('CHAT - ' + live_raw_filename,'Downloading JSON and rendering chat logs from VOD:\n' + current_vod["title"])
chat_settings = ["--background-color", "#FF111111", "-w", "500", "-h", "1080", "--outline", "true", "-f", "Arial", "--font-size", "22", "--update-rate", "1.0", "--offline", "--ffmpeg-path", f"{bin_path}/ffmpeg.exe", "--temp-path", f"{bin_path}/temp"]
try:
if self.os == 'windows':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+"/bin/chat.bat", str(current_vod["id"]), os.path.join(self.chatJSON_path, "CHAT_" + live_raw_filename + ".json"), os.path.join(self.chatMP4_path, "CHAT_" + live_raw_filename + ".mp4")])
elif self.os == 'linux':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+"/bin/chat.sh", str(current_vod["id"]), os.path.join(self.chatJSON_path, "CHAT_" + live_raw_filename + ".json"), os.path.join(self.chatMP4_path, "CHAT_" + live_raw_filename + ".mp4")])
if self.os == 'windows':
subprocess.call([bin_path + '/TwitchDownloaderCLI.exe', 'chatdownload', '--id', current_vod["id"], '-o', chat_json_path, '-E'])
print('Rendering CHAT: ' + current_vod["title"])
subprocess.call([bin_path + '/TwitchDownloaderCLI.exe', 'chatrender', '-i', chat_json_path, '-o', chat_video_path] + chat_settings)
elif self.os == 'linux':
subprocess.call([bin_path + '/TwitchDownloaderCLI', 'chatdownload', '--id', current_vod["id"], '-o', chat_json_path, '-E'])
subprocess.call([bin_path + '/TwitchDownloaderCLI', 'chatrender', '-i', chat_json_path, '-o', chat_video_path] + chat_settings)
except Exception as e:
self.sendNotif('ERROR - ' + live_raw_filename, "A ERROR has ocurred and chat will need to be downloaded and rendered manually.\n")
print("A ERROR has ocurred and chat will need to be downloaded and rendered manually\n")
@ -212,16 +214,12 @@ class TwitchArchive:
if self.cleanRaw == 1:
print('Deleting raw files')
if(os.path.exists(live_raw_path) is True): os.remove(live_raw_path)
if self.downloadVOD == 1:
if(os.path.exists(os.path.join(self.raw_path, "VOD_" + live_raw_filename + ".ts")) is True):
os.remove(os.path.join(self.raw_path, "VOD_" + live_raw_filename + ".ts"))
if self.uploadCloud == 1:
with open(str(pathlib.Path(__file__).parent.resolve())+"/bin/temp/upload.txt", "a") as myfile:
myfile.write("LIVE_" + live_raw_filename + ".ts\n"+"VOD_" + live_raw_filename + ".ts\n"+"LIVE_" + live_raw_filename + ".mp4\n"+"VOD_" + live_raw_filename + ".mp4\n"+"METADATA_" + live_raw_filename + ".json\n"+"CHAT_" + live_raw_filename + ".json\n"+"CHAT_" + live_raw_filename + ".mp4\n")
print('Uploading files')
self.sendNotif("UPLOADING - " + live_raw_filename, 'The files are being uploaded')
if self.os == 'windows':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/upload.bat', str(pathlib.Path(self.root_path).resolve()),self.username])
elif self.os == 'linux':subprocess.call([str(pathlib.Path(__file__).parent.resolve())+'/bin/upload.sh', str(pathlib.Path(self.root_path).resolve()),self.username])
subprocess.call(['rclone', 'copy', str(pathlib.Path(self.root_path).resolve()), self.rclone_path, '--include-from', bin_path + '/temp/upload.txt'])
os.remove(str(pathlib.Path(__file__).parent.resolve())+"/bin/temp/upload.txt")
if self.deleteFiles == 1:
self.sendNotif("DELETING - " + live_raw_filename, "Deleting the files from current seccion.")