[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.

Remember queue on restart

Kasper (Mar 8, 2026, 6:27 AM +0100) 4c1ccc7f 1b22cd88

+313 -6
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 ## Next 4 + - Remember queue on restart 4 5 - Android: Add track streaming search links for Spotify and YouTube Music 5 6 6 7 ## 0.21.0 - 2026 Map 6
+17
Cargo.lock
··· 1124 1124 "rayon", 1125 1125 "redb", 1126 1126 "serde", 1127 + "serde_cbor", 1127 1128 "serde_json", 1128 1129 "simd-json", 1129 1130 "specta", ··· 1664 1665 "quote", 1665 1666 "syn 2.0.106", 1666 1667 ] 1668 + 1669 + [[package]] 1670 + name = "half" 1671 + version = "1.8.3" 1672 + source = "registry+https://github.com/rust-lang/crates.io-index" 1673 + checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" 1667 1674 1668 1675 [[package]] 1669 1676 name = "halfbrown" ··· 3805 3812 "serde", 3806 3813 "serde_core", 3807 3814 "typeid", 3815 + ] 3816 + 3817 + [[package]] 3818 + name = "serde_cbor" 3819 + version = "0.11.2" 3820 + source = "registry+https://github.com/rust-lang/crates.io-index" 3821 + checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" 3822 + dependencies = [ 3823 + "half", 3824 + "serde", 3808 3825 ] 3809 3826 3810 3827 [[package]]
+1
Cargo.toml
··· 34 34 serde = { version = "1.0", features = ["derive"] } 35 35 lofty = "0.22" 36 36 serde_json = "1.0" 37 + serde_cbor = "0.11" 37 38 nanoid = "0.4" 38 39 alphanumeric-sort = "1.5" 39 40 linked-hash-map = { version = "0.5", features = ["serde_impl"] }
+26
ferrum-addon/addon.d.ts
··· 88 88 89 89 export declare function load_data(isDev: boolean, localDataPath?: string | undefined | null, libraryPath?: string | undefined | null): void 90 90 91 + export declare function load_queue_state(filePath: string): QueueState | null 92 + 91 93 export declare function load_tags(trackId: string): void 92 94 93 95 export declare function load_view_options(): ViewOptions ··· 107 109 cacheDb: string 108 110 localDataDir: string 109 111 viewOptionsFile: string 112 + queueFile: string 110 113 logsDir: string 111 114 } 112 115 ··· 125 128 126 129 export declare function playlist_filter_duplicates(playlistId: TrackID, ids: Array<string>): Array<TrackID> 127 130 131 + export interface QueueCurrentState { 132 + item: QueueItemState 133 + from_auto_queue: boolean 134 + } 135 + 136 + export interface QueueItemState { 137 + qId: number 138 + id: string 139 + non_shuffle_pos?: number 140 + } 141 + 142 + export interface QueueState { 143 + past: Array<QueueItemState> 144 + current?: QueueCurrentState 145 + user_queue: Array<QueueItemState> 146 + auto_queue: Array<QueueItemState> 147 + last_qid: number 148 + shuffle: boolean 149 + repeat: boolean 150 + } 151 + 128 152 export declare function read_cover_async(filePath: string, index: number): Promise<Buffer | null> 129 153 130 154 /** Returns `None` if the file does not have an image */ ··· 135 159 export declare function remove_image(index: number): void 136 160 137 161 export declare function save(): void 162 + 163 + export declare function save_queue_state(queueState: QueueState, filePath: string): Promise<void> 138 164 139 165 export declare function save_view_options(viewOptions: ViewOptions, filePath: string): Promise<void> 140 166
+1
src-native/data.rs
··· 102 102 cache_db: path_to_string(cache_dir.join("Cache.redb")), 103 103 local_data_dir: path_to_string(&local_data_dir), 104 104 view_options_file: path_to_string(local_data_dir.join("view.json")), 105 + queue_file: path_to_string(local_data_dir.join("queue.cbor")), 105 106 // This makes sure we can get the logs dir, which is important for crash logs 106 107 logs_dir: path_to_string(app_log_dir()?), 107 108 };
+2
src-native/lib.rs
··· 32 32 #[cfg(feature = "napi-rs")] 33 33 mod playlists; 34 34 #[cfg(feature = "napi-rs")] 35 + mod queue_state; 36 + #[cfg(feature = "napi-rs")] 35 37 mod sort; 36 38 #[cfg(feature = "napi-rs")] 37 39 mod tracks;
+1
src-native/library.rs
··· 27 27 pub cache_db: String, 28 28 pub local_data_dir: String, 29 29 pub view_options_file: String, 30 + pub queue_file: String, 30 31 pub logs_dir: String, 31 32 } 32 33 #[cfg(feature = "napi-rs")]
+174
src-native/queue_state.rs
··· 1 + use anyhow::{Context, Result}; 2 + use atomicwrites::AtomicFile; 3 + use atomicwrites::OverwriteBehavior::AllowOverwrite; 4 + use serde::{Deserialize, Serialize}; 5 + use std::fs; 6 + use std::io::Write; 7 + 8 + #[derive(Serialize, Deserialize, Debug, Clone, Default)] 9 + #[napi(object)] 10 + pub struct QueueItemState { 11 + #[serde(rename = "qId")] 12 + #[napi(js_name = "qId")] 13 + pub q_id: i64, 14 + pub id: String, 15 + #[napi(js_name = "non_shuffle_pos")] 16 + pub non_shuffle_pos: Option<u32>, 17 + } 18 + 19 + #[derive(Serialize, Deserialize, Debug, Clone, Default)] 20 + #[napi(object)] 21 + pub struct QueueCurrentState { 22 + pub item: QueueItemState, 23 + #[napi(js_name = "from_auto_queue")] 24 + pub from_auto_queue: bool, 25 + } 26 + 27 + #[derive(Serialize, Deserialize, Debug, Clone, Default)] 28 + #[napi(object)] 29 + pub struct QueueState { 30 + #[serde(default)] 31 + pub past: Vec<QueueItemState>, 32 + #[serde(default)] 33 + pub current: Option<QueueCurrentState>, 34 + #[serde(default)] 35 + #[napi(js_name = "user_queue")] 36 + pub user_queue: Vec<QueueItemState>, 37 + #[serde(default)] 38 + #[napi(js_name = "auto_queue")] 39 + pub auto_queue: Vec<QueueItemState>, 40 + #[serde(default)] 41 + #[napi(js_name = "last_qid")] 42 + pub last_qid: i64, 43 + #[serde(default)] 44 + pub shuffle: bool, 45 + #[serde(default)] 46 + pub repeat: bool, 47 + } 48 + 49 + #[derive(Serialize, Deserialize, Debug, Clone)] 50 + #[serde(untagged)] 51 + enum DiskAutoQueueItem { 52 + Id(String), 53 + IdAndPos((String, u32)), 54 + } 55 + 56 + #[derive(Serialize, Deserialize, Debug, Clone, Default)] 57 + struct DiskQueueState( 58 + Vec<String>, // past 59 + Option<String>, // current 60 + Vec<String>, // user_queue 61 + Vec<DiskAutoQueueItem>, // auto_queue 62 + bool, // shuffle 63 + bool, // repeat 64 + ); 65 + 66 + impl From<&QueueState> for DiskQueueState { 67 + fn from(value: &QueueState) -> Self { 68 + DiskQueueState( 69 + value.past.iter().map(|item| item.id.clone()).collect(), 70 + value 71 + .current 72 + .as_ref() 73 + .map(|current| current.item.id.clone()), 74 + value 75 + .user_queue 76 + .iter() 77 + .map(|item| item.id.clone()) 78 + .collect(), 79 + value 80 + .auto_queue 81 + .iter() 82 + .map(|item| match item.non_shuffle_pos { 83 + Some(non_shuffle_pos) => { 84 + DiskAutoQueueItem::IdAndPos((item.id.clone(), non_shuffle_pos)) 85 + } 86 + None => DiskAutoQueueItem::Id(item.id.clone()), 87 + }) 88 + .collect(), 89 + value.shuffle, 90 + value.repeat, 91 + ) 92 + } 93 + } 94 + 95 + impl From<DiskQueueState> for QueueState { 96 + fn from(value: DiskQueueState) -> Self { 97 + let DiskQueueState(past_ids, current_id, user_ids, auto_items, shuffle, repeat) = value; 98 + 99 + let mut next_qid: i64 = -1; 100 + let mut new_item = |id: String, non_shuffle_pos: Option<u32>| { 101 + next_qid += 1; 102 + QueueItemState { 103 + q_id: next_qid, 104 + id, 105 + non_shuffle_pos, 106 + } 107 + }; 108 + 109 + let past = past_ids 110 + .into_iter() 111 + .map(|id| new_item(id, None)) 112 + .collect::<Vec<_>>(); 113 + let current = current_id.map(|id| QueueCurrentState { 114 + item: new_item(id, None), 115 + from_auto_queue: false, 116 + }); 117 + let user_queue = user_ids 118 + .into_iter() 119 + .map(|id| new_item(id, None)) 120 + .collect::<Vec<_>>(); 121 + let auto_queue = auto_items 122 + .into_iter() 123 + .map(|item| match item { 124 + DiskAutoQueueItem::Id(id) => new_item(id, None), 125 + DiskAutoQueueItem::IdAndPos((id, non_shuffle_pos)) => { 126 + new_item(id, Some(non_shuffle_pos)) 127 + } 128 + }) 129 + .collect::<Vec<_>>(); 130 + 131 + QueueState { 132 + past, 133 + current, 134 + user_queue, 135 + auto_queue, 136 + last_qid: next_qid, 137 + shuffle, 138 + repeat, 139 + } 140 + } 141 + } 142 + 143 + impl QueueState { 144 + pub fn load(file_path: &str) -> Option<QueueState> { 145 + let bytes = fs::read(file_path).ok()?; 146 + if let Ok(compact) = serde_cbor::from_slice::<DiskQueueState>(&bytes) { 147 + return Some(compact.into()); 148 + } 149 + // Backward compatibility for existing full-struct CBOR 150 + serde_cbor::from_slice::<QueueState>(&bytes).ok() 151 + } 152 + 153 + pub fn save(&self, file_path: &str) -> Result<()> { 154 + let compact: DiskQueueState = self.into(); 155 + let bytes = serde_cbor::to_vec(&compact).context("Error encoding queue.cbor")?; 156 + let af = AtomicFile::new(file_path, AllowOverwrite); 157 + af.write(|f| f.write_all(&bytes)) 158 + .context("Error writing queue.cbor")?; 159 + Ok(()) 160 + } 161 + } 162 + 163 + #[napi(js_name = "load_queue_state")] 164 + #[allow(dead_code)] 165 + pub fn load_queue_state(file_path: String) -> Option<QueueState> { 166 + QueueState::load(&file_path) 167 + } 168 + 169 + #[napi(js_name = "save_queue_state")] 170 + #[allow(dead_code)] 171 + pub async fn save_queue_state(queue_state: QueueState, file_path: String) -> Result<()> { 172 + queue_state.save(&file_path)?; 173 + Ok(()) 174 + }
+2 -1
src/lib/data.ts
··· 9 9 TracksPageOptions, 10 10 ViewOptions, 11 11 } from '../../ferrum-addon' 12 - import { queue } from './queue' 12 + import { init_queue_persistence, queue } from './queue' 13 13 import { current_playlist_id } from '$components/TrackList.svelte' 14 14 import { navigate } from './router' 15 15 import { call_sync, get_error_message, strict_call } from './error' ··· 25 25 strict_call((addon) => addon.load_data(is_dev, local_data_path, library_path)) 26 26 27 27 export const paths = strict_call((addon) => addon.get_paths()) 28 + init_queue_persistence(paths.queueFile) 28 29 export function join_paths(...args: string[]) { 29 30 return args.join(paths.pathSeparator) 30 31 }
+5 -5
src/lib/player.ts
··· 57 57 const current_id = queue.getCurrent()?.id 58 58 return current_id 59 59 }) 60 - playing_id.subscribe((current_id) => { 61 - if (current_id) { 62 - cover_src.newFromTrackId(current_id) 63 - } 64 - }) 65 60 let waiting_to_play = false 66 61 const media_session = navigator.mediaSession 67 62 ··· 112 107 subscribe, 113 108 } 114 109 })() 110 + playing_id.subscribe((current_id) => { 111 + if (current_id) { 112 + cover_src.newFromTrackId(current_id) 113 + } 114 + }) 115 115 116 116 audio.onplay = update_time_details 117 117 audio.onloadeddata = update_time_details
+83
src/lib/queue.ts
··· 3 3 import { getter_writable } from './helpers' 4 4 import { ipc_renderer } from './window' 5 5 import { track_exists } from '$lib/data' 6 + import quit from './quit' 6 7 7 8 export const queue_visible = writable(false) 8 9 export function toggle_queue_visibility() { ··· 57 58 } 58 59 })() 59 60 61 + let queue_file_path = '' 62 + let queue_loaded = false 63 + const MAX_HISTORY = 1000 64 + const SAVE_DEBOUNCE_MS = 1000 65 + let pending_save_timeout: ReturnType<typeof setTimeout> | null = null 66 + 67 + function limit_history(past: QueueItem[]) { 68 + if (past.length <= MAX_HISTORY) return past 69 + return past.slice(past.length - MAX_HISTORY) 70 + } 71 + 72 + function save_queue_state_now() { 73 + if (!queue_loaded) return 74 + return window.addon 75 + .save_queue_state( 76 + { 77 + past: queue.get().past, 78 + current: queue.get().current ?? undefined, 79 + user_queue: queue.get().user_queue, 80 + auto_queue: queue.get().auto_queue, 81 + last_qid, 82 + shuffle: get(shuffle), 83 + repeat: repeat.get(), 84 + }, 85 + queue_file_path, 86 + ) 87 + .catch(() => {}) 88 + } 89 + function save_queue_state() { 90 + if (!queue_loaded) return 91 + if (pending_save_timeout) { 92 + clearTimeout(pending_save_timeout) 93 + } 94 + pending_save_timeout = setTimeout(() => { 95 + pending_save_timeout = null 96 + save_queue_state_now() 97 + }, SAVE_DEBOUNCE_MS) 98 + } 99 + 100 + quit.set_handler('queue-state', async () => { 101 + if (pending_save_timeout) { 102 + clearTimeout(pending_save_timeout) 103 + pending_save_timeout = null 104 + } 105 + await save_queue_state_now() 106 + }) 107 + 108 + export function init_queue_persistence(file_path: string) { 109 + if (queue_loaded) return 110 + queue_file_path = file_path 111 + 112 + const saved = window.addon.load_queue_state(queue_file_path) 113 + if (saved) { 114 + const past = saved.past.filter((item) => track_exists(item.id)) 115 + const current = saved.current && track_exists(saved.current.item.id) ? saved.current : null 116 + const user_queue = saved.user_queue.filter((item) => track_exists(item.id)) 117 + const auto_queue = saved.auto_queue.filter((item) => track_exists(item.id)) 118 + if (current) { 119 + past.push(current.item) 120 + } 121 + const limited_past = limit_history(past) 122 + 123 + last_qid = Math.max( 124 + -1, 125 + saved.last_qid, 126 + ...limited_past.map((item) => item.qId), 127 + ...user_queue.map((item) => item.qId), 128 + ...auto_queue.map((item) => item.qId), 129 + ) 130 + repeat.set(saved.repeat) 131 + shuffle.set(saved.shuffle) 132 + queue.set({ past: limited_past, current: null, user_queue, auto_queue }) 133 + } 134 + 135 + queue.subscribe(save_queue_state) 136 + queue_loaded = true 137 + } 138 + 60 139 /** Fisher-Yates shuffle */ 61 140 function shuffle_array<T>(array: Array<T>) { 62 141 let current_index = array.length, ··· 103 182 shuffle.subscribe(($shuffle) => { 104 183 apply_shuffle($shuffle) 105 184 ipc_renderer.invoke('update:Shuffle', $shuffle) 185 + save_queue_state() 106 186 }) 107 187 ipc_renderer.on('Shuffle', () => { 108 188 shuffle.update((value) => !value) ··· 114 194 }) 115 195 repeat.subscribe(($repeat) => { 116 196 ipc_renderer.invoke('update:Repeat', $repeat) 197 + save_queue_state() 117 198 }) 118 199 119 200 export function get_current() { ··· 250 331 const q = queue.get() 251 332 if (q.current) { 252 333 q.past.push(q.current.item) 334 + q.past = limit_history(q.past) 253 335 } 254 336 if (q.user_queue.length) { 255 337 q.current = { ··· 289 371 queue.update((q) => { 290 372 if (q.current) { 291 373 q.past.push(q.current.item) 374 + q.past = limit_history(q.past) 292 375 } 293 376 q.current = current 294 377 ? {