< Summary

Line coverage
64%
Covered lines: 63
Uncovered lines: 34
Coverable lines: 97
Total lines: 368
Line coverage: 64.9%
Branch coverage
48%
Covered branches: 24
Total branches: 50
Branch coverage: 48%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: .cctor()100%210%
File 2: .ctor(...)100%11100%
File 2: .cctor()100%210%
File 2: get_Order()100%210%
File 2: CanInvoke(...)100%210%
File 2: InvokeAsync()100%11100%
File 2: ExtractExif(...)100%11100%
File 2: BuildExifData(...)83.33%66100%
File 2: GetCoordinates(...)25%9430.76%
File 2: GetDimension(...)75%121285.71%
File 2: GetOrientationDegrees(...)60%121072.72%
File 2: GetDateCaptured(...)25%14855.55%
File 2: GetDurationInSeconds(...)10%361036.36%

File(s)

/home/runner/work/anichron/anichron/src/.artifacts/obj/Anichron.Worker/debug/Microsoft.Extensions.Logging.Generators/Microsoft.Extensions.Logging.Generators.LoggerMessageGenerator/LoggerMessage.g.cs

File '/home/runner/work/anichron/anichron/src/.artifacts/obj/Anichron.Worker/debug/Microsoft.Extensions.Logging.Generators/Microsoft.Extensions.Logging.Generators.LoggerMessageGenerator/LoggerMessage.g.cs' does not exist (any more).

/home/runner/work/anichron/anichron/src/Anichron.Worker/Ingestion/Middlewares/ExifExtractionMiddleware.cs

#LineLine coverage
 1using Anichron.Worker.Ingestion.Pipeline;
 2using MetadataExtractor;
 3using MetadataExtractor.Formats.Exif;
 4using MetadataExtractor.Formats.QuickTime;
 5using NodaTime;
 6using NodaTime.Text;
 7using System.IO.Abstractions;
 8
 9namespace Anichron.Worker.Ingestion.Middlewares;
 10
 611internal sealed partial class ExifExtractionMiddleware(
 612    IFileSystem fileSystem,
 613    ILogger<ExifExtractionMiddleware> logger) : IIngestionMiddleware
 14{
 15    // EXIF dates use colons as date separators ("2024:07:15 13:22:01"), unlike ISO 8601.
 016    private static readonly LocalDateTimePattern exifDatePattern =
 017        LocalDateTimePattern.CreateWithInvariantCulture("yyyy:MM:dd HH:mm:ss");
 18
 019    public int Order => IngestionOrder.ExifExtraction;
 020    public bool CanInvoke(IngestionContext context) => true;
 21
 22    public async Task InvokeAsync(IngestionContext context, IngestionDelegate next, CancellationToken ct)
 623    {
 624        context.Exif = ExtractExif(context.Item.AbsolutePath);
 625        await next(context, ct);
 626    }
 27
 28    private ExifData ExtractExif(string absolutePath)
 629    {
 30        try
 631        {
 632            using var stream = fileSystem.File.OpenRead(absolutePath);
 633            var metadataDirectories = ImageMetadataReader.ReadMetadata(stream);
 534            return BuildExifData(metadataDirectories);
 35        }
 136        catch (Exception ex)
 137        {
 138            Log.ExtractionFailed(logger, absolutePath, ex);
 139            return ExifData.Empty;
 40        }
 641    }
 42
 43    private ExifData BuildExifData(IReadOnlyList<MetadataExtractor.Directory> metadataDirectories)
 544    {
 545        var cameraInfo = metadataDirectories.OfType<ExifIfd0Directory>().FirstOrDefault();     // IFD0: camera make/mode
 546        var captureDetails = metadataDirectories.OfType<ExifSubIfdDirectory>().FirstOrDefault(); // Sub-IFD: date captur
 547        var gps = metadataDirectories.OfType<GpsDirectory>().FirstOrDefault();                // GPS coordinates
 548        var trackHeader = metadataDirectories.OfType<QuickTimeTrackHeaderDirectory>().FirstOrDefault(); // video dimensi
 549        var movieHeader = metadataDirectories.OfType<QuickTimeMovieHeaderDirectory>().FirstOrDefault(); // video duratio
 50
 551        var (latitude, longitude) = GetCoordinates(gps);
 52
 553        return new ExifData(
 554            Width: GetDimension(cameraInfo, ExifDirectoryBase.TagImageWidth, trackHeader, QuickTimeTrackHeaderDirectory.
 555            Height: GetDimension(cameraInfo, ExifDirectoryBase.TagImageHeight, trackHeader, QuickTimeTrackHeaderDirector
 556            OrientationDegrees: GetOrientationDegrees(cameraInfo),
 557            DateCaptured: GetDateCaptured(captureDetails),
 558            Latitude: latitude,
 559            Longitude: longitude,
 560            CameraMake: cameraInfo?.GetString(ExifDirectoryBase.TagMake),
 561            CameraModel: cameraInfo?.GetString(ExifDirectoryBase.TagModel),
 562            LensModel: captureDetails?.GetString(ExifDirectoryBase.TagLensModel),
 563            DurationInSeconds: GetDurationInSeconds(movieHeader));
 564    }
 65
 66    private (double? Latitude, double? Longitude) GetCoordinates(GpsDirectory? gps)
 567    {
 568        if (gps is null)
 569            return (null, null);
 70        try
 071        {
 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.
 075            var location = gps.GetGeoLocation();
 076            return location is { } coordinates
 077                ? (coordinates.Latitude, coordinates.Longitude)
 078                : (null, null);
 79        }
 080        catch (Exception ex)
 081        {
 082            Log.GpsCoordinatesParseFailed(logger, ex);
 083            return (null, null);
 84        }
 585    }
 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)
 1092    {
 93        // Each check below: segment present AND tag readable AND value is a valid positive dimension.
 1094        if (exifDirectory is not null && exifDirectory.TryGetInt32(exifTag, out var exifPixels) && exifPixels > 0)
 895            return exifPixels;
 296        if (trackHeader is not null && trackHeader.TryGetInt32(quickTimeTag, out var qtPixels) && qtPixels > 0)
 097            return qtPixels;
 298        return 0;
 1099    }
 100
 101    private static int GetOrientationDegrees(ExifIfd0Directory? cameraInfo)
 5102    {
 103        // Segment absent OR tag unreadable — assume upright (0°).
 5104        if (cameraInfo is null || !cameraInfo.TryGetInt32(ExifDirectoryBase.TagOrientation, out var orientation))
 1105            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.
 4108        return orientation switch
 4109        {
 4110            6 => 90,
 0111            3 => 180,
 0112            8 => 270,
 0113            _ => 0,
 4114        };
 5115    }
 116
 117    private LocalDateTime? GetDateCaptured(ExifSubIfdDirectory? captureDetails)
 5118    {
 5119        var raw = captureDetails?.GetString(ExifDirectoryBase.TagDateTimeOriginal);
 5120        if (raw is null)
 5121            return null;
 0122        var result = exifDatePattern.Parse(raw);
 0123        if (!result.Success)
 0124            Log.DateParseFailed(logger, raw);
 0125        return result.Success ? result.Value : null;
 5126    }
 127
 128    private static int? GetDurationInSeconds(QuickTimeMovieHeaderDirectory? movieHeader)
 5129    {
 5130        if (movieHeader is null)
 5131            return null;
 0132        if (!movieHeader.TryGetInt64(QuickTimeMovieHeaderDirectory.TagDuration, out var duration) ||    // tag absent or
 0133            !movieHeader.TryGetInt32(QuickTimeMovieHeaderDirectory.TagTimeScale, out var timeScale) ||  // tag absent or
 0134            timeScale <= 0 ||                                                                           // guard: preven
 0135            duration < 0)                                                                               // guard: malfor
 0136        {
 0137            return null;
 138        }
 139
 0140        return (int)Math.Round((double)duration / timeScale);
 5141    }
 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}