Skip to content

Commit b3bb2dd

Browse files
rhooprclaude
andauthored
Add parallel concurrent downloads (#1)
* Add parallel concurrent downloads and --recent early termination Two-phase download design: sequential filter pass builds task list, then buffer_unordered downloads with --threads-num concurrency. Remove --until-found (will be replaced by stateful incremental sync). Add --recent N early termination to stop API enumeration after N photos. Add progress logging during photo enumeration and service init. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add 19 unit tests and apply idiomatic Rust fixes - Add CLI, config, download, and album unit tests (56 total) - Remove dead dry_run guards in download phase 2 - Modernize ref patterns to idiomatic & style - Fix stale doc comment in album.rs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Rob Hooper <rhoopr@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3c6ceff commit b3bb2dd

File tree

8 files changed

+380
-64
lines changed

8 files changed

+380
-64
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.claude/
2+
.testplans/
23
CLAUDE.md
34
TODO.md
45
.DS_Store

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Behavioral Changes from Python icloud-photos-downloader
2+
3+
- `--recent N` stops fetching from the API after N photos instead of enumerating the entire library first
4+
- `--until-found` removed — will be replaced by stateful incremental sync with local database

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Full feature parity with the Python icloudpd, including:
1919
- **Complete Apple authentication** — SRP-6a, two-factor authentication, session persistence with trust tokens
2020
- **Full iCloud Photos API** — albums, smart folders, shared libraries, asset enumeration with pagination
2121
- **Flexible downloads** — multiple size variants (original, medium, thumb, adjusted, alternative), live photos, RAW files
22-
- **Content filtering** — by media type, date range, album, recency, and consecutive-found early exit
22+
- **Content filtering** — by media type, date range, album, recency
2323
- **File organization** — date-based folder structures, filename sanitization, deduplication policies
2424
- **EXIF metadata** — read and write DateTimeOriginal, file modification time sync
2525
- **XMP sidecar export** — GPS, keywords, ratings, title, description, orientation
@@ -71,6 +71,8 @@ icloudpd-rs --username my@email.address --directory /photos
7171
| `--auth-only` | Only authenticate, don't download |
7272
| `-l, --list-albums` | List available albums |
7373
| `--list-libraries` | List available libraries |
74+
| `--recent N` | Download only the N most recent photos |
75+
| `--threads-num N` | Number of concurrent downloads (default: 1) |
7476
| `--dry-run` | Preview without modifying files or iCloud |
7577

7678
## License

src/cli.rs

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ pub struct Cli {
5050
#[arg(long)]
5151
pub recent: Option<u32>,
5252

53-
/// Download until finding X consecutive existing photos
54-
#[arg(long)]
55-
pub until_found: Option<u32>,
53+
/// Number of concurrent download threads (default: 1)
54+
#[arg(long = "threads-num", default_value_t = 1, value_parser = clap::value_parser!(u16).range(1..))]
55+
pub threads_num: u16,
5656

5757
/// Don't download videos
5858
#[arg(long)]
@@ -142,3 +142,64 @@ pub struct Cli {
142142
#[arg(long)]
143143
pub only_print_filenames: bool,
144144
}
145+
146+
#[cfg(test)]
147+
mod tests {
148+
use super::*;
149+
use clap::Parser;
150+
151+
fn parse(args: &[&str]) -> Cli {
152+
Cli::try_parse_from(args).unwrap()
153+
}
154+
155+
fn base_args() -> Vec<&'static str> {
156+
vec!["icloudpd-rs", "--username", "test@example.com"]
157+
}
158+
159+
#[test]
160+
fn test_threads_num_defaults_to_1() {
161+
let cli = parse(&base_args());
162+
assert_eq!(cli.threads_num, 1);
163+
}
164+
165+
#[test]
166+
fn test_threads_num_accepts_valid_value() {
167+
let mut args = base_args();
168+
args.extend(["--threads-num", "8"]);
169+
let cli = parse(&args);
170+
assert_eq!(cli.threads_num, 8);
171+
}
172+
173+
#[test]
174+
fn test_threads_num_rejects_zero() {
175+
let mut args = base_args();
176+
args.extend(["--threads-num", "0"]);
177+
assert!(Cli::try_parse_from(&args).is_err());
178+
}
179+
180+
#[test]
181+
fn test_dry_run_default_false() {
182+
let cli = parse(&base_args());
183+
assert!(!cli.dry_run);
184+
}
185+
186+
#[test]
187+
fn test_size_default_original() {
188+
let cli = parse(&base_args());
189+
assert!(matches!(cli.size, VersionSize::Original));
190+
}
191+
192+
#[test]
193+
fn test_recent_none_by_default() {
194+
let cli = parse(&base_args());
195+
assert!(cli.recent.is_none());
196+
}
197+
198+
#[test]
199+
fn test_recent_accepts_value() {
200+
let mut args = base_args();
201+
args.extend(["--recent", "50"]);
202+
let cli = parse(&args);
203+
assert_eq!(cli.recent, Some(50));
204+
}
205+
}

src/config.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub struct Config {
1515
pub size: VersionSize,
1616
pub live_photo_size: LivePhotoSize,
1717
pub recent: Option<u32>,
18-
pub until_found: Option<u32>,
18+
pub threads_num: u16,
1919
pub skip_videos: bool,
2020
pub skip_photos: bool,
2121
pub skip_live_photos: bool,
@@ -92,7 +92,7 @@ impl Config {
9292
size: cli.size,
9393
live_photo_size: cli.live_photo_size,
9494
recent: cli.recent,
95-
until_found: cli.until_found,
95+
threads_num: cli.threads_num,
9696
skip_videos: cli.skip_videos,
9797
skip_photos: cli.skip_photos,
9898
skip_live_photos: cli.skip_live_photos,
@@ -198,4 +198,40 @@ mod tests {
198198
assert!(parse_date_or_interval("not-a-date").is_err());
199199
assert!(parse_date_or_interval("").is_err());
200200
}
201+
202+
fn make_cli(overrides: impl FnOnce(&mut crate::cli::Cli)) -> crate::cli::Cli {
203+
use clap::Parser;
204+
let mut cli = crate::cli::Cli::try_parse_from([
205+
"icloudpd-rs", "--username", "u@example.com",
206+
]).unwrap();
207+
overrides(&mut cli);
208+
cli
209+
}
210+
211+
#[test]
212+
fn test_from_cli_threads_num_passthrough() {
213+
let cli = make_cli(|c| c.threads_num = 4);
214+
let cfg = Config::from_cli(cli).unwrap();
215+
assert_eq!(cfg.threads_num, 4);
216+
}
217+
218+
#[test]
219+
fn test_from_cli_skip_flags() {
220+
let cli = make_cli(|c| {
221+
c.skip_videos = true;
222+
c.skip_photos = true;
223+
c.skip_live_photos = true;
224+
});
225+
let cfg = Config::from_cli(cli).unwrap();
226+
assert!(cfg.skip_videos);
227+
assert!(cfg.skip_photos);
228+
assert!(cfg.skip_live_photos);
229+
}
230+
231+
#[test]
232+
fn test_from_cli_dry_run() {
233+
let cli = make_cli(|c| c.dry_run = true);
234+
let cfg = Config::from_cli(cli).unwrap();
235+
assert!(cfg.dry_run);
236+
}
201237
}

0 commit comments

Comments
 (0)