338 lines
14 KiB
C#
338 lines
14 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;
|
||
}
|
||
|
||
// Hinweis der letzten Analyse (z.B. Bildordner passt nicht zur Ghost-Instanz)
|
||
public string? LastWarning { get; private set; }
|
||
|
||
// Findet Bildpfade unterhalb von /content/images/ in HTML, Lexical-JSON und API-Feldern.
|
||
// Größen-Varianten (size/w600/..., size/w600/format/webp/...) werden auf das Ursprungsbild zurückgeführt.
|
||
private static readonly Regex ContentImageRegex = new(
|
||
@"content\\?/images\\?/(?:size\\?/w\d+\\?/(?:format\\?/\w+\\?/)?)?(?<path>[^""'\s\\()<>?#,]+(?:\\?/[^""'\s\\()<>?#,]+)*)",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance)
|
||
{
|
||
var images = new List<GhostImage>();
|
||
LastWarning = null;
|
||
_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);
|
||
LastWarning = $"Bildordner nicht gefunden: {instance.ImageFolderPath}";
|
||
return images;
|
||
}
|
||
|
||
// 1. Immer ALLE Posts und Seiten abrufen – sonst gelten Bilder aus nicht geladenen Posts als verwaist
|
||
var posts = await _ghostApiService.GetPostsAsync(instance);
|
||
posts.AddRange(await _ghostApiService.GetPostsAsync(instance, resource: "pages"));
|
||
_logger.LogInformation("{Count} Posts/Seiten geladen, extrahiere Bild-Referenzen", posts.Count);
|
||
|
||
// 2. Bild-Referenzen sammeln. Schlüssel: relativer Pfad unter content/images (Ghost-Bilder)
|
||
// bzw. die vollständige URL (externe Bilder)
|
||
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
PostReference GetReference(string key, GhostPost post)
|
||
{
|
||
if (!imageReferences.TryGetValue(key, out var list))
|
||
imageReferences[key] = list = new List<PostReference>();
|
||
|
||
var reference = list.FirstOrDefault(r => r.PostId == post.Id);
|
||
if (reference == null)
|
||
{
|
||
reference = new PostReference { PostId = post.Id, Title = post.Title, Slug = post.Slug };
|
||
list.Add(reference);
|
||
}
|
||
return reference;
|
||
}
|
||
|
||
foreach (var post in posts)
|
||
{
|
||
// Feature Image
|
||
if (!string.IsNullOrEmpty(post.FeatureImage))
|
||
GetReference(GetReferenceKey(post.FeatureImage), post).IsFeatureImage = true;
|
||
|
||
// Ghost-Bilder im Inhalt: HTML (src, srcset, Hintergrundbilder), Lexical (Galerien, Karten), og/twitter
|
||
foreach (var path in ExtractContentImagePaths($"{post.Html}\n{post.Lexical}\n{post.OtherImageFields}"))
|
||
GetReference(path, post).IsInContent = true;
|
||
|
||
// Externe Bilder im HTML
|
||
foreach (var imgUrl in ExtractImageUrls(post.Html))
|
||
{
|
||
if (!ContentImageRegex.IsMatch(imgUrl))
|
||
GetReference(imgUrl, post).IsInContent = true;
|
||
}
|
||
}
|
||
|
||
// Tags, Benutzer und Website-Einstellungen (Logo, Icon, Cover ...)
|
||
foreach (var (label, json) in await _ghostApiService.GetOtherImageSourcesAsync(instance))
|
||
{
|
||
var source = new GhostPost { Id = label, Title = label };
|
||
foreach (var path in ExtractContentImagePaths(json))
|
||
GetReference(path, source);
|
||
}
|
||
|
||
// 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);
|
||
|
||
var localPaths = localFiles
|
||
.Select(f => GetRelativePath(instance.ImageFolderPath, f.FullName))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
|
||
var ghostReferenceCount = imageReferences.Keys.Count(k => !IsExternalUrl(k));
|
||
if (ghostReferenceCount > 0 && localFiles.Count > 0 && !imageReferences.Keys.Any(localPaths.Contains))
|
||
{
|
||
LastWarning = $"Keines der {ghostReferenceCount} in Ghost verwendeten Bilder liegt im Ordner {instance.ImageFolderPath}. " +
|
||
"Der Ordner ist vermutlich nicht (mehr) der content/images-Ordner dieser Ghost-Instanz – alle lokalen Dateien erscheinen deshalb als verwaist.";
|
||
_logger.LogWarning("{Warning}", LastWarning);
|
||
}
|
||
|
||
// 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);
|
||
imageReferences.TryGetValue(relativePath, out var refs);
|
||
|
||
var image = new GhostImage
|
||
{
|
||
FilePath = file.FullName,
|
||
FileName = file.Name,
|
||
RelativePath = relativePath,
|
||
Url = absoluteUrl,
|
||
FileSize = file.Length,
|
||
LastModified = file.LastWriteTime,
|
||
UsedInPosts = refs ?? new List<PostReference>(),
|
||
IsOrphaned = refs == null
|
||
};
|
||
|
||
// "bild_o.jpg" ist Ghosts Originalkopie von "bild.jpg": zählt als verwendet, solange das Bild verwendet wird
|
||
var basePath = GetOriginalBasePath(relativePath);
|
||
if (refs == null && basePath != null && localPaths.Contains(basePath))
|
||
{
|
||
image.IsOriginal = true;
|
||
image.OriginalOf = Path.GetFileName(basePath);
|
||
image.IsOrphaned = !imageReferences.ContainsKey(basePath);
|
||
}
|
||
|
||
// 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 key in imageReferences.Keys)
|
||
{
|
||
if (localPaths.Contains(key))
|
||
continue;
|
||
|
||
var url = IsExternalUrl(key) ? key : $"{instance.Url.TrimEnd('/')}/content/images/{key}";
|
||
var localPath = ConvertUrlToLocalPath(instance, url);
|
||
|
||
var externalImage = new GhostImage
|
||
{
|
||
Url = url,
|
||
FileName = Path.GetFileName(url),
|
||
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
|
||
FilePath = localPath,
|
||
UsedInPosts = imageReferences[key],
|
||
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;
|
||
image.Category = managedImage.Category;
|
||
|
||
// 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 static List<string> ExtractContentImagePaths(string text)
|
||
{
|
||
return ContentImageRegex.Matches(text)
|
||
.Select(m => Uri.UnescapeDataString(m.Groups["path"].Value.Replace("\\/", "/")))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
}
|
||
|
||
// Ghost-Bild -> relativer Pfad, alles andere -> URL unverändert
|
||
private static string GetReferenceKey(string url)
|
||
{
|
||
return ExtractContentImagePaths(url).FirstOrDefault() ?? url;
|
||
}
|
||
|
||
private static bool IsExternalUrl(string key)
|
||
{
|
||
return key.Contains("://") || key.StartsWith("//") || key.StartsWith("data:", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
// "2026/09/bild_o.jpg" -> "2026/09/bild.jpg"; null, wenn der Name nicht auf _o endet
|
||
private static string? GetOriginalBasePath(string relativePath)
|
||
{
|
||
var extension = Path.GetExtension(relativePath);
|
||
var withoutExtension = relativePath[..^extension.Length];
|
||
return withoutExtension.EndsWith("_o", StringComparison.OrdinalIgnoreCase)
|
||
? withoutExtension[..^2] + extension
|
||
: null;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|