// 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)
{
}
}