feat(rust): Integrate DownloadManager with sync command
- Added local_path column to files table for tracking downloaded files - Implemented get_pending_downloads() to find files without local_path - Integrated DownloadManager into sync command for full file downloads - Added collection key decryption for file downloads - Generate proper export paths with date/album structure - Track successful downloads and update database with local paths - Added migration to add local_path column to existing databases The sync command now supports full file downloads (not just metadata). Files are downloaded to the export directory with proper organization. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -44,8 +44,10 @@ The Rust CLI now has a **fully functional export capability** with proper file d
|
||||
|
||||
### Account Management (`/rust/src/commands/account.rs`)
|
||||
- ✅ **Account list** - Display all configured accounts
|
||||
- ✅ **Account add** (partial) - Add account with stored credentials
|
||||
- ✅ **Account add** - Full SRP authentication implemented
|
||||
- ✅ Store encrypted credentials in SQLite
|
||||
- ✅ 2FA/OTP support
|
||||
- ✅ Proper key derivation with Argon2
|
||||
|
||||
### Metadata Handling (`/rust/src/models/metadata.rs`)
|
||||
- ✅ **Metadata decryption and parsing**
|
||||
@@ -55,24 +57,28 @@ The Rust CLI now has a **fully functional export capability** with proper file d
|
||||
|
||||
## In Progress 🚧
|
||||
|
||||
### Sync Command (`/rust/src/commands/sync.rs`)
|
||||
- ✅ **Metadata sync implemented**
|
||||
- ✅ Fetch collections with pagination
|
||||
- ✅ Fetch files metadata per collection (matching Go CLI)
|
||||
- ✅ Store sync state in SQLite
|
||||
- ✅ Handle deleted files/collections
|
||||
- ✅ Per-collection incremental sync tracking for metadata
|
||||
- ⚠️ **File downloads NOT integrated** - only syncs metadata currently
|
||||
- 📝 TODO: Integrate DownloadManager for actual file downloads
|
||||
|
||||
### File Download Manager
|
||||
- ⚠️ Basic structure exists but not fully integrated
|
||||
- ✅ Basic structure implemented (`/rust/src/sync/download.rs`)
|
||||
- ✅ Download individual files with decryption
|
||||
- ✅ Parallel download infrastructure
|
||||
- ⚠️ **NOT integrated with sync command**
|
||||
- Need to implement:
|
||||
- Parallel downloads with progress tracking
|
||||
- Integration with sync command
|
||||
- Integration with sync command for full sync mode
|
||||
- Progress tracking UI
|
||||
- Resume interrupted downloads
|
||||
|
||||
## Remaining Components 📝
|
||||
|
||||
### Sync Command (`/rust/src/commands/sync.rs`)
|
||||
- ✅ **Full sync command implemented**
|
||||
- ✅ Fetch collections with pagination
|
||||
- ✅ Fetch files per collection (matching Go CLI)
|
||||
- ✅ Store sync state in SQLite
|
||||
- ✅ Handle deleted files/collections
|
||||
- ✅ Metadata-only and full sync modes
|
||||
- ✅ Per-collection incremental sync tracking
|
||||
|
||||
### Database and Storage (`/rust/src/storage/`)
|
||||
- ✅ **Platform-specific config directory** (`~/.config/ente-cli/`)
|
||||
- ✅ Avoid conflicts with Go CLI path
|
||||
@@ -142,14 +148,15 @@ The Rust CLI now has a **fully functional export capability** with proper file d
|
||||
### Feature Parity Progress
|
||||
- [x] Multi-account support (storage)
|
||||
- [x] Photos export (basic)
|
||||
- [x] Sync command (collections and files)
|
||||
- [x] Sync command (metadata only currently)
|
||||
- [x] Album organization
|
||||
- [x] Deduplicated storage
|
||||
- [x] Platform-specific config paths
|
||||
- [ ] SRP authentication (using stored tokens currently)
|
||||
- [x] SRP authentication (fully implemented)
|
||||
- [ ] Full sync with file downloads
|
||||
- [ ] Locker export
|
||||
- [ ] Auth (2FA) export
|
||||
- [ ] Incremental sync (partial - needs testing)
|
||||
- [x] Incremental sync (metadata only)
|
||||
- [ ] Export filters (albums, shared, hidden)
|
||||
|
||||
### Data Migration
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use crate::Result;
|
||||
use crate::api::client::ApiClient;
|
||||
use crate::models::account::Account;
|
||||
use crate::api::methods::ApiMethods;
|
||||
use crate::crypto::secret_box_open;
|
||||
use crate::models::{account::Account, metadata::FileMetadata};
|
||||
use crate::storage::Storage;
|
||||
use crate::sync::{SyncEngine, SyncStats};
|
||||
use crate::sync::{SyncEngine, SyncStats, download::DownloadManager};
|
||||
use base64::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub async fn run_sync(
|
||||
account_email: Option<String>,
|
||||
@@ -83,12 +87,17 @@ async fn sync_account(
|
||||
storage.sync().clear_sync_state(account.id)?;
|
||||
}
|
||||
|
||||
// Create sync engine (need to create new storage instance for ownership)
|
||||
// Create sync engine (need to create new instances for ownership)
|
||||
let db_path = storage
|
||||
.db_path()
|
||||
.ok_or_else(|| crate::Error::Generic("Database path not available".into()))?;
|
||||
|
||||
// Create API client for sync engine
|
||||
let sync_api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
||||
sync_api_client.add_token(&account.email, &token);
|
||||
|
||||
let sync_storage = Storage::new(db_path)?;
|
||||
let sync_engine = SyncEngine::new(api_client, sync_storage, account.clone());
|
||||
let sync_engine = SyncEngine::new(sync_api_client, sync_storage, account.clone());
|
||||
|
||||
// Run sync
|
||||
println!("Fetching collections and files...");
|
||||
@@ -99,15 +108,74 @@ async fn sync_account(
|
||||
|
||||
// Download files if not metadata-only
|
||||
if !metadata_only {
|
||||
println!("\n📥 Downloading files would happen here (not yet implemented)");
|
||||
// TODO: Implement file download using DownloadManager
|
||||
// let pending_files = sync_engine.get_pending_downloads().await?;
|
||||
// if !pending_files.is_empty() {
|
||||
// println!("Found {} files to download", pending_files.len());
|
||||
// let download_manager = DownloadManager::new(api_client, storage.clone())?;
|
||||
// // Set collection keys...
|
||||
// // Download files...
|
||||
// }
|
||||
// Get pending downloads
|
||||
let pending_files = storage.sync().get_pending_downloads(account.id)?;
|
||||
|
||||
if !pending_files.is_empty() {
|
||||
println!("\n📥 Found {} files to download", pending_files.len());
|
||||
|
||||
// Get collections to decrypt collection keys
|
||||
// Need to fetch from API to get the api::models::Collection type with encrypted_key
|
||||
let api = ApiMethods::new(&api_client);
|
||||
let api_collections = api.get_collections(&account.email, 0).await?;
|
||||
|
||||
// Decrypt collection keys
|
||||
let collection_keys = decrypt_collection_keys(
|
||||
&api_collections,
|
||||
&secrets.master_key,
|
||||
&secrets.secret_key,
|
||||
)?;
|
||||
|
||||
// Create download manager
|
||||
// Create a new API client for the download manager
|
||||
let download_api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
||||
download_api_client.add_token(&account.email, &token);
|
||||
|
||||
// Create another storage instance for download manager
|
||||
let download_storage = Storage::new(db_path)?;
|
||||
let mut download_manager = DownloadManager::new(download_api_client, download_storage)?;
|
||||
download_manager.set_collection_keys(collection_keys);
|
||||
|
||||
// Determine export directory
|
||||
let export_dir = if let Some(ref dir) = account.export_dir {
|
||||
PathBuf::from(dir)
|
||||
} else {
|
||||
std::env::current_dir()?.join("ente-export")
|
||||
};
|
||||
|
||||
// Prepare download tasks with proper paths
|
||||
let download_tasks = prepare_download_tasks(
|
||||
&pending_files,
|
||||
&export_dir,
|
||||
&api_collections,
|
||||
&download_manager,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Download files
|
||||
let download_stats = download_manager
|
||||
.download_files(&account.email, download_tasks)
|
||||
.await?;
|
||||
|
||||
// Update local paths in database
|
||||
for (file, path) in &download_stats.successful_downloads {
|
||||
storage.sync().update_file_local_path(
|
||||
account.id,
|
||||
file.id,
|
||||
path.to_str().unwrap_or(""),
|
||||
)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n✅ Downloaded {} files successfully",
|
||||
download_stats.successful
|
||||
);
|
||||
if download_stats.failed > 0 {
|
||||
println!("❌ Failed to download {} files", download_stats.failed);
|
||||
}
|
||||
} else {
|
||||
println!("\n✨ All files are already downloaded");
|
||||
}
|
||||
} else {
|
||||
println!("\n📋 Metadata-only sync completed (skipping file downloads)");
|
||||
}
|
||||
@@ -141,3 +209,146 @@ fn display_sync_stats(stats: &SyncStats) {
|
||||
);
|
||||
println!("└─────────────────────────────────────┘");
|
||||
}
|
||||
|
||||
/// Decrypt collection keys for file decryption
|
||||
fn decrypt_collection_keys(
|
||||
collections: &[crate::api::models::Collection],
|
||||
master_key: &[u8],
|
||||
_secret_key: &[u8],
|
||||
) -> Result<HashMap<i64, Vec<u8>>> {
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
let mut keys = HashMap::new();
|
||||
|
||||
for collection in collections {
|
||||
if collection.is_deleted {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt collection key
|
||||
let encrypted_bytes = BASE64.decode(&collection.encrypted_key)?;
|
||||
let nonce_bytes = BASE64.decode(&collection.key_decryption_nonce)?;
|
||||
|
||||
match secret_box_open(&encrypted_bytes, &nonce_bytes, master_key) {
|
||||
Ok(key) => {
|
||||
keys.insert(collection.id, key);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to decrypt key for collection {}: {}",
|
||||
collection.id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Prepare download tasks with proper file paths
|
||||
async fn prepare_download_tasks(
|
||||
files: &[crate::models::file::RemoteFile],
|
||||
export_dir: &Path,
|
||||
collections: &[crate::api::models::Collection],
|
||||
download_manager: &DownloadManager,
|
||||
) -> Result<Vec<(crate::models::file::RemoteFile, PathBuf)>> {
|
||||
use crate::crypto::decrypt_stream;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
// Create collection lookup map
|
||||
let collection_map: HashMap<i64, &crate::api::models::Collection> =
|
||||
collections.iter().map(|c| (c.id, c)).collect();
|
||||
|
||||
for file in files {
|
||||
// Get collection for this file
|
||||
let collection = collection_map.get(&file.collection_id);
|
||||
|
||||
// Try to decrypt metadata to get original filename
|
||||
let metadata = if let Some(col_key) =
|
||||
download_manager.collection_keys.get(&file.collection_id)
|
||||
{
|
||||
// Decrypt file key first
|
||||
let file_key = {
|
||||
let key_bytes = BASE64.decode(&file.encrypted_key)?;
|
||||
let nonce = BASE64.decode(&file.key_decryption_nonce)?;
|
||||
secret_box_open(&key_bytes, &nonce, col_key)?
|
||||
};
|
||||
|
||||
// Then decrypt metadata if available
|
||||
// Note: file.metadata is a crate::models::file::MetadataInfo, not FileAttributes
|
||||
if !file.metadata.encrypted_data.is_empty() {
|
||||
if !file.metadata.decryption_header.is_empty() {
|
||||
let encrypted_bytes = BASE64.decode(&file.metadata.encrypted_data)?;
|
||||
let header_bytes = BASE64.decode(&file.metadata.decryption_header)?;
|
||||
|
||||
match decrypt_stream(&encrypted_bytes, &header_bytes, &file_key) {
|
||||
Ok(decrypted) => serde_json::from_slice::<FileMetadata>(&decrypted).ok(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decrypt metadata for file {}: {}", file.id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Generate export path
|
||||
let mut path = export_dir.to_path_buf();
|
||||
|
||||
// Add date-based directory structure
|
||||
let datetime = Utc
|
||||
.timestamp_micros(file.updated_at)
|
||||
.single()
|
||||
.ok_or_else(|| crate::Error::Generic("Invalid timestamp".into()))?;
|
||||
|
||||
let year = datetime.format("%Y").to_string();
|
||||
let month = datetime.format("%m-%B").to_string();
|
||||
|
||||
path.push(year);
|
||||
path.push(month);
|
||||
|
||||
// Add collection name if available
|
||||
if let Some(col) = collection {
|
||||
if let Some(ref name) = col.name {
|
||||
if !name.is_empty() && name != "Uncategorized" {
|
||||
let safe_name: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
path.push(safe_name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use original filename from metadata or generate fallback
|
||||
let filename = if let Some(ref meta) = metadata {
|
||||
if let Some(title) = meta.get_title() {
|
||||
title.to_string()
|
||||
} else {
|
||||
format!("file_{}.jpg", file.id)
|
||||
}
|
||||
} else {
|
||||
format!("file_{}.jpg", file.id)
|
||||
};
|
||||
|
||||
path.push(filename);
|
||||
|
||||
tasks.push((file.clone(), path));
|
||||
}
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn create_tables(conn: &Connection) -> Result<()> {
|
||||
file_info TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
local_path TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (collection_id) REFERENCES collections(collection_id),
|
||||
@@ -129,5 +130,18 @@ pub fn create_tables(conn: &Connection) -> Result<()> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Add migration for local_path column if it doesn't exist
|
||||
let has_column = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('files') WHERE name='local_path'",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if has_column == 0 {
|
||||
conn.execute("ALTER TABLE files ADD COLUMN local_path TEXT", [])?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -204,4 +204,53 @@ impl<'a> SyncStore<'a> {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get files that need downloading (no local_path)
|
||||
pub fn get_pending_downloads(&self, account_id: i64) -> Result<Vec<RemoteFile>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT file_id, collection_id, encrypted_key, key_decryption_nonce,
|
||||
file_info, metadata, is_deleted, updated_at
|
||||
FROM files
|
||||
WHERE account_id = ?1 AND is_deleted = 0 AND local_path IS NULL
|
||||
ORDER BY file_id",
|
||||
)?;
|
||||
|
||||
let files = stmt
|
||||
.query_map(params![account_id], |row| {
|
||||
let file_info: String = row.get(4)?;
|
||||
let metadata: String = row.get(5)?;
|
||||
|
||||
Ok(RemoteFile {
|
||||
id: row.get(0)?,
|
||||
collection_id: row.get(1)?,
|
||||
owner_id: 0, // Will need to fetch from account
|
||||
encrypted_key: row.get(2)?,
|
||||
key_decryption_nonce: row.get(3)?,
|
||||
file: serde_json::from_str(&file_info).unwrap(),
|
||||
thumbnail: serde_json::from_str("{}").unwrap(), // Placeholder
|
||||
metadata: serde_json::from_str(&metadata).unwrap(),
|
||||
is_deleted: row.get::<_, i32>(6)? != 0,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Update file local path after successful download
|
||||
pub fn update_file_local_path(
|
||||
&self,
|
||||
account_id: i64,
|
||||
file_id: i64,
|
||||
local_path: &str,
|
||||
) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE files SET local_path = ?3
|
||||
WHERE account_id = ?1 AND file_id = ?2",
|
||||
params![account_id, file_id, local_path],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub struct DownloadManager {
|
||||
#[allow(dead_code)]
|
||||
storage: Storage,
|
||||
temp_dir: PathBuf,
|
||||
collection_keys: HashMap<i64, Vec<u8>>,
|
||||
pub collection_keys: HashMap<i64, Vec<u8>>,
|
||||
concurrent_downloads: usize,
|
||||
}
|
||||
|
||||
@@ -111,9 +111,11 @@ impl DownloadManager {
|
||||
let results: Vec<_> = stream::iter(files)
|
||||
.map(|(file, path)| {
|
||||
let account_id = account_id.to_string();
|
||||
let file_clone = file.clone();
|
||||
let path_clone = path.clone();
|
||||
async move {
|
||||
let result = self.download_file(&account_id, &file, &path).await;
|
||||
(file.id, result)
|
||||
(file_clone, path_clone, result)
|
||||
}
|
||||
})
|
||||
.buffer_unordered(self.concurrent_downloads)
|
||||
@@ -121,11 +123,14 @@ impl DownloadManager {
|
||||
.await;
|
||||
|
||||
// Count results
|
||||
for (_file_id, result) in results {
|
||||
for (file, path, result) in results {
|
||||
match result {
|
||||
Ok(_) => stats.successful += 1,
|
||||
Ok(_) => {
|
||||
stats.successful += 1;
|
||||
stats.successful_downloads.push((file, path));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Download failed: {e}");
|
||||
log::error!("Download failed for file {}: {}", file.id, e);
|
||||
stats.failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -238,6 +243,7 @@ pub struct DownloadStats {
|
||||
pub successful: usize,
|
||||
pub failed: usize,
|
||||
pub skipped: usize,
|
||||
pub successful_downloads: Vec<(RemoteFile, PathBuf)>,
|
||||
}
|
||||
|
||||
impl DownloadStats {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod download;
|
||||
pub mod download;
|
||||
mod engine;
|
||||
mod files;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user