Wp2ghost/GhostImageManager/Services/ImageAnalyzerService.cs
2026-09-28 19:09:09 +02:00

280 lines
10 KiB
C#

using System.Text.RegularExpressions;
using GhostImageManager.Data;
using GhostImageManager.Models;
using HtmlAgilityPack;
namespace GhostImageManager.Services;
public class ImageAnalyzerService
{
private readonly GhostApiService _ghostApiService;
private readonly ExifService _exifService;
private readonly ImageDatabaseService _dbService;
private readonly ILogger<ImageAnalyzerService> _logger;
public ImageAnalyzerService(
GhostApiService ghostApiService,
ExifService exifService,
ImageDatabaseService dbService,
ILogger<ImageAnalyzerService> logger)
{
_ghostApiService = ghostApiService;
_exifService = exifService;
_dbService = dbService;
_logger = logger;
}
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
{
var images = new List<GhostImage>();
_logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath);
if (!Directory.Exists(instance.ImageFolderPath))
{
_logger.LogError("Image folder not found: {Path}", instance.ImageFolderPath);
return images;
}
// 1. Alle Posts abrufen
_logger.LogInformation("Rufe Posts mit Filter '{Filter}' ab", searchTerm);
var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm);
_logger.LogInformation("{Count} Posts geladen, extrahiere Bild-Referenzen", posts.Count);
// 2. Bild-Referenzen aus Posts sammeln
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
foreach (var post in posts)
{
// Feature Image
if (!string.IsNullOrEmpty(post.FeatureImage))
{
if (!imageReferences.ContainsKey(post.FeatureImage))
imageReferences[post.FeatureImage] = new List<PostReference>();
imageReferences[post.FeatureImage].Add(new PostReference
{
PostId = post.Id,
Title = post.Title,
Slug = post.Slug,
IsFeatureImage = true,
IsInContent = false
});
}
// Bilder im HTML
if (!string.IsNullOrEmpty(post.Html))
{
var htmlImages = ExtractImageUrls(post.Html);
foreach (var imgUrl in htmlImages)
{
if (!imageReferences.ContainsKey(imgUrl))
imageReferences[imgUrl] = new List<PostReference>();
// Prüfen ob bereits als Feature Image vorhanden
var existing = imageReferences[imgUrl].FirstOrDefault(r => r.PostId == post.Id);
if (existing != null)
{
existing.IsInContent = true;
}
else
{
imageReferences[imgUrl].Add(new PostReference
{
PostId = post.Id,
Title = post.Title,
Slug = post.Slug,
IsFeatureImage = false,
IsInContent = true
});
}
}
}
}
// 3. Lokale Dateien scannen
_logger.LogInformation("Scanne lokalen Image-Ordner: {Folder}", instance.ImageFolderPath);
var localFiles = ScanLocalImageFiles(instance.ImageFolderPath);
_logger.LogInformation("{Count} lokale Bilddateien gefunden", localFiles.Count);
// 4. GhostImage-Objekte erstellen und mit DB synchronisieren
foreach (var file in localFiles)
{
var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName);
var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName);
var image = new GhostImage
{
FilePath = file.FullName,
FileName = file.Name,
RelativePath = relativePath,
Url = absoluteUrl,
FileSize = file.Length,
LastModified = file.LastWriteTime,
UsedInPosts = imageReferences.TryGetValue(absoluteUrl, out var refs) ? refs : new List<PostReference>(),
IsOrphaned = !imageReferences.ContainsKey(absoluteUrl)
};
// Dimensionen ermitteln
GetImageDimensions(image);
// EXIF-Daten extrahieren
ExifData exifData = null!;
if (!file.Extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
exifData = _exifService.ExtractExifData(file.FullName);
ApplyExifData(image, exifData);
}
// Mit Datenbank synchronisieren
var managedImage = await _dbService.SyncImageAsync(image, instance.Id, exifData);
ApplyDatabaseData(image, managedImage);
images.Add(image);
}
// Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder)
foreach (var url in imageReferences.Keys)
{
var localPath = ConvertUrlToLocalPath(instance, url);
if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)))
{
var externalImage = new GhostImage
{
Url = url,
FileName = Path.GetFileName(url),
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
FilePath = localPath,
UsedInPosts = imageReferences[url],
IsOrphaned = false
};
var managedImage = await _dbService.SyncImageAsync(externalImage, instance.Id, null);
ApplyDatabaseData(externalImage, managedImage);
images.Add(externalImage);
}
}
var orphanedCount = images.Count(i => i.IsOrphaned);
_logger.LogInformation("Bildanalyse abgeschlossen: {Total} Bilder, {Orphaned} verwaist, {Used} verwendet", images.Count, orphanedCount, images.Count - orphanedCount);
return images;
}
private void ApplyExifData(GhostImage image, ExifData exifData)
{
image.CameraMake = exifData.CameraMake;
image.CameraModel = exifData.CameraModel;
image.Lens = exifData.Lens;
image.Aperture = exifData.Aperture;
image.ShutterSpeed = exifData.ShutterSpeed;
image.Iso = exifData.Iso;
image.FocalLength = exifData.FocalLength;
image.CaptureDate = exifData.CaptureDate;
}
private void ApplyDatabaseData(GhostImage image, ManagedImage managedImage)
{
image.ManagedImageId = managedImage.Id;
image.AuthorId = managedImage.AuthorId;
image.AuthorName = managedImage.AuthorName;
image.Theme = managedImage.Theme;
// CaptureDate aus DB hat Priorität, falls manuell überschrieben
if (managedImage.CaptureDate.HasValue)
image.CaptureDate = managedImage.CaptureDate.Value;
}
private List<FileInfo> ScanLocalImageFiles(string folderPath)
{
var files = new List<FileInfo>();
var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" };
if (!Directory.Exists(folderPath))
return files;
foreach (var ext in extensions)
{
files.AddRange(new DirectoryInfo(folderPath)
.EnumerateFiles($"*{ext}", SearchOption.AllDirectories));
}
var excludedDirs = Path.Combine(folderPath, "size");
return files.Where(f => f.DirectoryName.StartsWith(excludedDirs) == false).OrderBy(f => f.FullName).ToList();
}
private List<string> ExtractImageUrls(string html)
{
var urls = new List<string>();
try
{
var doc = new HtmlDocument();
doc.LoadHtml(html);
var imgTags = doc.DocumentNode.SelectNodes("//img[@src]");
if (imgTags != null)
{
foreach (var img in imgTags)
{
var src = img.GetAttributeValue("src", "").Trim();
if (!string.IsNullOrEmpty(src))
urls.Add(src);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Parsen von HTML nach Bild-URLs");
}
return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath)
{
var relativePath = GetRelativePath(instance.ImageFolderPath, localPath);
return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}";
}
private string ConvertUrlToLocalPath(GhostInstance instance, string url)
{
var contentImagesUrl = $"{instance.Url.TrimEnd('/')}/content/images/";
if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase))
{
var relativePath = url[contentImagesUrl.Length..];
return Path.Combine(instance.ImageFolderPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
}
return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url));
}
private string GetRelativePath(string basePath, string targetPath)
{
if (!basePath.EndsWith(Path.DirectorySeparatorChar) && !basePath.EndsWith(Path.AltDirectorySeparatorChar))
basePath += Path.DirectorySeparatorChar;
var baseUri = new Uri(basePath);
var targetUri = new Uri(targetPath);
var relativeUri = baseUri.MakeRelativeUri(targetUri);
return Uri.UnescapeDataString(relativeUri.ToString());
}
private void GetImageDimensions(GhostImage image)
{
if (image.FilePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
return;
try
{
using var fs = File.OpenRead(image.FilePath);
var (width, height) = ImageDimensionReader.Read(fs);
image.Width = width;
image.Height = height;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath);
}
}
}