Files
fotospiel-app/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
Codex Agent 898ac9ff0e
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add uploader advanced settings and live status
2026-01-12 20:50:39 +01:00

651 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
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;
using Avalonia.Platform.Storage;
using Avalonia.Platform;
using Avalonia.Threading;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
namespace PhotoboothUploader;
public partial class MainWindow : Window
{
private const string DefaultBaseUrl = "https://fotospiel.app";
private PhotoboothConnectClient _client;
private readonly SettingsStore _settingsStore = new();
private readonly UploadService _uploadService = new();
private PhotoboothSettings _settings;
private FileSystemWatcher? _watcher;
private readonly Dictionary<string, UploadItem> _uploadsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _failedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly string _userAgent;
private int _queuedCount;
private int _uploadingCount;
private int _failedCount;
private DateTimeOffset? _lastSuccessAt;
private bool _advancedVisible;
private readonly DispatcherTimer _liveTimer = new();
public ObservableCollection<UploadItem> RecentUploads { get; } = new();
public MainWindow()
{
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)
{
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 response = await _client.RedeemAsync(code);
if (response.Data is null)
{
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
SetLastError(response.Message ?? "Verbindung fehlgeschlagen.");
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;
ReconnectButton.IsEnabled = true;
UpdateDiagnostics();
StartUploadPipelineIfReady();
}
private async void PickFolderButton_Click(object? sender, RoutedEventArgs e)
{
var options = new FolderPickerOpenOptions
{
Title = "Upload-Ordner auswählen",
AllowMultiple = false,
};
var folders = await StorageProvider.OpenFolderPickerAsync(options);
var folder = folders.FirstOrDefault();
var localPath = folder?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
return;
}
_settings.WatchFolder = localPath;
_settingsStore.Save(_settings);
FolderText.Text = localPath;
UpdateFolderHealth();
StartUploadPipelineIfReady();
}
private void ApplySettings()
{
if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
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();
}
private void StartUploadPipelineIfReady()
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
UpdateSteps();
return;
}
ResetCounters();
_uploadService.Start(_settings, OnQueued, OnUploading, OnSuccess, OnFailure);
StartWatcher(_settings.WatchFolder);
UpdateSteps();
}
private void StartWatcher(string folder)
{
_watcher?.Dispose();
_watcher = new FileSystemWatcher(folder)
{
IncludeSubdirectories = false,
EnableRaisingEvents = true,
};
_watcher.Created += OnFileChanged;
_watcher.Changed += OnFileChanged;
_watcher.Renamed += OnFileRenamed;
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private bool IsSupportedImage(string path)
{
var extension = Path.GetExtension(path)?.ToLowerInvariant();
return extension is ".jpg" or ".jpeg" or ".png" or ".webp";
}
private void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() => StatusText.Text = message);
}
private void OnQueued(string path)
{
Interlocked.Increment(ref _queuedCount);
UpdateUpload(path, UploadStatus.Queued);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
UpdateCountersText();
}
private void OnUploading(string path)
{
Interlocked.Decrement(ref _queuedCount);
Interlocked.Increment(ref _uploadingCount);
UpdateUpload(path, UploadStatus.Uploading);
UpdateStatusIfAllowed($"Upload läuft: {Path.GetFileName(path)}", false);
UpdateCountersText();
}
private void OnSuccess(string path)
{
_failedPaths.Remove(path);
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);
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)}");
UpdateRetryButton();
UpdateCountersText();
}
private void UpdateUpload(string path, UploadStatus status)
{
Dispatcher.UIThread.Post(() =>
{
if (!_uploadsByPath.TryGetValue(path, out var item))
{
item = new UploadItem(path);
_uploadsByPath[path] = item;
RecentUploads.Insert(0, item);
}
item.Status = status;
LastUploadText.Text = status == UploadStatus.Success
? $"Letzter Upload: {item.UpdatedLabel}"
: LastUploadText.Text;
while (RecentUploads.Count > 3)
{
var last = RecentUploads[^1];
_uploadsByPath.Remove(last.Path);
RecentUploads.RemoveAt(RecentUploads.Count - 1);
}
UpdateRetryButton();
});
}
private void UpdateStatusIfAllowed(string message, bool important)
{
var quiet = QuietToggle?.IsChecked ?? false;
if (quiet && !important)
{
return;
}
UpdateStatus(message);
}
private void UpdateRetryButton()
{
RetryFailedButton.IsEnabled = _failedPaths.Count > 0;
}
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();
Interlocked.Add(ref _failedCount, -retried);
Interlocked.Add(ref _queuedCount, retried);
UpdateRetryButton();
UpdateCountersText();
}
private void UpdateSteps()
{
var hasCode = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
var hasFolder = !string.IsNullOrWhiteSpace(_settings.WatchFolder);
var ready = hasCode && hasFolder;
StepCodeText.Text = hasCode ? "1. Code eingeben ✓" : "1. Code eingeben";
StepFolderText.Text = hasFolder ? "2. Upload-Ordner wählen ✓" : "2. Upload-Ordner wählen";
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))
{
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<string> 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 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))
{
return uploadUrl;
}
if (Uri.TryCreate(uploadUrl, UriKind.Absolute, out _))
{
return uploadUrl;
}
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(() =>
{
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)}";
});
}
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();
}
private static bool IsValidCode(string code)
{
return code.Length == 6 && code.All(ch => ch is >= '0' and <= '9');
}
}