Skip to content

Commit 09c5a71

Browse files
Add column filtering
1 parent 48d674c commit 09c5a71

File tree

6 files changed

+181
-40
lines changed

6 files changed

+181
-40
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## Next
4+
- Add filtering for specific fields in search, like `artist:apashe`. You can also right-click a column header to add a filter.
45
- Add genre autocomplete
56
- Make macOS media key permission request non-intrusive
67
- Improve error and crashing behaviour

src-native/filter.rs

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,51 @@ fn find_match_opt(text: &Option<String>, keyword: &str) -> bool {
5757
}
5858
}
5959

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> {
61105
let id_map = TRACK_ID_MAP.read().unwrap();
62106
let filtered_tracks: Vec<_> = ids
63107
.into_par_iter()
@@ -68,29 +112,102 @@ fn filter_keyword(ids: Vec<ItemId>, keyword: &str, library: &Library) -> Vec<Ite
68112
Ok(track) => track,
69113
Err(_) => panic!("Track ID {} not found", track_id),
70114
};
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+
};
76167
is_match
77168
})
78169
.map(|id| id.clone())
79170
.collect();
80171
filtered_tracks
81172
}
82173

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+
83199
pub fn filter(mut item_ids: Vec<ItemId>, query: String, library: &Library) -> Vec<ItemId> {
84200
let now = Instant::now();
85201
if query == "" {
86202
return item_ids;
87203
}
88204
let query: String = query.nfc().collect();
89205

90-
for keyword in query.split(' ') {
91-
if keyword == "" {
206+
for word in query.split(' ') {
207+
if word == "" {
92208
continue;
93209
}
210+
let keyword = Keyword::parse(word);
94211
item_ids = filter_keyword(item_ids, keyword, &library);
95212
}
96213
println!("Filter: {}ms", now.elapsed().as_millis());

src/components/Filter.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
66
let filter_input: HTMLInputElement
77
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+
}
1015
}),
1116
)
1217
</script>

src/components/TrackList.svelte

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,20 @@
248248
type TrackListColumn = Column & {
249249
name: string
250250
key: 'index' | 'image' | keyof Track
251+
filter?: string | undefined
251252
}
252253
const all_columns: TrackListColumn[] = [
253254
// sorted alphabetically
254255
{ name: '#', key: 'index', width: 46 },
255256
// { 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 },
259260
// { 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 },
262263
// { 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 },
264265
{ name: 'Date Added', key: 'dateAdded', width: 140 },
265266
// { name: 'DateImported', key: 'dateImported' },
266267
// { name: 'DateModified', key: 'dateModified' },
@@ -269,8 +270,8 @@
269270
// { name: 'DiscNum', key: 'discNum' },
270271
// { name: 'Disliked', key: 'disliked' },
271272
{ 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 },
274275
{
275276
name: 'Image',
276277
key: 'image',
@@ -342,11 +343,11 @@
342343
},
343344
// { name: 'ImportedFrom', key: 'importedFrom' },
344345
// { 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 },
347348
// { name: 'Rating', key: 'rating' },
348349
// { name: 'SampleRate', key: 'sampleRate' },
349-
{ name: 'Skips', key: 'skipCount', width: 52 },
350+
{ name: 'Skips', key: 'skipCount', filter: 'skips', width: 52 },
350351
// { name: 'Sort Album', key: 'sortAlbumName', width: 0.65, is_pct: true },
351352
// { name: 'Sort Album Artist', key: 'sortAlbumArtist', width: 0.65, is_pct: true },
352353
// { name: 'Sort Artist', key: 'sortArtist', width: 0.65, is_pct: true },
@@ -355,7 +356,7 @@
355356
// { name: 'TrackCount', key: 'trackCount' },
356357
// { name: 'TrackNum', key: 'trackNum' },
357358
// { name: 'Volume', key: 'volume' },
358-
{ name: 'Year', key: 'year', width: 47 },
359+
{ name: 'Year', key: 'year', filter: 'year', width: 47 },
359360
]
360361
const default_columns: Column['key'][] = [
361362
'index',
@@ -391,18 +392,6 @@
391392
}
392393
save_view_options(view_options)
393394
}
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-
}
406395
onDestroy(
407396
ipc_listen('context.toggle_column', (_, item) => {
408397
if (item.checked) {
@@ -474,13 +463,17 @@
474463
}
475464
}
476465
466+
console.log('vgrid')
477467
const virtual_grid = VirtualGrid.create(tracks_page.itemIds, {
478468
buffer: 20,
479469
row_prepare(item_id, i) {
470+
console.log('row_prepare0')
480471
const { track, id } = get_item(item_id)
472+
console.log('row_prepare1')
481473
if (track === null) {
482474
throw new Error(`Track with item_id ${item_id} not found`)
483475
}
476+
console.log('row_prepare2')
484477
return {
485478
...track,
486479
item_id,
@@ -533,7 +526,6 @@
533526
class="row table-header shrink-0 border-b border-b-slate-500/30"
534527
class:desc={$sort_desc}
535528
role="row"
536-
on:contextmenu={on_column_context_menu}
537529
on:dragleave={() => (col_drag_to_index = null)}
538530
bind:this={col_container}
539531
>
@@ -559,6 +551,21 @@
559551
sort_desc.set(get_default_sort_desc(column.key))
560552
}
561553
}}
554+
on:contextmenu={() => {
555+
const column_filter = 'filter' in column ? column.filter : null
556+
console.log('cf', column_filter)
557+
ipc_renderer.invoke('show_columns_menu', {
558+
column_filter: typeof column_filter === 'string' ? column_filter : null,
559+
menu: all_columns.map((col) => {
560+
return {
561+
id: col.key,
562+
label: col.name,
563+
type: 'checkbox',
564+
checked: !!columns.find((c) => c.key === col.key),
565+
} as const
566+
}),
567+
})
568+
}}
562569
draggable="true"
563570
on:dragstart={(e) => on_col_drag_start(e, i)}
564571
on:dragend={col_drag_end_handler}

src/electron/ipc.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,16 @@ ipc_main.handle('showTracklistMenu', (e, args) => {
143143
})
144144

145145
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) => {
148156
item.click = (item) => {
149157
e.sender.send('context.toggle_column', {
150158
id: item.id,
@@ -154,6 +162,6 @@ ipc_main.handle('show_columns_menu', (e, args) => {
154162
}
155163
return item
156164
}),
157-
)
165+
])
158166
menu.popup()
159167
})

src/electron/typed_ipc.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ type Events = {
106106
show_settings: () => void
107107
itunesImport: () => void
108108
import: () => void
109-
filter: () => void
109+
filter: (text?: string) => void
110110

111111
playPause: () => void
112112
Next: () => void
@@ -161,7 +161,10 @@ type Commands = {
161161
revealTrackFile: (...paths: string[]) => void
162162
show_tracks_menu: (options: ShowTrackMenuOptions) => Promise<null | SelectedTracksAction>
163163
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
165168
volume_change: (up: boolean) => void
166169
init_media_keys: (
167170
prompt: boolean,

0 commit comments

Comments
 (0)