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 _queue = Channel.CreateUnbounded(); private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase); private string _userAgent = "FotospielPhotoboothUploader"; private CancellationTokenSource? _cts; private readonly List _workers = new(); public void Configure(string userAgent) { if (!string.IsNullOrWhiteSpace(userAgent)) { _userAgent = userAgent; } } public void Start( PhotoboothSettings settings, Action onQueued, Action onUploading, Action onSuccess, Action 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 onQueued) { if (!_pending.TryAdd(path, 0)) { return; } _queue.Writer.TryWrite(path); onQueued(path); } private async Task WorkerAsync( PhotoboothSettings settings, Action onQueued, Action onUploading, Action onSuccess, Action 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 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 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), "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 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 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); } }