├── src ├── webhooks │ ├── __init__.py │ └── backends │ │ ├── __init__.py │ │ ├── welltory.py │ │ ├── zapier.py │ │ ├── slack.py │ │ └── base.py ├── helpers.py ├── check_env.py ├── jwt_auth.py ├── get_google_refresh_token.py ├── settings.py ├── main.py ├── zoom.py └── youtube.py ├── cron └── crontab ├── requirements.txt ├── docker-compose.yml ├── Makefile ├── Dockerfile ├── LICENSE ├── .gitignore └── README.md /src/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | from webhooks.backends import * 2 | -------------------------------------------------------------------------------- /cron/crontab: -------------------------------------------------------------------------------- 1 | */5 * * * * /usr/local/bin/python3.7 /opt/app/src/main.py >> /var/log/cron.log 2>&1 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.20.0 2 | python-dotenv==0.6.4 3 | google-api-python-client==1.6.2 4 | httplib2 5 | pyjwt 6 | -------------------------------------------------------------------------------- /src/webhooks/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from webhooks.backends.slack import * 2 | from webhooks.backends.zapier import * 3 | from webhooks.backends.welltory import * 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - .:/opt/app 9 | -------------------------------------------------------------------------------- /src/helpers.py: -------------------------------------------------------------------------------- 1 | 2 | def import_by_string(name: str): 3 | components = name.split('.') 4 | mod = __import__(components[0]) 5 | for comp in components[1:]: 6 | mod = getattr(mod, comp) 7 | return mod 8 | -------------------------------------------------------------------------------- /src/webhooks/backends/welltory.py: -------------------------------------------------------------------------------- 1 | from webhooks.backends.zapier import ZapierClient 2 | 3 | 4 | class WelltoryTeam(ZapierClient): 5 | 6 | def get_data_for_event(self, event_name, **kwargs): 7 | data = super().get_data_for_event(event_name, **kwargs) 8 | title = data['result']['name'].split(' ')[0] 9 | data['result']['title'] = title 10 | return data 11 | 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker-compose build 3 | 4 | up: 5 | docker-compose up 6 | 7 | stop: 8 | docker-compose stop app 9 | 10 | remove: 11 | docker-compose rm --all -f app 12 | 13 | bash: 14 | docker-compose run app bash 15 | 16 | check-env: 17 | docker-compose run app python3.6 src/check_env.py 18 | 19 | get-google-refresh-token: 20 | docker-compose run app python3.6 src/get_google_refresh_token.py -------------------------------------------------------------------------------- /src/webhooks/backends/zapier.py: -------------------------------------------------------------------------------- 1 | from webhooks.backends.base import WebHookBase 2 | from settings import ZAPIER_URL 3 | 4 | 5 | class ZapierClient(WebHookBase): 6 | 7 | def get_url(self, event_name, **kwargs) -> str: 8 | return ZAPIER_URL 9 | 10 | def get_request_method(self, event_name, **kwargs) -> str: 11 | return 'post' 12 | 13 | def new_video(self, **kwargs): 14 | return self.payload 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.4-stretch 2 | 3 | RUN apt-get update 4 | RUN apt-get install -y cron 5 | 6 | # RUN apt-get install -y software-properties-common 7 | # RUN add-apt-repository ppa:jonathonf/ffmpeg-4 8 | # RUN apt-get install -y ffmpeg 9 | 10 | RUN python3.7 -m pip install pip --upgrade 11 | 12 | COPY requirements.txt requirements.txt 13 | RUN pip3 install -r requirements.txt 14 | 15 | RUN mkfifo --mode 0666 /var/log/cron.log 16 | 17 | WORKDIR /opt/app 18 | 19 | CMD ["/bin/bash", "-c", "crontab cron/crontab && service cron start && tail -f /var/log/cron.log & wait $!"] 20 | -------------------------------------------------------------------------------- /src/check_env.py: -------------------------------------------------------------------------------- 1 | import settings 2 | 3 | 4 | def check_keys(): 5 | success = True 6 | for key in ('GOOGLE_REFRESH_TOKEN', 7 | 'GOOGLE_CLIENT_ID', 8 | 'GOOGLE_CLIENT_SECRET', 9 | 'GOOGLE_CODE', 10 | 'ZOOM_EMAIL', 11 | 'ZOOM_API_KEY', 12 | 'ZOOM_API_SECRET', 13 | 'VIDEO_DIR', 14 | 'SLACK_TOKEN', 15 | 'SLACK_CHANNEL'): 16 | value = getattr(settings, key) 17 | if not value: 18 | success = False 19 | print('{:<30} IS NOT FILLED.'.format(key)) 20 | else: 21 | print('{:<30} OK.'.format(key)) 22 | 23 | if success: 24 | print('Successfully.') 25 | 26 | 27 | if __name__ == '__main__': 28 | check_keys() 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Welltory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/jwt_auth.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from datetime import datetime 4 | from datetime import timedelta 5 | 6 | import jwt 7 | 8 | 9 | def make_http_headers(access_token: str) -> dict: 10 | return { 11 | "authorization": "Bearer {}".format(access_token), 12 | "content-type": "application/json" 13 | } 14 | 15 | 16 | def generate_access_token( 17 | api_key: str, 18 | api_secret: str, 19 | token_exp_delta: int 20 | ) -> str: 21 | jwt_payload = make_jwt_payload(api_key, token_exp_delta) 22 | return generate_jwt_token(jwt_payload, api_secret) 23 | 24 | 25 | def make_jwt_payload(api_key: str, token_exp_delta: int) -> dict: 26 | dt = datetime.utcnow() + timedelta(seconds=token_exp_delta) 27 | exp = int(time.mktime(dt.timetuple())) 28 | 29 | return { 30 | "iss": api_key, 31 | "exp": exp 32 | } 33 | 34 | 35 | def generate_jwt_token(payload: dict, api_secret: str) -> str: 36 | encoded = jwt.encode(payload, api_secret, algorithm='HS256') 37 | if isinstance(encoded, bytes): 38 | # For PyJWT <= 1.7.1 39 | return encoded.decode() 40 | # For PyJWT >= 2.0.0a1 41 | return encoded 42 | -------------------------------------------------------------------------------- /src/get_google_refresh_token.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import requests 4 | 5 | from settings import GOOGLE_CODE, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET 6 | 7 | TOKEN_URL = 'https://accounts.google.com/o/oauth2/token' 8 | REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' 9 | GRANT_TYPE = 'authorization_code' 10 | 11 | 12 | def get_token(url, code, client_id, client_secret, redirect_uri, grant_type): 13 | assert code, "Not found GOOGLE_CODE" 14 | assert client_id, "Not found GOOGLE_CLIENT_ID" 15 | assert client_secret, "Not found GOOGLE_CLIENT_SECRET" 16 | 17 | payload = dict( 18 | code=code, 19 | client_id=client_id, 20 | client_secret=client_secret, 21 | redirect_uri=redirect_uri, 22 | grant_type=grant_type 23 | ) 24 | response = requests.post(url, data=payload) 25 | return response.status_code, response.json() 26 | 27 | 28 | if __name__ == '__main__': 29 | status, data = get_token( 30 | TOKEN_URL, 31 | GOOGLE_CODE, 32 | GOOGLE_CLIENT_ID, 33 | GOOGLE_CLIENT_SECRET, 34 | REDIRECT_URI, 35 | GRANT_TYPE 36 | ) 37 | if status == 200: 38 | print('Your token:\n{}\n'.format(data.get('refresh_token'))) 39 | else: 40 | print('Error: {}\nDescription: {}'.format( 41 | data.get('error'), data.get('error_description')) 42 | ) 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | .idea 104 | -------------------------------------------------------------------------------- /src/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from os.path import join, dirname 4 | from dotenv import load_dotenv 5 | 6 | dotenv_path = join(dirname(dirname(__file__)), '.env') 7 | load_dotenv(dotenv_path) 8 | 9 | BASE_DIR = dirname(dirname(os.path.abspath(__file__))) 10 | 11 | GOOGLE_REFRESH_TOKEN = os.environ.get('GOOGLE_REFRESH_TOKEN') 12 | GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID') 13 | GOOGLE_CLIENT_SECRET = os.environ.get('GOOGLE_CLIENT_SECRET') 14 | GOOGLE_CODE = os.environ.get('GOOGLE_CODE') 15 | 16 | ZOOM_EMAIL = os.environ.get('ZOOM_EMAIL') 17 | ZOOM_API_KEY = os.environ.get("ZOOM_API_KEY") 18 | ZOOM_API_SECRET = os.environ.get("ZOOM_API_SECRET") 19 | ZOOM_FROM_DAY_DELTA = int(os.environ.get("ZOOM_FROM_DAY_DELTA") or 7) 20 | 21 | VIDEO_DIR = join(BASE_DIR, 'video') 22 | 23 | SLACK_TOKEN = os.environ.get('SLACK_TOKEN') 24 | SLACK_CHANNEL = os.environ.get('SLACK_CHANNEL') 25 | SLACK_CHANNELS_UNIQUE_SETTINGS = {} # Example: {'lesson_1': ['#main', '#lessons']} 26 | NOT_SEND_MSG_TO_PUBLIC_CHANNEL_FOR_MEETINGS = [ 27 | n.strip() for n in os.environ.get("NOT_SEND_MSG_TO_PUBLIC_CHANNEL_FOR_MEETINGS", "").split(",") 28 | ] 29 | 30 | ZAPIER_URL = os.environ.get('ZAPIER_URL') 31 | 32 | DOWNLOADED_FILES = join(BASE_DIR, 'downloaded') 33 | LOCK_FILE = join(BASE_DIR, 'lock') 34 | 35 | 36 | WEBHOOK_BACKEND_PIPELINES = [ 37 | 'webhooks.backends.slack.SlackClient', 38 | ] 39 | 40 | MIN_DURATION = int(os.environ.get('MIN_DURATION') or 10) # minute 41 | 42 | FILTER_MEETING_BY_NAME = os.environ.get( 43 | "FILTER_MEETING_BY_NAME", "false" 44 | ).lower() in ["true", "on", "1"] 45 | 46 | 47 | ONLY_MEETING_NAMES = [ 48 | n.strip() for n in os.environ.get("ONLY_MEETING_NAMES", "").split(",") 49 | ] 50 | 51 | ENABLE_VIDEO_CONVERTING = os.environ.get("ENABLE_VIDEO_CONVERTING", "off") == "on" 52 | 53 | try: 54 | from local_settings import * 55 | except ImportError: 56 | pass 57 | -------------------------------------------------------------------------------- /src/webhooks/backends/slack.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | from webhooks.backends.base import WebHookBase 4 | from settings import SLACK_CHANNEL, SLACK_TOKEN, SLACK_CHANNELS_UNIQUE_SETTINGS, NOT_SEND_MSG_TO_PUBLIC_CHANNEL_FOR_MEETINGS 5 | 6 | 7 | class SlackClient(WebHookBase): 8 | 9 | BASE_URL = 'https://slack.com/api/' 10 | 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self.chat_url = urljoin(self.BASE_URL, 'chat.postMessage') 14 | self.channels = [ch.strip() for ch in SLACK_CHANNEL.split(',')] 15 | self.channels_unique_settings = SLACK_CHANNELS_UNIQUE_SETTINGS 16 | self.bot_name = 'zoom2youtube' 17 | self.token = SLACK_TOKEN 18 | 19 | def get_url(self, event_name, **kwargs) -> str: 20 | return self.chat_url 21 | 22 | def get_request_method(self, event_name, **kwargs) -> str: 23 | return 'post' 24 | 25 | def send(self, event_name: str, **kwargs): 26 | data = self.get_data_for_event(event_name, **kwargs) 27 | url = self.get_url(event_name, **kwargs) 28 | method = self.get_request_method(event_name, **kwargs) 29 | headers = {'content-type': 'application/x-www-form-urlencoded'} 30 | 31 | if self.payload['result']['name'] not in NOT_SEND_MSG_TO_PUBLIC_CHANNEL_FOR_MEETINGS: 32 | for channel in self.channels: 33 | data['channel'] = channel 34 | self._request(url, method=method, payload=data, headers=headers) 35 | 36 | for video_name, channels in self.channels_unique_settings.items(): 37 | if video_name in self.payload['result']['name']: 38 | for channel in channels: 39 | data['channel'] = channel 40 | self._request( 41 | url, method=method, payload=data, headers=headers 42 | ) 43 | 44 | def new_video(self, **kwargs): 45 | name = self.payload['result']['name'] 46 | link = self.payload['result']['link'] 47 | text = '{} - {}'.format(name, link) 48 | data = {'text': text, 'token': self.token} 49 | if self.bot_name: 50 | data['username'] = self.bot_name 51 | return data 52 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from settings import ( 4 | ZOOM_EMAIL, 5 | VIDEO_DIR, 6 | GOOGLE_REFRESH_TOKEN, 7 | GOOGLE_CLIENT_ID, 8 | GOOGLE_CLIENT_SECRET, 9 | DOWNLOADED_FILES, 10 | LOCK_FILE, 11 | MIN_DURATION, 12 | FILTER_MEETING_BY_NAME, 13 | ONLY_MEETING_NAMES, 14 | ZOOM_API_KEY, 15 | ZOOM_API_SECRET, 16 | ZOOM_FROM_DAY_DELTA, 17 | ZOOM_PAGE_SIZE, 18 | ) 19 | from youtube import YoutubeRecording 20 | from zoom import ZoomRecording 21 | from zoom import ZoomJWTClient 22 | 23 | 24 | class lock(object): 25 | def __init__(self, lock_file): 26 | self._lock_file = lock_file 27 | 28 | def __enter__(self): 29 | if os.path.exists(self._lock_file): 30 | exit('The program is still running') 31 | open(self._lock_file, 'w').close() 32 | 33 | def __exit__(self, exc_type, exc_val, exc_tb): 34 | if os.path.exists(self._lock_file): 35 | os.remove(self._lock_file) 36 | 37 | 38 | if __name__ == '__main__': 39 | with lock(LOCK_FILE): 40 | print('Start...') 41 | # download videos from zoom 42 | 43 | zoom_client = ZoomJWTClient( 44 | ZOOM_API_KEY, 45 | ZOOM_API_SECRET, 46 | 86400 47 | ) 48 | 49 | for email in ZOOM_EMAIL.split(','): 50 | print( 51 | "Using email : {}".format( 52 | email 53 | ) 54 | ) 55 | zoom = ZoomRecording( 56 | zoom_client, 57 | email, 58 | duration_min=MIN_DURATION, 59 | filter_meeting_by_name=FILTER_MEETING_BY_NAME, 60 | only_meeting_names=ONLY_MEETING_NAMES, 61 | from_day_delta=ZOOM_FROM_DAY_DELTA, 62 | page_size=ZOOM_PAGE_SIZE 63 | ) 64 | 65 | zoom.download_meetings( 66 | VIDEO_DIR, 67 | DOWNLOADED_FILES 68 | ) 69 | 70 | # upload videos to youtube 71 | youtube = YoutubeRecording( 72 | GOOGLE_CLIENT_ID, 73 | GOOGLE_CLIENT_SECRET, 74 | GOOGLE_REFRESH_TOKEN 75 | ) 76 | youtube.upload_from_dir(VIDEO_DIR) 77 | print('End.') 78 | -------------------------------------------------------------------------------- /src/webhooks/backends/base.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from threading import Thread 4 | 5 | import requests 6 | 7 | 8 | class WebHookBase(Thread): 9 | 10 | def __init__(self, group=None, target=None, name=None, args=(), 11 | kwargs=None, *, daemon=None): 12 | super().__init__(group, target, name, args, kwargs, daemon=daemon) 13 | 14 | assert kwargs 15 | self.event_name = kwargs.get('event_name') 16 | self.payload = kwargs.get('payload') 17 | 18 | assert self.event_name, 'not specified event name' 19 | assert self.payload, 'not specified payload' 20 | 21 | def run(self): 22 | self.send(self.event_name, payload=self.payload) 23 | 24 | def get_url(self, event_name, **kwargs) -> str: 25 | raise NotImplementedError 26 | 27 | def get_request_method(self, event_name, **kwargs) -> str: 28 | raise NotImplementedError 29 | 30 | def send(self, event_name: str, **kwargs): 31 | data = self.get_data_for_event(event_name, **kwargs) 32 | url = self.get_url(event_name, **kwargs) 33 | method = self.get_request_method(event_name, **kwargs) 34 | return self._request(url, method=method, payload=data) 35 | 36 | def get_method_by_event_name(self, event_name): 37 | method = getattr(self, event_name, None) 38 | if not method: 39 | raise NotImplementedError( 40 | 'Event {} not implemented'.format(event_name) 41 | ) 42 | return method 43 | 44 | def get_data_for_event(self, event_name, **kwargs): 45 | method = self.get_method_by_event_name(event_name) 46 | return method(**kwargs) 47 | 48 | def _request(self, url, method='get', payload=None, **kwargs): 49 | no_answer = True 50 | attempt_request_limit = 5 51 | request_count = 0 52 | request_method = getattr(requests, method) 53 | 54 | while no_answer: 55 | if method.lower() == 'post': 56 | resp = request_method(url, data=payload, **kwargs) 57 | else: 58 | resp = request_method(url, **kwargs) 59 | 60 | request_count += 1 61 | 62 | if resp.status_code in (200, 201): 63 | no_answer = False 64 | elif (resp.status_code in (502, 503, 504, 521, 522, 523, 524) 65 | and request_count < attempt_request_limit): 66 | time.sleep(5) 67 | else: 68 | try: 69 | errors = resp.json() 70 | except Exception: 71 | errors = resp.text 72 | 73 | raise Exception('{}: {}'.format(resp.status_code, errors)) 74 | 75 | return resp 76 | -------------------------------------------------------------------------------- /src/zoom.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from datetime import datetime 4 | from datetime import timedelta 5 | from urllib.parse import urljoin 6 | 7 | import requests 8 | 9 | from jwt_auth import make_http_headers 10 | from jwt_auth import generate_access_token 11 | 12 | 13 | class ZoomJWTClient(object): 14 | 15 | BASE_URL = 'https://api.zoom.us/v2/' 16 | 17 | def __init__( 18 | self, 19 | api_key: str, 20 | api_secret: str, 21 | token_exp_delta: int 22 | ): 23 | self.api_key = api_key 24 | self.api_secret = api_secret 25 | self.token_exp_delta = token_exp_delta 26 | 27 | self._setup() 28 | 29 | def _setup(self): 30 | self.access_token = generate_access_token( 31 | self.api_key, 32 | self.api_secret, 33 | self.token_exp_delta 34 | ) 35 | self.http_headers = make_http_headers(self.access_token) 36 | 37 | def _join_url(self, path): 38 | if path.startswith('/'): 39 | path = path[1:] 40 | return urljoin(self.BASE_URL, path) 41 | 42 | def get(self, uri: str, **kwargs): 43 | url = self._join_url(uri) 44 | resp = requests.get(url, headers=self.http_headers, timeout=60) 45 | return resp 46 | 47 | 48 | class ZoomRecording(object): 49 | def __init__( 50 | self, 51 | client, 52 | email, 53 | duration_min=10, 54 | filter_meeting_by_name=False, 55 | only_meeting_names=None, 56 | from_day_delta=7, 57 | page_size=10, 58 | ): 59 | 60 | self.client = client 61 | self.email = email 62 | 63 | self.duration_min = duration_min 64 | self.filter_meeting_by_name = filter_meeting_by_name 65 | self.only_meeting_names = only_meeting_names or [] 66 | self.from_day_delta = from_day_delta 67 | 68 | def get_meetings(self): 69 | uri = "users/{}/recordings?from={}&page_size={}".format( 70 | self.email, 71 | (datetime.utcnow() - timedelta(days=self.from_day_delta)).strftime("%Y-%m-%d"), 72 | page_size 73 | ) 74 | resp = self.client.get(uri) 75 | if resp.status_code != 200: 76 | print( 77 | "Get meeting status error: {}. Detail: {}".format( 78 | resp.status_code, resp.content 79 | ) 80 | ) 81 | return None 82 | 83 | data = resp.json() 84 | return data.get('meetings', []) 85 | 86 | def filter_meetings(self, meetings): 87 | for m in meetings: 88 | if m.get("duration", 0) < self.duration_min: 89 | continue 90 | 91 | if self.filter_meeting_by_name and m.get("topic").strip() not in self.only_meeting_names: 92 | continue 93 | 94 | yield m 95 | 96 | def download_meetings(self, save_dir, downloaded_files): 97 | meetings = self.get_meetings() 98 | if not meetings: 99 | print("Does not exists meetings.") 100 | return 101 | 102 | meetings = self.filter_meetings(meetings) 103 | for meeting in meetings: 104 | recording_files = filter( 105 | lambda x: x.get("file_type") == "MP4", 106 | meeting.get('recording_files', []) 107 | ) 108 | for i, video_data in enumerate(recording_files): 109 | rid = video_data.get('id') 110 | 111 | if not self._is_downloaded(downloaded_files, rid): 112 | continue 113 | 114 | prefix = i or '' 115 | filename = self._get_output_filename(meeting, prefix) 116 | save_path = self._get_output_path(filename, save_dir) 117 | 118 | download_url = video_data.get('download_url') 119 | download_url += "?access_token={}".format(self.client.access_token) 120 | self._real_download_file( 121 | download_url, 122 | save_path 123 | ) 124 | 125 | print('Downloaded the file: {}'.format(video_data.get('download_url'))) 126 | self._save_to_db(downloaded_files, rid) 127 | # TODO Remove video processing 128 | 129 | def _is_downloaded(self, downloaded_files, recording_id): 130 | if not os.path.exists(downloaded_files): 131 | return True 132 | 133 | with open(downloaded_files, 'r') as f: 134 | ids = [x.strip() for x in f.readlines() if x] 135 | 136 | if recording_id in ids: 137 | return False 138 | 139 | return True 140 | 141 | def _get_output_filename(self, meeting, prefix=''): 142 | start_time = datetime.strptime( 143 | meeting.get('start_time'), '%Y-%m-%dT%H:%M:%SZ' 144 | ).strftime('%d-%m-%Y') 145 | topic = meeting.get('topic').replace('/', '.') 146 | return '{}{} {}.mp4'.format(topic, prefix, start_time) 147 | 148 | def _get_output_path(self, fname, save_dir): 149 | if not os.path.exists(save_dir): 150 | os.makedirs(save_dir) 151 | return os.path.join(save_dir, fname) 152 | 153 | def _real_download_file(self, url, fpath): 154 | response = requests.get(url) 155 | if response.status_code == 200: 156 | with open(fpath.encode('utf-8'), 'wb') as f: 157 | f.write(response.content) 158 | return True 159 | return False 160 | 161 | def _save_to_db(self, downloaded_files, recording_id): 162 | with open(downloaded_files, 'a+') as f: 163 | f.write('{}\n'.format(recording_id)) 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Zoom2youtube showcase](http://i.imgur.com/snCLd13.gif) 2 | 3 | ↓↓↓ 4 | 5 | ![Zoom2youtube slack notifications](http://i.imgur.com/2nxeNBG.png) 6 | 7 | # Zoom2Youtube is a utility for transferring video recordings from the Zoom.us to YouTube 8 | 9 | At [Welltory](https://welltory.com), we hold and record 3-4 virtual meetings every day. The easiest way is to record meetings in [zoom.us](https://zoom.us), then upload them to YouTube where they can be accessed by anyone, from any device: phones, Chromecast, etc. We’ve automated video transfers from Zoom to YouTube, added notifications, and now every recording is automatically dropped into a Slack channel. We use privacy settings (**unlisted**) on YouTube to make sure people who aren’t on the team don’t have access to our meetings. 10 | 11 | The project is written in Python and launched in Docker. This simplifies the project’s initial deployment. 12 | 13 | # About 14 | 15 | Disclaimer: The utility is supplied "AS IS" without any warranties. 16 | 17 | You can reach us at github@welltory.com 18 | 19 | # Features 20 | 21 | - Automatically download a new Zoom video 22 | - Upload the video to YouTube (privacy settings: unlisted) 23 | - Drop a link to the YouTube video into a Slack channel 24 | - Filter settings: will not upload videos under 15 minutes long to prevent uploads of accidental recordings 25 | 26 | 27 | Quick Start Guide 28 | ========= 29 | 30 | Step 1 - Set up Docker 31 | ------------------------ 32 | 33 | Install Docker and Docker-Compose 34 | 35 | 1. Docker installation instructions: https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/#install-docker-ce 36 | 2. Docker-compose installation instructions: https://docs.docker.com/compose/install/#alternative-install-options 37 | 38 | Then create a Docker image. To do this, enter the command: 39 | 40 | ``` 41 | $ make build 42 | ``` 43 | 44 | 45 | Step 2 - set up Zoom 46 | ---------------------- 47 | 48 | You need to create a `.env` file in the root directory of the project, specifying the keys listed below: 49 | 50 | ZOOM_API_KEY 51 | ZOOM_API_SECRET 52 | ZOOM_EMAIL 53 | 54 | To get the keys, follow these steps: 55 | 1. Follow the link: https://marketplace.zoom.us/docs/guides/build/jwt-app 56 | 2. Create JWT app 57 | 3. Enter the `API Key` in `ZOOM_API_KEY`, `API Secret` in `ZOOM_API_SECRET` 58 | 59 | 60 | Step 3 - Set up Youtube 61 | ------------------------- 62 | 63 | Add the following keys to the `.env` file 64 | 65 | GOOGLE_REFRESH_TOKEN 66 | GOOGLE_CLIENT_ID 67 | GOOGLE_CLIENT_SECRET 68 | 69 | To get the keys, follow these steps: 70 | 1. Go to the developer console: https://console.developers.google.com/cloud-resource-manager 71 | 2. Create a new project and go to the new project 72 | 3. Follow the link: https://console.developers.google.com/apis/api/youtube.googleapis.com/overview 73 | 4. Turn on `YouTube Data API v3` 74 | 5. Follow the link: https://console.developers.google.com/apis/credentials 75 | 6. create OAuth client credentials. 76 | 7. Select Other types or `Other` (depends on localization), create 77 | 8. Enter `Client ID` in `GOOGLE_CLIENT_ID` and `Client Secret` in `GOOGLE_CLIENT_SECRET` 78 | 79 | To get the `GOOGLE_REFRESH_TOKEN` follow these steps: 80 | 81 | 1. Follow the link: [https://accounts.google.com/o/oauth2/auth?client_id=&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/youtube.upload&access_type=offline&response_type=code](https://accounts.google.com/o/oauth2/auth?client_id=&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/youtube.upload&access_type=offline&response_type=code), **replacing** `` with the `GOOGLE_CLIENT_ID`, you got from the previous step 82 | 2. Select the Google account you need access for 83 | 3. Get access 84 | 4. Enter the token in the .env file, in the `.env` in the `GOOGLE_CODE` field 85 | 5. Run the script in docker container 86 | ``` 87 | $ docker-compose run app bash 88 | $ python3.6 src/get_google_refresh_token.py` 89 | ``` 90 | 6. Enter the refresh token in the `.env` file, in the `GOOGLE_REFRESH_TOKEN` field 91 | 92 | 93 | Step 4 - Set up Slack 94 | ----------------------- 95 | 96 | Add the following keys to the `.env` file 97 | 98 | SLACK_CHANNEL 99 | SLACK_TOKEN 100 | 101 | 1. Enter the recipients (separated with commas) in `SLACK_CHANNEL`, for example `SLACK_CHANNEL=#my_cannel,@my_user` 102 | 2. Enter the slack token in `SLACK_TOKEN` 103 | 104 | 105 | Step 5 - Check keys 106 | ----------------------- 107 | 108 | To make sure all the keys were entered into the `.env` file, run the script in docker container 109 | ``` 110 | $ docker-compose run app bash 111 | $ python3.6 src/check_env.py 112 | ``` 113 | 114 | 115 | Step 6 - Run the app 116 | ------------------------- 117 | 118 | Launch the container: 119 | ``` 120 | $ make up 121 | ``` 122 | 123 | 124 | Another way to run the app, through virtualenv 125 | ------------------------------------------------------------------------ 126 | 127 | 1. Create a virtual environment 128 | ``` 129 | $ virtualenv venv -p /usr/bin/python3 --no-site-package 130 | ``` 131 | 2. Activate virtual environment 132 | ``` 133 | $ source venv/bin/activate 134 | ``` 135 | 3. Establish requirements 136 | ``` 137 | $ pip install -r requirements.txt 138 | ``` 139 | 4. Copy cron config 140 | ``` 141 | $ sudo cp cron/crontab /etc/cron.d/zoom2youtube-cron 142 | ``` 143 | 5. Restart cron 144 | ``` 145 | $ sudo service cron restart 146 | ``` 147 | 148 | Sample .env file 149 | ----------------- 150 | 151 | ``` 152 | ZOOM_API_KEY=AAAAAAAAAAAAAAA 153 | ZOOM_API_SECRET=BBBBBBBBBBBB 154 | ZOOM_EMAIL=test@test.com 155 | 156 | GOOGLE_CLIENT_ID=AAAAAAAAAAAAAA.apps.googleusercontent.com 157 | GOOGLE_CLIENT_SECRET=BBBBBBBBBBBBBb 158 | GOOGLE_REFRESH_TOKEN=CCCCCCCCCCCC 159 | GOOGLE_CODE=DDDDDDDDDDDDDD 160 | 161 | SLACK_CHANNEL=@user 162 | SLACK_TOKEN=AAAAAAAAAAAAA 163 | 164 | ``` 165 | 166 | ``` 167 | // Extra/optional configs 168 | ZOOM_FROM_DAY_DELTA=7 169 | ZOOM_PAGE_SIZE=10 170 | ``` 171 | 172 | License 173 | ------- 174 | 175 | [The MIT License (MIT)](https://en.wikipedia.org/wiki/MIT_License) 176 | -------------------------------------------------------------------------------- /src/youtube.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import random 5 | import time 6 | import re 7 | from typing import List 8 | 9 | import httplib2 10 | import requests 11 | 12 | try: 13 | import httplib 14 | except ImportError: 15 | import http.client as httplib 16 | 17 | from subprocess import call 18 | 19 | from apiclient.discovery import build 20 | from apiclient.errors import HttpError 21 | from apiclient.http import MediaFileUpload 22 | from oauth2client.client import AccessTokenCredentials 23 | 24 | from helpers import import_by_string 25 | from settings import WEBHOOK_BACKEND_PIPELINES 26 | from settings import ENABLE_VIDEO_CONVERTING 27 | 28 | 29 | # Explicitly tell the underlying HTTP transport library not to retry, 30 | # since we are handling retry logic ourselves. 31 | httplib2.RETRIES = 1 32 | 33 | # Maximum number of times to retry before giving up. 34 | MAX_RETRIES = 10 35 | 36 | # Always retry when these exceptions are raised. 37 | RETRIABLE_EXCEPTIONS = ( 38 | httplib2.HttpLib2Error, IOError, httplib.NotConnected, 39 | httplib.IncompleteRead, httplib.ImproperConnectionState, 40 | httplib.CannotSendRequest, httplib.CannotSendHeader, 41 | httplib.ResponseNotReady, httplib.BadStatusLine) 42 | 43 | # Always retry when an apiclient.errors.HttpError 44 | # with one of these status codes is raised. 45 | RETRIABLE_STATUS_CODES = (500, 502, 503, 504) 46 | 47 | 48 | class YoutubeClient(object): 49 | def __init__(self, client_id, client_sercet, refresh_token): 50 | self.client_id = client_id 51 | self.client_secret = client_sercet 52 | self.refresh_token = refresh_token 53 | 54 | def get_auth_code(self): 55 | """ Get access token for connect to youtube api """ 56 | oauth_url = 'https://accounts.google.com/o/oauth2/token' 57 | data = dict( 58 | refresh_token=self.refresh_token, 59 | client_id=self.client_id, 60 | client_secret=self.client_secret, 61 | grant_type='refresh_token', 62 | ) 63 | 64 | headers = { 65 | 'Content-Type': 'application/x-www-form-urlencoded', 66 | 'Accept': 'application/json' 67 | } 68 | response = requests.post(oauth_url, data=data, headers=headers) 69 | response = response.json() 70 | return response.get('access_token') 71 | 72 | def get_authenticated_service(self): 73 | """ Create youtube oauth2 connection """ 74 | credentials = AccessTokenCredentials( 75 | access_token=self.get_auth_code(), 76 | user_agent='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36' 77 | ) 78 | return build( 79 | 'youtube', 'v3', http=credentials.authorize(httplib2.Http()) 80 | ) 81 | 82 | 83 | class FFMpegHandler(object): 84 | 85 | def start(self, video_dir, fpath): 86 | out_fpath = os.path.join(video_dir, 'out.mp4') 87 | call([ 88 | 'ffmpeg', '-i', fpath, '-crf', '18', '-preset', 'veryfast', '-c:a', 'copy', out_fpath 89 | ]) 90 | os.remove(fpath) 91 | os.rename(out_fpath, fpath) 92 | 93 | 94 | class YoutubeRecording(object): 95 | def __init__(self, 96 | client_id, 97 | client_sercet, 98 | refresh_token, 99 | video_handler_class=FFMpegHandler): 100 | self.client = YoutubeClient(client_id, client_sercet, refresh_token) 101 | self.video_handler = video_handler_class() 102 | 103 | def upload_from_dir(self, video_dir: str, 104 | privacy_status='unlisted', 105 | remove_file=True, 106 | notify=True): 107 | 108 | assert os.path.isdir(video_dir), "Not found directory" 109 | files = self._get_files_from_dir(video_dir, 'mp4') 110 | for fname in files: 111 | fpath = os.path.join(video_dir, fname) 112 | if not os.path.exists(fpath): 113 | continue 114 | 115 | if ENABLE_VIDEO_CONVERTING: 116 | self.video_handler.start(video_dir, fpath) 117 | 118 | title = os.path.splitext(os.path.basename(fname))[0] 119 | 120 | title = self.modify_title(title) 121 | 122 | options = dict( 123 | file=fpath, 124 | title=title, 125 | privacyStatus=privacy_status, 126 | ) 127 | video_id = self.upload_video(options) 128 | if not video_id: 129 | continue 130 | 131 | video_url = 'https://www.youtube.com/watch?v={}'.format(video_id) 132 | print('File uploaded: {}'.format(video_url)) 133 | 134 | if notify: 135 | match = re.search(r'\d{2}-\d{2}-\d{4}', title) 136 | date = match.group() if match else None 137 | payload = { 138 | "success": True, 139 | "result": { 140 | "name": title, 141 | "link": video_url, 142 | "date": date, 143 | } 144 | } 145 | self.webhooks(payload=payload) 146 | 147 | if remove_file: 148 | os.remove(fpath) 149 | 150 | def modify_title(self, title): 151 | return title.replace(">", "").replace("<", "") 152 | 153 | def upload_video(self, options: dict): 154 | """ 155 | Options is Dict with 156 | 157 | file - filepath to video 158 | title - title of video 159 | privacyStatus 160 | 161 | :param options: 162 | :return: 163 | """ 164 | body = self._generate_meta_data(options) 165 | connector = self.client.get_authenticated_service() 166 | insert_request = connector \ 167 | .videos() \ 168 | .insert(part=",".join(body.keys()), 169 | body=body, 170 | media_body=MediaFileUpload(options.get('file'), 171 | chunksize=-1, 172 | resumable=True)) 173 | try: 174 | return self._real_upload_video(insert_request) 175 | except Exception as e: 176 | print(str(e)) 177 | 178 | def _generate_meta_data(self, options: dict): 179 | return dict( 180 | snippet=dict( 181 | title=options.get('title'), 182 | ), 183 | status=dict( 184 | privacyStatus=options.get('privacyStatus') 185 | ) 186 | ) 187 | 188 | def _real_upload_video(self, insert_request): 189 | response = None 190 | error = None 191 | retry = 0 192 | print('File upload in progress...', end='') 193 | while response is None: 194 | try: 195 | status, response = insert_request.next_chunk() 196 | print('.', end='') 197 | if 'id' in response: 198 | print() 199 | return response['id'] 200 | except HttpError as err: 201 | if err.resp.status in RETRIABLE_STATUS_CODES: 202 | error = True 203 | else: 204 | raise 205 | except RETRIABLE_EXCEPTIONS: 206 | error = True 207 | 208 | if error: 209 | retry += 1 210 | if retry > MAX_RETRIES: 211 | raise Exception('Maximum retry are fail') 212 | 213 | sleep_seconds = random.random() * 2 ** retry 214 | time.sleep(sleep_seconds) 215 | 216 | def _get_files_from_dir(self, path: str, ext: str) -> List[str]: 217 | """ 218 | Return list of files with .ext 219 | :param path: 220 | :param ext: 221 | :return: 222 | """ 223 | return [x for x in os.listdir(path) if x.endswith('.{}'.format(ext))] 224 | 225 | def webhooks(self, **kwargs): 226 | kwargs['event_name'] = 'new_video' 227 | for backend in WEBHOOK_BACKEND_PIPELINES: 228 | klass = import_by_string(backend) 229 | klass(kwargs=kwargs).start() 230 | --------------------------------------------------------------------------------