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 _uploadsByPath = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _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 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(); Opened += OnWindowOpened; Closing += OnWindowClosing; 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 OnWindowOpened(object? sender, EventArgs e) { ApplyWindowSize(); } private void OnWindowClosing(object? sender, WindowClosingEventArgs e) { _settings.WindowWidth = Width; _settings.WindowHeight = Height; _settingsStore.Save(_settings); } private void ApplyWindowSize() { if (_settings.WindowWidth > 0) { Width = Math.Max(MinWidth, _settings.WindowWidth); } if (_settings.WindowHeight > 0) { Height = Math.Max(MinHeight, _settings.WindowHeight); } } 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 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'); } }