Replace sparkbooth upload with photobooth uploader

This commit is contained in:
soeren
2026-01-29 12:16:27 +01:00
parent e37f533bcb
commit 084c52ba2d
27 changed files with 2163 additions and 50 deletions

View File

@@ -0,0 +1,297 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class UploadService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
private const int MaxRetries = 2;
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private string _userAgent = "AIStylegalleryPhotoboothUploader";
private CancellationTokenSource? _cts;
private readonly List<Task> _workers = new();
public void Configure(string userAgent)
{
if (!string.IsNullOrWhiteSpace(userAgent))
{
_userAgent = userAgent;
}
}
public void Start(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string, string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
var workerCount = GetWorkerCount(settings);
for (var i = 0; i < workerCount; i++)
{
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
}
}
public void Stop()
{
_cts?.Cancel();
_cts = null;
_pending.Clear();
_workers.Clear();
}
public void Enqueue(string path, Action<string> onQueued)
{
if (!_pending.TryAdd(path, 0))
{
return;
}
_queue.Writer.TryWrite(path);
onQueued(path);
}
private async Task WorkerAsync(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string, string> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
{
return;
}
using var client = new HttpClient();
client.Timeout = DefaultTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
while (await _queue.Reader.WaitToReadAsync(token))
{
while (_queue.Reader.TryRead(out var path))
{
try
{
onUploading(path);
var error = await UploadWithRetryAsync(client, settings, path, token);
if (error is null)
{
onSuccess(path);
}
else
{
onFailure(path, error);
}
}
catch (OperationCanceledException)
{
return;
}
finally
{
_pending.TryRemove(path, out _);
if (settings.UploadDelayMs > 0)
{
await Task.Delay(settings.UploadDelayMs, token);
}
}
}
}
}
private static async Task<string?> UploadWithRetryAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
var attemptError = await UploadOnceAsync(client, settings, path, token);
if (attemptError.Success)
{
return null;
}
if (!attemptError.Retryable || attempt >= MaxRetries)
{
return attemptError.Error ?? "Upload fehlgeschlagen.";
}
await Task.Delay(GetRetryDelay(attempt), token);
}
return "Upload fehlgeschlagen.";
}
private static async Task<UploadAttempt> UploadOnceAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
var readyError = await WaitForFileReadyAsync(path, token);
if (readyError is not null)
{
return UploadAttempt.Fail(readyError, retryable: false);
}
if (!File.Exists(path))
{
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
}
using var content = new MultipartFormDataContent();
if (!string.IsNullOrWhiteSpace(settings.Username))
{
content.Add(new StringContent(settings.Username), "username");
}
if (!string.IsNullOrWhiteSpace(settings.Password))
{
content.Add(new StringContent(settings.Password), "password");
}
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
{
content.Add(new StringContent(settings.ResponseFormat), "response_format");
}
var stream = File.OpenRead(path);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
content.Add(fileContent, "media", Path.GetFileName(path));
try
{
var response = await client.PostAsync(settings.UploadUrl, content, token);
if (response.IsSuccessStatusCode)
{
return UploadAttempt.Ok();
}
var body = await ReadResponseBodyAsync(response, token);
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} {body}";
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
}
catch (TaskCanceledException) when (!token.IsCancellationRequested)
{
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
}
catch (HttpRequestException)
{
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
}
catch (IOException)
{
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
}
}
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
{
var lastSize = -1L;
for (var attempts = 0; attempts < 10; attempts++)
{
token.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return null;
}
lastSize = size;
await Task.Delay(700, token);
}
return "Datei ist noch in Bearbeitung.";
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path)?.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
{
var numeric = (int)statusCode;
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
}
private static TimeSpan GetRetryDelay(int attempt)
{
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
}
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
{
if (response.Content is null)
{
return null;
}
var body = await response.Content.ReadAsStringAsync(token);
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
body = body.Trim();
return body.Length > 200 ? body[..200] + "…" : body;
}
private static int GetWorkerCount(PhotoboothSettings settings)
{
var count = settings.MaxConcurrentUploads;
if (count < 1)
{
return 1;
}
return count > 5 ? 5 : count;
}
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
{
public static UploadAttempt Ok() => new(true, false, null);
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
}
}