Posting Images to Mastodon with Django ImageField

I just finished adding support for Photo posts to my website with the ability to syndicate to both Twitter and Mastodon. These are some of the sticking points I ran into with Mastodon and Django.

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)

    # ...

MastodonMediaUpload:

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()

    #...