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
148 changes: 122 additions & 26 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,16 @@ def get_region(config: dict) -> str:
Region string ('global' or 'china')
"""
config_path = ["app", "region"]
region = get_config_value_or_default(config=config, config_path=config_path, default="global")
region = get_config_value_or_default(
config=config, config_path=config_path, default="global",
)

if region == "global" and not traverse_config_path(config=config, config_path=config_path):
log_config_not_found_warning(config_path, "not found. Using default value - global ...")
if region == "global" and not traverse_config_path(
config=config, config_path=config_path,
):
log_config_not_found_warning(
config_path, "not found. Using default value - global ...",
)
elif region not in ["global", "china"]:
log_config_error(
config_path,
Expand All @@ -167,7 +173,9 @@ def get_region(config: dict) -> str:
# =============================================================================


def get_sync_interval(config: dict, config_path: list[str], service_name: str, log_messages: bool = True) -> int:
def get_sync_interval(
config: dict, config_path: list[str], service_name: str, log_messages: bool = True,
) -> int:
"""Get sync interval for a service (drive or photos).

Extracted common logic for retrieving sync intervals.
Expand All @@ -194,7 +202,9 @@ def get_sync_interval(config: dict, config_path: list[str], service_name: str, l
f"is not found. Using default sync_interval: {sync_interval} seconds ...",
)
else:
log_config_found_info(f"Syncing {service_name} every {sync_interval} seconds.")
log_config_found_info(
f"Syncing {service_name} every {sync_interval} seconds.",
)

return sync_interval

Expand All @@ -210,7 +220,12 @@ def get_drive_sync_interval(config: dict, log_messages: bool = True) -> int:
Drive sync interval in seconds
"""
config_path = ["drive", "sync_interval"]
return get_sync_interval(config=config, config_path=config_path, service_name="drive", log_messages=log_messages)
return get_sync_interval(
config=config,
config_path=config_path,
service_name="drive",
log_messages=log_messages,
)


def get_drive_request_timeout(config: dict) -> int:
Expand Down Expand Up @@ -241,7 +256,12 @@ def get_photos_sync_interval(config: dict, log_messages: bool = True) -> int:
Photos sync interval in seconds
"""
config_path = ["photos", "sync_interval"]
return get_sync_interval(config=config, config_path=config_path, service_name="photos", log_messages=log_messages)
return get_sync_interval(
config=config,
config_path=config_path,
service_name="photos",
log_messages=log_messages,
)


# =============================================================================
Expand Down Expand Up @@ -271,9 +291,13 @@ def parse_max_threads_value(max_threads_config: Any, default_max_threads: int) -
# Handle "auto" value
if isinstance(max_threads_config, str) and max_threads_config.lower() == "auto":
max_threads = default_max_threads
log_config_found_info(f"Using automatic thread count: {max_threads} threads (based on CPU cores).")
log_config_found_info(
f"Using automatic thread count: {max_threads} threads (based on CPU cores).",
)
elif isinstance(max_threads_config, int) and max_threads_config >= 1:
max_threads = min(max_threads_config, 16) # Cap at 16 to avoid overwhelming servers
max_threads = min(
max_threads_config, 16,
) # Cap at 16 to avoid overwhelming servers
log_config_found_info(f"Using configured max_threads: {max_threads}.")
else:
log_invalid_config_value(
Expand Down Expand Up @@ -433,19 +457,60 @@ def get_drive_remove_obsolete(config: dict) -> bool:
True if obsolete files should be removed, False otherwise
"""
config_path = ["drive", "remove_obsolete"]
drive_remove_obsolete = get_config_value_or_default(config=config, config_path=config_path, default=False)
drive_remove_obsolete = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if not drive_remove_obsolete:
_log_config_warning_once(
config_path,
"remove_obsolete is not found. Not removing the obsolete files and folders.",
)
else:
log_config_debug(f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...")
log_config_debug(
f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...",
)

return drive_remove_obsolete


def get_drive_flatten_packages(config: dict | None) -> bool:
"""Return whether iCloud Drive package downloads should be kept on
disk as single binary files (zip / gzip bytes) instead of being
unpacked into bundle directories.

Default ``False`` — preserves the historical mandarons behaviour
of unpacking ``.band``-style bundles so the local representation
mirrors macOS's directory-bundle semantics.

Setting this to ``True`` is appropriate for backup-style deployments
(NAS, cold storage) where the operator prefers:

- **Single-file storage** — one mtime / size per package for
simpler dedup, restoration, and round-trip back to iCloud.
- **No internal name collisions** — two iWork files in the same
folder normally share bare-pathed internal entries like
``Data/Document.iwa``; unpacking both into the same parent dir
raises ``FileExistsError``. Skipping unpack avoids this entirely.
- **Lower inode footprint** — large bundles often expand to
hundreds of small files on disk.

Args:
config: Configuration dictionary (None ok).

Returns:
bool — True if packages should be kept as single files.
"""
if not config:
return False
config_path = ["drive", "flatten_packages"]
return bool(
get_config_value_or_default(
config=config, config_path=config_path, default=False,
),
)


# =============================================================================
# Photos Configuration Functions
# =============================================================================
Expand Down Expand Up @@ -501,7 +566,9 @@ def get_photos_all_albums(config: dict) -> bool:
True if all albums should be synced, False otherwise
"""
config_path = ["photos", "all_albums"]
download_all = get_config_value_or_default(config=config, config_path=config_path, default=False)
download_all = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if download_all:
log_config_found_info("Syncing all albums.")
Expand All @@ -520,7 +587,9 @@ def get_photos_use_hardlinks(config: dict, log_messages: bool = True) -> bool:
True if hard links should be used, False otherwise
"""
config_path = ["photos", "use_hardlinks"]
use_hardlinks = get_config_value_or_default(config=config, config_path=config_path, default=False)
use_hardlinks = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if use_hardlinks and log_messages:
log_config_found_info("Using hard links for duplicate photos.")
Expand All @@ -538,15 +607,19 @@ def get_photos_remove_obsolete(config: dict) -> bool:
True if obsolete files should be removed, False otherwise
"""
config_path = ["photos", "remove_obsolete"]
photos_remove_obsolete = get_config_value_or_default(config=config, config_path=config_path, default=False)
photos_remove_obsolete = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if not photos_remove_obsolete:
_log_config_warning_once(
config_path,
"remove_obsolete is not found. Not removing the obsolete files and folders.",
)
else:
log_config_debug(f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...")
log_config_debug(
f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...",
)

return photos_remove_obsolete

Expand Down Expand Up @@ -599,7 +672,9 @@ def validate_file_sizes(file_sizes: list[str]) -> list[str]:
return validated_sizes if validated_sizes else ["original"]


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

Args:
Expand All @@ -613,13 +688,17 @@ def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> li
libraries = get_config_value_or_none(config=config, config_path=config_path)

if not libraries or len(libraries) == 0:
log_config_not_found_warning(config_path, "not found. Downloading all libraries ...")
log_config_not_found_warning(
config_path, "not found. Downloading all libraries ...",
)
return None

return libraries


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

Args:
Expand All @@ -633,13 +712,17 @@ def get_photos_albums_filter(config: dict, base_config_path: list[str]) -> list[
albums = get_config_value_or_none(config=config, config_path=config_path)

if not albums or len(albums) == 0:
log_config_not_found_warning(config_path, "not found. Downloading all albums ...")
log_config_not_found_warning(
config_path, "not found. Downloading all albums ...",
)
return None

return albums


def get_photos_file_sizes_filter(config: dict, base_config_path: list[str]) -> list[str]:
def get_photos_file_sizes_filter(
config: dict, base_config_path: list[str],
) -> list[str]:
"""Get file sizes filter from photos config.

Args:
Expand All @@ -652,14 +735,18 @@ def get_photos_file_sizes_filter(config: dict, base_config_path: list[str]) -> l
config_path = base_config_path + ["file_sizes"]

if not traverse_config_path(config=config, config_path=config_path):
log_config_not_found_warning(config_path, "not found. Downloading original size photos ...")
log_config_not_found_warning(
config_path, "not found. Downloading original size photos ...",
)
return ["original"]

file_sizes = get_config_value(config=config, config_path=config_path)
return validate_file_sizes(file_sizes)


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

Args:
Expand All @@ -673,7 +760,9 @@ def get_photos_extensions_filter(config: dict, base_config_path: list[str]) -> l
extensions = get_config_value_or_none(config=config, config_path=config_path)

if not extensions or len(extensions) == 0:
log_config_not_found_warning(config_path, "not found. Downloading all extensions ...")
log_config_not_found_warning(
config_path, "not found. Downloading all extensions ...",
)
return None

return extensions
Expand Down Expand Up @@ -708,8 +797,12 @@ def get_photos_filters(config: dict) -> dict[str, Any]:
# Parse individual filter components
photos_filters["libraries"] = get_photos_libraries_filter(config, base_config_path)
photos_filters["albums"] = get_photos_albums_filter(config, base_config_path)
photos_filters["file_sizes"] = get_photos_file_sizes_filter(config, base_config_path)
photos_filters["extensions"] = get_photos_extensions_filter(config, base_config_path)
photos_filters["file_sizes"] = get_photos_file_sizes_filter(
config, base_config_path,
)
photos_filters["extensions"] = get_photos_extensions_filter(
config, base_config_path,
)

return photos_filters

Expand All @@ -719,7 +812,9 @@ def get_photos_filters(config: dict) -> dict[str, Any]:
# =============================================================================


def get_smtp_config_value(config: dict, key: str, warn_if_missing: bool = True) -> str | None:
def get_smtp_config_value(
config: dict, key: str, warn_if_missing: bool = True,
) -> str | None:
"""Get SMTP configuration value with optional warning.

Common helper for SMTP config retrieval to reduce duplication.
Expand Down Expand Up @@ -925,6 +1020,7 @@ def get_pushover_api_token(config: dict) -> str | None:
"""
return get_notification_config_value(config, "pushover", "api_token")


def get_pushover_notification_priority(config: dict) -> int | None:
"""Return Pushover notification priority from config.

Expand Down
30 changes: 24 additions & 6 deletions src/drive_file_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
LOGGER = get_logger()


def download_file(item: Any, local_file: str) -> str | None:
def download_file(
item: Any, local_file: str, flatten_packages: bool = False,
) -> str | None:
"""Download a file from iCloud to local filesystem.

This function handles the actual download of files from iCloud, including
Expand All @@ -28,6 +30,12 @@ def download_file(item: Any, local_file: str) -> str | None:
Args:
item: iCloud file item to download
local_file: Local path to save the file
flatten_packages: When True, package downloads (``/packageDownload?``
URLs) skip the unpack step entirely and stay on disk as a
single binary file. Useful for backup-style deployments where
bundle-directory semantics aren't needed and single-file
storage simplifies dedup / restoration. Opt-in via
``drive.flatten_packages: true`` in config.yaml.

Returns:
Path to the downloaded/processed file, or None if download failed
Expand All @@ -42,13 +50,23 @@ def download_file(item: Any, local_file: str) -> str | None:
for chunk in response.iter_content(4 * 1024 * 1024):
file_out.write(chunk)

# Check if this is a package that needs processing
# Check if this is a package that needs processing.
#
# ``process_package`` now returns the local_file path for both
# successful unpacks AND unrecognised mime types (the bytes
# are still on disk as a flat bundle — see the docstring on
# ``process_package`` for the rationale). It only returns
# ``None`` on hard processing failure that leaves the file in
# an unusable state. So we surface that as a download failure
# but no longer treat "couldn't unpack" as failure when the
# downloaded bytes are intact.
if response.url and "/packageDownload?" in response.url:
processed_file = process_package(local_file=local_file)
if processed_file:
local_file = processed_file
else:
processed_file = process_package(
local_file=local_file, flatten=flatten_packages,
)
if processed_file is None:
return None
local_file = processed_file

# Set the file modification time to match the remote file.
# iCloudPy produces date_modified via strptime(..., "%Y-%m-%dT%H:%M:%SZ") — always
Expand Down
Loading
Loading