···11+CREATE TABLE IF NOT EXISTS state (
22+ id INTEGER PRIMARY KEY NOT NULL,
33+ version INTEGER NOT NULL,
44+ is_external BOOL
55+);
66+77+CREATE TABLE IF NOT EXISTS tracks (
88+ id INTEGER PRIMARY KEY NOT NULL,
99+ track_id TEXT NOT NULL,
1010+ title TEXT NOT NULL,
1111+ artist TEXT NOT NULL,
1212+ album TEXT NOT NULL,
1313+ number INTEGER NOT NULL,
1414+ disc_number INTEGER NOT NULL,
1515+ disc_total INTEGER NOT NULL,
1616+ file_state INTEGER NOT NULL,
1717+ file_path TEXT NOT NULL,
1818+ extension TEXT NOT NULL
1919+);
2020+2121+CREATE VIEW IF NOT EXISTS albums (
2222+ title,
2323+ artist,
2424+ format
2525+) AS SELECT DISTINCT album, artist, extension FROM tracks;
+3
database/migrations/local/1_added_dirs.sql
···11+CREATE TABLE IF NOT EXISTS directories (
22+ directory TEXT PRIMARY KEY NOT NULL
33+);
+5
database/migrations/local/2_albums_view.sql
···11+CREATE VIEW IF NOT EXISTS albums (
22+ title,
33+ artist,
44+ format
55+) AS SELECT DISTINCT album, artist, extension FROM tracks;
+9
database/migrations/local/3_fts.sql
···11+CREATE VIRTUAL TABLE track_fts USING fts5(track_id, title, album, artist, extension, content=tracks, content_rowid=id);
22+33+CREATE TRIGGER track_fts_ai_insert AFTER INSERT ON tracks BEGIN
44+ INSERT INTO track_fts(rowid, track_id, title, album, artist, extension) VALUES (new.id, new.track_id, new.title, new.album, new.artist, new.extension);
55+END;
66+77+CREATE TRIGGER track_fts_ai_delete AFTER DELETE ON tracks BEGIN
88+ INSERT INTO track_fts(track_fts, rowid, track_id, title, album, artist, extension) VALUES('delete', old.id, old.track_id, old.title, old.album, old.artist, old.extension);
99+END;
+2
database/migrations/local/4_filter.sql
···11+ALTER TABLE state
22+ADD COLUMN filter TEXT;
+31
src/cli.rs
···11+use clap::{command, Parser, Subcommand};
22+33+use crate::cmd;
44+55+#[derive(Parser)]
66+#[command(version, about, long_about = None)]
77+#[command(propagate_version = true)]
88+pub struct Cli {
99+ #[command(subcommand)]
1010+ pub command: Commands,
1111+}
1212+1313+#[derive(Subcommand)]
1414+pub enum Commands {
1515+ /// Syncs source and destination, based on the database constructed with 'add`.
1616+ Sync(cmd::sync::Args),
1717+1818+ /// Adds a directory's content to the database.
1919+ Add(cmd::add::Args),
2020+2121+ /// Finds albums that are stored in more than one audio format.
2222+ Dupes(cmd::dupes::Args),
2323+2424+ /// Updates the local database with new tracks from previously added directories.
2525+ Update(cmd::add::Args),
2626+2727+ /// Cleans destination of uncleanly-copied files.
2828+ Clean(cmd::clean::Args),
2929+3030+ Filter(cmd::filter::Args),
3131+}
+226
src/cmd/add.rs
···11+use std::collections::hash_set;
22+33+use crate::cmd::*;
44+use crate::*;
55+use anyhow::{Context, Result};
66+use clap::Args as ClapArgs;
77+use futures::{executor::block_on, future::try_join_all};
88+use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
99+use model::FileState;
1010+1111+#[derive(ClapArgs, Debug)]
1212+pub struct Args {
1313+ /// Directory in which tunesdirector will store its local database.
1414+ #[arg(short, long, default_value_t = db::default_database_dir().to_str().unwrap().to_owned())]
1515+ pub database_path: String,
1616+1717+ /// A path in which tunesdirector will look for music files.
1818+ /// Specify more than one for multiple sources.
1919+ #[arg(short, long = "source", value_name = "SOURCE", action = clap::ArgAction::Append)]
2020+ pub sources: Option<Vec<String>>,
2121+2222+ /// Specifies if the database is to be written is a destination one.
2323+ #[arg(
2424+ long = "destination",
2525+ value_name = "TRUE|FALSE",
2626+ default_value_t = false
2727+ )]
2828+ pub is_destination: bool,
2929+}
3030+3131+impl Args {
3232+ pub fn validate(&self) -> Result<(), error::Error> {
3333+ if let None = self.sources {
3434+ return Err(error::Error::ValidationError(
3535+ "missing source(s)".to_owned(),
3636+ ));
3737+ };
3838+3939+ Ok(())
4040+ }
4141+}
4242+4343+pub async fn run(args: Args, update: bool) -> Result<()> {
4444+ let val_res = args.validate();
4545+4646+ match update {
4747+ true => {}
4848+ false => val_res?,
4949+ };
5050+5151+ log::debug!("CLI args: {:?}", args);
5252+5353+ // Open a database
5454+ let db = db::Instance::new(&args.database_path, args.is_destination)
5555+ .await
5656+ .with_context(|| "Cannot open local database instance")?;
5757+5858+ let sources = match update {
5959+ false => args.sources.unwrap(),
6060+ true => db
6161+ .directories()
6262+ .await
6363+ .with_context(|| "Cannot fetch track directories from database")?,
6464+ };
6565+6666+ let mp = MultiProgress::new();
6767+ let mut tracks = vec![];
6868+6969+ for source in &sources {
7070+ for i in db
7171+ .track_paths_from_dir(source.clone())
7272+ .await
7373+ .with_context(|| "Cannot fetch track paths from directory")?
7474+ {
7575+ tracks.push(i)
7676+ }
7777+ }
7878+7979+ let tracks_set: hash_set::HashSet<String> = tracks.into_iter().collect();
8080+8181+ let res = try_join_all(
8282+ sources
8383+ .into_iter()
8484+ .map(|source| {
8585+ traverse_and_add_param(&db, &mp, source, {
8686+ let tracks_set = tracks_set.clone();
8787+8888+ move |path, db, pb| match update {
8989+ false => add_dupe_checker(path, db, pb),
9090+ true => Ok(!tracks_set.contains(path)),
9191+ }
9292+ })
9393+ })
9494+ .collect::<Vec<_>>(),
9595+ )
9696+ .await?;
9797+9898+ let totals = res.iter().fold((0, 0), |acc, r| (acc.0 + r.0, acc.1 + r.1));
9999+100100+ match totals.1 {
101101+ 0 => log::info!("Imported {} tracks", totals.0),
102102+ _ => match update {
103103+ false => log::info!(
104104+ "Imported {} new tracks, but found {} duplicates",
105105+ totals.0,
106106+ totals.1
107107+ ),
108108+ true => {}
109109+ },
110110+ };
111111+112112+ if update {
113113+ let prog = mp.add(
114114+ ProgressBar::new_spinner()
115115+ .with_message("Looking for tracks not on disk anymore...")
116116+ .with_style(ProgressStyle::default_spinner()),
117117+ );
118118+119119+ prog.enable_steady_tick(std::time::Duration::from_millis(50));
120120+121121+ // look for files in db that are not on the filesystem anymore
122122+ let track_iter = db
123123+ .tracks_iter()
124124+ .await
125125+ .with_context(|| "Cannot create an iterator for existing tracks in database")?;
126126+127127+ while let Ok(track) = track_iter.recv().await {
128128+ let track = track?;
129129+130130+ let tp = std::path::Path::new(&track.file_path);
131131+132132+ match tp.exists() {
133133+ true => {}
134134+ false => {
135135+ prog.set_message(format!(
136136+ "Found track in database not existing on filesystem, deleting: {}",
137137+ track.file_path,
138138+ ));
139139+140140+ db.delete(track.id)
141141+ .await
142142+ .with_context(|| "Cannot delete track from database.")?;
143143+ }
144144+ }
145145+ }
146146+147147+ prog.finish();
148148+ mp.remove(&prog);
149149+ }
150150+151151+ Ok(())
152152+}
153153+154154+fn add_dupe_checker(path: &String, db: &db::Instance, pb: &indicatif::ProgressBar) -> Result<bool> {
155155+ block_on(async {
156156+ if db.exists(path.clone()).await? {
157157+ pb.set_message(format!("Found duplicate at {}", path.clone()));
158158+ return Ok(true);
159159+ }
160160+161161+ return Ok(false);
162162+ })
163163+}
164164+165165+pub(crate) async fn traverse_and_add_param<F>(
166166+ db: &db::Instance,
167167+ mp: &MultiProgress,
168168+ path: String,
169169+ dupe_checker: F,
170170+) -> Result<(u64, u64)>
171171+where
172172+ F: FnOnce(&String, &db::Instance, &indicatif::ProgressBar) -> Result<bool> + Clone,
173173+{
174174+ let paths = fs::traverse(&path).await;
175175+176176+ let base_msg = format!("Reading {}...", path.clone());
177177+178178+ let prog = mp.add(
179179+ ProgressBar::new_spinner()
180180+ .with_message(base_msg.clone())
181181+ .with_style(ProgressStyle::default_spinner()),
182182+ );
183183+184184+ prog.enable_steady_tick(std::time::Duration::from_millis(50));
185185+186186+ let mut new_tracks = 0;
187187+ let mut duplicate = 0;
188188+189189+ while let Ok(p) = paths.recv().await {
190190+ let p = p?.clone();
191191+192192+ let dc = dupe_checker.clone();
193193+ if dc(&p, db, &prog)? {
194194+ duplicate += 1;
195195+ continue;
196196+ }
197197+198198+ let tags = audiotags::Tag::new()
199199+ .read_from_path(p.clone())
200200+ .with_context(|| format!("Cannot read tags from {}", p.clone()))?;
201201+202202+ let mut track: model::Track = model::RawTrack { tags, path: p }.into();
203203+ track.file_state = FileState::Copied;
204204+205205+ db.insert_track(&track)
206206+ .await
207207+ .with_context(|| format!("Cannot write track data to database"))?;
208208+209209+ prog.set_message(format!(
210210+ "{}\nFound track: {} - {}, from {}",
211211+ base_msg.clone(),
212212+ track.title,
213213+ track.artist,
214214+ track.album
215215+ ));
216216+217217+ new_tracks += 1;
218218+ }
219219+220220+ prog.finish();
221221+ mp.remove(&prog);
222222+223223+ db.insert_directory(path).await?;
224224+225225+ Ok((new_tracks, duplicate))
226226+}
···11+/*
22+Hi!
33+44+This is the filter file, in here you can write Rhai code to filter out tracks
55+during the sync process.
66+77+This file must contain the `filter(track)` function, which will be evaluated for
88+every candidate copy track: return `true` to copy the track.
99+1010+If you want to match any String to a regex, call `regex_match(pattern, your_string)`.
1111+1212+The `track` object that's being passed as argument is the same as `model::BaseTrack`.
1313+*/
1414+1515+fn filter(track) {
1616+ // add your filtering code here!
1717+1818+ true
1919+}
+179
src/cmd/dupes.rs
···11+use crate::db;
22+use anyhow::{Context, Result};
33+use clap::Args as ClapArgs;
44+use std::collections::{hash_map, hash_set};
55+66+#[derive(ClapArgs, Debug)]
77+pub struct Args {
88+ /// Directory in which tunesdirector will store its local database.
99+ #[arg(short, long, default_value_t = db::default_database_dir().to_str().unwrap().to_owned())]
1010+ pub database_path: String,
1111+}
1212+1313+pub async fn run(args: Args) -> Result<()> {
1414+ // Open a database
1515+ let db = db::Instance::new(&args.database_path, false)
1616+ .await
1717+ .with_context(|| "Cannot open local database instance")?;
1818+1919+ let albums = db.albums().await.with_context(|| "Cannot fetch albums")?;
2020+2121+ // (Artist, album keywords)
2222+ let keywords: Vec<(String, Vec<String>)> = albums
2323+ .into_iter()
2424+ .map(|a| {
2525+ let album = split_after_parenthesis(a.title.clone());
2626+2727+ (
2828+ clean(a.artist.clone()),
2929+ album
3030+ .split_whitespace()
3131+ .into_iter()
3232+ .filter_map(|word| {
3333+ if word.len() > 3 {
3434+ return Some(clean(word.to_owned()));
3535+ }
3636+3737+ return None;
3838+ })
3939+ .collect::<Vec<_>>(),
4040+ )
4141+ })
4242+ .collect();
4343+4444+ let mut dedup: hash_set::HashSet<(String, String)> = hash_set::HashSet::new();
4545+4646+ for keyword in keywords {
4747+ if keyword.1.len() == 0 {
4848+ continue;
4949+ }
5050+5151+ log::debug!("looking for: {:?}", keyword.1);
5252+ // Album name : (track id, format)
5353+ let albums = db
5454+ .fuzzy_find_album(&keyword.1)
5555+ .await
5656+ .with_context(|| "Could not fuzzy find album")?
5757+ .into_iter()
5858+ .map(|e| {
5959+ (
6060+ split_after_parenthesis(e.1),
6161+ (split_after_parenthesis(e.2), e.0),
6262+ )
6363+ })
6464+ .collect::<hash_map::HashMap<_, _>>();
6565+6666+ log::debug!("found {} entries", albums.len());
6767+ if albums.is_empty() {
6868+ log::debug!("did not find any album for fuzzy query");
6969+ continue;
7070+ }
7171+7272+ if albums.len() <= 1 {
7373+ log::debug!("found just one album for query, likely no dupe");
7474+ continue;
7575+ }
7676+7777+ let album_names: Vec<String> = albums.keys().into_iter().cloned().collect();
7878+7979+ for (album_name, metadata) in &albums {
8080+ let mut options = album_names.clone();
8181+ if let Some(pos) = options.iter().position(|x| **x == *album_name) {
8282+ options.remove(pos);
8383+ }
8484+8585+ let (format, _) = metadata;
8686+8787+ match similar_string::find_best_similarity(album_name.clone(), &options) {
8888+ Some(res) => {
8989+ let (dupe_name, score) = res;
9090+ let an_trim = album_name.trim().to_string();
9191+9292+ match dedup.get(&(an_trim.clone(), dupe_name.clone())) {
9393+ Some(_) => continue,
9494+ None => {
9595+ dedup.insert((an_trim.clone(), dupe_name.clone()));
9696+ }
9797+ };
9898+9999+ if res.1 < 0.6 {
100100+ continue;
101101+ }
102102+103103+ let dupe_meta = albums.get(&dupe_name).unwrap();
104104+105105+ let dupe_path = db
106106+ .tracks_by_id(vec![dupe_meta.1.clone()])
107107+ .await
108108+ .with_context(|| "Cannot fetch dupe track")?
109109+ .first()
110110+ .unwrap()
111111+ .file_path
112112+ .clone();
113113+114114+ // parse dupe path and get the directory containing it
115115+ let dupe_path = std::path::Path::new(&dupe_path).parent().unwrap();
116116+ let dupe_path = dupe_path.to_str().unwrap();
117117+118118+ println!(
119119+ "Maybe duplicate:\n\t\"{}\": \"{}\" (confidence: {:.1}%) \n\tat path {}, format {}",
120120+ album_name.trim(),
121121+ dupe_name.trim(),
122122+ score * (100 as f64),
123123+ dupe_path,
124124+ format,
125125+ );
126126+ }
127127+ None => {}
128128+ }
129129+ }
130130+ }
131131+132132+ let std_duplicates = db
133133+ .duplicate_albums()
134134+ .await
135135+ .with_context(|| "Cannot fetch duplicate albums")?;
136136+137137+ for sd in std_duplicates {
138138+ let (album, amt) = sd;
139139+ let paths = db
140140+ .album_paths(&album.title, &album.artist)
141141+ .await
142142+ .with_context(|| "Cannot fetch duplicate album")?;
143143+144144+ println!(r#"Found "{}" in {} formats:"#, album.title, amt);
145145+146146+ for ele in paths {
147147+ let (path, ext) = ele;
148148+ println!("\t {}: {}", path, ext);
149149+ }
150150+ }
151151+152152+ Ok(())
153153+}
154154+155155+fn clean(s: String) -> String {
156156+ s.replace("(", " ")
157157+ .replace(")", " ")
158158+ .replace(":", " ")
159159+ .replace(r#"'"#, r#" "#)
160160+ .replace(".", " ")
161161+ .replace("<", " ")
162162+ .replace(">", " ")
163163+ .replace(",", " ")
164164+ .replace("-", " ")
165165+ .replace("[", " ")
166166+ .replace("]", " ")
167167+ .replace("?", " ")
168168+ .replace("/", " ")
169169+ .replace("!", " ")
170170+}
171171+172172+fn split_after_parenthesis(s: String) -> String {
173173+ let split: Vec<(usize, char)> = s.char_indices().filter(|e| e.1 == '(').collect();
174174+175175+ match split.len() {
176176+ 0 => s,
177177+ _ => s.clone().split_at(split.first().unwrap().0).0.to_string(),
178178+ }
179179+}