Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,36 @@ def validate_file_sizes(file_sizes: list[str]) -> list[str]:
return validated_sizes if validated_sizes else ["original"]


def get_photos_library_destinations(config: dict) -> dict[str, str]:
"""Get per-library destination subdirectory mapping from photos config.

Optional config block (under top-level ``photos``):

.. code-block:: yaml

photos:
destination: photos
library_destinations:
PrimarySync: personal
SharedLibrary: shared

When set, photos from each library are written to
``<photos.destination>/<library_destinations[library]>/...`` instead of
sharing one destination tree. When unset (the default), all libraries
share the single ``photos.destination`` path — preserving the historical
behaviour of mandarons/icloud-docker.

Returns:
Dict mapping library name → subdirectory relative to ``photos.destination``.
Returns ``{}`` if not configured (backward-compatible default).
"""
config_path = ["photos", "library_destinations"]
mapping = get_config_value_or_none(config=config, config_path=config_path)
if not mapping or not isinstance(mapping, dict):
return {}
return {str(k): str(v) for k, v in mapping.items()}


def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
"""Get libraries filter from photos config.

Expand Down
60 changes: 53 additions & 7 deletions src/sync_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ def sync_photos(config, photos):
"""
# Parse configuration using centralized config parser
destination_path = config_parser.prepare_photos_destination(config=config)
library_destinations = config_parser.get_photos_library_destinations(config=config)
filters = config_parser.get_photos_filters(config=config)
files = set()
download_all = config_parser.get_photos_all_albums(config=config)
Expand All @@ -408,6 +409,7 @@ def sync_photos(config, photos):
folder_format,
hardlink_registry,
config,
library_destinations=library_destinations,
)
total_successful += sub_successful
total_failed += sub_failed
Expand All @@ -423,17 +425,57 @@ def sync_photos(config, photos):
folder_format,
hardlink_registry,
config,
library_destinations=library_destinations,
)
total_successful += sub_successful
total_failed += sub_failed

# Clean up obsolete files if enabled
# Clean up obsolete files if enabled. When per-library destinations are
# configured we walk each library's subdir independently, otherwise the
# legacy single-destination walk preserves backward compatibility.
if config_parser.get_photos_remove_obsolete(config=config):
remove_obsolete_files(destination_path, files)
if library_destinations:
for library in libraries:
lib_dest = _library_destination(destination_path, library, library_destinations)
remove_obsolete_files(lib_dest, files)
else:
remove_obsolete_files(destination_path, files)

return total_successful, total_failed


def _library_destination(base_destination: str, library: str, library_destinations: dict) -> str:
"""Resolve the on-disk destination for a given iCloud photo library.

When ``library_destinations`` provides a mapping for ``library``, joins
the configured subdirectory under ``base_destination`` and ensures the
directory exists. Otherwise returns ``base_destination`` unchanged
(preserving mandarons' legacy single-destination behaviour).

Library-name matching has three rules, in priority order:

1. **Exact match.** ``library_destinations[library]`` if present.
2. **Role alias for `SharedLibrary`.** Apple's modern iCloud Shared
Photo Library is exposed by icloudpy under a GUID-based zone name
like ``SharedSync-3C977B4A-C15A-46E4-9854-585B9342C409``. A config
key of ``SharedLibrary`` matches any zone whose name starts with
``SharedSync-`` so users don't need to discover and hardcode the
per-account GUID. (Configs that already use the literal current
Apple zone name still work via rule 1.)
3. **Fallthrough.** Returns ``base_destination`` unchanged.
"""
if not library_destinations:
return base_destination
subdir = library_destinations.get(library)
if not subdir and library.startswith("SharedSync-"):
subdir = library_destinations.get("SharedLibrary")
if not subdir:
return base_destination
dest = os.path.join(base_destination, subdir)
os.makedirs(dest, exist_ok=True)
return dest


def _sync_all_photos_first_for_hardlinks(
photos,
libraries,
Expand All @@ -443,6 +485,7 @@ def _sync_all_photos_first_for_hardlinks(
folder_format,
hardlink_registry,
config,
library_destinations: dict | None = None,
) -> tuple[int, int]:
"""Sync 'All Photos' album first to populate hardlink registry.

Expand All @@ -462,9 +505,10 @@ def _sync_all_photos_first_for_hardlinks(
for library in libraries:
if library == "PrimarySync" and "All Photos" in photos.libraries[library].albums:
LOGGER.info("Syncing 'All Photos' album first for hard link reference...")
lib_dest = _library_destination(destination_path, library, library_destinations or {})
result = sync_album_photos(
album=photos.libraries[library].albums["All Photos"],
destination_path=os.path.join(destination_path, "All Photos"),
destination_path=os.path.join(lib_dest, "All Photos"),
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
Expand Down Expand Up @@ -493,6 +537,7 @@ def _sync_albums_by_configuration(
folder_format,
hardlink_registry,
config,
library_destinations: dict | None = None,
) -> tuple[int, int]:
"""Sync albums based on configuration settings.

Expand All @@ -512,12 +557,13 @@ def _sync_albums_by_configuration(
"""
total_successful, total_failed = 0, 0
for library in libraries:
lib_dest = _library_destination(destination_path, library, library_destinations or {})
if download_all and library == "PrimarySync":
sub_successful, sub_failed = _sync_all_albums_except_filtered(
photos,
library,
filters,
destination_path,
lib_dest,
files,
folder_format,
hardlink_registry,
Expand All @@ -528,7 +574,7 @@ def _sync_albums_by_configuration(
photos,
library,
filters,
destination_path,
lib_dest,
files,
folder_format,
hardlink_registry,
Expand All @@ -539,7 +585,7 @@ def _sync_albums_by_configuration(
photos,
library,
filters,
destination_path,
lib_dest,
files,
folder_format,
hardlink_registry,
Expand All @@ -549,7 +595,7 @@ def _sync_albums_by_configuration(
sub_successful, sub_failed = _sync_all_photos_in_library(
photos,
library,
destination_path,
lib_dest,
filters,
files,
folder_format,
Expand Down
167 changes: 167 additions & 0 deletions tests/test_library_destinations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Tests for per-library destination subdirectories.

Added 2026-05-27 as part of feat/per-library-destinations-and-live-photos.
Covers ``config_parser.get_photos_library_destinations`` and the
``_library_destination`` helper threaded through sync_photos.
"""

import os
import tempfile
import unittest

from src import config_parser
from src.sync_photos import _library_destination


class TestGetPhotosLibraryDestinations(unittest.TestCase):
"""get_photos_library_destinations returns dict or {} based on config."""

def test_returns_empty_dict_when_unset(self):
config = {"photos": {"destination": "photos"}}
assert config_parser.get_photos_library_destinations(config) == {}

def test_returns_empty_dict_when_photos_section_missing(self):
config = {}
assert config_parser.get_photos_library_destinations(config) == {}

def test_returns_mapping_when_configured(self):
config = {
"photos": {
"destination": "photos",
"library_destinations": {
"PrimarySync": "personal",
"SharedLibrary": "shared",
},
},
}
result = config_parser.get_photos_library_destinations(config)
assert result == {"PrimarySync": "personal", "SharedLibrary": "shared"}

def test_returns_empty_dict_when_value_is_not_a_dict(self):
config = {"photos": {"library_destinations": ["foo", "bar"]}}
assert config_parser.get_photos_library_destinations(config) == {}

def test_coerces_non_string_keys_and_values_to_str(self):
config = {
"photos": {"library_destinations": {123: 456, "PrimarySync": "personal"}},
}
result = config_parser.get_photos_library_destinations(config)
assert result == {"123": "456", "PrimarySync": "personal"}


class TestLibraryDestinationHelper(unittest.TestCase):
"""_library_destination resolves the right on-disk path per library."""

def test_returns_base_when_no_mapping(self):
with tempfile.TemporaryDirectory() as base:
result = _library_destination(base, "PrimarySync", {})
assert result == base

def test_returns_base_when_library_not_in_mapping(self):
with tempfile.TemporaryDirectory() as base:
mapping = {"PrimarySync": "personal"}
result = _library_destination(base, "SharedLibrary", mapping)
# Library not in mapping → fall through to base destination
assert result == base

def test_joins_subdir_and_creates_directory(self):
with tempfile.TemporaryDirectory() as base:
mapping = {"PrimarySync": "personal"}
result = _library_destination(base, "PrimarySync", mapping)
assert result == os.path.join(base, "personal")
assert os.path.isdir(result)

def test_creates_nested_subdirectories(self):
with tempfile.TemporaryDirectory() as base:
mapping = {"PrimarySync": "by-source/personal"}
result = _library_destination(base, "PrimarySync", mapping)
assert result == os.path.join(base, "by-source", "personal")
assert os.path.isdir(result)

def test_none_mapping_is_safe(self):
with tempfile.TemporaryDirectory() as base:
result = _library_destination(base, "PrimarySync", None)
assert result == base

def test_shared_library_alias_matches_guid_named_zone(self):
"""`SharedLibrary` in the config matches Apple's GUID-named shared zones
(e.g. ``SharedSync-3C977B4A-...``) — users don't have to discover and
hardcode their per-account GUID."""
with tempfile.TemporaryDirectory() as base:
mapping = {"PrimarySync": "Eric", "SharedLibrary": "Shared"}
result = _library_destination(
base, "SharedSync-3C977B4A-C15A-46E4-9854-585B9342C409", mapping,
)
assert result == os.path.join(base, "Shared")
assert os.path.isdir(result)

def test_shared_library_alias_only_fires_for_sharedsync_prefix(self):
"""The alias rule does NOT silently catch unrelated library names —
a non-SharedSync library that isn't explicitly mapped still falls
through to base."""
with tempfile.TemporaryDirectory() as base:
mapping = {"PrimarySync": "Eric", "SharedLibrary": "Shared"}
result = _library_destination(base, "OtherLibrary", mapping)
assert result == base

def test_exact_match_wins_over_shared_library_alias(self):
"""If the exact GUID is mapped explicitly, that wins over the
``SharedLibrary`` alias — predictability for users who pinned the
GUID before this feature shipped."""
with tempfile.TemporaryDirectory() as base:
guid_name = "SharedSync-DEADBEEF-1234-5678-9ABC-DEF012345678"
mapping = {guid_name: "Exact", "SharedLibrary": "Aliased"}
result = _library_destination(base, guid_name, mapping)
assert result == os.path.join(base, "Exact")


class TestSyncPhotosRemoveObsoletePerLibrary(unittest.TestCase):
"""When ``library_destinations`` is configured AND
``photos.remove_obsolete`` is true, ``sync_photos`` must call the
obsolete-cleanup once per library subdir (not once on the parent
destination — that would walk siblings' photo trees and delete them)."""

def test_per_library_remove_obsolete_invokes_once_per_subdir(self):
from unittest.mock import MagicMock, patch

from src import sync_photos as sp

with tempfile.TemporaryDirectory() as base:
config = {
"photos": {
"destination": base,
"remove_obsolete": True,
"library_destinations": {
"PrimarySync": "personal",
"SharedLibrary": "shared",
},
"filters": {
"libraries": ["PrimarySync", "SharedLibrary"],
"file_sizes": ["original"],
},
},
}
fake_photos = MagicMock()
fake_photos.libraries = {
"PrimarySync": MagicMock(),
"SharedLibrary": MagicMock(),
}

with patch.object(
sp.config_parser,
"prepare_photos_destination",
return_value=base,
), patch.object(
sp,
"_sync_albums_by_configuration",
return_value=(0, 0),
), patch.object(
sp, "remove_obsolete_files",
) as fake_remove:
sp.sync_photos(config=config, photos=fake_photos)

called_paths = [c.args[0] for c in fake_remove.call_args_list]
assert called_paths == [
os.path.join(base, "personal"),
os.path.join(base, "shared"),
]
Loading