diff --git a/app/Http/Controllers/Api/PhotoboothConnectController.php b/app/Http/Controllers/Api/PhotoboothConnectController.php
index d1c9ddf..678601f 100644
--- a/app/Http/Controllers/Api/PhotoboothConnectController.php
+++ b/app/Http/Controllers/Api/PhotoboothConnectController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
+use App\Models\Event;
use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse;
@@ -33,6 +34,7 @@ class PhotoboothConnectController extends Controller
return response()->json([
'data' => [
+ 'event_name' => $this->resolveEventName($event),
'upload_url' => route('api.v1.photobooth.upload'),
'username' => $setting->username,
'password' => $setting->password,
@@ -42,4 +44,27 @@ class PhotoboothConnectController extends Controller
],
]);
}
+
+ private function resolveEventName(?Event $event): ?string
+ {
+ if (! $event) {
+ return null;
+ }
+
+ $name = $event->name;
+
+ if (is_string($name) && trim($name) !== '') {
+ return $name;
+ }
+
+ if (is_array($name)) {
+ foreach ($name as $value) {
+ if (is_string($value) && trim($value) !== '') {
+ return $value;
+ }
+ }
+ }
+
+ return $event->slug ?: null;
+ }
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Assets/sample-upload.png b/clients/photobooth-uploader/PhotoboothUploader/Assets/sample-upload.png
new file mode 100644
index 0000000..fef1915
Binary files /dev/null and b/clients/photobooth-uploader/PhotoboothUploader/Assets/sample-upload.png differ
diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
index a52b0a1..2088699 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
@@ -30,6 +30,7 @@
+
@@ -41,6 +42,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
index a6c73f8..5f2573d 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
@@ -3,9 +3,12 @@ using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Reflection;
+using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
+using Avalonia.Platform;
using Avalonia.Threading;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
@@ -22,6 +25,10 @@ public partial class MainWindow : Window
private FileSystemWatcher? _watcher;
private readonly Dictionary _uploadsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet _failedPaths = new(StringComparer.OrdinalIgnoreCase);
+ private readonly string _userAgent;
+ private int _queuedCount;
+ private int _uploadingCount;
+ private int _failedCount;
public ObservableCollection RecentUploads { get; } = new();
@@ -29,8 +36,10 @@ public partial class MainWindow : Window
{
InitializeComponent();
_settings = _settingsStore.Load();
- _settings.BaseUrl ??= DefaultBaseUrl;
- _client = new PhotoboothConnectClient(_settings.BaseUrl);
+ _settings.BaseUrl = NormalizeBaseUrl(_settings.BaseUrl) ?? DefaultBaseUrl;
+ _userAgent = $"FotospielPhotoboothUploader/{GetAppVersion()}";
+ _client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
+ _uploadService.Configure(_userAgent);
_settingsStore.Save(_settings);
DataContext = this;
ApplySettings();
@@ -38,6 +47,20 @@ public partial class MainWindow : Window
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
{
+ var normalizedBaseUrl = NormalizeBaseUrl(_settings.BaseUrl);
+ if (normalizedBaseUrl is null)
+ {
+ UpdateStatus("Ungültige Basis-URL. Bitte Support kontaktieren.");
+ SetLastError("Ungültige Basis-URL.");
+ return;
+ }
+
+ if (!string.Equals(normalizedBaseUrl, _settings.BaseUrl, StringComparison.OrdinalIgnoreCase))
+ {
+ _settings.BaseUrl = normalizedBaseUrl;
+ _client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
+ }
+
var code = (CodeBox.Text ?? string.Empty).Trim();
if (code.Length != 6 || code.Any(ch => ch is < '0' or > '9'))
@@ -54,18 +77,23 @@ public partial class MainWindow : Window
if (response.Data is null)
{
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
+ SetLastError(response.Message ?? "Verbindung fehlgeschlagen.");
ConnectButton.IsEnabled = true;
return;
}
+ ClearLastError();
_settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
_settings.Username = response.Data.Username;
_settings.Password = response.Data.Password;
_settings.ResponseFormat = response.Data.ResponseFormat;
+ _settings.EventName = response.Data.EventName;
_settingsStore.Save(_settings);
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
+ TestUploadButton.IsEnabled = true;
+ UpdateDiagnostics();
StartUploadPipelineIfReady();
ConnectButton.IsEnabled = true;
}
@@ -105,9 +133,12 @@ public partial class MainWindow : Window
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
+ TestUploadButton.IsEnabled = true;
StartUploadPipelineIfReady();
}
+ UpdateCountersText();
+ UpdateDiagnostics();
UpdateSteps();
}
@@ -173,29 +204,40 @@ public partial class MainWindow : Window
private void OnQueued(string path)
{
+ _queuedCount = Math.Max(0, _queuedCount + 1);
UpdateUpload(path, UploadStatus.Queued);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
+ UpdateCountersText();
}
private void OnUploading(string path)
{
+ _queuedCount = Math.Max(0, _queuedCount - 1);
+ _uploadingCount = Math.Max(0, _uploadingCount + 1);
UpdateUpload(path, UploadStatus.Uploading);
UpdateStatusIfAllowed($"Upload läuft: {Path.GetFileName(path)}", false);
+ UpdateCountersText();
}
private void OnSuccess(string path)
{
_failedPaths.Remove(path);
+ _uploadingCount = Math.Max(0, _uploadingCount - 1);
UpdateUpload(path, UploadStatus.Success);
UpdateStatusIfAllowed($"Hochgeladen: {Path.GetFileName(path)}", false);
+ UpdateCountersText();
}
private void OnFailure(string path)
{
_failedPaths.Add(path);
+ _uploadingCount = Math.Max(0, _uploadingCount - 1);
+ _failedCount = Math.Max(0, _failedCount + 1);
UpdateUpload(path, UploadStatus.Failed);
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
+ SetLastError($"Upload fehlgeschlagen: {Path.GetFileName(path)}");
UpdateRetryButton();
+ UpdateCountersText();
}
private void UpdateUpload(string path, UploadStatus status)
@@ -244,13 +286,22 @@ public partial class MainWindow : Window
private void RetryFailedButton_Click(object? sender, RoutedEventArgs e)
{
+ if (_failedPaths.Count == 0)
+ {
+ return;
+ }
+
+ var retried = _failedPaths.Count;
foreach (var path in _failedPaths.ToList())
{
_uploadService.Enqueue(path, OnQueued);
}
_failedPaths.Clear();
+ _failedCount = Math.Max(0, _failedCount - retried);
+ _queuedCount = Math.Max(0, _queuedCount + retried);
UpdateRetryButton();
+ UpdateCountersText();
}
private void UpdateSteps()
@@ -264,6 +315,37 @@ public partial class MainWindow : Window
StepReadyText.Text = ready ? "3. Upload läuft ✓" : "3. Upload läuft";
}
+ private async void TestUploadButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_settings.UploadUrl))
+ {
+ UpdateStatus("Bitte zuerst verbinden.");
+ return;
+ }
+
+ try
+ {
+ var tempPath = await CreateSampleUploadAsync();
+ _uploadService.Enqueue(tempPath, OnQueued);
+ UpdateStatusIfAllowed("Test-Upload hinzugefügt.", false);
+ }
+ catch
+ {
+ UpdateStatus("Test-Upload konnte nicht erstellt werden.");
+ SetLastError("Test-Upload konnte nicht erstellt werden.");
+ }
+ }
+
+ private async Task CreateSampleUploadAsync()
+ {
+ var uri = new Uri("avares://PhotoboothUploader/Assets/sample-upload.png");
+ await using var source = AssetLoader.Open(uri);
+ var tempPath = Path.Combine(Path.GetTempPath(), $"fotospiel-test-{DateTimeOffset.Now:yyyyMMddHHmmss}.png");
+ await using var target = File.Create(tempPath);
+ await source.CopyToAsync(target);
+ return tempPath;
+ }
+
private string? ResolveUploadUrl(string? uploadUrl)
{
if (string.IsNullOrWhiteSpace(uploadUrl))
@@ -279,4 +361,82 @@ public partial class MainWindow : Window
var baseUri = new Uri(_settings.BaseUrl ?? DefaultBaseUrl, UriKind.Absolute);
return new Uri(baseUri, uploadUrl).ToString();
}
+
+ private static string? NormalizeBaseUrl(string? baseUrl)
+ {
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ {
+ return null;
+ }
+
+ var trimmed = baseUrl.Trim();
+
+ if (Uri.TryCreate(trimmed, UriKind.Absolute, out var absolute))
+ {
+ return absolute.GetLeftPart(UriPartial.Authority);
+ }
+
+ if (Uri.TryCreate($"https://{trimmed}", UriKind.Absolute, out absolute))
+ {
+ return absolute.GetLeftPart(UriPartial.Authority);
+ }
+
+ return null;
+ }
+
+ private void UpdateCountersText()
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ QueueStatusText.Text = $"Warteschlange: {_queuedCount} · Läuft: {_uploadingCount} · Fehlgeschlagen: {_failedCount}";
+ });
+ }
+
+ private void UpdateDiagnostics()
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ EventNameText.Text = $"Event: {_settings.EventName ?? "—"}";
+ BaseUrlText.Text = $"Basis-URL: {_settings.BaseUrl ?? "—"}";
+ VersionText.Text = $"App-Version: {GetAppVersion()}";
+ LastErrorText.Text = $"Letzter Fehler: {FormatLastError()}";
+ });
+ }
+
+ private void SetLastError(string message)
+ {
+ _settings.LastError = message;
+ _settings.LastErrorAt = DateTimeOffset.Now.ToString("O");
+ _settingsStore.Save(_settings);
+ UpdateDiagnostics();
+ }
+
+ private void ClearLastError()
+ {
+ _settings.LastError = null;
+ _settings.LastErrorAt = null;
+ _settingsStore.Save(_settings);
+ UpdateDiagnostics();
+ }
+
+ private string FormatLastError()
+ {
+ if (string.IsNullOrWhiteSpace(_settings.LastError))
+ {
+ return "—";
+ }
+
+ if (DateTimeOffset.TryParse(_settings.LastErrorAt, out var timestamp))
+ {
+ return $"{timestamp:dd.MM.yyyy HH:mm} – {_settings.LastError}";
+ }
+
+ return _settings.LastError;
+ }
+
+ private static string GetAppVersion()
+ {
+ var version = Assembly.GetExecutingAssembly().GetName().Version;
+ return version is null ? "0.0.0" : version.ToString();
+ }
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs
index 9b4f2d2..3a88e7f 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs
@@ -13,6 +13,9 @@ public sealed class PhotoboothConnectResponse
public sealed class PhotoboothConnectPayload
{
+ [JsonPropertyName("event_name")]
+ public string? EventName { get; set; }
+
[JsonPropertyName("upload_url")]
public string? UploadUrl { get; set; }
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
index 11f72ae..e413a7c 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
@@ -3,9 +3,12 @@ namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings
{
public string? BaseUrl { get; set; }
+ public string? EventName { get; set; }
public string? UploadUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; }
+ public string? LastError { get; set; }
+ public string? LastErrorAt { get; set; }
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj b/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj
index 7293344..22f66ab 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj
+++ b/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj
@@ -21,5 +21,6 @@
+
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs
index 2f839dd..057ca30 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs
@@ -1,5 +1,7 @@
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;
@@ -10,41 +12,111 @@ 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)
+ 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 RedeemAsync(string code, CancellationToken cancellationToken = default)
{
- var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
- var payload = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken);
+ var request = new { code };
- if (payload is null)
+ for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
- return new PhotoboothConnectResponse
+ try
{
- Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
- };
+ 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.");
+ }
}
- if (!response.IsSuccessStatusCode)
+ return Fail("Verbindung fehlgeschlagen.");
+ }
+
+ private async Task ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ if (response.Content.Headers.ContentLength == 0)
{
- return new PhotoboothConnectResponse
- {
- Message = payload.Message ?? "Verbindung fehlgeschlagen.",
- };
+ return null;
}
- return payload;
+ return await response.Content.ReadFromJsonAsync(_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,
+ };
}
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
index bd6b63f..c4cef39 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
@@ -12,10 +12,20 @@ namespace PhotoboothUploader.Services;
public sealed class UploadService
{
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private readonly Channel _queue = Channel.CreateUnbounded();
private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase);
+ private string _userAgent = "FotospielPhotoboothUploader";
private CancellationTokenSource? _cts;
+ public void Configure(string userAgent)
+ {
+ if (!string.IsNullOrWhiteSpace(userAgent))
+ {
+ _userAgent = userAgent;
+ }
+ }
+
public void Start(
PhotoboothSettings settings,
Action onQueued,
@@ -61,6 +71,9 @@ public sealed class UploadService
}
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))
{
diff --git a/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php b/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
index 68bdb6b..9aeba80 100644
--- a/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
+++ b/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
@@ -39,6 +39,7 @@ class PhotoboothConnectCodeTest extends TenantTestCase
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-redeem',
+ 'name' => 'Winterhochzeit',
]);
EventPhotoboothSetting::factory()
@@ -59,6 +60,7 @@ class PhotoboothConnectCodeTest extends TenantTestCase
]);
$redeem->assertOk()
+ ->assertJsonPath('data.event_name', 'Winterhochzeit')
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
->assertJsonPath('data.username', 'pbconnect')
->assertJsonPath('data.password', 'SECRET12');