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

Refactor Paths

Store `Strings` instead of `PathBuf`s, add `view_options_file` prop, and use the paths struct in more places

Kasper (Sep 16, 2025, 4:53 AM +0200) ac7de35b 28b0009c

+78 -91
+6 -4
ferrum-addon/addon.d.ts
··· 2 2 /* eslint-disable */ 3 3 export declare class ItunesImport { 4 4 static new(): ItunesImport 5 - start(path: string, tracksDir: string): Promise<ImportStatus> 5 + start(path: string): Promise<ImportStatus> 6 6 finish(): void 7 7 } 8 8 ··· 53 53 54 54 export declare function get_image(index: number): JsImage | null 55 55 56 - export declare function get_paths(): PathsJs 56 + export declare function get_paths(): Paths 57 57 58 58 export declare function get_track(id: string): Track 59 59 ··· 99 99 100 100 export declare function new_playlist(name: string, description: string, isFolder: boolean, parentId: string): void 101 101 102 - export interface PathsJs { 102 + export interface Paths { 103 + pathSeparator: string 103 104 libraryDir: string 104 105 tracksDir: string 105 106 libraryJson: string 107 + cacheDir: string 106 108 cacheDb: string 107 109 localDataDir: string 108 - pathSeparator: string 110 + viewOptionsFile: string 109 111 } 110 112 111 113 export interface Playlist {
+20 -10
src-native/data.rs
··· 7 7 use serde::Serialize; 8 8 use std::env; 9 9 use std::io::Write; 10 - use std::path::PathBuf; 10 + use std::path::{Path, PathBuf}; 11 11 use std::time::Instant; 12 + 13 + pub fn path_to_string<P: AsRef<Path>>(path: P) -> String { 14 + path.as_ref() 15 + .to_str() 16 + .expect("Invalid path str") 17 + .to_string() 18 + } 12 19 13 20 pub struct Data { 14 21 pub paths: Paths, ··· 62 69 .context("Local data folder not found")? 63 70 .join("space.kasper.ferrum"); 64 71 }; 72 + let local_data_dir = match local_data_path { 73 + Some(path) => PathBuf::from(path), 74 + None => local_data_dir, 75 + }; 65 76 if let Some(library_path) = library_path { 66 77 library_dir = PathBuf::from(library_path); 67 78 } 68 79 let paths = Paths { 69 - library_dir: library_dir.clone(), 70 - tracks_dir: library_dir.join("Tracks"), 71 - library_json: library_dir.join("Library.json"), 72 - cache_dir: cache_dir.clone(), 73 - cache_db: cache_dir.join("Cache.redb"), 74 - local_data_dir: match local_data_path { 75 - Some(path) => PathBuf::from(path), 76 - None => local_data_dir, 77 - }, 80 + path_separator: std::path::MAIN_SEPARATOR_STR.into(), 81 + library_dir: path_to_string(&library_dir), 82 + tracks_dir: path_to_string(library_dir.join("Tracks")), 83 + library_json: path_to_string(library_dir.join("Library.json")), 84 + cache_dir: path_to_string(&cache_dir), 85 + cache_db: path_to_string(cache_dir.join("Cache.redb")), 86 + local_data_dir: path_to_string(&local_data_dir), 87 + view_options_file: path_to_string(local_data_dir.join("view.json")), 78 88 }; 79 89 80 90 let loaded_library = load_library(&paths)?;
+4 -20
src-native/data_js.rs
··· 1 1 use crate::FerrumStatus; 2 2 use crate::data::Data; 3 + use crate::library::Paths; 3 4 use anyhow::Result; 4 5 use napi::Env; 5 6 use rfd::MessageDialog; ··· 56 57 return Ok(()); 57 58 } 58 59 59 - #[napi(object)] 60 - pub struct PathsJs { 61 - pub library_dir: String, 62 - pub tracks_dir: String, 63 - pub library_json: String, 64 - pub cache_db: String, 65 - pub local_data_dir: String, 66 - pub path_separator: String, 67 - } 68 - 69 60 #[napi(js_name = "get_paths")] 70 61 #[allow(dead_code)] 71 - pub fn get_paths(env: Env) -> PathsJs { 72 - let data: &mut Data = get_data(&env); 73 - PathsJs { 74 - library_dir: data.paths.library_dir.to_string_lossy().into(), 75 - tracks_dir: data.paths.tracks_dir.to_string_lossy().into(), 76 - library_json: data.paths.library_json.to_string_lossy().into(), 77 - cache_db: data.paths.cache_db.to_string_lossy().into(), 78 - local_data_dir: data.paths.local_data_dir.to_string_lossy().into(), 79 - path_separator: std::path::MAIN_SEPARATOR_STR.into(), 80 - } 62 + pub fn get_paths(env: Env) -> Paths { 63 + let data: &Data = get_data(&env); 64 + data.paths.clone() 81 65 } 82 66 83 67 #[napi(js_name = "save")]
+11 -16
src-native/itunes_import.rs
··· 1 1 use crate::data_js::get_data; 2 2 use crate::get_now_timestamp; 3 + use crate::library::Paths; 3 4 use crate::library_types::{ 4 5 CountObject, Folder, Library, Playlist, Track, TrackList, new_item_ids_from_track_ids, 5 6 }; ··· 11 12 use serde::{Deserialize, Serialize}; 12 13 use std::collections::HashMap; 13 14 use std::fs; 14 - use std::path::{Path, PathBuf}; 15 + use std::path::PathBuf; 15 16 use std::sync::Mutex; 16 17 use time::OffsetDateTime; 17 18 use time::serde::iso8601; ··· 319 320 } 320 321 321 322 /// Parses track but does not move it to `tracks_dir` 322 - fn parse_track( 323 - xml_track: XmlTrack, 324 - start_time: i64, 325 - tracks_dir: &Path, 326 - ) -> Result<(PathBuf, Track)> { 323 + fn parse_track(xml_track: XmlTrack, start_time: i64, paths: &Paths) -> Result<(PathBuf, Track)> { 327 324 let xml_location = xml_track.location.context("Missing track location")?; 328 325 if xml_track.track_type != Some("File".to_string()) { 329 326 bail!( ··· 358 355 359 356 let name = xml_track.name.unwrap_or_default(); 360 357 let artist = xml_track.artist.unwrap_or_default(); 361 - let filename = generate_filename(tracks_dir, &artist, &name, file_type.file_extension()); 358 + let filename = generate_filename(&paths, &artist, &name, file_type.file_extension()); 362 359 363 360 let track = Track { 364 361 size: file_md.len() as i64, ··· 584 581 new_library: Mutex<Option<Library>>, 585 582 /// iTunes path -> Ferrum file 586 583 itunes_track_paths: Mutex<HashMap<PathBuf, String>>, 584 + paths: Paths, 587 585 } 588 586 #[napi] 589 587 impl ItunesImport { ··· 593 591 Ok(Self { 594 592 new_library: Some(data.library.clone()).into(), 595 593 itunes_track_paths: HashMap::new().into(), 594 + paths: data.paths.clone(), 596 595 }) 597 596 } 598 597 #[napi] 599 - pub async fn start(&self, path: String, tracks_dir: String) -> napi::Result<ImportStatus> { 600 - Ok(import_itunes(self, path, tracks_dir).await?) 598 + pub async fn start(&self, path: String) -> napi::Result<ImportStatus> { 599 + Ok(import_itunes(self, path).await?) 601 600 } 602 601 #[napi] 603 602 pub fn finish(&mut self, env: Env) -> napi::Result<()> { 604 603 let data = get_data(&env); 605 604 let itunes_track_paths = &mut *self.itunes_track_paths.lock().unwrap(); 606 605 for (itunes_path, ferrum_file) in itunes_track_paths { 607 - let new_path = data.paths.tracks_dir.join(ferrum_file); 606 + let new_path = data.paths.get_track_file_path(ferrum_file); 608 607 fs::copy(itunes_path, new_path).context("Error copying file")?; 609 608 } 610 609 let new_library = &mut self.new_library.lock().unwrap(); ··· 613 612 } 614 613 } 615 614 616 - pub async fn import_itunes( 617 - itunes_import: &ItunesImport, 618 - path: String, 619 - tracks_dir: String, 620 - ) -> Result<ImportStatus> { 615 + async fn import_itunes(itunes_import: &ItunesImport, path: String) -> Result<ImportStatus> { 621 616 let new_library_lock = &mut *itunes_import.new_library.lock().unwrap(); 622 617 let mut library = match new_library_lock { 623 618 Some(library) => library, ··· 663 658 errors.push(format!("Missing track artist: {artist_title}")); 664 659 } 665 660 666 - match parse_track(xml_track, start_time, Path::new(&tracks_dir)) { 661 + match parse_track(xml_track, start_time, &itunes_import.paths) { 667 662 Ok((xml_track_path, track)) => { 668 663 let generated_id = library.generate_id(); 669 664 // immediately insert into library so new generated ids are unique
+1 -2
src-native/lib.rs
··· 2 2 use serde::de::DeserializeOwned; 3 3 use std::fs::File; 4 4 use std::io::BufReader; 5 - use std::path::PathBuf; 6 5 use std::time::{SystemTime, UNIX_EPOCH}; 7 6 8 7 #[macro_use] ··· 58 57 } 59 58 } 60 59 61 - fn path_to_json<J>(path: PathBuf) -> Result<J> 60 + fn path_to_json<J>(path: &str) -> Result<J> 62 61 where 63 62 J: DeserializeOwned, 64 63 {
+14 -12
src-native/library.rs
··· 3 3 use crate::library_types::{Library, VersionedLibrary}; 4 4 use anyhow::{Context, Result, bail}; 5 5 use napi::Env; 6 - use serde::{Deserialize, Serialize}; 7 6 use serde_json::{Value, json}; 8 7 use std::fs::{File, create_dir_all}; 9 8 use std::io::{ErrorKind, Read}; 10 9 use std::path::PathBuf; 11 10 use std::time::Instant; 12 11 13 - #[derive(Serialize, Deserialize)] 12 + #[derive(Clone)] 13 + #[napi(object)] 14 14 pub struct Paths { 15 - pub library_dir: PathBuf, 16 - pub tracks_dir: PathBuf, 17 - pub library_json: PathBuf, 18 - pub cache_dir: PathBuf, 19 - pub cache_db: PathBuf, 20 - pub local_data_dir: PathBuf, 15 + pub path_separator: String, 16 + pub library_dir: String, 17 + pub tracks_dir: String, 18 + pub library_json: String, 19 + pub cache_dir: String, 20 + pub cache_db: String, 21 + pub local_data_dir: String, 22 + pub view_options_file: String, 21 23 } 22 24 impl Paths { 23 25 fn ensure_dirs_exists(&self) -> Result<()> { ··· 27 29 create_dir_all(&self.cache_dir)?; 28 30 return Ok(()); 29 31 } 32 + pub fn get_track_file_path(&self, file: &str) -> PathBuf { 33 + PathBuf::from(&self.tracks_dir).join(file) 34 + } 30 35 } 31 36 32 37 pub fn load_library(paths: &Paths) -> Result<Library> { ··· 35 40 paths 36 41 .ensure_dirs_exists() 37 42 .context("Error ensuring folder exists")?; 38 - println!( 39 - "Loading library at path: {}", 40 - paths.library_dir.to_string_lossy() 41 - ); 43 + println!("Loading library at path: {}", paths.library_dir); 42 44 43 45 let mut library_file = match File::open(&paths.library_json) { 44 46 Ok(file) => file,
+3 -7
src-native/library_types.rs
··· 1 1 #![allow(non_snake_case)] 2 2 3 + use crate::library::Paths; 3 4 use crate::playlists::{delete_file, remove_from_all_playlists}; 4 5 use crate::{FerrumStatus, get_now_timestamp}; 5 6 use anyhow::{Context, Result, bail}; ··· 8 9 use serde::{Deserialize, Serialize}; 9 10 use std::borrow::Cow; 10 11 use std::collections::HashSet; 11 - use std::path::PathBuf; 12 12 use std::sync::RwLock; 13 13 use std::time::Instant; 14 14 ··· 138 138 track_id_map.push(id.clone()); 139 139 self.track_item_ids.insert(id, item_id); 140 140 } 141 - pub fn delete_track_and_file( 142 - &mut self, 143 - id: &TrackID, 144 - tracks_dir: &PathBuf, 145 - ) -> Result<FerrumStatus> { 141 + pub fn delete_track_and_file(&mut self, id: &TrackID, paths: &Paths) -> Result<FerrumStatus> { 146 142 let file_path = { 147 143 let track = self.get_track(id)?; 148 - tracks_dir.join(&track.file) 144 + paths.get_track_file_path(&track.file) 149 145 }; 150 146 if !file_path.exists() { 151 147 return Ok(FerrumStatus::FileDeletionError(format!(
+1 -1
src-native/playlists.rs
··· 221 221 let library = &mut data.library; 222 222 let track_ids = get_track_ids_from_item_ids(&item_ids); 223 223 for track_id in &track_ids { 224 - let status = library.delete_track_and_file(track_id, &data.paths.tracks_dir)?; 224 + let status = library.delete_track_and_file(track_id, &data.paths)?; 225 225 if status.is_err() { 226 226 return Ok(status); 227 227 }
+3 -4
src-native/tracks/import.rs
··· 2 2 use crate::library_types::Track; 3 3 use crate::sys_time_to_timestamp; 4 4 use crate::tracks::generate_filename; 5 - use anyhow::{bail, Context, Result}; 5 + use anyhow::{Context, Result, bail}; 6 6 use lofty::file::{AudioFile, TaggedFileExt}; 7 7 use lofty::tag::{Accessor, ItemKey, TagExt}; 8 8 use std::fs; ··· 94 94 }; 95 95 let artist = tag.artist().map(|s| s.into_owned()).unwrap_or_default(); 96 96 97 - let tracks_dir = &data.paths.tracks_dir; 98 97 let extension = match FileType::from_path(track_path)? { 99 98 FileType::Opus => "opus", 100 99 FileType::M4a => "m4a", 101 100 FileType::Mp3 => "mp3", 102 101 }; 103 - let filename = generate_filename(tracks_dir, &artist, &title, extension); 104 - let dest_path = tracks_dir.join(&filename); 102 + let filename = generate_filename(&data.paths, &artist, &title, extension); 103 + let dest_path = data.paths.get_track_file_path(&filename); 105 104 106 105 fs::copy(track_path, &dest_path).context("Error copying file")?; 107 106 println!(
+5 -5
src-native/tracks/md.rs
··· 1 1 #![allow(non_snake_case)] 2 2 3 3 use super::{Tag, generate_filename}; 4 + use crate::library::Paths; 4 5 use crate::library_types::Track; 5 6 use crate::{get_now_timestamp, str_to_option}; 6 7 use anyhow::{Context, Result, bail}; 7 8 use serde::{Deserialize, Serialize}; 8 9 use std::fs; 9 - use std::path::PathBuf; 10 10 11 11 #[derive(Serialize, Deserialize, Debug)] 12 12 #[napi(object)] ··· 32 32 } 33 33 34 34 pub fn update_track_info( 35 - tracks_dir: &PathBuf, 35 + paths: &Paths, 36 36 track: &mut Track, 37 37 tag: &mut Tag, 38 38 new_info: TrackMD, 39 39 ) -> Result<()> { 40 - let old_path = tracks_dir.join(&track.file); 40 + let old_path = paths.get_track_file_path(&track.file); 41 41 if !old_path.exists() { 42 42 bail!("File does not exist: {}", track.file); 43 43 } ··· 146 146 147 147 // move file 148 148 if new_name != track.name || new_artist != track.artist { 149 - let new_filename = generate_filename(tracks_dir, &new_artist, &new_name, &ext); 150 - let new_path = tracks_dir.join(&new_filename); 149 + let new_filename = generate_filename(&paths, &new_artist, &new_name, &ext); 150 + let new_path = paths.get_track_file_path(&new_filename); 151 151 match fs::rename(old_path, new_path) { 152 152 Ok(_) => { 153 153 track.file = new_filename;
+6 -5
src-native/tracks/mod.rs
··· 1 1 use crate::data::Data; 2 2 use crate::data_js::get_data; 3 3 use crate::get_now_timestamp; 4 + use crate::library::Paths; 4 5 use crate::library_types::{ItemId, MsSinceUnixEpoch, TRACK_ID_MAP, Track, TrackID}; 5 6 use anyhow::{Context, Result, bail}; 6 7 use napi::Env; ··· 126 127 return string; 127 128 } 128 129 129 - pub fn generate_filename(dest_dir: &Path, artist: &str, title: &str, ext: &str) -> String { 130 + pub fn generate_filename(paths: &Paths, artist: &str, title: &str, ext: &str) -> String { 130 131 let beginning = artist.to_owned() + " - " + title; 131 132 let beginning = sanitize_filename(&beginning); 132 133 ··· 136 137 if i == 1000 { 137 138 panic!("Already got 500 files with that artist and title") 138 139 } 139 - let full_path = dest_dir.join(&filename); 140 + let full_path = paths.get_track_file_path(&filename); 140 141 if full_path.exists() { 141 142 file_num += 1; 142 143 filename = beginning.clone() + " " + file_num.to_string().as_str() + "." + ext; ··· 164 165 data.current_tag = None; 165 166 let track = id_to_track(&env, &track_id).context("Could not load tags")?; 166 167 167 - let path = data.paths.tracks_dir.join(&track.file); 168 + let path = data.paths.get_track_file_path(&track.file); 168 169 let tag = Tag::read_from_path(&path).context("Could not load tags")?; 169 170 data.current_tag = Some(tag); 170 171 Ok(()) ··· 208 209 #[allow(dead_code)] 209 210 pub fn set_image(index: u32, path_str: String, env: Env) -> Result<()> { 210 211 let data: &mut Data = get_data(&env); 211 - let path = data.paths.tracks_dir.join(path_str); 212 + let path = data.paths.get_track_file_path(&path_str); 212 213 let tag = match &mut data.current_tag { 213 214 Some(tag) => tag, 214 215 None => bail!("No tag loaded"), ··· 252 253 Some(tag) => tag, 253 254 None => bail!("No tag loaded"), 254 255 }; 255 - md::update_track_info(&data.paths.tracks_dir, track, tag, info)?; 256 + md::update_track_info(&data.paths, track, tag, info)?; 256 257 257 258 Ok(()) 258 259 }
+2 -3
src-native/view_options.rs
··· 22 22 } 23 23 impl ViewOptions { 24 24 pub fn load(paths: &Paths) -> ViewOptions { 25 - match path_to_json(paths.local_data_dir.join("view.json")) { 25 + match path_to_json(&paths.view_options_file) { 26 26 Ok(view_cache) => view_cache, 27 27 Err(_) => ViewOptions { 28 28 shown_playlist_folders: Vec::new(), ··· 34 34 } 35 35 pub fn save(&self, paths: &Paths) -> Result<()> { 36 36 let json_str = serde_json::to_string(self).context("Error saving view.json")?; 37 - let file_path = paths.local_data_dir.join("view.json"); 38 - let af = AtomicFile::new(file_path, AllowOverwrite); 37 + let af = AtomicFile::new(&paths.view_options_file, AllowOverwrite); 39 38 af.write(|f| f.write_all(json_str.as_bytes())) 40 39 .context("Error writing view.json")?; 41 40 Ok(())
+2 -2
src/components/ItunesImport.svelte
··· 1 1 <script lang="ts"> 2 - import { ItunesImport, paths, tracklist_updated, track_lists_details_map, save } from '@/lib/data' 2 + import { ItunesImport, tracklist_updated, track_lists_details_map, save } from '@/lib/data' 3 3 import { ipc_renderer } from '@/lib/window' 4 4 import type { ImportStatus } from 'ferrum-addon/addon' 5 5 import Button from './Button.svelte' ··· 29 29 if (!open.canceled && open.filePaths[0]) { 30 30 stage = 'scanning' 31 31 const file_path = open.filePaths[0] 32 - stage = await call(() => itunes_import.start(file_path, paths.tracksDir)) 32 + stage = await call(() => itunes_import.start(file_path)) 33 33 } else { 34 34 stage = 'select' 35 35 }