A tool to sync music with your favorite devices
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: allow insertion of duplicate tracks with same hash but different storage path

Gee Sawra 74c78fd0 e8c36a54

+170 -47
+26
.sqlx/query-5504830c028ce3c260767d543aae7de963b3984f06029c7f619225630d8451a6.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT track_id, file_path FROM tracks where file_path LIKE ?1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "track_id", 8 + "ordinal": 0, 9 + "type_info": "Text" 10 + }, 11 + { 12 + "name": "file_path", 13 + "ordinal": 1, 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Right": 1 19 + }, 20 + "nullable": [ 21 + false, 22 + false 23 + ] 24 + }, 25 + "hash": "5504830c028ce3c260767d543aae7de963b3984f06029c7f619225630d8451a6" 26 + }
+3 -3
.sqlx/query-5f54bdccb58cf9524504f207dd522954d9fc632d6f88a5b68e242a33bd6b48bc.json .sqlx/query-cd91dfb09f6ec81a46f310c28dc7e1762450cd8025b7b0dfca30c609b3d018db.json
··· 1 1 { 2 2 "db_name": "SQLite", 3 - "query": "\n SELECT id FROM tracks WHERE file_path = ?1;\n ", 3 + "query": "\n SELECT id FROM tracks WHERE file_path = ? AND track_id = ?;\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 10 10 } 11 11 ], 12 12 "parameters": { 13 - "Right": 1 13 + "Right": 2 14 14 }, 15 15 "nullable": [ 16 16 false 17 17 ] 18 18 }, 19 - "hash": "5f54bdccb58cf9524504f207dd522954d9fc632d6f88a5b68e242a33bd6b48bc" 19 + "hash": "cd91dfb09f6ec81a46f310c28dc7e1762450cd8025b7b0dfca30c609b3d018db" 20 20 }
-20
.sqlx/query-716eb6fc78c3f4d43e1b55fa003753d7e25079e08d046f00e89f3d0a0434f0df.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT file_path FROM tracks where file_path LIKE ?1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "file_path", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "716eb6fc78c3f4d43e1b55fa003753d7e25079e08d046f00e89f3d0a0434f0df" 20 - }
+9 -5
src/db/instance.rs
··· 194 194 Ok(()) 195 195 } 196 196 197 - pub async fn exists(&self, path: String) -> Result<bool, Error> { 197 + pub async fn exists(&self, track_id: String, path: String) -> Result<bool, Error> { 198 198 let mut conn = self.pool.acquire().await?; 199 199 200 200 match sqlx::query!( 201 201 r#" 202 - SELECT id FROM tracks WHERE file_path = ?1; 202 + SELECT id FROM tracks WHERE file_path = ? AND track_id = ?; 203 203 "#, 204 204 path, 205 + track_id, 205 206 ) 206 207 .fetch_one(&mut *conn) 207 208 .await ··· 410 411 Ok(()) 411 412 } 412 413 413 - pub async fn track_paths_from_dir(&self, directory: String) -> Result<Vec<String>, Error> { 414 + pub async fn track_paths_from_dir( 415 + &self, 416 + directory: String, 417 + ) -> Result<Vec<(String, String)>, Error> { 414 418 let mut conn = self.pool.acquire().await?; 415 419 416 420 let directory = format!("{}%", directory); 417 421 Ok(sqlx::query!( 418 - r#"SELECT file_path FROM tracks where file_path LIKE ?1"#, 422 + r#"SELECT track_id, file_path FROM tracks where file_path LIKE ?1"#, 419 423 directory, 420 424 ) 421 425 .fetch_all(&mut *conn) 422 426 .await? 423 427 .into_iter() 424 - .map(|e| e.file_path) 428 + .map(|e| (e.track_id, e.file_path)) 425 429 .collect()) 426 430 } 427 431
+1
src/db/migrations/0008_track_primary_key_path_and_hash.sql
··· 1 + CREATE UNIQUE INDEX idx_file_path ON tracks(file_path);
+44
src/db/migrations/0009_remove_unique_track_id.sql
··· 1 + DROP VIEW IF EXISTS albums; 2 + 3 + CREATE TABLE tracks_new ( 4 + id INTEGER PRIMARY KEY NOT NULL, 5 + track_id TEXT NOT NULL, 6 + title TEXT NOT NULL, 7 + artist TEXT NOT NULL, 8 + album TEXT NOT NULL, 9 + genre TEXT NOT NULL, 10 + track_len INTEGER NOT NULL, 11 + year INTEGER NOT NULL, 12 + number INTEGER NOT NULL, 13 + disc_number INTEGER NOT NULL, 14 + disc_total INTEGER NOT NULL, 15 + file_state INTEGER NOT NULL, 16 + file_path TEXT NOT NULL, 17 + extension TEXT NOT NULL, 18 + artwork_path TEXT, 19 + kind INTEGER NOT NULL, 20 + description TEXT, 21 + artwork_bytes BLOB 22 + ); 23 + 24 + INSERT INTO tracks_new SELECT id, track_id, title, artist, album, genre, track_len, year, number, disc_number, disc_total, file_state, file_path, extension, artwork_path, kind, description, artwork_bytes FROM tracks; 25 + 26 + DROP TABLE tracks; 27 + 28 + ALTER TABLE tracks_new RENAME TO tracks; 29 + 30 + CREATE UNIQUE INDEX idx_file_path ON tracks(file_path); 31 + 32 + CREATE TRIGGER track_fts_ai_insert AFTER INSERT ON tracks BEGIN 33 + 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); 34 + END; 35 + 36 + CREATE TRIGGER track_fts_ai_delete AFTER DELETE ON tracks BEGIN 37 + 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); 38 + END; 39 + 40 + CREATE VIEW IF NOT EXISTS albums ( 41 + title, 42 + artist, 43 + format 44 + ) AS SELECT DISTINCT album, artist, extension FROM tracks;
+68 -5
src/gtkgui/gtkgui.rs
··· 4 4 use anyhow::Result; 5 5 use glib::BoxedAnyObject; 6 6 use gtk::{ 7 - ColumnView, ColumnViewColumn, CustomSorter, FileDialog, Label, ListItem, MenuButton, Ordering, 8 - PolicyType, ScrolledWindow, SignalListItemFactory, SortListModel, SortType, StringList, gio, 7 + ColumnView, ColumnViewColumn, CustomFilter, CustomSorter, FileDialog, FilterChange, 8 + FilterListModel, Label, ListItem, MenuButton, Ordering, PolicyType, ScrolledWindow, 9 + SearchEntry, SignalListItemFactory, SortListModel, SortType, StringList, gio, 9 10 }; 10 11 use gtk4 as gtk; 11 12 use libadwaita as adw; ··· 46 47 // Setup Actions 47 48 setup_actions(app, db_state.clone(), store.clone()); 48 49 50 + let search_query = Rc::new(RefCell::new(String::new())); 51 + let search_query_clone = search_query.clone(); 52 + 53 + let filter = CustomFilter::new(move |obj| { 54 + let item = obj.downcast_ref::<BoxedAnyObject>().unwrap(); 55 + let track = item.borrow::<Track>(); 56 + let query = search_query_clone.borrow(); 57 + 58 + if query.is_empty() { 59 + return true; 60 + } 61 + 62 + let q = query.to_lowercase(); 63 + track.title.to_lowercase().contains(&q) 64 + || track.artist.to_lowercase().contains(&q) 65 + || track.album.to_lowercase().contains(&q) 66 + }); 67 + 68 + let filter_model = FilterListModel::new(Some(store.clone()), Some(filter.clone())); 69 + 49 70 // Initially Create SortListModel without a sorter 50 - let sort_model = SortListModel::new(Some(store.clone()), None::<gtk::Sorter>); 71 + let sort_model = SortListModel::new(Some(filter_model), None::<gtk::Sorter>); 51 72 let selection_model = gtk::NoSelection::new(Some(sort_model.clone())); 52 73 53 74 let column_view = ColumnView::new(Some(selection_model)); ··· 94 115 Some(50), 95 116 true, 96 117 ); 118 + add_column( 119 + &column_view, 120 + "Path", 121 + |t: &Track| t.file_path.clone(), 122 + |a, b| a.file_path.cmp(&b.file_path).into(), 123 + None, 124 + true, 125 + ); 97 126 98 127 // Set the sorter on the model to use the column view's sorter 99 128 if let Some(sorter) = column_view.sorter() { ··· 113 142 114 143 let header_bar = adw::HeaderBar::new(); 115 144 145 + let search_button = gtk::ToggleButton::builder() 146 + .icon_name("system-search-symbolic") 147 + .build(); 148 + header_bar.pack_start(&search_button); 149 + 150 + let search_entry = SearchEntry::builder() 151 + .placeholder_text("Search...") 152 + .margin_top(8) 153 + .margin_bottom(8) 154 + .margin_start(8) 155 + .margin_end(8) 156 + .build(); 157 + 158 + let search_revealer = gtk::Revealer::builder() 159 + .child(&search_entry) 160 + .transition_type(gtk::RevealerTransitionType::SlideDown) 161 + .build(); 162 + 163 + search_button 164 + .bind_property("active", &search_revealer, "reveal-child") 165 + .sync_create() 166 + .build(); 167 + 168 + let search_query_clone = search_query.clone(); 169 + let filter_clone = filter.clone(); 170 + search_entry.connect_search_changed(move |entry| { 171 + *search_query_clone.borrow_mut() = entry.text().to_string(); 172 + filter_clone.changed(FilterChange::Different); 173 + }); 174 + 116 175 // Menu Setup 117 176 let menu = gio::Menu::new(); 118 177 menu.append(Some("Open Library"), Some("app.open")); ··· 128 187 129 188 header_bar.pack_end(&menu_button); 130 189 131 - let toolbar_view = adw::ToolbarView::builder() 132 - .content(&scrolled_window) 190 + let main_box = gtk::Box::builder() 191 + .orientation(gtk::Orientation::Vertical) 133 192 .build(); 193 + main_box.append(&search_revealer); 194 + main_box.append(&scrolled_window); 195 + 196 + let toolbar_view = adw::ToolbarView::builder().content(&main_box).build(); 134 197 135 198 toolbar_view.add_top_bar(&header_bar); 136 199
+19 -14
src/library.rs
··· 24 24 } 25 25 } 26 26 27 - let tracks_set: hash_set::HashSet<String> = tracks.into_iter().collect(); 27 + let tracks_set: hash_set::HashSet<(String, String)> = tracks.into_iter().collect(); 28 28 29 29 let res = try_join_all( 30 30 sources ··· 35 35 db, 36 36 source, 37 37 { 38 - let tracks_set = tracks_set.clone(); 39 - 40 - move |path, _| Ok(tracks_set.contains(path)) 38 + |path, _| { 39 + let id = (path.0.clone(), path.1.clone()); 40 + println!("{:?}", id); 41 + Ok(tracks_set.contains(&id)) 42 + } 41 43 }, 42 44 Some(cb), 43 45 kind, ··· 83 85 .await 84 86 } 85 87 86 - fn add_dupe_checker(path: &String, db: &db::Instance) -> Result<bool> { 88 + fn add_dupe_checker(key: (&String, &String), db: &db::Instance) -> Result<bool> { 87 89 block_on(async { 88 - if db.exists(path.clone()).await? { 90 + let (track_id, path) = key; 91 + 92 + if db.exists(track_id.clone(), path.clone()).await? { 89 93 return Ok(true); 90 94 } 91 95 ··· 101 105 kind: model::TrackKind, 102 106 ) -> Result<(u64, u64)> 103 107 where 104 - F: FnOnce(&String, &db::Instance) -> Result<bool> + Clone, 108 + F: FnOnce((&String, &String), &db::Instance) -> Result<bool> + Clone, 105 109 P: Fn(&model::Track) + Send + Sync + 'static, 106 110 { 107 111 log::info!("scanning {}", path); ··· 114 118 while let Ok(p) = paths.recv().await { 115 119 let p = p.context(format!("yolo"))?.clone(); 116 120 117 - let dc = dupe_checker.clone(); 118 - if dc(&p, db)? { 119 - duplicate += 1; 120 - continue; 121 - } 122 - 123 121 let tags = match lofty::read_from_path(p.clone()) { 124 122 Ok(t) => t, 125 123 Err(e) => { ··· 133 131 134 132 let mut track: model::Track = model::RawTrack { 135 133 tags, 136 - path: p, 134 + path: p.clone(), 137 135 artwork_path: find_cover_image(&p_path), 138 136 kind: kind, 139 137 } 140 138 .try_into()?; 139 + 140 + let dc = dupe_checker.clone(); 141 + if dc((&track.track_id.clone(), &p), db)? { 142 + duplicate += 1; 143 + continue; 144 + } 145 + 141 146 track.file_state = model::FileState::Copied; 142 147 143 148 log::info!("new track: {}", track);