Scripting way to upload videos to peertube and youtube
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

415 lines
16 KiB

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. #!/usr/bin/env python
  2. # coding: utf-8
  3. # From Youtube samples: https://raw.githubusercontent.com/youtube/api-samples/master/python/upload_video.py # noqa
  4. import http.client
  5. import httplib2
  6. import random
  7. import time
  8. import copy
  9. import json
  10. from os.path import splitext, basename, exists
  11. import os
  12. import google.oauth2.credentials
  13. import datetime
  14. import pytz
  15. import logging
  16. from tzlocal import get_localzone
  17. from clint.textui.progress import Bar as ProgressBar
  18. from googleapiclient.discovery import build
  19. from googleapiclient.errors import HttpError
  20. from googleapiclient.http import MediaFileUpload
  21. from google_auth_oauthlib.flow import InstalledAppFlow
  22. from . import utils
  23. logger = logging.getLogger('Prismedia')
  24. # Explicitly tell the underlying HTTP transport library not to retry, since
  25. # we are handling retry logic ourselves.
  26. httplib2.RETRIES = 1
  27. # Maximum number of times to retry before giving up.
  28. MAX_RETRIES = 10
  29. # Youtube retriables cases
  30. RETRIABLE_EXCEPTIONS = (
  31. IOError,
  32. httplib2.HttpLib2Error,
  33. http.client.NotConnected,
  34. http.client.IncompleteRead,
  35. http.client.ImproperConnectionState,
  36. http.client.CannotSendRequest,
  37. http.client.CannotSendHeader,
  38. http.client.ResponseNotReady,
  39. http.client.BadStatusLine,
  40. )
  41. RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
  42. CLIENT_SECRETS_FILE = 'youtube_secret.json'
  43. CREDENTIALS_PATH = ".youtube_credentials.json"
  44. SCOPES = ['https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.force-ssl']
  45. API_SERVICE_NAME = 'youtube'
  46. API_VERSION = 'v3'
  47. # Authorize the request and store authorization credentials.
  48. def get_authenticated_service():
  49. check_authenticated_scopes()
  50. flow = InstalledAppFlow.from_client_secrets_file(
  51. CLIENT_SECRETS_FILE, SCOPES)
  52. if exists(CREDENTIALS_PATH):
  53. with open(CREDENTIALS_PATH, 'r') as f:
  54. credential_params = json.load(f)
  55. credentials = google.oauth2.credentials.Credentials(
  56. credential_params["token"],
  57. refresh_token=credential_params["_refresh_token"],
  58. token_uri=credential_params["_token_uri"],
  59. client_id=credential_params["_client_id"],
  60. client_secret=credential_params["_client_secret"]
  61. )
  62. else:
  63. credentials = flow.run_console()
  64. with open(CREDENTIALS_PATH, 'w') as f:
  65. p = copy.deepcopy(vars(credentials))
  66. del p["expiry"]
  67. json.dump(p, f)
  68. return build(API_SERVICE_NAME, API_VERSION, credentials=credentials, cache_discovery=False)
  69. def check_authenticated_scopes():
  70. if exists(CREDENTIALS_PATH):
  71. with open(CREDENTIALS_PATH, 'r') as f:
  72. credential_params = json.load(f)
  73. # Check if all scopes are present
  74. if credential_params["_scopes"] != SCOPES:
  75. logger.warning("Youtube: Credentials are obsolete, need to re-authenticate.")
  76. os.remove(CREDENTIALS_PATH)
  77. def convert_youtube_date(date):
  78. # Youtube needs microsecond and the local timezone from ISO 8601
  79. date = date + ".000001"
  80. date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.%f')
  81. tz = get_localzone()
  82. tz = pytz.timezone(str(tz))
  83. return tz.localize(date).isoformat()
  84. def initialize_upload(youtube, options):
  85. path = options.get('--file')
  86. tags = None
  87. if options.get('--tags'):
  88. tags = options.get('--tags').split(',')
  89. category = None
  90. if options.get('--category'):
  91. category = utils.getCategory(options.get('--category'), 'youtube')
  92. language = None
  93. if options.get('--language'):
  94. language = utils.getLanguage(options.get('--language'), "youtube")
  95. license = None
  96. if options.get('--cca'):
  97. license = "creativeCommon"
  98. # We set recordingDetails empty because it's easier to add options if it already exists
  99. # and if empty, it does not cause problem during upload
  100. body = {
  101. "snippet": {
  102. "title": options.get('--name') or splitext(basename(path))[0],
  103. "description": options.get('--description') or "default description",
  104. "tags": tags,
  105. # if no category, set default to 1 (Films)
  106. "categoryId": str(category or 1),
  107. "defaultAudioLanguage": str(language or 'en')
  108. },
  109. "status": {
  110. "privacyStatus": str(options.get('--privacy') or "private"),
  111. "license": str(license or "youtube"),
  112. },
  113. "recordingDetails": {
  114. }
  115. }
  116. # If youtubeAt exists, use instead of publishAt
  117. if options.get('--youtubeAt'):
  118. publishAt = options.get('--youtubeAt')
  119. elif options.get('--publishAt'):
  120. publishAt = options.get('--publishAt')
  121. # Check if publishAt variable exists in local variables
  122. if 'publishAt' in locals():
  123. publishAt = convert_youtube_date(publishAt)
  124. body['status']['publishAt'] = str(publishAt)
  125. # Set originalDate except if the user force no originalDate
  126. if options.get('--originalDate'):
  127. originalDate = convert_youtube_date(options.get('--originalDate'))
  128. body['recordingDetails']['recordingDate'] = str(originalDate)
  129. if options.get('--playlist'):
  130. playlist_id = get_playlist_by_name(youtube, options.get('--playlist'))
  131. if not playlist_id and options.get('--playlistCreate'):
  132. playlist_id = create_playlist(youtube, options.get('--playlist'))
  133. elif not playlist_id:
  134. logger.warning("Youtube: Playlist `" + options.get('--playlist') + "` is unknown.")
  135. logger.warning("Youtube: If you want to create it, set the --playlistCreate option.")
  136. playlist_id = ""
  137. else:
  138. playlist_id = ""
  139. # Call the API's videos.insert method to create and upload the video.
  140. insert_request = youtube.videos().insert(
  141. part=','.join(list(body.keys())),
  142. body=body,
  143. media_body=MediaFileUpload(path, chunksize=-1, resumable=True) # 256 * 1024 * 1024
  144. )
  145. video_id = resumable_upload(insert_request, 'video', 'insert', options)
  146. # If we get a video_id, upload is successful and we are able to set thumbnail
  147. if video_id and options.get('--thumbnail'):
  148. set_thumbnail(options, youtube, options.get('--thumbnail'), videoId=video_id)
  149. # If we get a video_id and a playlist_id, upload is successful and we are able to set playlist
  150. if video_id and playlist_id != "":
  151. set_playlist(youtube, playlist_id, video_id)
  152. def get_playlist_by_name(youtube, playlist_name):
  153. pageToken = ""
  154. while pageToken != None:
  155. response = youtube.playlists().list(
  156. part='snippet,id',
  157. mine=True,
  158. maxResults=50,
  159. pageToken=pageToken
  160. ).execute()
  161. for playlist in response["items"]:
  162. if playlist["snippet"]["title"] == playlist_name:
  163. return playlist["id"]
  164. # Ask next page if there are any
  165. if "nextPageToken" in response:
  166. pageToken = response["nextPageToken"]
  167. else:
  168. pageToken = None
  169. def create_playlist(youtube, playlist_name):
  170. template = 'Youtube: Playlist %s does not exist, creating it.'
  171. logger.info(template % (str(playlist_name)))
  172. resources = build_resource({'snippet.title': playlist_name,
  173. 'snippet.description': '',
  174. 'status.privacyStatus': 'public'})
  175. response = youtube.playlists().insert(
  176. body=resources,
  177. part='status,snippet,id'
  178. ).execute()
  179. return response["id"]
  180. def build_resource(properties):
  181. resource = {}
  182. for p in properties:
  183. # Given a key like "snippet.title", split into "snippet" and "title", where
  184. # "snippet" will be an object and "title" will be a property in that object.
  185. prop_array = p.split('.')
  186. ref = resource
  187. for pa in range(0, len(prop_array)):
  188. is_array = False
  189. key = prop_array[pa]
  190. # For properties that have array values, convert a name like
  191. # "snippet.tags[]" to snippet.tags, and set a flag to handle
  192. # the value as an array.
  193. if key[-2:] == '[]':
  194. key = key[0:len(key)-2:]
  195. is_array = True
  196. if pa == (len(prop_array) - 1):
  197. # Leave properties without values out of inserted resource.
  198. if properties[p]:
  199. if is_array:
  200. ref[key] = properties[p].split(',')
  201. else:
  202. ref[key] = properties[p]
  203. elif key not in ref:
  204. # For example, the property is "snippet.title", but the resource does
  205. # not yet have a "snippet" object. Create the snippet object here.
  206. # Setting "ref = ref[key]" means that in the next time through the
  207. # "for pa in range ..." loop, we will be setting a property in the
  208. # resource's "snippet" object.
  209. ref[key] = {}
  210. ref = ref[key]
  211. else:
  212. # For example, the property is "snippet.description", and the resource
  213. # already has a "snippet" object.
  214. ref = ref[key]
  215. return resource
  216. def set_thumbnail(options, youtube, media_file, **kwargs):
  217. kwargs = utils.remove_empty_kwargs(**kwargs)
  218. request = youtube.thumbnails().set(
  219. media_body=MediaFileUpload(media_file, chunksize=-1,
  220. resumable=True),
  221. **kwargs
  222. )
  223. return resumable_upload(request, 'thumbnail', 'set', options)
  224. def set_playlist(youtube, playlist_id, video_id):
  225. logger.info('Youtube: Configuring playlist...')
  226. resource = build_resource({'snippet.playlistId': playlist_id,
  227. 'snippet.resourceId.kind': 'youtube#video',
  228. 'snippet.resourceId.videoId': video_id,
  229. 'snippet.position': ''}
  230. )
  231. try:
  232. youtube.playlistItems().insert(
  233. body=resource,
  234. part='snippet'
  235. ).execute()
  236. except Exception as e:
  237. if hasattr(e, 'message'):
  238. logger.critical("Youtube: " + str(e.message))
  239. exit(1)
  240. else:
  241. logger.critical("Youtube: " + str(e))
  242. exit(1)
  243. logger.info('Youtube: Video is correctly added to the playlist.')
  244. # This method implements an exponential backoff strategy to resume a
  245. # failed upload.
  246. def resumable_upload(request, resource, method, options):
  247. response = None
  248. error = None
  249. retry = 0
  250. logger_stdout = None
  251. if options.get('--url-only') or options.get('--batch'):
  252. logger_stdout = logging.getLogger('stdoutlogs')
  253. template = 'Youtube: Uploading %s...'
  254. logger.info(template % resource)
  255. # progress_callback = create_callback(100, "percentage") # options.get('--progress'))
  256. while response is None:
  257. try:
  258. status, response = request.next_chunk()
  259. # https://googleapis.github.io/google-api-python-client/docs/media.html
  260. if status:
  261. # progress_callback(int(status.progress() * 100.0))
  262. print("Uploaded %d%%." % int(status.progress() * 100))
  263. else:
  264. print("No status but a loop has been done")
  265. if response is not None:
  266. if method == 'insert' and 'id' in response:
  267. logger.info('Youtube: Video was successfully uploaded.')
  268. template = 'Youtube: Watch it at https://youtu.be/%s (post-encoding could take some time)'
  269. logger.info(template % response['id'])
  270. template_stdout = 'https://youtu.be/%s'
  271. if options.get('--url-only'):
  272. logger_stdout.info(template_stdout % response['id'])
  273. elif options.get('--batch'):
  274. logger_stdout.info("Youtube: " + template_stdout % response['id'])
  275. return response['id']
  276. elif method != 'insert' or "id" not in response:
  277. logger.info('Youtube: Thumbnail was successfully set.')
  278. else:
  279. template = ('Youtube: The upload failed with an '
  280. 'unexpected response: %s')
  281. logger.critical(template % response)
  282. exit(1)
  283. except HttpError as e:
  284. if e.resp.status in RETRIABLE_STATUS_CODES:
  285. template = 'Youtube: A retriable HTTP error %d occurred:\n%s'
  286. error = template % (e.resp.status, e.content)
  287. else:
  288. raise
  289. except RETRIABLE_EXCEPTIONS as e:
  290. error = 'Youtube: A retriable error occurred: %s' % e
  291. if error is not None:
  292. logger.warning(error)
  293. retry += 1
  294. if retry > MAX_RETRIES:
  295. logger.error('Youtube: No longer attempting to retry.')
  296. max_sleep = 2 ** retry
  297. sleep_seconds = random.random() * max_sleep
  298. logger.warning('Youtube: Sleeping %f seconds and then retrying...'
  299. % sleep_seconds)
  300. time.sleep(sleep_seconds)
  301. # upload_finished = False
  302. def create_callback(filesize, progress_type):
  303. upload_size_MB = filesize * (1 / (1024 * 1024))
  304. if progress_type is None or "percentage" in progress_type.lower():
  305. progress_lambda = lambda x: int((x / filesize) * 100) # Default to percentage
  306. elif "bigfile" in progress_type.lower():
  307. progress_lambda = lambda x: x * (1 / (1024 * 1024)) # MB
  308. elif "accurate" in progress_type.lower():
  309. progress_lambda = lambda x: x * (1 / (1024)) # kB
  310. else:
  311. # Should not happen outside of development when adding partly a progress type
  312. logger.critical("Youtube: Unknown progress type `" + progress_type + "`")
  313. exit(1)
  314. bar = ProgressBar(expected_size=progress_lambda(filesize), label=f"Youtube upload progress ({upload_size_MB:.2f}MB) ", filled_char='=')
  315. def callback(current_position):
  316. # We want the condition to capture the varible from the parent scope, not a local variable that is created after
  317. # global upload_finished
  318. progress = progress_lambda(current_position)
  319. bar.show(progress)
  320. if current_position == filesize:
  321. # if not upload_finished:
  322. # # We get two time in the callback with both bytes equals, skip the first
  323. # upload_finished = True
  324. # else:
  325. # Print a blank line to not (partly) override the progress bar
  326. print()
  327. logger.info("Youtube: Upload finish, Processing…")
  328. return callback
  329. def hearthbeat():
  330. """Use the minimums credits possibles of the API so google does not readuce to 0 the allowed credits.
  331. This apparently happens after 90 days without any usage of credits.
  332. For more info see the official documentations:
  333. - General informations about quotas: https://developers.google.com/youtube/v3/getting-started#quota
  334. - Quota costs for API requests: https://developers.google.com/youtube/v3/determine_quota_cost
  335. - ToS (Americas) #Usage and Quotas: https://developers.google.com/youtube/terms/api-services-terms-of-service#usage-and-quotas"""
  336. youtube = get_authenticated_service()
  337. try:
  338. get_playlist_by_name(youtube, "Foo")
  339. except HttpError as e:
  340. logger.error('Youtube: An HTTP error %d occurred on hearthbeat:\n%s' %
  341. (e.resp.status, e.content))
  342. def run(options):
  343. youtube = get_authenticated_service()
  344. try:
  345. initialize_upload(youtube, options)
  346. except HttpError as e:
  347. logger.error('Youtube: An HTTP error %d occurred:\n%s' % (e.resp.status,
  348. e.content))