youtube_upload.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. #!/usr/bin/python2
  2. #
  3. # Youtube-upload is free software: you can redistribute it and/or modify
  4. # it under the terms of the GNU General Public License as published by
  5. # the Free Software Foundation, either version 3 of the License, or
  6. # (at your option) any later version.
  7. #
  8. # Youtube-upload is distributed in the hope that it will be useful,
  9. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. # GNU General Public License for more details.
  12. #
  13. # You should have received a copy of the GNU General Public License
  14. # along with Youtube-upload. If not, see <http://www.gnu.org/licenses/>.
  15. #
  16. # Author: Arnau Sanchez <tokland@gmail.com>
  17. # Websites: http://code.google.com/p/youtube-upload
  18. # http://code.google.com/p/tokland
  19. """
  20. Upload a video to Youtube from the command-line.
  21. $ youtube-upload --email=myemail@gmail.com \
  22. --password=mypassword \
  23. --title="A.S. Mutter playing" \
  24. --description="Anne Sophie Mutter plays Beethoven" \
  25. --category=Music \
  26. --keywords="mutter, beethoven" \
  27. anne_sophie_mutter.flv
  28. www.youtube.com/watch?v=pxzZ-fYjeYs
  29. """
  30. import os
  31. import re
  32. import sys
  33. import time
  34. import string
  35. import locale
  36. import urllib
  37. import socket
  38. import getpass
  39. import StringIO
  40. import optparse
  41. import itertools
  42. # python >= 2.6
  43. from xml.etree import ElementTree
  44. # python-gdata (>= 1.2.4)
  45. import gdata.media
  46. import gdata.service
  47. import gdata.geo
  48. import gdata.youtube
  49. import gdata.youtube.service
  50. from gdata.media import YOUTUBE_NAMESPACE
  51. from atom import ExtensionElement
  52. # http://pycurl.sourceforge.net/
  53. try:
  54. import pycurl
  55. except ImportError:
  56. pycurl = None
  57. # http://code.google.com/p/python-progressbar (>= 2.3)
  58. try:
  59. import progressbar
  60. except ImportError:
  61. progressbar = None
  62. class InvalidCategory(Exception): pass
  63. class VideoArgumentMissing(Exception): pass
  64. class OptionsMissing(Exception): pass
  65. class BadAuthentication(Exception): pass
  66. class CaptchaRequired(Exception): pass
  67. class ParseError(Exception): pass
  68. class VideoNotFound(Exception): pass
  69. class UnsuccessfulHTTPResponseCode(Exception): pass
  70. VERSION = "0.7.4"
  71. DEVELOPER_KEY = "AI39si7iJ5TSVP3U_j4g3GGNZeI6uJl6oPLMxiyMst24zo1FEgnLzcG4i" + \
  72. "SE0t2pLvi-O03cW918xz9JFaf_Hn-XwRTTK7i1Img"
  73. EXIT_CODES = {
  74. # Non-retryable
  75. BadAuthentication: 1,
  76. VideoArgumentMissing: 2,
  77. OptionsMissing: 2,
  78. InvalidCategory: 3,
  79. CaptchaRequired: 4, # retry with options --captcha-token and --captcha-response
  80. ParseError: 5,
  81. VideoNotFound: 6,
  82. # Retryable
  83. UnsuccessfulHTTPResponseCode: 100,
  84. }
  85. def to_utf8(s):
  86. """Re-encode string from the default system encoding to UTF-8."""
  87. current = locale.getpreferredencoding()
  88. return (s.decode(current).encode("UTF-8") if s and current != "UTF-8" else s)
  89. def debug(obj, fd=sys.stderr):
  90. """Write obj to standard error."""
  91. string = str(obj.encode(get_encoding(fd), "backslashreplace")
  92. if isinstance(obj, unicode) else obj)
  93. fd.write(string + "\n")
  94. def catch_exceptions(exit_codes, fun, *args, **kwargs):
  95. """
  96. Catch exceptions on 'fun(*args, **kwargs)' and return the exit code specified
  97. in the 'exit_codes' dictionary. Returns 0 if no exception is raised.
  98. """
  99. try:
  100. fun(*args, **kwargs)
  101. return 0
  102. except tuple(exit_codes.keys()) as exc:
  103. debug("[%s] %s" % (exc.__class__.__name__, exc))
  104. return exit_codes[exc.__class__]
  105. def get_encoding(fd):
  106. """Guess terminal encoding."""
  107. return fd.encoding or locale.getpreferredencoding()
  108. def compact(it):
  109. """Filter false (in the truth sense) elements in iterator."""
  110. return filter(bool, it)
  111. def tosize(seq, size):
  112. """Return a list of fixed length from sequence (which may be shorter or longer)."""
  113. return (seq[:size] if len(seq) >= size else (seq + [None] * (size-len(seq))))
  114. def first(it):
  115. """Return first element in iterable."""
  116. return it.next()
  117. def post(url, files_params, extra_params, show_progressbar=True):
  118. """Post files to a given URL."""
  119. def progress(bar, maxval, download_t, download_d, upload_t, upload_d):
  120. bar.update(min(maxval, upload_d))
  121. c = pycurl.Curl()
  122. file_params2 = [(key, (pycurl.FORM_FILE, path)) for (key, path) in files_params.items()]
  123. items = extra_params.items() + file_params2
  124. c.setopt(c.URL, url + "?nexturl=http://code.google.com/p/youtube-upload")
  125. c.setopt(c.HTTPPOST, items)
  126. if show_progressbar and progressbar:
  127. widgets = [
  128. progressbar.Percentage(), ' ',
  129. progressbar.Bar(), ' ',
  130. progressbar.ETA(), ' ',
  131. progressbar.FileTransferSpeed(),
  132. ]
  133. total_filesize = sum(os.path.getsize(path) for path in files_params.values())
  134. bar = progressbar.ProgressBar(widgets=widgets, maxval=total_filesize)
  135. bar.start()
  136. c.setopt(c.NOPROGRESS, 0)
  137. c.setopt(c.PROGRESSFUNCTION, lambda *args: progress(bar, total_filesize, *args))
  138. elif show_progressbar:
  139. debug("Install python-progressbar to see a nice progress bar")
  140. bar = None
  141. else:
  142. bar = None
  143. body_container = StringIO.StringIO()
  144. headers_container = StringIO.StringIO()
  145. c.setopt(c.WRITEFUNCTION, body_container.write)
  146. c.setopt(c.HEADERFUNCTION, headers_container.write)
  147. c.setopt(c.SSL_VERIFYPEER, 0)
  148. c.setopt(c.SSL_VERIFYHOST, 0)
  149. c.perform()
  150. http_code = c.getinfo(pycurl.HTTP_CODE)
  151. c.close()
  152. if bar:
  153. bar.finish()
  154. headers = dict([s.strip() for s in line.split(":", 1)] for line in
  155. headers_container.getvalue().splitlines() if ":" in line)
  156. return http_code, headers, body_container.getvalue()
  157. class Youtube:
  158. """Interface the Youtube API."""
  159. CATEGORIES_SCHEME = "http://gdata.youtube.com/schemas/2007/categories.cat"
  160. def __init__(self, developer_key, source="tokland-youtube_upload",
  161. client_id="tokland-youtube_upload"):
  162. """Login and preload available categories."""
  163. service = gdata.youtube.service.YouTubeService()
  164. service.ssl = False # SSL is not yet supported by the API
  165. service.source = source
  166. service.developer_key = developer_key
  167. service.client_id = client_id
  168. self.service = service
  169. def login(self, email, password, captcha_token=None, captcha_response=None):
  170. """Login into youtube."""
  171. self.service.email = email
  172. self.service.password = password
  173. self.service.ProgrammaticLogin(captcha_token, captcha_response)
  174. def get_upload_form_data(self, path, *args, **kwargs):
  175. """Return dict with keys 'post_url' and 'token' with upload info."""
  176. entry = self._create_video_entry(*args, **kwargs)
  177. post_url, token = self.service.GetFormUploadToken(entry)
  178. return dict(entry=entry, post_url=post_url, token=token)
  179. def upload_video(self, path, *args, **kwargs):
  180. """Upload a video."""
  181. video_entry = self._create_video_entry(*args, **kwargs)
  182. return self.service.InsertVideoEntry(video_entry, path)
  183. def create_playlist(self, title, description, private=False):
  184. """Create a new playlist and return its uri."""
  185. playlist = self.service.AddPlaylist(title, description, private)
  186. # playlist.GetFeedLink() returns None, why?
  187. return first(el.get("href") for el in playlist._ToElementTree() if "feedLink" in el.tag)
  188. def add_video_to_playlist(self, video_id, playlist_uri, title=None, description=None):
  189. """Add video to playlist."""
  190. expected = r"http://gdata.youtube.com/feeds/api/playlists/"
  191. if not re.match("^" + expected, playlist_uri):
  192. raise ParseError("expecting playlist feed URL (%sID), but got '%s'" %
  193. (expected.decode("string-escape"), playlist_uri))
  194. playlist_video_entry = self.service.AddPlaylistVideoEntryToPlaylist(
  195. playlist_uri, video_id, title, description)
  196. return playlist_video_entry
  197. def update_metadata(self, url, title, description, keywords):
  198. """Change metadata of a video."""
  199. entry = self._get_feed_from_url(url)
  200. if title:
  201. entry.media.title = gdata.media.Title(text=title)
  202. if description:
  203. entry.media.description = \
  204. gdata.media.Description(description_type='plain', text=description)
  205. if keywords:
  206. entry.media.keywords = gdata.media.Keywords(text=keywords)
  207. return self.service.UpdateVideoEntry(entry)
  208. def delete_video_from_playlist(self, video_id, playlist_uri):
  209. """Delete video from playlist."""
  210. expected = r"http://gdata.youtube.com/feeds/api/playlists/"
  211. if not re.match("^" + expected, playlist_uri):
  212. raise ParseError("expecting playlist feed URL (%sID), but got '%s'" %
  213. (expected.decode("string-escape"), playlist_uri))
  214. entries = self.service.GetYouTubePlaylistVideoFeed(playlist_uri).entry
  215. for entry in entries:
  216. url, entry_id = get_entry_info(entry)
  217. if video_id == entry_id:
  218. playlist_video_entry_id = entry.id.text.split('/')[-1]
  219. self.service.DeletePlaylistVideoEntry(playlist_uri, playlist_video_entry_id)
  220. break
  221. else:
  222. raise VideoNotFound("Video %s not found in playlist %s" % (video_id, playlist_uri))
  223. def check_upload_status(self, video_id):
  224. """
  225. Check upload status of a video.
  226. Return None if video is processed, and a pair (status, message) otherwise.
  227. """
  228. return self.service.CheckUploadStatus(video_id=video_id)
  229. def _get_feed_from_url(self, url):
  230. template = 'http://gdata.youtube.com/feeds/api/users/default/uploads/%s'
  231. video_id = get_video_id_from_url(url)
  232. return self.service.GetYouTubeVideoEntry(template % video_id)
  233. def _create_video_entry(self, title, description, category, keywords=None,
  234. location=None, private=False, unlisted=False, recorded=None,
  235. nocomments=False, noratings=False):
  236. self.categories = self.get_categories()
  237. if category not in self.categories:
  238. valid = " ".join(self.categories.keys())
  239. raise InvalidCategory("Invalid category '%s' (valid: %s)" % (category, valid))
  240. media_group = gdata.media.Group(
  241. title=gdata.media.Title(text=title),
  242. description=gdata.media.Description(description_type='plain', text=description),
  243. keywords=gdata.media.Keywords(text=keywords),
  244. category=gdata.media.Category(
  245. text=category,
  246. label=self.categories[category],
  247. scheme=self.CATEGORIES_SCHEME),
  248. private=(gdata.media.Private() if private else None),
  249. player=None)
  250. if location:
  251. where = gdata.geo.Where()
  252. where.set_location(location)
  253. else:
  254. where = None
  255. extensions = []
  256. if unlisted:
  257. list_denied = {
  258. "namespace": YOUTUBE_NAMESPACE,
  259. "attributes": {'action': 'list', 'permission': 'denied'},
  260. }
  261. extensions.append(ExtensionElement('accessControl', **list_denied))
  262. if nocomments:
  263. comment_denied = {
  264. "namespace": YOUTUBE_NAMESPACE,
  265. "attributes": {'action': 'comment', 'permission': 'denied'},
  266. }
  267. extensions.append(ExtensionElement('accessControl', **comment_denied))
  268. if noratings:
  269. rate_denied = {
  270. "namespace": YOUTUBE_NAMESPACE,
  271. "attributes": {'action': 'rate', 'permission': 'denied'},
  272. }
  273. extensions.append(ExtensionElement('accessControl', **rate_denied))
  274. when = (gdata.youtube.Recorded(None, None, recorded) if recorded else None)
  275. return gdata.youtube.YouTubeVideoEntry(media=media_group, geo=where,
  276. recorded=when, extension_elements=extensions)
  277. @classmethod
  278. def get_categories(cls):
  279. """Return categories dictionary with pairs (term, label)."""
  280. def get_pair(element):
  281. """Return pair (term, label) for a (non-deprecated) XML element."""
  282. if all(not(str(x.tag).endswith("deprecated")) for x in element.getchildren()):
  283. return (element.get("term"), element.get("label"))
  284. xmldata = str(urllib.urlopen(cls.CATEGORIES_SCHEME).read())
  285. xml = ElementTree.XML(xmldata)
  286. return dict(compact(map(get_pair, xml)))
  287. def get_video_id_from_url(url):
  288. """Return video ID from a Youtube URL."""
  289. match = re.search("v=(.*)$", url)
  290. if not match:
  291. raise ParseError("expecting a video URL (http://www.youtube.com?v=ID), but got '%s'" % url)
  292. return match.group(1)
  293. def get_entry_info(entry):
  294. """Return pair (url, id) for video entry."""
  295. url = entry.GetHtmlLink().href.replace("&feature=youtube_gdata", "")
  296. video_id = get_video_id_from_url(url)
  297. return url, video_id
  298. def parse_location(string):
  299. """Return tuple (long, latitude) from string with coordinates."""
  300. if string and string.strip():
  301. return map(float, string.split(",", 1))
  302. def wait_processing(youtube_obj, video_id):
  303. """Wait until a video id recently uploaded has been procesed."""
  304. debug("waiting until video is processed")
  305. while 1:
  306. try:
  307. response = youtube_obj.check_upload_status(video_id)
  308. except socket.gaierror as msg:
  309. debug("non-fatal network error: %s" % msg)
  310. continue
  311. if not response:
  312. debug("video is processed")
  313. break
  314. status, message = response
  315. debug("check_upload_status: %s" % " - ".join(compact(response)))
  316. if status != "processing":
  317. break
  318. time.sleep(5)
  319. def upload_video(youtube, options, video_path, total_videos, index):
  320. """Upload video with index (for split videos)."""
  321. title = to_utf8(options.title)
  322. description = to_utf8(options.description or "").decode("string-escape")
  323. namespace = dict(title=title, n=index+1, total=total_videos)
  324. complete_title = (string.Template(options.title_template).substitute(**namespace)
  325. if total_videos > 1 else title)
  326. args = [video_path, complete_title, description,
  327. options.category, options.keywords]
  328. kwargs = {
  329. "private": options.private,
  330. "location": parse_location(options.location),
  331. "unlisted": options.unlisted,
  332. "recorded": options.recorded,
  333. "nocomments": options.nocomments,
  334. "noratings": options.noratings,
  335. }
  336. if options.get_upload_form_data:
  337. data = youtube.get_upload_form_data(*args, **kwargs)
  338. return "\n".join([video_path, data["token"], data["post_url"]])
  339. elif options.api_upload or not pycurl:
  340. if not options.api_upload:
  341. debug("Install pycurl to upload the video using HTTP")
  342. debug("Start upload using basic gdata API: %s" % video_path)
  343. entry = youtube.upload_video(*args, **kwargs)
  344. url, video_id = get_entry_info(entry)
  345. else: # upload with curl
  346. data = youtube.get_upload_form_data(*args, **kwargs)
  347. entry = data["entry"]
  348. debug("Start upload using a HTTP post: %s -> %s" % (video_path, data["post_url"]))
  349. http_code, headers, body = post(data["post_url"],
  350. {"file": video_path}, {"token": data["token"]},
  351. show_progressbar=not(options.hide_progressbar))
  352. if http_code != 302:
  353. raise UnsuccessfulHTTPResponseCode(
  354. "HTTP code on upload: %d (expected 302)" % http_code)
  355. params = dict(s.split("=", 1) for s in headers["Location"].split("?", 1)[1].split("&"))
  356. if params["status"] != "200":
  357. raise UnsuccessfulHTTPResponseCode(
  358. "HTTP status on upload link: %s (expected 200)" % params["status"])
  359. video_id = params["id"]
  360. url = "http://www.youtube.com/watch?v=%s" % video_id
  361. if options.wait_processing:
  362. wait_processing(youtube, video_id)
  363. return url
  364. def run_main(parser, options, args, output=sys.stdout):
  365. """Run the main scripts from the parsed options/args."""
  366. if options.get_categories:
  367. output.write(" ".join(Youtube.get_categories().keys()) + "\n")
  368. return
  369. elif (options.create_playlist or options.add_to_playlist or
  370. options.delete_from_playlist or options.update_metadata):
  371. required_options = ["email"]
  372. else:
  373. if not args:
  374. parser.print_usage()
  375. raise VideoArgumentMissing("Specify a video file to upload")
  376. required_options = ["email", "title", "category"]
  377. missing = [opt for opt in required_options if not getattr(options, opt)]
  378. if missing:
  379. parser.print_usage()
  380. raise OptionsMissing("Some required option are missing: %s" % ", ".join(missing))
  381. if options.password is None:
  382. password = getpass.getpass("Password for account <%s>: " % options.email)
  383. elif options.password == "-":
  384. password = sys.stdin.readline().strip()
  385. else:
  386. password = options.password
  387. youtube = Youtube(DEVELOPER_KEY)
  388. debug("Login to Youtube API: email='%s', password='%s'" %
  389. (options.email, "*" * len(password)))
  390. try:
  391. youtube.login(options.email, password, captcha_token=options.captcha_token,
  392. captcha_response=options.captcha_response)
  393. except gdata.service.BadAuthentication:
  394. raise BadAuthentication("Authentication failed")
  395. except gdata.service.CaptchaRequired:
  396. token = youtube.service.captcha_token
  397. message = [
  398. "Captcha request: %s" % youtube.service.captcha_url,
  399. "Re-run the command with: --captcha-token=%s --captcha-response=CAPTCHA" % token,
  400. ]
  401. raise CaptchaRequired("\n".join(message))
  402. if options.create_playlist:
  403. title, description, private = tosize(options.create_playlist.split("|", 2), 3)
  404. playlist_uri = youtube.create_playlist(title, description, (private == "1"))
  405. debug("Playlist created: %s" % playlist_uri)
  406. output.write(playlist_uri+"\n")
  407. elif options.update_metadata:
  408. if not args:
  409. parser.print_usage()
  410. raise VideoArgumentMissing("Specify a video URL to upload")
  411. url = args[0]
  412. updated = youtube.update_metadata(url, options.title, options.description, options.keywords)
  413. debug("Video metadata updated: %s" % url)
  414. elif options.add_to_playlist:
  415. for url in args:
  416. debug("Adding video (%s) to playlist: %s" % (url, options.add_to_playlist))
  417. video_id = get_video_id_from_url(url)
  418. youtube.add_video_to_playlist(video_id, options.add_to_playlist)
  419. elif options.delete_from_playlist:
  420. playlist = options.delete_from_playlist
  421. for url in args:
  422. video_id = get_video_id_from_url(url)
  423. debug("delete video (%s) from playlist: %s; video-id: %s" %
  424. (url, playlist, video_id))
  425. youtube.delete_video_from_playlist(video_id, playlist)
  426. else:
  427. for index, video_path in enumerate(args):
  428. url = upload_video(youtube, options, video_path, len(args), index)
  429. output.write(url + "\n")
  430. def main(arguments):
  431. """Upload video to Youtube."""
  432. usage = """Usage: %prog [OPTIONS] VIDEO_PATH ...
  433. Upload videos to youtube."""
  434. parser = optparse.OptionParser(usage, version=VERSION)
  435. # Required options
  436. parser.add_option('-m', '--email', dest='email', type="string",
  437. help='Authentication email or Youtube username')
  438. parser.add_option('-p', '--password', dest='password', type="string",
  439. help='Authentication password')
  440. parser.add_option('-t', '--title', dest='title', type="string",
  441. help='Video(s) title')
  442. parser.add_option('-c', '--category', dest='category', type="string",
  443. help='Video(s) category')
  444. # Side commands
  445. parser.add_option('', '--get-categories', dest='get_categories',
  446. action="store_true", default=False, help='Show video categories')
  447. parser.add_option('', '--create-playlist', dest='create_playlist', type="string",
  448. default=None, metavar="TITLE|DESCRIPTION|PRIVATE (0=no, 1=yes)",
  449. help='Create new playlist and add uploaded video(s) to it')
  450. # Optional options
  451. parser.add_option('-d', '--description', dest='description', type="string",
  452. help='Video(s) description')
  453. parser.add_option('', '--keywords', dest='keywords', type="string",
  454. help='Video(s) keywords (separated by commas: tag1,tag2,...)')
  455. parser.add_option('', '--title-template', dest='title_template', type="string",
  456. default="$title [$n/$total]", metavar="STRING",
  457. help='Title template to use on multiple videos (default: $title [$n/$total])')
  458. parser.add_option('', '--private', dest='private',
  459. action="store_true", default=False, help='Set uploaded video(s) as private')
  460. parser.add_option('', '--unlisted', dest='unlisted',
  461. action="store_true", default=False, help='Set uploaded video(s) as unlisted')
  462. parser.add_option('', '--nocomments', dest='nocomments',
  463. action="store_true", default=False, help='Disable comments for video(s)')
  464. parser.add_option('', '--noratings', dest='noratings',
  465. action="store_true", default=False, help='Disable ratings for video(s)')
  466. parser.add_option('', '--location', dest='location', type="string", default=None,
  467. metavar="LAT,LON", help='Video(s) location (lat, lon). example: "43.3,5.42"')
  468. parser.add_option('', '--recorded', dest='recorded', type="string", default=None,
  469. metavar="STRING", help='Video(s) recording time (YYYY-MM-DD). example: "2013-12-29"')
  470. parser.add_option('', '--update-metadata', dest='update_metadata',
  471. action="store_true", default=False, help='Update video metadata (title/description)')
  472. # Upload options
  473. parser.add_option('', '--api-upload', dest='api_upload',
  474. action="store_true", default=False, help="Use the API upload instead of pycurl")
  475. parser.add_option('', '--get-upload-form-info', dest='get_upload_form_data',
  476. action="store_true", default=False, help="Don't upload, get the form info (PATH, TOKEN, URL)")
  477. parser.add_option('', '--hide-progressbar', dest='hide_progressbar',
  478. action="store_true", default=False, help="Hide progressbar on uploads")
  479. # Playlist options
  480. parser.add_option('', '--add-to-playlist', dest='add_to_playlist', type="string", default=None,
  481. metavar="URI", help='Add video(s) to an existing playlist')
  482. parser.add_option('', '--delete-from-playlist', dest='delete_from_playlist', type="string", default=None,
  483. metavar="URI", help='Delete video(s) from an existing playlist')
  484. parser.add_option('', '--wait-processing', dest='wait_processing', action="store_true",
  485. default=False, help='Wait until the video(s) has been processed')
  486. # Captcha options
  487. parser.add_option('', '--captcha-token', dest='captcha_token', type="string",
  488. metavar="STRING", help='Captcha token')
  489. parser.add_option('', '--captcha-response', dest='captcha_response', type="string",
  490. metavar="STRING", help='Captcha response')
  491. options, args = parser.parse_args(arguments)
  492. run_main(parser, options, args)
  493. if __name__ == '__main__':
  494. sys.exit(catch_exceptions(EXIT_CODES, main, sys.argv[1:]))