[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 genre autocomplete

Kasper (Sep 14, 2025, 9:50 AM +0200) 28cb8083 89f9defa

+89 -42
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 ## Next 4 + - Add genre autocomplete 4 5 - Make macOS media key permission request non-intrusive 5 6 - Improve crashing behaviour 6 7
+2 -1
ferrum-addon/addon.d.ts
··· 3 3 4 4 /* auto-generated by NAPI-RS */ 5 5 6 - export declare function get_artists(): Array<string> 7 6 export declare function load_data(isDev: boolean, localDataPath?: string | undefined | null, libraryPath?: string | undefined | null): void 8 7 export interface PathsJs { 9 8 libraryDir: string ··· 21 20 playlistsCount: number 22 21 } 23 22 export declare function get_default_sort_desc(field: string): boolean 23 + export declare function get_genres(): Array<string> 24 + export declare function get_artists(): Array<string> 24 25 export interface Track { 25 26 size: number 26 27 duration: number
-27
src-native/artists.rs
··· 1 - use crate::data::Data; 2 - use crate::data_js::get_data; 3 - use crate::library_types::Library; 4 - use napi::{Env, Result}; 5 - use std::collections::HashSet; 6 - use std::time::Instant; 7 - 8 - pub fn load_artists(library: &Library) -> HashSet<String> { 9 - let now = Instant::now(); 10 - let mut artists = HashSet::new(); 11 - for (_, track) in library.get_tracks() { 12 - if !artists.contains(&track.artist) { 13 - artists.insert(track.artist.clone()); 14 - } 15 - } 16 - println!("Get artists: {}ms", now.elapsed().as_millis()); 17 - return artists; 18 - } 19 - 20 - #[napi(js_name = "get_artists")] 21 - #[allow(dead_code)] 22 - pub fn get_artists(env: Env) -> Result<Vec<String>> { 23 - let data: &mut Data = get_data(&env)?; 24 - let mut artists: Vec<String> = data.artists.clone().into_iter().collect(); 25 - artists.sort(); 26 - Ok(artists) 27 - }
+1 -6
src-native/data.rs
··· 1 - use crate::artists::load_artists; 2 - use crate::library::{load_library, Paths}; 1 + use crate::library::{Paths, load_library}; 3 2 use crate::library_types::Library; 4 3 use crate::tracks::Tag; 5 4 use anyhow::{Context, Result}; 6 5 use atomicwrites::{AllowOverwrite, AtomicFile}; 7 6 use dirs_next; 8 7 use serde::Serialize; 9 - use std::collections::HashSet; 10 8 use std::env; 11 9 use std::io::Write; 12 10 use std::path::PathBuf; ··· 17 15 pub library: Library, 18 16 /// Current tag being edited 19 17 pub current_tag: Option<Tag>, 20 - pub artists: HashSet<String>, 21 18 } 22 19 23 20 impl Data { ··· 81 78 }; 82 79 83 80 let loaded_library = load_library(&paths)?; 84 - let artists = load_artists(&loaded_library); 85 81 86 82 let data = Data { 87 83 paths, 88 84 library: loaded_library, 89 - artists, 90 85 current_tag: None, 91 86 }; 92 87 return Ok(data);
-1
src-native/lib.rs
··· 8 8 #[macro_use] 9 9 extern crate napi_derive; 10 10 11 - mod artists; 12 11 mod data; 13 12 mod data_js; 14 13 mod filter;
+22 -3
src-native/library.rs
··· 1 + use crate::data::Data; 2 + use crate::data_js::get_data; 1 3 use crate::library_types::{Library, VersionedLibrary}; 2 - use anyhow::{bail, Context, Result}; 4 + use anyhow::{Context, Result, bail}; 5 + use napi::Env; 3 6 use serde::{Deserialize, Serialize}; 4 - use serde_json::{json, Value}; 5 - use std::fs::{create_dir_all, File}; 7 + use serde_json::{Value, json}; 8 + use std::fs::{File, create_dir_all}; 6 9 use std::io::{ErrorKind, Read}; 7 10 use std::path::PathBuf; 8 11 use std::time::Instant; ··· 143 146 }; 144 147 return Ok(field); 145 148 } 149 + 150 + #[napi(js_name = "get_genres")] 151 + #[allow(dead_code)] 152 + pub fn get_genres(env: Env) -> Result<Vec<String>> { 153 + let data: &mut Data = get_data(&env)?; 154 + let genres = data.library.get_genres(); 155 + Ok(genres.clone()) 156 + } 157 + 158 + #[napi(js_name = "get_artists")] 159 + #[allow(dead_code)] 160 + pub fn get_artists(env: Env) -> Result<Vec<String>> { 161 + let data: &mut Data = get_data(&env)?; 162 + let genres = data.library.get_artists(); 163 + Ok(genres.clone()) 164 + }
+40 -2
src-native/library_types.rs
··· 7 7 use nanoid::nanoid; 8 8 use serde::{Deserialize, Serialize}; 9 9 use std::borrow::Cow; 10 + use std::collections::HashSet; 10 11 use std::path::PathBuf; 11 12 use std::sync::RwLock; 13 + use std::time::Instant; 12 14 13 15 #[derive(Clone, Debug)] 14 16 pub struct Library { ··· 22 24 /// - timestamps aren't updated after pausing 23 25 pub v1PlayTime: Vec<PlayTime>, 24 26 pub playTime: Vec<PlayTime>, 27 + pub artists: Option<Vec<String>>, 28 + pub genres: Option<Vec<String>>, 25 29 } 26 30 impl Library { 27 31 pub fn versioned(&'_ self) -> VersionedLibrary<'_> { ··· 70 74 trackLists: self.trackLists.into_owned(), 71 75 v1PlayTime: self.playTime.into_owned(), 72 76 playTime: Vec::new(), 77 + artists: None, 78 + genres: None, 73 79 }; 74 80 for (id, track) in self.tracks.into_owned() { 75 81 library.insert_track(id, track); ··· 112 118 trackLists: track_lists, 113 119 v1PlayTime: Vec::new(), 114 120 playTime: Vec::new(), 121 + artists: None, 122 + genres: None, 115 123 } 116 124 } 117 - #[inline] 118 125 pub fn get_tracks(&self) -> &LinkedHashMap<TrackID, Track> { 119 126 &self.tracks 120 127 } 121 - #[inline] 122 128 pub fn get_track_item_ids(&self) -> &LinkedHashMap<TrackID, ItemId> { 123 129 &self.track_item_ids 124 130 } ··· 231 237 } 232 238 } 233 239 None 240 + } 241 + pub fn get_genres(&mut self) -> &Vec<String> { 242 + if self.genres.is_none() { 243 + let now = Instant::now(); 244 + let mut genres = HashSet::new(); 245 + for track in self.tracks.values() { 246 + if let Some(genre) = &track.genre { 247 + genres.insert(genre.clone()); 248 + } 249 + } 250 + let mut genres: Vec<String> = genres.into_iter().collect(); 251 + genres.sort(); 252 + self.genres = Some(genres); 253 + println!("Load genres: {}ms", now.elapsed().as_millis()); 254 + } 255 + return self.genres.as_ref().unwrap(); 256 + } 257 + pub fn get_artists(&mut self) -> &Vec<String> { 258 + if self.artists.is_none() { 259 + let now = Instant::now(); 260 + let mut artists = HashSet::new(); 261 + for track in self.tracks.values() { 262 + if track.artist != "" { 263 + artists.insert(track.artist.clone()); 264 + } 265 + } 266 + let mut artists: Vec<String> = artists.into_iter().collect(); 267 + artists.sort(); 268 + self.artists = Some(artists); 269 + println!("Load artists: {}ms", now.elapsed().as_millis()); 270 + } 271 + return self.artists.as_ref().unwrap(); 234 272 } 235 273 } 236 274
+22 -1
src/components/TrackInfo.svelte
··· 27 27 import { get, writable } from 'svelte/store' 28 28 import Modal, { modal_count } from './Modal.svelte' 29 29 import { 30 + get_genres, 30 31 get_image, 31 32 get_track, 32 33 load_tags, ··· 35 36 set_image_data, 36 37 update_track_info, 37 38 } from '@/lib/data' 39 + 40 + const genres = get_genres() 38 41 39 42 function cancel() { 40 43 current_list.set(null) ··· 449 452 </div> 450 453 <div class="row"> 451 454 <div class="label">Genre</div> 452 - <input type="text" bind:value={genre} /> 455 + <input 456 + type="text" 457 + bind:value={genre} 458 + on:input={(e) => { 459 + if (!(e instanceof InputEvent) || e.inputType !== 'insertText') { 460 + return 461 + } 462 + const match = genres.find((genre) => { 463 + return genre.startsWith(e.currentTarget.value) 464 + }) 465 + if (!match) { 466 + return 467 + } 468 + const start = e.currentTarget.selectionStart 469 + genre = match 470 + e.currentTarget.value = genre 471 + e.currentTarget.setSelectionRange(start, genre.length) // select the appended text 472 + }} 473 + /> 453 474 </div> 454 475 <div class="row"> 455 476 <div class="label">Year</div>
+1 -1
src/lib/data.ts
··· 234 234 export const tracklist_updated = create_refresh_store() 235 235 236 236 export function get_artists() { 237 - return call((addon) => addon.get_artists()) 237 + return strict_call((addon) => addon.get_artists()) 238 238 } 239 239 export function get_genres() { 240 240 return strict_call((addon) => addon.get_genres())