193 lines
5.8 KiB
Rust
193 lines
5.8 KiB
Rust
use std::error::Error;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use google_api_auth;
|
|
use google_photoslibrary1;
|
|
use google_photoslibrary1::schemas::Album;
|
|
use google_photoslibrary1::schemas::SearchMediaItemsRequest;
|
|
use log::debug;
|
|
use log::info;
|
|
use regex::Regex;
|
|
use structopt::StructOpt;
|
|
use yup_oauth2::{Authenticator, InstalledFlow};
|
|
|
|
#[derive(Debug, StructOpt)]
|
|
enum Command {
|
|
/// List albums for the user of the given credentials. Optionally title filter.
|
|
ListAlbums {
|
|
title_filter: Option<Regex>,
|
|
},
|
|
SearchMediaItems {
|
|
album_id: String,
|
|
},
|
|
Sync {
|
|
/// Optional album title to filter. Default will mirror all albums.
|
|
#[structopt(short, long)]
|
|
title_filter: Option<Regex>,
|
|
/// Directory to store sync.
|
|
output: PathBuf,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, StructOpt)]
|
|
#[structopt(
|
|
name = "photosync",
|
|
about = "Utility for interacting with Google Photos API."
|
|
)]
|
|
struct Opt {
|
|
/// Activate debug mode
|
|
#[structopt(short, parse(from_occurrences))]
|
|
verbose: usize,
|
|
|
|
/// Path to json file containing Google client ID and secrets for out of band auth flow.
|
|
#[structopt(long)]
|
|
credentials: PathBuf,
|
|
/// Path to json file where photosync will store auth tokens refreshed from Google.
|
|
#[structopt(long)]
|
|
token_cache: PathBuf,
|
|
|
|
#[structopt(subcommand)]
|
|
cmd: Command,
|
|
}
|
|
|
|
fn new_client(
|
|
credentials: &PathBuf,
|
|
token_cache: &PathBuf,
|
|
) -> Result<google_photoslibrary1::Client, Box<dyn Error>> {
|
|
let secret = yup_oauth2::read_application_secret(credentials)?;
|
|
|
|
// Create an authenticator that uses an InstalledFlow to authenticate. The
|
|
// authentication tokens are persisted to a file named tokencache.json. The
|
|
// authenticator takes care of caching tokens to disk and refreshing tokens once
|
|
// they've expired.
|
|
let auth = Authenticator::new(InstalledFlow::new(
|
|
secret,
|
|
yup_oauth2::InstalledFlowReturnMethod::Interactive,
|
|
))
|
|
.persist_tokens_to_disk(token_cache)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let scopes = vec![google_photoslibrary1::scopes::PHOTOSLIBRARY_READONLY];
|
|
|
|
let auth = google_api_auth::yup_oauth2::from_authenticator(auth, scopes);
|
|
|
|
Ok(google_photoslibrary1::Client::new(auth))
|
|
}
|
|
|
|
fn search_media_items(
|
|
client: google_photoslibrary1::Client,
|
|
album_id: String,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let mut page_token = None;
|
|
let mut total = 0;
|
|
loop {
|
|
let resp = client
|
|
.media_items()
|
|
.search(SearchMediaItemsRequest {
|
|
album_id: Some(album_id.clone()),
|
|
// 100 is the documented max.
|
|
page_size: Some(100),
|
|
page_token,
|
|
..Default::default()
|
|
})
|
|
.execute_with_all_fields()?;
|
|
|
|
let media_items = resp.media_items.ok_or("no results")?;
|
|
println!("got ({}) items", media_items.len());
|
|
total += media_items.len();
|
|
for mi in media_items {
|
|
println!(
|
|
"{} {}",
|
|
mi.id.unwrap_or("NO ID".to_string()),
|
|
mi.filename.unwrap_or("NO FILENAME".to_string())
|
|
);
|
|
}
|
|
page_token = resp.next_page_token;
|
|
if page_token.is_none() {
|
|
println!("({}) items total", total);
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sync_albums(
|
|
client: google_photoslibrary1::Client,
|
|
title_filter: Option<Regex>,
|
|
output_dir: PathBuf,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let albums = list_albums(client, title_filter)?;
|
|
for a in &albums {
|
|
let album_dir = output_dir.join(a.id.as_ref().expect("missing album id"));
|
|
if !album_dir.exists() {
|
|
info!("making album directory {}", album_dir.to_string_lossy());
|
|
fs::create_dir_all(album_dir)?;
|
|
}
|
|
}
|
|
// Serialize it to a JSON string.
|
|
let j = serde_json::to_string(&albums)?;
|
|
|
|
let path = output_dir.join("albums.json");
|
|
info!("saving {}", path.to_string_lossy());
|
|
fs::write(path, j)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn print_albums(albums: Vec<Album>) {
|
|
for a in albums {
|
|
println!(
|
|
"album: {} {} ({} items)",
|
|
a.id.unwrap_or("NO ID".to_string()),
|
|
a.title.unwrap_or("NO TITLE".to_string()).to_string(),
|
|
a.media_items_count.unwrap_or(0)
|
|
);
|
|
}
|
|
}
|
|
|
|
fn list_albums(
|
|
client: google_photoslibrary1::Client,
|
|
title_filter: Option<Regex>,
|
|
) -> Result<Vec<Album>, Box<dyn Error>> {
|
|
Ok(client
|
|
.shared_albums()
|
|
.list()
|
|
.iter_shared_albums_with_all_fields()
|
|
.filter_map(|a| a.ok())
|
|
.filter(|a| {
|
|
match (&title_filter, &a.title) {
|
|
// keep everything when no filter or title.
|
|
(None, None) => true,
|
|
// skip when filter given but the media item doesn't have a title (it can't match)
|
|
(_, None) => false,
|
|
// skip when the media item doesn't match the filter
|
|
(Some(title_filter), Some(title)) if !title_filter.is_match(&title) => false,
|
|
// keep everything else
|
|
_ => true,
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let opt = Opt::from_args();
|
|
stderrlog::new()
|
|
.module(module_path!())
|
|
.verbosity(opt.verbose)
|
|
.init()
|
|
.unwrap();
|
|
debug!("opt: {:?}", opt);
|
|
let client = new_client(&opt.credentials, &opt.token_cache)?;
|
|
match opt.cmd {
|
|
Command::ListAlbums { title_filter } => {
|
|
print_albums(list_albums(client, title_filter)?);
|
|
Ok(())
|
|
}
|
|
Command::SearchMediaItems { album_id } => search_media_items(client, album_id),
|
|
Command::Sync {
|
|
title_filter,
|
|
output,
|
|
} => sync_albums(client, title_filter, output),
|
|
}
|
|
}
|