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.

407 lines
17 KiB

  1. #!/usr/bin/env python
  2. # coding: utf-8
  3. import pluginInterfaces as pi
  4. import utils
  5. import mimetypes
  6. import json
  7. import logging
  8. import datetime
  9. import pytz
  10. from os.path import splitext, basename, abspath
  11. from tzlocal import get_localzone
  12. from configparser import RawConfigParser
  13. from requests_oauthlib import OAuth2Session
  14. from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
  15. from oauthlib.oauth2 import LegacyApplicationClient
  16. from clint.textui.progress import Bar as ProgressBar
  17. from yapsy.PluginManager import PluginManagerSingleton
  18. logger = logging.getLogger('Prismedia')
  19. class Peertube(pi.IPlatformPlugin):
  20. """
  21. Plugin to upload to the Peertube platform.
  22. The connections files should be set as # TODO: EXPLAIN HOW TO SETUP THE SECRET FILES
  23. - `publish-at-peertube=DATE`: overrides the default `publish-at=DATE` for this platform. # TODO: Maybe we will use a [<plugin_name>] section on the config fire, explain that.
  24. """
  25. NAME = "peertube" # TODO: find if it is possible to get the plugin’s name from inside the plugin
  26. SECRETS_FILE = "peertube_secret"
  27. PRIVACY = {
  28. "public": 1,
  29. "unlisted": 2,
  30. "private": 3
  31. }
  32. CATEGORY = {
  33. "music": 1,
  34. "films": 2,
  35. "vehicles": 3,
  36. "sport": 5,
  37. "travels": 6,
  38. "gaming": 7,
  39. "people": 8,
  40. "comedy": 9,
  41. "entertainment": 10,
  42. "news": 11,
  43. "how to": 12,
  44. "education": 13,
  45. "activism": 14,
  46. "science & technology": 15,
  47. "science": 15,
  48. "technology": 15,
  49. "animals": 16
  50. }
  51. LANGUAGE = {
  52. "arabic": "ar",
  53. "english": "en",
  54. "french": "fr",
  55. "german": "de",
  56. "hindi": "hi",
  57. "italian": "it",
  58. "japanese": "ja",
  59. "korean": "ko",
  60. "mandarin": "zh",
  61. "portuguese": "pt",
  62. "punjabi": "pa",
  63. "russian": "ru",
  64. "spanish": "es"
  65. }
  66. def __init__(self):
  67. self.channelCreate = False
  68. self.oauth = {}
  69. self.secret = {}
  70. def prepare_options(self, video, options):
  71. pluginManager = PluginManagerSingleton.get()
  72. # TODO: get the `publish-at-peertube=DATE` option
  73. # TODO: get the `channel` and `channel-create` options
  74. pluginManager.registerOptionFromPlugin("Platform", self.NAME, "publish-at", "2034-05-07T19:00:00")
  75. pluginManager.registerOptionFromPlugin("Platform", self.NAME, "channel", "toto")
  76. pluginManager.registerOptionFromPlugin("Platform", self.NAME, "channel-create", False)
  77. video.platform[self.NAME].channel = ""
  78. self.secret = RawConfigParser()
  79. self.secret.read(self.SECRETS_FILE)
  80. self.get_authenticated_service()
  81. return True
  82. def get_authenticated_service(self):
  83. instance_url = str(self.secret.get('peertube', 'peertube_url')).rstrip("/")
  84. oauth_client = LegacyApplicationClient(
  85. client_id=str(self.secret.get('peertube', 'client_id'))
  86. )
  87. self.oauth = OAuth2Session(client=oauth_client)
  88. self.oauth.fetch_token(
  89. token_url=str(instance_url + '/api/v1/users/token'),
  90. # lower as peertube does not store uppercase for pseudo
  91. username=str(self.secret.get('peertube', 'username').lower()),
  92. password=str(self.secret.get('peertube', 'password')),
  93. client_id=str(self.secret.get('peertube', 'client_id')),
  94. client_secret=str(self.secret.get('peertube', 'client_secret'))
  95. )
  96. def convert_peertube_date(self, date):
  97. date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%S')
  98. tz = get_localzone()
  99. tz = pytz.timezone(str(tz))
  100. return tz.localize(date).isoformat()
  101. def get_default_channel(self, user_info):
  102. return user_info['videoChannels'][0]['id']
  103. def get_channel_by_name(self, user_info, video):
  104. for channel in user_info["videoChannels"]:
  105. if channel['displayName'] == video.platform[self.NAME].channel:
  106. return channel['id']
  107. def create_channel(self, instance_url, video):
  108. template = ('Peertube: Channel %s does not exist, creating it.')
  109. logger.info(template % (video.platform[self.NAME].channel))
  110. channel_name = utils.cleanString(video.platform[self.NAME].channel)
  111. # Peertube allows 20 chars max for channel name
  112. channel_name = channel_name[:19]
  113. data = '{"name":"' + channel_name + '", \
  114. "displayName":"' + video.platform[self.NAME].channel + '", \
  115. "description":null, \
  116. "support":null}'
  117. headers = {
  118. 'Content-Type': "application/json; charset=UTF-8"
  119. }
  120. try:
  121. response = self.oauth.post(instance_url + "/api/v1/video-channels/",
  122. data=data.encode('utf-8'),
  123. headers=headers)
  124. except Exception as e:
  125. logger.error("Peertube: " + utils.get_exception_string(e))
  126. if response is not None:
  127. if response.status_code == 200:
  128. jresponse = response.json()
  129. jresponse = jresponse['videoChannel']
  130. return jresponse['id']
  131. if response.status_code == 409:
  132. logger.critical('Peertube: It seems there is a conflict with an existing channel named '
  133. + channel_name + '.'
  134. ' Please beware Peertube internal name is compiled from 20 firsts characters of channel name.'
  135. ' Also note that channel name are not case sensitive (no uppercase nor accent)'
  136. ' Please check your channel name and retry.')
  137. exit(1)
  138. else:
  139. logger.critical(('Peertube: Creating channel failed with an unexpected response: '
  140. '%s') % response)
  141. exit(1)
  142. def get_default_playlist(self, user_info):
  143. return user_info['videoChannels'][0]['id']
  144. def get_playlist_by_name(self, instance_url, username, video):
  145. start = 0
  146. user_playlists = json.loads(self.oauth.get(
  147. instance_url + "/api/v1/accounts/" + username + "/video-playlists?start=" + str(
  148. start) + "&count=100").content)
  149. total = user_playlists["total"]
  150. data = user_playlists["data"]
  151. # We need to iterate on pagination as peertube returns max 100 playlists (see #41)
  152. while start < total:
  153. for playlist in data:
  154. if playlist['displayName'] == video.playlistName:
  155. return playlist['id']
  156. start = start + 100
  157. user_playlists = json.loads(self.oauth.get(
  158. instance_url + "/api/v1/accounts/" + username + "/video-playlists?start=" + str(
  159. start) + "&count=100").content)
  160. data = user_playlists["data"]
  161. def create_playlist(self, instance_url, video, channel):
  162. template = ('Peertube: Playlist %s does not exist, creating it.')
  163. logger.info(template % (str(video.playlistName)))
  164. # We use files for form-data Content
  165. # see https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file
  166. # None is used to mute "filename" field
  167. files = {'displayName': (None, str(video.playlistName)),
  168. 'privacy': (None, "1"),
  169. 'description': (None, "null"),
  170. 'videoChannelId': (None, str(channel)),
  171. 'thumbnailfile': (None, "null")}
  172. try:
  173. response = self.oauth.post(instance_url + "/api/v1/video-playlists/",
  174. files=files)
  175. except Exception as e:
  176. logger.error("Peertube: " + utils.get_exception_string(e))
  177. if response is not None:
  178. if response.status_code == 200:
  179. jresponse = response.json()
  180. jresponse = jresponse['videoPlaylist']
  181. return jresponse['id']
  182. else:
  183. logger.critical(('Peertube: Creating the playlist failed with an unexpected response: '
  184. '%s') % response)
  185. exit(1)
  186. def set_playlist(self, instance_url, video_id, playlist_id):
  187. logger.info('Peertube: add video to playlist.')
  188. data = '{"videoId":"' + str(video_id) + '"}'
  189. headers = {
  190. 'Content-Type': "application/json"
  191. }
  192. try:
  193. response = self.oauth.post(instance_url + "/api/v1/video-playlists/" + str(playlist_id) + "/videos",
  194. data=data,
  195. headers=headers)
  196. except Exception as e:
  197. logger.error("Peertube: " + utils.get_exception_string(e))
  198. if response is not None:
  199. if response.status_code == 200:
  200. logger.info('Peertube: Video is successfully added to the playlist.')
  201. else:
  202. logger.critical(('Peertube: Configuring the playlist failed with an unexpected response: '
  203. '%s') % response)
  204. exit(1)
  205. def upload_video(self, video, options):
  206. def get_userinfo(base_url):
  207. return json.loads(self.oauth.get(base_url + "/api/v1/users/me").content)
  208. def get_file(video_path):
  209. mimetypes.init()
  210. return (basename(video_path), open(abspath(video_path), 'rb'),
  211. mimetypes.types_map[splitext(video_path)[1]])
  212. path = video.path
  213. instance_url = str(self.secret.get('peertube', 'peertube_url')).rstrip('/')
  214. user_info = get_userinfo(instance_url)
  215. username = str(self.secret.get('peertube', 'username').lower())
  216. # We need to transform fields into tuple to deal with tags as
  217. # MultipartEncoder does not support list refer
  218. # https://github.com/requests/toolbelt/issues/190 and
  219. # https://github.com/requests/toolbelt/issues/205
  220. fields = [
  221. ("name", video.name),
  222. ("licence", "1"), # TODO: get licence from video object
  223. ("description", video.description),
  224. ("category", str(self.CATEGORY[video.category])),
  225. ("language", str(self.LANGUAGE[video.language])),
  226. ("commentsEnabled", "0" if video.disableComments else "1"),
  227. ("nsfw", "1" if video.nsfw else "0"),
  228. ("videofile", get_file(path))
  229. ]
  230. tag_number = 0
  231. for strtag in video.tags:
  232. tag_number = tag_number + 1
  233. # Empty tag crashes Peertube, so skip them
  234. if strtag == "":
  235. continue
  236. # Tag more than 30 chars crashes Peertube, so skip tags
  237. if len(strtag) >= 30:
  238. logger.warning(
  239. "Peertube: Sorry, Peertube does not support tag with more than 30 characters, please reduce tag: " + strtag)
  240. logger.warning("Peertube: Meanwhile, this tag will be skipped")
  241. continue
  242. # Peertube supports only 5 tags at the moment
  243. if tag_number > 5:
  244. logger.warning("Peertube: Sorry, Peertube support 5 tags max, additional tag will be skipped")
  245. logger.warning("Peertube: Skipping tag " + strtag)
  246. continue
  247. fields.append(("tags[]", strtag))
  248. # If peertubeAt exists, use instead of publishAt
  249. if video.platform[self.NAME].publishAt:
  250. publishAt = video.platform[self.NAME].publishAt
  251. elif video.publishAt:
  252. publishAt = video.publishAt
  253. if 'publishAt' in locals():
  254. publishAt = convert_peertube_date(publishAt)
  255. fields.append(("scheduleUpdate[updateAt]", publishAt))
  256. fields.append(("scheduleUpdate[privacy]", str(self.PRIVACY["public"])))
  257. fields.append(("privacy", str(self.PRIVACY["private"])))
  258. else:
  259. fields.append(("privacy", str(self.PRIVACY[video.privacy])))
  260. if video.originalDate:
  261. originalDate = convert_peertube_date(video.originalDate)
  262. fields.append(("originallyPublishedAt", originalDate))
  263. if video.thumbnail:
  264. fields.append(("thumbnailfile", get_file(video.thumbnail)))
  265. fields.append(("previewfile", get_file(video.thumbnail)))
  266. if hasattr(video.platform[self.NAME], "channel"): # TODO: Should always be present
  267. channel_id = self.get_channel_by_name(user_info, video)
  268. if not channel_id and self.channelCreate:
  269. channel_id = self.create_channel(instance_url, video)
  270. elif not channel_id:
  271. logger.warning("Peertube: Channel `" + video.platform[
  272. self.NAME].channel + "` is unknown, using default channel.") # TODO: debate if we should have the same message and behavior than playlist: "does not exist, please set --channelCreate"
  273. channel_id = self.get_default_channel(user_info)
  274. else:
  275. channel_id = self.get_default_channel(user_info)
  276. fields.append(("channelId", str(channel_id)))
  277. if video.playlistName:
  278. playlist_id = get_playlist_by_name(instance_url, username, video)
  279. if not playlist_id and video.playlistCreate:
  280. playlist_id = create_playlist(instance_url, video, channel_id)
  281. elif not playlist_id:
  282. logger.critical(
  283. "Peertube: Playlist `" + video.playlistName + "` does not exist, please set --playlistCreate"
  284. " if you want to create it")
  285. exit(1)
  286. encoder = MultipartEncoder(fields)
  287. # if options.get('--quiet'):
  288. multipart_data = encoder
  289. # else:
  290. # progress_callback = create_callback(encoder, options.get('--progress'))
  291. # multipart_data = MultipartEncoderMonitor(encoder, progress_callback)
  292. headers = {
  293. 'Content-Type': multipart_data.content_type
  294. }
  295. response = self.oauth.post(instance_url + "/api/v1/videos/upload",
  296. data=multipart_data,
  297. headers=headers)
  298. if response is not None:
  299. if response.status_code == 200:
  300. jresponse = response.json()
  301. jresponse = jresponse['video']
  302. uuid = jresponse['uuid']
  303. video_id = str(jresponse['id'])
  304. logger.info("Peertube: Video was successfully uploaded.")
  305. template_url = "%s/videos/watch/%s"
  306. video.platform[self.NAME].url = template_url % (instance_url, uuid)
  307. logger.info("Peertube: Watch it at " + video.platform[self.NAME].url + ".")
  308. # Upload is successful we may set playlist
  309. if 'playlist_id' in locals():
  310. set_playlist(instance_url, video_id, playlist_id)
  311. else:
  312. logger.critical(('Peertube: The upload failed with an unexpected response: '
  313. '%s') % response)
  314. exit(1)
  315. # upload_finished = False
  316. # def create_callback(encoder, progress_type):
  317. # upload_size_MB = encoder.len * (1 / (1024 * 1024))
  318. #
  319. # if progress_type is None or "percentage" in progress_type.lower():
  320. # progress_lambda = lambda x: int((x / encoder.len) * 100) # Default to percentage
  321. # elif "bigfile" in progress_type.lower():
  322. # progress_lambda = lambda x: x * (1 / (1024 * 1024)) # MB
  323. # elif "accurate" in progress_type.lower():
  324. # progress_lambda = lambda x: x * (1 / (1024)) # kB
  325. # else:
  326. # # Should not happen outside of development when adding partly a progress type
  327. # logger.critical("Peertube: Unknown progress type `" + progress_type + "`")
  328. # exit(1)
  329. #
  330. # bar = ProgressBar(expected_size=progress_lambda(encoder.len), label=f"Peertube upload progress ({upload_size_MB:.2f}MB) ", filled_char='=')
  331. #
  332. # def callback(monitor):
  333. # # We want the condition to capture the varible from the parent scope, not a local variable that is created after
  334. # global upload_finished
  335. # progress = progress_lambda(monitor.bytes_read)
  336. #
  337. # bar.show(progress)
  338. #
  339. # if monitor.bytes_read == encoder.len:
  340. # if not upload_finished:
  341. # # We get two time in the callback with both bytes equals, skip the first
  342. # upload_finished = True
  343. # else:
  344. # # Print a blank line to not (partly) override the progress bar
  345. # print()
  346. # logger.info("Peertube: Upload finish, Processing…")
  347. #
  348. # return callback
  349. def heartbeat(self):
  350. """
  351. If needed for your platform, use a bit of the api so the platform is aware the keys are still in use.
  352. """
  353. print("heartbeat for peertube (nothing to do)")
  354. pass
  355. # def run(options):
  356. def upload(self, video, options):
  357. logger.info('Peertube: Uploading video...')
  358. self.upload_video(video, options)