Compare commits
3 Commits
bd2e388fd0
...
9b39de7b76
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b39de7b76 | |||
| 8d27883312 | |||
| b1054d08b2 |
104
src/CamBooth/CAMERA_FOCUS_AND_FLASH_FIX.md
Normal file
104
src/CamBooth/CAMERA_FOCUS_AND_FLASH_FIX.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Kamera Fokus und Blitz Behebung
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Die reale Kamera konnte in dunklen Bedingungen nicht fokussieren und der Blitz wurde nicht verwendet.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Die CameraService Klasse hatte keine Konfiguration für:
|
||||||
|
1. **Autofokus-Modus (AFMode)** - War nicht auf eine geeignete Einstellung für Live-Fokussierung konfiguriert
|
||||||
|
2. **Blitz-Einstellungen** - Waren nicht aktiviert oder konfiguriert
|
||||||
|
|
||||||
|
## Lösung
|
||||||
|
|
||||||
|
### 1. Autofokus-Modus Konfiguration
|
||||||
|
Der Autofokus-Modus wurde in der neuen Methode `ConfigureCameraForFocusAndFlash()` auf **AIFocus** gesetzt:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
AFMode.AIFocus // Kamera fokussiert kontinuierlich, passt sich an Bewegungen an
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verfügbare Modi:**
|
||||||
|
- `OneShot (0)` - Fokus wird einmal eingestellt, dann gesperrt
|
||||||
|
- `AIServo (1)` - Kontinuierlicher Autofokus bei bewegten Objekten
|
||||||
|
- `AIFocus (2)` - Intelligent: Wechselt zwischen OneShot und AIServo ⭐ EMPFOHLEN
|
||||||
|
- `Manual (3)` - Manueller Fokus
|
||||||
|
|
||||||
|
**AIFocus ist die beste Wahl**, da es:
|
||||||
|
- Automatisch zwischen OneShot und AIServo wechselt
|
||||||
|
- In dunklen Bedingungen besser funktioniert
|
||||||
|
- Flexibler ist
|
||||||
|
|
||||||
|
### 2. Blitz-Konfiguration
|
||||||
|
Der Blitz wird durch zwei Einstellungen aktiviert:
|
||||||
|
|
||||||
|
#### a) Flash Compensation (Blitzstärke)
|
||||||
|
```csharp
|
||||||
|
this._mainCamera.SetSetting(PropertyID.FlashCompensation, 0);
|
||||||
|
```
|
||||||
|
- Werte: -3.0 bis +3.0
|
||||||
|
- 0 = Standard Blitzstärke
|
||||||
|
|
||||||
|
#### b) Red-Eye Reduction (Rote-Augen-Reduktion)
|
||||||
|
```csharp
|
||||||
|
this._mainCamera.SetSetting(PropertyID.RedEye, (int)RedEye.On);
|
||||||
|
```
|
||||||
|
- Dies aktiviert auch den Blitz in dunklen Bedingungen
|
||||||
|
- Sendet Pre-Flash vor Hauptblitz aus
|
||||||
|
|
||||||
|
### 3. Verbesserte Autofokus-Feedback
|
||||||
|
Die `PrepareFocusAsync()` Methode wurde erweitert um:
|
||||||
|
- Besseres Logging der Fokus-Ergebnisse
|
||||||
|
- Fehlermeldungen, wenn Fokus fehlschlägt
|
||||||
|
- Hinweise auf Lichtverhältnisse und Blitz
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Die Konfiguration erfolgt automatisch wenn:
|
||||||
|
1. Kamera verbunden wird
|
||||||
|
2. Session geöffnet wird
|
||||||
|
3. Speicherort auf Computer eingestellt wird
|
||||||
|
|
||||||
|
### Code in CameraService.cs:
|
||||||
|
```csharp
|
||||||
|
private void SetSettingSaveToComputer()
|
||||||
|
{
|
||||||
|
this._mainCamera.SetSetting(PropertyID.SaveTo, (int)SaveTo.Host);
|
||||||
|
this._mainCamera.SetCapacity(4096, int.MaxValue);
|
||||||
|
ConfigureCameraForFocusAndFlash(); // ← NEUE ZEILE
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureCameraForFocusAndFlash()
|
||||||
|
{
|
||||||
|
// Autofocus auf AIFocus setzen
|
||||||
|
this._mainCamera.SetSetting(PropertyID.AFMode, (int)AFMode.AIFocus);
|
||||||
|
|
||||||
|
// Blitzstärke konfigurieren
|
||||||
|
this._mainCamera.SetSetting(PropertyID.FlashCompensation, 0);
|
||||||
|
|
||||||
|
// Rote-Augen-Reduktion aktivieren (aktiviert auch Blitz)
|
||||||
|
this._mainCamera.SetSetting(PropertyID.RedEye, (int)RedEye.On);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canon SDK PropertyIDs verwendet
|
||||||
|
- **PropertyID.AFMode** (0x00000404) - Autofokus-Modus
|
||||||
|
- **PropertyID.FlashCompensation** (0x00000408) - Blitzkompensation
|
||||||
|
- **PropertyID.RedEye** (0x00000413) - Rote-Augen-Reduktion
|
||||||
|
|
||||||
|
## Enums verwendet
|
||||||
|
- **AFMode** - Autofokus Modi
|
||||||
|
- **RedEye** - Rote-Augen-Reduktion (Off/On)
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
Nach dieser Änderung sollte die Kamera:
|
||||||
|
1. ✅ Autofokus in dunklen Bedingungen durchführen
|
||||||
|
2. ✅ Blitz automatisch auslösen wenn nötig
|
||||||
|
3. ✅ Bessere Fokus-Ergebnisse in Low-Light Situationen bieten
|
||||||
|
|
||||||
|
## Weitere Empfehlungen
|
||||||
|
Falls die Kamera noch immer nicht fokussiert:
|
||||||
|
1. Überprüfen Sie die **Lichtverhältnisse** - Blitz ist deaktiviert
|
||||||
|
2. Überprüfen Sie die **Objektiv-Fokus-Einstellung** (AF/MF Schalter an der Linse)
|
||||||
|
3. Überprüfen Sie, ob das **Autofokus-System des Objektivs** funktioniert
|
||||||
|
4. Versuchen Sie ISO-Wert zu erhöhen: `SetSetting(PropertyID.ISO, (int)ISOValue)`
|
||||||
|
5. Überprüfen Sie die **Kamera-Menüs** auf Fokus-Einschränkungen
|
||||||
@ -1,9 +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.PictureGallery;
|
using CamBooth.App.Features.PictureGallery;
|
||||||
|
|
||||||
using EDSDKLib.API.Base;
|
using EDSDKLib.API.Base;
|
||||||
@ -31,6 +32,7 @@ public partial class App : Application
|
|||||||
services.AddSingleton<Logger>();
|
services.AddSingleton<Logger>();
|
||||||
services.AddSingleton<AppSettingsService>();
|
services.AddSingleton<AppSettingsService>();
|
||||||
services.AddSingleton<PictureGalleryService>();
|
services.AddSingleton<PictureGalleryService>();
|
||||||
|
services.AddSingleton<LycheeUploadService>();
|
||||||
services.AddSingleton<CameraService>();
|
services.AddSingleton<CameraService>();
|
||||||
|
|
||||||
// Zuerst den Provider bauen, um AppSettings zu laden
|
// Zuerst den Provider bauen, um AppSettings zu laden
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using CamBooth.App.Core.Logging;
|
using CamBooth.App.Core.Logging;
|
||||||
|
|
||||||
namespace CamBooth.App.Core.AppSettings;
|
namespace CamBooth.App.Core.AppSettings;
|
||||||
|
|
||||||
@ -61,4 +61,15 @@ public class AppSettingsService
|
|||||||
public string? ConnectionString => configuration.GetConnectionString("DefaultConnection");
|
public string? ConnectionString => configuration.GetConnectionString("DefaultConnection");
|
||||||
|
|
||||||
public string ConfigFileName => loadedConfigFile;
|
public string ConfigFileName => loadedConfigFile;
|
||||||
|
|
||||||
|
// Lychee Upload Settings
|
||||||
|
public string? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"];
|
||||||
|
|
||||||
|
public string? LycheeUsername => configuration["LycheeSettings:Username"];
|
||||||
|
|
||||||
|
public string? LycheePassword => configuration["LycheeSettings:Password"];
|
||||||
|
|
||||||
|
public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"];
|
||||||
|
|
||||||
|
public bool LycheeAutoUploadEnabled => bool.Parse(configuration["LycheeSettings:AutoUploadEnabled"] ?? "false");
|
||||||
}
|
}
|
||||||
@ -2,15 +2,22 @@
|
|||||||
"AppSettings": {
|
"AppSettings": {
|
||||||
"AppName": "Meine Anwendung",
|
"AppName": "Meine Anwendung",
|
||||||
"Version": "1.0.0",
|
"Version": "1.0.0",
|
||||||
"IsDebugMode": true,
|
"IsDebugMode": false,
|
||||||
"PictureLocation": "C:\\tmp\\cambooth",
|
"PictureLocation": "C:\\tmp\\cambooth",
|
||||||
"DebugConsoleVisible": "true",
|
"DebugConsoleVisible": "false",
|
||||||
"PhotoCountdownSeconds": 2,
|
"PhotoCountdownSeconds": 2,
|
||||||
"FocusDelaySeconds": 1,
|
"FocusDelaySeconds": 1,
|
||||||
"FocusTimeoutMs": 1000,
|
"FocusTimeoutMs": 1000,
|
||||||
"IsShutdownEnabled": false,
|
"IsShutdownEnabled": false,
|
||||||
"UseMockCamera": true
|
"UseMockCamera": true
|
||||||
},
|
},
|
||||||
|
"LycheeSettings": {
|
||||||
|
"ApiUrl": "https://cambooth-pics.rblnews.de",
|
||||||
|
"Username": "itob",
|
||||||
|
"Password": "VfVyqal&Nv8U&P",
|
||||||
|
"DefaultAlbumId": "gMM3W-Pk9mr8k57k-WU2Jz8t",
|
||||||
|
"AutoUploadEnabled": true
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;"
|
"DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,13 @@
|
|||||||
"IsShutdownEnabled": true,
|
"IsShutdownEnabled": true,
|
||||||
"UseMockCamera": true
|
"UseMockCamera": true
|
||||||
},
|
},
|
||||||
|
"LycheeSettings": {
|
||||||
|
"ApiUrl": "https://cambooth-pics.rblnews.de",
|
||||||
|
"Username": "itob",
|
||||||
|
"Password": "VfVyqal&Nv8U&P",
|
||||||
|
"DefaultAlbumId": "gMM3W-Pk9mr8k57k-WU2Jz8t",
|
||||||
|
"AutoUploadEnabled": false
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;"
|
"DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,82 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace CamBooth.App.Core.Logging;
|
namespace CamBooth.App.Core.Logging;
|
||||||
|
|
||||||
public class Logger
|
public class Logger
|
||||||
{
|
{
|
||||||
|
private readonly string _logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||||
|
private readonly string _errorLogPath;
|
||||||
|
|
||||||
public event LoggingEventHandler? InfoLog;
|
public event LoggingEventHandler? InfoLog;
|
||||||
public event LoggingEventHandler? ErrorLog;
|
public event LoggingEventHandler? ErrorLog;
|
||||||
|
public event LoggingEventHandler? WarningLog;
|
||||||
|
public event LoggingEventHandler? DebugLog;
|
||||||
public delegate void LoggingEventHandler(string text);
|
public delegate void LoggingEventHandler(string text);
|
||||||
|
|
||||||
|
public Logger()
|
||||||
|
{
|
||||||
|
// Logs-Ordner erstellen, falls nicht vorhanden
|
||||||
|
if (!Directory.Exists(_logsDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_logsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
_errorLogPath = Path.Combine(_logsDirectory, "error.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteToErrorLog(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.AppendAllText(_errorLogPath, message + Environment.NewLine);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fehler beim Schreiben in Fehlerlog: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Info(string message)
|
public void Info(string message)
|
||||||
{
|
{
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
message = DateTime.Now.ToString("dd.MM.yyyy HH:MM:ss", CultureInfo.InvariantCulture) + ": " + message;
|
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [INFO] " + message;
|
||||||
InfoLog?.Invoke(message);
|
InfoLog?.Invoke(message);
|
||||||
Console.WriteLine(message);
|
Console.WriteLine(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Warning(string message)
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [WARNING] " + message;
|
||||||
|
WarningLog?.Invoke(message);
|
||||||
|
Console.WriteLine(message);
|
||||||
|
WriteToErrorLog(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public void Error(string message)
|
public void Error(string message)
|
||||||
{
|
{
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
message = DateTime.Now.ToString("dd.MM.yyyy HH:MM:ss", CultureInfo.InvariantCulture) + ": " + message;
|
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [ERROR] " + message;
|
||||||
ErrorLog?.Invoke(message);
|
ErrorLog?.Invoke(message);
|
||||||
Console.WriteLine(message);
|
Console.WriteLine(message);
|
||||||
|
WriteToErrorLog(message);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Debug(string message)
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [DEBUG] " + message;
|
||||||
|
DebugLog?.Invoke(message);
|
||||||
|
Console.WriteLine(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,6 +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.PictureGallery;
|
using CamBooth.App.Features.PictureGallery;
|
||||||
|
|
||||||
using EOSDigital.API;
|
using EOSDigital.API;
|
||||||
@ -19,6 +20,8 @@ public class CameraService : IDisposable
|
|||||||
|
|
||||||
private readonly PictureGalleryService _pictureGalleryService;
|
private readonly PictureGalleryService _pictureGalleryService;
|
||||||
|
|
||||||
|
private readonly LycheeUploadService _lycheeUploadService;
|
||||||
|
|
||||||
private readonly ICanonAPI _APIHandler;
|
private readonly ICanonAPI _APIHandler;
|
||||||
|
|
||||||
private CameraValue[] AvList;
|
private CameraValue[] AvList;
|
||||||
@ -43,12 +46,14 @@ public class CameraService : IDisposable
|
|||||||
public CameraService(Logger logger,
|
public CameraService(Logger logger,
|
||||||
AppSettingsService appSettings,
|
AppSettingsService appSettings,
|
||||||
PictureGalleryService pictureGalleryService,
|
PictureGalleryService pictureGalleryService,
|
||||||
|
LycheeUploadService lycheeUploadService,
|
||||||
ICamera mainCamera,
|
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._mainCamera = mainCamera;
|
this._mainCamera = mainCamera;
|
||||||
this._APIHandler = APIHandler;
|
this._APIHandler = APIHandler;
|
||||||
try
|
try
|
||||||
@ -81,17 +86,30 @@ public class CameraService : IDisposable
|
|||||||
ErrorHandler.SevereErrorHappened += this.ErrorHandler_SevereErrorHappened;
|
ErrorHandler.SevereErrorHappened += this.ErrorHandler_SevereErrorHappened;
|
||||||
ErrorHandler.NonSevereErrorHappened += this.ErrorHandler_NonSevereErrorHappened;
|
ErrorHandler.NonSevereErrorHappened += this.ErrorHandler_NonSevereErrorHappened;
|
||||||
|
|
||||||
this.RefreshCamera();
|
try
|
||||||
List<ICamera> cameraList = this._APIHandler.GetCameraList();
|
|
||||||
if (cameraList.Any())
|
|
||||||
{
|
{
|
||||||
this.OpenSession();
|
this.RefreshCamera();
|
||||||
this.SetSettingSaveToComputer();
|
List<ICamera> cameraList = this._APIHandler.GetCameraList();
|
||||||
this.StarLiveView();
|
if (cameraList.Any())
|
||||||
}
|
{
|
||||||
|
this.OpenSession();
|
||||||
|
this.SetSettingSaveToComputer();
|
||||||
|
this.StarLiveView();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.ReportError("No cameras / devices found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string cameraDeviceNames = string.Join(", ", cameraList.Select(cam => cam.DeviceName));
|
string cameraDeviceNames = string.Join(", ", cameraList.Select(cam => cam.DeviceName));
|
||||||
this._logger.Info(cameraDeviceNames == string.Empty ? "No cameras / devices found" : cameraDeviceNames);
|
this._logger.Info(cameraDeviceNames);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"Error connecting camera: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -104,7 +122,18 @@ public class CameraService : IDisposable
|
|||||||
|
|
||||||
public void CloseSession()
|
public void CloseSession()
|
||||||
{
|
{
|
||||||
this._mainCamera.CloseSession();
|
try
|
||||||
|
{
|
||||||
|
if (this._mainCamera != null && this._mainCamera.SessionOpen)
|
||||||
|
{
|
||||||
|
this._mainCamera.CloseSession();
|
||||||
|
this._logger.Info("Camera session closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"Error closing camera session: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
// AvCoBox.Items.Clear();
|
// AvCoBox.Items.Clear();
|
||||||
// TvCoBox.Items.Clear();
|
// TvCoBox.Items.Clear();
|
||||||
@ -130,17 +159,41 @@ public class CameraService : IDisposable
|
|||||||
|
|
||||||
private void OpenSession()
|
private void OpenSession()
|
||||||
{
|
{
|
||||||
this._mainCamera = this.CamList[0];
|
try
|
||||||
this._mainCamera.OpenSession();
|
{
|
||||||
|
if (this.CamList == null || this.CamList.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No cameras available in camera list");
|
||||||
|
}
|
||||||
|
|
||||||
//_mainCamera.ProgressChanged += MainCamera_ProgressChanged;
|
this._mainCamera = this.CamList[0];
|
||||||
this._mainCamera.StateChanged += this.MainCamera_StateChanged;
|
|
||||||
this._mainCamera.DownloadReady += this.MainCamera_DownloadReady;
|
|
||||||
|
|
||||||
//SessionLabel.Content = _mainCamera.DeviceName;
|
// Check if session is already open
|
||||||
this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av);
|
if (this._mainCamera.SessionOpen)
|
||||||
this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv);
|
{
|
||||||
this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO);
|
this._logger.Info($"Camera session already open for {this._mainCamera.DeviceName}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.Info($"Opening session for camera: {this._mainCamera.DeviceName}");
|
||||||
|
this._mainCamera.OpenSession();
|
||||||
|
this._logger.Info("Camera session opened successfully");
|
||||||
|
|
||||||
|
//_mainCamera.ProgressChanged += MainCamera_ProgressChanged;
|
||||||
|
this._mainCamera.StateChanged += this.MainCamera_StateChanged;
|
||||||
|
this._mainCamera.DownloadReady += this.MainCamera_DownloadReady;
|
||||||
|
|
||||||
|
//SessionLabel.Content = _mainCamera.DeviceName;
|
||||||
|
this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av);
|
||||||
|
this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv);
|
||||||
|
this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"Failed to open camera session: {ex.Message}");
|
||||||
|
this.ReportError($"Failed to open camera session: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
// foreach (var Av in AvList) AvCoBox.Items.Add(Av.StringValue);
|
// foreach (var Av in AvList) AvCoBox.Items.Add(Av.StringValue);
|
||||||
// foreach (var Tv in TvList) TvCoBox.Items.Add(Tv.StringValue);
|
// foreach (var Tv in TvList) TvCoBox.Items.Add(Tv.StringValue);
|
||||||
@ -316,11 +369,42 @@ public class CameraService : IDisposable
|
|||||||
{
|
{
|
||||||
Info.FileName = $"img_{Guid.NewGuid().ToString()}.jpg";
|
Info.FileName = $"img_{Guid.NewGuid().ToString()}.jpg";
|
||||||
sender.DownloadFile(Info, this._appSettings.PictureLocation);
|
sender.DownloadFile(Info, this._appSettings.PictureLocation);
|
||||||
this._logger.Info("Download complete: " + Path.Combine(this._appSettings.PictureLocation, Info.FileName));
|
var savedPhotoPath = Path.Combine(this._appSettings.PictureLocation, Info.FileName);
|
||||||
|
this._logger.Info("Download complete: " + savedPhotoPath);
|
||||||
|
|
||||||
Application.Current.Dispatcher.Invoke(() => {
|
Application.Current.Dispatcher.Invoke(() => {
|
||||||
this._pictureGalleryService.IncrementNewPhotoCount();
|
this._pictureGalleryService.IncrementNewPhotoCount();
|
||||||
this._pictureGalleryService.LoadThumbnailsToCache();
|
this._pictureGalleryService.LoadThumbnailsToCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-Upload zu Lychee, falls aktiviert
|
||||||
|
if (this._appSettings.LycheeAutoUploadEnabled)
|
||||||
|
{
|
||||||
|
this._logger.Info("Auto-Upload aktiviert. Starte Upload zu Lychee...");
|
||||||
|
|
||||||
|
// Upload im Hintergrund, damit die Fotobox nicht blockiert wird
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var albumId = this._appSettings.LycheeDefaultAlbumId;
|
||||||
|
var uploadSuccess = await this._lycheeUploadService.UploadImageAsync(savedPhotoPath, albumId);
|
||||||
|
|
||||||
|
if (uploadSuccess)
|
||||||
|
{
|
||||||
|
this._logger.Info($"✅ Lychee-Upload erfolgreich: {Info.FileName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._logger.Warning($"⚠️ Lychee-Upload fehlgeschlagen: {Info.FileName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"❌ Fehler beim Lychee-Upload: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
xmlns:liveView="clr-namespace:CamBooth.App.Features.LiveView"
|
xmlns:liveView="clr-namespace:CamBooth.App.Features.LiveView"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="LiveViewPage" Width="1350" Height="900"
|
Title="LiveViewPage" Width="1350" Height="900"
|
||||||
Background="PaleVioletRed">
|
Background="Transparent">
|
||||||
<Grid HorizontalAlignment="Stretch">
|
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||||
<Canvas x:Name="LVCanvas" Background="Bisque" />
|
<Canvas x:Name="LVCanvas" Background="Transparent" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
@ -33,13 +33,41 @@ public partial class LiveViewPage : Page
|
|||||||
this.InitializeComponent();
|
this.InitializeComponent();
|
||||||
this.SetImageAction = img => { this.bgbrush.ImageSource = img; };
|
this.SetImageAction = img => { this.bgbrush.ImageSource = img; };
|
||||||
|
|
||||||
// Mirror the LiveView image horizontally
|
// Configure the image brush
|
||||||
ScaleTransform scaleTransform = new ScaleTransform(-1, 1, 0.5, 0.5);
|
this.bgbrush.Stretch = Stretch.UniformToFill;
|
||||||
this.bgbrush.Transform = scaleTransform;
|
this.bgbrush.AlignmentX = AlignmentX.Center;
|
||||||
|
this.bgbrush.AlignmentY = AlignmentY.Center;
|
||||||
|
|
||||||
this.LVCanvas.Background = this.bgbrush;
|
this.LVCanvas.Background = this.bgbrush;
|
||||||
cameraService.ConnectCamera();
|
|
||||||
cameraService._mainCamera.LiveViewUpdated += this.MainCamera_OnLiveViewUpdated;
|
// Apply horizontal flip on the Canvas using RenderTransform
|
||||||
|
TransformGroup transformGroup = new();
|
||||||
|
transformGroup.Children.Add(new ScaleTransform { ScaleX = -1, ScaleY = 1 });
|
||||||
|
transformGroup.Children.Add(new TranslateTransform { X = 1, Y = 0 });
|
||||||
|
this.LVCanvas.RenderTransform = transformGroup;
|
||||||
|
this.LVCanvas.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cameraService.ConnectCamera();
|
||||||
|
|
||||||
|
// Verify that camera session is open before subscribing to events
|
||||||
|
if (cameraService._mainCamera != null && cameraService._mainCamera.SessionOpen)
|
||||||
|
{
|
||||||
|
cameraService._mainCamera.LiveViewUpdated += this.MainCamera_OnLiveViewUpdated;
|
||||||
|
this._logger.Info("LiveViewPage initialized successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._logger.Error("Camera session is not open after connection attempt");
|
||||||
|
throw new InvalidOperationException("Camera session failed to open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"Failed to initialize LiveViewPage: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,12 @@
|
|||||||
Fill="#4CAF50"
|
Fill="#4CAF50"
|
||||||
Height="75"
|
Height="75"
|
||||||
HorizontalAlignment="Left"/>
|
HorizontalAlignment="Left"/>
|
||||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="72">HIER EINE ANWEISUNG ANZEIGEN</TextBlock>
|
<TextBlock x:Name="InstructionText"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
FontSize="72"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="White">Lächeln!</TextBlock>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ~1~ Countdown-Anzeige @1@ -->
|
<!-- ~1~ Countdown-Anzeige @1@ -->
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Media.Animation;
|
using System.Windows.Media.Animation;
|
||||||
using System.Windows.Shapes;
|
using System.Windows.Shapes;
|
||||||
@ -22,6 +22,32 @@ public partial class TimerControlRectangleAnimation : UserControl
|
|||||||
|
|
||||||
private Storyboard _progressBarAnimation;
|
private Storyboard _progressBarAnimation;
|
||||||
|
|
||||||
|
private Random _random = new Random();
|
||||||
|
|
||||||
|
private List<string> _photoInstructions = new List<string>
|
||||||
|
{
|
||||||
|
"Lächeln! 😊",
|
||||||
|
"Hasenohren machen! 🐰",
|
||||||
|
"Zunge rausstrecken! 👅",
|
||||||
|
"Grimasse ziehen! 😝",
|
||||||
|
"Daumen hoch! 👍",
|
||||||
|
"Peace-Zeichen! ✌️",
|
||||||
|
"Lustig gucken! 🤪",
|
||||||
|
"Crazy Face! 🤯",
|
||||||
|
"Küsschen! 😘",
|
||||||
|
"Winken! 👋",
|
||||||
|
"Herz mit den Händen! ❤️",
|
||||||
|
"Verrückt sein! 🤪",
|
||||||
|
"Überrascht schauen! 😲",
|
||||||
|
"Cool bleiben! 😎",
|
||||||
|
"Lachen! 😄",
|
||||||
|
"Zähne zeigen! 😁",
|
||||||
|
"Schnute ziehen! 😗",
|
||||||
|
"Augen zukneifen! 😆",
|
||||||
|
"Arm hochstrecken! 🙌",
|
||||||
|
"Gruppe umarmen! 🤗"
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
public TimerControlRectangleAnimation()
|
public TimerControlRectangleAnimation()
|
||||||
{
|
{
|
||||||
@ -66,6 +92,10 @@ public partial class TimerControlRectangleAnimation : UserControl
|
|||||||
|
|
||||||
// TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss");
|
// TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss");
|
||||||
// StatusText.Text = "Timer läuft...";
|
// StatusText.Text = "Timer läuft...";
|
||||||
|
|
||||||
|
// Show initial random instruction
|
||||||
|
ShowRandomInstruction();
|
||||||
|
|
||||||
_timer.Start();
|
_timer.Start();
|
||||||
|
|
||||||
StartProgressBarAnimation();
|
StartProgressBarAnimation();
|
||||||
@ -120,4 +150,13 @@ public partial class TimerControlRectangleAnimation : UserControl
|
|||||||
{
|
{
|
||||||
_progressBarAnimation?.Stop();
|
_progressBarAnimation?.Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ShowRandomInstruction()
|
||||||
|
{
|
||||||
|
if (_photoInstructions.Count > 0)
|
||||||
|
{
|
||||||
|
int randomIndex = _random.Next(_photoInstructions.Count);
|
||||||
|
InstructionText.Text = _photoInstructions[randomIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
236
src/CamBooth/CamBooth.App/Features/LycheeUpload/ARCHITECTURE.md
Normal file
236
src/CamBooth/CamBooth.App/Features/LycheeUpload/ARCHITECTURE.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# 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
|
||||||
101
src/CamBooth/CamBooth.App/Features/LycheeUpload/CHANGELOG.md
Normal file
101
src/CamBooth/CamBooth.App/Features/LycheeUpload/CHANGELOG.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# 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/)
|
||||||
153
src/CamBooth/CamBooth.App/Features/LycheeUpload/INTEGRATION.md
Normal file
153
src/CamBooth/CamBooth.App/Features/LycheeUpload/INTEGRATION.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# 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
|
||||||
@ -0,0 +1,198 @@
|
|||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
<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"
|
||||||
|
Width="140"
|
||||||
|
Height="45"
|
||||||
|
Background="#D4AF37"
|
||||||
|
Foreground="#1F1A00"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
</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"
|
||||||
|
Width="200"
|
||||||
|
Height="45"
|
||||||
|
Margin="0,0,10,0"
|
||||||
|
Appearance="Secondary" />
|
||||||
|
<ui:Button x:Name="UploadAllPhotosButton"
|
||||||
|
Content="Alle Fotos hochladen"
|
||||||
|
Click="UploadAllPhotosButton_Click"
|
||||||
|
Width="200"
|
||||||
|
Height="45"
|
||||||
|
Background="#D4AF37"
|
||||||
|
Foreground="#1F1A00"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
</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>
|
||||||
@ -0,0 +1,249 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,540 @@
|
|||||||
|
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 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 CookieContainer _cookieContainer;
|
||||||
|
private readonly HttpClientHandler _httpClientHandler;
|
||||||
|
private string? _csrfToken;
|
||||||
|
private bool _isAuthenticated = false;
|
||||||
|
|
||||||
|
public LycheeUploadService(AppSettingsService appSettings, Logger logger)
|
||||||
|
{
|
||||||
|
_appSettings = appSettings;
|
||||||
|
_logger = 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>
|
||||||
|
/// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_httpClient?.Dispose();
|
||||||
|
_httpClientHandler?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
// 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();
|
||||||
196
src/CamBooth/CamBooth.App/Features/LycheeUpload/README.md
Normal file
196
src/CamBooth/CamBooth.App/Features/LycheeUpload/README.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# 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
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
# 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]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
# 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!
|
||||||
@ -9,7 +9,12 @@
|
|||||||
Background="Black">
|
Background="Black">
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<ScrollViewer x:Name="GalleryScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Background="Black" CanContentScroll="False">
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="0" x:Name="GalleryScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Background="Black" CanContentScroll="False">
|
||||||
<ItemsControl x:Name="PicturesPanel" Background="Black">
|
<ItemsControl x:Name="PicturesPanel" Background="Black">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
@ -18,6 +23,50 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Pager Controls -->
|
||||||
|
<Border Grid.Row="1" Background="#2C2C2C" Padding="20,15" BorderBrush="#444444" BorderThickness="0,1,0,0">
|
||||||
|
<Grid HorizontalAlignment="Center">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- First Page Button -->
|
||||||
|
<Button Grid.Column="0" x:Name="FirstPageButton" Content="⏮️"
|
||||||
|
Width="60" Height="50" Margin="5,0"
|
||||||
|
FontSize="20" Background="#3C3C3C" Foreground="White"
|
||||||
|
Click="FirstPageButton_Click" />
|
||||||
|
|
||||||
|
<!-- Previous Page Button -->
|
||||||
|
<Button Grid.Column="1" x:Name="PreviousPageButton" Content="◀️"
|
||||||
|
Width="60" Height="50" Margin="5,0"
|
||||||
|
FontSize="20" Background="#3C3C3C" Foreground="White"
|
||||||
|
Click="PreviousPageButton_Click" />
|
||||||
|
|
||||||
|
<!-- Page Info -->
|
||||||
|
<TextBlock Grid.Column="2" x:Name="PageInfoText"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Margin="20,0" FontSize="18" Foreground="White"
|
||||||
|
Text="Seite 1 von 1" MinWidth="150" TextAlignment="Center" />
|
||||||
|
|
||||||
|
<!-- Next Page Button -->
|
||||||
|
<Button Grid.Column="3" x:Name="NextPageButton" Content="▶️"
|
||||||
|
Width="60" Height="50" Margin="5,0"
|
||||||
|
FontSize="20" Background="#3C3C3C" Foreground="White"
|
||||||
|
Click="NextPageButton_Click" />
|
||||||
|
|
||||||
|
<!-- Last Page Button -->
|
||||||
|
<Button Grid.Column="4" x:Name="LastPageButton" Content="⏭️"
|
||||||
|
Width="60" Height="50" Margin="5,0"
|
||||||
|
FontSize="20" Background="#3C3C3C" Foreground="White"
|
||||||
|
Click="LastPageButton_Click" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<ContentPresenter x:Name="RootContentDialogPresenter" Grid.Row="0" />
|
<ContentPresenter x:Name="RootContentDialogPresenter" Grid.Row="0" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Documents;
|
using System.Windows.Documents;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
@ -26,6 +26,10 @@ public partial class PictureGalleryPage : Page
|
|||||||
|
|
||||||
private ContentDialog? _openContentDialog;
|
private ContentDialog? _openContentDialog;
|
||||||
|
|
||||||
|
private int _currentPage = 1;
|
||||||
|
private int _itemsPerPage = 12;
|
||||||
|
private int _totalPages = 1;
|
||||||
|
|
||||||
|
|
||||||
public PictureGalleryPage(AppSettingsService appSettingsService, Logger logger, PictureGalleryService pictureGalleryService)
|
public PictureGalleryPage(AppSettingsService appSettingsService, Logger logger, PictureGalleryService pictureGalleryService)
|
||||||
{
|
{
|
||||||
@ -41,7 +45,10 @@ public partial class PictureGalleryPage : Page
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
this.LoadPictures(12);
|
_currentPage = 1;
|
||||||
|
CalculateTotalPages();
|
||||||
|
LoadCurrentPage();
|
||||||
|
UpdatePagerControls();
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@ -59,16 +66,20 @@ public partial class PictureGalleryPage : Page
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void LoadPictures(int howManyPictures = 0)
|
private void LoadPictures(int startIndex, int count)
|
||||||
{
|
{
|
||||||
int loop = 0;
|
|
||||||
|
|
||||||
this.Dispatcher.Invoke(
|
this.Dispatcher.Invoke(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
do
|
// Clear existing items
|
||||||
|
this.PicturesPanel.Items.Clear();
|
||||||
|
|
||||||
|
int totalThumbnails = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending.Count;
|
||||||
|
int endIndex = Math.Min(startIndex + count, totalThumbnails);
|
||||||
|
|
||||||
|
for (int i = startIndex; i < endIndex; i++)
|
||||||
{
|
{
|
||||||
BitmapImage thumbnail = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending[loop];
|
BitmapImage thumbnail = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending[i];
|
||||||
|
|
||||||
TextBlock? textBlock = new();
|
TextBlock? textBlock = new();
|
||||||
Hyperlink? hyperlink = new();
|
Hyperlink? hyperlink = new();
|
||||||
@ -85,9 +96,7 @@ public partial class PictureGalleryPage : Page
|
|||||||
|
|
||||||
textBlock.Inlines.Add(hyperlink);
|
textBlock.Inlines.Add(hyperlink);
|
||||||
this.PicturesPanel.Items.Add(textBlock);
|
this.PicturesPanel.Items.Add(textBlock);
|
||||||
loop++;
|
|
||||||
}
|
}
|
||||||
while ((loop < howManyPictures || howManyPictures == 0) && loop < this._pictureGalleryService.ThumbnailsOrderedByNewestDescending.Count);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,17 +125,24 @@ public partial class PictureGalleryPage : Page
|
|||||||
this.Dispatcher.Invoke(CloseDialog);
|
this.Dispatcher.Invoke(CloseDialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RefreshGallery()
|
||||||
private void Hyperlink_OnClick(object sender, RoutedEventArgs e)
|
|
||||||
{
|
{
|
||||||
Uri? picturePathUri = ((Hyperlink)sender).Tag as Uri;
|
CalculateTotalPages();
|
||||||
if (picturePathUri is null)
|
|
||||||
|
// If current page is now beyond total pages, go to last page
|
||||||
|
if (_currentPage > _totalPages)
|
||||||
{
|
{
|
||||||
return;
|
_currentPage = Math.Max(1, _totalPages);
|
||||||
}
|
}
|
||||||
|
|
||||||
string picturePath = Uri.UnescapeDataString(picturePathUri.AbsolutePath);
|
LoadCurrentPage();
|
||||||
Application.Current.Dispatcher.BeginInvoke(
|
UpdatePagerControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task ShowPhotoDialogAsync(string picturePath)
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
this.CloseOpenDialog();
|
this.CloseOpenDialog();
|
||||||
@ -145,12 +161,22 @@ public partial class PictureGalleryPage : Page
|
|||||||
contentDialog.CloseButtonAppearance = ControlAppearance.Light;
|
contentDialog.CloseButtonAppearance = ControlAppearance.Light;
|
||||||
contentDialog.Background = new SolidColorBrush(Colors.White);
|
contentDialog.Background = new SolidColorBrush(Colors.White);
|
||||||
contentDialog.Foreground = new SolidColorBrush(Colors.White);
|
contentDialog.Foreground = new SolidColorBrush(Colors.White);
|
||||||
// contentDialog.SetCurrentValue(ContentDialog.TitleProperty, "Hello World");
|
|
||||||
contentDialog.SetCurrentValue(ContentControl.ContentProperty, imageToShow);
|
contentDialog.SetCurrentValue(ContentControl.ContentProperty, imageToShow);
|
||||||
contentDialog.SetCurrentValue(ContentDialog.CloseButtonTextProperty, "Schließen");
|
contentDialog.SetCurrentValue(ContentDialog.CloseButtonTextProperty, "Schließen");
|
||||||
contentDialog.SetCurrentValue(ContentDialog.PrimaryButtonTextProperty, "Drucken");
|
contentDialog.SetCurrentValue(ContentDialog.PrimaryButtonTextProperty, "Drucken");
|
||||||
|
|
||||||
// contentDialog.SetCurrentValue(ContentDialog.PrimaryButtonIconProperty, PictureGalleryService.CreateRegularSymbolIcon(SymbolRegular.Print48, Colors.Tomato));
|
// Apply gold color to Primary button (Drucken)
|
||||||
|
contentDialog.Loaded += (s, args) =>
|
||||||
|
{
|
||||||
|
// Find the Primary button and apply gold styling
|
||||||
|
if (contentDialog.Template?.FindName("PrimaryButton", contentDialog) is System.Windows.Controls.Button primaryButton)
|
||||||
|
{
|
||||||
|
primaryButton.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#D4AF37"));
|
||||||
|
primaryButton.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1F1A00"));
|
||||||
|
primaryButton.BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F6E7A1"));
|
||||||
|
primaryButton.BorderThickness = new Thickness(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
contentDialog.Tag = picturePath;
|
contentDialog.Tag = picturePath;
|
||||||
contentDialog.ButtonClicked += this.ContentDialog_OnButtonClicked;
|
contentDialog.ButtonClicked += this.ContentDialog_OnButtonClicked;
|
||||||
@ -169,6 +195,87 @@ public partial class PictureGalleryPage : Page
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Hyperlink_OnClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Uri? picturePathUri = ((Hyperlink)sender).Tag as Uri;
|
||||||
|
if (picturePathUri is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string picturePath = Uri.UnescapeDataString(picturePathUri.AbsolutePath);
|
||||||
|
_ = this.ShowPhotoDialogAsync(picturePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CalculateTotalPages()
|
||||||
|
{
|
||||||
|
int totalItems = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending.Count;
|
||||||
|
_totalPages = totalItems > 0 ? (int)Math.Ceiling((double)totalItems / _itemsPerPage) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadCurrentPage()
|
||||||
|
{
|
||||||
|
int startIndex = (_currentPage - 1) * _itemsPerPage;
|
||||||
|
LoadPictures(startIndex, _itemsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePagerControls()
|
||||||
|
{
|
||||||
|
this.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
PageInfoText.Text = $"Seite {_currentPage} von {_totalPages}";
|
||||||
|
|
||||||
|
FirstPageButton.IsEnabled = _currentPage > 1;
|
||||||
|
PreviousPageButton.IsEnabled = _currentPage > 1;
|
||||||
|
NextPageButton.IsEnabled = _currentPage < _totalPages;
|
||||||
|
LastPageButton.IsEnabled = _currentPage < _totalPages;
|
||||||
|
|
||||||
|
// Visual feedback for disabled buttons
|
||||||
|
FirstPageButton.Opacity = FirstPageButton.IsEnabled ? 1.0 : 0.5;
|
||||||
|
PreviousPageButton.Opacity = PreviousPageButton.IsEnabled ? 1.0 : 0.5;
|
||||||
|
NextPageButton.Opacity = NextPageButton.IsEnabled ? 1.0 : 0.5;
|
||||||
|
LastPageButton.Opacity = LastPageButton.IsEnabled ? 1.0 : 0.5;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FirstPageButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_currentPage = 1;
|
||||||
|
LoadCurrentPage();
|
||||||
|
UpdatePagerControls();
|
||||||
|
GalleryScrollViewer.ScrollToTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PreviousPageButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentPage > 1)
|
||||||
|
{
|
||||||
|
_currentPage--;
|
||||||
|
LoadCurrentPage();
|
||||||
|
UpdatePagerControls();
|
||||||
|
GalleryScrollViewer.ScrollToTop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NextPageButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentPage < _totalPages)
|
||||||
|
{
|
||||||
|
_currentPage++;
|
||||||
|
LoadCurrentPage();
|
||||||
|
UpdatePagerControls();
|
||||||
|
GalleryScrollViewer.ScrollToTop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LastPageButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_currentPage = _totalPages;
|
||||||
|
LoadCurrentPage();
|
||||||
|
UpdatePagerControls();
|
||||||
|
GalleryScrollViewer.ScrollToTop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -26,10 +26,10 @@
|
|||||||
<Frame Grid.Row="0"
|
<Frame Grid.Row="0"
|
||||||
x:Name="MainFrame"
|
x:Name="MainFrame"
|
||||||
NavigationUIVisibility="Hidden"
|
NavigationUIVisibility="Hidden"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Stretch"
|
||||||
Background="Black"
|
VerticalAlignment="Stretch"
|
||||||
VerticalAlignment="Center"
|
Background="Transparent"
|
||||||
Panel.ZIndex="0" />
|
Panel.ZIndex="1" />
|
||||||
|
|
||||||
<!-- Picture Gallery -->
|
<!-- Picture Gallery -->
|
||||||
<Frame Grid.Row="0"
|
<Frame Grid.Row="0"
|
||||||
@ -37,7 +37,7 @@
|
|||||||
NavigationUIVisibility="Hidden"
|
NavigationUIVisibility="Hidden"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Background="Blue"
|
Background="Transparent"
|
||||||
Panel.ZIndex="1" />
|
Panel.ZIndex="1" />
|
||||||
|
|
||||||
<!-- Inhalt der dritten Zeile -->
|
<!-- Inhalt der dritten Zeile -->
|
||||||
@ -53,11 +53,24 @@
|
|||||||
Margin="24 24 24 12"/>
|
Margin="24 24 24 12"/>
|
||||||
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<!-- Hide Debug Button (top-right) -->
|
||||||
|
<ui:Button Grid.Row="0"
|
||||||
|
x:Name="HideDebugButton"
|
||||||
|
Content="Hide Debug"
|
||||||
|
Click="SetVisibilityDebugConsole"
|
||||||
|
Width="160"
|
||||||
|
Height="60"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="20"
|
||||||
|
Panel.ZIndex="5"
|
||||||
|
Visibility="Hidden"
|
||||||
|
FontSize="16"
|
||||||
|
Appearance="Secondary" />
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom" Name="ButtonPanel" Background="Transparent" Panel.ZIndex="2"
|
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom" Name="ButtonPanel" Background="Transparent" Panel.ZIndex="2"
|
||||||
Visibility="Hidden"
|
Visibility="Hidden"
|
||||||
Margin="0 0 0 0">
|
Margin="0 0 0 0">
|
||||||
<ui:Button x:Name="HideDebugButton" Content="Hide Debug" Click="SetVisibilityDebugConsole" Width="200" Height="75"
|
|
||||||
VerticalAlignment="Bottom" Margin="0 0 5 0" />
|
|
||||||
<!-- <ui:Button Content="Take Photo" Click="StartTakePhotoProcess" Width="200" Height="75" VerticalAlignment="Bottom" -->
|
<!-- <ui:Button Content="Take Photo" Click="StartTakePhotoProcess" Width="200" Height="75" VerticalAlignment="Bottom" -->
|
||||||
<!-- Margin="0 0 5 0" /> -->
|
<!-- Margin="0 0 5 0" /> -->
|
||||||
<Button Width="160" Height="160"
|
<Button Width="160" Height="160"
|
||||||
@ -137,7 +150,12 @@
|
|||||||
<ui:Button Content="Jetzt in Galerie ansehen"
|
<ui:Button Content="Jetzt in Galerie ansehen"
|
||||||
Click="OpenGalleryFromPrompt"
|
Click="OpenGalleryFromPrompt"
|
||||||
Width="240"
|
Width="240"
|
||||||
Height="52" />
|
Height="52"
|
||||||
|
Background="#D4AF37"
|
||||||
|
Foreground="#1F1A00"
|
||||||
|
BorderBrush="#F6E7A1"
|
||||||
|
BorderThickness="2"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@ -241,5 +259,8 @@
|
|||||||
Background="LightGreen"
|
Background="LightGreen"
|
||||||
Panel.ZIndex="2" />
|
Panel.ZIndex="2" />
|
||||||
|
|
||||||
|
<!-- Dialog Host for ContentDialogs -->
|
||||||
|
<ContentPresenter x:Name="DialogPresenter" Grid.Row="0" Panel.ZIndex="100" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Animation;
|
using System.Windows.Media.Animation;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
|
|
||||||
@ -10,8 +12,11 @@ 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.PictureGallery;
|
using CamBooth.App.Features.PictureGallery;
|
||||||
|
|
||||||
|
using Wpf.Ui.Controls;
|
||||||
|
|
||||||
namespace CamBooth.App;
|
namespace CamBooth.App;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -27,6 +32,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private readonly CameraService _cameraService;
|
private readonly CameraService _cameraService;
|
||||||
|
|
||||||
|
private readonly LycheeUploadService _lycheeUploadService;
|
||||||
|
|
||||||
private bool _isDebugConsoleVisible = true;
|
private bool _isDebugConsoleVisible = true;
|
||||||
|
|
||||||
private bool _isPicturePanelVisible = false;
|
private bool _isPicturePanelVisible = false;
|
||||||
@ -56,12 +63,14 @@ public partial class MainWindow : Window
|
|||||||
Logger logger,
|
Logger logger,
|
||||||
AppSettingsService appSettings,
|
AppSettingsService appSettings,
|
||||||
PictureGalleryService pictureGalleryService,
|
PictureGalleryService pictureGalleryService,
|
||||||
CameraService cameraService)
|
CameraService cameraService,
|
||||||
|
LycheeUploadService lycheeUploadService)
|
||||||
{
|
{
|
||||||
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;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible);
|
this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible);
|
||||||
this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
|
this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
|
||||||
@ -81,6 +90,24 @@ 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
|
||||||
|
if (appSettings.LycheeAutoUploadEnabled)
|
||||||
|
{
|
||||||
|
logger.Info("Lychee Auto-Upload ist aktiviert. Authentifiziere...");
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var authSuccess = await _lycheeUploadService.AuthenticateAsync();
|
||||||
|
if (authSuccess)
|
||||||
|
{
|
||||||
|
logger.Info("Lychee-Authentifizierung erfolgreich!");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.Warning("Lychee-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info($"config file loaded: '{appSettings.ConfigFileName}'");
|
logger.Info($"config file loaded: '{appSettings.ConfigFileName}'");
|
||||||
logger.Info("MainWindow initialized");
|
logger.Info("MainWindow initialized");
|
||||||
}
|
}
|
||||||
@ -171,9 +198,18 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._liveViewPage = new LiveViewPage(this._logger, this._appSettings, this._cameraService);
|
try
|
||||||
this.MainFrame.Navigate(this._liveViewPage);
|
{
|
||||||
this._isCameraStarted = true;
|
this._liveViewPage = new LiveViewPage(this._logger, this._appSettings, this._cameraService);
|
||||||
|
this.MainFrame.Navigate(this._liveViewPage);
|
||||||
|
this._isCameraStarted = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"Failed to start live view: {ex.Message}\n{ex.StackTrace}");
|
||||||
|
System.Windows.MessageBox.Show($"Failed to initialize camera. Please ensure the camera is properly connected.\n\nError: {ex.Message}",
|
||||||
|
"Camera Connection Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -263,8 +299,28 @@ public partial class MainWindow : Window
|
|||||||
this.Close();
|
this.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShutdownWindows(object sender, RoutedEventArgs e)
|
private async void ShutdownWindows(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
// Show confirmation dialog
|
||||||
|
var confirmDialog = new ContentDialog(this.DialogPresenter);
|
||||||
|
confirmDialog.Title = "Sicherheitsabfrage";
|
||||||
|
confirmDialog.Content = "Möchtest du die Fotobox wirklich ausschalten?";
|
||||||
|
confirmDialog.PrimaryButtonText = "Ja, ausschalten";
|
||||||
|
confirmDialog.CloseButtonText = "Abbrechen";
|
||||||
|
confirmDialog.DefaultButton = ContentDialogButton.Close;
|
||||||
|
confirmDialog.PrimaryButtonAppearance = ControlAppearance.Danger;
|
||||||
|
confirmDialog.CloseButtonAppearance = ControlAppearance.Secondary;
|
||||||
|
confirmDialog.Background = new SolidColorBrush(Colors.White);
|
||||||
|
confirmDialog.Foreground = new SolidColorBrush(Colors.Black);
|
||||||
|
|
||||||
|
var result = await confirmDialog.ShowAsync();
|
||||||
|
|
||||||
|
// Only proceed with shutdown if user confirmed
|
||||||
|
if (result != ContentDialogResult.Primary)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._appSettings.IsShutdownEnabled)
|
if (this._appSettings.IsShutdownEnabled)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -332,10 +388,58 @@ public partial class MainWindow : Window
|
|||||||
this.GalleryPrompt.Visibility = Visibility.Collapsed;
|
this.GalleryPrompt.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenGalleryFromPrompt(object sender, RoutedEventArgs e)
|
private async void OpenGalleryFromPrompt(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
this.HideGalleryPrompt();
|
this.HideGalleryPrompt();
|
||||||
this.SetVisibilityPicturePanel(true);
|
this.SetVisibilityPicturePanel(true);
|
||||||
|
|
||||||
|
// Wait a bit for the page to load before showing the dialog
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
// Show the latest photo in a dialog
|
||||||
|
await this.ShowLatestPhotoDialogAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowLatestPhotoDialogAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get the latest photo path
|
||||||
|
await this._pictureGalleryService.LoadThumbnailsToCache(1);
|
||||||
|
var latestPhotos = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending;
|
||||||
|
|
||||||
|
if (latestPhotos.Count == 0)
|
||||||
|
{
|
||||||
|
this._logger.Error("No photos found in gallery");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file path from the BitmapImage source
|
||||||
|
var latestPhoto = latestPhotos[0];
|
||||||
|
if (latestPhoto.UriSource == null)
|
||||||
|
{
|
||||||
|
this._logger.Error("Latest photo UriSource is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string photoPath = Uri.UnescapeDataString(latestPhoto.UriSource.AbsolutePath);
|
||||||
|
this._logger.Info($"Opening photo dialog for: {photoPath}");
|
||||||
|
|
||||||
|
// Get the current gallery page
|
||||||
|
if (this.PicturePanel.Content is PictureGalleryPage galleryPage)
|
||||||
|
{
|
||||||
|
// Show the photo dialog
|
||||||
|
await galleryPage.ShowPhotoDialogAsync(photoPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._logger.Error("PicturePanel content is not a PictureGalleryPage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._logger.Error($"Error showing photo dialog: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PictureGalleryService_NewPhotoCountChanged(object? sender, int newPhotoCount)
|
private void PictureGalleryService_NewPhotoCountChanged(object? sender, int newPhotoCount)
|
||||||
|
|||||||
@ -5,4 +5,7 @@
|
|||||||
- Kiosk Modus einrichten
|
- Kiosk Modus einrichten
|
||||||
- Energiesparmodus abschalten
|
- Energiesparmodus abschalten
|
||||||
- Starbildschirm mit freundlicher Begrüßung, kurzer Erklärung, und viel Spaß wünschen mit der FotoCam
|
- Starbildschirm mit freundlicher Begrüßung, kurzer Erklärung, und viel Spaß wünschen mit der FotoCam
|
||||||
- Verschiedene Hinweise anzeigen beim Fotofrafieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.)
|
- Verschiedene Hinweise anzeigen beim Fotografieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.)
|
||||||
|
- Bild über QR Code runterladen
|
||||||
|
- Windows updates deaktivieren
|
||||||
|
- logging einbinden (Elastic order ähnliches)
|
||||||
@ -7,6 +7,7 @@
|
|||||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=C_003A_005CDev_005CRepos_005CPrivat_005CCamBooth_005Cmisc_005CCanonBinaries_005CMlib_002Edll/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=C_003A_005CDev_005CRepos_005CPrivat_005CCamBooth_005Cmisc_005CCanonBinaries_005CMlib_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=C_003A_005CDev_005CRepos_005CPrivat_005CCamBooth_005Cmisc_005CCanonBinaries_005CUcs32P_002Edll/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=C_003A_005CDev_005CRepos_005CPrivat_005CCamBooth_005Cmisc_005CCanonBinaries_005CUcs32P_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApplication_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F6665f2e3e843578225e3796b83c5342a58c3f72bfef19eeee7aa90d157d4949_003FApplication_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApplication_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F6665f2e3e843578225e3796b83c5342a58c3f72bfef19eeee7aa90d157d4949_003FApplication_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AContentDialog_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003FTobias_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F50577c506a4fb056866bbe92395cb594743ca23835afa29fd9c9ef6a9686988_003FContentDialog_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AContentPresenter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F606231aefeb19a0da9311c15b3798ade39bbe28dd28a8e719884133e2e2f67_003FContentPresenter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AContentPresenter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F606231aefeb19a0da9311c15b3798ade39bbe28dd28a8e719884133e2e2f67_003FContentPresenter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADispatcher_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9ac32f819d6853e0a6eda168c52b7f38eef9ae75936fb85d96a15c39d115245_003FDispatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADispatcher_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9ac32f819d6853e0a6eda168c52b7f38eef9ae75936fb85d96a15c39d115245_003FDispatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEventRoute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4bda76b5cc453e1edf5d5c754c4a8215edbd3d3e4f80706dcf4f52a4f68979_003FEventRoute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEventRoute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FUsers_003Ftobia_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4bda76b5cc453e1edf5d5c754c4a8215edbd3d3e4f80706dcf4f52a4f68979_003FEventRoute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
|||||||
BIN
src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.dll
Normal file
BIN
src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.dll
Normal file
Binary file not shown.
BIN
src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.pdb
Normal file
BIN
src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.pdb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user