[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

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

Add column filtering

Kasper (Sep 16, 2025, 11:25 AM +0200) 8a22ecc3 48d674ca

+177 -40
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 ## Next 4 + - Add filtering for specific fields in search, like `artist:apashe`. You can also right-click a column header to add a filter. 4 5 - Add genre autocomplete 5 6 - Make macOS media key permission request non-intrusive 6 7 - Improve error and crashing behaviour
+125 -8
src-native/filter.rs
··· 57 57 } 58 58 } 59 59 60 - fn filter_keyword(ids: Vec<ItemId>, keyword: &str, library: &Library) -> Vec<ItemId> { 60 + fn strip_prefix_ignore_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { 61 + if text.len() >= prefix.len() && text[..prefix.len()].eq_ignore_ascii_case(prefix) { 62 + Some(&text[prefix.len()..]) 63 + } else { 64 + None 65 + } 66 + } 67 + 68 + fn strip_suffix_ignore_case<'a>(text: &'a str, suffix: &str) -> Option<&'a str> { 69 + if text.len() >= suffix.len() && text[text.len() - suffix.len()..].eq_ignore_ascii_case(suffix) 70 + { 71 + Some(&text[..text.len() - suffix.len()]) 72 + } else { 73 + None 74 + } 75 + } 76 + 77 + fn feat_artists_match(track_name: &str, keyword: &str) -> bool { 78 + for (open, close) in &[('(', ')'), ('[', ']')] { 79 + let mut rest = track_name; 80 + while let Some(start) = rest.find(*open) { 81 + if let Some(end) = rest[start..].find(*close) { 82 + let inside = &rest[start + 1..start + end]; 83 + for prefix in ["feat.", "feat ", "ft.", "ft ", "featuring "] { 84 + let artist_text = strip_prefix_ignore_case(inside, prefix); 85 + if let Some(artist_text) = artist_text { 86 + return find_match(artist_text, keyword); 87 + } 88 + } 89 + for prefix in [" remix", " flip", " bootleg", " edit"] { 90 + let artist_text = strip_suffix_ignore_case(inside, prefix); 91 + if let Some(artist_text) = artist_text { 92 + return find_match(artist_text, keyword); 93 + } 94 + } 95 + rest = &rest[start + end + 1..]; 96 + } else { 97 + break; 98 + } 99 + } 100 + } 101 + false 102 + } 103 + 104 + fn filter_keyword(ids: Vec<ItemId>, keyword: Keyword, library: &Library) -> Vec<ItemId> { 61 105 let id_map = TRACK_ID_MAP.read().unwrap(); 62 106 let filtered_tracks: Vec<_> = ids 63 107 .into_par_iter() ··· 68 112 Ok(track) => track, 69 113 Err(_) => panic!("Track ID {} not found", track_id), 70 114 }; 71 - let is_match = find_match(&track.name, keyword) 72 - || find_match(&track.artist, keyword) 73 - || find_match_opt(&track.albumName, keyword) 74 - || find_match_opt(&track.comments, keyword) 75 - || find_match_opt(&track.genre, keyword); 115 + let field = match &keyword.field { 116 + Some(field) => field.to_lowercase(), 117 + None => "".to_string(), 118 + }; 119 + let is_match = match field.as_str() { 120 + "name" | "title" => find_match(&track.name, &keyword.literal), 121 + "artist" | "band" => { 122 + find_match(&track.artist, &keyword.literal) 123 + || feat_artists_match(&track.name, &keyword.literal) 124 + } 125 + "album" | "albumname" | "album_name" => { 126 + find_match_opt(&track.albumName, &keyword.literal) 127 + } 128 + "albumartist" | "album_artist" => { 129 + find_match_opt(&track.albumArtist, &keyword.literal) 130 + } 131 + "comment" | "comments" | "description" | "notes" => { 132 + find_match_opt(&track.comments, &keyword.literal) 133 + } 134 + "genre" => find_match_opt(&track.genre, &keyword.literal), 135 + "composer" => find_match_opt(&track.composer, &keyword.literal), 136 + "group" | "grouping" => find_match_opt(&track.grouping, &keyword.literal), 137 + "year" => { 138 + track.year.map(|n| n.to_string()).unwrap_or("".to_string()) == keyword.literal 139 + } 140 + "plays" => { 141 + track 142 + .plays 143 + .as_ref() 144 + .map(|n| n.len().to_string()) 145 + .unwrap_or("".to_string()) 146 + == keyword.literal 147 + } 148 + "skips" => { 149 + track 150 + .skips 151 + .as_ref() 152 + .map(|n| n.len().to_string()) 153 + .unwrap_or("".to_string()) 154 + == keyword.literal 155 + } 156 + "bpm" => { 157 + track.bpm.map(|n| n.to_string()).unwrap_or("".to_string()) == keyword.literal 158 + } 159 + _ => { 160 + find_match(&track.name, &keyword.full_word) 161 + || find_match(&track.artist, &keyword.full_word) 162 + || find_match_opt(&track.albumName, &keyword.full_word) 163 + || find_match_opt(&track.comments, &keyword.full_word) 164 + || find_match_opt(&track.genre, &keyword.full_word) 165 + } 166 + }; 76 167 is_match 77 168 }) 78 169 .map(|id| id.clone()) ··· 80 171 filtered_tracks 81 172 } 82 173 174 + struct Keyword { 175 + full_word: String, 176 + field: Option<String>, 177 + literal: String, 178 + } 179 + impl Keyword { 180 + fn parse(word: &str) -> Keyword { 181 + let mut parts = word.splitn(2, ':'); 182 + let field = parts.next(); 183 + let literal = parts.next(); 184 + match (field, literal) { 185 + (Some(field), Some(literal)) => Keyword { 186 + full_word: word.to_string(), 187 + field: Some(field.to_string()), 188 + literal: literal.to_string(), 189 + }, 190 + _ => Keyword { 191 + full_word: word.to_string(), 192 + field: None, 193 + literal: word.to_string(), 194 + }, 195 + } 196 + } 197 + } 198 + 83 199 pub fn filter(mut item_ids: Vec<ItemId>, query: String, library: &Library) -> Vec<ItemId> { 84 200 let now = Instant::now(); 85 201 if query == "" { ··· 87 203 } 88 204 let query: String = query.nfc().collect(); 89 205 90 - for keyword in query.split(' ') { 91 - if keyword == "" { 206 + for word in query.split(' ') { 207 + if word == "" { 92 208 continue; 93 209 } 210 + let keyword = Keyword::parse(word); 94 211 item_ids = filter_keyword(item_ids, keyword, &library); 95 212 } 96 213 println!("Filter: {}ms", now.elapsed().as_millis());
+7 -2
src/components/Filter.svelte
··· 5 5 6 6 let filter_input: HTMLInputElement 7 7 onDestroy( 8 - ipc_listen('filter', () => { 9 - filter_input.select() 8 + ipc_listen('filter', (e, text) => { 9 + if (text) { 10 + $filter = text 11 + filter_input.select() 12 + } else { 13 + filter_input.select() 14 + } 10 15 }), 11 16 ) 12 17 </script>
+28 -25
src/components/TrackList.svelte
··· 248 248 type TrackListColumn = Column & { 249 249 name: string 250 250 key: 'index' | 'image' | keyof Track 251 + filter?: string | undefined 251 252 } 252 253 const all_columns: TrackListColumn[] = [ 253 254 // sorted alphabetically 254 255 { name: '#', key: 'index', width: 46 }, 255 256 // { name: 'Size', key: 'size' }, 256 - { name: 'Album', key: 'albumName', width: 0.9, is_pct: true }, 257 - { name: 'Album Artist', key: 'albumArtist', width: 0.9, is_pct: true }, 258 - { name: 'Artist', key: 'artist', width: 1.2, is_pct: true }, 257 + { name: 'Album', key: 'albumName', filter: 'album', width: 0.9, is_pct: true }, 258 + { name: 'Album Artist', key: 'albumArtist', filter: 'albumartist', width: 0.9, is_pct: true }, 259 + { name: 'Artist', key: 'artist', filter: 'artist', width: 1.2, is_pct: true }, 259 260 // { name: 'Bitrate', key: 'bitrate' }, 260 - { name: 'BPM', key: 'bpm', width: 43 }, 261 - { name: 'Comments', key: 'comments', width: 0.65, is_pct: true }, 261 + { name: 'BPM', key: 'bpm', filter: 'bpm', width: 43 }, 262 + { name: 'Comments', key: 'comments', filter: 'comment', width: 0.65, is_pct: true }, 262 263 // { name: 'Compilation', key: 'compilation' }, 263 - { name: 'Composer', key: 'composer', width: 0.65, is_pct: true }, 264 + { name: 'Composer', key: 'composer', filter: 'composer', width: 0.65, is_pct: true }, 264 265 { name: 'Date Added', key: 'dateAdded', width: 140 }, 265 266 // { name: 'DateImported', key: 'dateImported' }, 266 267 // { name: 'DateModified', key: 'dateModified' }, ··· 269 270 // { name: 'DiscNum', key: 'discNum' }, 270 271 // { name: 'Disliked', key: 'disliked' }, 271 272 { name: 'Time', key: 'duration', width: 50 }, 272 - { name: 'Genre', key: 'genre', width: 0.65, is_pct: true }, 273 - { name: 'Grouping', key: 'grouping', width: 0.65, is_pct: true }, 273 + { name: 'Genre', key: 'genre', filter: 'genre', width: 0.65, is_pct: true }, 274 + { name: 'Grouping', key: 'grouping', filter: 'grouping', width: 0.65, is_pct: true }, 274 275 { 275 276 name: 'Image', 276 277 key: 'image', ··· 342 343 }, 343 344 // { name: 'ImportedFrom', key: 'importedFrom' }, 344 345 // { name: 'Liked', key: 'liked' }, 345 - { name: 'Name', key: 'name', width: 1.7, is_pct: true }, 346 - { name: 'Plays', key: 'playCount', width: 52 }, 346 + { name: 'Name', key: 'name', filter: 'name', width: 1.7, is_pct: true }, 347 + { name: 'Plays', key: 'playCount', filter: 'plays', width: 52 }, 347 348 // { name: 'Rating', key: 'rating' }, 348 349 // { name: 'SampleRate', key: 'sampleRate' }, 349 - { name: 'Skips', key: 'skipCount', width: 52 }, 350 + { name: 'Skips', key: 'skipCount', filter: 'skips', width: 52 }, 350 351 // { name: 'Sort Album', key: 'sortAlbumName', width: 0.65, is_pct: true }, 351 352 // { name: 'Sort Album Artist', key: 'sortAlbumArtist', width: 0.65, is_pct: true }, 352 353 // { name: 'Sort Artist', key: 'sortArtist', width: 0.65, is_pct: true }, ··· 355 356 // { name: 'TrackCount', key: 'trackCount' }, 356 357 // { name: 'TrackNum', key: 'trackNum' }, 357 358 // { name: 'Volume', key: 'volume' }, 358 - { name: 'Year', key: 'year', width: 47 }, 359 + { name: 'Year', key: 'year', filter: 'year', width: 47 }, 359 360 ] 360 361 const default_columns: Column['key'][] = [ 361 362 'index', ··· 391 392 } 392 393 save_view_options(view_options) 393 394 } 394 - function on_column_context_menu() { 395 - ipc_renderer.invoke('show_columns_menu', { 396 - menu: all_columns.map((col) => { 397 - return { 398 - id: col.key, 399 - label: col.name, 400 - type: 'checkbox', 401 - checked: !!columns.find((c) => c.key === col.key), 402 - } 403 - }), 404 - }) 405 - } 406 395 onDestroy( 407 396 ipc_listen('context.toggle_column', (_, item) => { 408 397 if (item.checked) { ··· 533 522 class="row table-header shrink-0 border-b border-b-slate-500/30" 534 523 class:desc={$sort_desc} 535 524 role="row" 536 - on:contextmenu={on_column_context_menu} 537 525 on:dragleave={() => (col_drag_to_index = null)} 538 526 bind:this={col_container} 539 527 > ··· 558 546 sort_key.set(column.key) 559 547 sort_desc.set(get_default_sort_desc(column.key)) 560 548 } 549 + }} 550 + on:contextmenu={() => { 551 + const column_filter = 'filter' in column ? column.filter : null 552 + console.log('cf', column_filter) 553 + ipc_renderer.invoke('show_columns_menu', { 554 + column_filter: typeof column_filter === 'string' ? column_filter : null, 555 + menu: all_columns.map((col) => { 556 + return { 557 + id: col.key, 558 + label: col.name, 559 + type: 'checkbox', 560 + checked: !!columns.find((c) => c.key === col.key), 561 + } as const 562 + }), 563 + }) 561 564 }} 562 565 draggable="true" 563 566 on:dragstart={(e) => on_col_drag_start(e, i)}
+11 -3
src/electron/ipc.ts
··· 143 143 }) 144 144 145 145 ipc_main.handle('show_columns_menu', (e, args) => { 146 - const menu = Menu.buildFromTemplate( 147 - args.menu.map((item) => { 146 + const menu = Menu.buildFromTemplate([ 147 + { 148 + label: 'Filter by this field', 149 + click: () => { 150 + e.sender.send('filter', (args.column_filter ?? 'error') + ':') 151 + }, 152 + visible: args.column_filter !== null, 153 + }, 154 + { type: 'separator', visible: args.column_filter !== null }, 155 + ...args.menu.map((item) => { 148 156 item.click = (item) => { 149 157 e.sender.send('context.toggle_column', { 150 158 id: item.id, ··· 154 162 } 155 163 return item 156 164 }), 157 - ) 165 + ]) 158 166 menu.popup() 159 167 })
+5 -2
src/electron/typed_ipc.ts
··· 106 106 show_settings: () => void 107 107 itunesImport: () => void 108 108 import: () => void 109 - filter: () => void 109 + filter: (text?: string) => void 110 110 111 111 playPause: () => void 112 112 Next: () => void ··· 161 161 revealTrackFile: (...paths: string[]) => void 162 162 show_tracks_menu: (options: ShowTrackMenuOptions) => Promise<null | SelectedTracksAction> 163 163 showTracklistMenu: (options: { id: string; isFolder: boolean; isRoot: boolean }) => void 164 - show_columns_menu: (options: { menu: MenuItemConstructorOptions[] }) => void 164 + show_columns_menu: (options: { 165 + column_filter: string | null 166 + menu: MenuItemConstructorOptions[] 167 + }) => void 165 168 volume_change: (up: boolean) => void 166 169 init_media_keys: ( 167 170 prompt: boolean,