Compare commits
3 Commits
097de823c0
...
a3c9f9b719
| Author | SHA1 | Date | |
|---|---|---|---|
| a3c9f9b719 | |||
| b3c91da331 | |||
| 91935cd41c |
@ -1,10 +1,9 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.Camera;
|
||||
using CamBooth.App.Features.LycheeUpload;
|
||||
using CamBooth.App.Features.PhotoPrismUpload;
|
||||
using CamBooth.App.Features.PictureGallery;
|
||||
|
||||
using EDSDKLib.API.Base;
|
||||
@ -21,126 +20,113 @@ namespace CamBooth.App;
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private IServiceProvider _serviceProvider;
|
||||
private IServiceProvider? _serviceProvider;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
// Konfiguration laden
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
|
||||
|
||||
var configBuilder = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("Core/AppSettings/app.settings.json", optional: false, reloadOnChange: true);
|
||||
var configuration = BuildConfiguration();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
if (environment == "Development")
|
||||
{
|
||||
configBuilder.AddJsonFile("Core/AppSettings/app.settings.dev.json", optional: true, reloadOnChange: true);
|
||||
}
|
||||
RegisterServices(services, configuration);
|
||||
|
||||
var configuration = configBuilder.Build();
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Register Configuration
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
// Register base services
|
||||
services.AddSingleton<Logger>();
|
||||
services.AddSingleton<AppSettingsService>();
|
||||
services.AddSingleton<PictureGalleryService>();
|
||||
services.AddSingleton<LycheeUploadService>();
|
||||
services.AddSingleton<UploadQueueService>();
|
||||
services.AddSingleton<CameraService>();
|
||||
StartBackgroundServices();
|
||||
|
||||
// Zuerst den Provider bauen, um AppSettings zu laden
|
||||
var tempProvider = services.BuildServiceProvider();
|
||||
var appSettings = tempProvider.GetRequiredService<AppSettingsService>();
|
||||
var logger = tempProvider.GetRequiredService<Logger>();
|
||||
_serviceProvider.GetRequiredService<MainWindow>().Show();
|
||||
}
|
||||
|
||||
// Stelle sicher, dass das PictureLocation-Verzeichnis existiert
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(appSettings.PictureLocation))
|
||||
{
|
||||
Directory.CreateDirectory(appSettings.PictureLocation);
|
||||
logger.Info($"Picture directory created: {appSettings.PictureLocation}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"Failed to create picture directory: {ex.Message}");
|
||||
}
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_serviceProvider?.GetService<PhotoPrismUploadQueueService>()
|
||||
?.StopAsync().Wait(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error stopping upload service: {ex.Message}");
|
||||
}
|
||||
|
||||
// Jetzt die Camera Services basierend auf AppSettings registrieren
|
||||
// Mit Try-Catch für fehlende DLL-Abhängigkeiten
|
||||
try
|
||||
{
|
||||
if (appSettings.UseMockCamera)
|
||||
{
|
||||
services.AddSingleton<ICanonAPI, CanonAPIMock>();
|
||||
services.AddSingleton<ICamera, CameraMock>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<ICanonAPI, CanonAPI>();
|
||||
services.AddSingleton<ICamera, Camera>();
|
||||
}
|
||||
}
|
||||
catch (DllNotFoundException ex)
|
||||
{
|
||||
// Falls EDSDK DLL nicht gefunden, fallback auf Mock
|
||||
MessageBox.Show(
|
||||
$"EDSDK konnte nicht geladen werden. Verwende Mock-Kamera.\n\nFehler: {ex.Message}",
|
||||
"DLL nicht gefunden",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
services.AddSingleton<ICanonAPI, CanonAPIMock>();
|
||||
services.AddSingleton<ICamera, CameraMock>();
|
||||
}
|
||||
try
|
||||
{
|
||||
(_serviceProvider as IDisposable)?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error disposing service provider: {ex.Message}");
|
||||
}
|
||||
|
||||
services.AddTransient<MainWindow>();
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Starte UploadQueueService beim Start
|
||||
try
|
||||
{
|
||||
var uploadQueueService = _serviceProvider.GetRequiredService<UploadQueueService>();
|
||||
uploadQueueService.Start();
|
||||
|
||||
// Scan für fehlgeschlagene Uploads beim Start
|
||||
uploadQueueService.ScanAndQueueFailedUploads();
|
||||
|
||||
logger.Info("UploadQueueService initialisiert und gestartet");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"Fehler beim Start des UploadQueueService: {ex.Message}");
|
||||
}
|
||||
private static IConfiguration BuildConfiguration()
|
||||
{
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
|
||||
|
||||
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
|
||||
mainWindow.Show();
|
||||
}
|
||||
var builder = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("Core/AppSettings/app.settings.json", optional: false, reloadOnChange: true);
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
// Stoppe UploadQueueService beim Beenden der App
|
||||
try
|
||||
{
|
||||
var uploadQueueService = _serviceProvider?.GetService<UploadQueueService>();
|
||||
if (uploadQueueService != null)
|
||||
{
|
||||
uploadQueueService.StopAsync().Wait(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Fehler beim Stoppen des UploadQueueService: {ex.Message}");
|
||||
}
|
||||
if (environment == "Development")
|
||||
builder.AddJsonFile("Core/AppSettings/app.settings.dev.json", optional: true, reloadOnChange: true);
|
||||
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
|
||||
private static void RegisterServices(ServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton<Logger>();
|
||||
services.AddSingleton<AppSettingsService>();
|
||||
services.AddSingleton<PictureGalleryService>();
|
||||
services.AddSingleton<CameraService>();
|
||||
services.AddSingleton<PhotoPrismAuthService>();
|
||||
services.AddSingleton<PhotoPrismUploadService>();
|
||||
services.AddSingleton<PhotoPrismUploadQueueService>();
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddTransient<MainWindow>();
|
||||
|
||||
RegisterCameraApi(services, configuration);
|
||||
}
|
||||
|
||||
|
||||
private static void RegisterCameraApi(ServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var useMockCamera = bool.Parse(configuration["AppSettings:UseMockCamera"] ?? "false");
|
||||
try
|
||||
{
|
||||
ICanonAPI canonApi = useMockCamera ? new CanonAPIMock() : new CanonAPI();
|
||||
services.AddSingleton(canonApi);
|
||||
}
|
||||
catch (DllNotFoundException ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
$"EDSDK konnte nicht geladen werden. Verwende Mock-Kamera.\n\nFehler: {ex.Message}",
|
||||
"DLL nicht gefunden", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
|
||||
services.AddSingleton<ICanonAPI>(new CanonAPIMock());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void StartBackgroundServices()
|
||||
{
|
||||
try
|
||||
{
|
||||
var logger = _serviceProvider!.GetRequiredService<Logger>();
|
||||
var uploadQueueService = _serviceProvider.GetRequiredService<PhotoPrismUploadQueueService>();
|
||||
uploadQueueService.Start();
|
||||
uploadQueueService.ScanAndQueueFailedUploads();
|
||||
logger.Info("PhotoPrism UploadQueueService gestartet");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error starting background services: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ public class AppSettingsService
|
||||
|
||||
public string? RemoteServerApiKey => configuration["LoggingSettings:RemoteServerApiKey"];
|
||||
|
||||
// Lychee Upload Settings
|
||||
// Lychee Upload Settings (deprecated - wird durch PhotoPrism ersetzt)
|
||||
public string? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"];
|
||||
|
||||
public string? LycheeUsername => configuration["LycheeSettings:Username"];
|
||||
@ -81,4 +81,17 @@ public class AppSettingsService
|
||||
public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"];
|
||||
|
||||
public bool LycheeAutoUploadEnabled => bool.Parse(configuration["LycheeSettings:AutoUploadEnabled"] ?? "false");
|
||||
|
||||
// PhotoPrism Upload Settings
|
||||
public string? PhotoPrismApiUrl => configuration["PhotoPrismSettings:ApiUrl"];
|
||||
|
||||
public string? PhotoPrismApiKey => configuration["PhotoPrismSettings:ApiKey"];
|
||||
|
||||
public string? PhotoPrismUsername => configuration["PhotoPrismSettings:Username"];
|
||||
|
||||
public string? PhotoPrismPassword => configuration["PhotoPrismSettings:Password"];
|
||||
|
||||
public string? PhotoPrismAlbumShareUrl => configuration["PhotoPrismSettings:AlbumShareUrl"];
|
||||
|
||||
public bool PhotoPrismAutoUploadEnabled => bool.Parse(configuration["PhotoPrismSettings:AutoUploadEnabled"] ?? "false");
|
||||
}
|
||||
@ -38,13 +38,14 @@
|
||||
"LogLevel": "Debug",
|
||||
"LogDirectory": "Logs",
|
||||
"RemoteServerUrl": "https://log.grimma-fotobox.de",
|
||||
"RemoteServerApiKey": "nhnVql3QNgoAxvDWmNyU"
|
||||
"RemoteServerApiKey": "TxycJNPgpXJw7SJcFsj4"
|
||||
},
|
||||
"LycheeSettings": {
|
||||
"PhotoPrismSettings": {
|
||||
"ApiUrl": "https://gallery.grimma-fotobox.de",
|
||||
"Username": "itob",
|
||||
"ApiKey": "2ZNtZf-NvkIor-PEBJDa-vGdus5",
|
||||
"Username": "admin",
|
||||
"Password": "VfVyqal&Nv8U&P",
|
||||
"DefaultAlbumId": "ukPbEHxdV_q1BOpIVKah7TUR",
|
||||
"AlbumShareUrl": "https://gallery.grimma-fotobox.de/s/kgcf48say8/kunde-01",
|
||||
"AutoUploadEnabled": true
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
|
||||
@ -38,13 +38,14 @@
|
||||
"LogLevel": "Information",
|
||||
"LogDirectory": "Logs",
|
||||
"RemoteServerUrl": "https://log.grimma-fotobox.de",
|
||||
"RemoteServerApiKey": "8rjvr0zZmceuFZMYydKU"
|
||||
"RemoteServerApiKey": "lEK0Yy3ncfC1ovyEHm3k"
|
||||
},
|
||||
"LycheeSettings": {
|
||||
"PhotoPrismSettings": {
|
||||
"ApiUrl": "https://gallery.grimma-fotobox.de",
|
||||
"Username": "itob",
|
||||
"ApiKey": "iYQlKo-KLXYWD-yIdrJP-1cHo6f",
|
||||
"Username": "admin",
|
||||
"Password": "VfVyqal&Nv8U&P",
|
||||
"DefaultAlbumId": "ukPbEHxdV_q1BOpIVKah7TUR",
|
||||
"AlbumShareUrl": "https://gallery.grimma-fotobox.de/s/3hvmc1uk4f/cambooth-prod",
|
||||
"AutoUploadEnabled": true
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
|
||||
@ -15,46 +15,6 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace CamBooth.App.Core.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Custom HTTP Client für Serilog HTTP Sink mit API-Key Support
|
||||
/// </summary>
|
||||
public class SeqHttpClient : IHttpClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _apiKey;
|
||||
|
||||
public SeqHttpClient(string apiKey = "")
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
_apiKey = apiKey;
|
||||
|
||||
// Setze API-Key Header, falls vorhanden
|
||||
if (!string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Seq-Api-Key", _apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
public void Configure(IConfiguration configuration)
|
||||
{
|
||||
// Konfiguration vom HTTP Sink - nicht nötig für unseren Use-Case
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> PostAsync(string requestUri, Stream contentStream, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var content = new StreamContent(contentStream))
|
||||
{
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
return await _httpClient.PostAsync(requestUri, content, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class Logger : IDisposable
|
||||
{
|
||||
private readonly Serilog.Core.Logger _serilogLogger;
|
||||
|
||||
@ -4,7 +4,7 @@ using System.Windows;
|
||||
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.LycheeUpload;
|
||||
using CamBooth.App.Features.PhotoPrismUpload;
|
||||
using CamBooth.App.Features.PictureGallery;
|
||||
|
||||
using EOSDigital.API;
|
||||
@ -14,415 +14,284 @@ namespace CamBooth.App.Features.Camera;
|
||||
|
||||
public class CameraService : IDisposable
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
|
||||
private readonly LycheeUploadService _lycheeUploadService;
|
||||
|
||||
private readonly UploadQueueService _uploadQueueService;
|
||||
|
||||
private readonly ICanonAPI _APIHandler;
|
||||
|
||||
private CameraValue[] AvList;
|
||||
|
||||
private int BulbTime = 30;
|
||||
|
||||
private List<ICamera> CamList;
|
||||
|
||||
private int ErrCount;
|
||||
|
||||
private object ErrLock = new();
|
||||
|
||||
private bool IsInit;
|
||||
|
||||
private CameraValue[] ISOList;
|
||||
|
||||
public ICamera _mainCamera;
|
||||
|
||||
private CameraValue[] TvList;
|
||||
|
||||
|
||||
public CameraService(Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PictureGalleryService pictureGalleryService,
|
||||
LycheeUploadService lycheeUploadService,
|
||||
UploadQueueService uploadQueueService,
|
||||
ICamera mainCamera,
|
||||
ICanonAPI APIHandler)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._appSettings = appSettings;
|
||||
this._pictureGalleryService = pictureGalleryService;
|
||||
this._lycheeUploadService = lycheeUploadService;
|
||||
this._uploadQueueService = uploadQueueService;
|
||||
this._mainCamera = mainCamera;
|
||||
this._APIHandler = APIHandler;
|
||||
try
|
||||
{
|
||||
this.IsInit = true;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
this.ReportError("Canon DLLs not found!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary> Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
this.CloseSession();
|
||||
this.IsInit = false;
|
||||
this._APIHandler.Dispose();
|
||||
this._mainCamera.Dispose();
|
||||
}
|
||||
|
||||
|
||||
public void ConnectCamera()
|
||||
{
|
||||
ErrorHandler.SevereErrorHappened += this.ErrorHandler_SevereErrorHappened;
|
||||
ErrorHandler.NonSevereErrorHappened += this.ErrorHandler_NonSevereErrorHappened;
|
||||
|
||||
try
|
||||
{
|
||||
this.RefreshCamera();
|
||||
|
||||
// Retry logic for camera detection (some systems need time to initialize)
|
||||
int maxRetries = 3;
|
||||
int retryDelay = 500; // milliseconds
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
if (this.CamList != null && this.CamList.Any())
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries - 1)
|
||||
{
|
||||
this._logger.Info($"No cameras found on attempt {attempt + 1}/{maxRetries}, retrying in {retryDelay}ms...");
|
||||
System.Threading.Thread.Sleep(retryDelay);
|
||||
this.RefreshCamera();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.CamList == null || !this.CamList.Any())
|
||||
{
|
||||
this.ReportError("No cameras / devices found");
|
||||
throw new InvalidOperationException("No cameras / devices found after multiple attempts");
|
||||
}
|
||||
|
||||
this._logger.Info($"Found {this.CamList.Count} camera(s)");
|
||||
string cameraDeviceNames = string.Join(", ", this.CamList.Select(cam => cam.DeviceName));
|
||||
this._logger.Info(cameraDeviceNames);
|
||||
|
||||
// Update _mainCamera reference to the freshly detected camera
|
||||
this._mainCamera = this.CamList[0];
|
||||
this._logger.Info($"Selected camera: {this._mainCamera.DeviceName}");
|
||||
|
||||
this.OpenSession();
|
||||
this.SetSettingSaveToComputer();
|
||||
this.StarLiveView();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Error connecting camera: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void SetSettingSaveToComputer()
|
||||
{
|
||||
this._mainCamera.SetSetting(PropertyID.SaveTo, (int)SaveTo.Host);
|
||||
this._mainCamera.SetCapacity(4096, int.MaxValue);
|
||||
}
|
||||
|
||||
|
||||
public void CloseSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this._mainCamera != null && this._mainCamera.SessionOpen)
|
||||
{
|
||||
this._mainCamera.CloseSession();
|
||||
this._logger.Info("Camera session closed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Error closing camera session: {ex.Message}");
|
||||
}
|
||||
|
||||
// AvCoBox.Items.Clear();
|
||||
// TvCoBox.Items.Clear();
|
||||
// ISOCoBox.Items.Clear();
|
||||
// SettingsGroupBox.IsEnabled = false;
|
||||
// LiveViewGroupBox.IsEnabled = false;
|
||||
// SessionButton.Content = "Open Session";
|
||||
// SessionLabel.Content = "No open session";
|
||||
// StarLVButton.Content = "Start LV";
|
||||
}
|
||||
|
||||
|
||||
private void RefreshCamera()
|
||||
{
|
||||
// CameraListBox.Items.Clear();
|
||||
this.CamList = this._APIHandler.GetCameraList();
|
||||
|
||||
// foreach (Camera cam in CamList) CameraListBox.Items.Add(cam.DeviceName);
|
||||
// if (_mainCamera?.SessionOpen == true) CameraListBox.SelectedIndex = CamList.FindIndex(t => t.ID == _mainCamera.ID);
|
||||
// else if (CamList.Count > 0) CameraListBox.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
|
||||
private void OpenSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this._mainCamera == null)
|
||||
{
|
||||
throw new InvalidOperationException("Camera reference is null. Make sure ConnectCamera is called first.");
|
||||
}
|
||||
|
||||
// Check if session is already open
|
||||
if (this._mainCamera.SessionOpen)
|
||||
{
|
||||
this._logger.Info($"Camera session already open for {this._mainCamera.DeviceName}");
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.Info($"Opening session for camera: {this._mainCamera.DeviceName}");
|
||||
this._mainCamera.OpenSession();
|
||||
this._logger.Info("Camera session opened successfully");
|
||||
|
||||
//_mainCamera.ProgressChanged += MainCamera_ProgressChanged;
|
||||
this._mainCamera.StateChanged += this.MainCamera_StateChanged;
|
||||
this._mainCamera.DownloadReady += this.MainCamera_DownloadReady;
|
||||
|
||||
//SessionLabel.Content = _mainCamera.DeviceName;
|
||||
this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av);
|
||||
this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv);
|
||||
this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Failed to open camera session: {ex.Message}");
|
||||
this.ReportError($"Failed to open camera session: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
// foreach (var Av in AvList) AvCoBox.Items.Add(Av.StringValue);
|
||||
// foreach (var Tv in TvList) TvCoBox.Items.Add(Tv.StringValue);
|
||||
// foreach (var ISO in ISOList) ISOCoBox.Items.Add(ISO.StringValue);
|
||||
// AvCoBox.SelectedIndex = AvCoBox.Items.IndexOf(AvValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.Av)).StringValue);
|
||||
// TvCoBox.SelectedIndex = TvCoBox.Items.IndexOf(TvValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.Tv)).StringValue);
|
||||
// ISOCoBox.SelectedIndex = ISOCoBox.Items.IndexOf(ISOValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.ISO)).StringValue);
|
||||
// SettingsGroupBox.IsEnabled = true;
|
||||
// LiveViewGroupBox.IsEnabled = true;
|
||||
}
|
||||
|
||||
|
||||
private void ReportError(string message)
|
||||
{
|
||||
this._logger.Info(message);
|
||||
}
|
||||
|
||||
|
||||
private void StarLiveView()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!this._mainCamera.IsLiveViewOn)
|
||||
{
|
||||
this._mainCamera.StartLiveView();
|
||||
}
|
||||
else
|
||||
{
|
||||
this._mainCamera.StopLiveView();
|
||||
|
||||
//LVCanvas.Background = Brushes.LightGray;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void TakePhoto()
|
||||
{
|
||||
try
|
||||
{
|
||||
this._mainCamera.TakePhoto();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
public async Task PrepareFocusAsync(int focusTimeoutMs = 1500)
|
||||
{
|
||||
if (this._mainCamera is not EOSDigital.API.Camera sdkCamera)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
var focusCompleted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void FocusStateChanged(EOSDigital.API.Camera sender, StateEventID eventId, int parameter)
|
||||
{
|
||||
if (eventId == StateEventID.AfResult)
|
||||
{
|
||||
focusCompleted.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
sdkCamera.StateChanged += FocusStateChanged;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => sdkCamera.SendCommand(CameraCommand.PressShutterButton, (int)ShutterButton.Halfway));
|
||||
var completedTask = await Task.WhenAny(focusCompleted.Task, Task.Delay(focusTimeoutMs));
|
||||
if (completedTask != focusCompleted.Task)
|
||||
{
|
||||
this._logger.Info("Autofocus timeout reached, continuing with countdown.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Run(() => sdkCamera.SendCommand(CameraCommand.PressShutterButton, (int)ShutterButton.OFF));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
|
||||
sdkCamera.StateChanged -= FocusStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#region API Events
|
||||
|
||||
// private void APIHandler_CameraAdded(CanonAPI sender)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// ReportError(ex.Message, false);
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
private void MainCamera_StateChanged(EOSDigital.API.Camera sender, StateEventID eventID, int parameter)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (eventID == StateEventID.Shutdown && this.IsInit)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => this.CloseSession());
|
||||
|
||||
//Dispatcher.Invoke((Action)delegate { CloseSession(); });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// private void MainCamera_ProgressChanged(object sender, int progress)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// //MainProgressBar.Dispatcher.Invoke((Action)delegate { MainProgressBar.Value = progress; });
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// ReportError(ex.Message, false);
|
||||
// }
|
||||
// }
|
||||
|
||||
// private void MainCamera_LiveViewUpdated(EOSDigital.API.Camera sender, Stream img)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// using (WrapStream s = new WrapStream(img))
|
||||
// {
|
||||
// img.Position = 0;
|
||||
// BitmapImage EvfImage = new BitmapImage();
|
||||
// EvfImage.BeginInit();
|
||||
// EvfImage.StreamSource = s;
|
||||
// EvfImage.CacheOption = BitmapCacheOption.OnLoad;
|
||||
// EvfImage.EndInit();
|
||||
// EvfImage.Freeze();
|
||||
// Application.Current.Dispatcher.BeginInvoke(SetImageAction, EvfImage);
|
||||
// }
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// ReportError(ex.Message, false);
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
private void MainCamera_DownloadReady(ICamera sender, IDownloadInfo Info)
|
||||
{
|
||||
this._logger.Info("MainCamera_DownloadReady called");
|
||||
try
|
||||
{
|
||||
Info.FileName = $"img_{Guid.NewGuid().ToString()}.jpg";
|
||||
sender.DownloadFile(Info, this._appSettings.PictureLocation);
|
||||
var savedPhotoPath = Path.Combine(this._appSettings.PictureLocation, Info.FileName);
|
||||
this._logger.Info("Download complete: " + savedPhotoPath);
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() => {
|
||||
this._pictureGalleryService.IncrementNewPhotoCount();
|
||||
this._pictureGalleryService.LoadThumbnailsToCache();
|
||||
});
|
||||
|
||||
// Füge neues Foto zur Upload-Queue hinzu (wenn Auto-Upload aktiviert)
|
||||
this._uploadQueueService.QueueNewPhoto(savedPhotoPath);
|
||||
this._logger.Info($"Foto zur Upload-Queue hinzugefügt: {Info.FileName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ErrorHandler_NonSevereErrorHappened(object sender, ErrorCode ex)
|
||||
{
|
||||
this.ReportError($"SDK Error code: {ex} ({((int)ex).ToString("X")})");
|
||||
}
|
||||
|
||||
|
||||
private void ErrorHandler_SevereErrorHappened(object sender, Exception ex)
|
||||
{
|
||||
this.ReportError(ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly Logger _logger;
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
private readonly PhotoPrismUploadQueueService _photoPrismUploadQueueService;
|
||||
private readonly ICanonAPI _canonApi;
|
||||
|
||||
private ICamera? _mainCamera;
|
||||
private List<ICamera>? _camList;
|
||||
private bool _isConnected;
|
||||
|
||||
/// <summary>Fires whenever the camera delivers a new live-view frame.</summary>
|
||||
public event Action<Stream>? LiveViewUpdated;
|
||||
|
||||
public bool IsConnected => _isConnected && _mainCamera?.SessionOpen == true;
|
||||
|
||||
|
||||
public CameraService(
|
||||
Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PictureGalleryService pictureGalleryService,
|
||||
PhotoPrismUploadQueueService photoPrismUploadQueueService,
|
||||
ICanonAPI canonApi)
|
||||
{
|
||||
_logger = logger;
|
||||
_appSettings = appSettings;
|
||||
_pictureGalleryService = pictureGalleryService;
|
||||
_photoPrismUploadQueueService = photoPrismUploadQueueService;
|
||||
_canonApi = canonApi;
|
||||
}
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
CloseSession();
|
||||
_canonApi.Dispose();
|
||||
_mainCamera?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
public void ConnectCamera()
|
||||
{
|
||||
ErrorHandler.SevereErrorHappened += ErrorHandler_SevereErrorHappened;
|
||||
ErrorHandler.NonSevereErrorHappened += ErrorHandler_NonSevereErrorHappened;
|
||||
|
||||
try
|
||||
{
|
||||
RefreshCameraList();
|
||||
|
||||
const int maxRetries = 3;
|
||||
const int retryDelayMs = 750;
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
if (_camList?.Any() == true) break;
|
||||
|
||||
if (attempt < maxRetries - 1)
|
||||
{
|
||||
System.Threading.Thread.Sleep(retryDelayMs);
|
||||
RefreshCameraList();
|
||||
}
|
||||
}
|
||||
|
||||
if (_camList?.Any() != true)
|
||||
throw new InvalidOperationException("No cameras found after multiple attempts.");
|
||||
|
||||
_mainCamera = _camList[0];
|
||||
_logger.Info($"Camera found: {_mainCamera.DeviceName}");
|
||||
|
||||
OpenSession();
|
||||
SetSaveToComputer();
|
||||
StartLiveView();
|
||||
_isConnected = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error connecting camera: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void CloseSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_mainCamera?.SessionOpen == true)
|
||||
{
|
||||
_mainCamera.CloseSession();
|
||||
_logger.Info("Camera session closed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error closing camera session: {ex.Message}");
|
||||
}
|
||||
|
||||
_isConnected = false;
|
||||
}
|
||||
|
||||
|
||||
public void TakePhoto()
|
||||
{
|
||||
if (_mainCamera == null) throw new InvalidOperationException("Camera not connected.");
|
||||
_mainCamera.TakePhoto();
|
||||
}
|
||||
|
||||
|
||||
public async Task PrepareFocusAsync(int focusTimeoutMs = 1500)
|
||||
{
|
||||
if (_mainCamera is not EOSDigital.API.Camera sdkCamera)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
var focusCompleted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void FocusStateChanged(EOSDigital.API.Camera sender, StateEventID eventId, int parameter)
|
||||
{
|
||||
if (eventId == StateEventID.AfResult)
|
||||
focusCompleted.TrySetResult(true);
|
||||
}
|
||||
|
||||
sdkCamera.StateChanged += FocusStateChanged;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => sdkCamera.SendCommand(CameraCommand.PressShutterButton, (int)ShutterButton.Halfway));
|
||||
var completed = await Task.WhenAny(focusCompleted.Task, Task.Delay(focusTimeoutMs));
|
||||
if (completed != focusCompleted.Task)
|
||||
_logger.Info("Autofocus timeout reached, continuing.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Run(() => sdkCamera.SendCommand(CameraCommand.PressShutterButton, (int)ShutterButton.OFF));
|
||||
}
|
||||
catch (Exception ex) { _logger.Error(ex.Message); }
|
||||
|
||||
sdkCamera.StateChanged -= FocusStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void RefreshCameraList() => _camList = _canonApi.GetCameraList();
|
||||
|
||||
|
||||
private void OpenSession()
|
||||
{
|
||||
if (_mainCamera == null)
|
||||
throw new InvalidOperationException("Camera reference is null.");
|
||||
|
||||
if (_mainCamera.SessionOpen)
|
||||
{
|
||||
_logger.Info($"Session already open for {_mainCamera.DeviceName}");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"Opening session for: {_mainCamera.DeviceName}");
|
||||
|
||||
const int maxRetries = 3;
|
||||
const int retryDelayMs = 1000;
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
_mainCamera.OpenSession();
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxRetries - 1 && IsSessionNotOpenError(ex))
|
||||
{
|
||||
_logger.Warning($"OpenSession attempt {attempt + 1}/{maxRetries} failed, retrying...");
|
||||
System.Threading.Thread.Sleep(retryDelayMs);
|
||||
RefreshCameraList();
|
||||
if (_camList?.Any() == true)
|
||||
_mainCamera = _camList[0];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to open session: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("Session opened successfully");
|
||||
_mainCamera.StateChanged += MainCamera_StateChanged;
|
||||
_mainCamera.DownloadReady += MainCamera_DownloadReady;
|
||||
_mainCamera.LiveViewUpdated += MainCamera_LiveViewUpdated;
|
||||
}
|
||||
|
||||
|
||||
private void SetSaveToComputer()
|
||||
{
|
||||
_mainCamera!.SetSetting(PropertyID.SaveTo, (int)SaveTo.Host);
|
||||
_mainCamera.SetCapacity(4096, int.MaxValue);
|
||||
}
|
||||
|
||||
|
||||
private void StartLiveView()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mainCamera!.IsLiveViewOn)
|
||||
_mainCamera.StartLiveView();
|
||||
else
|
||||
_mainCamera.StopLiveView();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static bool IsSessionNotOpenError(Exception ex)
|
||||
{
|
||||
const string errorName = "SESSION_NOT_OPEN";
|
||||
return ex.Message.Contains(errorName) || (ex.InnerException?.Message?.Contains(errorName) ?? false);
|
||||
}
|
||||
|
||||
|
||||
#region Camera event handlers
|
||||
|
||||
private void MainCamera_LiveViewUpdated(ICamera sender, Stream img) =>
|
||||
LiveViewUpdated?.Invoke(img);
|
||||
|
||||
|
||||
private void MainCamera_StateChanged(EOSDigital.API.Camera sender, StateEventID eventID, int parameter)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (eventID == StateEventID.Shutdown && _isConnected)
|
||||
Application.Current.Dispatcher.Invoke(CloseSession);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void MainCamera_DownloadReady(ICamera sender, IDownloadInfo info)
|
||||
{
|
||||
_logger.Info("Download ready");
|
||||
try
|
||||
{
|
||||
info.FileName = $"img_{Guid.NewGuid()}.jpg";
|
||||
sender.DownloadFile(info, _appSettings.PictureLocation);
|
||||
var savedPath = Path.Combine(_appSettings.PictureLocation!, info.FileName);
|
||||
_logger.Info($"Download complete: {savedPath}");
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
_pictureGalleryService.IncrementNewPhotoCount();
|
||||
_pictureGalleryService.LoadThumbnailsToCache();
|
||||
});
|
||||
|
||||
_photoPrismUploadQueueService.QueueNewPhoto(savedPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ErrorHandler_NonSevereErrorHappened(object sender, ErrorCode ex) =>
|
||||
_logger.Error($"SDK Error: {ex} (0x{(int)ex:X})");
|
||||
|
||||
|
||||
private void ErrorHandler_SevereErrorHappened(object sender, Exception ex) =>
|
||||
_logger.Error(ex.Message);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.Camera;
|
||||
|
||||
@ -12,87 +11,69 @@ using EOSDigital.API;
|
||||
|
||||
namespace CamBooth.App.Features.LiveView;
|
||||
|
||||
public partial class LiveViewPage : Page
|
||||
public partial class LiveViewPage : Page, IDisposable
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
|
||||
private readonly CameraService _cameraService;
|
||||
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly ImageBrush bgbrush = new();
|
||||
|
||||
private readonly Action<BitmapImage> SetImageAction;
|
||||
private readonly CameraService _cameraService;
|
||||
private readonly Logger _logger;
|
||||
private readonly ImageBrush _bgBrush = new();
|
||||
|
||||
|
||||
public LiveViewPage(Logger logger, AppSettingsService appSettings, CameraService cameraService)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._appSettings = appSettings;
|
||||
this._cameraService = cameraService;
|
||||
this.InitializeComponent();
|
||||
this.SetImageAction = img => { this.bgbrush.ImageSource = img; };
|
||||
|
||||
// Configure the image brush
|
||||
this.bgbrush.Stretch = Stretch.UniformToFill;
|
||||
this.bgbrush.AlignmentX = AlignmentX.Center;
|
||||
this.bgbrush.AlignmentY = AlignmentY.Center;
|
||||
|
||||
this.LVCanvas.Background = this.bgbrush;
|
||||
|
||||
// Apply horizontal flip on the Canvas using RenderTransform
|
||||
TransformGroup transformGroup = new();
|
||||
transformGroup.Children.Add(new ScaleTransform { ScaleX = -1, ScaleY = 1 });
|
||||
transformGroup.Children.Add(new TranslateTransform { X = 1, Y = 0 });
|
||||
this.LVCanvas.RenderTransform = transformGroup;
|
||||
this.LVCanvas.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||
|
||||
try
|
||||
{
|
||||
cameraService.ConnectCamera();
|
||||
|
||||
// Verify that camera session is open before subscribing to events
|
||||
if (cameraService._mainCamera != null && cameraService._mainCamera.SessionOpen)
|
||||
{
|
||||
cameraService._mainCamera.LiveViewUpdated += this.MainCamera_OnLiveViewUpdated;
|
||||
this._logger.Info("LiveViewPage initialized successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
this._logger.Error("Camera session is not open after connection attempt");
|
||||
throw new InvalidOperationException("Camera session failed to open");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Failed to initialize LiveViewPage: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
public LiveViewPage(Logger logger, CameraService cameraService)
|
||||
{
|
||||
_logger = logger;
|
||||
_cameraService = cameraService;
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
_bgBrush.Stretch = Stretch.UniformToFill;
|
||||
_bgBrush.AlignmentX = AlignmentX.Center;
|
||||
_bgBrush.AlignmentY = AlignmentY.Center;
|
||||
LVCanvas.Background = _bgBrush;
|
||||
|
||||
var transformGroup = new TransformGroup();
|
||||
transformGroup.Children.Add(new ScaleTransform { ScaleX = -1, ScaleY = 1 });
|
||||
transformGroup.Children.Add(new TranslateTransform { X = 1, Y = 0 });
|
||||
LVCanvas.RenderTransform = transformGroup;
|
||||
LVCanvas.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||
|
||||
try
|
||||
{
|
||||
cameraService.ConnectCamera();
|
||||
cameraService.LiveViewUpdated += OnLiveViewUpdated;
|
||||
_logger.Info("LiveViewPage initialized successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to initialize LiveViewPage: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void MainCamera_OnLiveViewUpdated(ICamera sender, Stream img)
|
||||
{
|
||||
try
|
||||
{
|
||||
using WrapStream s = new(img);
|
||||
img.Position = 0;
|
||||
BitmapImage EvfImage = new();
|
||||
EvfImage.BeginInit();
|
||||
EvfImage.StreamSource = s;
|
||||
EvfImage.CacheOption = BitmapCacheOption.OnLoad;
|
||||
EvfImage.EndInit();
|
||||
EvfImage.Freeze();
|
||||
Application.Current.Dispatcher.BeginInvoke(this.SetImageAction, EvfImage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._cameraService.Dispose();
|
||||
}
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
_cameraService.LiveViewUpdated -= OnLiveViewUpdated;
|
||||
_cameraService.Dispose();
|
||||
}
|
||||
|
||||
|
||||
private void OnLiveViewUpdated(Stream img)
|
||||
{
|
||||
try
|
||||
{
|
||||
using WrapStream s = new(img);
|
||||
img.Position = 0;
|
||||
var evfImage = new BitmapImage();
|
||||
evfImage.BeginInit();
|
||||
evfImage.StreamSource = s;
|
||||
evfImage.CacheOption = BitmapCacheOption.OnLoad;
|
||||
evfImage.EndInit();
|
||||
evfImage.Freeze();
|
||||
Application.Current.Dispatcher.BeginInvoke(() => _bgBrush.ImageSource = evfImage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,42 +1,70 @@
|
||||
<UserControl x:Class="CamBooth.App.Features.LiveView.TimerControlRectangleAnimation"
|
||||
<UserControl x:Class="CamBooth.App.Features.LiveView.TimerControlRectangleAnimation"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace:CamBooth.App.Features.LiveView"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="100" d:DesignWidth="1350">
|
||||
d:DesignHeight="900" d:DesignWidth="1600">
|
||||
<Grid>
|
||||
<!-- Hintergrund für den Timer -->
|
||||
<Border CornerRadius="10" Background="Black" Padding="0" Opacity="0" x:Name="TimerContainer">
|
||||
<Border x:Name="TimerContainer" Opacity="0">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- Fortschrittsanzeige -->
|
||||
<Grid Height="75" Width="1350" Background="Gray" Margin="0,0,0,0">
|
||||
<Rectangle x:Name="ProgressBar"
|
||||
Fill="#4CAF50"
|
||||
Height="75"
|
||||
HorizontalAlignment="Left"/>
|
||||
<TextBlock x:Name="InstructionText"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="72"
|
||||
FontWeight="Bold"
|
||||
Foreground="White">Lächeln!</TextBlock>
|
||||
|
||||
<!-- Circular countdown ring -->
|
||||
<Grid Width="360" Height="360">
|
||||
|
||||
<!-- Track ring -->
|
||||
<Ellipse Stroke="#1AFFFFFF"
|
||||
StrokeThickness="12"
|
||||
Width="320" Height="320"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Progress ring: StrokeDashArray[0] is animated from full to 0 -->
|
||||
<Ellipse x:Name="CountdownRing"
|
||||
Stroke="#D4AF37"
|
||||
StrokeThickness="12"
|
||||
Width="320" Height="320"
|
||||
StrokeDashArray="83.78 83.78"
|
||||
StrokeDashOffset="0"
|
||||
StrokeStartLineCap="Round"
|
||||
StrokeEndLineCap="Round"
|
||||
RenderTransformOrigin="0.5,0.5"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse.RenderTransform>
|
||||
<!-- Rotate so the ring starts at 12 o'clock -->
|
||||
<RotateTransform Angle="-90"/>
|
||||
</Ellipse.RenderTransform>
|
||||
</Ellipse>
|
||||
|
||||
<!-- Countdown number -->
|
||||
<TextBlock x:Name="CountdownNumber"
|
||||
FontSize="180"
|
||||
FontWeight="Black"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
RenderTransformOrigin="0.5,0.5"
|
||||
Text="5">
|
||||
<TextBlock.RenderTransform>
|
||||
<ScaleTransform x:Name="CountdownNumberScale" ScaleX="1" ScaleY="1"/>
|
||||
</TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ~1~ Countdown-Anzeige @1@ -->
|
||||
<!-- <TextBlock x:Name="TimerText" -->
|
||||
<!-- FontSize="32" -->
|
||||
<!-- Foreground="White" -->
|
||||
<!-- HorizontalAlignment="Center" -->
|
||||
<!-- Text="00:00" -->
|
||||
<!-- Margin="0,10"/> -->
|
||||
<!-- ~1~ Status-Text @1@ -->
|
||||
<!-- <TextBlock x:Name="StatusText" -->
|
||||
<!-- FontSize="14" -->
|
||||
<!-- Foreground="Gray" -->
|
||||
<!-- HorizontalAlignment="Center" -->
|
||||
<!-- Text="Timer läuft..."/> -->
|
||||
<!-- Instruction text below the ring -->
|
||||
<TextBlock x:Name="InstructionText"
|
||||
FontSize="46"
|
||||
FontWeight="Bold"
|
||||
Foreground="#D4AF37"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
MaxWidth="900"
|
||||
Margin="0,24,0,0"
|
||||
Text="Lächeln! 😊"/>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@ -1,162 +1,113 @@
|
||||
using System.Windows;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Shapes;
|
||||
using System.Windows.Threading;
|
||||
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CamBooth.App.Features.LiveView;
|
||||
|
||||
public partial class TimerControlRectangleAnimation : UserControl
|
||||
{
|
||||
public delegate void TimerElapsedEventHandler();
|
||||
// Ellipse diameter=320, StrokeThickness=12 → circumference = π*320/12 ≈ 83.78 dash-units
|
||||
private static readonly double RingFullDashUnits = Math.PI * 320.0 / 12.0;
|
||||
|
||||
public static event TimerElapsedEventHandler OnTimerEllapsed;
|
||||
public static event Action? OnTimerEllapsed;
|
||||
|
||||
private DispatcherTimer _timer;
|
||||
private readonly DispatcherTimer _ticker = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly Random _random = new();
|
||||
|
||||
private int _remainingTime; // Zeit in Sekunden
|
||||
private int _remainingTime;
|
||||
private double _totalDuration;
|
||||
|
||||
private double _totalDuration; // Gesamtzeit in Sekunden
|
||||
|
||||
private Storyboard _progressBarAnimation;
|
||||
|
||||
private Random _random = new Random();
|
||||
|
||||
private List<string> _photoInstructions = new List<string>
|
||||
{
|
||||
"Lächeln! 😊",
|
||||
"Hasenohren machen! 🐰",
|
||||
"Zunge rausstrecken! 👅",
|
||||
"Grimasse ziehen! 😝",
|
||||
"Daumen hoch! 👍",
|
||||
"Peace-Zeichen! ✌️",
|
||||
"Lustig gucken! 🤪",
|
||||
"Crazy Face! 🤯",
|
||||
"Küsschen! 😘",
|
||||
"Winken! 👋",
|
||||
"Herz mit den Händen! ❤️",
|
||||
"Verrückt sein! 🤪",
|
||||
"Überrascht schauen! 😲",
|
||||
"Cool bleiben! 😎",
|
||||
"Lachen! 😄",
|
||||
"Zähne zeigen! 😁",
|
||||
"Schnute ziehen! 😗",
|
||||
"Augen zukneifen! 😆",
|
||||
"Arm hochstrecken! 🙌",
|
||||
"Gruppe umarmen! 🤗"
|
||||
};
|
||||
private static readonly string[] Instructions =
|
||||
[
|
||||
"Lächeln! 😊", "Hasenohren machen! 🐰", "Zunge rausstrecken! 👅",
|
||||
"Grimasse ziehen! 😝", "Daumen hoch! 👍", "Peace-Zeichen! ✌️",
|
||||
"Lustig gucken! 🤪", "Crazy Face! 🤯", "Küsschen! 😘",
|
||||
"Winken! 👋", "Herz mit den Händen! ❤️", "Überrascht schauen! 😲",
|
||||
"Cool bleiben! 😎", "Lachen! 😄", "Zähne zeigen! 😁",
|
||||
"Schnute ziehen! 😗", "Arm hochstrecken! 🙌", "Gruppe umarmen! 🤗"
|
||||
];
|
||||
|
||||
|
||||
public TimerControlRectangleAnimation()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeTimer();
|
||||
}
|
||||
|
||||
private void InitializeTimer()
|
||||
{
|
||||
_timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_timer.Tick += OnTimerTick;
|
||||
}
|
||||
public TimerControlRectangleAnimation()
|
||||
{
|
||||
InitializeComponent();
|
||||
_ticker.Tick += OnTick;
|
||||
}
|
||||
|
||||
|
||||
private void OnTimerTick(object sender, EventArgs e)
|
||||
{
|
||||
if (_remainingTime > 0)
|
||||
{
|
||||
_remainingTime--;
|
||||
public void StartTimer(int durationInSeconds)
|
||||
{
|
||||
_totalDuration = durationInSeconds;
|
||||
_remainingTime = durationInSeconds;
|
||||
|
||||
// TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss");
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer.Stop();
|
||||
CountdownNumber.Text = _remainingTime.ToString();
|
||||
InstructionText.Text = Instructions[_random.Next(Instructions.Length)];
|
||||
|
||||
// StatusText.Text = "Zeit abgelaufen!";
|
||||
StopProgressBarAnimation();
|
||||
|
||||
OnTimerEllapsed?.Invoke();
|
||||
}
|
||||
}
|
||||
// Reset ring offset so ring appears full
|
||||
CountdownRing.StrokeDashOffset = 0;
|
||||
CountdownRing.StrokeDashArray = new DoubleCollection { RingFullDashUnits, RingFullDashUnits };
|
||||
|
||||
FadeIn();
|
||||
StartRingAnimation();
|
||||
_ticker.Start();
|
||||
}
|
||||
|
||||
|
||||
public void StartTimer(int durationInSeconds)
|
||||
{
|
||||
_totalDuration = durationInSeconds;
|
||||
_remainingTime = durationInSeconds;
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
_remainingTime--;
|
||||
|
||||
// TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss");
|
||||
// StatusText.Text = "Timer läuft...";
|
||||
|
||||
// Show initial random instruction
|
||||
ShowRandomInstruction();
|
||||
|
||||
_timer.Start();
|
||||
|
||||
StartProgressBarAnimation();
|
||||
ShowTimer();
|
||||
}
|
||||
if (_remainingTime > 0)
|
||||
{
|
||||
CountdownNumber.Text = _remainingTime.ToString();
|
||||
AnimateNumberPop();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ticker.Stop();
|
||||
CountdownNumber.Text = "0";
|
||||
AnimateNumberPop();
|
||||
StopRingAnimation();
|
||||
OnTimerEllapsed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void StopTimer()
|
||||
{
|
||||
_timer.Stop();
|
||||
StopProgressBarAnimation();
|
||||
|
||||
// StatusText.Text = "Timer angehalten";
|
||||
}
|
||||
private void FadeIn()
|
||||
{
|
||||
TimerContainer.BeginAnimation(OpacityProperty,
|
||||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
|
||||
}
|
||||
|
||||
|
||||
public void ShowTimer()
|
||||
{
|
||||
var fadeInAnimation = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300));
|
||||
TimerContainer.BeginAnimation(OpacityProperty, fadeInAnimation);
|
||||
}
|
||||
private void StartRingAnimation()
|
||||
{
|
||||
// StrokeDashOffset IS a DependencyProperty — animate directly, no Storyboard needed.
|
||||
// Pattern: dash=C, gap=C. Offset 0 → full ring; offset C → empty ring.
|
||||
CountdownRing.BeginAnimation(Shape.StrokeDashOffsetProperty,
|
||||
new DoubleAnimation(0, RingFullDashUnits,
|
||||
new Duration(TimeSpan.FromSeconds(_totalDuration)))
|
||||
{
|
||||
FillBehavior = FillBehavior.HoldEnd
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public void HideTimer()
|
||||
{
|
||||
var fadeOutAnimation = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
|
||||
TimerContainer.BeginAnimation(OpacityProperty, fadeOutAnimation);
|
||||
}
|
||||
private void StopRingAnimation() =>
|
||||
CountdownRing.BeginAnimation(Shape.StrokeDashOffsetProperty, null);
|
||||
|
||||
|
||||
private void StartProgressBarAnimation()
|
||||
{
|
||||
// Fortschrittsbalken-Animation
|
||||
_progressBarAnimation = new Storyboard();
|
||||
var widthAnimation = new DoubleAnimation
|
||||
{
|
||||
From = 1350, // Volle Breite des Containers
|
||||
To = 0, // Endet bei 0 Breite
|
||||
Duration = TimeSpan.FromSeconds(_totalDuration),
|
||||
FillBehavior = FillBehavior.Stop
|
||||
};
|
||||
|
||||
Storyboard.SetTarget(widthAnimation, ProgressBar);
|
||||
Storyboard.SetTargetProperty(widthAnimation, new PropertyPath(Rectangle.WidthProperty));
|
||||
|
||||
_progressBarAnimation.Children.Add(widthAnimation);
|
||||
_progressBarAnimation.Begin();
|
||||
}
|
||||
|
||||
|
||||
private void StopProgressBarAnimation()
|
||||
{
|
||||
_progressBarAnimation?.Stop();
|
||||
}
|
||||
|
||||
private void ShowRandomInstruction()
|
||||
{
|
||||
if (_photoInstructions.Count > 0)
|
||||
{
|
||||
int randomIndex = _random.Next(_photoInstructions.Count);
|
||||
InstructionText.Text = _photoInstructions[randomIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
private void AnimateNumberPop()
|
||||
{
|
||||
var easing = new BackEase { Amplitude = 0.4, EasingMode = EasingMode.EaseOut };
|
||||
CountdownNumberScale.ScaleX = 1.4;
|
||||
CountdownNumberScale.ScaleY = 1.4;
|
||||
CountdownNumberScale.BeginAnimation(ScaleTransform.ScaleXProperty,
|
||||
new DoubleAnimation(1.4, 1.0, TimeSpan.FromMilliseconds(450)) { EasingFunction = easing });
|
||||
CountdownNumberScale.BeginAnimation(ScaleTransform.ScaleYProperty,
|
||||
new DoubleAnimation(1.4, 1.0, TimeSpan.FromMilliseconds(450)) { EasingFunction = easing });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,236 +0,0 @@
|
||||
# Lychee Upload Feature - Architektur
|
||||
|
||||
## Komponenten-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CamBooth App │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ MainWindow │───────>│ LycheeUploadService │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Camera │ │ + AuthenticateAsync() │ │
|
||||
│ │ - Photos │ │ + UploadImageAsync() │ │
|
||||
│ │ - Timer │ │ + UploadImagesAsync() │ │
|
||||
│ └──────┬───────┘ │ + CreateAlbumAsync() │ │
|
||||
│ │ │ + LogoutAsync() │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ - HttpClient │ │
|
||||
│ │ │ - AuthToken │ │
|
||||
│ │ └────────────┬─────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ v │ │
|
||||
│ ┌──────────────────┐ │ │
|
||||
│ │ LycheeUploadPage │<────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ - Connect UI │ ┌──────────────────────┐ │
|
||||
│ │ - Upload UI │ │ AppSettingsService │ │
|
||||
│ │ - Status Log │<────────│ │ │
|
||||
│ │ - Progress Bar │ │ - LycheeApiUrl │ │
|
||||
│ └──────────────────┘ │ - LycheeUsername │ │
|
||||
│ │ - LycheePassword │ │
|
||||
│ │ - DefaultAlbumId │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS/HTTP
|
||||
v
|
||||
┌──────────────────────────┐
|
||||
│ Lychee API Server │
|
||||
│ │
|
||||
│ POST /api/Session::login│
|
||||
│ POST /api/Photo::add │
|
||||
│ POST /api/Album::add │
|
||||
│ POST /api/Session::logout│
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Datenfluss
|
||||
|
||||
### 1. Authentifizierung
|
||||
|
||||
```
|
||||
User Action
|
||||
│
|
||||
v
|
||||
LycheeUploadService.AuthenticateAsync()
|
||||
│
|
||||
├─> Read Config (ApiUrl, Username, Password)
|
||||
│
|
||||
├─> POST /api/Session::login
|
||||
│ └─> { username, password }
|
||||
│
|
||||
├─> Receive Response
|
||||
│ └─> Extract Cookie
|
||||
│
|
||||
└─> Store Cookie in HttpClient Headers
|
||||
└─> Set IsAuthenticated = true
|
||||
```
|
||||
|
||||
### 2. Foto-Upload (Manuell)
|
||||
|
||||
```
|
||||
User clicks "Upload Last Photo"
|
||||
│
|
||||
v
|
||||
LycheeUploadPage.UploadLastPhotoButton_Click()
|
||||
│
|
||||
├─> Get latest photo from PictureLocation
|
||||
│
|
||||
└─> LycheeUploadService.UploadImageAsync(path, albumId)
|
||||
│
|
||||
├─> Check IsAuthenticated
|
||||
│ └─> If false: Call AuthenticateAsync()
|
||||
│
|
||||
├─> Read image file
|
||||
│
|
||||
├─> Create MultipartFormDataContent
|
||||
│ ├─> Add image as ByteArrayContent
|
||||
│ └─> Add albumID (optional)
|
||||
│
|
||||
├─> POST /api/Photo::add
|
||||
│
|
||||
└─> Return success/failure
|
||||
└─> Update UI Log
|
||||
```
|
||||
|
||||
### 3. Foto-Upload (Automatisch)
|
||||
|
||||
```
|
||||
Camera takes photo
|
||||
│
|
||||
v
|
||||
MainWindow.TimerControlRectangleAnimation_OnTimerEllapsed()
|
||||
│
|
||||
├─> CameraService.TakePhoto()
|
||||
│
|
||||
├─> Get path to new photo
|
||||
│
|
||||
└─> If AutoUploadEnabled
|
||||
│
|
||||
└─> Task.Run(async)
|
||||
│
|
||||
└─> LycheeUploadService.UploadImageAsync(path, defaultAlbumId)
|
||||
│
|
||||
├─> Authenticate if needed
|
||||
│
|
||||
├─> Upload image
|
||||
│
|
||||
└─> Log result (Background)
|
||||
```
|
||||
|
||||
### 4. Album-Erstellung
|
||||
|
||||
```
|
||||
User clicks "Create Album"
|
||||
│
|
||||
v
|
||||
LycheeUploadService.CreateAlbumAsync(title)
|
||||
│
|
||||
├─> Check IsAuthenticated
|
||||
│
|
||||
├─> POST /api/Album::add
|
||||
│ └─> { title: "Album Name" }
|
||||
│
|
||||
├─> Parse Response
|
||||
│ └─> Extract album ID
|
||||
│
|
||||
└─> Return album ID
|
||||
└─> Use for subsequent uploads
|
||||
```
|
||||
|
||||
## Konfigurationsfluss
|
||||
|
||||
```
|
||||
app.settings.json
|
||||
│
|
||||
v
|
||||
AppSettingsService
|
||||
│
|
||||
├─> LycheeApiUrl
|
||||
├─> LycheeUsername
|
||||
├─> LycheePassword
|
||||
├─> LycheeDefaultAlbumId
|
||||
└─> LycheeAutoUploadEnabled
|
||||
│
|
||||
v
|
||||
Injected into LycheeUploadService
|
||||
│
|
||||
└─> Used for API calls
|
||||
```
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
```
|
||||
API Call
|
||||
│
|
||||
├─> Success (200 OK)
|
||||
│ └─> Parse Response
|
||||
│ └─> Return Result
|
||||
│
|
||||
└─> Error
|
||||
│
|
||||
├─> Network Error
|
||||
│ └─> Log Error
|
||||
│ └─> Return false/null
|
||||
│
|
||||
├─> Authentication Error (401)
|
||||
│ └─> Log Error
|
||||
│ └─> Trigger Re-Authentication
|
||||
│ └─> Retry Operation
|
||||
│
|
||||
└─> Other HTTP Error
|
||||
└─> Log Error + Status Code
|
||||
└─> Return false/null
|
||||
```
|
||||
|
||||
## Thread-Sicherheit
|
||||
|
||||
```
|
||||
UI Thread
|
||||
│
|
||||
├─> User Interaction
|
||||
│ └─> Async Call
|
||||
│ │
|
||||
│ v
|
||||
│ Background Task
|
||||
│ │
|
||||
│ ├─> HTTP Request
|
||||
│ ├─> File I/O
|
||||
│ └─> Processing
|
||||
│ │
|
||||
│ v
|
||||
│ Dispatcher.Invoke()
|
||||
│ │
|
||||
│ v
|
||||
└─────> UI Update
|
||||
```
|
||||
|
||||
## Ressourcen-Management
|
||||
|
||||
```
|
||||
LycheeUploadService : IDisposable
|
||||
│
|
||||
├─> Constructor
|
||||
│ └─> new HttpClient()
|
||||
│
|
||||
├─> Usage
|
||||
│ └─> Multiple API calls
|
||||
│
|
||||
└─> Dispose()
|
||||
└─> HttpClient.Dispose()
|
||||
└─> Free network resources
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Asynchrone Operationen**: Alle API-Calls sind async/await
|
||||
2. **Fehlerbehandlung**: Try-catch mit detailliertem Logging
|
||||
3. **Ressourcen**: IDisposable Pattern für HttpClient
|
||||
4. **Thread-Sicherheit**: Dispatcher für UI-Updates
|
||||
5. **Konfiguration**: Zentrale Settings-Verwaltung
|
||||
6. **Sicherheit**: Cookie-basierte Session-Verwaltung
|
||||
7. **Performance**: Background-Tasks für Uploads
|
||||
8. **User Experience**: Live-Feedback und Progress-Anzeigen
|
||||
@ -1,101 +0,0 @@
|
||||
# Lychee Upload Feature - Changelog
|
||||
|
||||
## Version 1.0.0 - 2026-02-28
|
||||
|
||||
### ✨ Neue Features
|
||||
|
||||
#### LycheeUploadService
|
||||
- **Authentifizierung**: Vollständige Cookie-basierte Authentifizierung mit Lychee API
|
||||
- **Single Upload**: Upload einzelner Bilder mit optionaler Album-Zuordnung
|
||||
- **Batch Upload**: Upload mehrerer Bilder mit automatischer Fehlerbehandlung
|
||||
- **Album-Verwaltung**: Erstellen neuer Alben direkt aus der Anwendung
|
||||
- **Auto-Upload**: Konfigurierbare automatische Upload-Funktion nach Fotoaufnahme
|
||||
- **Session-Management**: Automatisches Re-Authentifizieren bei abgelaufenen Sessions
|
||||
- **Fehlerbehandlung**: Umfassendes Logging und Error-Handling
|
||||
|
||||
#### UI-Komponenten
|
||||
- **LycheeUploadPage**: Vollständige Verwaltungsoberfläche
|
||||
- Verbindungsstatus-Anzeige
|
||||
- Upload-Optionen und Album-Auswahl
|
||||
- Live-Upload-Log mit Zeitstempel
|
||||
- Fortschrittsanzeige für Batch-Uploads
|
||||
- Einzelbild und Alle-Bilder Upload-Funktionen
|
||||
|
||||
#### Konfiguration
|
||||
- **AppSettingsService**: Erweitert um Lychee-Konfigurationsoptionen
|
||||
- `LycheeApiUrl`: API-Endpunkt
|
||||
- `LycheeUsername`: Benutzername
|
||||
- `LycheePassword`: Passwort
|
||||
- `LycheeDefaultAlbumId`: Standard-Album für Uploads
|
||||
- `LycheeAutoUploadEnabled`: Auto-Upload ein/aus
|
||||
|
||||
#### Dokumentation
|
||||
- **README.md**: Vollständige Feature-Dokumentation
|
||||
- **INTEGRATION.md**: Schritt-für-Schritt Integrationsanleitung
|
||||
- **Unit Tests**: Beispiel-Tests für alle Hauptfunktionen
|
||||
|
||||
### 🎨 Design
|
||||
- Goldenes Farbschema (#D4AF37) passend zur Galerie
|
||||
- Moderne, dunkle UI mit hohem Kontrast
|
||||
- Responsive Layout mit Live-Feedback
|
||||
- Statusanzeige mit farbcodierten Indikatoren
|
||||
|
||||
### 🔧 Technische Details
|
||||
- **HTTP Client**: Langlebiger HttpClient mit 5-Minuten-Timeout
|
||||
- **Async/Await**: Vollständig asynchrone Implementierung
|
||||
- **IDisposable**: Korrekte Ressourcenverwaltung
|
||||
- **JSON Serialization**: System.Text.Json für API-Kommunikation
|
||||
- **Multipart Forms**: Proper Image Upload mit Content-Type Headers
|
||||
|
||||
### 📋 API-Endpunkte
|
||||
- `POST /api/Session::login` - Authentifizierung
|
||||
- `POST /api/Photo::add` - Bild-Upload
|
||||
- `POST /api/Album::add` - Album-Erstellung
|
||||
- `POST /api/Session::logout` - Abmeldung
|
||||
|
||||
### 🔐 Sicherheit
|
||||
- Cookie-basierte Session-Verwaltung
|
||||
- Konfigurierbare Credentials
|
||||
- HTTPS-Unterstützung
|
||||
- Hinweise zur sicheren Credential-Speicherung
|
||||
|
||||
### 🐛 Bekannte Einschränkungen
|
||||
- Credentials werden aktuell in Plain-Text in JSON gespeichert
|
||||
- **Empfehlung**: Für Production Environment Vault/Secrets verwenden
|
||||
- Keine automatische Konfliktauflösung bei doppelten Uploads
|
||||
- Batch-Upload ohne Pause-Funktion
|
||||
|
||||
### 🚀 Geplante Features (Zukunft)
|
||||
- [ ] Upload-Queue mit Retry-Mechanismus
|
||||
- [ ] Verschlüsselte Credential-Speicherung
|
||||
- [ ] Upload-Historie mit Datenbank
|
||||
- [ ] Thumbnail-Generierung vor Upload
|
||||
- [ ] Duplikat-Erkennung
|
||||
- [ ] Multi-Album-Upload
|
||||
- [ ] Upload-Scheduler
|
||||
- [ ] Bandbreiten-Limitierung
|
||||
- [ ] Resume-fähiger Upload bei Abbruch
|
||||
|
||||
### 📦 Dateien
|
||||
```
|
||||
Features/LycheeUpload/
|
||||
├── LycheeUploadService.cs # Haupt-Service
|
||||
├── LycheeUploadPage.xaml # UI-Komponente
|
||||
├── LycheeUploadPage.xaml.cs # UI Code-Behind
|
||||
├── LycheeUploadServiceTests.cs # Unit Tests
|
||||
├── README.md # Feature-Dokumentation
|
||||
├── INTEGRATION.md # Integrations-Guide
|
||||
└── CHANGELOG.md # Diese Datei
|
||||
```
|
||||
|
||||
### 🔄 Migration
|
||||
Keine Migration erforderlich - neues Feature.
|
||||
|
||||
### ✅ Testing
|
||||
- Getestet mit Lychee v4.x und v5.x
|
||||
- Funktioniert mit selbst-gehosteten und Cloud-Instanzen
|
||||
- Kompatibel mit HTTPS und HTTP (nur für Development!)
|
||||
|
||||
### 👥 Credits
|
||||
Entwickelt für CamBooth Photo Booth Application
|
||||
Kompatibel mit Lychee Photo Management (https://lycheeorg.github.io/)
|
||||
@ -1,153 +0,0 @@
|
||||
# Integration von LycheeUploadService in MainWindow
|
||||
|
||||
## Schritt 1: Service in MainWindow injizieren
|
||||
|
||||
Füge den `LycheeUploadService` als Dependency im Constructor hinzu:
|
||||
|
||||
```csharp
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
// ...existing fields...
|
||||
private readonly LycheeUploadService _lycheeService;
|
||||
|
||||
public MainWindow(
|
||||
Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PictureGalleryService pictureGalleryService,
|
||||
CameraService cameraService,
|
||||
LycheeUploadService lycheeService) // <- Neu hinzufügen
|
||||
{
|
||||
// ...existing initialization...
|
||||
this._lycheeService = lycheeService;
|
||||
|
||||
// Optional: Bei Start authentifizieren
|
||||
if (appSettings.LycheeAutoUploadEnabled)
|
||||
{
|
||||
_ = Task.Run(async () => await _lycheeService.AuthenticateAsync());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Schritt 2: Upload nach Fotoaufnahme hinzufügen
|
||||
|
||||
Erweitere die `TimerControlRectangleAnimation_OnTimerEllapsed` Methode:
|
||||
|
||||
```csharp
|
||||
private async void TimerControlRectangleAnimation_OnTimerEllapsed()
|
||||
{
|
||||
var photoTakenSuccessfully = false;
|
||||
string? lastPhotoPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Foto aufnehmen
|
||||
this._cameraService.TakePhoto();
|
||||
photoTakenSuccessfully = true;
|
||||
|
||||
// Pfad zum letzten Foto ermitteln
|
||||
var pictureLocation = _appSettings.PictureLocation;
|
||||
if (Directory.Exists(pictureLocation))
|
||||
{
|
||||
var latestPhoto = Directory.GetFiles(pictureLocation, "*.jpg")
|
||||
.OrderByDescending(f => File.GetCreationTime(f))
|
||||
.FirstOrDefault();
|
||||
|
||||
lastPhotoPath = latestPhoto;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// ...existing error handling...
|
||||
}
|
||||
|
||||
// Upload zu Lychee (asynchron im Hintergrund)
|
||||
if (photoTakenSuccessfully &&
|
||||
!string.IsNullOrEmpty(lastPhotoPath) &&
|
||||
_appSettings.LycheeAutoUploadEnabled)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var albumId = _appSettings.LycheeDefaultAlbumId;
|
||||
var success = await _lycheeService.UploadImageAsync(lastPhotoPath, albumId);
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.Info($"Foto erfolgreich zu Lychee hochgeladen: {Path.GetFileName(lastPhotoPath)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"Lychee-Upload fehlgeschlagen: {Path.GetFileName(lastPhotoPath)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Lychee-Upload: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ...rest of existing code...
|
||||
}
|
||||
```
|
||||
|
||||
## Schritt 3: Dependency Injection konfigurieren
|
||||
|
||||
Wenn du einen DI-Container verwendest (z.B. in App.xaml.cs):
|
||||
|
||||
```csharp
|
||||
// Register LycheeUploadService
|
||||
services.AddSingleton<LycheeUploadService>();
|
||||
```
|
||||
|
||||
Oder ohne DI-Container:
|
||||
|
||||
```csharp
|
||||
var lycheeService = new LycheeUploadService(appSettingsService, logger);
|
||||
```
|
||||
|
||||
## Schritt 4: Optional - Upload-Page zur Navigation hinzufügen
|
||||
|
||||
Falls du einen Zugriff auf die Upload-Verwaltung per UI möchtest:
|
||||
|
||||
```csharp
|
||||
// In MainWindow.xaml.cs
|
||||
private void OpenLycheeUploadManager(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var uploadPage = new LycheeUploadPage(
|
||||
_appSettings,
|
||||
_logger,
|
||||
_lycheeService,
|
||||
_pictureGalleryService);
|
||||
|
||||
// Navigiere zu Upload-Page oder zeige als Dialog
|
||||
this.MainFrame.Navigate(uploadPage);
|
||||
}
|
||||
```
|
||||
|
||||
Und in MainWindow.xaml einen Button hinzufügen:
|
||||
|
||||
```xml
|
||||
<ui:Button Content="Lychee Upload"
|
||||
Click="OpenLycheeUploadManager"
|
||||
Width="200"
|
||||
Height="75"
|
||||
Margin="5,0" />
|
||||
```
|
||||
|
||||
## Wichtige Hinweise
|
||||
|
||||
1. **Asynchroner Upload**: Der Upload läuft im Hintergrund, sodass die Fotobox nicht blockiert wird
|
||||
2. **Fehlerbehandlung**: Fehler beim Upload werden geloggt, aber die normale Funktionalität bleibt erhalten
|
||||
3. **Performance**: Bei vielen Fotos kann der Upload Zeit in Anspruch nehmen
|
||||
4. **Authentifizierung**: Der Service authentifiziert sich automatisch bei Bedarf
|
||||
|
||||
## Testing
|
||||
|
||||
1. Konfiguriere die Lychee-Einstellungen in `app.settings.json`
|
||||
2. Setze `AutoUploadEnabled` auf `true`
|
||||
3. Mache ein Testfoto
|
||||
4. Prüfe die Logs für Upload-Status
|
||||
5. Verifiziere in deiner Lychee-Instanz, dass das Foto angekommen ist
|
||||
@ -1,198 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Alternative Authentifizierungs-Strategie für Lychee
|
||||
/// Kombiniert CSRF-Token-Fetch und Login in einem Session-Flow
|
||||
/// </summary>
|
||||
public class LycheeAuthStrategy
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly Logger _logger;
|
||||
private readonly CookieContainer _cookieContainer;
|
||||
private readonly HttpClientHandler _httpClientHandler;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public LycheeAuthStrategy(AppSettingsService appSettings, Logger logger, CookieContainer cookieContainer, HttpClientHandler httpClientHandler, HttpClient httpClient)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
_logger = logger;
|
||||
_cookieContainer = cookieContainer;
|
||||
_httpClientHandler = httpClientHandler;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alternative Strategie: Verwende die Web-Login-Seite statt API
|
||||
/// Dies umgeht Session-Probleme bei der API-Authentifizierung
|
||||
/// </summary>
|
||||
public async Task<(bool success, string? csrfToken)> AuthenticateViaWebAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
var username = _appSettings.LycheeUsername;
|
||||
var password = _appSettings.LycheePassword;
|
||||
|
||||
_logger.Info("Versuche Web-basierte Authentifizierung...");
|
||||
|
||||
// Schritt 1: Hole Login-Seite (enthält CSRF-Token)
|
||||
_logger.Debug("Hole Login-Seite...");
|
||||
var loginPageResponse = await _httpClient.GetAsync($"{lycheeUrl}");
|
||||
|
||||
if (!loginPageResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Error($"Login-Seite konnte nicht geladen werden: {loginPageResponse.StatusCode}");
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var html = await loginPageResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Extrahiere CSRF-Token aus HTML
|
||||
var csrfMatch = Regex.Match(html, @"csrf-token""\s*content=""([^""]+)""");
|
||||
if (!csrfMatch.Success)
|
||||
{
|
||||
_logger.Error("CSRF-Token konnte nicht aus HTML extrahiert werden");
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var csrfToken = csrfMatch.Groups[1].Value;
|
||||
_logger.Debug($"CSRF-Token gefunden: {csrfToken.Substring(0, Math.Min(20, csrfToken.Length))}...");
|
||||
|
||||
// Debug: Zeige Cookies nach Login-Seite
|
||||
var uri = new Uri(lycheeUrl);
|
||||
var cookies = _cookieContainer.GetCookies(uri);
|
||||
_logger.Debug($"Cookies nach Login-Seite: {cookies.Count}");
|
||||
foreach (Cookie cookie in cookies)
|
||||
{
|
||||
_logger.Debug($" Cookie: {cookie.Name} (Domain: {cookie.Domain})");
|
||||
}
|
||||
|
||||
// Schritt 2: Sende Login-Request SOFORT (gleiche Session)
|
||||
_logger.Debug("Sende Login-Request...");
|
||||
|
||||
var loginData = new Dictionary<string, string>
|
||||
{
|
||||
{ "username", username },
|
||||
{ "password", password },
|
||||
{ "_token", csrfToken } // Laravel erwartet _token im Body
|
||||
};
|
||||
|
||||
var loginContent = new FormUrlEncodedContent(loginData);
|
||||
|
||||
var loginRequest = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/Session::login")
|
||||
{
|
||||
Content = loginContent
|
||||
};
|
||||
|
||||
// Wichtig: Setze alle erforderlichen Headers
|
||||
loginRequest.Headers.Add("Accept", "application/json");
|
||||
loginRequest.Headers.Add("X-CSRF-TOKEN", csrfToken);
|
||||
loginRequest.Headers.Add("X-XSRF-TOKEN", csrfToken);
|
||||
loginRequest.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
loginRequest.Headers.Add("Origin", $"{uri.Scheme}://{uri.Host}");
|
||||
loginRequest.Headers.Add("Referer", lycheeUrl);
|
||||
|
||||
var loginResponse = await _httpClient.SendAsync(loginRequest);
|
||||
|
||||
if (loginResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var responseContent = await loginResponse.Content.ReadAsStringAsync();
|
||||
_logger.Debug($"Login Response: {responseContent}");
|
||||
_logger.Info("✅ Web-basierte Authentifizierung erfolgreich!");
|
||||
return (true, csrfToken);
|
||||
}
|
||||
|
||||
_logger.Error($"Login fehlgeschlagen: {loginResponse.StatusCode}");
|
||||
var errorBody = await loginResponse.Content.ReadAsStringAsync();
|
||||
_logger.Debug($"Error Response: {errorBody}");
|
||||
return (false, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler bei Web-Authentifizierung: {ex.Message}");
|
||||
return (false, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategie 2: Verwende Session-Cookie direkt ohne API-Login
|
||||
/// Funktioniert wenn Lychee im Browser bereits eingeloggt ist
|
||||
/// </summary>
|
||||
public async Task<bool> TryExistingSessionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
_logger.Info("Prüfe existierende Session...");
|
||||
|
||||
// Versuche Session::info abzurufen
|
||||
var response = await _httpClient.GetAsync($"{lycheeUrl}/api/Session::info");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
_logger.Debug($"Session Info: {content}");
|
||||
|
||||
// Wenn wir Daten bekommen, ist die Session gültig
|
||||
if (!string.IsNullOrWhiteSpace(content) && content.Contains("username"))
|
||||
{
|
||||
_logger.Info("✅ Existierende Session gefunden und gültig!");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug("Keine gültige existierende Session gefunden");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Prüfen der Session: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategie 3: Browser-Cookies importieren
|
||||
/// Kopiert Session-Cookies aus dem Browser in den HttpClient
|
||||
/// </summary>
|
||||
public void ImportBrowserCookies(string sessionCookie, string xsrfToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
var uri = new Uri(lycheeUrl);
|
||||
|
||||
_logger.Info("Importiere Browser-Cookies...");
|
||||
|
||||
// Füge Session-Cookie hinzu
|
||||
var sessionCookieObj = new Cookie("lychee_session", sessionCookie)
|
||||
{
|
||||
Domain = uri.Host.StartsWith("www.") ? uri.Host.Substring(4) : uri.Host,
|
||||
Path = "/"
|
||||
};
|
||||
_cookieContainer.Add(uri, sessionCookieObj);
|
||||
|
||||
// Füge XSRF-Token hinzu
|
||||
var xsrfCookieObj = new Cookie("XSRF-TOKEN", xsrfToken)
|
||||
{
|
||||
Domain = uri.Host.StartsWith("www.") ? uri.Host.Substring(4) : uri.Host,
|
||||
Path = "/"
|
||||
};
|
||||
_cookieContainer.Add(uri, xsrfCookieObj);
|
||||
|
||||
_logger.Info("Browser-Cookies erfolgreich importiert");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Importieren der Cookies: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
<Page x:Class="CamBooth.App.Features.LycheeUpload.LycheeUploadPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
mc:Ignorable="d"
|
||||
Title="Lychee Upload" Width="800" Height="600"
|
||||
Background="#1E1E1E">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,20">
|
||||
<TextBlock Text="Lychee Upload Manager"
|
||||
FontSize="32"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"
|
||||
Margin="0,0,0,10" />
|
||||
<TextBlock Text="Verwalte deine Foto-Uploads zu Lychee"
|
||||
FontSize="16"
|
||||
Foreground="#CCCCCC" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<Border Grid.Row="1"
|
||||
Background="#2C2C2C"
|
||||
CornerRadius="10"
|
||||
Padding="20"
|
||||
Margin="0,0,0,20">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0"
|
||||
Width="16"
|
||||
Height="16"
|
||||
CornerRadius="8"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#FF4444" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ElementName=ConnectionStatusText, Path=Text}" Value="Verbunden">
|
||||
<Setter Property="Background" Value="#4CAF50" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ConnectionStatusText"
|
||||
Text="Nicht verbunden"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="White" />
|
||||
<TextBlock x:Name="ConnectionUrlText"
|
||||
Text=""
|
||||
FontSize="14"
|
||||
Foreground="#999999"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<ui:Button Grid.Column="2"
|
||||
x:Name="ConnectButton"
|
||||
Content="Verbinden"
|
||||
Click="ConnectButton_Click"
|
||||
Style="{StaticResource PrimaryActionButtonStyle}"
|
||||
Width="140"
|
||||
Height="45" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<Border Grid.Row="2"
|
||||
Background="#2C2C2C"
|
||||
CornerRadius="10"
|
||||
Padding="20">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Upload Options -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,20">
|
||||
<TextBlock Text="Upload-Optionen"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="White"
|
||||
Margin="0,0,0,15" />
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="Album:"
|
||||
Foreground="#CCCCCC"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,15,0" />
|
||||
<TextBox Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
x:Name="AlbumIdTextBox"
|
||||
Height="35"
|
||||
Background="#3C3C3C"
|
||||
Foreground="White"
|
||||
BorderBrush="#555555"
|
||||
Padding="8"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<CheckBox Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
x:Name="AutoUploadCheckBox"
|
||||
Content="Automatischer Upload nach Fotoaufnahme"
|
||||
Foreground="#CCCCCC"
|
||||
FontSize="14"
|
||||
Margin="0,10,0,0"
|
||||
Checked="AutoUploadCheckBox_Changed"
|
||||
Unchecked="AutoUploadCheckBox_Changed" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Upload Log -->
|
||||
<Border Grid.Row="1"
|
||||
Background="#1E1E1E"
|
||||
CornerRadius="5"
|
||||
Padding="10"
|
||||
Margin="0,0,0,15">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock x:Name="UploadLogTextBlock"
|
||||
Text="Bereit für Upload..."
|
||||
Foreground="#CCCCCC"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Upload Buttons -->
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right">
|
||||
<ui:Button x:Name="UploadLastPhotoButton"
|
||||
Content="Letztes Foto hochladen"
|
||||
Click="UploadLastPhotoButton_Click"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Width="200"
|
||||
Height="45"
|
||||
Margin="0,0,10,0" />
|
||||
<ui:Button x:Name="UploadAllPhotosButton"
|
||||
Content="Alle Fotos hochladen"
|
||||
Click="UploadAllPhotosButton_Click"
|
||||
Style="{StaticResource PrimaryActionButtonStyle}"
|
||||
Width="200"
|
||||
Height="45" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<ProgressBar Grid.Row="3"
|
||||
x:Name="UploadProgressBar"
|
||||
Height="8"
|
||||
Margin="0,20,0,0"
|
||||
Foreground="#D4AF37"
|
||||
Background="#3C3C3C"
|
||||
Visibility="Collapsed" />
|
||||
</Grid>
|
||||
</Page>
|
||||
@ -1,249 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.PictureGallery;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
|
||||
public partial class LycheeUploadPage : Page
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly Logger _logger;
|
||||
private readonly LycheeUploadService _lycheeService;
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
|
||||
public LycheeUploadPage(
|
||||
AppSettingsService appSettings,
|
||||
Logger logger,
|
||||
LycheeUploadService lycheeService,
|
||||
PictureGalleryService pictureGalleryService)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
_logger = logger;
|
||||
_lycheeService = lycheeService;
|
||||
_pictureGalleryService = pictureGalleryService;
|
||||
|
||||
InitializeComponent();
|
||||
InitializePage();
|
||||
}
|
||||
|
||||
private void InitializePage()
|
||||
{
|
||||
// Set initial values
|
||||
ConnectionUrlText.Text = _appSettings.LycheeApiUrl ?? "Nicht konfiguriert";
|
||||
AlbumIdTextBox.Text = _appSettings.LycheeDefaultAlbumId ?? "";
|
||||
AutoUploadCheckBox.IsChecked = _appSettings.LycheeAutoUploadEnabled;
|
||||
|
||||
UpdateConnectionStatus();
|
||||
}
|
||||
|
||||
private void UpdateConnectionStatus()
|
||||
{
|
||||
ConnectionStatusText.Text = _lycheeService.IsAuthenticated ? "Verbunden" : "Nicht verbunden";
|
||||
ConnectButton.Content = _lycheeService.IsAuthenticated ? "Trennen" : "Verbinden";
|
||||
}
|
||||
|
||||
private async void ConnectButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ConnectButton.IsEnabled = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (_lycheeService.IsAuthenticated)
|
||||
{
|
||||
// Disconnect
|
||||
await _lycheeService.LogoutAsync();
|
||||
AddLogEntry("Von Lychee getrennt.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Connect
|
||||
AddLogEntry("Verbinde mit Lychee...");
|
||||
var success = await _lycheeService.AuthenticateAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
AddLogEntry("✅ Erfolgreich verbunden!");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLogEntry("❌ Verbindung fehlgeschlagen. Prüfe deine Einstellungen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ConnectButton.IsEnabled = true;
|
||||
UpdateConnectionStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private async void UploadLastPhotoButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UploadLastPhotoButton.IsEnabled = false;
|
||||
UploadProgressBar.Visibility = Visibility.Visible;
|
||||
UploadProgressBar.IsIndeterminate = true;
|
||||
|
||||
try
|
||||
{
|
||||
var pictureLocation = _appSettings.PictureLocation;
|
||||
|
||||
if (string.IsNullOrEmpty(pictureLocation) || !Directory.Exists(pictureLocation))
|
||||
{
|
||||
AddLogEntry("❌ Bild-Verzeichnis nicht gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the latest photo
|
||||
var imageFiles = Directory.GetFiles(pictureLocation, "*.jpg")
|
||||
.OrderByDescending(f => File.GetCreationTime(f))
|
||||
.ToList();
|
||||
|
||||
if (imageFiles.Count == 0)
|
||||
{
|
||||
AddLogEntry("❌ Keine Fotos zum Hochladen gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
var lastPhoto = imageFiles.First();
|
||||
var fileName = Path.GetFileName(lastPhoto);
|
||||
|
||||
AddLogEntry($"📤 Lade hoch: {fileName}");
|
||||
|
||||
var albumId = string.IsNullOrWhiteSpace(AlbumIdTextBox.Text) ? null : AlbumIdTextBox.Text;
|
||||
var success = await _lycheeService.UploadImageAsync(lastPhoto, albumId);
|
||||
|
||||
if (success)
|
||||
{
|
||||
AddLogEntry($"✅ Upload erfolgreich: {fileName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLogEntry($"❌ Upload fehlgeschlagen: {fileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLogEntry($"❌ Fehler: {ex.Message}");
|
||||
_logger.Error($"Upload error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
UploadLastPhotoButton.IsEnabled = true;
|
||||
UploadProgressBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private async void UploadAllPhotosButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var result = MessageBox.Show(
|
||||
"Möchtest du wirklich alle Fotos zu Lychee hochladen? Dies kann einige Zeit dauern.",
|
||||
"Alle Fotos hochladen",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question);
|
||||
|
||||
if (result != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UploadAllPhotosButton.IsEnabled = false;
|
||||
UploadLastPhotoButton.IsEnabled = false;
|
||||
UploadProgressBar.Visibility = Visibility.Visible;
|
||||
UploadProgressBar.IsIndeterminate = false;
|
||||
UploadProgressBar.Value = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var pictureLocation = _appSettings.PictureLocation;
|
||||
|
||||
if (string.IsNullOrEmpty(pictureLocation) || !Directory.Exists(pictureLocation))
|
||||
{
|
||||
AddLogEntry("❌ Bild-Verzeichnis nicht gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all photos
|
||||
var imageFiles = Directory.GetFiles(pictureLocation, "*.jpg").ToList();
|
||||
|
||||
if (imageFiles.Count == 0)
|
||||
{
|
||||
AddLogEntry("❌ Keine Fotos zum Hochladen gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
AddLogEntry($"📤 Starte Upload von {imageFiles.Count} Fotos...");
|
||||
|
||||
var albumId = string.IsNullOrWhiteSpace(AlbumIdTextBox.Text) ? null : AlbumIdTextBox.Text;
|
||||
int uploadedCount = 0;
|
||||
|
||||
for (int i = 0; i < imageFiles.Count; i++)
|
||||
{
|
||||
var imagePath = imageFiles[i];
|
||||
var fileName = Path.GetFileName(imagePath);
|
||||
|
||||
AddLogEntry($"📤 ({i + 1}/{imageFiles.Count}) {fileName}");
|
||||
|
||||
var success = await _lycheeService.UploadImageAsync(imagePath, albumId);
|
||||
|
||||
if (success)
|
||||
{
|
||||
uploadedCount++;
|
||||
AddLogEntry($"✅ Upload erfolgreich");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLogEntry($"❌ Upload fehlgeschlagen");
|
||||
}
|
||||
|
||||
// Update progress
|
||||
UploadProgressBar.Value = ((i + 1) / (double)imageFiles.Count) * 100;
|
||||
|
||||
// Small delay between uploads
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
AddLogEntry($"✅ Fertig! {uploadedCount} von {imageFiles.Count} Fotos erfolgreich hochgeladen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLogEntry($"❌ Fehler: {ex.Message}");
|
||||
_logger.Error($"Batch upload error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
UploadAllPhotosButton.IsEnabled = true;
|
||||
UploadLastPhotoButton.IsEnabled = true;
|
||||
UploadProgressBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void AutoUploadCheckBox_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Note: This would need to persist the setting back to configuration
|
||||
// For now, it just shows the intended behavior
|
||||
var isEnabled = AutoUploadCheckBox.IsChecked == true;
|
||||
AddLogEntry(isEnabled
|
||||
? "ℹ️ Automatischer Upload aktiviert"
|
||||
: "ℹ️ Automatischer Upload deaktiviert");
|
||||
}
|
||||
|
||||
private void AddLogEntry(string message)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("HH:mm:ss");
|
||||
var logEntry = $"[{timestamp}] {message}\n";
|
||||
|
||||
UploadLogTextBlock.Text += logEntry;
|
||||
|
||||
// Auto-scroll to bottom
|
||||
if (UploadLogTextBlock.Parent is ScrollViewer scrollViewer)
|
||||
{
|
||||
scrollViewer.ScrollToEnd();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,594 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Media.Imaging;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Service für den Upload von Bildern zu Lychee mit Authentifizierung
|
||||
/// </summary>
|
||||
public class LycheeUploadService : IDisposable
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly Logger _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly QRCodeGenerationService _qrCodeGenerationService;
|
||||
private readonly CookieContainer _cookieContainer;
|
||||
private readonly HttpClientHandler _httpClientHandler;
|
||||
private string? _csrfToken;
|
||||
private bool _isAuthenticated = false;
|
||||
|
||||
public LycheeUploadService(AppSettingsService appSettings, Logger logger)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
_logger = logger;
|
||||
_qrCodeGenerationService = new QRCodeGenerationService(logger);
|
||||
|
||||
// CookieContainer für Session-Management
|
||||
_cookieContainer = new CookieContainer();
|
||||
_httpClientHandler = new HttpClientHandler
|
||||
{
|
||||
CookieContainer = _cookieContainer,
|
||||
UseCookies = true
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(_httpClientHandler);
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
// Note: Accept header wird per-Request gesetzt, nicht als Default
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob der Service authentifiziert ist
|
||||
/// </summary>
|
||||
public bool IsAuthenticated => _isAuthenticated;
|
||||
|
||||
/// <summary>
|
||||
/// Der zuletzt generierte QR-Code
|
||||
/// </summary>
|
||||
public BitmapImage? LastGeneratedQRCode { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentifiziert sich bei Lychee
|
||||
/// </summary>
|
||||
/// <returns>True wenn erfolgreich, sonst False</returns>
|
||||
public async Task<bool> AuthenticateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Starte Lychee-Authentifizierung...");
|
||||
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
var username = _appSettings.LycheeUsername;
|
||||
var password = _appSettings.LycheePassword;
|
||||
|
||||
if (string.IsNullOrEmpty(lycheeUrl) || string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
_logger.Error("Lychee-Konfiguration unvollständig. Bitte LycheeApiUrl, LycheeUsername und LycheePassword in den Einstellungen setzen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// WICHTIG: Zuerst die Hauptseite abrufen um Session zu initialisieren!
|
||||
// Ohne vorherige Session weigert sich Lychee, den Login zu akzeptieren
|
||||
_logger.Debug($"Initialisiere Session durch GET-Request zu: {lycheeUrl}");
|
||||
var sessionInitResponse = await _httpClient.GetAsync(lycheeUrl);
|
||||
|
||||
if (!sessionInitResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Warning($"Session-Initialisierung fehlgeschlagen (Status: {sessionInitResponse.StatusCode}), versuche trotzdem Login...");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("✅ Session initialisiert");
|
||||
|
||||
// Debug: Zeige Cookies nach Initialisierung
|
||||
var uri = new Uri(lycheeUrl);
|
||||
var cookies = _cookieContainer.GetCookies(uri);
|
||||
_logger.Debug($"Cookies nach Session-Init: {cookies.Count}");
|
||||
foreach (Cookie cookie in cookies)
|
||||
{
|
||||
_logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}...");
|
||||
}
|
||||
}
|
||||
|
||||
// Jetzt den Login-Request senden
|
||||
var loginData = new
|
||||
{
|
||||
username = username,
|
||||
password = password
|
||||
};
|
||||
|
||||
var jsonContent = JsonSerializer.Serialize(loginData);
|
||||
_logger.Debug($"Login Data: {jsonContent}");
|
||||
|
||||
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
|
||||
// Erstelle Request für v2 API
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Auth::login")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// WICHTIG: Extrahiere XSRF-TOKEN aus Cookie und sende als Header
|
||||
// Laravel braucht das für CSRF-Schutz!
|
||||
var uri2 = new Uri(lycheeUrl);
|
||||
var cookiesForLogin = _cookieContainer.GetCookies(uri2);
|
||||
string? xsrfToken = null;
|
||||
|
||||
foreach (Cookie cookie in cookiesForLogin)
|
||||
{
|
||||
if (cookie.Name == "XSRF-TOKEN")
|
||||
{
|
||||
// URL-decode den Cookie-Wert
|
||||
xsrfToken = Uri.UnescapeDataString(cookie.Value);
|
||||
_logger.Debug($"XSRF-TOKEN aus Cookie extrahiert: {xsrfToken.Substring(0, Math.Min(30, xsrfToken.Length))}...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Setze erforderliche Headers für v2 API
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
request.Headers.Add("User-Agent", "CamBooth/1.0");
|
||||
request.Headers.Add("Origin", new Uri(lycheeUrl).GetLeftPart(UriPartial.Authority));
|
||||
request.Headers.Add("Referer", lycheeUrl);
|
||||
|
||||
// CSRF-Token als Header hinzufügen (falls vorhanden)
|
||||
if (!string.IsNullOrEmpty(xsrfToken))
|
||||
{
|
||||
request.Headers.Add("X-XSRF-TOKEN", xsrfToken);
|
||||
_logger.Debug("✅ X-XSRF-TOKEN Header hinzugefügt");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("⚠️ Kein XSRF-TOKEN Cookie gefunden!");
|
||||
}
|
||||
|
||||
_logger.Debug($"Sende Login-Request an: {lycheeUrl}/api/v2/Auth::login");
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// Lychee v2 API kann 204 No Content zurückgeben (erfolgreich, aber kein Body)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
_logger.Info("✅ Login-Response: 204 No Content (erfolgreich)");
|
||||
|
||||
// 204 bedeutet Login war erfolgreich - Session ist aktiv
|
||||
// Optionale Validierung mit auth/me (kann übersprungen werden)
|
||||
_logger.Debug("Validiere Session mit auth/me endpoint...");
|
||||
|
||||
try
|
||||
{
|
||||
var meRequest = new HttpRequestMessage(HttpMethod.Get, $"{lycheeUrl}/api/v2/auth/me");
|
||||
// Lychee erwartet manchmal text/html statt application/json
|
||||
meRequest.Headers.Add("Accept", "application/json, text/html, */*");
|
||||
|
||||
var meResponse = await _httpClient.SendAsync(meRequest);
|
||||
|
||||
if (meResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var meContent = await meResponse.Content.ReadAsStringAsync();
|
||||
_logger.Debug($"auth/me Response: {meContent}");
|
||||
_logger.Info("✅ Session validiert!");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"Session-Validierung nicht erfolgreich (Status: {meResponse.StatusCode}), aber Login war OK - fahre fort.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Session-Validierung fehlgeschlagen: {ex.Message}, aber Login war OK - fahre fort.");
|
||||
}
|
||||
|
||||
// Authentifizierung ist erfolgreich (Login gab 204 zurück)
|
||||
_isAuthenticated = true;
|
||||
_logger.Info("✅ Lychee-Authentifizierung erfolgreich!");
|
||||
|
||||
// Debug: Zeige Cookies
|
||||
var uri3 = new Uri(lycheeUrl);
|
||||
var cookiesAfterLogin = _cookieContainer.GetCookies(uri3);
|
||||
_logger.Debug($"Cookies nach Login: {cookiesAfterLogin.Count}");
|
||||
foreach (Cookie cookie in cookiesAfterLogin)
|
||||
{
|
||||
_logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}...");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.Debug($"Login Response: {responseContent}");
|
||||
|
||||
// Debug: Zeige alle Response-Header
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
_logger.Debug($"Response Header: {header.Key} = {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
// Debug: Zeige Cookies
|
||||
var uri4 = new Uri(lycheeUrl);
|
||||
var cookiesAfterLogin = _cookieContainer.GetCookies(uri4);
|
||||
_logger.Debug($"Cookies nach Login: {cookiesAfterLogin.Count}");
|
||||
foreach (Cookie cookie in cookiesAfterLogin)
|
||||
{
|
||||
_logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}...");
|
||||
}
|
||||
|
||||
// Parse die Response
|
||||
using var jsonDoc = JsonDocument.Parse(responseContent);
|
||||
|
||||
// Prüfe auf API-Token in der Response
|
||||
if (jsonDoc.RootElement.TryGetProperty("api_token", out var apiToken))
|
||||
{
|
||||
var tokenValue = apiToken.GetString();
|
||||
_logger.Debug($"✅ API-Token erhalten: {tokenValue?.Substring(0, Math.Min(30, tokenValue?.Length ?? 0))}...");
|
||||
|
||||
// Setze API-Token als Authorization Header für zukünftige Requests
|
||||
_httpClient.DefaultRequestHeaders.Remove("Authorization");
|
||||
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {tokenValue}");
|
||||
|
||||
_isAuthenticated = true;
|
||||
_logger.Info("✅ Lychee-Authentifizierung erfolgreich mit API-Token!");
|
||||
return true;
|
||||
}
|
||||
|
||||
// v2 API gibt success oder status zurück
|
||||
if (jsonDoc.RootElement.TryGetProperty("success", out var success) && success.GetBoolean())
|
||||
{
|
||||
_isAuthenticated = true;
|
||||
_logger.Info("✅ Lychee-Authentifizierung erfolgreich!");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback für andere Response-Formate
|
||||
if (jsonDoc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
_isAuthenticated = true;
|
||||
_logger.Info("✅ Lychee-Authentifizierung erfolgreich!");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Error($"❌ Lychee-Authentifizierung fehlgeschlagen. Status: {response.StatusCode} - {response.ReasonPhrase}");
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.Debug($"Error Response: {errorBody}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"❌ Fehler bei der Lychee-Authentifizierung: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt ein Bild zu Lychee hoch
|
||||
/// </summary>
|
||||
/// <param name="imagePath">Pfad zum Bild</param>
|
||||
/// <param name="albumId">Optional: Album-ID in Lychee</param>
|
||||
/// <returns>True wenn erfolgreich, sonst False</returns>
|
||||
public async Task<bool> UploadImageAsync(string imagePath, string? albumId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_isAuthenticated)
|
||||
{
|
||||
_logger.Warning("Nicht authentifiziert. Versuche Authentifizierung...");
|
||||
var authSuccess = await AuthenticateAsync();
|
||||
if (!authSuccess)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(imagePath))
|
||||
{
|
||||
_logger.Error($"Bild nicht gefunden: {imagePath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.Info($"Starte Upload: {imagePath}");
|
||||
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
|
||||
// Debug: Zeige Authentifizierungs-Status
|
||||
_logger.Debug($"IsAuthenticated: {_isAuthenticated}");
|
||||
_logger.Debug($"Authorization Header vorhanden: {_httpClient.DefaultRequestHeaders.Contains("Authorization")}");
|
||||
|
||||
// Debug: Zeige Cookies für Upload
|
||||
var uri = new Uri(lycheeUrl);
|
||||
var cookies = _cookieContainer.GetCookies(uri);
|
||||
_logger.Debug($"Cookies für Upload: {cookies.Count}");
|
||||
foreach (Cookie cookie in cookies)
|
||||
{
|
||||
_logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}...");
|
||||
}
|
||||
|
||||
// Lese das Bild
|
||||
var fileName = Path.GetFileName(imagePath);
|
||||
var imageBytes = await File.ReadAllBytesAsync(imagePath);
|
||||
|
||||
// Extrahiere Datei-Informationen
|
||||
var fileInfo = new FileInfo(imagePath);
|
||||
var fileExtension = Path.GetExtension(imagePath).TrimStart('.');
|
||||
|
||||
// File last modified time als Unix-Timestamp in Millisekunden (Lychee erwartet eine Zahl!)
|
||||
var lastModifiedUnixMs = new DateTimeOffset(fileInfo.LastWriteTimeUtc).ToUnixTimeMilliseconds().ToString();
|
||||
|
||||
// Erstelle Multipart Form Data nach Lychee v2 API Spezifikation
|
||||
using var form = new MultipartFormDataContent();
|
||||
|
||||
// Album ID (optional)
|
||||
var albumIdContent = new StringContent(albumId ?? "");
|
||||
albumIdContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "album_id" };
|
||||
form.Add(albumIdContent);
|
||||
|
||||
// File last modified time (Unix-Timestamp in Millisekunden)
|
||||
var modifiedTimeContent = new StringContent(lastModifiedUnixMs);
|
||||
modifiedTimeContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file_last_modified_time" };
|
||||
form.Add(modifiedTimeContent);
|
||||
|
||||
// File (das eigentliche Bild)
|
||||
var imageContent = new ByteArrayContent(imageBytes);
|
||||
imageContent.Headers.ContentType = MediaTypeHeaderValue.Parse("image/jpeg");
|
||||
imageContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file", FileName = fileName };
|
||||
form.Add(imageContent);
|
||||
|
||||
// File name
|
||||
var fileNameContent = new StringContent(fileName);
|
||||
fileNameContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file_name" };
|
||||
form.Add(fileNameContent);
|
||||
|
||||
// UUID name - Lychee erwartet NULL/leer für automatische Generierung!
|
||||
var uuidContent = new StringContent("");
|
||||
uuidContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "uuid_name" };
|
||||
form.Add(uuidContent);
|
||||
|
||||
// Extension - Lychee erwartet NULL/leer für automatische Erkennung!
|
||||
var extensionContent = new StringContent("");
|
||||
extensionContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "extension" };
|
||||
form.Add(extensionContent);
|
||||
|
||||
// Chunk number (für nicht-gechunkte Uploads: 1, nicht 0!)
|
||||
var chunkNumberContent = new StringContent("1");
|
||||
chunkNumberContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "chunk_number" };
|
||||
form.Add(chunkNumberContent);
|
||||
|
||||
// Total chunks (für nicht-gechunkte Uploads: 1)
|
||||
var totalChunksContent = new StringContent("1");
|
||||
totalChunksContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "total_chunks" };
|
||||
form.Add(totalChunksContent);
|
||||
|
||||
// WICHTIG: Extrahiere XSRF-TOKEN aus Cookie für Upload-Request
|
||||
string? xsrfTokenForUpload = null;
|
||||
foreach (Cookie cookie in cookies)
|
||||
{
|
||||
if (cookie.Name == "XSRF-TOKEN")
|
||||
{
|
||||
xsrfTokenForUpload = Uri.UnescapeDataString(cookie.Value);
|
||||
_logger.Debug($"XSRF-TOKEN für Upload extrahiert: {xsrfTokenForUpload.Substring(0, Math.Min(30, xsrfTokenForUpload.Length))}...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sende Upload-Request (Lychee v2 API)
|
||||
// Korrekter Endpoint: /api/v2/Photo (mit großem P, wie im C# Example!)
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Photo")
|
||||
{
|
||||
Content = form
|
||||
};
|
||||
|
||||
// Lychee erwartet manchmal text/html statt application/json
|
||||
request.Headers.Add("Accept", "application/json, text/html, */*");
|
||||
|
||||
// CSRF-Token als Header hinzufügen (WICHTIG!)
|
||||
if (!string.IsNullOrEmpty(xsrfTokenForUpload))
|
||||
{
|
||||
request.Headers.Add("X-XSRF-TOKEN", xsrfTokenForUpload);
|
||||
_logger.Debug("✅ X-XSRF-TOKEN Header zum Upload hinzugefügt");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("⚠️ Kein XSRF-TOKEN für Upload gefunden!");
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.Info($"Upload erfolgreich: {fileName}");
|
||||
_logger.Debug($"Lychee Response: {responseContent}");
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Error($"Upload fehlgeschlagen. Status: {response.StatusCode}");
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.Error($"Error Response: {errorContent}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Upload: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt mehrere Bilder zu Lychee hoch
|
||||
/// </summary>
|
||||
/// <param name="imagePaths">Liste von Bildpfaden</param>
|
||||
/// <param name="albumId">Optional: Album-ID in Lychee</param>
|
||||
/// <returns>Anzahl erfolgreich hochgeladener Bilder</returns>
|
||||
public async Task<int> UploadImagesAsync(IEnumerable<string> imagePaths, string? albumId = null)
|
||||
{
|
||||
int successCount = 0;
|
||||
|
||||
foreach (var imagePath in imagePaths)
|
||||
{
|
||||
var success = await UploadImageAsync(imagePath, albumId);
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// Kleine Verzögerung zwischen Uploads
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
_logger.Info($"{successCount} von {imagePaths.Count()} Bildern erfolgreich hochgeladen.");
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt ein neues Album in Lychee
|
||||
/// </summary>
|
||||
/// <param name="albumTitle">Titel des Albums</param>
|
||||
/// <returns>Album-ID wenn erfolgreich, sonst null</returns>
|
||||
public async Task<string?> CreateAlbumAsync(string albumTitle)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_isAuthenticated)
|
||||
{
|
||||
_logger.Warning("Nicht authentifiziert. Versuche Authentifizierung...");
|
||||
var authSuccess = await AuthenticateAsync();
|
||||
if (!authSuccess)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"Erstelle Album: {albumTitle}");
|
||||
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
|
||||
// Lychee v2 API - JSON Format
|
||||
var albumData = new
|
||||
{
|
||||
title = albumTitle
|
||||
};
|
||||
|
||||
var jsonContent = JsonSerializer.Serialize(albumData);
|
||||
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
|
||||
// Sende Request (Lychee v2 API)
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Albums")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
using var jsonDoc = JsonDocument.Parse(responseContent);
|
||||
|
||||
if (jsonDoc.RootElement.TryGetProperty("id", out var idElement))
|
||||
{
|
||||
var albumId = idElement.GetString();
|
||||
_logger.Info($"Album erfolgreich erstellt. ID: {albumId}");
|
||||
return albumId;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Error($"Album-Erstellung fehlgeschlagen. Status: {response.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Erstellen des Albums: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet sich von Lychee ab
|
||||
/// </summary>
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_isAuthenticated)
|
||||
{
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Auth::logout");
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
|
||||
await _httpClient.SendAsync(request);
|
||||
_logger.Info("Von Lychee abgemeldet.");
|
||||
}
|
||||
|
||||
_isAuthenticated = false;
|
||||
_httpClient.DefaultRequestHeaders.Clear();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Abmelden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generiert einen QR-Code für die Lychee-Galerie-URL
|
||||
/// </summary>
|
||||
/// <returns>BitmapImage des QR-Codes oder null bei Fehler</returns>
|
||||
public BitmapImage? GenerateGalleryQRCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lycheeUrl = _appSettings.LycheeApiUrl;
|
||||
var albumId = _appSettings.LycheeDefaultAlbumId;
|
||||
|
||||
if (string.IsNullOrEmpty(lycheeUrl))
|
||||
{
|
||||
_logger.Error("Lychee-URL ist nicht konfiguriert. Kann QR-Code nicht generieren.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(albumId))
|
||||
{
|
||||
_logger.Warning("Lychee DefaultAlbumId ist nicht konfiguriert. QR-Code zeigt nur die Basis-URL.");
|
||||
}
|
||||
|
||||
_logger.Info("Generiere QR-Code für Lychee-Galerie...");
|
||||
|
||||
// Konstruiere die Gallery-URL: ApiUrl + /Gallery + DefaultAlbumId
|
||||
var galleryUrl = $"{lycheeUrl}/gallery/{albumId}";
|
||||
_logger.Debug($"QR-Code URL: {galleryUrl}");
|
||||
|
||||
// Generiere QR-Code mit der Gallery-URL
|
||||
var qrCode = _qrCodeGenerationService.GenerateQRCode(galleryUrl);
|
||||
|
||||
if (qrCode != null)
|
||||
{
|
||||
LastGeneratedQRCode = qrCode;
|
||||
_logger.Info("✅ QR-Code erfolgreich generiert");
|
||||
}
|
||||
|
||||
return qrCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Generieren des QR-Codes: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
_httpClientHandler?.Dispose();
|
||||
}
|
||||
}
|
||||
@ -1,241 +0,0 @@
|
||||
// using System;
|
||||
// using System.IO;
|
||||
// using System.Threading.Tasks;
|
||||
// using CamBooth.App.Core.AppSettings;
|
||||
// using CamBooth.App.Core.Logging;
|
||||
// using CamBooth.App.Features.LycheeUpload;
|
||||
//
|
||||
// namespace CamBooth.Tests.Features.LycheeUpload;
|
||||
//
|
||||
// /// <summary>
|
||||
// /// Example Unit Tests for LycheeUploadService
|
||||
// /// Note: Requires a running Lychee instance for integration tests
|
||||
// /// </summary>
|
||||
// public class LycheeUploadServiceTests
|
||||
// {
|
||||
// private readonly Logger _logger;
|
||||
// private readonly AppSettingsService _appSettings;
|
||||
// private LycheeUploadService _service;
|
||||
//
|
||||
// public LycheeUploadServiceTests()
|
||||
// {
|
||||
// _logger = new Logger(null);
|
||||
// _appSettings = new AppSettingsService(_logger);
|
||||
// _service = new LycheeUploadService(_appSettings, _logger);
|
||||
// }
|
||||
//
|
||||
// // Unit Test: Authentication
|
||||
// public async Task TestAuthentication()
|
||||
// {
|
||||
// Console.WriteLine("Testing Lychee Authentication...");
|
||||
//
|
||||
// var result = await _service.AuthenticateAsync();
|
||||
//
|
||||
// if (result)
|
||||
// {
|
||||
// Console.WriteLine("✅ Authentication successful");
|
||||
// Console.WriteLine($" IsAuthenticated: {_service.IsAuthenticated}");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Console.WriteLine("❌ Authentication failed");
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Unit Test: Single Image Upload
|
||||
// public async Task TestSingleImageUpload()
|
||||
// {
|
||||
// Console.WriteLine("\nTesting Single Image Upload...");
|
||||
//
|
||||
// // Authenticate first
|
||||
// await _service.AuthenticateAsync();
|
||||
//
|
||||
// // Create a test image
|
||||
// var testImagePath = CreateTestImage();
|
||||
//
|
||||
// var result = await _service.UploadImageAsync(testImagePath);
|
||||
//
|
||||
// if (result)
|
||||
// {
|
||||
// Console.WriteLine("✅ Image upload successful");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Console.WriteLine("❌ Image upload failed");
|
||||
// }
|
||||
//
|
||||
// // Cleanup
|
||||
// if (File.Exists(testImagePath))
|
||||
// {
|
||||
// File.Delete(testImagePath);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Unit Test: Batch Upload
|
||||
// public async Task TestBatchUpload()
|
||||
// {
|
||||
// Console.WriteLine("\nTesting Batch Image Upload...");
|
||||
//
|
||||
// // Authenticate first
|
||||
// await _service.AuthenticateAsync();
|
||||
//
|
||||
// // Create test images
|
||||
// var testImages = new[]
|
||||
// {
|
||||
// CreateTestImage("test1.jpg"),
|
||||
// CreateTestImage("test2.jpg"),
|
||||
// CreateTestImage("test3.jpg")
|
||||
// };
|
||||
//
|
||||
// var successCount = await _service.UploadImagesAsync(testImages);
|
||||
//
|
||||
// Console.WriteLine($" Uploaded: {successCount}/{testImages.Length} images");
|
||||
//
|
||||
// if (successCount == testImages.Length)
|
||||
// {
|
||||
// Console.WriteLine("✅ Batch upload successful");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Console.WriteLine($"⚠️ Partial success: {successCount}/{testImages.Length}");
|
||||
// }
|
||||
//
|
||||
// // Cleanup
|
||||
// foreach (var image in testImages)
|
||||
// {
|
||||
// if (File.Exists(image))
|
||||
// {
|
||||
// File.Delete(image);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Unit Test: Album Creation
|
||||
// public async Task TestAlbumCreation()
|
||||
// {
|
||||
// Console.WriteLine("\nTesting Album Creation...");
|
||||
//
|
||||
// // Authenticate first
|
||||
// await _service.AuthenticateAsync();
|
||||
//
|
||||
// var albumName = $"Test Album {DateTime.Now:yyyy-MM-dd HH:mm:ss}";
|
||||
// var albumId = await _service.CreateAlbumAsync(albumName);
|
||||
//
|
||||
// if (albumId != null)
|
||||
// {
|
||||
// Console.WriteLine($"✅ Album created successfully");
|
||||
// Console.WriteLine($" Album ID: {albumId}");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Console.WriteLine("❌ Album creation failed");
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Unit Test: Upload to Specific Album
|
||||
// public async Task TestUploadToAlbum()
|
||||
// {
|
||||
// Console.WriteLine("\nTesting Upload to Specific Album...");
|
||||
//
|
||||
// // Authenticate first
|
||||
// await _service.AuthenticateAsync();
|
||||
//
|
||||
// // Create test album
|
||||
// var albumName = $"Test Album {DateTime.Now:yyyy-MM-dd HH:mm:ss}";
|
||||
// var albumId = await _service.CreateAlbumAsync(albumName);
|
||||
//
|
||||
// if (albumId == null)
|
||||
// {
|
||||
// Console.WriteLine("❌ Failed to create test album");
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Upload image to album
|
||||
// var testImagePath = CreateTestImage();
|
||||
// var result = await _service.UploadImageAsync(testImagePath, albumId);
|
||||
//
|
||||
// if (result)
|
||||
// {
|
||||
// Console.WriteLine($"✅ Image uploaded to album successfully");
|
||||
// Console.WriteLine($" Album ID: {albumId}");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Console.WriteLine("❌ Upload to album failed");
|
||||
// }
|
||||
//
|
||||
// // Cleanup
|
||||
// if (File.Exists(testImagePath))
|
||||
// {
|
||||
// File.Delete(testImagePath);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Unit Test: Logout
|
||||
// public async Task TestLogout()
|
||||
// {
|
||||
// Console.WriteLine("\nTesting Logout...");
|
||||
//
|
||||
// // Authenticate first
|
||||
// await _service.AuthenticateAsync();
|
||||
// Console.WriteLine($" Before logout - IsAuthenticated: {_service.IsAuthenticated}");
|
||||
//
|
||||
// // Logout
|
||||
// await _service.LogoutAsync();
|
||||
// Console.WriteLine($" After logout - IsAuthenticated: {_service.IsAuthenticated}");
|
||||
//
|
||||
// if (!_service.IsAuthenticated)
|
||||
// {
|
||||
// Console.WriteLine("✅ Logout successful");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Console.WriteLine("❌ Logout failed");
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Helper: Create a test image
|
||||
// private string CreateTestImage(string fileName = "test_image.jpg")
|
||||
// {
|
||||
// var tempPath = Path.Combine(Path.GetTempPath(), fileName);
|
||||
//
|
||||
// // Create a simple 1x1 pixel JPEG (base64 encoded minimal JPEG)
|
||||
// var minimalJpeg = Convert.FromBase64String(
|
||||
// "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAA8A/9k="
|
||||
// );
|
||||
//
|
||||
// File.WriteAllBytes(tempPath, minimalJpeg);
|
||||
// return tempPath;
|
||||
// }
|
||||
//
|
||||
// // Run all tests
|
||||
// public static async Task RunAllTests()
|
||||
// {
|
||||
// Console.WriteLine("=== Lychee Upload Service Tests ===\n");
|
||||
//
|
||||
// var tests = new LycheeUploadServiceTests();
|
||||
//
|
||||
// try
|
||||
// {
|
||||
// await tests.TestAuthentication();
|
||||
// await tests.TestSingleImageUpload();
|
||||
// await tests.TestBatchUpload();
|
||||
// await tests.TestAlbumCreation();
|
||||
// await tests.TestUploadToAlbum();
|
||||
// await tests.TestLogout();
|
||||
//
|
||||
// Console.WriteLine("\n=== All tests completed ===");
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Console.WriteLine($"\n❌ Test error: {ex.Message}");
|
||||
// }
|
||||
// finally
|
||||
// {
|
||||
// tests._service.Dispose();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Example usage in Program.cs or test runner:
|
||||
// // await LycheeUploadServiceTests.RunAllTests();
|
||||
@ -1,91 +0,0 @@
|
||||
<Window x:Class="CamBooth.App.Features.LycheeUpload.QRCodeDisplayWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
mc:Ignorable="d"
|
||||
Title="QR Code - Zum Fotoalbum"
|
||||
Width="900" Height="1000"
|
||||
Background="Black"
|
||||
WindowStyle="None"
|
||||
ResizeMode="NoResize"
|
||||
AllowsTransparency="True"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Foreground="White">
|
||||
<Window.Resources>
|
||||
<ResourceDictionary Source="pack://application:,,,/CamBooth.App;component/Resources/ButtonStyles.xaml" />
|
||||
</Window.Resources>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Close Button (top-right) -->
|
||||
<Button Grid.Row="0"
|
||||
Content="✕"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Width="60"
|
||||
Height="60"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="10"
|
||||
FontSize="24"
|
||||
Click="CloseButton_Click" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<StackPanel Grid.Row="0" Grid.RowSpan="3"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="40">
|
||||
|
||||
<!-- Top Text: "Scan Me" -->
|
||||
<TextBlock Text="SCAN ME"
|
||||
Foreground="White"
|
||||
FontSize="56"
|
||||
FontWeight="Bold"
|
||||
TextAlignment="Center"
|
||||
Margin="0 0 0 30" />
|
||||
|
||||
<!-- QR Code Image -->
|
||||
<Border Background="White"
|
||||
Padding="20"
|
||||
CornerRadius="20"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0 0 0 30">
|
||||
<Image x:Name="QRCodeImage"
|
||||
Width="500"
|
||||
Height="500"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Margin="0" />
|
||||
</Border>
|
||||
|
||||
<!-- Bottom Text: "zum Fotoalbum" -->
|
||||
<TextBlock Text="zum Fotoalbum"
|
||||
Foreground="White"
|
||||
FontSize="48"
|
||||
FontWeight="Bold"
|
||||
TextAlignment="Center"
|
||||
Margin="0 30 0 0" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- Close Button at the bottom -->
|
||||
<Button Grid.Row="3"
|
||||
Content="Fenster schließen"
|
||||
Style="{StaticResource PrimaryActionButtonStyle}"
|
||||
Width="300"
|
||||
Height="60"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0 0 0 20"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
Click="CloseButton_Click" />
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@ -1,196 +0,0 @@
|
||||
# Lychee Upload Feature
|
||||
|
||||
Dieses Feature ermöglicht den automatischen Upload von Fotos zu einer Lychee-Instanz mit Authentifizierung.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Authentifizierung bei Lychee via API
|
||||
- ✅ **CSRF-Token-Handling** für Laravel/Lychee
|
||||
- ✅ **Cookie-basiertes Session-Management**
|
||||
- ✅ Upload einzelner Bilder
|
||||
- ✅ Batch-Upload mehrerer Bilder
|
||||
- ✅ Erstellung neuer Alben
|
||||
- ✅ Zuordnung zu spezifischen Alben
|
||||
- ✅ Automatisches Re-Authentifizieren bei Session-Ablauf
|
||||
- ✅ Ausführliches Logging aller Operationen
|
||||
|
||||
## Technische Details
|
||||
|
||||
### CSRF-Schutz (HTTP 419 Prevention)
|
||||
|
||||
Lychee basiert auf Laravel und verwendet CSRF-Token-Schutz für alle POST-Requests. Der Service:
|
||||
|
||||
1. **Holt automatisch den CSRF-Token** vor der Authentifizierung
|
||||
2. **Versucht mehrere Methoden:**
|
||||
- Extrahiert Token aus `XSRF-TOKEN` Cookie
|
||||
- Falls nicht vorhanden: Parst HTML-Meta-Tag `csrf-token`
|
||||
3. **Fügt CSRF-Token zu allen POST-Requests hinzu:**
|
||||
- `X-CSRF-TOKEN` Header
|
||||
- `X-XSRF-TOKEN` Header
|
||||
|
||||
### Content-Type
|
||||
|
||||
Lychee erwartet für die meisten API-Calls:
|
||||
- **Login & Album-Erstellung:** `application/x-www-form-urlencoded` (HTML Form)
|
||||
- **Datei-Upload:** `multipart/form-data`
|
||||
- **Responses:** `application/json`
|
||||
|
||||
⚠️ **Wichtig:** Obwohl die Lychee API als "JSON API" bezeichnet wird, verwendet sie für POST-Requests tatsächlich Form-Data, nicht JSON!
|
||||
|
||||
### Session-Management
|
||||
|
||||
- Verwendet `CookieContainer` für automatisches Cookie-Handling
|
||||
- Speichert Session-Cookies (`lychee_session`)
|
||||
- CSRF-Token wird mit Session verknüpft
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die Lychee-Einstellungen werden in der `app.settings.json` konfiguriert:
|
||||
|
||||
```json
|
||||
"LycheeSettings": {
|
||||
"ApiUrl": "https://your-lychee-instance.com",
|
||||
"Username": "admin",
|
||||
"Password": "your-password",
|
||||
"DefaultAlbumId": "",
|
||||
"AutoUploadEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### Konfigurationsparameter
|
||||
|
||||
- **ApiUrl**: Die URL deiner Lychee-Instanz (ohne trailing slash)
|
||||
- **Username**: Benutzername für die Lychee-Authentifizierung
|
||||
- **Password**: Passwort für die Lychee-Authentifizierung
|
||||
- **DefaultAlbumId**: Optional - Standard-Album-ID für Uploads
|
||||
- **AutoUploadEnabled**: Wenn `true`, werden Fotos automatisch nach der Aufnahme hochgeladen
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Initialisierung
|
||||
|
||||
```csharp
|
||||
var lycheeService = new LycheeUploadService(appSettingsService, logger);
|
||||
```
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
```csharp
|
||||
bool success = await lycheeService.AuthenticateAsync();
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine("Erfolgreich authentifiziert!");
|
||||
}
|
||||
```
|
||||
|
||||
### Einzelnes Bild hochladen
|
||||
|
||||
```csharp
|
||||
string imagePath = @"C:\cambooth\pictures\photo.jpg";
|
||||
bool uploadSuccess = await lycheeService.UploadImageAsync(imagePath);
|
||||
```
|
||||
|
||||
### Bild zu spezifischem Album hochladen
|
||||
|
||||
```csharp
|
||||
string imagePath = @"C:\cambooth\pictures\photo.jpg";
|
||||
string albumId = "abc123";
|
||||
bool uploadSuccess = await lycheeService.UploadImageAsync(imagePath, albumId);
|
||||
```
|
||||
|
||||
### Mehrere Bilder hochladen
|
||||
|
||||
```csharp
|
||||
var imagePaths = new List<string>
|
||||
{
|
||||
@"C:\cambooth\pictures\photo1.jpg",
|
||||
@"C:\cambooth\pictures\photo2.jpg",
|
||||
@"C:\cambooth\pictures\photo3.jpg"
|
||||
};
|
||||
|
||||
int successCount = await lycheeService.UploadImagesAsync(imagePaths);
|
||||
Console.WriteLine($"{successCount} Bilder erfolgreich hochgeladen");
|
||||
```
|
||||
|
||||
### Neues Album erstellen
|
||||
|
||||
```csharp
|
||||
string? albumId = await lycheeService.CreateAlbumAsync("Party 2026");
|
||||
if (albumId != null)
|
||||
{
|
||||
Console.WriteLine($"Album erstellt mit ID: {albumId}");
|
||||
}
|
||||
```
|
||||
|
||||
### Abmelden
|
||||
|
||||
```csharp
|
||||
await lycheeService.LogoutAsync();
|
||||
```
|
||||
|
||||
## Integration in MainWindow
|
||||
|
||||
Um den automatischen Upload nach der Fotoaufnahme zu aktivieren:
|
||||
|
||||
```csharp
|
||||
private LycheeUploadService _lycheeService;
|
||||
|
||||
public MainWindow(/* ... */, LycheeUploadService lycheeService)
|
||||
{
|
||||
// ...
|
||||
_lycheeService = lycheeService;
|
||||
}
|
||||
|
||||
private async void TimerControlRectangleAnimation_OnTimerEllapsed()
|
||||
{
|
||||
var photoTakenSuccessfully = false;
|
||||
|
||||
try
|
||||
{
|
||||
this._cameraService.TakePhoto();
|
||||
photoTakenSuccessfully = true;
|
||||
|
||||
// Upload zu Lychee, falls aktiviert
|
||||
if (_appSettings.LycheeAutoUploadEnabled && photoTakenSuccessfully)
|
||||
{
|
||||
var photoPath = /* Pfad zum gerade aufgenommenen Foto */;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _lycheeService.UploadImageAsync(photoPath, _appSettings.LycheeDefaultAlbumId);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
Der Service verwendet folgende Lychee API-Endpunkte:
|
||||
|
||||
- `POST /api/Session::login` - Authentifizierung
|
||||
- `POST /api/Photo::add` - Bild hochladen
|
||||
- `POST /api/Album::add` - Album erstellen
|
||||
- `POST /api/Session::logout` - Abmelden
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Alle Methoden loggen Fehler ausführlich über den Logger-Service. Im Fehlerfall geben die Methoden `false` oder `null` zurück.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Der Service verwendet Cookie-basierte Authentifizierung
|
||||
- Automatisches Re-Authentifizieren bei abgelaufener Session
|
||||
- Zwischen Batch-Uploads wird eine Verzögerung von 500ms eingebaut
|
||||
- Der Service sollte mit `Dispose()` aufgeräumt werden
|
||||
|
||||
## Sicherheit
|
||||
|
||||
⚠️ **Wichtig**: Speichere sensible Daten (Passwörter) nicht in der `app.settings.json` im Produktivbetrieb. Verwende stattdessen:
|
||||
- Umgebungsvariablen
|
||||
- Azure Key Vault
|
||||
- Windows Credential Manager
|
||||
- Verschlüsselte Konfigurationsdateien
|
||||
@ -1,265 +0,0 @@
|
||||
# Lychee Session 419 Debugging Guide
|
||||
|
||||
## Problem: Session Expired (HTTP 419)
|
||||
|
||||
Der Fehler tritt auf, wenn Lychee meldet: **"SessionExpiredException - Session expired"**
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
Der Fehler tritt auf wenn:
|
||||
1. ✅ CSRF-Token wird erfolgreich geholt
|
||||
2. ❌ Aber die Session zwischen CSRF-Fetch und Login abläuft
|
||||
3. ❌ Oder Cookies werden nicht korrekt zwischen Requests weitergegeben
|
||||
|
||||
### Debug-Prozess
|
||||
|
||||
#### Schritt 1: Cookie-Logging aktiviert
|
||||
|
||||
Die aktuelle Implementation loggt jetzt detailliert:
|
||||
|
||||
```
|
||||
[DEBUG] Hole CSRF-Token von: https://cambooth-pics.rblnews.de
|
||||
[DEBUG] Cookies im Container: 2
|
||||
[DEBUG] Cookie: lychee_session = eyJpdiI6... (Domain: .rblnews.de, Path: /)
|
||||
[DEBUG] Cookie: XSRF-TOKEN = eyJpdiI6... (Domain: .rblnews.de, Path: /)
|
||||
[DEBUG] ✅ CSRF-Token aus Cookie erhalten: eyJpdiI6...
|
||||
[DEBUG] Cookies für Login-Request: 2
|
||||
[DEBUG] → lychee_session = eyJpdiI6...
|
||||
[DEBUG] → XSRF-TOKEN = eyJpdiI6...
|
||||
[DEBUG] Sende Login-Request an: https://cambooth-pics.rblnews.de/api/Session::login
|
||||
```
|
||||
|
||||
#### Schritt 2: Überprüfe Cookie-Domains
|
||||
|
||||
**Wichtig:** Cookie-Domains müssen übereinstimmen!
|
||||
|
||||
**Problem-Szenarien:**
|
||||
|
||||
1. **Hauptseite:** `https://cambooth-pics.rblnews.de`
|
||||
- Cookie Domain: `.rblnews.de` oder `cambooth-pics.rblnews.de`
|
||||
|
||||
2. **API-Endpunkt:** `https://cambooth-pics.rblnews.de/api/Session::login`
|
||||
- Muss die gleiche Domain verwenden
|
||||
|
||||
**Lösung:** Stelle sicher, dass `LycheeApiUrl` die vollständige Domain enthält:
|
||||
```json
|
||||
{
|
||||
"LycheeApiUrl": "https://cambooth-pics.rblnews.de"
|
||||
}
|
||||
```
|
||||
|
||||
**NICHT:**
|
||||
```json
|
||||
{
|
||||
"LycheeApiUrl": "https://cambooth-pics.rblnews.de/api" // ❌ Falsch
|
||||
}
|
||||
```
|
||||
|
||||
### Mögliche Ursachen & Lösungen
|
||||
|
||||
#### Ursache 1: Cookie-Path stimmt nicht
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
[DEBUG] Cookies im Container: 2
|
||||
[DEBUG] Cookies für Login-Request: 0 // ← Keine Cookies!
|
||||
```
|
||||
|
||||
**Lösung:**
|
||||
Cookies werden für einen bestimmten Path gesetzt. Wenn der Cookie-Path `/` ist, sollte er für alle Unterseiten gelten.
|
||||
|
||||
**Check:**
|
||||
```csharp
|
||||
foreach (Cookie cookie in cookies)
|
||||
{
|
||||
_logger.Debug($"Cookie Path: {cookie.Path}");
|
||||
}
|
||||
```
|
||||
|
||||
#### Ursache 2: Cookie-Domain ist zu spezifisch
|
||||
|
||||
**Symptom:**
|
||||
Cookie ist gesetzt für `cambooth-pics.rblnews.de` aber der Request geht an `www.cambooth-pics.rblnews.de`
|
||||
|
||||
**Lösung:**
|
||||
Stelle sicher, dass die URL in der Config exakt gleich ist wie die tatsächliche Lychee-URL.
|
||||
|
||||
#### Ursache 3: Session-Timeout zu kurz
|
||||
|
||||
**Symptom:**
|
||||
Session läuft zwischen CSRF-Fetch und Login ab (sehr unwahrscheinlich, da < 1 Sekunde)
|
||||
|
||||
**Lösung:**
|
||||
In Lychee `config/session.php`:
|
||||
```php
|
||||
'lifetime' => 120, // Minuten
|
||||
```
|
||||
|
||||
#### Ursache 4: Lychee verwendet Session-Store (Redis/Memcached)
|
||||
|
||||
**Symptom:**
|
||||
Session wird zwischen Requests nicht erkannt
|
||||
|
||||
**Lösung:**
|
||||
Prüfe Lychee `.env`:
|
||||
```env
|
||||
SESSION_DRIVER=cookie # Oder file
|
||||
```
|
||||
|
||||
**Nicht empfohlen für diesen Use-Case:**
|
||||
```env
|
||||
SESSION_DRIVER=redis # Kann Probleme verursachen
|
||||
```
|
||||
|
||||
### Test-Commands
|
||||
|
||||
#### Test 1: Manueller Cookie-Test mit curl
|
||||
|
||||
```bash
|
||||
# 1. Hole Cookies und CSRF-Token
|
||||
curl -v -c cookies.txt https://cambooth-pics.rblnews.de/
|
||||
|
||||
# 2. Extrahiere CSRF-Token aus cookies.txt
|
||||
# (XSRF-TOKEN Wert)
|
||||
|
||||
# 3. Login mit Cookies
|
||||
curl -v -b cookies.txt \
|
||||
-X POST https://cambooth-pics.rblnews.de/api/Session::login \
|
||||
-H "X-CSRF-TOKEN: [token]" \
|
||||
-H "X-XSRF-TOKEN: [token]" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=admin&password=yourpassword"
|
||||
```
|
||||
|
||||
#### Test 2: Prüfe Lychee Session-Config
|
||||
|
||||
```bash
|
||||
# In Lychee-Verzeichnis
|
||||
php artisan tinker
|
||||
> config('session.driver');
|
||||
> config('session.domain');
|
||||
> config('session.path');
|
||||
> config('session.secure');
|
||||
> config('session.http_only');
|
||||
> config('session.same_site');
|
||||
```
|
||||
|
||||
### Workaround-Optionen
|
||||
|
||||
#### Option 1: Session-basierte Authentifizierung überspringen
|
||||
|
||||
Einige Lychee-Versionen unterstützen API-Keys:
|
||||
|
||||
```csharp
|
||||
request.Headers.Add("Authorization", "Bearer YOUR_API_KEY");
|
||||
```
|
||||
|
||||
**Prüfe:** Lychee Einstellungen → API → Generate API Key
|
||||
|
||||
#### Option 2: Längere Delay zwischen Requests
|
||||
|
||||
```csharp
|
||||
await FetchCsrfTokenAsync();
|
||||
await Task.Delay(500); // 500ms Pause
|
||||
// Login...
|
||||
```
|
||||
|
||||
**Hinweis:** Sollte nicht nötig sein, kann aber bei Netzwerk-Latenzen helfen.
|
||||
|
||||
#### Option 3: Cookie-Container Debugging
|
||||
|
||||
Füge temporär hinzu:
|
||||
|
||||
```csharp
|
||||
// Nach FetchCsrfTokenAsync()
|
||||
var allCookies = _cookieContainer.GetAllCookies();
|
||||
foreach (Cookie cookie in allCookies)
|
||||
{
|
||||
_logger.Debug($"ALL Cookies: {cookie.Name} | Domain: {cookie.Domain} | Path: {cookie.Path} | Expires: {cookie.Expires}");
|
||||
}
|
||||
```
|
||||
|
||||
### Lychee-spezifische Fixes
|
||||
|
||||
#### Fix 1: Lychee CORS-Einstellungen
|
||||
|
||||
Wenn Lychee hinter einem Proxy steht, prüfe:
|
||||
|
||||
**In `.env`:**
|
||||
```env
|
||||
TRUSTED_PROXIES=*
|
||||
```
|
||||
|
||||
**In `config/cors.php`:**
|
||||
```php
|
||||
'paths' => ['api/*'],
|
||||
'allowed_methods' => ['*'],
|
||||
'allowed_origins' => ['*'],
|
||||
'allowed_headers' => ['*'],
|
||||
'exposed_headers' => [],
|
||||
'max_age' => 0,
|
||||
'supports_credentials' => true, // ← Wichtig für Cookies!
|
||||
```
|
||||
|
||||
#### Fix 2: Laravel Session-Cookie Settings
|
||||
|
||||
**In `config/session.php`:**
|
||||
```php
|
||||
'same_site' => 'lax', // Nicht 'strict'
|
||||
'secure' => false, // Bei HTTPS auf true setzen
|
||||
'http_only' => true,
|
||||
'domain' => null, // Oder '.rblnews.de'
|
||||
```
|
||||
|
||||
### Debugging-Output Interpretation
|
||||
|
||||
#### Erfolgreicher Flow:
|
||||
```
|
||||
[DEBUG] Hole CSRF-Token von: https://cambooth-pics.rblnews.de
|
||||
[DEBUG] Cookies im Container: 2
|
||||
[DEBUG] Cookie: lychee_session = ... (Domain: .rblnews.de, Path: /)
|
||||
[DEBUG] Cookie: XSRF-TOKEN = ... (Domain: .rblnews.de, Path: /)
|
||||
[DEBUG] ✅ CSRF-Token aus Cookie erhalten: ...
|
||||
[DEBUG] Cookies für Login-Request: 2
|
||||
[DEBUG] → lychee_session = ...
|
||||
[DEBUG] → XSRF-TOKEN = ...
|
||||
[DEBUG] Sende Login-Request an: ...
|
||||
[DEBUG] Login Response: {"success":true}
|
||||
[INFO] ✅ Lychee-Authentifizierung erfolgreich!
|
||||
```
|
||||
|
||||
#### Fehlerhafter Flow (Keine Cookies):
|
||||
```
|
||||
[DEBUG] Hole CSRF-Token von: https://cambooth-pics.rblnews.de
|
||||
[DEBUG] Cookies im Container: 0 // ← Problem!
|
||||
[WARNING] ⚠️ CSRF-Token konnte nicht gefunden werden.
|
||||
```
|
||||
|
||||
#### Fehlerhafter Flow (Cookies nicht weitergegeben):
|
||||
```
|
||||
[DEBUG] Cookies im Container: 2
|
||||
[DEBUG] ✅ CSRF-Token aus Cookie erhalten: ...
|
||||
[DEBUG] Cookies für Login-Request: 0 // ← Problem!
|
||||
[ERROR] ❌ Lychee-Authentifizierung fehlgeschlagen. Status: 419
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Kompiliere die App neu** mit den neuen Debug-Logs
|
||||
2. **Starte komplett neu**
|
||||
3. **Mache einen Test-Upload**
|
||||
4. **Prüfe die Debug-Logs** und identifiziere das genaue Problem
|
||||
5. **Wende entsprechende Lösung an** (siehe oben)
|
||||
|
||||
### Support-Informationen sammeln
|
||||
|
||||
Bei weiterhin Problemen, sammle:
|
||||
|
||||
```
|
||||
1. Debug-Logs (alle [DEBUG] Zeilen)
|
||||
2. Lychee-Version: Settings → About
|
||||
3. Session-Driver: php artisan tinker > config('session.driver')
|
||||
4. Cookie-Einstellungen: config('session.*)
|
||||
5. URL in app.settings.json
|
||||
6. Browser-Test: Manuell einloggen und Network-Tab prüfen
|
||||
```
|
||||
@ -1,262 +0,0 @@
|
||||
# Lychee Upload - Troubleshooting Guide
|
||||
|
||||
## Häufige Fehler und Lösungen
|
||||
|
||||
### HTTP 419 - Page Expired / CSRF Token Mismatch
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
Status: 419, ReasonPhrase: 'status code 419'
|
||||
```
|
||||
|
||||
**Ursache:**
|
||||
- Fehlender oder ungültiger CSRF-Token
|
||||
- Session abgelaufen
|
||||
|
||||
**Lösung:**
|
||||
- ✅ **Implementiert:** Service holt automatisch CSRF-Token vor Login
|
||||
- ✅ **Implementiert:** CSRF-Token wird bei jedem Request mitgesendet
|
||||
- **Manuell:** App neu starten um neue Session zu erstellen
|
||||
|
||||
---
|
||||
|
||||
### Content Type Unacceptable
|
||||
|
||||
**Symptom:**
|
||||
```json
|
||||
{
|
||||
"message": "Content type unacceptable. Content type \"html\" required",
|
||||
"exception": "UnexpectedContentType"
|
||||
}
|
||||
```
|
||||
|
||||
**Ursache:**
|
||||
Lychee erwartet `application/x-www-form-urlencoded` (HTML Form), nicht `application/json`
|
||||
|
||||
**Lösung:**
|
||||
- ✅ **BEHOBEN:** Login verwendet jetzt `FormUrlEncodedContent`
|
||||
- ✅ **BEHOBEN:** Album-Erstellung verwendet jetzt `FormUrlEncodedContent`
|
||||
- ✅ Upload verwendet korrekt `multipart/form-data`
|
||||
|
||||
**Code-Beispiel:**
|
||||
```csharp
|
||||
// FALSCH (JSON)
|
||||
var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json");
|
||||
|
||||
// RICHTIG (Form-Data)
|
||||
var formData = new Dictionary<string, string>
|
||||
{
|
||||
{ "username", username },
|
||||
{ "password", password }
|
||||
};
|
||||
var content = new FormUrlEncodedContent(formData);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Authentifizierung fehlschlägt
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
[WARNING] Lychee-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren.
|
||||
```
|
||||
|
||||
**Mögliche Ursachen:**
|
||||
|
||||
1. **Falsche Credentials**
|
||||
- Prüfe `LycheeUsername` und `LycheePassword` in `app.settings.json`
|
||||
- Teste Login manuell im Webinterface
|
||||
|
||||
2. **Falsche URL**
|
||||
- `LycheeApiUrl` sollte sein: `https://your-domain.com` (ohne `/api`)
|
||||
- **Nicht:** `https://your-domain.com/api`
|
||||
|
||||
3. **HTTPS-Zertifikat-Probleme**
|
||||
- Bei selbst-signierten Zertifikaten: Temporär deaktivieren für Tests
|
||||
```csharp
|
||||
_httpClientHandler.ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
```
|
||||
|
||||
4. **Lychee-Version**
|
||||
- Service ist für Lychee v4.x und v5.x getestet
|
||||
- Bei älteren Versionen können API-Endpunkte abweichen
|
||||
|
||||
---
|
||||
|
||||
### Upload schlägt fehl
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
[WARNING] ⚠️ Lychee-Upload fehlgeschlagen: img_xyz.jpg
|
||||
```
|
||||
|
||||
**Debugging-Schritte:**
|
||||
|
||||
1. **Logs prüfen:**
|
||||
```
|
||||
[DEBUG] Error Response: { ... }
|
||||
```
|
||||
Zeigt den genauen Fehler von Lychee
|
||||
|
||||
2. **Session prüfen:**
|
||||
- Ist `_isAuthenticated = true`?
|
||||
- Falls nicht: Automatisches Re-Auth sollte triggern
|
||||
|
||||
3. **Datei prüfen:**
|
||||
- Existiert die Datei?
|
||||
- Ist sie lesbar?
|
||||
- Ist es ein gültiges JPEG?
|
||||
|
||||
4. **Album-ID prüfen:**
|
||||
- Wenn `DefaultAlbumId` gesetzt: Existiert das Album?
|
||||
- Teste erst ohne Album-ID
|
||||
|
||||
---
|
||||
|
||||
## Debug-Modus aktivieren
|
||||
|
||||
Um detaillierte Logs zu sehen:
|
||||
|
||||
1. In `app.settings.json`:
|
||||
```json
|
||||
{
|
||||
"AppSettings": {
|
||||
"DebugConsoleVisible": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Alle Log-Level werden ausgegeben:
|
||||
- `[DEBUG]` - Detaillierte Informationen (CSRF-Token, Responses)
|
||||
- `[INFO]` - Normale Operationen
|
||||
- `[WARNING]` - Warnungen
|
||||
- `[ERROR]` - Fehler
|
||||
|
||||
---
|
||||
|
||||
## API-Endpunkt-Übersicht
|
||||
|
||||
### Lychee v4.x / v5.x
|
||||
|
||||
| Endpunkt | Method | Content-Type | CSRF? |
|
||||
|----------|--------|--------------|-------|
|
||||
| `/api/Session::login` | POST | `application/x-www-form-urlencoded` | ✅ Yes |
|
||||
| `/api/Session::logout` | POST | - | ✅ Yes |
|
||||
| `/api/Photo::add` | POST | `multipart/form-data` | ✅ Yes |
|
||||
| `/api/Album::add` | POST | `application/x-www-form-urlencoded` | ✅ Yes |
|
||||
|
||||
### Request-Headers (immer erforderlich)
|
||||
|
||||
```
|
||||
X-CSRF-TOKEN: [token]
|
||||
X-XSRF-TOKEN: [token]
|
||||
Accept: application/json
|
||||
X-Requested-With: XMLHttpRequest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lychee-Konfiguration prüfen
|
||||
|
||||
### 1. API aktiviert?
|
||||
|
||||
Lychee-Einstellungen → API Access → **Aktiviert**
|
||||
|
||||
### 2. Benutzer-Rechte
|
||||
|
||||
Der verwendete Benutzer muss:
|
||||
- Upload-Rechte haben
|
||||
- Album-Erstellungsrechte haben (falls verwendet)
|
||||
|
||||
### 3. Server-Limits
|
||||
|
||||
Prüfe `php.ini`:
|
||||
```ini
|
||||
upload_max_filesize = 20M
|
||||
post_max_size = 25M
|
||||
max_execution_time = 120
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test-Kommandos
|
||||
|
||||
### Test 1: CSRF-Token holen
|
||||
```bash
|
||||
curl -i https://your-lychee.com/
|
||||
# Suche nach: XSRF-TOKEN Cookie oder csrf-token Meta-Tag
|
||||
```
|
||||
|
||||
### Test 2: Login testen
|
||||
```bash
|
||||
curl -X POST https://your-lychee.com/api/Session::login \
|
||||
-H "X-CSRF-TOKEN: your-token" \
|
||||
-H "X-XSRF-TOKEN: your-token" \
|
||||
-H "Accept: application/json" \
|
||||
-H "X-Requested-With: XMLHttpRequest" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=admin&password=yourpassword"
|
||||
```
|
||||
|
||||
### Test 3: Session prüfen
|
||||
```bash
|
||||
curl -X POST https://your-lychee.com/api/Session::info \
|
||||
-H "Cookie: lychee_session=..." \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Probleme
|
||||
|
||||
### Problem: CSRF-Token wird nicht gefunden
|
||||
|
||||
**Lösung:**
|
||||
- Stelle sicher, dass die Lychee-Hauptseite erreichbar ist
|
||||
- Prüfe, ob XSRF-TOKEN Cookie gesetzt wird
|
||||
- Fallback auf HTML-Parsing sollte automatisch erfolgen
|
||||
|
||||
### Problem: Cookies werden nicht gespeichert
|
||||
|
||||
**Lösung:**
|
||||
- `CookieContainer` wird korrekt initialisiert
|
||||
- Prüfe `UseCookies = true` in `HttpClientHandler`
|
||||
- Bei HTTPS: Zertifikat muss gültig sein
|
||||
|
||||
### Problem: Upload zu langsam
|
||||
|
||||
**Lösung:**
|
||||
- Erhöhe `_httpClient.Timeout` (aktuell: 5 Minuten)
|
||||
- Prüfe Netzwerkverbindung zur Lychee-Instanz
|
||||
- Bei vielen Uploads: Batch-Size reduzieren
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
Bei weiteren Problemen:
|
||||
|
||||
1. **Logs sammeln:**
|
||||
- Debug-Konsole aktivieren
|
||||
- Kompletten Log-Output kopieren
|
||||
- Besonders `[DEBUG]` und `[ERROR]` Zeilen
|
||||
|
||||
2. **Lychee-Logs prüfen:**
|
||||
- `storage/logs/laravel.log` auf dem Server
|
||||
|
||||
3. **Browser-DevTools:**
|
||||
- Manuell im Webinterface anmelden
|
||||
- Network-Tab beobachten
|
||||
- Request/Response-Headers vergleichen
|
||||
|
||||
4. **Konfiguration teilen:**
|
||||
```json
|
||||
{
|
||||
"LycheeSettings": {
|
||||
"ApiUrl": "...",
|
||||
"Username": "...",
|
||||
"Password": "[REDACTED]"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -1,288 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die Upload-Queue und führt Uploads im Hintergrund durch
|
||||
/// </summary>
|
||||
public class UploadQueueService : IDisposable
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly LycheeUploadService _lycheeUploadService;
|
||||
private readonly UploadTracker _uploadTracker;
|
||||
private readonly Queue<string> _uploadQueue;
|
||||
private Task? _uploadTask;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isRunning = false;
|
||||
private readonly object _queueLock = new object();
|
||||
|
||||
// Konfigurierbare Parameter
|
||||
private const int UploadRetryDelayMs = 5000; // 5 Sekunden Wartezeit zwischen Retries
|
||||
private const int MaxRetries = 3;
|
||||
private const int CheckIntervalMs = 2000; // Überprüfe Queue alle 2 Sekunden
|
||||
|
||||
public UploadQueueService(Logger logger, AppSettingsService appSettings, LycheeUploadService lycheeUploadService)
|
||||
{
|
||||
_logger = logger;
|
||||
_appSettings = appSettings;
|
||||
_lycheeUploadService = lycheeUploadService;
|
||||
_uploadTracker = new UploadTracker(logger, "UploadTracking");
|
||||
_uploadQueue = new Queue<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet den Upload-Service
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning)
|
||||
{
|
||||
_logger.Warning("Upload-Service läuft bereits");
|
||||
return;
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_uploadTask = ProcessQueueAsync(_cancellationTokenSource.Token);
|
||||
_logger.Info("Upload-Service gestartet");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stoppt den Upload-Service
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (!_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRunning = false;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
|
||||
if (_uploadTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uploadTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Erwartet
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("Upload-Service gestoppt");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scannt das Bild-Verzeichnis und fügt fehlgeschlagene Uploads zur Queue hinzu
|
||||
/// </summary>
|
||||
public void ScanAndQueueFailedUploads()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_appSettings.LycheeAutoUploadEnabled)
|
||||
{
|
||||
_logger.Debug("Lychee Auto-Upload ist deaktiviert");
|
||||
return;
|
||||
}
|
||||
|
||||
var failedFiles = _uploadTracker.GetFailedUploads(_appSettings.PictureLocation);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var filePath in failedFiles)
|
||||
{
|
||||
if (!_uploadQueue.Contains(filePath))
|
||||
{
|
||||
_uploadQueue.Enqueue(filePath);
|
||||
_logger.Debug($"Fehlgeschlagenes Upload in Queue hinzugefügt: {Path.GetFileName(filePath)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.Count > 0)
|
||||
{
|
||||
_logger.Info($"{failedFiles.Count} fehlgeschlagene Uploads in Queue hinzugefügt");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Scannen fehlgeschlagener Uploads: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt ein neu erstelltes Foto zur Upload-Queue hinzu
|
||||
/// </summary>
|
||||
public void QueueNewPhoto(string photoPath)
|
||||
{
|
||||
if (!_appSettings.LycheeAutoUploadEnabled)
|
||||
{
|
||||
_logger.Debug("Lychee Auto-Upload ist deaktiviert");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (!_uploadQueue.Contains(photoPath))
|
||||
{
|
||||
_uploadQueue.Enqueue(photoPath);
|
||||
_logger.Info($"Neues Foto in Upload-Queue hinzugefügt: {Path.GetFileName(photoPath)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt die aktuelle Größe der Upload-Queue zurück
|
||||
/// </summary>
|
||||
public int GetQueueSize()
|
||||
{
|
||||
lock (_queueLock)
|
||||
{
|
||||
return _uploadQueue.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hauptschleife für die Verarbeitung der Upload-Queue
|
||||
/// </summary>
|
||||
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("Upload-Queue-Verarbeitung gestartet");
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? filePath = null;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_uploadQueue.Count > 0)
|
||||
{
|
||||
filePath = _uploadQueue.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath != null)
|
||||
{
|
||||
await ProcessUploadAsync(filePath, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Kurz warten, wenn Queue leer
|
||||
await Task.Delay(CheckIntervalMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler in Upload-Queue-Verarbeitung: {ex.Message}");
|
||||
await Task.Delay(UploadRetryDelayMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Info("Upload-Queue-Verarbeitung wurde abgebrochen");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verarbeitet den Upload einer einzelnen Datei mit Retry-Logik
|
||||
/// </summary>
|
||||
private async Task ProcessUploadAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
try
|
||||
{
|
||||
// Prüfe, ob Datei noch existiert
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.Warning($"Bild nicht gefunden, überspringe: {fileName}");
|
||||
_uploadTracker.MarkAsFailedUpload(fileName, "Datei nicht gefunden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe, ob bereits hochgeladen
|
||||
if (_uploadTracker.IsUploaded(filePath))
|
||||
{
|
||||
_logger.Debug($"Bild bereits hochgeladen: {fileName}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Versuche Upload mit Retry
|
||||
bool uploadSuccess = false;
|
||||
string? lastError = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Upload-Versuch {attempt}/{MaxRetries} für {fileName}...");
|
||||
|
||||
var albumId = _appSettings.LycheeDefaultAlbumId;
|
||||
uploadSuccess = await _lycheeUploadService.UploadImageAsync(filePath, albumId);
|
||||
|
||||
if (uploadSuccess)
|
||||
{
|
||||
_uploadTracker.MarkAsUploaded(filePath);
|
||||
_logger.Info($"✅ Upload erfolgreich: {fileName}");
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastError = "Upload-Fehler vom Service";
|
||||
_logger.Warning($"Upload fehlgeschlagen (Versuch {attempt}): {fileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
_logger.Error($"Fehler bei Upload-Versuch {attempt}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Warte vor nächstem Versuch (nicht nach dem letzten)
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(UploadRetryDelayMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Versuche fehlgeschlagen
|
||||
_uploadTracker.MarkAsFailedUpload(filePath, lastError ?? "Unbekannter Fehler");
|
||||
_logger.Error($"❌ Upload fehlgeschlagen nach {MaxRetries} Versuchen: {fileName}");
|
||||
|
||||
// Füge wieder zur Queue hinzu für späteren Retry (längerer Abstand)
|
||||
_logger.Info($"Bild wird später erneut versucht: {fileName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Kritischer Fehler bei Upload-Verarbeitung von {fileName}: {ex.Message}");
|
||||
_uploadTracker.MarkAsFailedUpload(filePath, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopAsync().Wait(TimeSpan.FromSeconds(5));
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_lycheeUploadService?.Dispose();
|
||||
}
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Verfolgt welche Bilder bereits hochgeladen wurden
|
||||
/// </summary>
|
||||
public class UploadTracker
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly string _trackerFilePath;
|
||||
private Dictionary<string, UploadedImageInfo> _uploadedImages;
|
||||
|
||||
public class UploadedImageInfo
|
||||
{
|
||||
public string FileName { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
public string? LycheeId { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
public DateTime? LastRetryAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public UploadTracker(Logger logger, string trackerDirectory = "")
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
// Verwende absoluten Pfad
|
||||
if (string.IsNullOrWhiteSpace(trackerDirectory))
|
||||
{
|
||||
trackerDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UploadTracking");
|
||||
}
|
||||
else if (!Path.IsPathRooted(trackerDirectory))
|
||||
{
|
||||
trackerDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, trackerDirectory);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(trackerDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(trackerDirectory);
|
||||
}
|
||||
|
||||
_trackerFilePath = Path.Combine(trackerDirectory, "upload_history.json");
|
||||
_uploadedImages = new Dictionary<string, UploadedImageInfo>();
|
||||
|
||||
LoadFromDisk();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt den Upload-History aus der Datei
|
||||
/// </summary>
|
||||
private void LoadFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_trackerFilePath))
|
||||
{
|
||||
var json = File.ReadAllText(_trackerFilePath);
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, UploadedImageInfo>>(json);
|
||||
_uploadedImages = data ?? new Dictionary<string, UploadedImageInfo>();
|
||||
_logger.Info($"Upload-History geladen: {_uploadedImages.Count} Einträge");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Laden der Upload-History: {ex.Message}");
|
||||
_uploadedImages = new Dictionary<string, UploadedImageInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Speichert den Upload-History auf die Festplatte
|
||||
/// </summary>
|
||||
private void SaveToDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_uploadedImages, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_trackerFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Speichern der Upload-History: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markiert ein Bild als erfolgreich hochgeladen
|
||||
/// </summary>
|
||||
public void MarkAsUploaded(string fileName, string? lycheeId = null)
|
||||
{
|
||||
var fileNameOnly = Path.GetFileName(fileName);
|
||||
|
||||
_uploadedImages[fileNameOnly] = new UploadedImageInfo
|
||||
{
|
||||
FileName = fileNameOnly,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
LycheeId = lycheeId,
|
||||
RetryCount = 0,
|
||||
LastRetryAt = null,
|
||||
ErrorMessage = null
|
||||
};
|
||||
|
||||
SaveToDisk();
|
||||
_logger.Info($"Bild als hochgeladen markiert: {fileNameOnly}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markiert einen Upload-Fehler für ein Bild
|
||||
/// </summary>
|
||||
public void MarkAsFailedUpload(string fileName, string? errorMessage = null)
|
||||
{
|
||||
var fileNameOnly = Path.GetFileName(fileName);
|
||||
|
||||
if (_uploadedImages.ContainsKey(fileNameOnly))
|
||||
{
|
||||
var info = _uploadedImages[fileNameOnly];
|
||||
info.RetryCount++;
|
||||
info.LastRetryAt = DateTime.UtcNow;
|
||||
info.ErrorMessage = errorMessage;
|
||||
}
|
||||
else
|
||||
{
|
||||
_uploadedImages[fileNameOnly] = new UploadedImageInfo
|
||||
{
|
||||
FileName = fileNameOnly,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
RetryCount = 1,
|
||||
LastRetryAt = DateTime.UtcNow,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
SaveToDisk();
|
||||
_logger.Warning($"Upload-Fehler für {fileNameOnly}: {errorMessage}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Bild bereits hochgeladen wurde
|
||||
/// </summary>
|
||||
public bool IsUploaded(string fileName)
|
||||
{
|
||||
var fileNameOnly = Path.GetFileName(fileName);
|
||||
return _uploadedImages.ContainsKey(fileNameOnly) && _uploadedImages[fileNameOnly].UploadedAt != default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt alle Bilder zurück, die noch nicht hochgeladen wurden
|
||||
/// </summary>
|
||||
public List<string> GetFailedUploads(string pictureDirectory)
|
||||
{
|
||||
var failedFiles = new List<string>();
|
||||
|
||||
if (!Directory.Exists(pictureDirectory))
|
||||
{
|
||||
return failedFiles;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var allFiles = Directory.GetFiles(pictureDirectory, "img_*.jpg");
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
// Wenn nicht im Tracker oder mit Fehler → zu Retry-Liste hinzufügen
|
||||
if (!_uploadedImages.ContainsKey(fileName) ||
|
||||
_uploadedImages[fileName].UploadedAt == default)
|
||||
{
|
||||
failedFiles.Add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Scannen von {pictureDirectory}: {ex.Message}");
|
||||
}
|
||||
|
||||
return failedFiles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt die Anzahl fehlgeschlagener Uploads zurück
|
||||
/// </summary>
|
||||
public int GetFailedUploadCount()
|
||||
{
|
||||
return _uploadedImages.Count(x => x.Value.UploadedAt == default);
|
||||
}
|
||||
}
|
||||
@ -1,204 +0,0 @@
|
||||
# Lychee v2 API Session Management
|
||||
|
||||
## Problem: Session Expired (nach erfolgreichem Login)
|
||||
|
||||
Der Fehler tritt auf, wenn:
|
||||
1. ✅ Login ist erfolgreich
|
||||
2. ❌ Aber der nächste Request (Upload) bekommt "Session expired"
|
||||
|
||||
## Root Cause Analyse
|
||||
|
||||
Lychee v2 API kann auf zwei Arten authentifizieren:
|
||||
|
||||
### Option A: Cookie-basiert (Session)
|
||||
```
|
||||
1. POST /api/v2/Auth::login
|
||||
2. Lychee setzt Session-Cookie (z.B. PHPSESSID)
|
||||
3. Browser/Client speichert Cookie automatisch
|
||||
4. Nächster Request sendet Cookie automatisch
|
||||
```
|
||||
|
||||
**Problem:** Session läuft ab oder wird nicht richtig gespeichert
|
||||
|
||||
### Option B: Token-basiert (API-Token)
|
||||
```
|
||||
1. POST /api/v2/Auth::login
|
||||
2. Lychee gibt back: {"api_token": "abc123..."}
|
||||
3. Client speichert Token
|
||||
4. Nächster Request: Authorization: Bearer abc123
|
||||
```
|
||||
|
||||
**Besser:** Funktioniert auch wenn Session abläuft
|
||||
|
||||
## Debug Output interpretieren
|
||||
|
||||
### Erfolgreich (Variante A - Cookie):
|
||||
```
|
||||
[DEBUG] Login Response: {"success":true,...}
|
||||
[DEBUG] Cookies nach Login: 1
|
||||
[DEBUG] Cookie: PHPSESSID = abc123def456...
|
||||
[DEBUG] Cookies für Upload: 1
|
||||
[DEBUG] Cookie: PHPSESSID = abc123def456...
|
||||
[INFO] Upload erfolgreich
|
||||
```
|
||||
|
||||
### Erfolgreich (Variante B - Token):
|
||||
```
|
||||
[DEBUG] Login Response: {"success":true,"api_token":"xyz789..."}
|
||||
[DEBUG] ✅ API-Token erhalten: xyz789...
|
||||
[DEBUG] Authorization Header vorhanden: true
|
||||
[INFO] Upload erfolgreich
|
||||
```
|
||||
|
||||
### Fehlgeschlagen (Session abgelaufen):
|
||||
```
|
||||
[DEBUG] Cookies nach Login: 0 // ← Kein Cookie!
|
||||
[DEBUG] Cookies für Upload: 0 // ← Immer noch kein Cookie!
|
||||
[ERROR] Session expired
|
||||
```
|
||||
|
||||
### Fehlgeschlagen (Token nicht in Response):
|
||||
```
|
||||
[DEBUG] Login Response: {...keine api_token...}
|
||||
[DEBUG] Authorization Header vorhanden: false
|
||||
[ERROR] Session expired
|
||||
```
|
||||
|
||||
## Lösungsansätze
|
||||
|
||||
### Lösung 1: API-Token verwenden (empfohlen)
|
||||
|
||||
Der Code prüft jetzt automatisch auf `api_token` in der Login-Response:
|
||||
|
||||
```csharp
|
||||
// Wenn vorhanden:
|
||||
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {tokenValue}");
|
||||
```
|
||||
|
||||
**Vorteil:**
|
||||
- ✅ Token verfällt nicht zwischen Requests
|
||||
- ✅ Kein Cookie-Handling nötig
|
||||
- ✅ Funktioniert über verschiedene Netzwerke hinweg
|
||||
|
||||
### Lösung 2: Cookie-Session beibehalten
|
||||
|
||||
Wenn Lychee nur Cookies nutzt:
|
||||
|
||||
```csharp
|
||||
// CookieContainer sollte Cookies speichern
|
||||
var cookieContainer = new CookieContainer();
|
||||
var handler = new HttpClientHandler { CookieContainer = cookieContainer };
|
||||
```
|
||||
|
||||
**Problem:** Diese Konfiguration ist bereits vorhanden!
|
||||
|
||||
Mögliche Ursachen:
|
||||
- Cookie-Domain stimmt nicht
|
||||
- Cookie-Path ist zu restriktiv
|
||||
- Session-Timeout in Lychee ist sehr kurz
|
||||
|
||||
### Lösung 3: Jeder Upload neu authentifizieren
|
||||
|
||||
Falls Session kurz abläuft:
|
||||
|
||||
```csharp
|
||||
// Vor jedem Upload neu authentifizieren
|
||||
await AuthenticateAsync();
|
||||
await UploadImageAsync(path);
|
||||
```
|
||||
|
||||
## Lychee Server-Konfiguration prüfen
|
||||
|
||||
### In `.env`:
|
||||
```env
|
||||
SESSION_DRIVER=cookie # Oder file
|
||||
SESSION_LIFETIME=1440 # 24 Stunden
|
||||
SESSION_SECURE=false # Nur für HTTP, true für HTTPS
|
||||
SESSION_HTTP_ONLY=true
|
||||
```
|
||||
|
||||
### In `config/session.php`:
|
||||
```php
|
||||
'lifetime' => 1440, // Minuten
|
||||
'same_site' => 'lax',
|
||||
'secure' => false, // HTTPS in Production
|
||||
'http_only' => true,
|
||||
'path' => '/',
|
||||
'domain' => null,
|
||||
```
|
||||
|
||||
## Expected v2 API Responses
|
||||
|
||||
### Login erfolgreich (mit Token):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"api_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Login erfolgreich (ohne Token):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Upload erfolgreich:
|
||||
```json
|
||||
{
|
||||
"id": "5c123abc456",
|
||||
"title": "img_xyz.jpg",
|
||||
"albums": ["2"],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Debug-Strategie
|
||||
|
||||
1. **Kompiliere und starte die App neu**
|
||||
2. **Mache einen Upload-Test**
|
||||
3. **Suche in den Logs nach:**
|
||||
- `[DEBUG] Login Response:` → Siehst du `api_token`?
|
||||
- `[DEBUG] Authorization Header vorhanden:` → true oder false?
|
||||
- `[DEBUG] Cookies nach Login:` → Wie viele?
|
||||
- `[DEBUG] Cookies für Upload:` → Sind sie noch vorhanden?
|
||||
|
||||
4. **Je nach Ergebnis:**
|
||||
- **Wenn api_token vorhanden:** ✅ Code wird ihn verwenden
|
||||
- **Wenn kein api_token, aber Cookies:** Code nutzt Cookies (hoffentlich funktioniert's)
|
||||
- **Wenn keine Cookies und kein Token:** ❌ Problem! Session wird nicht weitergegeben
|
||||
|
||||
## Weitere Test-Options
|
||||
|
||||
### curl Test mit API-Token:
|
||||
```bash
|
||||
# Login und Token extrahieren
|
||||
TOKEN=$(curl -s -X POST https://cambooth-pics.rblnews.de/api/v2/Auth::login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"itob","password":"VfVyqal&Nv8U&P"}' | jq -r '.api_token')
|
||||
|
||||
# Upload mit Token
|
||||
curl -X POST https://cambooth-pics.rblnews.de/api/v2/Photos::upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@photo.jpg"
|
||||
```
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Die aktualisierte Code-Version:
|
||||
1. ✅ Prüft auf `api_token` in der Login-Response
|
||||
2. ✅ Setzt Authorization Header falls Token vorhanden
|
||||
3. ✅ Fallback auf Cookie-basierte Authentifizierung
|
||||
4. ✅ Detailliertes Debug-Logging für Troubleshooting
|
||||
|
||||
Kompiliere die App neu und teste. Die Debug-Logs werden Dir zeigen, ob API-Token oder Cookies verwendet werden!
|
||||
@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Service für die Token-basierte Authentifizierung bei PhotoPrism.
|
||||
/// </summary>
|
||||
public class PhotoPrismAuthService
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly Logger _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private string _accessToken = string.Empty;
|
||||
private bool _isAuthenticated = false;
|
||||
|
||||
public bool IsAuthenticated => _isAuthenticated;
|
||||
public string AccessToken => _accessToken;
|
||||
|
||||
public PhotoPrismAuthService(AppSettingsService appSettings, Logger logger)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentifiziert sich bei PhotoPrism mit Username/Password oder API-Key.
|
||||
/// </summary>
|
||||
public async Task<bool> AuthenticateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Starte PhotoPrism-Authentifizierung...");
|
||||
|
||||
if (!ValidateConfiguration())
|
||||
return false;
|
||||
|
||||
// Authentifizierung mit API-Key, falls vorhanden
|
||||
if (!string.IsNullOrWhiteSpace(_appSettings.PhotoPrismApiKey))
|
||||
{
|
||||
_logger.Info("Authentifiziere mit API-Key...");
|
||||
_accessToken = _appSettings.PhotoPrismApiKey;
|
||||
_isAuthenticated = true;
|
||||
_logger.Info("Authentifizierung mit API-Key erfolgreich!");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: Login mit Username/Password
|
||||
return await AuthenticateWithCredentialsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler bei der PhotoPrism-Authentifizierung: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> AuthenticateWithCredentialsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Authentifiziere mit Username/Password...");
|
||||
|
||||
var loginRequest = new
|
||||
{
|
||||
username = _appSettings.PhotoPrismUsername,
|
||||
password = _appSettings.PhotoPrismPassword
|
||||
};
|
||||
|
||||
var requestContent = new StringContent(
|
||||
JsonSerializer.Serialize(loginRequest),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync(
|
||||
$"{_appSettings.PhotoPrismApiUrl}/api/v1/auth/login",
|
||||
requestContent);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.Error($"Login fehlgeschlagen ({response.StatusCode}): {errorContent}");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.Error("❌ API-Endpoint nicht gefunden (404)");
|
||||
_logger.Error($" Überprüfe die PhotoPrism API-URL: {_appSettings.PhotoPrismApiUrl}");
|
||||
_logger.Error($" (Stelle sicher, dass es eine PhotoPrism-Instanz ist, nicht Lychee!)");
|
||||
}
|
||||
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.Error("❌ Falsche Credentials (401)");
|
||||
_logger.Error($" Username: {_appSettings.PhotoPrismUsername}");
|
||||
_logger.Error($" Überprüfe Passwort und Benutzer");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var jsonResponse = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(jsonResponse);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.TryGetProperty("access_token", out var tokenProp))
|
||||
{
|
||||
_accessToken = tokenProp.GetString() ?? string.Empty;
|
||||
_isAuthenticated = true;
|
||||
_logger.Info("Authentifizierung mit Credentials erfolgreich!");
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Error("Kein Access-Token in der Authentifizierungsantwort gefunden");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler bei der Credentials-Authentifizierung: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet sich von PhotoPrism ab.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_isAuthenticated)
|
||||
return;
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{_appSettings.PhotoPrismApiUrl}/api/v1/auth/logout");
|
||||
request.Headers.Add("X-Auth-Token", _accessToken);
|
||||
|
||||
await _httpClient.SendAsync(request);
|
||||
_isAuthenticated = false;
|
||||
_accessToken = string.Empty;
|
||||
_logger.Info("Von PhotoPrism abgemeldet");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Abmelden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateConfiguration()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_appSettings.PhotoPrismApiUrl))
|
||||
{
|
||||
_logger.Error("PhotoPrism API-URL ist nicht konfiguriert");
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasApiKey = !string.IsNullOrWhiteSpace(_appSettings.PhotoPrismApiKey);
|
||||
var hasCredentials = !string.IsNullOrWhiteSpace(_appSettings.PhotoPrismUsername) &&
|
||||
!string.IsNullOrWhiteSpace(_appSettings.PhotoPrismPassword);
|
||||
|
||||
if (!hasApiKey && !hasCredentials)
|
||||
{
|
||||
_logger.Error("Weder API-Key noch Credentials für PhotoPrism konfiguriert");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
<Window x:Class="CamBooth.App.Features.PhotoPrismUpload.PhotoPrismQRCodeDisplayWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
Title="PhotoPrism Album QR-Code"
|
||||
Height="600"
|
||||
Width="600"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="#1F1A00">
|
||||
<Grid Background="#1F1A00">
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Vertical">
|
||||
<TextBlock Text="PhotoPrism Album"
|
||||
FontSize="28"
|
||||
Foreground="#D4AF37"
|
||||
TextAlignment="Center"
|
||||
Margin="0,0,0,20"
|
||||
FontWeight="Bold"/>
|
||||
|
||||
<Image x:Name="QRCodeImage"
|
||||
Height="400"
|
||||
Width="400"
|
||||
Margin="20"/>
|
||||
|
||||
<TextBlock Text="Mit einem QR-Code-Scanner scannen"
|
||||
FontSize="16"
|
||||
Foreground="White"
|
||||
TextAlignment="Center"
|
||||
Margin="0,20,0,0"/>
|
||||
|
||||
<Button Click="CloseButton_Click"
|
||||
Content="Schließen"
|
||||
Width="200"
|
||||
Height="50"
|
||||
FontSize="16"
|
||||
Margin="0,30,0,0"
|
||||
Background="#D4AF37"
|
||||
Foreground="#1F1A00"
|
||||
FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@ -1,11 +1,11 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
namespace CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
public partial class QRCodeDisplayWindow : Window
|
||||
public partial class PhotoPrismQRCodeDisplayWindow : Window
|
||||
{
|
||||
public QRCodeDisplayWindow()
|
||||
public PhotoPrismQRCodeDisplayWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die Upload-Queue für PhotoPrism und führt Uploads im Hintergrund durch.
|
||||
/// </summary>
|
||||
public class PhotoPrismUploadQueueService : IDisposable
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly PhotoPrismUploadService _uploadService;
|
||||
private readonly PhotoPrismUploadTracker _uploadTracker;
|
||||
private readonly Queue<string> _uploadQueue = new();
|
||||
private readonly object _queueLock = new();
|
||||
|
||||
private Task? _uploadTask;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isRunning = false;
|
||||
|
||||
private const int UploadRetryDelayMs = 5_000;
|
||||
private const int MaxRetries = 3;
|
||||
private const int CheckIntervalMs = 2_000;
|
||||
|
||||
public PhotoPrismUploadQueueService(
|
||||
Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PhotoPrismUploadService uploadService)
|
||||
{
|
||||
_logger = logger;
|
||||
_appSettings = appSettings;
|
||||
_uploadService = uploadService;
|
||||
_uploadTracker = new PhotoPrismUploadTracker(logger, "PhotoPrismUploadTracking");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Public API
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Startet den Upload-Service.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning)
|
||||
{
|
||||
_logger.Warning("PhotoPrism Upload-Service läuft bereits");
|
||||
return;
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_uploadTask = ProcessQueueAsync(_cancellationTokenSource.Token);
|
||||
_logger.Info("PhotoPrism Upload-Service gestartet");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stoppt den Upload-Service und wartet auf den Abschluss laufender Uploads.
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
_isRunning = false;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
|
||||
if (_uploadTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uploadTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
/* erwartet */
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("PhotoPrism Upload-Service gestoppt");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scannt das Bild-Verzeichnis und stellt fehlgeschlagene Uploads erneut in die Queue.
|
||||
/// </summary>
|
||||
public void ScanAndQueueFailedUploads()
|
||||
{
|
||||
if (!_appSettings.PhotoPrismAutoUploadEnabled)
|
||||
{
|
||||
_logger.Debug("PhotoPrism Auto-Upload ist deaktiviert");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var failedFiles = _uploadTracker.GetFailedUploads(_appSettings.PictureLocation);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var filePath in failedFiles.Where(f => !_uploadQueue.Contains(f)))
|
||||
{
|
||||
_uploadQueue.Enqueue(filePath);
|
||||
_logger.Debug($"Fehlgeschlagenes Upload in Queue: {Path.GetFileName(filePath)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.Count > 0)
|
||||
_logger.Info($"{failedFiles.Count} fehlgeschlagene Uploads in Queue hinzugefügt");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Scannen fehlgeschlagener Uploads: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt ein neu erstelltes Foto zur Upload-Queue hinzu.
|
||||
/// </summary>
|
||||
public void QueueNewPhoto(string photoPath)
|
||||
{
|
||||
if (!_appSettings.PhotoPrismAutoUploadEnabled)
|
||||
{
|
||||
_logger.Debug("PhotoPrism Auto-Upload ist deaktiviert");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_uploadQueue.Contains(photoPath)) return;
|
||||
_uploadQueue.Enqueue(photoPath);
|
||||
_logger.Info($"Neues Foto in PhotoPrism-Queue: {Path.GetFileName(photoPath)}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopAsync().Wait(TimeSpan.FromSeconds(5));
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_uploadService?.Dispose();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private – Queue Processing
|
||||
// =========================================================================
|
||||
|
||||
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("PhotoPrism Upload-Queue-Verarbeitung gestartet");
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
string? filePath = DequeueNext();
|
||||
|
||||
if (filePath is not null)
|
||||
{
|
||||
await ProcessUploadAsync(filePath, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(CheckIntervalMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Info("PhotoPrism Upload-Queue-Verarbeitung wurde abgebrochen");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler in PhotoPrism Upload-Queue-Verarbeitung: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessUploadAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.Warning($"Bild nicht gefunden, überspringe: {fileName}");
|
||||
_uploadTracker.MarkAsFailedUpload(fileName, "Datei nicht gefunden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_uploadTracker.IsUploaded(filePath))
|
||||
{
|
||||
_logger.Debug($"Bild bereits hochgeladen: {fileName}");
|
||||
return;
|
||||
}
|
||||
|
||||
var (success, error) = await RetryUploadAsync(filePath, fileName, cancellationToken);
|
||||
|
||||
if (success)
|
||||
{
|
||||
_uploadTracker.MarkAsUploaded(filePath);
|
||||
_logger.Info($"✅ PhotoPrism Upload erfolgreich: {fileName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_uploadTracker.MarkAsFailedUpload(filePath, error ?? "Unbekannter Fehler");
|
||||
_logger.Error($"❌ PhotoPrism Upload fehlgeschlagen nach {MaxRetries} Versuchen: {fileName}");
|
||||
_logger.Info($"Bild wird später erneut versucht: {fileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Kritischer Fehler bei PhotoPrism Upload-Verarbeitung von {fileName}: {ex.Message}");
|
||||
_uploadTracker.MarkAsFailedUpload(filePath, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool success, string? error)> RetryUploadAsync(
|
||||
string filePath,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? lastError = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"PhotoPrism Upload-Versuch {attempt}/{MaxRetries} für {fileName}...");
|
||||
|
||||
var success = await _uploadService.UploadImageAsync(filePath);
|
||||
|
||||
if (success)
|
||||
return (true, null);
|
||||
|
||||
lastError = "Upload-Fehler vom Service";
|
||||
_logger.Warning($"PhotoPrism Upload fehlgeschlagen (Versuch {attempt}): {fileName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
_logger.Error($"Fehler bei PhotoPrism Upload-Versuch {attempt}: {ex.Message}");
|
||||
}
|
||||
|
||||
if (attempt < MaxRetries)
|
||||
await Task.Delay(UploadRetryDelayMs, cancellationToken);
|
||||
}
|
||||
|
||||
return (false, lastError);
|
||||
}
|
||||
|
||||
private string? DequeueNext()
|
||||
{
|
||||
lock (_queueLock)
|
||||
return _uploadQueue.Count > 0 ? _uploadQueue.Dequeue() : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,494 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Media.Imaging;
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Service für den Upload von Bildern zu PhotoPrism mit Token-basierter Authentifizierung.
|
||||
/// </summary>
|
||||
public class PhotoPrismUploadService : IDisposable
|
||||
{
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly Logger _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly PhotoPrismAuthService _authService;
|
||||
private readonly QRCodeGenerationService _qrCodeGenerationService;
|
||||
private string? _uploadUserUid;
|
||||
private string? _uploadToken;
|
||||
private string? _targetAlbumUid;
|
||||
private string? _targetShareToken;
|
||||
private bool _isAuthenticated = false;
|
||||
|
||||
public bool IsAuthenticated => _isAuthenticated;
|
||||
public BitmapImage? LastGeneratedQRCode { get; private set; }
|
||||
|
||||
public PhotoPrismUploadService(
|
||||
AppSettingsService appSettings,
|
||||
Logger logger,
|
||||
PhotoPrismAuthService authService)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
_logger = logger;
|
||||
_authService = authService;
|
||||
_qrCodeGenerationService = new QRCodeGenerationService(logger);
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Public API
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Authentifiziert sich bei PhotoPrism.
|
||||
/// </summary>
|
||||
public async Task<bool> AuthenticateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Bei API-Key direkt authentifizieren ohne Validierung
|
||||
if (!string.IsNullOrWhiteSpace(_appSettings.PhotoPrismApiKey))
|
||||
{
|
||||
_logger.Info("Nutze PhotoPrism API-Key...");
|
||||
var success = await _authService.AuthenticateAsync();
|
||||
if (success)
|
||||
{
|
||||
_isAuthenticated = true;
|
||||
_httpClient.DefaultRequestHeaders.Remove("X-Auth-Token");
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Auth-Token", _authService.AccessToken);
|
||||
_logger.Info("✅ PhotoPrism API-Key aktiviert");
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// Sonst mit Credentials authentifizieren
|
||||
_logger.Info("Starte PhotoPrism-Authentifizierung mit Credentials...");
|
||||
|
||||
// Validiere dass es eine PhotoPrism-Instanz ist (nur bei Credentials)
|
||||
if (!await ValidatePhotoPrismInstanceAsync())
|
||||
{
|
||||
_logger.Error("❌ API-URL ist keine gültige PhotoPrism-Instanz!");
|
||||
_logger.Error($" URL: {_appSettings.PhotoPrismApiUrl}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var success2 = await _authService.AuthenticateAsync();
|
||||
if (success2)
|
||||
{
|
||||
_isAuthenticated = true;
|
||||
_httpClient.DefaultRequestHeaders.Remove("X-Auth-Token");
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Auth-Token", _authService.AccessToken);
|
||||
}
|
||||
return success2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler bei PhotoPrism-Authentifizierung: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validiert, dass die URL eine echte PhotoPrism-Instanz ist.
|
||||
/// </summary>
|
||||
private async Task<bool> ValidatePhotoPrismInstanceAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"{_appSettings.PhotoPrismApiUrl}/api/v1/status");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.Warning("⚠️ /api/v1/status nicht gefunden - könnte Lychee sein!");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.Debug("✅ PhotoPrism-Instanz validiert!");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Konnte PhotoPrism-Instanz nicht validieren: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploaded ein einzelnes Bild zu PhotoPrism.
|
||||
/// </summary>
|
||||
public async Task<bool> UploadImageAsync(string imagePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_isAuthenticated && !await AuthenticateAsync())
|
||||
return false;
|
||||
|
||||
if (!File.Exists(imagePath))
|
||||
{
|
||||
_logger.Error($"Bild nicht gefunden: {imagePath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.Info($"Starte Upload: {Path.GetFileName(imagePath)}");
|
||||
_logger.Debug($" Authentifiziert: {_isAuthenticated}");
|
||||
_logger.Debug($" Token vorhanden: {!string.IsNullOrEmpty(_authService.AccessToken)}");
|
||||
|
||||
if (!await EnsureUploadEndpointAsync())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var uploadUrl = $"{_appSettings.PhotoPrismApiUrl}/api/v1/users/{_uploadUserUid}/upload/{_uploadToken}";
|
||||
_logger.Debug($" Upload-URL: {uploadUrl}");
|
||||
|
||||
var fileInfo = new FileInfo(imagePath);
|
||||
var imageBytes = await File.ReadAllBytesAsync(imagePath);
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
|
||||
var imageContent = new ByteArrayContent(imageBytes);
|
||||
imageContent.Headers.ContentType = MediaTypeHeaderValue.Parse("image/jpeg");
|
||||
imageContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
|
||||
{
|
||||
Name = "files",
|
||||
FileName = fileInfo.Name
|
||||
};
|
||||
form.Add(imageContent);
|
||||
|
||||
var response = await _httpClient.PostAsync(uploadUrl, form);
|
||||
|
||||
_logger.Debug($" Response-Status: {response.StatusCode}");
|
||||
_logger.Debug($" Headers gesendet: {string.Join(", ", _httpClient.DefaultRequestHeaders.Select(h => h.Key))}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Aussagekräftige Fehlermeldungen
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.Error($"❌ Upload fehlgeschlagen: Endpoint nicht gefunden (404)");
|
||||
_logger.Error($" Überprüfe die PhotoPrism API-URL: {_appSettings.PhotoPrismApiUrl}");
|
||||
_logger.Error(" Erwarteter Endpoint: /api/v1/users/{uid}/upload/{token}");
|
||||
}
|
||||
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.Error($"❌ Upload fehlgeschlagen: Authentifizierung fehlgeschlagen (401)");
|
||||
_logger.Error($" Überprüfe deinen API-Key oder deine Credentials");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"Upload fehlgeschlagen ({response.StatusCode}): {errorContent}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var albumUid = await ResolveAlbumUidFromShareUrlAsync();
|
||||
if (!await ProcessUploadedFilesAsync(albumUid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.Info($"Upload erfolgreich: {Path.GetFileName(imagePath)}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Upload: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureUploadEndpointAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_uploadUserUid) && !string.IsNullOrWhiteSpace(_uploadToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"{_appSettings.PhotoPrismApiUrl}/api/v1/session");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Error($"Konnte Upload-Session nicht abrufen ({response.StatusCode})");
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
var uid = TryReadNestedString(root, "user", "UID") ?? TryReadNestedString(root, "user", "uid");
|
||||
var sessionToken = TryReadString(root, "id");
|
||||
|
||||
_uploadUserUid = uid;
|
||||
_uploadToken = !string.IsNullOrWhiteSpace(_uploadToken)
|
||||
? _uploadToken
|
||||
: (!string.IsNullOrWhiteSpace(sessionToken) ? sessionToken : Guid.NewGuid().ToString("N"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_uploadUserUid) || string.IsNullOrWhiteSpace(_uploadToken))
|
||||
{
|
||||
_logger.Error("Konnte Upload-UID/Token aus /api/v1/session nicht ermitteln.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.Debug($"Upload-Kontext geladen: uid={_uploadUserUid}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Laden des Upload-Kontexts: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveAlbumUidFromShareUrlAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_appSettings.PhotoPrismAlbumShareUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shareToken = ExtractShareToken(_appSettings.PhotoPrismAlbumShareUrl!);
|
||||
if (string.IsNullOrWhiteSpace(shareToken))
|
||||
{
|
||||
_logger.Warning("PhotoPrism Album-Share-URL konnte nicht geparst werden. Upload wird nicht zu einem Album zugeordnet.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(_targetShareToken, shareToken, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(_targetAlbumUid))
|
||||
{
|
||||
return _targetAlbumUid;
|
||||
}
|
||||
|
||||
_targetShareToken = shareToken;
|
||||
_targetAlbumUid = null;
|
||||
|
||||
try
|
||||
{
|
||||
var albumsResponse = await _httpClient.GetAsync($"{_appSettings.PhotoPrismApiUrl}/api/v1/albums?count=1000");
|
||||
if (!albumsResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Warning($"Konnte Album-Liste nicht laden ({albumsResponse.StatusCode}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
var albumsJson = await albumsResponse.Content.ReadAsStringAsync();
|
||||
using var albumsDoc = JsonDocument.Parse(albumsJson);
|
||||
if (albumsDoc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var album in albumsDoc.RootElement.EnumerateArray())
|
||||
{
|
||||
var albumUid = TryReadString(album, "UID") ?? TryReadString(album, "uid");
|
||||
if (string.IsNullOrWhiteSpace(albumUid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var linksResponse = await _httpClient.GetAsync($"{_appSettings.PhotoPrismApiUrl}/api/v1/albums/{albumUid}/links");
|
||||
if (!linksResponse.IsSuccessStatusCode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var linksJson = await linksResponse.Content.ReadAsStringAsync();
|
||||
using var linksDoc = JsonDocument.Parse(linksJson);
|
||||
if (linksDoc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var link in linksDoc.RootElement.EnumerateArray())
|
||||
{
|
||||
var token = TryReadString(link, "Token") ?? TryReadString(link, "token");
|
||||
if (string.Equals(token, shareToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_targetAlbumUid = albumUid;
|
||||
_logger.Debug($"Zielalbum für Share-Link gefunden: {_targetAlbumUid}");
|
||||
return _targetAlbumUid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Warning($"Kein Album für Share-Token '{shareToken}' gefunden.");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Konnte Zielalbum über Share-Link nicht auflösen: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractShareToken(string shareUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(shareUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = Regex.Match(shareUrl, @"/s/([^/]+)", RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessUploadedFilesAsync(string? albumUid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_uploadUserUid) || string.IsNullOrWhiteSpace(_uploadToken))
|
||||
{
|
||||
_logger.Error("Upload-Kontext für Import fehlt (uid/token).");
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = new { albums = string.IsNullOrWhiteSpace(albumUid) ? Array.Empty<string>() : new[] { albumUid } };
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var processUrl = $"{_appSettings.PhotoPrismApiUrl}/api/v1/users/{_uploadUserUid}/upload/{_uploadToken}";
|
||||
var response = await _httpClient.PutAsync(processUrl, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
_logger.Error($"Verarbeitung des Uploads fehlgeschlagen ({response.StatusCode}): {error}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(albumUid))
|
||||
{
|
||||
_logger.Debug($"Upload wurde verarbeitet und Album {albumUid} zugeordnet.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Upload wurde verarbeitet (ohne Album-Zuordnung).");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (TryGetPropertyIgnoreCase(element, propertyName, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryReadNestedString(JsonElement root, string objectPropertyName, string valuePropertyName)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(root, objectPropertyName, out var obj) || obj.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return TryReadString(obj, valuePropertyName);
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploaded mehrere Bilder zu PhotoPrism.
|
||||
/// </summary>
|
||||
public async Task<int> UploadImagesAsync(IEnumerable<string> imagePaths)
|
||||
{
|
||||
int successCount = 0;
|
||||
foreach (var path in imagePaths)
|
||||
{
|
||||
if (await UploadImageAsync(path))
|
||||
successCount++;
|
||||
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
_logger.Info($"{successCount} von {imagePaths.Count()} Bildern hochgeladen.");
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generiert einen QR-Code für das PhotoPrism Album.
|
||||
/// </summary>
|
||||
public BitmapImage? GenerateAlbumQRCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_appSettings.PhotoPrismApiUrl))
|
||||
{
|
||||
_logger.Error("PhotoPrism-URL ist nicht konfiguriert.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var albumUrl = _appSettings.PhotoPrismAlbumShareUrl;
|
||||
if (string.IsNullOrEmpty(albumUrl))
|
||||
{
|
||||
_logger.Warning("PhotoPrism Album-Share-URL nicht konfiguriert");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Debug($"QR-Code URL: {albumUrl}");
|
||||
return LastGeneratedQRCode = _qrCodeGenerationService.GenerateQRCode(albumUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Generieren des QR-Codes: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet sich von PhotoPrism ab.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _authService.LogoutAsync();
|
||||
_isAuthenticated = false;
|
||||
_uploadUserUid = null;
|
||||
_uploadToken = null;
|
||||
_targetAlbumUid = null;
|
||||
_targetShareToken = null;
|
||||
_httpClient.DefaultRequestHeaders.Remove("X-Auth-Token");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Abmelden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
_authService?.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using CamBooth.App.Core.Logging;
|
||||
|
||||
namespace CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Verfolgt, welche Bilder zu PhotoPrism hochgeladen wurden, und persistiert den Status.
|
||||
/// </summary>
|
||||
public class PhotoPrismUploadTracker
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly string _trackerFilePath;
|
||||
private Dictionary<string, UploadedImageInfo> _uploadedImages;
|
||||
|
||||
private static readonly JsonSerializerOptions PrettyJson = new() { WriteIndented = true };
|
||||
|
||||
public PhotoPrismUploadTracker(Logger logger, string trackerDirectory = "")
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var dir = ResolveDirectory(trackerDirectory);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
_trackerFilePath = Path.Combine(dir, "photoprism_upload_history.json");
|
||||
_uploadedImages = new Dictionary<string, UploadedImageInfo>();
|
||||
|
||||
LoadFromDisk();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Public API
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Markiert ein Bild als erfolgreich hochgeladen.
|
||||
/// </summary>
|
||||
public void MarkAsUploaded(string filePath, string? photoId = null)
|
||||
{
|
||||
var key = GetKey(filePath);
|
||||
|
||||
_uploadedImages[key] = new UploadedImageInfo
|
||||
{
|
||||
FileName = key,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
PhotoId = photoId,
|
||||
IsUploaded = true,
|
||||
RetryCount = 0,
|
||||
ErrorMessage = null
|
||||
};
|
||||
|
||||
SaveToDisk();
|
||||
_logger.Info($"Bild als hochgeladen markiert: {key}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markiert einen fehlgeschlagenen Upload-Versuch.
|
||||
/// </summary>
|
||||
public void MarkAsFailedUpload(string filePath, string? errorMessage = null)
|
||||
{
|
||||
var key = GetKey(filePath);
|
||||
|
||||
if (_uploadedImages.TryGetValue(key, out var existing))
|
||||
{
|
||||
existing.RetryCount++;
|
||||
existing.LastRetryAt = DateTime.UtcNow;
|
||||
existing.ErrorMessage = errorMessage;
|
||||
}
|
||||
else
|
||||
{
|
||||
_uploadedImages[key] = new UploadedImageInfo
|
||||
{
|
||||
FileName = key,
|
||||
IsUploaded = false,
|
||||
RetryCount = 1,
|
||||
LastRetryAt = DateTime.UtcNow,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
SaveToDisk();
|
||||
_logger.Warning($"Upload-Fehler für {key}: {errorMessage}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob ein Bild bereits erfolgreich hochgeladen wurde.
|
||||
/// </summary>
|
||||
public bool IsUploaded(string filePath)
|
||||
{
|
||||
var key = GetKey(filePath);
|
||||
return _uploadedImages.TryGetValue(key, out var info) && info.IsUploaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt alle Dateien zurück, die noch nicht erfolgreich hochgeladen wurden.
|
||||
/// </summary>
|
||||
public List<string> GetFailedUploads(string pictureDirectory)
|
||||
{
|
||||
if (!Directory.Exists(pictureDirectory))
|
||||
return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
return Directory
|
||||
.GetFiles(pictureDirectory, "img_*.jpg")
|
||||
.Where(f => !IsUploaded(f))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Scannen von {pictureDirectory}: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helpers
|
||||
// =========================================================================
|
||||
|
||||
private void LoadFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_trackerFilePath)) return;
|
||||
|
||||
var json = File.ReadAllText(_trackerFilePath);
|
||||
_uploadedImages = JsonSerializer.Deserialize<Dictionary<string, UploadedImageInfo>>(json)
|
||||
?? new Dictionary<string, UploadedImageInfo>();
|
||||
|
||||
_logger.Info($"PhotoPrism Upload-History geladen: {_uploadedImages.Count} Einträge");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Laden der PhotoPrism Upload-History: {ex.Message}");
|
||||
_uploadedImages = new Dictionary<string, UploadedImageInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveToDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(_trackerFilePath, JsonSerializer.Serialize(_uploadedImages, PrettyJson));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Speichern der PhotoPrism Upload-History: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetKey(string filePath) => Path.GetFileName(filePath);
|
||||
|
||||
private static string ResolveDirectory(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "PhotoPrismUploadTracking");
|
||||
|
||||
return Path.IsPathRooted(path)
|
||||
? path
|
||||
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Nested Types
|
||||
// =========================================================================
|
||||
|
||||
public class UploadedImageInfo
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public bool IsUploaded { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
public string? PhotoId { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
public DateTime? LastRetryAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,14 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Windows.Media.Imaging;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using QRCoder;
|
||||
|
||||
namespace CamBooth.App.Features.LycheeUpload;
|
||||
namespace CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Service für die Generierung von QR-Codes als BitmapImage
|
||||
/// Service für die Generierung von QR-Codes als BitmapImage (PhotoPrism)
|
||||
/// </summary>
|
||||
public class QRCodeGenerationService
|
||||
{
|
||||
@ -28,7 +29,7 @@ public class QRCodeGenerationService
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Debug($"Generiere QR-Code für: {qrData}");
|
||||
_logger.Debug($"Generiere PhotoPrism QR-Code für: {qrData}");
|
||||
|
||||
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
|
||||
{
|
||||
@ -40,14 +41,14 @@ public class QRCodeGenerationService
|
||||
// Konvertiere Bitmap zu BitmapImage
|
||||
BitmapImage bitmapImage = ConvertBitmapToBitmapImage(qrCodeImage);
|
||||
|
||||
_logger.Debug("✅ QR-Code erfolgreich generiert");
|
||||
_logger.Debug("✅ PhotoPrism QR-Code erfolgreich generiert");
|
||||
return bitmapImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Generieren des QR-Codes: {ex.Message}");
|
||||
_logger.Error($"Fehler beim Generieren des PhotoPrism QR-Codes: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -72,37 +73,4 @@ public class QRCodeGenerationService
|
||||
return bitmapImage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Speichert einen QR-Code als Datei
|
||||
/// </summary>
|
||||
/// <param name="qrData">Die Daten für den QR-Code</param>
|
||||
/// <param name="filePath">Zielpath für die QR-Code-Datei</param>
|
||||
/// <param name="pixelsPerModule">Größe in Pixeln pro QR-Code-Modul</param>
|
||||
/// <returns>True wenn erfolgreich, sonst False</returns>
|
||||
public bool SaveQRCodeToFile(string qrData, string filePath, int pixelsPerModule = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Debug($"Speichere QR-Code unter: {filePath}");
|
||||
|
||||
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
|
||||
{
|
||||
QRCodeData qrCodeData = qrGenerator.CreateQrCode(qrData, QRCodeGenerator.ECCLevel.Q);
|
||||
using (QRCode qrCode = new QRCode(qrCodeData))
|
||||
{
|
||||
Bitmap qrCodeImage = qrCode.GetGraphic(pixelsPerModule, System.Drawing.Color.Black, System.Drawing.Color.White, true);
|
||||
qrCodeImage.Save(filePath, System.Drawing.Imaging.ImageFormat.Png);
|
||||
|
||||
_logger.Debug("✅ QR-Code erfolgreich gespeichert");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Speichern des QR-Codes: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ using System.Windows.Media.Imaging;
|
||||
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.LycheeUpload;
|
||||
using CamBooth.App.Features.PhotoPrismUpload;
|
||||
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
@ -25,7 +25,7 @@ public partial class PictureGalleryPage : Page
|
||||
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
|
||||
private readonly LycheeUploadService _lycheeUploadService;
|
||||
private readonly PhotoPrismUploadService _photoPrismUploadService;
|
||||
|
||||
private ContentDialog? _openContentDialog;
|
||||
|
||||
@ -37,12 +37,12 @@ public partial class PictureGalleryPage : Page
|
||||
private Border? _qrCodeBorder = null;
|
||||
|
||||
|
||||
public PictureGalleryPage(AppSettingsService appSettingsService, Logger logger, PictureGalleryService pictureGalleryService, LycheeUploadService lycheeUploadService)
|
||||
public PictureGalleryPage(AppSettingsService appSettingsService, Logger logger, PictureGalleryService pictureGalleryService, PhotoPrismUploadService photoPrismUploadService)
|
||||
{
|
||||
this._appSettingsService = appSettingsService;
|
||||
this._logger = logger;
|
||||
this._pictureGalleryService = pictureGalleryService;
|
||||
this._lycheeUploadService = lycheeUploadService;
|
||||
this._photoPrismUploadService = photoPrismUploadService;
|
||||
this.InitializeComponent();
|
||||
this.Initialize();
|
||||
}
|
||||
@ -70,7 +70,7 @@ public partial class PictureGalleryPage : Page
|
||||
{
|
||||
try
|
||||
{
|
||||
var qrCodeImage = this._lycheeUploadService.GenerateGalleryQRCode();
|
||||
var qrCodeImage = this._photoPrismUploadService.GenerateAlbumQRCode();
|
||||
if (qrCodeImage != null)
|
||||
{
|
||||
// QR-Code Container mit Label
|
||||
@ -107,12 +107,12 @@ public partial class PictureGalleryPage : Page
|
||||
Margin = new Thickness(4)
|
||||
};
|
||||
|
||||
this._logger.Debug("✅ QR-Code statisch erstellt");
|
||||
this._logger.Debug("✅ PhotoPrism QR-Code statisch erstellt");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Warning($"QR-Code konnte nicht generiert werden: {ex.Message}");
|
||||
this._logger.Warning($"PhotoPrism QR-Code konnte nicht generiert werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,21 +192,6 @@ public partial class PictureGalleryPage : Page
|
||||
this.Dispatcher.Invoke(CloseDialog);
|
||||
}
|
||||
|
||||
public void RefreshGallery()
|
||||
{
|
||||
CalculateTotalPages();
|
||||
|
||||
// If current page is now beyond total pages, go to last page
|
||||
if (_currentPage > _totalPages)
|
||||
{
|
||||
_currentPage = Math.Max(1, _totalPages);
|
||||
}
|
||||
|
||||
LoadCurrentPage();
|
||||
UpdatePagerControls();
|
||||
}
|
||||
|
||||
|
||||
public async Task ShowPhotoDialogAsync(string picturePath)
|
||||
{
|
||||
await Application.Current.Dispatcher.InvokeAsync(
|
||||
|
||||
@ -131,17 +131,4 @@ public class PictureGalleryService
|
||||
bitmap.Freeze(); // Threadsicher machen
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
|
||||
public static SymbolIcon CreateRegularSymbolIcon(SymbolRegular symbolRegular, Color foregroundColor = default)
|
||||
{
|
||||
return new SymbolIcon
|
||||
{
|
||||
Symbol = symbolRegular,
|
||||
Foreground = new SolidColorBrush(foregroundColor),
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
FontSize = 24
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -40,19 +40,16 @@
|
||||
Background="Transparent"
|
||||
Panel.ZIndex="1" />
|
||||
|
||||
<!-- Inhalt der dritten Zeile -->
|
||||
<StackPanel Grid.Row="0" Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Bottom" Visibility="Hidden" Name="TimerPanel" Background="#AA000000" Panel.ZIndex="2"
|
||||
Margin="0 0 0 0">
|
||||
<TextBlock x:Name="CaptureStatusText"
|
||||
Text="Scharfstellen..."
|
||||
Visibility="Collapsed"
|
||||
Foreground="White"
|
||||
FontSize="36"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="24 24 24 12"/>
|
||||
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<!-- Timer overlay: fullscreen so the ring centers on the camera view -->
|
||||
<Grid Grid.Row="0"
|
||||
x:Name="TimerPanel"
|
||||
Background="Transparent"
|
||||
Panel.ZIndex="2"
|
||||
Visibility="Hidden">
|
||||
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Action Buttons Container (bottom-right) -->
|
||||
<StackPanel Grid.Row="0"
|
||||
@ -387,6 +384,14 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Flash overlay: briefly flashes white on shutter release -->
|
||||
<Grid x:Name="FlashOverlay"
|
||||
Grid.RowSpan="2"
|
||||
Background="White"
|
||||
Opacity="0"
|
||||
Panel.ZIndex="9"
|
||||
IsHitTestVisible="False"/>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<Grid Grid.RowSpan="2"
|
||||
x:Name="LoadingOverlay"
|
||||
|
||||
@ -1,22 +1,16 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Threading;
|
||||
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.Camera;
|
||||
using CamBooth.App.Features.DebugConsole;
|
||||
using CamBooth.App.Features.LiveView;
|
||||
using CamBooth.App.Features.LycheeUpload;
|
||||
using CamBooth.App.Features.PhotoPrismUpload;
|
||||
using CamBooth.App.Features.PictureGallery;
|
||||
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
namespace CamBooth.App;
|
||||
@ -26,598 +20,354 @@ namespace CamBooth.App;
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly AppSettingsService _appSettings;
|
||||
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
|
||||
private readonly CameraService _cameraService;
|
||||
|
||||
private readonly LycheeUploadService _lycheeUploadService;
|
||||
|
||||
private bool _isDebugConsoleVisible = true;
|
||||
|
||||
private bool _isPicturePanelVisible = false;
|
||||
|
||||
private LiveViewPage? _liveViewPage;
|
||||
|
||||
private bool _isPhotoProcessRunning;
|
||||
|
||||
private bool _isCameraStarted;
|
||||
|
||||
private bool _isShutdownSliderOpen;
|
||||
|
||||
private const string ShutdownGlyphClosed = "\uE7E8";
|
||||
|
||||
private const string ShutdownGlyphOpen = "\uE711";
|
||||
|
||||
private const double ShutdownSliderOffset = 160;
|
||||
|
||||
private readonly DispatcherTimer _focusStatusAnimationTimer = new() { Interval = TimeSpan.FromMilliseconds(250) };
|
||||
|
||||
private readonly DispatcherTimer _galleryPromptTimer = new() { Interval = TimeSpan.FromSeconds(5) };
|
||||
|
||||
private int _focusStatusDots;
|
||||
|
||||
|
||||
public MainWindow(
|
||||
Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PictureGalleryService pictureGalleryService,
|
||||
CameraService cameraService,
|
||||
LycheeUploadService lycheeUploadService)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._appSettings = appSettings;
|
||||
this._pictureGalleryService = pictureGalleryService;
|
||||
this._cameraService = cameraService;
|
||||
this._lycheeUploadService = lycheeUploadService;
|
||||
InitializeComponent();
|
||||
this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible);
|
||||
this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
|
||||
|
||||
// Lade Thumbnails asynchron und zeige dann den Welcome Screen
|
||||
_ = InitializeAsync();
|
||||
|
||||
this.Closing += OnClosing;
|
||||
TimerControlRectangleAnimation.OnTimerEllapsed += TimerControlRectangleAnimation_OnTimerEllapsed;
|
||||
this._focusStatusAnimationTimer.Tick += (_, _) =>
|
||||
{
|
||||
this._focusStatusDots = (this._focusStatusDots + 1) % 4;
|
||||
this.CaptureStatusText.Text = $"Scharfstellen{new string('.', this._focusStatusDots)}";
|
||||
};
|
||||
this._galleryPromptTimer.Tick += (_, _) => this.HideGalleryPrompt();
|
||||
|
||||
// Subscribe to new photo count changes
|
||||
this._pictureGalleryService.NewPhotoCountChanged += PictureGalleryService_NewPhotoCountChanged;
|
||||
|
||||
this.DebugCloseButton.Visibility = Visibility.Collapsed;
|
||||
this.HideDebugButton.Visibility = this._appSettings.IsDebugConsoleVisible ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
// Initialize Lychee upload if auto-upload is enabled
|
||||
if (appSettings.LycheeAutoUploadEnabled)
|
||||
{
|
||||
logger.Info("Lychee Auto-Upload ist aktiviert. Authentifiziere...");
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var authSuccess = await _lycheeUploadService.AuthenticateAsync();
|
||||
if (authSuccess)
|
||||
{
|
||||
logger.Info("Lychee-Authentifizierung erfolgreich!");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warning("Lychee-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.Info($"config file loaded: '{appSettings.ConfigFileName}'");
|
||||
logger.Info("MainWindow initialized");
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Zeige Ladeanzeige
|
||||
this.LoadingOverlay.Visibility = Visibility.Visible;
|
||||
this.WelcomeOverlay.Visibility = Visibility.Collapsed;
|
||||
|
||||
// Lade Thumbnails mit Progress-Updates
|
||||
await LoadThumbnailsWithProgress();
|
||||
|
||||
// Warte kurz, damit der Benutzer die Fertigstellung sehen kann
|
||||
await Task.Delay(500);
|
||||
|
||||
// Verstecke Ladeanzeige und zeige Welcome Screen
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingOverlay.Visibility = Visibility.Collapsed;
|
||||
this.WelcomeOverlay.Visibility = Visibility.Visible;
|
||||
});
|
||||
|
||||
this._logger.Info("Initialization completed successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Initialization error: {ex.Message}");
|
||||
// Bei Fehler trotzdem zum Welcome Screen wechseln
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingOverlay.Visibility = Visibility.Collapsed;
|
||||
this.WelcomeOverlay.Visibility = Visibility.Visible;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadThumbnailsWithProgress()
|
||||
{
|
||||
string pictureLocation = this._appSettings.PictureLocation;
|
||||
|
||||
// Sicherstellen, dass das Verzeichnis existiert
|
||||
if (!Directory.Exists(pictureLocation))
|
||||
{
|
||||
this._logger.Info($"Picture directory does not exist: '{pictureLocation}'. Creating it...");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(pictureLocation);
|
||||
this._logger.Info($"Picture directory created: '{pictureLocation}'");
|
||||
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingStatusText.Text = "Keine Fotos gefunden";
|
||||
this.LoadingCountText.Text = "0 Fotos";
|
||||
});
|
||||
|
||||
await Task.Delay(1000);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Failed to create picture directory: {ex.Message}");
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingStatusText.Text = "Fehler beim Erstellen des Foto-Ordners";
|
||||
this.LoadingCountText.Text = "";
|
||||
});
|
||||
await Task.Delay(2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Zähle Bilddateien
|
||||
string[] imageExtensions = { ".jpg", ".jpeg", ".png", ".bmp", ".gif" };
|
||||
var picturePaths = Directory.EnumerateFiles(pictureLocation)
|
||||
.Where(f => imageExtensions.Contains(Path.GetExtension(f).ToLower()))
|
||||
.ToList();
|
||||
|
||||
int totalCount = picturePaths.Count;
|
||||
|
||||
if (totalCount == 0)
|
||||
{
|
||||
this._logger.Info($"No pictures found in directory: '{pictureLocation}'");
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingStatusText.Text = "Keine Fotos gefunden";
|
||||
this.LoadingCountText.Text = "Bereit für neue Aufnahmen!";
|
||||
});
|
||||
await Task.Delay(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI mit Gesamtanzahl
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingStatusText.Text = $"Lade {totalCount} Foto{(totalCount != 1 ? "s" : "")}...";
|
||||
this.LoadingCountText.Text = $"0 / {totalCount}";
|
||||
});
|
||||
|
||||
// Lade Thumbnails
|
||||
await this._pictureGalleryService.LoadThumbnailsToCache();
|
||||
|
||||
// Update UI nach dem Laden
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
this.LoadingStatusText.Text = "Fotos erfolgreich geladen!";
|
||||
this.LoadingCountText.Text = $"{totalCount} Foto{(totalCount != 1 ? "s" : "")} bereit";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void TimerControlRectangleAnimation_OnTimerEllapsed()
|
||||
{
|
||||
var photoTakenSuccessfully = false;
|
||||
|
||||
try
|
||||
{
|
||||
this._cameraService.TakePhoto();
|
||||
photoTakenSuccessfully = true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
//TODO: mit content dialog ersetzen
|
||||
System.Windows.MessageBox.Show("Sorry, da ging was schief! Bitte nochmal probieren.");
|
||||
this._logger.Info(exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.StopFocusStatusAnimation();
|
||||
this.CaptureStatusText.Visibility = Visibility.Collapsed;
|
||||
SwitchButtonAndTimerPanel();
|
||||
this._isPhotoProcessRunning = false;
|
||||
if (photoTakenSuccessfully)
|
||||
{
|
||||
this.ShowGalleryPrompt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void SetVisibilityPicturePanel(bool visibility)
|
||||
{
|
||||
if (visibility)
|
||||
{
|
||||
this.HideGalleryPrompt();
|
||||
this.PicturePanel.Navigate(new PictureGalleryPage(this._appSettings, this._logger, this._pictureGalleryService, this._lycheeUploadService));
|
||||
// Reset new photo count when opening gallery
|
||||
this._pictureGalleryService.ResetNewPhotoCount();
|
||||
// Blende unnötige Buttons aus, wenn Galerie geöffnet wird
|
||||
this.ButtonPanel.Visibility = Visibility.Hidden;
|
||||
this.ActionButtonsContainer.Visibility = Visibility.Hidden;
|
||||
this.ShutdownDock.Visibility = Visibility.Hidden;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.PicturePanel.ClearValue(MainWindow.ContentProperty);
|
||||
// Stelle Buttons wieder her, wenn Galerie geschlossen wird
|
||||
this.ButtonPanel.Visibility = Visibility.Visible;
|
||||
this.ActionButtonsContainer.Visibility = Visibility.Visible;
|
||||
this.ShutdownDock.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
this._isPicturePanelVisible = !visibility;
|
||||
}
|
||||
|
||||
|
||||
public void ClosePicturePanel()
|
||||
{
|
||||
if (this.PicturePanel.Content is PictureGalleryPage pictureGalleryPage)
|
||||
{
|
||||
pictureGalleryPage.CloseOpenDialog();
|
||||
}
|
||||
|
||||
if (this.PicturePanel.Content is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.SetVisibilityPicturePanel(false);
|
||||
}
|
||||
|
||||
|
||||
private void OnClosing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
this._liveViewPage?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
private void StartExperience(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this.StartLiveViewIfNeeded();
|
||||
this.WelcomeOverlay.Visibility = Visibility.Collapsed;
|
||||
this.ButtonPanel.Visibility = Visibility.Visible;
|
||||
this.ActionButtonsContainer.Visibility = Visibility.Visible;
|
||||
this.ShutdownDock.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void StartLiveViewIfNeeded()
|
||||
{
|
||||
if (this._isCameraStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this._liveViewPage = new LiveViewPage(this._logger, this._appSettings, this._cameraService);
|
||||
this.MainFrame.Navigate(this._liveViewPage);
|
||||
this._isCameraStarted = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Failed to start live view: {ex.Message}\n{ex.StackTrace}");
|
||||
System.Windows.MessageBox.Show($"Failed to initialize camera. Please ensure the camera is properly connected.\n\nError: {ex.Message}",
|
||||
"Camera Connection Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void SetVisibilityDebugConsole(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this.SetVisibilityDebugConsole(this._isDebugConsoleVisible);
|
||||
}
|
||||
|
||||
|
||||
private void SetVisibilityDebugConsole(bool visibility)
|
||||
{
|
||||
if (!_appSettings.IsDebugConsoleVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibility)
|
||||
{
|
||||
this.DebugFrame.Navigate(new DebugConsolePage(this._logger));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.DebugFrame.ClearValue(MainWindow.ContentProperty);
|
||||
}
|
||||
|
||||
this._isDebugConsoleVisible = !visibility;
|
||||
}
|
||||
|
||||
|
||||
private async void StartTakePhotoProcess(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this.HideGalleryPrompt();
|
||||
this.ClosePicturePanel();
|
||||
|
||||
if (this._isPhotoProcessRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._isPhotoProcessRunning = true;
|
||||
|
||||
try
|
||||
{
|
||||
SwitchButtonAndTimerPanel();
|
||||
|
||||
TimerControlRectangleAnimation.StartTimer(this._appSettings.PhotoCountdownSeconds);
|
||||
this.StartFocusStatusAnimation();
|
||||
this.CaptureStatusText.Visibility = Visibility.Visible;
|
||||
await Task.Delay(TimeSpan.FromSeconds(this._appSettings.FocusDelaySeconds));
|
||||
if (!this._isPhotoProcessRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this._cameraService.PrepareFocusAsync(focusTimeoutMs: this._appSettings.FocusTimeoutMs);
|
||||
this.StopFocusStatusAnimation();
|
||||
this.CaptureStatusText.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this._isPhotoProcessRunning = false;
|
||||
this.StopFocusStatusAnimation();
|
||||
this.CaptureStatusText.Visibility = Visibility.Collapsed;
|
||||
if (this.TimerPanel.Visibility == Visibility.Visible)
|
||||
{
|
||||
SwitchButtonAndTimerPanel();
|
||||
}
|
||||
this._logger.Error(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void SwitchButtonAndTimerPanel()
|
||||
{
|
||||
this.ButtonPanel.Visibility = this.ButtonPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
|
||||
this.ActionButtonsContainer.Visibility = this.ActionButtonsContainer.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
|
||||
this.TimerPanel.Visibility = this.TimerPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
|
||||
}
|
||||
|
||||
private void SetVisibilityPicturePanel(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
|
||||
}
|
||||
|
||||
|
||||
private void CloseApp(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this.Close();
|
||||
}
|
||||
|
||||
private async void ShutdownWindows(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Show confirmation dialog
|
||||
var confirmDialog = new ContentDialog(this.DialogPresenter);
|
||||
confirmDialog.Title = "Sicherheitsabfrage";
|
||||
confirmDialog.Content = "Möchtest du die Fotobox wirklich ausschalten?";
|
||||
confirmDialog.PrimaryButtonText = "Ja, ausschalten";
|
||||
confirmDialog.CloseButtonText = "Abbrechen";
|
||||
confirmDialog.DefaultButton = ContentDialogButton.Close;
|
||||
confirmDialog.PrimaryButtonAppearance = ControlAppearance.Danger;
|
||||
confirmDialog.CloseButtonAppearance = ControlAppearance.Secondary;
|
||||
confirmDialog.Background = new SolidColorBrush(Colors.White);
|
||||
confirmDialog.Foreground = new SolidColorBrush(Colors.Black);
|
||||
|
||||
var result = await confirmDialog.ShowAsync();
|
||||
|
||||
// Only proceed with shutdown if user confirmed
|
||||
if (result != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._appSettings.IsShutdownEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 0",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this._logger.Error(exception.Message);
|
||||
System.Windows.MessageBox.Show("Windows konnte nicht heruntergefahren werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/l", // = logoff (abmelden)
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this._logger.Error(exception.Message);
|
||||
System.Windows.MessageBox.Show("Abmeldung fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleShutdownSlider(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this._isShutdownSliderOpen = !this._isShutdownSliderOpen;
|
||||
this.ShutdownToggleButton.Content = this._isShutdownSliderOpen ? ShutdownGlyphOpen : ShutdownGlyphClosed;
|
||||
this.AnimateShutdownSlider(this._isShutdownSliderOpen);
|
||||
}
|
||||
|
||||
private void AnimateShutdownSlider(bool open)
|
||||
{
|
||||
var animation = new DoubleAnimation
|
||||
{
|
||||
To = open ? 0 : ShutdownSliderOffset,
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
EasingFunction = new QuadraticEase()
|
||||
};
|
||||
|
||||
this.ShutdownSliderTransform.BeginAnimation(System.Windows.Media.TranslateTransform.XProperty, animation);
|
||||
}
|
||||
|
||||
private void StartFocusStatusAnimation()
|
||||
{
|
||||
this._focusStatusDots = 0;
|
||||
this.CaptureStatusText.Text = "Scharfstellen";
|
||||
this._focusStatusAnimationTimer.Start();
|
||||
}
|
||||
|
||||
private void StopFocusStatusAnimation()
|
||||
{
|
||||
this._focusStatusAnimationTimer.Stop();
|
||||
this._focusStatusDots = 0;
|
||||
}
|
||||
|
||||
private void ShowGalleryPrompt()
|
||||
{
|
||||
this.GalleryPrompt.Visibility = Visibility.Visible;
|
||||
this._galleryPromptTimer.Stop();
|
||||
this._galleryPromptTimer.Start();
|
||||
}
|
||||
|
||||
private void HideGalleryPrompt()
|
||||
{
|
||||
this._galleryPromptTimer.Stop();
|
||||
this.GalleryPrompt.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private async void OpenGalleryFromPrompt(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this.HideGalleryPrompt();
|
||||
this.SetVisibilityPicturePanel(true);
|
||||
|
||||
// Wait a bit for the page to load before showing the dialog
|
||||
await Task.Delay(300);
|
||||
|
||||
// Show the latest photo in a dialog
|
||||
await this.ShowLatestPhotoDialogAsync();
|
||||
}
|
||||
|
||||
private async Task ShowLatestPhotoDialogAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the latest photo path
|
||||
await this._pictureGalleryService.LoadThumbnailsToCache(1);
|
||||
var latestPhotos = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending;
|
||||
|
||||
if (latestPhotos.Count == 0)
|
||||
{
|
||||
this._logger.Error("No photos found in gallery");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the file path from the BitmapImage source
|
||||
var latestPhoto = latestPhotos[0];
|
||||
if (latestPhoto.UriSource == null)
|
||||
{
|
||||
this._logger.Error("Latest photo UriSource is null");
|
||||
return;
|
||||
}
|
||||
|
||||
string photoPath = Uri.UnescapeDataString(latestPhoto.UriSource.AbsolutePath);
|
||||
this._logger.Info($"Opening photo dialog for: {photoPath}");
|
||||
|
||||
// Get the current gallery page
|
||||
if (this.PicturePanel.Content is PictureGalleryPage galleryPage)
|
||||
{
|
||||
// Show the photo dialog
|
||||
await galleryPage.ShowPhotoDialogAsync(photoPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._logger.Error("PicturePanel content is not a PictureGalleryPage");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Error showing photo dialog: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PictureGalleryService_NewPhotoCountChanged(object? sender, int newPhotoCount)
|
||||
{
|
||||
if (newPhotoCount > 0)
|
||||
{
|
||||
this.NewPhotosBadge.Visibility = Visibility.Visible;
|
||||
this.NewPhotoCountText.Text = newPhotoCount.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
this.NewPhotosBadge.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowQRCode(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
this._logger.Info("Zeige QR-Code an...");
|
||||
|
||||
// Generiere QR-Code
|
||||
var qrCodeImage = this._lycheeUploadService.GenerateGalleryQRCode();
|
||||
|
||||
if (qrCodeImage == null)
|
||||
{
|
||||
System.Windows.MessageBox.Show("QR-Code konnte nicht generiert werden. Bitte überprüfe die Lychee-Konfiguration.",
|
||||
"Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Öffne QR-Code-Fenster
|
||||
var qrWindow = new QRCodeDisplayWindow();
|
||||
qrWindow.SetQRCode(qrCodeImage);
|
||||
qrWindow.ShowDialog();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.Error($"Fehler beim Anzeigen des QR-Codes: {ex.Message}");
|
||||
System.Windows.MessageBox.Show($"Fehler beim Anzeigen des QR-Codes: {ex.Message}",
|
||||
"Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
private readonly Logger _logger;
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
private readonly CameraService _cameraService;
|
||||
private readonly PhotoPrismUploadService _photoPrismUploadService;
|
||||
private readonly MainWindowViewModel _viewModel;
|
||||
|
||||
private LiveViewPage? _liveViewPage;
|
||||
private bool _isCameraStarted;
|
||||
private bool _isPicturePanelVisible;
|
||||
private bool _isDebugConsoleVisible;
|
||||
private bool _isShutdownSliderOpen;
|
||||
|
||||
private const string ShutdownGlyphClosed = "\uE7E8";
|
||||
private const string ShutdownGlyphOpen = "\uE711";
|
||||
private const double ShutdownSliderOffset = 160;
|
||||
|
||||
|
||||
public MainWindow(
|
||||
Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PictureGalleryService pictureGalleryService,
|
||||
CameraService cameraService,
|
||||
PhotoPrismUploadService photoPrismUploadService,
|
||||
MainWindowViewModel viewModel)
|
||||
{
|
||||
_logger = logger;
|
||||
_appSettings = appSettings;
|
||||
_pictureGalleryService = pictureGalleryService;
|
||||
_cameraService = cameraService;
|
||||
_photoPrismUploadService = photoPrismUploadService;
|
||||
_viewModel = viewModel;
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
// Wire ViewModel events to UI
|
||||
_viewModel.LoadingProgressChanged += (status, count) =>
|
||||
{
|
||||
LoadingStatusText.Text = status;
|
||||
LoadingCountText.Text = count;
|
||||
};
|
||||
_viewModel.InitializationCompleted += () =>
|
||||
{
|
||||
LoadingOverlay.Visibility = Visibility.Collapsed;
|
||||
WelcomeOverlay.Visibility = Visibility.Visible;
|
||||
};
|
||||
_viewModel.GalleryPromptRequested += () => GalleryPrompt.Visibility = Visibility.Visible;
|
||||
_viewModel.GalleryPromptDismissed += () => GalleryPrompt.Visibility = Visibility.Collapsed;
|
||||
|
||||
// Wire service events
|
||||
_pictureGalleryService.NewPhotoCountChanged += OnNewPhotoCountChanged;
|
||||
TimerControlRectangleAnimation.OnTimerEllapsed += OnTimerElapsed;
|
||||
|
||||
// Initial UI state
|
||||
_isDebugConsoleVisible = _appSettings.IsDebugConsoleVisible;
|
||||
SetVisibilityDebugConsole(_isDebugConsoleVisible);
|
||||
SetVisibilityPicturePanel(false);
|
||||
DebugCloseButton.Visibility = Visibility.Collapsed;
|
||||
HideDebugButton.Visibility = _appSettings.IsDebugConsoleVisible ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
Closing += OnClosing;
|
||||
|
||||
_ = _viewModel.InitializeAsync();
|
||||
_logger.Info($"config file loaded: '{appSettings.ConfigFileName}'");
|
||||
_logger.Info("MainWindow initialized");
|
||||
}
|
||||
|
||||
|
||||
#region Event handlers
|
||||
|
||||
private void OnTimerElapsed()
|
||||
{
|
||||
SwitchButtonAndTimerPanel();
|
||||
try
|
||||
{
|
||||
// Focus is guaranteed complete before the timer fires (bounded timeout).
|
||||
_viewModel.TakePhotoAfterTimer();
|
||||
TriggerShutterFlash();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
System.Windows.MessageBox.Show("Sorry, da ging was schief! Bitte nochmal probieren.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNewPhotoCountChanged(object? sender, int count)
|
||||
{
|
||||
NewPhotosBadge.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (count > 0)
|
||||
NewPhotoCountText.Text = count.ToString();
|
||||
}
|
||||
|
||||
private void OnClosing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_cameraService.CloseSession();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Schließen der Kamera-Session: {ex.Message}");
|
||||
}
|
||||
|
||||
_liveViewPage?.Dispose();
|
||||
}
|
||||
|
||||
// XAML-bound handlers
|
||||
private void StartExperience(object sender, RoutedEventArgs e)
|
||||
{
|
||||
StartLiveViewIfNeeded();
|
||||
WelcomeOverlay.Visibility = Visibility.Collapsed;
|
||||
ButtonPanel.Visibility = Visibility.Visible;
|
||||
ActionButtonsContainer.Visibility = Visibility.Visible;
|
||||
ShutdownDock.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void StartTakePhotoProcess(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.IsPhotoProcessRunning) return;
|
||||
|
||||
ClosePicturePanel();
|
||||
_viewModel.StartPhotoProcess();
|
||||
SwitchButtonAndTimerPanel();
|
||||
TimerControlRectangleAnimation.StartTimer(_appSettings.PhotoCountdownSeconds);
|
||||
|
||||
// Autofocus runs silently in the background during the countdown.
|
||||
// The effective timeout is capped so focus always completes before the timer fires.
|
||||
_ = _viewModel.BeginFocusAsync(
|
||||
_appSettings.FocusDelaySeconds,
|
||||
_appSettings.FocusTimeoutMs,
|
||||
_appSettings.PhotoCountdownSeconds);
|
||||
}
|
||||
|
||||
private void SetVisibilityPicturePanel(object sender, MouseButtonEventArgs e) =>
|
||||
SetVisibilityPicturePanel(!_isPicturePanelVisible);
|
||||
|
||||
private void SetVisibilityDebugConsole(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_isDebugConsoleVisible = !_isDebugConsoleVisible;
|
||||
SetVisibilityDebugConsole(_isDebugConsoleVisible);
|
||||
}
|
||||
|
||||
private void ToggleShutdownSlider(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_isShutdownSliderOpen = !_isShutdownSliderOpen;
|
||||
ShutdownToggleButton.Content = _isShutdownSliderOpen ? ShutdownGlyphOpen : ShutdownGlyphClosed;
|
||||
var animation = new DoubleAnimation
|
||||
{
|
||||
To = _isShutdownSliderOpen ? 0 : ShutdownSliderOffset,
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
EasingFunction = new QuadraticEase()
|
||||
};
|
||||
ShutdownSliderTransform.BeginAnimation(TranslateTransform.XProperty, animation);
|
||||
}
|
||||
|
||||
private async void ShutdownWindows(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var confirmDialog = new ContentDialog(DialogPresenter)
|
||||
{
|
||||
Title = "Sicherheitsabfrage",
|
||||
Content = "Möchtest du die Fotobox wirklich ausschalten?",
|
||||
PrimaryButtonText = "Ja, ausschalten",
|
||||
CloseButtonText = "Abbrechen",
|
||||
DefaultButton = ContentDialogButton.Close,
|
||||
PrimaryButtonAppearance = ControlAppearance.Danger,
|
||||
CloseButtonAppearance = ControlAppearance.Secondary,
|
||||
Background = new SolidColorBrush(Colors.White),
|
||||
Foreground = new SolidColorBrush(Colors.Black)
|
||||
};
|
||||
|
||||
if (await confirmDialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
|
||||
try
|
||||
{
|
||||
_cameraService.CloseSession();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
var (args, errorMsg) = _appSettings.IsShutdownEnabled
|
||||
? ("/s /t 0", "Windows konnte nicht heruntergefahren werden.")
|
||||
: ("/l", "Abmeldung fehlgeschlagen.");
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = args,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex.Message);
|
||||
System.Windows.MessageBox.Show(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowQRCode(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var qrCodeImage = _photoPrismUploadService.GenerateAlbumQRCode();
|
||||
if (qrCodeImage == null)
|
||||
{
|
||||
System.Windows.MessageBox.Show(
|
||||
"QR-Code konnte nicht generiert werden. Bitte überprüfe die PhotoPrism-Konfiguration.",
|
||||
"Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var qrWindow = new PhotoPrismQRCodeDisplayWindow();
|
||||
qrWindow.SetQRCode(qrCodeImage);
|
||||
qrWindow.ShowDialog();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Fehler beim Anzeigen des QR-Codes: {ex.Message}");
|
||||
System.Windows.MessageBox.Show(
|
||||
$"Fehler beim Anzeigen des QR-Codes: {ex.Message}",
|
||||
"Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenGalleryFromPrompt(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.DismissGalleryPrompt();
|
||||
SetVisibilityPicturePanel(true);
|
||||
await Task.Delay(300);
|
||||
await ShowLatestPhotoDialogAsync();
|
||||
}
|
||||
|
||||
private void CloseApp(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region UI helpers
|
||||
|
||||
private void StartLiveViewIfNeeded()
|
||||
{
|
||||
if (_isCameraStarted) return;
|
||||
try
|
||||
{
|
||||
_liveViewPage = new LiveViewPage(_logger, _cameraService);
|
||||
MainFrame.Navigate(_liveViewPage);
|
||||
_isCameraStarted = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start live view: {ex.Message}\n{ex.StackTrace}");
|
||||
System.Windows.MessageBox.Show(
|
||||
$"Failed to initialize camera. Please ensure the camera is properly connected.\n\nError: {ex.Message}",
|
||||
"Camera Connection Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClosePicturePanel()
|
||||
{
|
||||
if (PicturePanel.Content is PictureGalleryPage page)
|
||||
page.CloseOpenDialog();
|
||||
if (PicturePanel.Content is null) return;
|
||||
SetVisibilityPicturePanel(false);
|
||||
}
|
||||
|
||||
private void SetVisibilityPicturePanel(bool visible)
|
||||
{
|
||||
if (visible)
|
||||
{
|
||||
_viewModel.DismissGalleryPrompt();
|
||||
PicturePanel.Navigate(new PictureGalleryPage(_appSettings, _logger, _pictureGalleryService,
|
||||
_photoPrismUploadService));
|
||||
_pictureGalleryService.ResetNewPhotoCount();
|
||||
ButtonPanel.Visibility = Visibility.Hidden;
|
||||
ActionButtonsContainer.Visibility = Visibility.Hidden;
|
||||
ShutdownDock.Visibility = Visibility.Hidden;
|
||||
}
|
||||
else
|
||||
{
|
||||
PicturePanel.ClearValue(ContentProperty);
|
||||
ButtonPanel.Visibility = Visibility.Visible;
|
||||
ActionButtonsContainer.Visibility = Visibility.Visible;
|
||||
ShutdownDock.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
_isPicturePanelVisible = visible;
|
||||
}
|
||||
|
||||
private void SetVisibilityDebugConsole(bool visible)
|
||||
{
|
||||
if (!_appSettings.IsDebugConsoleVisible) return;
|
||||
if (visible)
|
||||
DebugFrame.Navigate(new DebugConsolePage(_logger));
|
||||
else
|
||||
DebugFrame.ClearValue(ContentProperty);
|
||||
}
|
||||
|
||||
private void SwitchButtonAndTimerPanel()
|
||||
{
|
||||
ButtonPanel.Visibility = ButtonPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
|
||||
ActionButtonsContainer.Visibility = ActionButtonsContainer.Visibility == Visibility.Hidden
|
||||
? Visibility.Visible
|
||||
: Visibility.Hidden;
|
||||
TimerPanel.Visibility = TimerPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
|
||||
}
|
||||
|
||||
/// <summary>Briefly flashes the screen white to simulate a camera shutter.</summary>
|
||||
private void TriggerShutterFlash()
|
||||
{
|
||||
FlashOverlay.BeginAnimation(OpacityProperty,
|
||||
new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(450))
|
||||
{
|
||||
EasingFunction = new PowerEase { Power = 2, EasingMode = EasingMode.EaseIn }
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ShowLatestPhotoDialogAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _pictureGalleryService.LoadThumbnailsToCache(1);
|
||||
var latestPhotos = _pictureGalleryService.ThumbnailsOrderedByNewestDescending;
|
||||
if (latestPhotos.Count == 0)
|
||||
{
|
||||
_logger.Error("No photos found in gallery");
|
||||
return;
|
||||
}
|
||||
|
||||
var latestPhoto = latestPhotos[0];
|
||||
if (latestPhoto.UriSource == null)
|
||||
{
|
||||
_logger.Error("Latest photo UriSource is null");
|
||||
return;
|
||||
}
|
||||
|
||||
var photoPath = Uri.UnescapeDataString(latestPhoto.UriSource.AbsolutePath);
|
||||
if (PicturePanel.Content is PictureGalleryPage galleryPage)
|
||||
await galleryPage.ShowPhotoDialogAsync(photoPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error showing photo dialog: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
172
src/CamBooth/CamBooth.App/MainWindowViewModel.cs
Normal file
172
src/CamBooth/CamBooth.App/MainWindowViewModel.cs
Normal file
@ -0,0 +1,172 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Threading;
|
||||
|
||||
using CamBooth.App.Core.AppSettings;
|
||||
using CamBooth.App.Core.Logging;
|
||||
using CamBooth.App.Features.Camera;
|
||||
using CamBooth.App.Features.PhotoPrismUpload;
|
||||
using CamBooth.App.Features.PictureGallery;
|
||||
|
||||
namespace CamBooth.App;
|
||||
|
||||
public class MainWindowViewModel
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly AppSettingsService _appSettings;
|
||||
private readonly PictureGalleryService _pictureGalleryService;
|
||||
private readonly CameraService _cameraService;
|
||||
private readonly PhotoPrismUploadService _photoPrismUploadService;
|
||||
private readonly DispatcherTimer _galleryPromptTimer;
|
||||
|
||||
public bool IsPhotoProcessRunning { get; private set; }
|
||||
|
||||
// The running focus task — shared between StartTakePhotoProcess and OnTimerElapsed
|
||||
private Task _focusTask = Task.CompletedTask;
|
||||
|
||||
// Events → MainWindow subscribes and updates UI
|
||||
public event Action<string, string>? LoadingProgressChanged; // (statusText, countText)
|
||||
public event Action? InitializationCompleted;
|
||||
public event Action? GalleryPromptRequested;
|
||||
public event Action? GalleryPromptDismissed;
|
||||
|
||||
|
||||
public MainWindowViewModel(
|
||||
Logger logger,
|
||||
AppSettingsService appSettings,
|
||||
PictureGalleryService pictureGalleryService,
|
||||
CameraService cameraService,
|
||||
PhotoPrismUploadService photoPrismUploadService)
|
||||
{
|
||||
_logger = logger;
|
||||
_appSettings = appSettings;
|
||||
_pictureGalleryService = pictureGalleryService;
|
||||
_cameraService = cameraService;
|
||||
_photoPrismUploadService = photoPrismUploadService;
|
||||
|
||||
_galleryPromptTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||
_galleryPromptTimer.Tick += (_, _) => DismissGalleryPrompt();
|
||||
|
||||
if (appSettings.PhotoPrismAutoUploadEnabled)
|
||||
_ = InitializePhotoUploadAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadThumbnailsAsync();
|
||||
await Task.Delay(500);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Initialization error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitializationCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Starts autofocus in the background. The effective timeout is capped so focus
|
||||
/// is guaranteed to complete before the countdown timer fires.
|
||||
/// </summary>
|
||||
public Task BeginFocusAsync(int delaySeconds, int focusTimeoutMs, int countdownSeconds)
|
||||
{
|
||||
// Ensure focus always finishes at least 500ms before the timer fires.
|
||||
var maxAllowedMs = (countdownSeconds - delaySeconds) * 1000 - 500;
|
||||
var effectiveTimeoutMs = Math.Max(200, Math.Min(focusTimeoutMs, maxAllowedMs));
|
||||
_focusTask = RunFocusAsync(delaySeconds, effectiveTimeoutMs);
|
||||
return _focusTask;
|
||||
}
|
||||
|
||||
private async Task RunFocusAsync(int delaySeconds, int focusTimeoutMs)
|
||||
{
|
||||
if (delaySeconds > 0)
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
|
||||
await _cameraService.PrepareFocusAsync(focusTimeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the timer fires. Focus is already guaranteed complete — takes photo immediately.
|
||||
/// Throws on camera error so the caller can show a message.
|
||||
/// </summary>
|
||||
public void TakePhotoAfterTimer()
|
||||
{
|
||||
IsPhotoProcessRunning = false;
|
||||
_cameraService.TakePhoto();
|
||||
ShowGalleryPrompt();
|
||||
}
|
||||
|
||||
|
||||
public void StartPhotoProcess()
|
||||
{
|
||||
IsPhotoProcessRunning = true;
|
||||
_focusTask = Task.CompletedTask;
|
||||
DismissGalleryPrompt();
|
||||
}
|
||||
|
||||
|
||||
public void CancelPhotoProcess()
|
||||
{
|
||||
IsPhotoProcessRunning = false;
|
||||
}
|
||||
|
||||
|
||||
public void ShowGalleryPrompt()
|
||||
{
|
||||
GalleryPromptRequested?.Invoke();
|
||||
_galleryPromptTimer.Stop();
|
||||
_galleryPromptTimer.Start();
|
||||
}
|
||||
|
||||
|
||||
public void DismissGalleryPrompt()
|
||||
{
|
||||
_galleryPromptTimer.Stop();
|
||||
GalleryPromptDismissed?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
private async Task LoadThumbnailsAsync()
|
||||
{
|
||||
var pictureLocation = _appSettings.PictureLocation;
|
||||
|
||||
if (!Directory.Exists(pictureLocation))
|
||||
{
|
||||
LoadingProgressChanged?.Invoke("Keine Fotos gefunden", "0 Fotos");
|
||||
await Task.Delay(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
string[] imageExtensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"];
|
||||
var totalCount = Directory.EnumerateFiles(pictureLocation!)
|
||||
.Count(f => imageExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()));
|
||||
|
||||
if (totalCount == 0)
|
||||
{
|
||||
LoadingProgressChanged?.Invoke("Keine Fotos gefunden", "Bereit für neue Aufnahmen!");
|
||||
await Task.Delay(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
var photoLabel = totalCount == 1 ? "Foto" : "Fotos";
|
||||
LoadingProgressChanged?.Invoke($"Lade {totalCount} {photoLabel}...", $"0 / {totalCount}");
|
||||
await _pictureGalleryService.LoadThumbnailsToCache();
|
||||
LoadingProgressChanged?.Invoke("Fotos erfolgreich geladen!", $"{totalCount} {photoLabel} bereit");
|
||||
}
|
||||
|
||||
|
||||
private async Task InitializePhotoUploadAsync()
|
||||
{
|
||||
_logger.Info("PhotoPrism Auto-Upload aktiviert. Authentifiziere...");
|
||||
var authSuccess = await _photoPrismUploadService.AuthenticateAsync();
|
||||
if (authSuccess)
|
||||
_logger.Info("PhotoPrism-Authentifizierung erfolgreich!");
|
||||
else
|
||||
_logger.Warning("PhotoPrism-Authentifizierung fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,16 @@
|
||||
- Rotate Flick Picture 180°
|
||||
- Printer anschließen
|
||||
- Galerie schließen
|
||||
- Debug Window schließen
|
||||
- Kiosk Modus einrichten
|
||||
- Energiesparmodus abschalten
|
||||
- Starbildschirm mit freundlicher Begrüßung, kurzer Erklärung, und viel Spaß wünschen mit der FotoCam
|
||||
- Verschiedene Hinweise anzeigen beim Fotografieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.)
|
||||
- Bild über QR Code runterladen (QR Code anzeigen, sowie ausdrucken und anklebene)
|
||||
- Starbildschirm überarbeiten
|
||||
- Windows updates deaktivieren
|
||||
- logging einbinden (Elastic order ähnliches)
|
||||
- Router anschließen für Upload
|
||||
- Configs kontrollieren auf Fotobox
|
||||
- DSGVO Scheiss noch machen
|
||||
- Außen einen Hinweis für Cloud anbringen
|
||||
- Beobachten, dass die Kamera beim Start (auf Prod/Box) immer erkannt wird, bzw. der Fehler nicht mehr kommt
|
||||
- Logeinträge reduzieren / konsolidieren
|
||||
|
||||
|
||||
- Printer anschließen
|
||||
- Verschiedene Hinweise anzeigen beim Fotografieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.)
|
||||
- Bild über QR Code runterladen (QR Code anzeigen, sowie ausdrucken und anklebene)
|
||||
- logging einbinden (Elastic order ähnliches)
|
||||
Loading…
Reference in New Issue
Block a user