Reapply photobooth uploader changes

This commit is contained in:
Codex Agent
2026-01-12 17:09:37 +01:00
parent e69c94ad20
commit 4f4a527010
55 changed files with 2974 additions and 190 deletions

View File

@@ -0,0 +1,7 @@
<Application
x:Class="PhotoboothUploader.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,17 @@
using Microsoft.UI.Xaml;
namespace PhotoboothUploader;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var window = new MainWindow();
window.Activate();
}
}

View File

@@ -0,0 +1,28 @@
<Window
x:Class="PhotoboothUploader.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fotospiel Photobooth Uploader"
Height="360"
Width="520">
<Grid Padding="24">
<StackPanel Spacing="16" MaxWidth="420">
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
<TextBox x:Name="CodeBox" MaxLength="6" PlaceholderText="123456">
<TextBox.InputScope>
<InputScope>
<InputScopeName NameValue="Number" />
</InputScope>
</TextBox.InputScope>
</TextBox>
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
<StackPanel Spacing="8">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
</StackPanel>
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,177 @@
using Microsoft.UI.Xaml;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
using System.Linq;
using System.IO;
using Windows.Storage;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace PhotoboothUploader;
public sealed 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;
public MainWindow()
{
InitializeComponent();
_settings = _settingsStore.Load();
_settings.BaseUrl ??= DefaultBaseUrl;
_client = new PhotoboothConnectClient(_settings.BaseUrl);
_settingsStore.Save(_settings);
ApplySettings();
}
private async void ConnectButton_Click(object sender, RoutedEventArgs e)
{
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.";
ConnectButton.IsEnabled = true;
return;
}
_settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
_settings.Username = response.Data.Username;
_settings.Password = response.Data.Password;
_settings.ResponseFormat = response.Data.ResponseFormat;
_settingsStore.Save(_settings);
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
StartUploadPipelineIfReady();
ConnectButton.IsEnabled = true;
}
private async void PickFolderButton_Click(object sender, RoutedEventArgs e)
{
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
};
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
StorageFolder? folder = await picker.PickSingleFolderAsync();
if (folder is null)
{
return;
}
_settings.WatchFolder = folder.Path;
_settingsStore.Save(_settings);
FolderText.Text = folder.Path;
StartUploadPipelineIfReady();
}
private void ApplySettings()
{
if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
FolderText.Text = _settings.WatchFolder;
}
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
StartUploadPipelineIfReady();
}
}
private void StartUploadPipelineIfReady()
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
return;
}
_uploadService.Start(_settings, UpdateStatus);
StartWatcher(_settings.WatchFolder);
}
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);
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath);
}
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)
{
DispatcherQueue.TryEnqueue(() => StatusText.Text = message);
}
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();
}
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothConnectResponse
{
[JsonPropertyName("data")]
public PhotoboothConnectPayload? Data { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
public sealed class PhotoboothConnectPayload
{
[JsonPropertyName("upload_url")]
public string? UploadUrl { get; set; }
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("password")]
public string? Password { get; set; }
[JsonPropertyName("expires_at")]
public string? ExpiresAt { get; set; }
[JsonPropertyName("response_format")]
public string? ResponseFormat { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings
{
public string? BaseUrl { 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; }
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<UseWinUI>true</UseWinUI>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<DisableImplicitWindowsAppSDKRuntimeIdentifiers>true</DisableImplicitWindowsAppSDKRuntimeIdentifiers>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<WindowsAppSDKRuntimeIdentifiers>win-x64</WindowsAppSDKRuntimeIdentifiers>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.5.240404000" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,46 @@
using System.Net.Http.Json;
using System.Text.Json;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class PhotoboothConnectClient
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public PhotoboothConnectClient(string baseUrl)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl),
};
}
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
{
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
if (payload is null)
{
return new PhotoboothConnectResponse
{
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
};
}
if (!response.IsSuccessStatusCode)
{
return new PhotoboothConnectResponse
{
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
};
}
return payload;
}
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class SettingsStore
{
private readonly JsonSerializerOptions _options = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
public string SettingsPath { get; }
public SettingsStore()
{
var basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Fotospiel",
"PhotoboothUploader");
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
}
public PhotoboothSettings Load()
{
if (!File.Exists(SettingsPath))
{
return new PhotoboothSettings();
}
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
}
public void Save(PhotoboothSettings settings)
{
var json = JsonSerializer.Serialize(settings, _options);
File.WriteAllText(SettingsPath, json);
}
}

View File

@@ -0,0 +1,143 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Threading.Channels;
using PhotoboothUploader.Models;
namespace PhotoboothUploader.Services;
public sealed class UploadService
{
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _cts;
public void Start(PhotoboothSettings settings, Action<string> setStatus)
{
Stop();
_cts = new CancellationTokenSource();
_ = Task.Run(() => WorkerAsync(settings, setStatus, _cts.Token));
}
public void Stop()
{
_cts?.Cancel();
_cts = null;
_pending.Clear();
}
public void Enqueue(string path)
{
if (!_pending.TryAdd(path, 0))
{
return;
}
_queue.Writer.TryWrite(path);
}
private async Task WorkerAsync(PhotoboothSettings settings, Action<string> setStatus, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
{
return;
}
using var client = new HttpClient();
while (await _queue.Reader.WaitToReadAsync(token))
{
while (_queue.Reader.TryRead(out var path))
{
try
{
await WaitForFileReadyAsync(path, token);
await UploadAsync(client, settings, path, token);
setStatus($"Hochgeladen: {Path.GetFileName(path)}");
}
catch (OperationCanceledException)
{
return;
}
catch
{
setStatus($"Upload fehlgeschlagen: {Path.GetFileName(path)}");
}
finally
{
_pending.TryRemove(path, out _);
}
}
}
}
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;
}
lastSize = size;
await Task.Delay(700, token);
}
}
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
{
if (!File.Exists(path))
{
return;
}
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));
var response = await client.PostAsync(settings.UploadUrl, content, token);
response.EnsureSuccessStatusCode();
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path)?.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
}