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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ photos:
all_albums: false # Optional, default false. If true preserve album structure. If same photo is in multiple albums creates duplicates on filesystem
use_hardlinks: false # Optional, default false. If true and all_albums is true, create hard links for duplicate photos instead of separate copies. Saves storage space.
folder_format: "%Y/%m" # optional, if set put photos in subfolders according to format. Format cheatsheet - https://strftime.org
filename_format: metadata # optional, default "metadata". "metadata" = name__filesize__base64id.ext (legacy). "simple" = plain name.ext (boredazfcuk/Apple-style; lets you migrate without re-downloading)
# file_format: optional single template applied to ALL versions (overrides filename_format). Tokens:
# ${photo.filename} ${photo.ext} ${photo.id} ${photo.file_size} ${photo.year} ${photo.month} ${photo.day}
# ${photo.variant} (empty for original/full, else version) and ${photo.variant_suffix} (separator+variant, only when present).
# Example (keeps plain Apple/boredazfcuk names for originals, so existing files are not re-downloaded):
# file_format: "${photo.filename}${photo.variant_suffix}.${photo.ext}"
# variant_separator: "_" # separator before the variant in ${photo.variant_suffix} (default "_")
filters:
# List of libraries to download. If omitted (default), photos from all libraries (own and shared) are downloaded. If included, photos only
# from the listed libraries are downloaded.
Expand Down
6 changes: 6 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ photos:
all_albums: false # Optional, default false. If true preserve album structure. If same photo is in multiple albums creates duplicates on filesystem
use_hardlinks: false # Optional, default false. If true and all_albums is true, create hard links for duplicate photos instead of separate copies. Saves storage space.
# folder_format: "%Y/%m" # optional, if set put photos in subfolders according to format. Format cheatsheet - https://strftime.org
# filename_format: metadata # optional, default "metadata". "metadata" = name__filesize__base64id.ext (legacy). "simple" = plain name.ext (boredazfcuk/Apple-style; lets you migrate without re-downloading)
# file_format: # optional, single template applied to ALL versions (overrides filename_format).
# Tokens: ${photo.filename} ${photo.ext} ${photo.id} ${photo.file_size} ${photo.year} ${photo.month} ${photo.day}
# ${photo.variant} (empty for original/full, else version) and ${photo.variant_suffix} (separator+variant, only when present).
# Example (keeps plain Apple/boredazfcuk names for originals -> no re-download): "${photo.filename}${photo.variant_suffix}.${photo.ext}"
# variant_separator: "_" # optional, separator before the variant in ${photo.variant_suffix} (default "_")
filters:
# List of libraries to download. If omitted (default), photos from all libraries (own and shared) are downloaded. If included, photos only
# from the listed libraries are downloaded.
Expand Down
64 changes: 64 additions & 0 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,70 @@ def validate_file_sizes(file_sizes: list[str]) -> list[str]:
return validated_sizes if validated_sizes else ["original"]


def get_photos_filename_format(config: dict) -> str:
"""Filename naming convention for downloaded photos.

- ``"metadata"`` (default, backward-compatible): ``name__filesize__base64id.ext``
— mandarons' historical format. Encodes the CloudKit asset ID into the
filename so the same source photo can be unambiguously round-tripped to
its iCloud record from the local file alone.
- ``"simple"``: ``name.ext`` — the boredazfcuk/docker-icloudpd convention
(and Apple's own download-from-icloud.com convention). Drops the
metadata suffix entirely. Trade-off: rename collisions are possible
(two distinct iCloud photos sharing the same human filename land on
the same path on disk; ``collect_download_task`` detects this and
falls back to the metadata-suffix name for the colliding photo so
both files coexist).
Useful for users migrating from boredazfcuk who want to point
mandarons at their existing photo tree without triggering a full
re-download — ``check_photo_exists`` compares by file size, not by
filename suffix, so matching simple-format files are correctly
treated as already-present.

Returns:
Either ``"metadata"`` (default) or ``"simple"``.
"""
config_path = ["photos", "filename_format"]
value = get_config_value_or_none(config=config, config_path=config_path)
if value is None:
return "metadata"
value = str(value).lower().strip()
if value not in ("metadata", "simple"):
log_config_not_found_warning(
config_path, f"unknown value {value!r}; falling back to 'metadata'",
)
return "metadata"
return value
Comment thread
epheterson marked this conversation as resolved.


def get_photos_file_format(config: dict) -> str | None:
"""Single filename template applied to all versions (``photos.file_format``).

Mirrors ``folder_format`` but for the filename. Uses ``${photo.*}`` tokens
(see ``photo_path_utils.render_filename_template``). When set it overrides
``filename_format``. Returns ``None`` (use ``filename_format``) when unset.
"""
config_path = ["photos", "file_format"]
value = get_config_value_or_none(config=config, config_path=config_path)
if value is None:
return None
if not isinstance(value, str) or not value.strip():
log_config_not_found_warning(config_path, "must be a non-empty template string; ignoring")
return None
return value


def get_photos_variant_separator(config: dict) -> str:
"""Separator inserted before the variant in ``${photo.variant_suffix}``.

Defaults to ``"_"``. Only appears when a version is non-primary (not
original/full), so primaries stay un-suffixed.
"""
config_path = ["photos", "variant_separator"]
value = get_config_value_or_none(config=config, config_path=config_path)
return str(value) if value is not None else "_"


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

Expand Down
129 changes: 106 additions & 23 deletions src/photo_download_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from src.photo_path_utils import (
create_folder_path_if_needed,
generate_photo_filename_with_metadata,
get_default_filename_format,
get_file_format,
normalize_file_path,
rename_legacy_file_if_exists,
)
Expand All @@ -29,9 +31,14 @@
class DownloadTaskInfo:
"""Information about a photo download task."""

def __init__(self, photo, file_size: str, photo_path: str,
hardlink_source: str | None = None,
hardlink_registry: HardlinkRegistry | None = None):
def __init__(
self,
photo,
file_size: str,
photo_path: str,
hardlink_source: str | None = None,
hardlink_registry: HardlinkRegistry | None = None,
):
"""Initialize download task info.

Args:
Expand Down Expand Up @@ -60,8 +67,9 @@ def get_max_threads_for_download(config) -> int:
return config_parser.get_app_max_threads(config)


def generate_photo_path(photo, file_size: str, destination_path: str,
folder_format: str | None) -> str:
def generate_photo_path(
photo, file_size: str, destination_path: str, folder_format: str | None,
) -> str:
"""Generate full file path for photo with legacy file renaming.

This function combines path generation, folder creation, and legacy
Expand All @@ -80,7 +88,9 @@ def generate_photo_path(photo, file_size: str, destination_path: str,
filename_with_metadata = generate_photo_filename_with_metadata(photo, file_size)

# Create folder path if needed
final_destination = create_folder_path_if_needed(destination_path, folder_format, photo)
final_destination = create_folder_path_if_needed(
destination_path, folder_format, photo,
)

# Generate paths for legacy file format handling
filename = photo.filename
Expand All @@ -90,7 +100,11 @@ def generate_photo_path(photo, file_size: str, destination_path: str,
file_path = os.path.join(destination_path, filename)
file_size_path = os.path.join(
destination_path,
f"{'__'.join([name, file_size])}" if extension == "" else f"{'__'.join([name, file_size])}.{extension}",
(
f"{'__'.join([name, file_size])}"
if extension == ""
else f"{'__'.join([name, file_size])}.{extension}"
),
)

# Final path with normalization
Expand All @@ -108,9 +122,14 @@ def generate_photo_path(photo, file_size: str, destination_path: str,
return normalized_path


def collect_download_task(photo, file_size: str, destination_path: str,
files: set[str] | None, folder_format: str | None,
hardlink_registry: HardlinkRegistry | None) -> DownloadTaskInfo | None:
def collect_download_task(
photo,
file_size: str,
destination_path: str,
files: set[str] | None,
folder_format: str | None,
hardlink_registry: HardlinkRegistry | None,
) -> DownloadTaskInfo | None:
"""Collect photo info for parallel download without immediately downloading.

Args:
Expand All @@ -126,23 +145,74 @@ def collect_download_task(photo, file_size: str, destination_path: str,
"""
# Check if file size exists on server
if file_size not in photo.versions:
photo_path = generate_photo_path(photo, file_size, destination_path, folder_format)
LOGGER.warning(f"File size {file_size} not found on server. Skipping the photo {photo_path} ...")
photo_path = generate_photo_path(
photo, file_size, destination_path, folder_format,
)
LOGGER.warning(
f"File size {file_size} not found on server. Skipping the photo {photo_path} ...",
)
return None

# Generate photo path
photo_path = generate_photo_path(photo, file_size, destination_path, folder_format)

# Thread-safe file set update
if files is not None:
with files_lock:
files.add(photo_path)

# Check if photo already exists with correct size
from src.photo_file_utils import check_photo_exists

if check_photo_exists(photo, file_size, photo_path):
if files is not None:
with files_lock:
files.add(photo_path)
return None

# Filename-collision fallback (``simple`` mode only): a plain
# ``IMG_1234.HEIC`` path may already be claimed by a DIFFERENT iCloud
# photo that happens to share the human filename. Two collision sources:
#
# 1. On-disk collision: ``check_photo_exists`` returned False but the
# path is occupied -- size mismatch with a photo from a prior sync.
# 2. In-flight collision: an earlier call in this same
# ``_collect_album_download_tasks`` pass already claimed this plain
# path. Without this check the later parallel download silently
# overwrites the earlier one and we lose data. ``collect_download_task``
# runs sequentially during collection so a plain ``in files`` membership
# test under ``files_lock`` is sufficient; we hold the lock only long
# enough to read.
#
# In either case we route this photo to the metadata-suffix filename so
# both files coexist and both round-trip stably on future syncs. We use
# the ``get_default_filename_format()`` accessor (rather than importing
# the module-level constant) so ``set_default_filename_format`` updates
# are observed live on every call.
# Non-unique naming (simple mode, or a photos.file_format template that may
# omit a unique component) can collide; the metadata format cannot.
is_non_unique = get_default_filename_format() == "simple" or get_file_format() is not None
in_flight_collision = False
if is_non_unique and files is not None:
with files_lock:
in_flight_collision = photo_path in files
if is_non_unique and (os.path.isfile(photo_path) or in_flight_collision):
suffix_folder = create_folder_path_if_needed(
destination_path, folder_format, photo,
)
suffix_basename = generate_photo_filename_with_metadata(
photo, file_size, "metadata",
)
photo_path = normalize_file_path(os.path.join(suffix_folder, suffix_basename))
LOGGER.info(
f"Filename collision for {photo.filename} (id={photo.id}); "
f"using suffix path {photo_path} to preserve both photos.",
)
if check_photo_exists(photo, file_size, photo_path):
if files is not None:
with files_lock:
files.add(photo_path)
return None

if files is not None:
with files_lock:
files.add(photo_path)

# Check for existing hardlink source
hardlink_source = None
if hardlink_registry is not None:
Expand Down Expand Up @@ -176,14 +246,20 @@ def execute_download_task(task_info: DownloadTaskInfo) -> bool:
return True
else:
# Fallback to download if hard link creation fails
LOGGER.warning(f"Hard link creation failed, downloading {task_info.photo_path} instead")
LOGGER.warning(
f"Hard link creation failed, downloading {task_info.photo_path} instead",
)

# Download the photo
result = download_photo_from_server(task_info.photo, task_info.file_size, task_info.photo_path)
result = download_photo_from_server(
task_info.photo, task_info.file_size, task_info.photo_path,
)
if result and task_info.hardlink_registry is not None:
# Register for future hard links if enabled
task_info.hardlink_registry.register_photo_path(
task_info.photo.id, task_info.file_size, task_info.photo_path,
task_info.photo.id,
task_info.file_size,
task_info.photo_path,
)
LOGGER.debug(f"[Thread] Completed download of {task_info.photo_path}")

Expand All @@ -194,7 +270,9 @@ def execute_download_task(task_info: DownloadTaskInfo) -> bool:
return False


def execute_parallel_downloads(download_tasks: list[DownloadTaskInfo], config) -> tuple[int, int]:
def execute_parallel_downloads(
download_tasks: list[DownloadTaskInfo], config,
) -> tuple[int, int]:
"""Execute download tasks in parallel using thread pool.

Args:
Expand Down Expand Up @@ -228,7 +306,10 @@ def execute_parallel_downloads(download_tasks: list[DownloadTaskInfo], config) -

with ThreadPoolExecutor(max_workers=max_threads) as executor:
# Submit all download tasks
future_to_task = {executor.submit(execute_download_task, task): task for task in download_tasks}
future_to_task = {
executor.submit(execute_download_task, task): task
for task in download_tasks
}

# Process completed downloads
for future in as_completed(future_to_task):
Expand All @@ -242,5 +323,7 @@ def execute_parallel_downloads(download_tasks: list[DownloadTaskInfo], config) -
LOGGER.error(f"Unexpected error during photo download: {e!s}")
failed_downloads += 1

LOGGER.info(f"Photo processing complete: {successful_downloads} successful, {failed_downloads} failed")
LOGGER.info(
f"Photo processing complete: {successful_downloads} successful, {failed_downloads} failed",
)
return successful_downloads, failed_downloads
Loading
Loading