Files
decePubClient/Services/MediaService.cs
T
thepraandClaude Opus 5.5 70cb389537 Media survive failures: uploaded when attached, the server's limits, alt text asked for
The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.

Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:56:59 +02:00

93 lines
4.3 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
/// </summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
{
static readonly MediaConfiguration Fallback = new()
{
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
ImageSizeLimit = 16 * 1024 * 1024,
VideoSizeLimit = 99 * 1024 * 1024
};
MediaConfiguration limits;
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
{
if (limits is not null)
return limits;
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
return limits = instance.Configuration.MediaAttachments;
return Fallback;
}
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
{
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
if (media.AltText is { Length: > 0 } description)
content.Add(new StringContent(description), "description");
return content;
}), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
/// when it can't be.</summary>
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
if (response.StatusCode == HttpStatusCode.PartialContent)
return new WebResult();
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = attachment };
}
}
}