// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Versioning; using System.Text; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; namespace Duplicati.Library.Snapshots.MacOS; /// /// Implements support functions around a MacOS Photos library /// [SupportedOSPlatform("macOS")] internal sealed class MacOSPhotosLibrary { /// /// Subfolder within the exported structure where photos assets are placed, /// to avoid collisions with other files in the library. /// This means that restores will place photos under this subfolder, and not inside Photos /// public const string EXPORT_SUBFOLDER = "dup_backup"; /// /// Creates a new Photos library helper for the specified path /// /// The path to the Photos library public MacOSPhotosLibrary(string path) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Path must be a non-empty string", nameof(path)); } /// /// Attempts to wrap the specified entry as a Photos library entry /// /// The entry to wrap /// The Photos handling strategy /// An optional forced path where the Photos library exists; if null the system default path is probed /// The wrapped entry if it is a Photos library, or the original entry otherwise public static ISourceProviderEntry TryWrap(ISourceProviderEntry entry, MacOSPhotosHandling macOSPhotosHandling, string? forcedPath) => entry.IsFolder && !(entry is MacOSPhotosLibraryEntry) && IsPhotosLibrary(entry.Path, forcedPath) ? new MacOSPhotosLibraryEntry(entry, macOSPhotosHandling) : entry; /// /// Determines whether the specified path is a MacOS Photos library /// /// The path to check /// An optional forced path where the Photos library exists; if null the system default path is probed /// True if the path is a Photos library, false otherwise internal static bool IsPhotosLibrary(string path, string? forcedPath) { if (!OperatingSystem.IsMacOS()) return false; if (string.IsNullOrWhiteSpace(path)) return false; var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!trimmed.EndsWith(".photoslibrary", Utility.Utility.ClientFilenameStringComparison)) return false; try { if (!Directory.Exists(trimmed)) return false; if (!string.IsNullOrEmpty(forcedPath)) return string.Equals(trimmed, forcedPath, Utility.Utility.ClientFilenameStringComparison); // Only return true if this is the current user's system photo library var userPhotoLibraryPath = GetSystemPhotoLibraryPath(); if (string.IsNullOrEmpty(userPhotoLibraryPath)) return false; // Compare the resolved paths to handle symlinks and relative paths var resolvedPath = Path.GetFullPath(trimmed); var resolvedUserPath = Path.GetFullPath(userPhotoLibraryPath); return string.Equals(resolvedPath, resolvedUserPath, Utility.Utility.ClientFilenameStringComparison); } catch { return false; } } /// /// Gets the path to the current user's system photo library /// /// The path to the system photo library, or null if not found private static string? GetSystemPhotoLibraryPath() { try { // The default system photo library is located in the Pictures folder // Use MyPictures special folder to handle localized folder names var picturesDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); if (string.IsNullOrEmpty(picturesDirectory) || !Directory.Exists(picturesDirectory)) return null; // Find .photoslibrary directories in the Pictures folder // The system library name can be localized (e.g., "Photos Library.photoslibrary", // "Fotomediathek.photoslibrary" in German, "Photothèque.photoslibrary" in French, etc.) var photoLibraries = Directory.GetDirectories(picturesDirectory, "*.photoslibrary", SearchOption.TopDirectoryOnly); // Return the first .photoslibrary found, as typically there's only one system library // If multiple exist, the first one is likely the system library return photoLibraries.Length > 0 ? photoLibraries[0] : null; } catch { return null; } } /// /// Gets the list of assets in the Photos library /// /// The cancellation token /// The list of assets public async Task> GetAssetsAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var nativeAssets = MacOSPhotosNative.ListAssets(); var results = new List(nativeAssets.Count); foreach (var native in nativeAssets) { cancellationToken.ThrowIfCancellationRequested(); var safeFileName = CreateSafeFileName(native.Identifier, native.FileName, native.UniformTypeIdentifier, native.MediaType); var relativePath = Path.Combine(EXPORT_SUBFOLDER, safeFileName); DateTime? creation = native.CreationSeconds.HasValue ? FromUnixSeconds(native.CreationSeconds.Value) : null; DateTime? modification = native.ModificationSeconds.HasValue ? FromUnixSeconds(native.ModificationSeconds.Value) : null; long? size = native.Size >= 0 ? native.Size : null; results.Add(new MacOSPhotoAsset( native.Identifier, safeFileName, relativePath, native.UniformTypeIdentifier, native.MediaType, size, native.PixelWidth, native.PixelHeight, creation, modification)); } return results; } /// /// Opens a read stream for the specified asset /// /// The asset to open /// The cancellation token /// >An open read stream for the asset public Task OpenAssetStreamAsync(MacOSPhotoAsset asset, CancellationToken cancellationToken) { if (asset is null) throw new ArgumentNullException(nameof(asset)); cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(MacOSPhotosNative.OpenReadStream(asset.Identifier, asset.Size)); } /// /// Converts Unix time in seconds to a DateTime object /// /// The Unix time in seconds /// A DateTime object representing the specified Unix time private static DateTime FromUnixSeconds(double seconds) { var rounded = (long)Math.Round(seconds, MidpointRounding.AwayFromZero); return DateTimeOffset.FromUnixTimeSeconds(rounded).UtcDateTime; } /// /// Creates a safe file name for the asset /// /// The unique identifier of the asset /// The original file name /// The Uniform Type Identifier of the asset /// The media type of the asset /// A safe file name for the asset private static string CreateSafeFileName(string identifier, string? filename, string? uti, MacOSPhotoMediaType mediaType) { var candidate = string.IsNullOrWhiteSpace(filename) ? $"asset{GetDefaultExtension(mediaType, uti)}" : filename!; var sanitized = SanitizeFileName(candidate); if (string.IsNullOrWhiteSpace(Path.GetExtension(sanitized))) sanitized = sanitized + GetDefaultExtension(mediaType, uti); return $"{SanitizeComponent(identifier)}_{sanitized}"; } /// /// Gets the default file extension for the specified media type and UTI /// /// The media type of the asset /// The Uniform Type Identifier of the asset /// The default file extension for the specified media type and UTI private static string GetDefaultExtension(MacOSPhotoMediaType mediaType, string? uti) { if (!string.IsNullOrEmpty(uti)) { var lower = uti.ToLowerInvariant(); if (lower.Contains("heic") || lower.Contains("heif")) return ".heic"; if (lower.Contains("png")) return ".png"; if (lower.Contains("gif")) return ".gif"; if (lower.Contains("jpeg") || lower.Contains("jpg")) return ".jpg"; if (lower.Contains("tiff")) return ".tif"; if (lower.Contains("mov")) return ".mov"; if (lower.Contains("mp4") || lower.Contains("m4v")) return ".mp4"; if (lower.Contains("m4a") || lower.Contains("aac")) return ".m4a"; } return mediaType switch { MacOSPhotoMediaType.Image => ".jpg", MacOSPhotoMediaType.Video => ".mov", MacOSPhotoMediaType.Audio => ".m4a", _ => ".bin" }; } /// /// Sanitizes a file name by replacing invalid characters with underscores /// /// The file name to sanitize /// >The sanitized file name private static string SanitizeFileName(string name) { var invalid = Path.GetInvalidFileNameChars(); var builder = new StringBuilder(name.Length); foreach (var ch in name) { if (invalid.Contains(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar) builder.Append('_'); else builder.Append(ch); } var result = builder.ToString().Trim(); return string.IsNullOrEmpty(result) ? "asset" : result; } /// /// Sanitizes a path component by replacing invalid characters with underscores /// /// The path component to sanitize /// The sanitized path component private static string SanitizeComponent(string value) { var invalid = Path.GetInvalidPathChars(); var builder = new StringBuilder(value.Length); foreach (var ch in value) { if (invalid.Contains(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar) builder.Append('_'); else builder.Append(ch); } var result = builder.ToString().Trim(); return string.IsNullOrEmpty(result) ? "asset" : result; } } /// /// Represents a photo asset within a MacOS Photos library /// /// The unique identifier of the asset /// The file name of the asset /// The relative path of the asset within the export structure /// The Uniform Type Identifier of the asset /// The media type of the asset /// The size of the asset in bytes /// The width of the asset in pixels /// >The height of the asset in pixels /// The creation time of the asset in UTC /// >The modification time of the asset in UTC [SupportedOSPlatform("macOS")] public sealed record MacOSPhotoAsset( string Identifier, string FileName, string RelativePath, string? UniformTypeIdentifier, MacOSPhotoMediaType MediaType, long? Size, int PixelWidth, int PixelHeight, DateTime? CreatedUtc, DateTime? ModifiedUtc ); /// /// Defines media types for MacOS Photos assets /// public enum MacOSPhotoMediaType { /// /// Unknown media type /// Unknown = 0, /// /// Image media type /// Image = 1, /// /// Video media type /// Video = 2, /// /// Audio media type /// Audio = 3 } /// /// Represents errors that occur during MacOS Photos library operations /// public sealed class MacOSPhotosException : Exception { /// /// Initializes a new instance of the class /// /// The error message public MacOSPhotosException(string message) : base(message) { } /// /// Initializes a new instance of the class /// /// The error message /// The inner exception public MacOSPhotosException(string message, Exception innerException) : base(message, innerException) { } }