diff --git a/src/config_parser.py b/src/config_parser.py index 4099b0766..d2c6bdd1f 100644 --- a/src/config_parser.py +++ b/src/config_parser.py @@ -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 + ``//...`` 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. diff --git a/src/sync_photos.py b/src/sync_photos.py index abedc0108..b7a4d9c08 100644 --- a/src/sync_photos.py +++ b/src/sync_photos.py @@ -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) @@ -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 @@ -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, @@ -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. @@ -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, @@ -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. @@ -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, @@ -528,7 +574,7 @@ def _sync_albums_by_configuration( photos, library, filters, - destination_path, + lib_dest, files, folder_format, hardlink_registry, @@ -539,7 +585,7 @@ def _sync_albums_by_configuration( photos, library, filters, - destination_path, + lib_dest, files, folder_format, hardlink_registry, @@ -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, diff --git a/tests/test_library_destinations.py b/tests/test_library_destinations.py new file mode 100644 index 000000000..a73c0920f --- /dev/null +++ b/tests/test_library_destinations.py @@ -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"), + ]