diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
index 2088699..48d51fa 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
@@ -10,7 +10,11 @@
-
+
@@ -23,8 +27,11 @@
-
-
+
+
+
+
+
@@ -34,6 +41,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -43,6 +61,7 @@
+
@@ -52,6 +71,8 @@
+
+
diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
index 5f2573d..f21ab4f 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
@@ -4,6 +4,7 @@ using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reflection;
+using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -29,6 +30,9 @@ public partial class MainWindow : Window
private int _queuedCount;
private int _uploadingCount;
private int _failedCount;
+ private DateTimeOffset? _lastSuccessAt;
+ private bool _advancedVisible;
+ private readonly DispatcherTimer _liveTimer = new();
public ObservableCollection RecentUploads { get; } = new();
@@ -37,15 +41,60 @@ public partial class MainWindow : Window
InitializeComponent();
_settings = _settingsStore.Load();
_settings.BaseUrl = NormalizeBaseUrl(_settings.BaseUrl) ?? DefaultBaseUrl;
+ if (_settings.MaxConcurrentUploads <= 0)
+ {
+ _settings.MaxConcurrentUploads = 2;
+ }
_userAgent = $"FotospielPhotoboothUploader/{GetAppVersion()}";
_client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
_uploadService.Configure(_userAgent);
_settingsStore.Save(_settings);
DataContext = this;
+ _liveTimer.Interval = TimeSpan.FromSeconds(30);
+ _liveTimer.Tick += (_, _) => UpdateLiveStatus();
+ _liveTimer.Start();
ApplySettings();
}
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var code = (CodeBox.Text ?? string.Empty).Trim();
+
+ if (!IsValidCode(code))
+ {
+ StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
+ return;
+ }
+
+ ConnectButton.IsEnabled = false;
+ ReconnectButton.IsEnabled = false;
+ StatusText.Text = "Verbinde...";
+
+ await RedeemCodeAsync(code);
+
+ ConnectButton.IsEnabled = true;
+ ReconnectButton.IsEnabled = true;
+ }
+
+ private async void ReconnectButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var code = (CodeBox.Text ?? string.Empty).Trim();
+
+ if (!IsValidCode(code))
+ {
+ StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
+ return;
+ }
+
+ ReconnectButton.IsEnabled = false;
+ StatusText.Text = "Verbindung erneuern...";
+
+ await RedeemCodeAsync(code);
+
+ ReconnectButton.IsEnabled = true;
+ }
+
+ private async Task RedeemCodeAsync(string code)
{
var normalizedBaseUrl = NormalizeBaseUrl(_settings.BaseUrl);
if (normalizedBaseUrl is null)
@@ -61,24 +110,12 @@ public partial class MainWindow : Window
_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'))
- {
- StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
- return;
- }
-
- ConnectButton.IsEnabled = false;
- StatusText.Text = "Verbinde...";
-
var response = await _client.RedeemAsync(code);
if (response.Data is null)
{
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
SetLastError(response.Message ?? "Verbindung fehlgeschlagen.");
- ConnectButton.IsEnabled = true;
return;
}
@@ -93,9 +130,9 @@ public partial class MainWindow : Window
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
TestUploadButton.IsEnabled = true;
+ ReconnectButton.IsEnabled = true;
UpdateDiagnostics();
StartUploadPipelineIfReady();
- ConnectButton.IsEnabled = true;
}
private async void PickFolderButton_Click(object? sender, RoutedEventArgs e)
@@ -119,6 +156,7 @@ public partial class MainWindow : Window
_settingsStore.Save(_settings);
FolderText.Text = localPath;
+ UpdateFolderHealth();
StartUploadPipelineIfReady();
}
@@ -129,15 +167,20 @@ public partial class MainWindow : Window
FolderText.Text = _settings.WatchFolder;
}
+ BaseUrlBox.Text = _settings.BaseUrl ?? DefaultBaseUrl;
+ MaxUploadsBox.Text = _settings.MaxConcurrentUploads.ToString();
+
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
TestUploadButton.IsEnabled = true;
+ ReconnectButton.IsEnabled = true;
StartUploadPipelineIfReady();
}
UpdateCountersText();
+ UpdateFolderHealth();
UpdateDiagnostics();
UpdateSteps();
}
@@ -150,6 +193,7 @@ public partial class MainWindow : Window
return;
}
+ ResetCounters();
_uploadService.Start(_settings, OnQueued, OnUploading, OnSuccess, OnFailure);
StartWatcher(_settings.WatchFolder);
UpdateSteps();
@@ -204,7 +248,7 @@ public partial class MainWindow : Window
private void OnQueued(string path)
{
- _queuedCount = Math.Max(0, _queuedCount + 1);
+ Interlocked.Increment(ref _queuedCount);
UpdateUpload(path, UploadStatus.Queued);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
UpdateCountersText();
@@ -212,8 +256,8 @@ public partial class MainWindow : Window
private void OnUploading(string path)
{
- _queuedCount = Math.Max(0, _queuedCount - 1);
- _uploadingCount = Math.Max(0, _uploadingCount + 1);
+ Interlocked.Decrement(ref _queuedCount);
+ Interlocked.Increment(ref _uploadingCount);
UpdateUpload(path, UploadStatus.Uploading);
UpdateStatusIfAllowed($"Upload läuft: {Path.GetFileName(path)}", false);
UpdateCountersText();
@@ -222,17 +266,19 @@ public partial class MainWindow : Window
private void OnSuccess(string path)
{
_failedPaths.Remove(path);
- _uploadingCount = Math.Max(0, _uploadingCount - 1);
+ Interlocked.Decrement(ref _uploadingCount);
+ _lastSuccessAt = DateTimeOffset.Now;
UpdateUpload(path, UploadStatus.Success);
UpdateStatusIfAllowed($"Hochgeladen: {Path.GetFileName(path)}", false);
UpdateCountersText();
+ UpdateLiveStatus();
}
private void OnFailure(string path)
{
_failedPaths.Add(path);
- _uploadingCount = Math.Max(0, _uploadingCount - 1);
- _failedCount = Math.Max(0, _failedCount + 1);
+ Interlocked.Decrement(ref _uploadingCount);
+ Interlocked.Increment(ref _failedCount);
UpdateUpload(path, UploadStatus.Failed);
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
SetLastError($"Upload fehlgeschlagen: {Path.GetFileName(path)}");
@@ -298,8 +344,8 @@ public partial class MainWindow : Window
}
_failedPaths.Clear();
- _failedCount = Math.Max(0, _failedCount - retried);
- _queuedCount = Math.Max(0, _queuedCount + retried);
+ Interlocked.Add(ref _failedCount, -retried);
+ Interlocked.Add(ref _queuedCount, retried);
UpdateRetryButton();
UpdateCountersText();
}
@@ -315,6 +361,53 @@ public partial class MainWindow : Window
StepReadyText.Text = ready ? "3. Upload läuft ✓" : "3. Upload läuft";
}
+ private void CodeBox_TextChanged(object? sender, TextChangedEventArgs e)
+ {
+ var code = (CodeBox.Text ?? string.Empty).Trim();
+ var enabled = IsValidCode(code);
+ ReconnectButton.IsEnabled = enabled;
+ }
+
+ private void TitleText_PointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
+ {
+ if (e.ClickCount < 2)
+ {
+ return;
+ }
+
+ _advancedVisible = !_advancedVisible;
+ AdvancedPanel.IsVisible = _advancedVisible;
+ }
+
+ private void SaveAdvancedButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var normalizedBaseUrl = NormalizeBaseUrl(BaseUrlBox.Text);
+ if (normalizedBaseUrl is null)
+ {
+ UpdateStatus("Ungültige Basis-URL.");
+ SetLastError("Ungültige Basis-URL.");
+ return;
+ }
+
+ if (!int.TryParse(MaxUploadsBox.Text, out var maxUploads) || maxUploads < 1 || maxUploads > 5)
+ {
+ UpdateStatus("Max. parallele Uploads muss zwischen 1 und 5 liegen.");
+ SetLastError("Ungültige Parallel-Uploads.");
+ return;
+ }
+
+ _settings.BaseUrl = normalizedBaseUrl;
+ _settings.MaxConcurrentUploads = maxUploads;
+ _settingsStore.Save(_settings);
+
+ _client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
+ _uploadService.Configure(_userAgent);
+ UpdateDiagnostics();
+ UpdateFolderHealth();
+ RestartUploadPipeline();
+ UpdateStatus("Einstellungen gespeichert.");
+ }
+
private async void TestUploadButton_Click(object? sender, RoutedEventArgs e)
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl))
@@ -346,6 +439,113 @@ public partial class MainWindow : Window
return tempPath;
}
+ private void ResetCounters()
+ {
+ Interlocked.Exchange(ref _queuedCount, 0);
+ Interlocked.Exchange(ref _uploadingCount, 0);
+ Interlocked.Exchange(ref _failedCount, 0);
+ _failedPaths.Clear();
+ _lastSuccessAt = null;
+ UpdateCountersText();
+ UpdateRetryButton();
+ UpdateLiveStatus();
+ }
+
+ private void RestartUploadPipeline()
+ {
+ _uploadService.Stop();
+ StartUploadPipelineIfReady();
+ }
+
+ private void UpdateFolderHealth()
+ {
+ var folder = _settings.WatchFolder;
+ if (string.IsNullOrWhiteSpace(folder))
+ {
+ FolderHealthText.Text = "Ordner: —";
+ DiskFreeText.Text = "Freier Speicher: —";
+ return;
+ }
+
+ if (!Directory.Exists(folder))
+ {
+ FolderHealthText.Text = "Ordner: fehlt";
+ DiskFreeText.Text = "Freier Speicher: —";
+ return;
+ }
+
+ try
+ {
+ _ = Directory.EnumerateFileSystemEntries(folder).FirstOrDefault();
+ FolderHealthText.Text = "Ordner: OK";
+ }
+ catch (UnauthorizedAccessException)
+ {
+ FolderHealthText.Text = "Ordner: Keine Berechtigung";
+ }
+ catch
+ {
+ FolderHealthText.Text = "Ordner: Fehler";
+ }
+
+ DiskFreeText.Text = $"Freier Speicher: {FormatDiskFree(folder)}";
+ }
+
+ private void UpdateLiveStatus()
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (_lastSuccessAt is null)
+ {
+ LiveStatusText.Text = "Live: —";
+ return;
+ }
+
+ var age = DateTimeOffset.Now - _lastSuccessAt.Value;
+ var isLive = age <= TimeSpan.FromMinutes(5);
+ var label = isLive ? "Live: Ja" : "Live: Nein";
+ LiveStatusText.Text = $"{label} (letzter Upload {FormatRelativeAge(age)})";
+ });
+ }
+
+ private static string FormatRelativeAge(TimeSpan age)
+ {
+ if (age.TotalMinutes < 1)
+ {
+ return "gerade eben";
+ }
+
+ if (age.TotalHours < 1)
+ {
+ return $"vor {Math.Round(age.TotalMinutes)} min";
+ }
+
+ if (age.TotalDays < 1)
+ {
+ return $"vor {Math.Round(age.TotalHours)} h";
+ }
+
+ return $"vor {Math.Round(age.TotalDays)} d";
+ }
+
+ private static string FormatDiskFree(string folder)
+ {
+ var root = Path.GetPathRoot(folder);
+ if (string.IsNullOrWhiteSpace(root))
+ {
+ return "—";
+ }
+
+ var drive = new DriveInfo(root);
+ if (!drive.IsReady)
+ {
+ return "—";
+ }
+
+ var freeGb = drive.AvailableFreeSpace / (1024d * 1024d * 1024d);
+ return $"{freeGb:0.0} GB";
+ }
+
private string? ResolveUploadUrl(string? uploadUrl)
{
if (string.IsNullOrWhiteSpace(uploadUrl))
@@ -388,7 +588,10 @@ public partial class MainWindow : Window
{
Dispatcher.UIThread.Post(() =>
{
- QueueStatusText.Text = $"Warteschlange: {_queuedCount} · Läuft: {_uploadingCount} · Fehlgeschlagen: {_failedCount}";
+ var queued = Volatile.Read(ref _queuedCount);
+ var uploading = Volatile.Read(ref _uploadingCount);
+ var failed = Volatile.Read(ref _failedCount);
+ QueueStatusText.Text = $"Warteschlange: {Math.Max(0, queued)} · Läuft: {Math.Max(0, uploading)} · Fehlgeschlagen: {Math.Max(0, failed)}";
});
}
@@ -439,4 +642,9 @@ public partial class MainWindow : Window
var version = Assembly.GetExecutingAssembly().GetName().Version;
return version is null ? "0.0.0" : version.ToString();
}
+
+ private static bool IsValidCode(string code)
+ {
+ return code.Length == 6 && code.All(ch => ch is >= '0' and <= '9');
+ }
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
index e413a7c..3e0d438 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
@@ -11,4 +11,5 @@ public sealed class PhotoboothSettings
public string? WatchFolder { get; set; }
public string? LastError { get; set; }
public string? LastErrorAt { get; set; }
+ public int MaxConcurrentUploads { get; set; } = 2;
}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
index c4cef39..107cb24 100644
--- a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -17,6 +18,7 @@ public sealed class UploadService
private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase);
private string _userAgent = "FotospielPhotoboothUploader";
private CancellationTokenSource? _cts;
+ private readonly List _workers = new();
public void Configure(string userAgent)
{
@@ -36,7 +38,11 @@ public sealed class UploadService
Stop();
_cts = new CancellationTokenSource();
- _ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
+ 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()
@@ -44,6 +50,7 @@ public sealed class UploadService
_cts?.Cancel();
_cts = null;
_pending.Clear();
+ _workers.Clear();
}
public void Enqueue(string path, Action onQueued)
@@ -171,4 +178,15 @@ public sealed class UploadService
_ => "image/jpeg",
};
}
+
+ private static int GetWorkerCount(PhotoboothSettings settings)
+ {
+ var count = settings.MaxConcurrentUploads;
+ if (count < 1)
+ {
+ return 1;
+ }
+
+ return count > 5 ? 5 : count;
+ }
}