use anyhow::{Context as AnyhowContext, Result};
use chrono::Utc;
use md5::Context as Md5Context;
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::api::SmugMugClient;
use crate::api::images::AlbumImage;
use crate::api::upload::{UploadPayload, replace_image, upload_image};
use crate::cache::hash_store::{HashStore, UploadedFile};
use crate::uploader::album_series::{AlbumSeries, ClientAlbumSeries};
pub struct UploadWorkerContext {
pub client: Arc<SmugMugClient>,
pub album_uri: String,
pub album_key: String,
pub series: Option<Arc<AlbumSeries<ClientAlbumSeries>>>,
pub hash_store: Arc<Mutex<HashStore>>,
pub remote_md5s: Option<Arc<std::collections::HashMap<String, String>>>,
pub remote_images: Option<Arc<std::collections::HashMap<String, AlbumImage>>>,
pub dry_run: bool,
pub no_cache: bool,
pub retry_attempts: u32,
pub render_raw: bool,
pub skip_raw_files: Arc<std::sync::atomic::AtomicBool>,
pub known_hashes: Option<Arc<std::collections::HashMap<std::path::PathBuf, String>>>,
}
fn is_retryable_error(error: &anyhow::Error) -> bool {
let error_msg = error.to_string().to_lowercase();
error_msg.contains("timeout")
|| error_msg.contains("connection")
|| error_msg.contains("network")
|| error_msg.contains("500")
|| error_msg.contains("502")
|| error_msg.contains("503")
|| error_msg.contains("504")
|| error_msg.contains("429") }
pub async fn upload_worker(
file_path: &Path,
context: Arc<UploadWorkerContext>,
) -> Result<UploadStatus> {
let is_raw = crate::scanner::is_raw_file(file_path);
let render = is_raw && context.render_raw;
if is_raw
&& !render
&& context
.skip_raw_files
.load(std::sync::atomic::Ordering::Relaxed)
{
return Ok(UploadStatus::Skipped);
}
let known_hash = context
.known_hashes
.as_ref()
.and_then(|hashes| hashes.get(file_path).cloned());
let hash = match known_hash {
Some(hash) => hash,
None => calculate_file_hash(file_path).with_context(|| "Failed to calculate file hash")?,
};
if !context.no_cache {
let store = context.hash_store.lock().await;
if let Some(_cached) = store.get(&hash)? {
return Ok(UploadStatus::Skipped);
}
}
if render {
return upload_rendered_raw(file_path, &hash, &context).await;
}
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
let needs_md5 = context.remote_md5s.is_some() || context.remote_images.is_some();
let md5_hash = if needs_md5 {
Some(calculate_md5_hash(file_path).with_context(|| "Failed to calculate MD5 hash")?)
} else {
None
};
if let (Some(remote_md5s), Some(md5_hash)) = (&context.remote_md5s, &md5_hash) {
if remote_md5s.contains_key(&md5_hash.to_lowercase()) {
return Ok(UploadStatus::Skipped);
}
}
let mut replace_target: Option<String> = None;
if let (Some(remote_images), Some(filename), Some(md5_hash)) =
(&context.remote_images, &filename, &md5_hash)
{
if let Some(remote_image) = remote_images.get(filename) {
let remote_md5_matches = remote_image
.archived_md5
.as_deref()
.map(|m| m.eq_ignore_ascii_case(md5_hash))
.unwrap_or(false);
if remote_md5_matches {
return Ok(UploadStatus::Skipped);
}
replace_target = Some(remote_image.uri.clone());
}
}
let file_size = std::fs::metadata(file_path)
.with_context(|| "Failed to get file metadata")?
.len();
let (album_uri, album_key, claimed) = claim_album(&context, replace_target.is_some()).await?;
if context.dry_run {
return Ok(UploadStatus::DryRun { file_size });
}
let payload = UploadPayload::from_path(file_path)
.await
.with_context(|| "Failed to read file")?;
let result = upload_with_retries(
&context,
file_path,
&hash,
&payload,
is_raw,
replace_target.as_deref(),
&album_uri,
&album_key,
)
.await;
release_on_failure(&context, &result, claimed).await;
result
}
async fn upload_rendered_raw(
file_path: &Path,
hash: &str,
context: &UploadWorkerContext,
) -> Result<UploadStatus> {
let file_name = crate::raw::rendered_file_name(file_path);
if let Some(remote_images) = &context.remote_images
&& remote_images.contains_key(&file_name)
{
return Ok(UploadStatus::Skipped);
}
let path = file_path.to_path_buf();
let rendered = tokio::task::spawn_blocking(move || crate::raw::render_jpeg(&path))
.await
.with_context(|| "RAW rendering task failed")?
.with_context(|| "Failed to render RAW file to JPEG")?;
if let Some(remote_md5s) = &context.remote_md5s
&& remote_md5s.contains_key(&format!("{:x}", md5::compute(&rendered.data)))
{
return Ok(UploadStatus::Skipped);
}
let payload = UploadPayload {
data: rendered.data.into(),
file_name,
mime_type: "image/jpeg".to_string(),
};
let file_size = payload.data.len() as u64;
let (album_uri, album_key, claimed) = claim_album(context, false).await?;
if context.dry_run {
return Ok(UploadStatus::DryRun { file_size });
}
let result = upload_with_retries(
context, file_path, hash, &payload, false, None, &album_uri, &album_key,
)
.await;
release_on_failure(context, &result, claimed).await;
result
}
async fn claim_album(
context: &UploadWorkerContext,
replacing: bool,
) -> Result<(String, String, Option<crate::api::albums::Album>)> {
Ok(match (&context.series, replacing) {
(Some(series), false) => {
let album = series.claim().await?;
(album.uri.clone(), album.album_key.clone(), Some(album))
}
_ => (context.album_uri.clone(), context.album_key.clone(), None),
})
}
async fn release_on_failure(
context: &UploadWorkerContext,
result: &Result<UploadStatus>,
claimed: Option<crate::api::albums::Album>,
) {
if let (Err(_), Some(album), Some(series)) = (result, &claimed, &context.series) {
series.release(album).await;
}
}
#[allow(clippy::too_many_arguments)]
async fn upload_with_retries(
context: &UploadWorkerContext,
file_path: &Path,
hash: &str,
payload: &UploadPayload,
is_raw: bool,
replace_target: Option<&str>,
album_uri: &str,
album_key: &str,
) -> Result<UploadStatus> {
let mut last_error = None;
let max_attempts = context.retry_attempts.max(1); let file_size = payload.data.len() as u64;
for attempt in 1..=max_attempts {
let upload_attempt = match replace_target {
Some(image_uri) => replace_image(&context.client, image_uri, payload).await,
None => upload_image(&context.client, album_uri, payload).await,
};
match upload_attempt {
Ok(upload_result) => {
let uploaded_file = UploadedFile {
smugmug_uri: upload_result.image_uri.clone(),
album_key: album_key.to_string(),
image_key: upload_result.image_key.clone(),
uploaded_at: Utc::now(),
file_size,
original_path: file_path.to_string_lossy().to_string(),
};
{
let store = context.hash_store.lock().await;
store.insert(hash, uploaded_file)?;
}
return Ok(if replace_target.is_some() {
UploadStatus::Replaced { file_size }
} else {
UploadStatus::Uploaded { file_size }
});
}
Err(e) => {
let error = e.context("Failed to upload image");
if is_raw {
let was_already_set = context
.skip_raw_files
.swap(true, std::sync::atomic::Ordering::Relaxed);
if !was_already_set {
eprintln!("\n⚠️ RAW file upload failed. Skipping remaining RAW files.");
eprintln!(" (RAW files require a SmugMug Source subscription)");
}
}
if attempt < max_attempts && is_retryable_error(&error) {
let delay_secs = 2u64.pow(attempt - 1);
tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
last_error = Some(error);
} else {
return Err(error);
}
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Upload failed after retries")))
}
pub enum UploadStatus {
Uploaded {
file_size: u64,
},
Replaced {
file_size: u64,
},
Skipped,
DryRun {
file_size: u64,
},
}
pub fn calculate_file_hash(file_path: &Path) -> Result<String> {
let file = File::open(file_path)?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0; 8192];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hex::encode(hasher.finalize()))
}
fn calculate_md5_hash(file_path: &Path) -> Result<String> {
let file = File::open(file_path)?;
let mut reader = BufReader::new(file);
let mut context = Md5Context::new();
let mut buffer = [0; 8192];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
context.consume(&buffer[..count]);
}
Ok(format!("{:x}", context.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_file(content: &[u8]) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content).unwrap();
file.flush().unwrap();
file
}
#[test]
fn test_calculate_file_hash() {
let content = b"test content for hashing";
let file = create_test_file(content);
let hash = calculate_file_hash(file.path()).unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
let hash2 = calculate_file_hash(file.path()).unwrap();
assert_eq!(hash, hash2);
}
#[test]
fn test_calculate_file_hash_empty_file() {
let file = create_test_file(b"");
let hash = calculate_file_hash(file.path()).unwrap();
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_calculate_file_hash_large_file() {
let content: Vec<u8> = (0..20000).map(|i| (i % 256) as u8).collect();
let file = create_test_file(&content);
let hash = calculate_file_hash(file.path()).unwrap();
assert_eq!(hash.len(), 64);
}
#[test]
fn test_calculate_md5_hash() {
let content = b"test content for md5";
let file = create_test_file(content);
let hash = calculate_md5_hash(file.path()).unwrap();
assert_eq!(hash.len(), 32);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
let hash2 = calculate_md5_hash(file.path()).unwrap();
assert_eq!(hash, hash2);
}
#[test]
fn test_calculate_md5_hash_empty_file() {
let file = create_test_file(b"");
let hash = calculate_md5_hash(file.path()).unwrap();
assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
}
#[test]
fn test_different_content_different_hashes() {
let file1 = create_test_file(b"content1");
let file2 = create_test_file(b"content2");
let hash1 = calculate_file_hash(file1.path()).unwrap();
let hash2 = calculate_file_hash(file2.path()).unwrap();
assert_ne!(hash1, hash2);
let md5_1 = calculate_md5_hash(file1.path()).unwrap();
let md5_2 = calculate_md5_hash(file2.path()).unwrap();
assert_ne!(md5_1, md5_2);
}
#[tokio::test]
async fn test_upload_worker_dry_run() {
let file = create_test_file(b"test image content");
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
));
let client = Arc::new(SmugMugClient::new(
"test_key".to_string(),
"test_secret".to_string(),
"test_token".to_string(),
"test_token_secret".to_string(),
));
let context = Arc::new(UploadWorkerContext {
client,
album_uri: "/api/v2/album/test".to_string(),
album_key: "test_album".to_string(),
series: None,
hash_store,
remote_md5s: None,
remote_images: None,
dry_run: true,
no_cache: false,
retry_attempts: 3,
render_raw: false,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
});
let result = upload_worker(file.path(), context).await.unwrap();
match result {
UploadStatus::DryRun { file_size } => {
assert!(file_size > 0);
}
_ => panic!("Expected DryRun status"),
}
}
#[tokio::test]
async fn test_upload_worker_skipped_local_cache() {
let file = create_test_file(b"cached content");
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
));
let hash = calculate_file_hash(file.path()).unwrap();
let uploaded_file = UploadedFile {
smugmug_uri: "/api/v2/image/test".to_string(),
album_key: "test_album".to_string(),
image_key: "test_key".to_string(),
uploaded_at: Utc::now(),
file_size: 100,
original_path: file.path().to_string_lossy().to_string(),
};
hash_store
.lock()
.await
.insert(&hash, uploaded_file)
.unwrap();
let client = Arc::new(SmugMugClient::new(
"test_key".to_string(),
"test_secret".to_string(),
"test_token".to_string(),
"test_token_secret".to_string(),
));
let context = Arc::new(UploadWorkerContext {
client,
album_uri: "/api/v2/album/test".to_string(),
album_key: "test_album".to_string(),
series: None,
hash_store,
remote_md5s: None,
remote_images: None,
dry_run: false,
no_cache: false,
retry_attempts: 3,
render_raw: false,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
});
let result = upload_worker(file.path(), context).await.unwrap();
match result {
UploadStatus::Skipped => {
}
_ => panic!("Expected Skipped status due to local cache"),
}
}
#[tokio::test]
async fn test_upload_worker_skipped_remote_md5() {
let file = create_test_file(b"remote content");
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
));
let md5 = calculate_md5_hash(file.path()).unwrap();
let mut remote_md5s = HashMap::new();
remote_md5s.insert(md5.to_lowercase(), "remote_image_key".to_string());
let client = Arc::new(SmugMugClient::new(
"test_key".to_string(),
"test_secret".to_string(),
"test_token".to_string(),
"test_token_secret".to_string(),
));
let context = Arc::new(UploadWorkerContext {
client,
album_uri: "/api/v2/album/test".to_string(),
album_key: "test_album".to_string(),
series: None,
hash_store,
remote_md5s: Some(Arc::new(remote_md5s)),
remote_images: None,
dry_run: false,
no_cache: false,
retry_attempts: 3,
render_raw: false,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
});
let result = upload_worker(file.path(), context).await.unwrap();
match result {
UploadStatus::Skipped => {
}
_ => panic!("Expected Skipped status due to remote MD5"),
}
}
#[tokio::test]
async fn test_upload_worker_skipped_same_filename_matching_md5() {
let file = create_test_file(b"unchanged content");
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
));
let filename = file
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string();
let md5 = calculate_md5_hash(file.path()).unwrap();
let mut remote_images = HashMap::new();
remote_images.insert(
filename,
AlbumImage {
image_key: "EXISTING123".to_string(),
file_name: file
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
archived_uri: "https://example.com/existing.jpg".to_string(),
file_size: 100,
format: "JPG".to_string(),
uri: "/api/v2/image/EXISTING123".to_string(),
title: None,
archived_md5: Some(md5),
},
);
let client = Arc::new(SmugMugClient::new(
"test_key".to_string(),
"test_secret".to_string(),
"test_token".to_string(),
"test_token_secret".to_string(),
));
let context = Arc::new(UploadWorkerContext {
client,
album_uri: "/api/v2/album/test".to_string(),
album_key: "test_album".to_string(),
series: None,
hash_store,
remote_md5s: None,
remote_images: Some(Arc::new(remote_images)),
dry_run: false,
no_cache: false,
retry_attempts: 3,
render_raw: false,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
});
let result = upload_worker(file.path(), context).await.unwrap();
match result {
UploadStatus::Skipped => {}
_ => panic!("Expected Skipped status because remote content is unchanged"),
}
}
#[tokio::test]
async fn test_upload_worker_no_cache_flag() {
let file = create_test_file(b"no cache test");
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
));
let hash = calculate_file_hash(file.path()).unwrap();
let uploaded_file = UploadedFile {
smugmug_uri: "/api/v2/image/test".to_string(),
album_key: "test_album".to_string(),
image_key: "test_key".to_string(),
uploaded_at: Utc::now(),
file_size: 100,
original_path: file.path().to_string_lossy().to_string(),
};
hash_store
.lock()
.await
.insert(&hash, uploaded_file)
.unwrap();
let client = Arc::new(SmugMugClient::new(
"test_key".to_string(),
"test_secret".to_string(),
"test_token".to_string(),
"test_token_secret".to_string(),
));
let context = Arc::new(UploadWorkerContext {
client,
album_uri: "/api/v2/album/test".to_string(),
album_key: "test_album".to_string(),
series: None,
hash_store,
remote_md5s: None,
remote_images: None,
dry_run: true, no_cache: true, retry_attempts: 3,
render_raw: false,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
});
let result = upload_worker(file.path(), context).await.unwrap();
match result {
UploadStatus::DryRun { .. } => {
}
_ => panic!("Expected DryRun status (cache should be bypassed)"),
}
}
fn render_context(remote_names: &[&str]) -> (Arc<UploadWorkerContext>, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().join("cache").to_str().unwrap()).unwrap(),
));
let remote_images = remote_names
.iter()
.map(|name| {
let image = AlbumImage {
image_key: "K".to_string(),
file_name: name.to_string(),
archived_uri: String::new(),
file_size: 1,
format: "JPG".to_string(),
uri: "/api/v2/image/K-0".to_string(),
title: None,
archived_md5: Some("0".repeat(32)),
};
(name.to_string(), image)
})
.collect();
let context = Arc::new(UploadWorkerContext {
client: Arc::new(SmugMugClient::new(
"k".to_string(),
"s".to_string(),
"t".to_string(),
"ts".to_string(),
)),
album_uri: "/api/v2/album/test".to_string(),
album_key: "test".to_string(),
series: None,
hash_store,
remote_md5s: None,
remote_images: Some(Arc::new(remote_images)),
dry_run: true,
no_cache: false,
retry_attempts: 1,
render_raw: true,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
});
(context, temp_dir)
}
fn write_fake_raw(dir: &Path, name: &str) -> std::path::PathBuf {
let mut raw = crate::raw::exif::tests::fake_tiff_raw_be();
raw.extend(crate::raw::jpeg::tests::fake_jpeg(0xC0, 6000, 4000, None));
let path = dir.join(name);
std::fs::write(&path, raw).unwrap();
path
}
#[tokio::test]
async fn test_rendered_raw_dry_run_reports_jpeg_size() {
let (context, dir) = render_context(&[]);
let path = write_fake_raw(dir.path(), "IMG_1.NEF");
let expected = crate::raw::render_jpeg(&path).unwrap().data.len() as u64;
match upload_worker(&path, context).await.unwrap() {
UploadStatus::DryRun { file_size } => assert_eq!(file_size, expected),
_ => panic!("Expected DryRun status"),
}
}
#[tokio::test]
async fn test_rendered_raw_skips_existing_jpeg_name() {
let (context, dir) = render_context(&["IMG_1.jpg"]);
let path = write_fake_raw(dir.path(), "IMG_1.NEF");
assert!(matches!(
upload_worker(&path, context).await.unwrap(),
UploadStatus::Skipped
));
}
#[tokio::test]
async fn test_rendered_raw_without_preview_fails() {
let (context, dir) = render_context(&[]);
let path = dir.path().join("IMG_1.CR2");
std::fs::write(&path, crate::raw::exif::tests::fake_tiff_raw_be()).unwrap();
let err = upload_worker(&path, context).await.err().unwrap();
assert!(format!("{err:#}").contains("no embedded JPEG preview"));
}
#[test]
fn test_upload_status_variants() {
let uploaded = UploadStatus::Uploaded { file_size: 1024 };
let skipped = UploadStatus::Skipped;
let dry_run = UploadStatus::DryRun { file_size: 2048 };
match uploaded {
UploadStatus::Uploaded { file_size } => assert_eq!(file_size, 1024),
_ => panic!("Wrong variant"),
}
match skipped {
UploadStatus::Skipped => {}
_ => panic!("Wrong variant"),
}
match dry_run {
UploadStatus::DryRun { file_size } => assert_eq!(file_size, 2048),
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_is_retryable_error_timeout() {
let error = anyhow::anyhow!("Connection timeout occurred");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_connection() {
let error = anyhow::anyhow!("Connection refused");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_network() {
let error = anyhow::anyhow!("Network error encountered");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_500() {
let error = anyhow::anyhow!("Server returned 500 Internal Server Error");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_502() {
let error = anyhow::anyhow!("502 Bad Gateway");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_503() {
let error = anyhow::anyhow!("503 Service Unavailable");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_504() {
let error = anyhow::anyhow!("504 Gateway Timeout");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_retryable_error_rate_limit() {
let error = anyhow::anyhow!("429 Too Many Requests");
assert!(is_retryable_error(&error));
}
#[test]
fn test_is_not_retryable_error_auth() {
let error = anyhow::anyhow!("401 Unauthorized");
assert!(!is_retryable_error(&error));
}
#[test]
fn test_is_not_retryable_error_not_found() {
let error = anyhow::anyhow!("404 Not Found");
assert!(!is_retryable_error(&error));
}
#[test]
fn test_is_not_retryable_error_validation() {
let error = anyhow::anyhow!("Invalid input: file too large");
assert!(!is_retryable_error(&error));
}
#[test]
fn test_is_not_retryable_error_forbidden() {
let error = anyhow::anyhow!("403 Forbidden");
assert!(!is_retryable_error(&error));
}
#[tokio::test]
async fn test_upload_worker_context_creation() {
let temp_dir = tempfile::tempdir().unwrap();
let hash_store = Arc::new(Mutex::new(
HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
));
let client = Arc::new(SmugMugClient::new(
"key".to_string(),
"secret".to_string(),
"token".to_string(),
"token_secret".to_string(),
));
let remote_md5s = HashMap::new();
let context = UploadWorkerContext {
client: client.clone(),
album_uri: "/api/v2/album/ABC123".to_string(),
album_key: "ABC123".to_string(),
series: None,
hash_store: hash_store.clone(),
remote_md5s: Some(Arc::new(remote_md5s)),
remote_images: None,
dry_run: false,
no_cache: true,
retry_attempts: 3,
render_raw: false,
skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
known_hashes: None,
};
assert_eq!(context.album_uri, "/api/v2/album/ABC123");
assert_eq!(context.album_key, "ABC123");
assert!(!context.dry_run);
assert!(context.no_cache);
assert!(context.remote_md5s.is_some());
}
}