I just finished adding support for Photo posts to my website with the ability to syndicate to both Twitter and Mastodon. I ran into several sticking points with the Mastodon integration.
Put the file in the files
parameter of requests.post
and the other data in data
I found the documentation a little confusing here, especially in how to follow it using python's requests
module.
The documentation specifies that a media upload should have a file
“form data parameter”, which is the file to be attached “encoded using multipart form data.”
In requests.post
, there's a file
parameter for multipart form data and a data
parameter for form data. In my testing with Postman, using form-data for the body and a “file” as the type of value for the file
value worked fine. But putting it all in data
for the requests.post
did not work.
What ended up working was putting the file in the files
parameter and the rest of the media fields in the data
parameter.
The ImageField
is the file
It took a lot of trial and error, complicated by the above issue with requests
, but I finally discovered that I can pass the ImageField
itself as the file. requests
wants it as a tuple though: file = (image_field.name.split('/')[-1], image_field)
.
The media_ids[]
parameter is literally media_ids[]
The Mastodon API uses a two-stepped approach to publish a status with media: first you upload the media, then you publish the status with the ids of the uploaded media.
The status request field is “media_ids[]”, which is an “Array of String”. The key in the data
parameter needs to be exactly media_ids[]
, with the two square brackets, and the ids need to be a python list. My unsuccessful attempts used media_ids
, without the brackets, and all kind of permutations of list-like syntaxes, but what is needed is "media_ids[]": [media_id]
.
Code
My code is spread across various models.py
, admin.py
, and helper modules, but here are the key parts consolidated:
From SyndicatableAdmin
, which PhotoAdmin
inherits:
def _syndicate_to_mastodon(self, request, obj):
# ...
media = obj.get_mastodon_media_upload()
status = obj.get_mastodon_status_update()
response = Syndication.syndicate_to_mastodon(status, media)
# ...
class MastodonMediaUpload(object):
def __init__(self, file, thumbnail=None, description=None, focus=None):
self.file = file
self.thumbnail = thumbnail
self.description = description
self.focus = focus
From MastodonSyndicatable
which the Photo
model inherits:
class MastodonSyndicatable(models.Model):
# ...
def get_mastodon_media_upload(self):
if not self.has_mastodon_media():
return None
media = self.get_mastodon_media_image_field()
file = (media.name.split('/')[-1], media)
media_upload = MastodonMediaUpload(file, description=self.get_mastodon_media_description())
return media_upload
# ...
From Syndication
, a service class for syndication methods:
class Syndication():
# ...
@staticmethod
def syndicate_to_mastodon(status=None, media=None):
if media is not None:
response = MastodonClient.post_media(media.file, media.thumbnail, media.description, media.focus)
if status is not None:
status.media_ids = [response['id']]
return MastodonClient.post_status(status.status, status.idempotency_key, status.in_reply_to_id, status.media_ids)
# ...
From the Mastodon Client
service class:
class MastodonClient(object):
# ...
@staticmethod
def post_media(file, thumbnail=None, description=None, focus=None):
files = {
'file': file
}
data = {}
if thumbnail is not None:
data['thumbnail'] = thumbnail
if description is not None:
data['description'] = description
if focus is not None:
data['focus'] = focus
headers = {
'Authorization': Client.get_auth_header(),
}
response = requests.post(Client.get_v2_url() + '/media', files=files, data=data, headers=headers)
response.raise_for_status()
return response.json()
@staticmethod
def post_status(status, idempotency_key, in_reply_to_id=None, media_ids=None, visibility='direct'):
data = {
'status': status,
'visibility': visibility
}
if in_reply_to_id is not None:
data['in_reply_to_id'] = in_reply_to_id
if media_ids is not None:
data['media_ids[]'] = media_ids
headers = {
'Authorization': Client.get_auth_header()
#'Idempotency-Key': idempotency_key # need to figure out how to use a value for this that updates after model saves.
}
response = requests.post(Client.get_v1_url() + '/statuses', data=data, headers=headers)
response.raise_for_status()
return response.json()
#...