"Lychee-Upload: Entfernen veralteter Dokumentation und Code-Dateien"

This commit is contained in:
iTob 2026-03-09 22:02:45 +01:00
parent 097de823c0
commit 91935cd41c
33 changed files with 1335 additions and 3707 deletions

View File

@ -1,10 +1,10 @@
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging; using CamBooth.App.Core.Logging;
using CamBooth.App.Features.Camera; using CamBooth.App.Features.Camera;
using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PhotoPrismUpload;
using CamBooth.App.Features.PictureGallery; using CamBooth.App.Features.PictureGallery;
using EDSDKLib.API.Base; using EDSDKLib.API.Base;
@ -43,29 +43,27 @@ public partial class App : Application
var services = new ServiceCollection(); var services = new ServiceCollection();
// Register Configuration
services.AddSingleton<IConfiguration>(configuration); services.AddSingleton<IConfiguration>(configuration);
// Register base services
services.AddSingleton<Logger>(); services.AddSingleton<Logger>();
services.AddSingleton<AppSettingsService>(); services.AddSingleton<AppSettingsService>();
services.AddSingleton<PictureGalleryService>(); services.AddSingleton<PictureGalleryService>();
services.AddSingleton<LycheeUploadService>();
services.AddSingleton<UploadQueueService>();
services.AddSingleton<CameraService>(); services.AddSingleton<CameraService>();
// Zuerst den Provider bauen, um AppSettings zu laden services.AddSingleton<PhotoPrismAuthService>();
services.AddSingleton<PhotoPrismUploadService>();
services.AddSingleton<PhotoPrismUploadQueueService>();
var tempProvider = services.BuildServiceProvider(); var tempProvider = services.BuildServiceProvider();
var appSettings = tempProvider.GetRequiredService<AppSettingsService>(); var appSettings = tempProvider.GetRequiredService<AppSettingsService>();
var logger = tempProvider.GetRequiredService<Logger>(); var logger = tempProvider.GetRequiredService<Logger>();
// Stelle sicher, dass das PictureLocation-Verzeichnis existiert
try try
{ {
if (!Directory.Exists(appSettings.PictureLocation)) if (!Directory.Exists(appSettings.PictureLocation))
{ {
Directory.CreateDirectory(appSettings.PictureLocation); Directory.CreateDirectory(appSettings.PictureLocation);
logger.Info($"Picture directory created: {appSettings.PictureLocation}"); logger.Debug($"Picture directory created: {appSettings.PictureLocation}");
} }
} }
catch (Exception ex) catch (Exception ex)
@ -80,12 +78,10 @@ public partial class App : Application
if (appSettings.UseMockCamera) if (appSettings.UseMockCamera)
{ {
services.AddSingleton<ICanonAPI, CanonAPIMock>(); services.AddSingleton<ICanonAPI, CanonAPIMock>();
services.AddSingleton<ICamera, CameraMock>();
} }
else else
{ {
services.AddSingleton<ICanonAPI, CanonAPI>(); services.AddSingleton<ICanonAPI, CanonAPI>();
services.AddSingleton<ICamera, Camera>();
} }
} }
catch (DllNotFoundException ex) catch (DllNotFoundException ex)
@ -98,27 +94,26 @@ public partial class App : Application
MessageBoxImage.Warning); MessageBoxImage.Warning);
services.AddSingleton<ICanonAPI, CanonAPIMock>(); services.AddSingleton<ICanonAPI, CanonAPIMock>();
services.AddSingleton<ICamera, CameraMock>();
} }
services.AddTransient<MainWindow>(); services.AddTransient<MainWindow>();
_serviceProvider = services.BuildServiceProvider(); _serviceProvider = services.BuildServiceProvider();
// Starte UploadQueueService beim Start // Starte PhotoPrism Upload-Service beim Start
try try
{ {
var uploadQueueService = _serviceProvider.GetRequiredService<UploadQueueService>(); var uploadQueueService = _serviceProvider.GetRequiredService<PhotoPrismUploadQueueService>();
uploadQueueService.Start(); uploadQueueService.Start();
// Scan für fehlgeschlagene Uploads beim Start // Scan für fehlgeschlagene Uploads beim Start
uploadQueueService.ScanAndQueueFailedUploads(); uploadQueueService.ScanAndQueueFailedUploads();
logger.Info("UploadQueueService initialisiert und gestartet"); logger.Info("PhotoPrism UploadQueueService initialisiert und gestartet");
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.Error($"Fehler beim Start des UploadQueueService: {ex.Message}"); logger.Error($"Fehler beim Start des PhotoPrism UploadQueueService: {ex.Message}");
} }
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>(); var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
@ -127,10 +122,10 @@ public partial class App : Application
protected override void OnExit(ExitEventArgs e) protected override void OnExit(ExitEventArgs e)
{ {
// Stoppe UploadQueueService beim Beenden der App // Stoppe PhotoPrism UploadQueueService beim Beenden der App
try try
{ {
var uploadQueueService = _serviceProvider?.GetService<UploadQueueService>(); var uploadQueueService = _serviceProvider?.GetService<PhotoPrismUploadQueueService>();
if (uploadQueueService != null) if (uploadQueueService != null)
{ {
uploadQueueService.StopAsync().Wait(TimeSpan.FromSeconds(10)); uploadQueueService.StopAsync().Wait(TimeSpan.FromSeconds(10));
@ -138,9 +133,22 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"Fehler beim Stoppen des UploadQueueService: {ex.Message}"); System.Diagnostics.Debug.WriteLine($"Fehler beim Stoppen des PhotoPrism UploadQueueService: {ex.Message}");
}
// Dispose Service Provider, damit IDisposable-Services (z.B. CameraService) sauber beendet werden.
try
{
if (_serviceProvider is IDisposable disposableProvider)
{
disposableProvider.Dispose();
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Fehler beim Dispose des ServiceProviders: {ex.Message}");
} }
base.OnExit(e); base.OnExit(e);
} }
} }

View File

@ -71,7 +71,7 @@ public class AppSettingsService
public string? RemoteServerApiKey => configuration["LoggingSettings:RemoteServerApiKey"]; 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? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"];
public string? LycheeUsername => configuration["LycheeSettings:Username"]; public string? LycheeUsername => configuration["LycheeSettings:Username"];
@ -81,4 +81,17 @@ public class AppSettingsService
public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"]; public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"];
public bool LycheeAutoUploadEnabled => bool.Parse(configuration["LycheeSettings:AutoUploadEnabled"] ?? "false"); 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");
} }

View File

@ -38,13 +38,14 @@
"LogLevel": "Debug", "LogLevel": "Debug",
"LogDirectory": "Logs", "LogDirectory": "Logs",
"RemoteServerUrl": "https://log.grimma-fotobox.de", "RemoteServerUrl": "https://log.grimma-fotobox.de",
"RemoteServerApiKey": "nhnVql3QNgoAxvDWmNyU" "RemoteServerApiKey": "TxycJNPgpXJw7SJcFsj4"
}, },
"LycheeSettings": { "PhotoPrismSettings": {
"ApiUrl": "https://gallery.grimma-fotobox.de", "ApiUrl": "https://gallery.grimma-fotobox.de",
"Username": "itob", "ApiKey": "2ZNtZf-NvkIor-PEBJDa-vGdus5",
"Username": "admin",
"Password": "VfVyqal&Nv8U&P", "Password": "VfVyqal&Nv8U&P",
"DefaultAlbumId": "ukPbEHxdV_q1BOpIVKah7TUR", "AlbumShareUrl": "https://gallery.grimma-fotobox.de/s/kgcf48say8/kunde-01",
"AutoUploadEnabled": true "AutoUploadEnabled": true
}, },
"ConnectionStrings": { "ConnectionStrings": {

View File

@ -38,13 +38,14 @@
"LogLevel": "Information", "LogLevel": "Information",
"LogDirectory": "Logs", "LogDirectory": "Logs",
"RemoteServerUrl": "https://log.grimma-fotobox.de", "RemoteServerUrl": "https://log.grimma-fotobox.de",
"RemoteServerApiKey": "8rjvr0zZmceuFZMYydKU" "RemoteServerApiKey": "lEK0Yy3ncfC1ovyEHm3k"
}, },
"LycheeSettings": { "PhotoPrismSettings": {
"ApiUrl": "https://gallery.grimma-fotobox.de", "ApiUrl": "https://gallery.grimma-fotobox.de",
"Username": "itob", "ApiKey": "iYQlKo-KLXYWD-yIdrJP-1cHo6f",
"Username": "admin",
"Password": "VfVyqal&Nv8U&P", "Password": "VfVyqal&Nv8U&P",
"DefaultAlbumId": "ukPbEHxdV_q1BOpIVKah7TUR", "AlbumShareUrl": "https://gallery.grimma-fotobox.de/s/3hvmc1uk4f/cambooth-prod",
"AutoUploadEnabled": true "AutoUploadEnabled": true
}, },
"ConnectionStrings": { "ConnectionStrings": {

View File

@ -15,46 +15,6 @@ using Microsoft.Extensions.Configuration;
namespace CamBooth.App.Core.Logging; 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 public class Logger : IDisposable
{ {
private readonly Serilog.Core.Logger _serilogLogger; private readonly Serilog.Core.Logger _serilogLogger;

View File

@ -4,7 +4,7 @@ using System.Windows;
using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging; using CamBooth.App.Core.Logging;
using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PhotoPrismUpload;
using CamBooth.App.Features.PictureGallery; using CamBooth.App.Features.PictureGallery;
using EOSDigital.API; using EOSDigital.API;
@ -20,9 +20,7 @@ public class CameraService : IDisposable
private readonly PictureGalleryService _pictureGalleryService; private readonly PictureGalleryService _pictureGalleryService;
private readonly LycheeUploadService _lycheeUploadService; private readonly PhotoPrismUploadQueueService _photoPrismUploadQueueService;
private readonly UploadQueueService _uploadQueueService;
private readonly ICanonAPI _APIHandler; private readonly ICanonAPI _APIHandler;
@ -40,7 +38,7 @@ public class CameraService : IDisposable
private CameraValue[] ISOList; private CameraValue[] ISOList;
public ICamera _mainCamera; public ICamera? _mainCamera;
private CameraValue[] TvList; private CameraValue[] TvList;
@ -48,17 +46,13 @@ public class CameraService : IDisposable
public CameraService(Logger logger, public CameraService(Logger logger,
AppSettingsService appSettings, AppSettingsService appSettings,
PictureGalleryService pictureGalleryService, PictureGalleryService pictureGalleryService,
LycheeUploadService lycheeUploadService, PhotoPrismUploadQueueService photoPrismUploadQueueService,
UploadQueueService uploadQueueService,
ICamera mainCamera,
ICanonAPI APIHandler) ICanonAPI APIHandler)
{ {
this._logger = logger; this._logger = logger;
this._appSettings = appSettings; this._appSettings = appSettings;
this._pictureGalleryService = pictureGalleryService; this._pictureGalleryService = pictureGalleryService;
this._lycheeUploadService = lycheeUploadService; this._photoPrismUploadQueueService = photoPrismUploadQueueService;
this._uploadQueueService = uploadQueueService;
this._mainCamera = mainCamera;
this._APIHandler = APIHandler; this._APIHandler = APIHandler;
try try
{ {
@ -81,7 +75,7 @@ public class CameraService : IDisposable
this.CloseSession(); this.CloseSession();
this.IsInit = false; this.IsInit = false;
this._APIHandler.Dispose(); this._APIHandler.Dispose();
this._mainCamera.Dispose(); this._mainCamera?.Dispose();
} }
@ -96,7 +90,7 @@ public class CameraService : IDisposable
// Retry logic for camera detection (some systems need time to initialize) // Retry logic for camera detection (some systems need time to initialize)
int maxRetries = 3; int maxRetries = 3;
int retryDelay = 500; // milliseconds int retryDelay = 750; // milliseconds
for (int attempt = 0; attempt < maxRetries; attempt++) for (int attempt = 0; attempt < maxRetries; attempt++)
{ {
@ -107,7 +101,6 @@ public class CameraService : IDisposable
if (attempt < maxRetries - 1) if (attempt < maxRetries - 1)
{ {
this._logger.Info($"No cameras found on attempt {attempt + 1}/{maxRetries}, retrying in {retryDelay}ms...");
System.Threading.Thread.Sleep(retryDelay); System.Threading.Thread.Sleep(retryDelay);
this.RefreshCamera(); this.RefreshCamera();
} }
@ -118,10 +111,9 @@ public class CameraService : IDisposable
this.ReportError("No cameras / devices found"); this.ReportError("No cameras / devices found");
throw new InvalidOperationException("No cameras / devices found after multiple attempts"); 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)); string cameraDeviceNames = string.Join(", ", this.CamList.Select(cam => cam.DeviceName));
this._logger.Info(cameraDeviceNames); this._logger.Debug(cameraDeviceNames);
// Update _mainCamera reference to the freshly detected camera // Update _mainCamera reference to the freshly detected camera
this._mainCamera = this.CamList[0]; this._mainCamera = this.CamList[0];
@ -185,45 +177,56 @@ public class CameraService : IDisposable
private void OpenSession() private void OpenSession()
{ {
try if (this._mainCamera == null)
{ {
if (this._mainCamera == null) throw new InvalidOperationException("Camera reference is null. Make sure ConnectCamera is called first.");
{
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); if (this._mainCamera.SessionOpen)
// foreach (var Tv in TvList) TvCoBox.Items.Add(Tv.StringValue); {
// foreach (var ISO in ISOList) ISOCoBox.Items.Add(ISO.StringValue); this._logger.Info($"Camera session already open for {this._mainCamera.DeviceName}");
// AvCoBox.SelectedIndex = AvCoBox.Items.IndexOf(AvValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.Av)).StringValue); return;
// TvCoBox.SelectedIndex = TvCoBox.Items.IndexOf(TvValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.Tv)).StringValue); }
this._logger.Info($"Opening session for camera: {this._mainCamera.DeviceName}");
const int maxRetries = 3;
const int retryDelayMs = 1000;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
this._mainCamera.OpenSession();
break;
}
catch (Exception ex) when (attempt < maxRetries - 1 && IsSessionNotOpenError(ex))
{
this._logger.Warning($"OpenSession attempt {attempt + 1}/{maxRetries} failed ({ex.Message}), refreshing camera and retrying in {retryDelayMs}ms...");
System.Threading.Thread.Sleep(retryDelayMs);
this.RefreshCamera();
if (this.CamList?.Any() == true)
{
this._mainCamera = this.CamList[0];
}
}
catch (Exception ex)
{
this._logger.Error($"Failed to open camera session: {ex.Message}");
this.ReportError($"Failed to open camera session: {ex.Message}");
throw;
}
}
this._logger.Info("Camera session opened successfully");
//_mainCamera.ProgressChanged += MainCamera_ProgressChanged;
this._mainCamera.StateChanged += this.MainCamera_StateChanged;
this._mainCamera.DownloadReady += this.MainCamera_DownloadReady;
this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av);
this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv);
this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO);
// ISOCoBox.SelectedIndex = ISOCoBox.Items.IndexOf(ISOValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.ISO)).StringValue); // ISOCoBox.SelectedIndex = ISOCoBox.Items.IndexOf(ISOValues.GetValue(_mainCamera.GetInt32Setting(PropertyID.ISO)).StringValue);
// SettingsGroupBox.IsEnabled = true; // SettingsGroupBox.IsEnabled = true;
// LiveViewGroupBox.IsEnabled = true; // LiveViewGroupBox.IsEnabled = true;
@ -236,6 +239,13 @@ public class CameraService : IDisposable
} }
private static bool IsSessionNotOpenError(Exception ex)
{
const string errorName = "SESSION_NOT_OPEN";
return ex.Message.Contains(errorName) || (ex.InnerException?.Message?.Contains(errorName) ?? false);
}
private void StarLiveView() private void StarLiveView()
{ {
try try
@ -401,9 +411,9 @@ public class CameraService : IDisposable
this._pictureGalleryService.LoadThumbnailsToCache(); this._pictureGalleryService.LoadThumbnailsToCache();
}); });
// Füge neues Foto zur Upload-Queue hinzu (wenn Auto-Upload aktiviert) // Füge neues Foto zur PhotoPrism Upload-Queue hinzu (wenn Auto-Upload aktiviert)
this._uploadQueueService.QueueNewPhoto(savedPhotoPath); this._photoPrismUploadQueueService.QueueNewPhoto(savedPhotoPath);
this._logger.Info($"Foto zur Upload-Queue hinzugefügt: {Info.FileName}"); this._logger.Info($"Foto zur PhotoPrism Upload-Queue hinzugefügt: {Info.FileName}");
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -102,30 +102,12 @@ public partial class TimerControlRectangleAnimation : UserControl
ShowTimer(); ShowTimer();
} }
private void ShowTimer()
public void StopTimer()
{
_timer.Stop();
StopProgressBarAnimation();
// StatusText.Text = "Timer angehalten";
}
public void ShowTimer()
{ {
var fadeInAnimation = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)); var fadeInAnimation = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300));
TimerContainer.BeginAnimation(OpacityProperty, fadeInAnimation); TimerContainer.BeginAnimation(OpacityProperty, fadeInAnimation);
} }
public void HideTimer()
{
var fadeOutAnimation = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
TimerContainer.BeginAnimation(OpacityProperty, fadeOutAnimation);
}
private void StartProgressBarAnimation() private void StartProgressBarAnimation()
{ {
// Fortschrittsbalken-Animation // Fortschrittsbalken-Animation

View File

@ -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

View File

@ -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/)

View File

@ -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

View File

@ -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}");
}
}
}

View File

@ -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>

View File

@ -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();
}
});
}
}

View File

@ -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();
}
}

View File

@ -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();

View File

@ -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>

View File

@ -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

View File

@ -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
```

View File

@ -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]"
}
}
```

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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!

View File

@ -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();
}
}

View File

@ -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>

View File

@ -1,11 +1,11 @@
using System.Windows; using System.Windows;
using System.Windows.Media.Imaging; 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(); InitializeComponent();
} }

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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; }
}
}

View File

@ -1,13 +1,14 @@
using System;
using System.Drawing; using System.Drawing;
using System.IO; using System.IO;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using CamBooth.App.Core.Logging; using CamBooth.App.Core.Logging;
using QRCoder; using QRCoder;
namespace CamBooth.App.Features.LycheeUpload; namespace CamBooth.App.Features.PhotoPrismUpload;
/// <summary> /// <summary>
/// Service für die Generierung von QR-Codes als BitmapImage /// Service für die Generierung von QR-Codes als BitmapImage (PhotoPrism)
/// </summary> /// </summary>
public class QRCodeGenerationService public class QRCodeGenerationService
{ {
@ -28,7 +29,7 @@ public class QRCodeGenerationService
{ {
try try
{ {
_logger.Debug($"Generiere QR-Code für: {qrData}"); _logger.Debug($"Generiere PhotoPrism QR-Code für: {qrData}");
using (QRCodeGenerator qrGenerator = new QRCodeGenerator()) using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
{ {
@ -40,14 +41,14 @@ public class QRCodeGenerationService
// Konvertiere Bitmap zu BitmapImage // Konvertiere Bitmap zu BitmapImage
BitmapImage bitmapImage = ConvertBitmapToBitmapImage(qrCodeImage); BitmapImage bitmapImage = ConvertBitmapToBitmapImage(qrCodeImage);
_logger.Debug("✅ QR-Code erfolgreich generiert"); _logger.Debug("✅ PhotoPrism QR-Code erfolgreich generiert");
return bitmapImage; return bitmapImage;
} }
} }
} }
catch (Exception ex) 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; return null;
} }
} }
@ -72,37 +73,4 @@ public class QRCodeGenerationService
return bitmapImage; 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;
}
}
} }

View File

@ -7,7 +7,7 @@ using System.Windows.Media.Imaging;
using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging; using CamBooth.App.Core.Logging;
using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PhotoPrismUpload;
using Wpf.Ui.Controls; using Wpf.Ui.Controls;
@ -25,7 +25,7 @@ public partial class PictureGalleryPage : Page
private readonly PictureGalleryService _pictureGalleryService; private readonly PictureGalleryService _pictureGalleryService;
private readonly LycheeUploadService _lycheeUploadService; private readonly PhotoPrismUploadService _photoPrismUploadService;
private ContentDialog? _openContentDialog; private ContentDialog? _openContentDialog;
@ -37,12 +37,12 @@ public partial class PictureGalleryPage : Page
private Border? _qrCodeBorder = null; 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._appSettingsService = appSettingsService;
this._logger = logger; this._logger = logger;
this._pictureGalleryService = pictureGalleryService; this._pictureGalleryService = pictureGalleryService;
this._lycheeUploadService = lycheeUploadService; this._photoPrismUploadService = photoPrismUploadService;
this.InitializeComponent(); this.InitializeComponent();
this.Initialize(); this.Initialize();
} }
@ -70,7 +70,7 @@ public partial class PictureGalleryPage : Page
{ {
try try
{ {
var qrCodeImage = this._lycheeUploadService.GenerateGalleryQRCode(); var qrCodeImage = this._photoPrismUploadService.GenerateAlbumQRCode();
if (qrCodeImage != null) if (qrCodeImage != null)
{ {
// QR-Code Container mit Label // QR-Code Container mit Label
@ -107,12 +107,12 @@ public partial class PictureGalleryPage : Page
Margin = new Thickness(4) Margin = new Thickness(4)
}; };
this._logger.Debug("✅ QR-Code statisch erstellt"); this._logger.Debug("✅ PhotoPrism QR-Code statisch erstellt");
} }
} }
catch (Exception ex) 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); 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) public async Task ShowPhotoDialogAsync(string picturePath)
{ {
await Application.Current.Dispatcher.InvokeAsync( await Application.Current.Dispatcher.InvokeAsync(

View File

@ -131,17 +131,4 @@ public class PictureGalleryService
bitmap.Freeze(); // Threadsicher machen bitmap.Freeze(); // Threadsicher machen
return bitmap; 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
};
}
} }

View File

@ -14,7 +14,7 @@ using CamBooth.App.Core.Logging;
using CamBooth.App.Features.Camera; using CamBooth.App.Features.Camera;
using CamBooth.App.Features.DebugConsole; using CamBooth.App.Features.DebugConsole;
using CamBooth.App.Features.LiveView; using CamBooth.App.Features.LiveView;
using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PhotoPrismUpload;
using CamBooth.App.Features.PictureGallery; using CamBooth.App.Features.PictureGallery;
using Wpf.Ui.Controls; using Wpf.Ui.Controls;
@ -34,7 +34,7 @@ public partial class MainWindow : Window
private readonly CameraService _cameraService; private readonly CameraService _cameraService;
private readonly LycheeUploadService _lycheeUploadService; private readonly PhotoPrismUploadService _photoPrismUploadService;
private bool _isDebugConsoleVisible = true; private bool _isDebugConsoleVisible = true;
@ -66,13 +66,13 @@ public partial class MainWindow : Window
AppSettingsService appSettings, AppSettingsService appSettings,
PictureGalleryService pictureGalleryService, PictureGalleryService pictureGalleryService,
CameraService cameraService, CameraService cameraService,
LycheeUploadService lycheeUploadService) PhotoPrismUploadService photoPrismUploadService)
{ {
this._logger = logger; this._logger = logger;
this._appSettings = appSettings; this._appSettings = appSettings;
this._pictureGalleryService = pictureGalleryService; this._pictureGalleryService = pictureGalleryService;
this._cameraService = cameraService; this._cameraService = cameraService;
this._lycheeUploadService = lycheeUploadService; this._photoPrismUploadService = photoPrismUploadService;
InitializeComponent(); InitializeComponent();
this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible); this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible);
this.SetVisibilityPicturePanel(this._isPicturePanelVisible); this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
@ -95,20 +95,20 @@ public partial class MainWindow : Window
this.DebugCloseButton.Visibility = Visibility.Collapsed; this.DebugCloseButton.Visibility = Visibility.Collapsed;
this.HideDebugButton.Visibility = this._appSettings.IsDebugConsoleVisible ? Visibility.Visible : Visibility.Collapsed; this.HideDebugButton.Visibility = this._appSettings.IsDebugConsoleVisible ? Visibility.Visible : Visibility.Collapsed;
// Initialize Lychee upload if auto-upload is enabled // Initialize PhotoPrism upload if auto-upload is enabled
if (appSettings.LycheeAutoUploadEnabled) if (appSettings.PhotoPrismAutoUploadEnabled)
{ {
logger.Info("Lychee Auto-Upload ist aktiviert. Authentifiziere..."); logger.Info("PhotoPrism Auto-Upload ist aktiviert. Authentifiziere...");
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
var authSuccess = await _lycheeUploadService.AuthenticateAsync(); var authSuccess = await _photoPrismUploadService.AuthenticateAsync();
if (authSuccess) if (authSuccess)
{ {
logger.Info("Lychee-Authentifizierung erfolgreich!"); logger.Info("PhotoPrism-Authentifizierung erfolgreich!");
} }
else else
{ {
logger.Warning("Lychee-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren."); logger.Warning("PhotoPrism-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren.");
} }
}); });
} }
@ -260,7 +260,7 @@ public partial class MainWindow : Window
if (visibility) if (visibility)
{ {
this.HideGalleryPrompt(); this.HideGalleryPrompt();
this.PicturePanel.Navigate(new PictureGalleryPage(this._appSettings, this._logger, this._pictureGalleryService, this._lycheeUploadService)); this.PicturePanel.Navigate(new PictureGalleryPage(this._appSettings, this._logger, this._pictureGalleryService, this._photoPrismUploadService));
// Reset new photo count when opening gallery // Reset new photo count when opening gallery
this._pictureGalleryService.ResetNewPhotoCount(); this._pictureGalleryService.ResetNewPhotoCount();
// Blende unnötige Buttons aus, wenn Galerie geöffnet wird // Blende unnötige Buttons aus, wenn Galerie geöffnet wird
@ -299,6 +299,7 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, CancelEventArgs e) private void OnClosing(object? sender, CancelEventArgs e)
{ {
this.CloseCameraSessionSafely();
this._liveViewPage?.Dispose(); this._liveViewPage?.Dispose();
} }
@ -442,6 +443,9 @@ public partial class MainWindow : Window
return; return;
} }
// Kamera-Session vor Abmeldung/Shutdown sauber schließen.
this.CloseCameraSessionSafely();
if (this._appSettings.IsShutdownEnabled) if (this._appSettings.IsShutdownEnabled)
{ {
try try
@ -480,6 +484,18 @@ public partial class MainWindow : Window
} }
} }
private void CloseCameraSessionSafely()
{
try
{
this._cameraService.CloseSession();
}
catch (Exception ex)
{
this._logger.Error($"Fehler beim Schließen der Kamera-Session: {ex.Message}");
}
}
private void ToggleShutdownSlider(object sender, RoutedEventArgs e) private void ToggleShutdownSlider(object sender, RoutedEventArgs e)
{ {
this._isShutdownSliderOpen = !this._isShutdownSliderOpen; this._isShutdownSliderOpen = !this._isShutdownSliderOpen;
@ -596,26 +612,26 @@ public partial class MainWindow : Window
{ {
try try
{ {
this._logger.Info("Zeige QR-Code an..."); this._logger.Info("Zeige PhotoPrism Album-QR-Code an...");
// Generiere QR-Code // Generiere QR-Code
var qrCodeImage = this._lycheeUploadService.GenerateGalleryQRCode(); var qrCodeImage = this._photoPrismUploadService.GenerateAlbumQRCode();
if (qrCodeImage == null) if (qrCodeImage == null)
{ {
System.Windows.MessageBox.Show("QR-Code konnte nicht generiert werden. Bitte überprüfe die Lychee-Konfiguration.", 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); "Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
return; return;
} }
// Öffne QR-Code-Fenster // Öffne QR-Code-Fenster
var qrWindow = new QRCodeDisplayWindow(); var qrWindow = new PhotoPrismQRCodeDisplayWindow();
qrWindow.SetQRCode(qrCodeImage); qrWindow.SetQRCode(qrCodeImage);
qrWindow.ShowDialog(); qrWindow.ShowDialog();
} }
catch (Exception ex) catch (Exception ex)
{ {
this._logger.Error($"Fehler beim Anzeigen des QR-Codes: {ex.Message}"); this._logger.Error($"Fehler beim Anzeigen des PhotoPrism QR-Codes: {ex.Message}");
System.Windows.MessageBox.Show($"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); "Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
} }

View File

@ -1,13 +1,16 @@
- Rotate Flick Picture 180° - Rotate Flick Picture 180°
- Printer anschließen
- Galerie schließen
- Debug Window schließen
- Kiosk Modus einrichten
- Energiesparmodus abschalten - Energiesparmodus abschalten
- Starbildschirm mit freundlicher Begrüßung, kurzer Erklärung, und viel Spaß wünschen mit der FotoCam - Starbildschirm überarbeiten
- Verschiedene Hinweise anzeigen beim Fotografieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.)
- Bild über QR Code runterladen (QR Code anzeigen, sowie ausdrucken und anklebene)
- Windows updates deaktivieren - Windows updates deaktivieren
- logging einbinden (Elastic order ähnliches)
- Router anschließen für Upload - Router anschließen für Upload
- Configs kontrollieren auf Fotobox - 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)