Replace sparkbooth upload with photobooth uploader
This commit is contained in:
122
PhotoboothUploader/Services/PhotoboothConnectClient.cs
Normal file
122
PhotoboothUploader/Services/PhotoboothConnectClient.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class PhotoboothConnectClient
|
||||
{
|
||||
private const int MaxRetries = 2;
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl),
|
||||
Timeout = DefaultTimeout,
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new { code };
|
||||
|
||||
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
|
||||
var payload = await ReadPayloadAsync(response, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Fail("Zeitüberschreitung bei der Verbindung.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Fail("Serverantwort konnte nicht gelesen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return Fail("Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsTransientStatus(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||
or HttpStatusCode.InternalServerError;
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
|
||||
}
|
||||
|
||||
private static PhotoboothConnectResponse Fail(string message)
|
||||
{
|
||||
return new PhotoboothConnectResponse
|
||||
{
|
||||
Message = message,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
PhotoboothUploader/Services/SettingsStore.cs
Normal file
47
PhotoboothUploader/Services/SettingsStore.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
private readonly JsonSerializerOptions _options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public string SettingsPath { get; }
|
||||
public string LogPath { get; }
|
||||
|
||||
public SettingsStore()
|
||||
{
|
||||
var basePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"AIStylegallery",
|
||||
"PhotoboothUploader");
|
||||
|
||||
Directory.CreateDirectory(basePath);
|
||||
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||
LogPath = Path.Combine(basePath, "uploader.log");
|
||||
}
|
||||
|
||||
public PhotoboothSettings Load()
|
||||
{
|
||||
if (!File.Exists(SettingsPath))
|
||||
{
|
||||
return new PhotoboothSettings();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
|
||||
}
|
||||
|
||||
public void Save(PhotoboothSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, _options);
|
||||
File.WriteAllText(SettingsPath, json);
|
||||
}
|
||||
}
|
||||
297
PhotoboothUploader/Services/UploadService.cs
Normal file
297
PhotoboothUploader/Services/UploadService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user