diff --git a/tests/assets/small.webm b/tests/assets/small.webm
new file mode 100644
index 0000000..da946da
Binary files /dev/null and b/tests/assets/small.webm differ
diff --git a/tests/test_console.py b/tests/test_console.py
index 9c46b86..3b58d18 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -292,7 +292,6 @@ def test_reblogged_by(mock_get, monkeypatch, capsys):
def test_upload(mock_post, capsys):
mock_post.return_value = MockResponse({
'id': 123,
- 'url': 'https://bigfish.software/123/456',
'preview_url': 'https://bigfish.software/789/012',
'url': 'https://bigfish.software/345/678',
'type': 'image',
@@ -300,10 +299,10 @@ def test_upload(mock_post, capsys):
console.run_command(app, user, 'upload', [__file__])
- mock_post.call_count == 1
+ assert mock_post.call_count == 1
args, kwargs = http.post.call_args
- assert args == (app, user, '/api/v1/media')
+ assert args == (app, user, '/api/v2/media')
assert isinstance(kwargs['files']['file'], io.BufferedReader)
out, err = capsys.readouterr()
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 1471960..ba254cc 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -207,6 +207,39 @@ def test_post_language(app, user, run):
assert status["language"] == "zh"
+def test_media_thumbnail(app, user, run):
+ assets_dir = path.realpath(path.join(path.dirname(__file__), "assets"))
+
+ video_path = path.join(assets_dir, "small.webm")
+ thumbnail_path = path.join(assets_dir, "test1.png")
+
+ out = run(
+ "post",
+ "--media", video_path,
+ "--thumbnail", thumbnail_path,
+ "--description", "foo",
+ "some text"
+ )
+
+ status_id = _posted_status_id(out)
+ status = api.fetch_status(app, user, status_id)
+ [media] = status["media_attachments"]
+
+ assert media["description"] == "foo"
+ assert media["type"] == "video"
+ assert media["url"].endswith(".mp4")
+ assert media["preview_url"].endswith(".png")
+
+ # Video properties
+ assert media["meta"]["original"]["duration"] == 5.58
+ assert media["meta"]["original"]["height"] == 320
+ assert media["meta"]["original"]["width"] == 560
+
+ # Thumbnail properties
+ assert media["meta"]["small"]["height"] == 50
+ assert media["meta"]["small"]["width"] == 50
+
+
def test_media_attachments(app, user, run):
assets_dir = path.realpath(path.join(path.dirname(__file__), "assets"))
diff --git a/toot/api.py b/toot/api.py
index 2c8edb3..910b3b5 100644
--- a/toot/api.py
+++ b/toot/api.py
@@ -1,12 +1,14 @@
+import mimetypes
+from os import path
import re
import uuid
-from typing import List
+from typing import BinaryIO, List, Optional
from urllib.parse import urlparse, urlencode, quote
-from toot import http, CLIENT_NAME, CLIENT_WEBSITE
-from toot.exceptions import AuthenticationError
-from toot.utils import str_bool, str_bool_nullable
+from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
+from toot.exceptions import AuthenticationError, ConsoleError
+from toot.utils import drop_empty_values, str_bool, str_bool_nullable
SCOPES = 'read write follow'
@@ -85,10 +87,9 @@ def update_account(
Update account credentials
https://docs.joinmastodon.org/methods/accounts/#update_credentials
"""
- files = {"avatar": avatar, "header": header}
- files = {k: v for k, v in files.items() if v is not None}
+ files = drop_empty_values({"avatar": avatar, "header": header})
- data = {
+ data = drop_empty_values({
"bot": str_bool_nullable(bot),
"discoverable": str_bool_nullable(discoverable),
"display_name": display_name,
@@ -97,8 +98,7 @@ def update_account(
"source[language]": language,
"source[privacy]": privacy,
"source[sensitive]": str_bool_nullable(sensitive),
- }
- data = {k: v for k, v in data.items() if v is not None}
+ })
return http.patch(app, user, "/api/v1/accounts/update_credentials", files=files, data=data)
@@ -182,7 +182,9 @@ def post_status(
# if the request is retried.
headers = {"Idempotency-Key": uuid.uuid4().hex}
- json = {
+ # Strip keys for which value is None
+ # Sending null values doesn't bother Mastodon, but it breaks Pleroma
+ json = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
@@ -192,11 +194,7 @@ def post_status(
'scheduled_at': scheduled_at,
'content_type': content_type,
'spoiler_text': spoiler_text
- }
-
- # Strip keys for which value is None
- # Sending null values doesn't bother Mastodon, but it breaks Pleroma
- json = {k: v for k, v in json.items() if v is not None}
+ })
return http.post(app, user, '/api/v1/statuses', json=json, headers=headers).json()
@@ -351,11 +349,44 @@ def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
return _anon_timeline_generator(instance, path, params)
-def upload_media(app, user, file, description=None):
- return http.post(app, user, '/api/v1/media',
- data={'description': description},
- files={'file': file}
- ).json()
+def get_media(app: App, user: User, id: str):
+ return http.get(app, user, f"/api/v1/media/{id}").json()
+
+
+def upload_media(
+ app: App,
+ user: User,
+ media: BinaryIO,
+ description: Optional[str] = None,
+ thumbnail: Optional[BinaryIO] = None,
+):
+ data = drop_empty_values({"description": description})
+
+ # NB: Documentation says that "file" should provide a mime-type which we
+ # don't do currently, but it works.
+ files = drop_empty_values({
+ "file": media,
+ "thumbnail": _add_mime_type(thumbnail)
+ })
+
+ return http.post(app, user, "/api/v2/media", data=data, files=files).json()
+
+
+def _add_mime_type(file):
+ if file is None:
+ return None
+
+ # TODO: mimetypes uses the file extension to guess the mime type which is
+ # not always good enough (e.g. files without extension). python-magic could
+ # be used instead but it requires adding it as a dependency.
+ mime_type = mimetypes.guess_type(file.name)
+
+ if not mime_type:
+ raise ConsoleError(f"Unable guess mime type of '{file.name}'. "
+ "Ensure the file has the desired extension.")
+
+ filename = path.basename(file.name)
+ return (filename, file, mime_type)
def search(app, user, query, resolve=False, type=None):
diff --git a/toot/commands.py b/toot/commands.py
index 9941e23..e21f5b3 100644
--- a/toot/commands.py
+++ b/toot/commands.py
@@ -2,6 +2,7 @@ import sys
import platform
from datetime import datetime, timedelta, timezone
+from time import sleep, time
from toot import api, config, __version__
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.exceptions import ApiError, ConsoleError
@@ -142,19 +143,54 @@ def _get_scheduled_at(scheduled_at, scheduled_in):
def _upload_media(app, user, args):
- # Match media to corresponding description and upload
+ # Match media to corresponding description and thumbnail
media = args.media or []
descriptions = args.description or []
+ thumbnails = args.thumbnail or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
- result = _do_upload(app, user, file, description)
+ thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
+ result = _do_upload(app, user, file, description, thumbnail)
uploaded_media.append(result)
+ _wait_until_all_processed(app, user, uploaded_media)
+
return [m["id"] for m in uploaded_media]
+def _wait_until_all_processed(app, user, uploaded_media):
+ """
+ Media is uploaded asynchronously, and cannot be attached until the server
+ has finished processing it. This function waits for that to happen.
+
+ Once media is processed, it will have the URL populated.
+ """
+ if all(m["url"] for m in uploaded_media):
+ return
+
+ # Timeout after waiting 1 minute
+ start_time = time()
+ timeout = 60
+
+ print_out("Waiting for media to finish processing...")
+ for media in uploaded_media:
+ _wait_until_processed(app, user, media, start_time, timeout)
+
+
+def _wait_until_processed(app, user, media, start_time, timeout):
+ if media["url"]:
+ return
+
+ media = api.get_media(app, user, media["id"])
+ while not media["url"]:
+ sleep(1)
+ if time() > start_time + timeout:
+ raise ConsoleError(f"Media not processed by server after {timeout} seconds. Aborting.")
+ media = api.get_media(app, user, media["id"])
+
+
def delete(app, user, args):
api.delete_status(app, user, args.status_id)
print_out("✓ Status deleted")
@@ -297,7 +333,7 @@ def activate(app, user, args):
def upload(app, user, args):
- response = _do_upload(app, user, args.file, args.description)
+ response = _do_upload(app, user, args.file, args.description, None)
msg = "Successfully uploaded media ID {}, type '{}'"
@@ -312,9 +348,9 @@ def search(app, user, args):
print_search_results(response)
-def _do_upload(app, user, file, description):
+def _do_upload(app, user, file, description, thumbnail):
print_out("Uploading media: {}".format(file.name))
- return api.upload_media(app, user, file, description=description)
+ return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
def _find_account(app, user, account_name):
diff --git a/toot/console.py b/toot/console.py
index f1f6994..ae98c6c 100644
--- a/toot/console.py
+++ b/toot/console.py
@@ -482,6 +482,12 @@ POST_COMMANDS = [
"help": "plain-text description of the media for accessibility "
"purposes, one per attached media"
}),
+ (["--thumbnail"], {
+ "action": "append",
+ "type": FileType("rb"),
+ "help": "path to an image file to serve as media thumbnail, "
+ "one per attached media"
+ }),
visibility_arg,
(["-s", "--sensitive"], {
"action": 'store_true',
diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py
index 73ab6af..8a39fd2 100644
--- a/toot/utils/__init__.py
+++ b/toot/utils/__init__.py
@@ -7,6 +7,7 @@ import unicodedata
import warnings
from bs4 import BeautifulSoup
+from typing import Dict
from toot.exceptions import ConsoleError
@@ -154,3 +155,8 @@ def _use_existing_tmp_file(tmp_path) -> bool:
return char == "o"
return False
+
+
+def drop_empty_values(data: Dict) -> Dict:
+ """Remove keys whose values are null"""
+ return {k: v for k, v in data.items() if v is not None}