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 _logger; public ImageAnalyzerService( GhostApiService ghostApiService, ExifService exifService, ImageDatabaseService dbService, ILogger logger) { _ghostApiService = ghostApiService; _exifService = exifService; _dbService = dbService; _logger = logger; } public async Task> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "") { var images = new List(); _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>(StringComparer.OrdinalIgnoreCase); foreach (var post in posts) { // Feature Image if (!string.IsNullOrEmpty(post.FeatureImage)) { if (!imageReferences.ContainsKey(post.FeatureImage)) imageReferences[post.FeatureImage] = new List(); 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(); // 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(), 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 ScanLocalImageFiles(string folderPath) { var files = new List(); 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 ExtractImageUrls(string html) { var urls = new List(); 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); } } }