Uploader des images lourdes / vidéos sur Twitter

Envoyer une image sur Twitter, difficile ?
Non, pas vraiment : Vous balancez directement l’image encodée en base64 dans un form, Twitter vous donne un media_id, vous l’associez au tweet, fin de l’histoire… ?

Et bien pas vraiment :
Et si vous souhaitez envoyer une image lourde ? Un GIF ? Une vidéo ?
Twitter vous interdit de faire comme ça (à raison).

Voyons ensemble une manière simple de le faire en PHP.


J’utiliserai ici la classe OAuth de base de PHP (extension PECL)
Vous aurez besoin d’un gestionnaire OAuth capable d’envoyer des headers personnalisés, si vous utilisez autre chose.

Le concept est que le fichier envoyé l’est par chunk (partie) : On envoie par parties de quelques centaines de Ko maximum. Si une partie échoue, on peut la renvoyer à loisir sans reprendre l’intégralité de l’upload.

L’endpoint utilisé par Twitter pour uploader quoique ce soit est https://upload.twitter.com/1.1/media/upload.json.

On va faire en sorte que notre fonction fasse partie d’un helper Twitter (voir les tutos Twitter de ce blog) prenant en paramètre le chemin du fichier à uploader.

Tout d’abord, on décide de la longueur du chunk que l’on fera (ici: 512ko qui seront encodés en base64, donc environ 700ko par chunk). Ensuite, on stocke la taille totale du fichier quelque part, Twitter va en avoir besoin, on récupère également son type MIME. Si le fichier est une vidéo, pour activer le support des vidéos longues, on définit le paramètre media_category à tweet_video.

Par sécurité, on définit le time limit du script PHP à une valeur élevée, si notre upload est long (ici: 10 secondes par Mo du fichier à envoyer).

On envoie tout ça à Twitter en HTTP POST, avec le header Content-Type approprié (application/x-www-form-urlencoded).

public function sendChunkedMedia(string $path) : ?string {
    if(file_exists($path)) {
        $len_chunk = 1024 * 512; // 512 ko
        // La taille demandée est la taille normale, pas la taille encodée en base64
        $total_size = filesize($path);

        // Initialisation de l'envoi
        $array_args = ['command' => 'INIT', 'total_bytes' => $total_size, 'media_type' => mime_content_type($path)];

        // Initialise le media category si c'est une vidéo pour permettre l'envoi asynchrone
        if(explode('/', $array_args['media_type'])[0] === 'video') {
            $array_args['media_category'] = 'tweet_video';
        }
        
        // Définition d'une haute time limit
        $max_time = ($total_size / 1024) * 10; // 10 secondes par Mo
        set_time_limit($max_time);

        try {
            $this->oauth->fetch($this->url_media_unique, $array_args, OAUTH_HTTP_METHOD_POST, 
                                ['Content-Type' => 'application/x-www-form-urlencoded']);
        } catch(OAuthException $ex) {
            return null;
        }

Twitter nous renvoie, suite à cette requête un media_id sur lequel on va pouvoir installer notre média.
On le récupère donc, et on se prépare à envoyer notre fichier.
On l’ouvre en mode binaire, et on va lire avec fread(ptr, longueur) partie par partie, histoire de pas saturer la RAM inutilement en ouvrant directement le fichier en entier.

// Récupération de la réponse de l'INIT pour obtenir le media_id
$json = json_decode($this->oauth->getLastResponse());
$id_media = $json->media_id_string;
$i = 0;

// Ouverture du fichier à l'emplacement $path
$handle = fopen($path, 'rb');

// Tant que le fichier n'est pas fini
while (!feof($handle)) {
    // Aussitôt lu, le buffer est encodé en base64
    $buffer = base64_encode(fread($handle, $len_chunk));

    if(!empty($buffer)) { // Ignore les parties vides (erreurs de lecture)
        // Envoie la partie $i à Twitter (associée avec son media_id)
        $array_args = ['command' => 'APPEND', 'media_id' => $id_media, 'segment_index' => $i, 'media_data' => $buffer];
        try {
            $this->oauth->fetch($this->url_media_unique, $array_args, OAUTH_HTTP_METHOD_POST, 
                                ['Content-Type' => 'application/x-www-form-urlencoded']);
        } catch (OAuthException $ex) {
            return null;
        }
        $i++;
    }
}

Une fois cette partie finie, l’upload est terminé !
Il ne reste plus qu’à dire à Twitter que c’est fait :

// Termine l'envoi
$array_args = ['command' => 'FINALIZE', 'media_id' => $id_media];
try {
    $this->oauth->fetch($this->url_media_unique, $array_args, OAUTH_HTTP_METHOD_POST, 
                        ['Content-Type' => 'application/x-www-form-urlencoded']);
} catch (OAuthException $ex) {
    return null;
}

// Récupère les infos (elles disent si le fichier est traité ou non)
$json = json_decode($this->oauth->getLastResponse());

// Remise à zéro de la time limit par une limite normale
set_time_limit(30);

Cependant… Oui c’est pas fini. Et si la vidéo est cours de traitement ?
Twitter nous le dit ! L’info est contenue dans le JSON de réponse, section processing_info.

On va faire des appels à Twitter pour qu’il nous dise où il en est, et le cas échéant si il réussit à traiter l’image, renvoyer l’ID média pour l’intégrer dans un tweet !

        if(isset($json->processing_info)) { // Le media est toujours en cours de traitement

            $complete = false;
            // On va lui demander où ça en est avec STATUS
            $array_args = ['command' => 'STATUS', 'media_id' => $id_media];
            do {
                if(!isset($json->processing_info->check_after_secs)) {
                    return null;
                }
                sleep($json->processing_info->check_after_secs);

                // On demande où ça en est en méthode GET (pas POST !)
                try {
                    $this->oauth->fetch($this->url_media_unique, $array_args);
                } catch (OAuthException $ex) {
                    return null;
                }

                // On regarde l'état de la réponse
                $json = json_decode($this->oauth->getLastResponse());
                if(!isset($json->processing_info->state)) {
                    return null;
                }

                // Fichier traité correctement
                if($json->processing_info->state === 'succeeded') {
                    $complete = true;
                }
                // Fichier invalide, il faut réessayer l'envoi / changer de fichier
                else if($json->processing_info->state === 'failed') {
                    return null;
                }

                // Sinon, c'est que l'envoi n'est pas fini; On recommence alors
            } while (!$complete);
        }

        return $id_media;
    }

    return null;   
}

Et voilà ! C’est terminé. On a réussi (normalement) à envoyer notre photo/vidéo avec en mode chunké. Il ne vous reste plus qu’à envoyer le tweet avec ce média !