Update dependencies and migrate to 2018 edition
[magnatune-sync.git] / src / main.rs
blobc0d529a3dab5096b9761f1dd5d569c59c0798c21
1 use std::env;
2 use std::error::Error;
3 use std::io;
4 use std::io::BufReader;
5 use std::path::{Path, PathBuf};
6 use rss::Channel;
7 use log::{warn, info, debug, trace};
8 use reqwest::{Client, RequestBuilder, Url, IntoUrl, header};
9 use std::fs;
10 use std::fs::OpenOptions;
11 use rusqlite::Connection;
12 use zip::ZipArchive;
13 use simplelog::{TermLogger, LevelFilter};
14 use structopt::StructOpt;
15 use directories::ProjectDirs;
17 #[derive(Debug)]
18 struct MagnatuneClient {
19     base_url: Url,
20     username: String,
21     password: String,
22     client: Client,
25 impl MagnatuneClient {
26     fn new(host: String, username: String, password: String) -> Result<MagnatuneClient, Box<Error>> {
27         let mut headers = header::HeaderMap::new();
28         headers.insert(header::USER_AGENT, header::HeaderValue::from_static(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))));
29         let client = Client::builder().default_headers(headers).build()?;
31         Ok(MagnatuneClient {
32             base_url: Url::parse(&format!("http://{}", host))?,
33             username: username,
34             password: password,
35             client: client,
36         })
37     }
39     fn url(&self, path: &[&str]) -> Result<Url, Box<Error>> {
40         let base = Url::options().base_url(Some(&self.base_url));
41         let mut url = base.parse("")?;
42         url.path_segments_mut().map_err(|_| "cannot be base")?.extend(path);
44         Ok(url)
45     }
47     fn get<U: IntoUrl>(&self, url: U) -> RequestBuilder {
48         self.client
49             .get(url)
50             .basic_auth(self.username.clone(), Some(self.password.clone()))
51     }
54 #[derive(Debug)]
55 struct Album {
56     name: String,
57     artist: String,
58     sku: String,
59     songs: Vec<Song>,
62 impl Album {
63     fn path(&self) -> PathBuf {
64         [ &self.artist, &self.name ].iter().collect()
65     }
68 #[derive(Debug)]
69 struct Song {
70     name: String,
71     track_no: i32,
74 #[derive(StructOpt, Debug)]
75 struct Opt {
76     #[structopt(long = "magnatune-host", default_value = "download.magnatune.com")]
77     magnatune_host: String,
79     /// Show verbose output
80     #[structopt(short = "v", long = "verbose")]
81     verbose: bool,
83     /// Show debug output
84     #[structopt(short = "d", long = "debug")]
85     debug: bool,
87     /// Target directory
88     #[structopt(parse(from_os_str))]
89     folder: PathBuf,
91     /// Limit to the first N entries in RSS feed. Useful for testing
92     #[structopt(long = "limit")]
93     limit: Option<usize>,
96 fn main() {
97     let opt = Opt::from_args();
98     ::std::process::exit(match run(opt) {
99        Ok(_) => 0,
100        Err(err) => {
101            eprintln!("{}", err);
102            1
103        }
104     });
107 fn run(opt: Opt) -> Result<(), Box<std::error::Error>> {
108     let level = if opt.debug {
109         LevelFilter::Trace
110     }
111     else if opt.verbose {
112         LevelFilter::Info
113     } else {
114         LevelFilter::Warn
115     };
116     TermLogger::init(level, simplelog::Config::default())?;
118     let username = env::var("magnatune_username").map_err(|_| "The environment variable magnatune_username must be set. Suggestion: run read magnatune_username; export magnatune_username")?;
119     let password = env::var("magnatune_password").map_err(|_| "The environment variable magnatune_password must be set. Suggestion: run read -s magnatune_password; export magnatune_password")?;
121     let magnatune_sqlite_url = "http://he3.magnatune.com/info/sqlite_normalized.db.gz";
123     info!("Syncing database...");
124     let sqlite_file = sync_magnatune_db(magnatune_sqlite_url).unwrap();
126     let magnatune = MagnatuneClient::new(opt.magnatune_host, username, password).unwrap();
128     info!("Fetching favourites...");
129     let favourites = fetch_favourites(&magnatune, &sqlite_file, opt.limit)?;
131     let favourites = favourites.into_iter().filter_map(|r| {
132         match r {
133             Ok(f) => Some(f),
134             Err(e) => {
135                 info!("Problematic favourites RSS entry: {}", e);
136                 None
137             }
138         }
139     });
140     trace!("{:#?}",favourites);
142     info!("Looking for missing albums...");
143     let mut missing_locally = Vec::new();
144     for album in favourites {
145         if check_album(&album, &opt.folder) {
146             trace!("OK: {} - {}", album.artist, album.name);
147         }
148         else {
149             info!("Missing: {} - {}", album.artist, album.name);
150             missing_locally.push(album);
151         }
152     }
154     if missing_locally.len() > 0 {
155         info!("Downloading...");
157         for album in missing_locally {
158             download(&magnatune, &album, &opt.folder)?;
159         }
160     }
161     else {
162         info!("Nothing missing. My work is done.");
163     }
165     Ok(())
168 fn fetch_favourites(client: &MagnatuneClient, sqlite_file: &Path, limit: Option<usize>) -> Result<Vec<Result<Album, Box<Error>>>, Box<Error>> {
169     let db = Connection::open(sqlite_file)?;
171     let mut url = client.url(&["member","favorites_export"])?;
172     url.query_pairs_mut().append_pair("format", "rss");
173     let res = client.get(url).send()?.error_for_status()?;
175     let reader = BufReader::new(res);
176     let channel = Channel::read_from(reader)?;
178     let iter = channel.items().into_iter();
179     let iter: Box<Iterator<Item = _>> = if let Some(n) = limit {
180         Box::new(iter.take(n))
181     }
182     else {
183         Box::new(iter)
184     };
186     let albums = iter.map(|item| {
187         if let Some(l) = item.link() {
188             find_album(&db, l)
189         }
190         else {
191             return Err(From::from(format!("RSS entry without link: {:#?}", item)));
192         }
193     }).collect();
195     Ok(albums)
198 fn sync_magnatune_db(url: &str ) -> Result<PathBuf, Box<Error>> {
199     let project_dirs = ProjectDirs::from("org.cakebox", "", env!("CARGO_PKG_NAME"))
200         .ok_or_else(|| format!("Could not find project directory"))?;
201     let dir = project_dirs.cache_dir();
203     fs::create_dir_all(dir)?;
205     let filename = dir.join("magnatune.sqlite");
206     let attr = fs::metadata(&filename);
207     if attr.is_err() {//TODO: check age
208         info!("Magnatune database missing or outdated, downloading...");
209         download_magnatune_db(url, &filename).map_err(|e| format!("Error downloading magnatune database: {}", e))?;
210     }
212     Ok(filename)
215 fn download_magnatune_db(url: &str, filename: &Path) -> Result<(), Box<Error>> {
216     let client = Client::new();
218     let res = client.get(url).send()?.error_for_status()?;
220     let mut uncompressed_res = flate2::bufread::GzDecoder::new(BufReader::new(res));
222     let mut file = OpenOptions::new().write(true)
223         .create_new(true)
224         .open(filename)?;
225     io::copy(&mut uncompressed_res, &mut file)?;
227     Ok(())
230 fn find_album(db: &Connection, link: &str) -> Result<Album, Box<Error>> {
231     let url = Url::parse(link)?;
232     let path: Vec<&str> = url.path_segments().unwrap().collect();
234     let parse_error = Err(From::from(format!("Expected url in artists/albums/:sku format: {}", link)));
235     if path.len() != 3 { return parse_error }
236     if path[0] != "artists" { return parse_error }
237     if path[1] != "albums" { return parse_error }
239     let sku = path[2];
241     let album = lookup_sku_in_db(&db, &sku)?;
243     Ok(album)
246 fn lookup_sku_in_db(db: &Connection, sku: &str) -> Result<Album, Box<Error>> {
247     let mut album_stmt = db.prepare_cached(
248         "SELECT album_id, albums.name, artists.name AS artist, albums.sku FROM albums JOIN artists ON (artist_id=artists_id) WHERE sku = ?"
249     )?;
250     let mut rows = album_stmt.query_and_then(&[&sku.to_string()], |album_row| {
251         let album_id: i64 = album_row.get(0);
252         let mut song_stmt = db.prepare_cached(
253             "SELECT name, track_no FROM songs WHERE album_id = ?"
254         )?;
255         let songs_iter = song_stmt.query_map(&[&album_id], |song_row| {
256             Song {
257                 name: song_row.get(0),
258                 track_no: song_row.get(1),
259             }
260         })?;
261         let mut songs: Vec<Song> = Vec::new();
262         for s in songs_iter {
263             songs.push(s?);
264         }
266         Ok(Album {
267             name: album_row.get(1),
268             artist: album_row.get(2),
269             sku: album_row.get(3),
270             songs: songs,
271         })
272     })?;
274     if let Some(album_result) = rows.next() {
275         album_result
276     }
277     else {
278         Err(From::from(format!("Could not find album with SKU {} in database", sku)))
279     }
282 //TODO: also check that all songs are present
283 fn check_album(album: &Album, folder: &Path) -> bool {
284     let stat = folder.join(album.path()).metadata();
285     stat.is_ok()
288 fn download(client: &MagnatuneClient, album: &Album, folder: &Path) -> Result<(), Box<Error>> {
289     info!("Downloading {}", album.name);
291     let album_folder = folder.join(album.path());
292     fs::create_dir_all(&album_folder)?;
294     download_and_unpack_flac(client, album, folder)?;
296     if let Err(e) = download_file(client, &["music", &album.artist, &album.name, "cover.jpg"], &album_folder.join("cover.jpg")) {
297         warn!("Error downloading cover: {}", e);
298     }
299     if let Err(e) = download_file(client, &["music", &album.artist, &album.name, "artwork.pdf"], &album_folder.join("artwork.pdf")) {
300         warn!("Error downloading artwork: {}", e);
301     }
303     Ok(())
306 fn download_and_unpack_flac(client: &MagnatuneClient, album: &Album, folder: &Path) -> Result<(), Box<Error>> {
307     // "http:/download.magnatune.com/music/".urlenc(artist)."/".urlenc(album)."/sku-wav.zip -- you won't need an API key.
308     let filename = format!("{}-flac.zip", album.sku);
309     let zip_path = folder.join(&filename);
310     download_file(client, &["music", &album.artist, &album.name, &filename], &zip_path)?;
312     unpack(&zip_path, &album, &folder)?;
314     fs::remove_file(zip_path)?;
316     Ok(())
319 fn download_file(client: &MagnatuneClient, url_path: &[&str], target: &Path) -> Result<(), Box<Error>> {
320     let mut resp = client.get(client.url(url_path)?).send()?.error_for_status()?;
322     let mut file = fs::OpenOptions::new().write(true).create_new(true).open(&target)?;
323     resp.copy_to(&mut file)?;
325     Ok(())
328 fn unpack(zip_path: &Path, album: &Album, folder: &Path) -> Result<(), Box<Error>> {
329     let album_folder = folder.join(album.path());
330     debug!("Unpacking {:?} to {:?}", zip_path, album_folder);//FIXME
332     let rdr = fs::File::open(zip_path)?;
333     let mut zip = ZipArchive::new(rdr)?;
335     for i in 0..zip.len() {
336         let mut file = zip.by_index(i)?;
338         info!("Extracting {:?}", file.sanitized_name());//FIXME
340         let mut relative_path = file.sanitized_name();
341         if relative_path.starts_with(&album.artist) {
342             relative_path = relative_path.strip_prefix(&album.artist).unwrap().into();
343         }
344         if relative_path.starts_with(&album.name) {
345             relative_path = relative_path.strip_prefix(&album.name).unwrap().into();
346         }
348         let path = album_folder.join(relative_path);
350         if ! path.parent().unwrap().canonicalize()?.starts_with(album_folder.canonicalize()?) {
351             return Err(format!("zip file wanted to write outside containing folder: {:?} is not beneath {:?}", path, folder).into()) //FIXME
352         }
354         debug!("Extracting to {:?}", path);//FIXME
355         let mut wtr = fs::OpenOptions::new().write(true).create_new(true).open(path)?;
356         io::copy(&mut file, &mut wtr)?;
357     }
359     Ok(())