Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds video upload #1414

Merged
merged 14 commits into from
Dec 28, 2020
210 changes: 202 additions & 8 deletions tweepy/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@
import imghdr
import mimetypes
import os

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessary. Separating standard library imports from third party imports is consistent with PEP 8.

import six

if six.PY2:
from urllib import urlencode
elif six.PY3:
from urllib.parse import urlencode

from tweepy.binder import bind_api, pagination
from tweepy.error import TweepError
from tweepy.parsers import ModelParser, Parser
from tweepy.utils import list_to_csv

IMAGE_TYPES = ['gif', 'jpeg', 'png', 'webp']
CHUNKED_TYPES = IMAGE_TYPES + ['video/mp4']

MAX_UPLOAD_SIZE_STANDARD = 4883 # standard uploads must be less then 5 MB
MAX_UPLOAD_SIZE_CHUNKED = 14649 # chunked uploads must be less than 15 MB


class API(object):
"""Twitter API"""
Expand Down Expand Up @@ -220,18 +230,28 @@ def media_upload(self, filename, *args, **kwargs):
:allowed_param:
"""
f = kwargs.pop('file', None)

file_type = imghdr.what(filename) or mimetypes.guess_type(filename)[0]
if file_type == 'gif':
max_size = 14649
size = os.path.getsize(filename)

if file_type == 'gif' or file_type in CHUNKED_TYPES:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part of this or is redundant; 'gif' is in CHUNKED_TYPES.

I'm not sure what this if-else statement is for. The only cases where the conditional is false is when the file type is not supported or when imghdr.what is unable to determine the file type from the header of a valid image file type (e.g. #1411) and the fallback mimetypes.guess_type determines the file type instead.

max_size = MAX_UPLOAD_SIZE_CHUNKED
else:
max_size = 4883
max_size = MAX_UPLOAD_SIZE_STANDARD

if file_type in IMAGE_TYPES and (size * 1024) < max_size:
fitnr marked this conversation as resolved.
Show resolved Hide resolved
return self.image_upload(filename, max_size, f=f, *args, **kwargs)

elif file_type in CHUNKED_TYPES:
return self.upload_chunked(filename, f=f, *args, **kwargs)
fitnr marked this conversation as resolved.
Show resolved Hide resolved

def image_upload(self, filename, max_size, *args, **kwargs):
""" :reference: https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload
:allowed_param:
"""
headers, post_data = API._pack_image(filename, max_size,
form_field='media', f=f,
file_type=file_type)
kwargs.update({'headers': headers, 'post_data': post_data})

return bind_api(
api=self,
path='/media/upload.json',
Expand All @@ -242,6 +262,70 @@ def media_upload(self, filename, *args, **kwargs):
upload_api=True
)(*args, **kwargs)

def upload_chunked(self, filename, *args, **kwargs):
""" :reference https://developer.twitter.com/en/docs/media/upload-media/uploading-media/chunked-media-upload
:allowed_param:
"""
f = kwargs.pop('file', None)

# Media category is dependant on whether media is attached to a tweet
# or to a direct message. Assume tweet by default.
is_direct_message = kwargs.pop('is_direct_message', False)

# Initialize upload (Twitter cannot handle videos > 15 MB)
headers, post_data, fp = API._chunk_media('init', filename, self.max_size_chunked, form_field='media', f=f, is_direct_message=is_direct_message)
fitnr marked this conversation as resolved.
Show resolved Hide resolved
kwargs.update({'headers': headers, 'post_data': post_data})

# Send the INIT request
media_info = bind_api(
api=self,
path='/media/upload.json',
method='POST',
payload_type='media',
allowed_param=[],
require_auth=True,
upload_api=True
)(*args, **kwargs)

# If a media ID has been generated, we can send the file
if media_info.media_id:
# default chunk size is 1MB, can be overridden with keyword argument.
# minimum chunk size is 16K, which keeps the maximum number of chunks under 999
chunk_size = kwargs.pop('chunk_size', 1024 * 1024)
chunk_size = max(chunk_size, 16 * 2014)

fsize = os.path.getsize(filename)
nloops = int(fsize / chunk_size) + (1 if fsize % chunk_size > 0 else 0)

for i in range(nloops):
headers, post_data, fp = API._chunk_media('append', filename, self.max_size_chunked, chunk_size=chunk_size, f=fp, media_id=media_info.media_id, segment_index=i, is_direct_message=is_direct_message)
fitnr marked this conversation as resolved.
Show resolved Hide resolved
fitnr marked this conversation as resolved.
Show resolved Hide resolved
kwargs.update({ 'headers': headers, 'post_data': post_data, 'parser': RawParser() })
# The APPEND command returns an empty response body
bind_api(
api=self,
path='/media/upload.json',
method='POST',
payload_type='media',
allowed_param=[],
require_auth=True,
upload_api=True
)(*args, **kwargs)

# When all chunks have been sent, we can finalize.
headers, post_data, fp = API._chunk_media('finalize', filename, self.max_size_chunked, media_id=media_info.media_id, is_direct_message=is_direct_message)
fitnr marked this conversation as resolved.
Show resolved Hide resolved
kwargs = {'headers': headers, 'post_data': post_data}

# The FINALIZE command returns media information
return bind_api(
api=self,
path='/media/upload.json',
method='POST',
payload_type='media',
allowed_param=[],
require_auth=True,
upload_api=True
)(*args, **kwargs)

def create_media_metadata(self, media_id, alt_text, *args, **kwargs):
""" :reference: https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-metadata-create
:allowed_param:
Expand Down Expand Up @@ -842,7 +926,6 @@ def mutes(self):
required_auth=True
)


@property
def create_mute(self):
""" :reference: https://developer.twitter.com/en/docs/accounts-and-users/mute-block-report-users/api-reference/post-mutes-users-create
Expand Down Expand Up @@ -1397,7 +1480,7 @@ def configuration(self):
@staticmethod
def _pack_image(filename, max_size, form_field='image', f=None, file_type=None):
"""Pack image from file into multipart-formdata post body"""
# image must be less than 700kb in size
# image must be less than 5MB in size
if f is None:
try:
if os.path.getsize(filename) > (max_size * 1024):
Expand Down Expand Up @@ -1450,3 +1533,114 @@ def _pack_image(filename, max_size, form_field='image', f=None, file_type=None):
}

return headers, body


@staticmethod
def _chunk_media(command, filename, max_size, form_field="media", chunk_size=4096, f=None, media_id=None, segment_index=0, is_direct_message=False):
fitnr marked this conversation as resolved.
Show resolved Hide resolved
fp = None
if command == 'init':
if f is None:
file_size = os.path.getsize(filename)
try:
if file_size > (max_size * 1024):
raise TweepError('File is too big, must be less than %skb.' % max_size)
except os.error as e:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
except os.error as e:
except OSError as e:

raise TweepError('Unable to access file: %s' % e.strerror)
fitnr marked this conversation as resolved.
Show resolved Hide resolved

# build the mulitpart-formdata body
fp = open(filename, 'rb')
else:
f.seek(0, 2) # Seek to end of file
file_size = f.tell()
if file_size > (max_size * 1024):
raise TweepError('File is too big, must be less than %skb.' % max_size)
f.seek(0) # Reset to beginning of file
fp = f
elif command != 'finalize':
fitnr marked this conversation as resolved.
Show resolved Hide resolved
if f is not None:
fp = f
else:
raise TweepError('File input for APPEND is mandatory.')

# video must be mp4
file_type, _ = mimetypes.guess_type(filename)

if file_type is None:
raise TweepError('Could not determine file type')

if file_type not in CHUNKED_TYPES:
raise TweepError('Invalid file type for video: %s' % file_type)
fitnr marked this conversation as resolved.
Show resolved Hide resolved

BOUNDARY = b'Tw3ePy'
body = list()
if command == 'init':
query = {
'command': 'INIT',
'media_type': file_type,
'total_bytes': file_size,
'media_category': API._get_media_category(is_direct_message, file_type)
}
body.append(urlencode(query).encode('utf-8'))
headers = {
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
}
elif command == 'append':
if media_id is None:
raise TweepError('Media ID is required for APPEND command.')
body.append(b'--' + BOUNDARY)
body.append('Content-Disposition: form-data; name="command"'.encode('utf-8'))
body.append(b'')
body.append(b'APPEND')
body.append(b'--' + BOUNDARY)
body.append('Content-Disposition: form-data; name="media_id"'.encode('utf-8'))
body.append(b'')
body.append(str(media_id).encode('utf-8'))
body.append(b'--' + BOUNDARY)
body.append('Content-Disposition: form-data; name="segment_index"'.encode('utf-8'))
body.append(b'')
body.append(str(segment_index).encode('utf-8'))
body.append(b'--' + BOUNDARY)
body.append('Content-Disposition: form-data; name="{0}"; filename="{1}"'.format(form_field, os.path.basename(filename)).encode('utf-8'))
body.append('Content-Type: {0}'.format(file_type).encode('utf-8'))
body.append(b'')
body.append(fp.read(chunk_size))
body.append(b'--' + BOUNDARY + b'--')
headers = {
'Content-Type': 'multipart/form-data; boundary=Tw3ePy'
}
elif command == 'finalize':
if media_id is None:
raise TweepError('Media ID is required for FINALIZE command.')
body.append(
urlencode({
'command': 'FINALIZE',
'media_id': media_id
}).encode('utf-8')
)
headers = {
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
}
fitnr marked this conversation as resolved.
Show resolved Hide resolved

body = b'\r\n'.join(body)
# build headers
headers['Content-Length'] = str(len(body))

return headers, body, fp

@staticmethod
def _get_media_category(is_direct_message, file_type):
""" :reference: https://developer.twitter.com/en/docs/direct-messages/message-attachments/guides/attaching-media
:allowed_param:
"""
if is_direct_message:
prefix = 'dm'
else:
prefix = 'tweet'

if file_type in IMAGE_MIMETYPES:
if file_type.endswith('gif'):
return prefix + '_gif'
else:
return prefix + '_image'
elif file_type.endswith('mp4'):
return prefix + '_video'
fitnr marked this conversation as resolved.
Show resolved Hide resolved