| | | 1 | | using Anichron.Worker.Ingestion.Pipeline; |
| | | 2 | | using MetadataExtractor; |
| | | 3 | | using MetadataExtractor.Formats.Exif; |
| | | 4 | | using MetadataExtractor.Formats.QuickTime; |
| | | 5 | | using NodaTime; |
| | | 6 | | using NodaTime.Text; |
| | | 7 | | using System.IO.Abstractions; |
| | | 8 | | |
| | | 9 | | namespace Anichron.Worker.Ingestion.Middlewares; |
| | | 10 | | |
| | 6 | 11 | | internal sealed partial class ExifExtractionMiddleware( |
| | 6 | 12 | | IFileSystem fileSystem, |
| | 6 | 13 | | ILogger<ExifExtractionMiddleware> logger) : IIngestionMiddleware |
| | | 14 | | { |
| | | 15 | | // EXIF dates use colons as date separators ("2024:07:15 13:22:01"), unlike ISO 8601. |
| | 0 | 16 | | private static readonly LocalDateTimePattern exifDatePattern = |
| | 0 | 17 | | LocalDateTimePattern.CreateWithInvariantCulture("yyyy:MM:dd HH:mm:ss"); |
| | | 18 | | |
| | 0 | 19 | | public int Order => IngestionOrder.ExifExtraction; |
| | 0 | 20 | | public bool CanInvoke(IngestionContext context) => true; |
| | | 21 | | |
| | | 22 | | public async Task InvokeAsync(IngestionContext context, IngestionDelegate next, CancellationToken ct) |
| | 6 | 23 | | { |
| | 6 | 24 | | context.Exif = ExtractExif(context.Item.AbsolutePath); |
| | 6 | 25 | | await next(context, ct); |
| | 6 | 26 | | } |
| | | 27 | | |
| | | 28 | | private ExifData ExtractExif(string absolutePath) |
| | 6 | 29 | | { |
| | | 30 | | try |
| | 6 | 31 | | { |
| | 6 | 32 | | using var stream = fileSystem.File.OpenRead(absolutePath); |
| | 6 | 33 | | var metadataDirectories = ImageMetadataReader.ReadMetadata(stream); |
| | 5 | 34 | | return BuildExifData(metadataDirectories); |
| | | 35 | | } |
| | 1 | 36 | | catch (Exception ex) |
| | 1 | 37 | | { |
| | 1 | 38 | | Log.ExtractionFailed(logger, absolutePath, ex); |
| | 1 | 39 | | return ExifData.Empty; |
| | | 40 | | } |
| | 6 | 41 | | } |
| | | 42 | | |
| | | 43 | | private ExifData BuildExifData(IReadOnlyList<MetadataExtractor.Directory> metadataDirectories) |
| | 5 | 44 | | { |
| | 5 | 45 | | var cameraInfo = metadataDirectories.OfType<ExifIfd0Directory>().FirstOrDefault(); // IFD0: camera make/mode |
| | 5 | 46 | | var captureDetails = metadataDirectories.OfType<ExifSubIfdDirectory>().FirstOrDefault(); // Sub-IFD: date captur |
| | 5 | 47 | | var gps = metadataDirectories.OfType<GpsDirectory>().FirstOrDefault(); // GPS coordinates |
| | 5 | 48 | | var trackHeader = metadataDirectories.OfType<QuickTimeTrackHeaderDirectory>().FirstOrDefault(); // video dimensi |
| | 5 | 49 | | var movieHeader = metadataDirectories.OfType<QuickTimeMovieHeaderDirectory>().FirstOrDefault(); // video duratio |
| | | 50 | | |
| | 5 | 51 | | var (latitude, longitude) = GetCoordinates(gps); |
| | | 52 | | |
| | 5 | 53 | | return new ExifData( |
| | 5 | 54 | | Width: GetDimension(cameraInfo, ExifDirectoryBase.TagImageWidth, trackHeader, QuickTimeTrackHeaderDirectory. |
| | 5 | 55 | | Height: GetDimension(cameraInfo, ExifDirectoryBase.TagImageHeight, trackHeader, QuickTimeTrackHeaderDirector |
| | 5 | 56 | | OrientationDegrees: GetOrientationDegrees(cameraInfo), |
| | 5 | 57 | | DateCaptured: GetDateCaptured(captureDetails), |
| | 5 | 58 | | Latitude: latitude, |
| | 5 | 59 | | Longitude: longitude, |
| | 5 | 60 | | CameraMake: cameraInfo?.GetString(ExifDirectoryBase.TagMake), |
| | 5 | 61 | | CameraModel: cameraInfo?.GetString(ExifDirectoryBase.TagModel), |
| | 5 | 62 | | LensModel: captureDetails?.GetString(ExifDirectoryBase.TagLensModel), |
| | 5 | 63 | | DurationInSeconds: GetDurationInSeconds(movieHeader)); |
| | 5 | 64 | | } |
| | | 65 | | |
| | | 66 | | private (double? Latitude, double? Longitude) GetCoordinates(GpsDirectory? gps) |
| | 5 | 67 | | { |
| | 5 | 68 | | if (gps is null) |
| | 5 | 69 | | return (null, null); |
| | | 70 | | try |
| | 0 | 71 | | { |
| | | 72 | | // MetadataExtractor 2.9 turned GeoLocation from a class into a readonly struct, so |
| | | 73 | | // GetGeoLocation() now returns Nullable<GeoLocation>. `is null` no longer narrows the |
| | | 74 | | // value for member access — hence the positional pattern, which unwraps it. |
| | 0 | 75 | | var location = gps.GetGeoLocation(); |
| | 0 | 76 | | return location is { } coordinates |
| | 0 | 77 | | ? (coordinates.Latitude, coordinates.Longitude) |
| | 0 | 78 | | : (null, null); |
| | | 79 | | } |
| | 0 | 80 | | catch (Exception ex) |
| | 0 | 81 | | { |
| | 0 | 82 | | Log.GpsCoordinatesParseFailed(logger, ex); |
| | 0 | 83 | | return (null, null); |
| | | 84 | | } |
| | 5 | 85 | | } |
| | | 86 | | |
| | | 87 | | // Photos embed dimensions in EXIF IFD0; videos only have QuickTime track headers. |
| | | 88 | | // Trying EXIF first covers both formats with a single helper. |
| | | 89 | | private static int GetDimension( |
| | | 90 | | MetadataExtractor.Directory? exifDirectory, int exifTag, |
| | | 91 | | QuickTimeTrackHeaderDirectory? trackHeader, int quickTimeTag) |
| | 10 | 92 | | { |
| | | 93 | | // Each check below: segment present AND tag readable AND value is a valid positive dimension. |
| | 10 | 94 | | if (exifDirectory is not null && exifDirectory.TryGetInt32(exifTag, out var exifPixels) && exifPixels > 0) |
| | 8 | 95 | | return exifPixels; |
| | 2 | 96 | | if (trackHeader is not null && trackHeader.TryGetInt32(quickTimeTag, out var qtPixels) && qtPixels > 0) |
| | 0 | 97 | | return qtPixels; |
| | 2 | 98 | | return 0; |
| | 10 | 99 | | } |
| | | 100 | | |
| | | 101 | | private static int GetOrientationDegrees(ExifIfd0Directory? cameraInfo) |
| | 5 | 102 | | { |
| | | 103 | | // Segment absent OR tag unreadable — assume upright (0°). |
| | 5 | 104 | | if (cameraInfo is null || !cameraInfo.TryGetInt32(ExifDirectoryBase.TagOrientation, out var orientation)) |
| | 1 | 105 | | return 0; |
| | | 106 | | // Raw EXIF orientation tag values per the EXIF 2.3 spec. |
| | | 107 | | // Only rotations (no mirroring) are represented; mirrored variants map to 0. |
| | 4 | 108 | | return orientation switch |
| | 4 | 109 | | { |
| | 4 | 110 | | 6 => 90, |
| | 0 | 111 | | 3 => 180, |
| | 0 | 112 | | 8 => 270, |
| | 0 | 113 | | _ => 0, |
| | 4 | 114 | | }; |
| | 5 | 115 | | } |
| | | 116 | | |
| | | 117 | | private LocalDateTime? GetDateCaptured(ExifSubIfdDirectory? captureDetails) |
| | 5 | 118 | | { |
| | 5 | 119 | | var raw = captureDetails?.GetString(ExifDirectoryBase.TagDateTimeOriginal); |
| | 5 | 120 | | if (raw is null) |
| | 5 | 121 | | return null; |
| | 0 | 122 | | var result = exifDatePattern.Parse(raw); |
| | 0 | 123 | | if (!result.Success) |
| | 0 | 124 | | Log.DateParseFailed(logger, raw); |
| | 0 | 125 | | return result.Success ? result.Value : null; |
| | 5 | 126 | | } |
| | | 127 | | |
| | | 128 | | private static int? GetDurationInSeconds(QuickTimeMovieHeaderDirectory? movieHeader) |
| | 5 | 129 | | { |
| | 5 | 130 | | if (movieHeader is null) |
| | 5 | 131 | | return null; |
| | 0 | 132 | | if (!movieHeader.TryGetInt64(QuickTimeMovieHeaderDirectory.TagDuration, out var duration) || // tag absent or |
| | 0 | 133 | | !movieHeader.TryGetInt32(QuickTimeMovieHeaderDirectory.TagTimeScale, out var timeScale) || // tag absent or |
| | 0 | 134 | | timeScale <= 0 || // guard: preven |
| | 0 | 135 | | duration < 0) // guard: malfor |
| | 0 | 136 | | { |
| | 0 | 137 | | return null; |
| | | 138 | | } |
| | | 139 | | |
| | 0 | 140 | | return (int)Math.Round((double)duration / timeScale); |
| | 5 | 141 | | } |
| | | 142 | | |
| | | 143 | | private static partial class Log |
| | | 144 | | { |
| | | 145 | | [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to extract EXIF metadata from {Path}.")] |
| | | 146 | | public static partial void ExtractionFailed(ILogger logger, string path, Exception exception); |
| | | 147 | | |
| | | 148 | | [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to parse GPS coordinates from EXIF data.")] |
| | | 149 | | public static partial void GpsCoordinatesParseFailed(ILogger logger, Exception exception); |
| | | 150 | | |
| | | 151 | | [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to parse EXIF date '{Raw}'.")] |
| | | 152 | | public static partial void DateParseFailed(ILogger logger, string raw); |
| | | 153 | | } |
| | | 154 | | } |