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

Work on porting itunes import to rust

Kasper (Oct 17, 2022, 12:07 PM +0200) 21b5e66a ecc79454

+1298 -133
+126 -1
Cargo.lock
··· 88 88 "js-sys", 89 89 "num-integer", 90 90 "num-traits", 91 - "time", 91 + "time 0.1.44", 92 92 "wasm-bindgen", 93 93 "winapi", 94 94 ] ··· 170 170 "napi", 171 171 "napi-build", 172 172 "napi-derive", 173 + "plist", 173 174 "serde", 174 175 "serde_json", 175 176 "serde_repr", 177 + "time 0.3.15", 178 + "tokio", 176 179 "trash", 180 + "url", 177 181 ] 178 182 179 183 [[package]] ··· 207 211 ] 208 212 209 213 [[package]] 214 + name = "hashbrown" 215 + version = "0.12.3" 216 + source = "registry+https://github.com/rust-lang/crates.io-index" 217 + checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 218 + 219 + [[package]] 220 + name = "hermit-abi" 221 + version = "0.1.19" 222 + source = "registry+https://github.com/rust-lang/crates.io-index" 223 + checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 224 + dependencies = [ 225 + "libc", 226 + ] 227 + 228 + [[package]] 210 229 name = "iana-time-zone" 211 230 version = "0.1.48" 212 231 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 239 258 dependencies = [ 240 259 "unicode-bidi", 241 260 "unicode-normalization", 261 + ] 262 + 263 + [[package]] 264 + name = "indexmap" 265 + version = "1.9.1" 266 + source = "registry+https://github.com/rust-lang/crates.io-index" 267 + checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" 268 + dependencies = [ 269 + "autocfg", 270 + "hashbrown", 242 271 ] 243 272 244 273 [[package]] ··· 288 317 ] 289 318 290 319 [[package]] 320 + name = "line-wrap" 321 + version = "0.1.1" 322 + source = "registry+https://github.com/rust-lang/crates.io-index" 323 + checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" 324 + dependencies = [ 325 + "safemem", 326 + ] 327 + 328 + [[package]] 291 329 name = "linked-hash-map" 292 330 version = "0.5.6" 293 331 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 388 426 "serde", 389 427 "serde_json", 390 428 "thread_local", 429 + "tokio", 391 430 ] 392 431 393 432 [[package]] ··· 452 491 ] 453 492 454 493 [[package]] 494 + name = "num_cpus" 495 + version = "1.13.1" 496 + source = "registry+https://github.com/rust-lang/crates.io-index" 497 + checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" 498 + dependencies = [ 499 + "hermit-abi", 500 + "libc", 501 + ] 502 + 503 + [[package]] 504 + name = "num_threads" 505 + version = "0.1.6" 506 + source = "registry+https://github.com/rust-lang/crates.io-index" 507 + checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" 508 + dependencies = [ 509 + "libc", 510 + ] 511 + 512 + [[package]] 455 513 name = "objc" 456 514 version = "0.2.7" 457 515 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 488 546 checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" 489 547 490 548 [[package]] 549 + name = "pin-project-lite" 550 + version = "0.2.9" 551 + source = "registry+https://github.com/rust-lang/crates.io-index" 552 + checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" 553 + 554 + [[package]] 555 + name = "plist" 556 + version = "1.3.1" 557 + source = "registry+https://github.com/rust-lang/crates.io-index" 558 + checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225" 559 + dependencies = [ 560 + "base64", 561 + "indexmap", 562 + "line-wrap", 563 + "serde", 564 + "time 0.3.15", 565 + "xml-rs", 566 + ] 567 + 568 + [[package]] 491 569 name = "ppv-lite86" 492 570 version = "0.2.16" 493 571 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 594 672 checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" 595 673 596 674 [[package]] 675 + name = "safemem" 676 + version = "0.3.3" 677 + source = "registry+https://github.com/rust-lang/crates.io-index" 678 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 679 + 680 + [[package]] 597 681 name = "scopeguard" 598 682 version = "1.1.0" 599 683 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 707 791 ] 708 792 709 793 [[package]] 794 + name = "time" 795 + version = "0.3.15" 796 + source = "registry+https://github.com/rust-lang/crates.io-index" 797 + checksum = "d634a985c4d4238ec39cacaed2e7ae552fbd3c476b552c1deac3021b7d7eaf0c" 798 + dependencies = [ 799 + "itoa", 800 + "libc", 801 + "num_threads", 802 + "serde", 803 + ] 804 + 805 + [[package]] 710 806 name = "tinyvec" 711 807 version = "1.6.0" 712 808 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 720 816 version = "0.1.0" 721 817 source = "registry+https://github.com/rust-lang/crates.io-index" 722 818 checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" 819 + 820 + [[package]] 821 + name = "tokio" 822 + version = "1.21.2" 823 + source = "registry+https://github.com/rust-lang/crates.io-index" 824 + checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099" 825 + dependencies = [ 826 + "autocfg", 827 + "num_cpus", 828 + "pin-project-lite", 829 + "tokio-macros", 830 + ] 831 + 832 + [[package]] 833 + name = "tokio-macros" 834 + version = "1.8.0" 835 + source = "registry+https://github.com/rust-lang/crates.io-index" 836 + checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" 837 + dependencies = [ 838 + "proc-macro2", 839 + "quote", 840 + "syn", 841 + ] 723 842 724 843 [[package]] 725 844 name = "trash" ··· 899 1018 version = "0.37.0" 900 1019 source = "registry+https://github.com/rust-lang/crates.io-index" 901 1020 checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" 1021 + 1022 + [[package]] 1023 + name = "xml-rs" 1024 + version = "0.8.4" 1025 + source = "registry+https://github.com/rust-lang/crates.io-index" 1026 + checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
+10 -1
Cargo.toml
··· 14 14 napi-build = "2.0" 15 15 16 16 [dependencies] 17 - napi = { version = "2.9", features = ["serde-json", "napi6"] } 17 + napi = { version = "2.9", features = ["serde-json", "napi6", "tokio_rt"] } 18 18 napi-derive = "2.9" 19 19 dirs = "4.0" 20 20 serde = { version = "1.0", features = ["derive"] } ··· 30 30 atomicwrites = "0.3" 31 31 trash = "2.0" 32 32 base64 = "0.13" 33 + plist = "1.3" 34 + tokio = { version = "1.21", features = ["macros"] } 35 + time = { version = "0.3", features = ["serde", "serde-well-known"] } 36 + url = "2.3" 37 + 38 + [profile.dev] 39 + panic = "abort" 33 40 34 41 [profile.release] 42 + panic = "abort" 43 + codegen-units = 1 35 44 lto = true
+11 -10
ferrum-addon/addon.d.ts
··· 3 3 4 4 /* auto-generated by NAPI-RS */ 5 5 6 + export function load_data(isDev: boolean): void 7 + export interface PathsJs { 8 + libraryDir: string 9 + tracksDir: string 10 + libraryJson: string 11 + localDataDir: string 12 + } 13 + export function get_paths(): PathsJs 14 + export function save(): void 15 + export function filter_open_playlist(query: string): void 6 16 export function copyFile(from: string, to: string): void 7 17 export function atomicFileSave(filePath: string, content: string): void 18 + export function import_itunes(path: string): Promise<void> 8 19 export interface Track { 9 20 size: number 10 21 duration: number ··· 89 100 export const enum SpecialTrackListName { 90 101 Root = 0 91 102 } 92 - export function load_data(isDev: boolean): void 93 - export interface PathsJs { 94 - libraryDir: string 95 - tracksDir: string 96 - libraryJson: string 97 - localDataDir: string 98 - } 99 - export function get_paths(): PathsJs 100 - export function save(): void 101 - export function filter_open_playlist(query: string): void 102 103 export function open_playlist(openPlaylistId: string): void 103 104 export function get_page_track(index: number): Track 104 105 export function get_page_track_id(index: number): string
+7 -7
src-native/lib.rs
··· 19 19 #[macro_use] 20 20 extern crate napi_derive; 21 21 22 - mod js; 23 - mod library; 24 - mod library_types; 25 - mod sort; 26 - 27 22 mod data; 28 23 mod data_js; 29 24 mod filter; 25 + mod js; 26 + mod library; 27 + mod library_import; 28 + mod library_types; 30 29 mod page; 31 30 mod playlists; 32 31 mod sidebar_view; 32 + mod sort; 33 33 mod tracks; 34 34 35 35 fn get_now_timestamp() -> i64 { 36 36 let timestamp = match SystemTime::now().duration_since(UNIX_EPOCH) { 37 37 Ok(n) => n.as_millis() as i64, 38 - Err(_) => panic!("Generated timestamp is earlier than Unix Epoch"), 38 + Err(err) => err.duration().as_millis() as i64, 39 39 }; 40 40 return timestamp; 41 41 } ··· 43 43 fn sys_time_to_timestamp(sys_time: &SystemTime) -> i64 { 44 44 let timestamp = match sys_time.duration_since(UNIX_EPOCH) { 45 45 Ok(n) => n.as_millis() as i64, 46 - Err(_) => panic!("Timestamp is earlier than Unix Epoch"), 46 + Err(err) => err.duration().as_millis() as i64, 47 47 }; 48 48 return timestamp; 49 49 }
+647
src-native/library_import.rs
··· 1 + use crate::get_now_timestamp; 2 + use crate::library_types::{CountObject, Folder, Library, Playlist, Track, TrackList}; 3 + use crate::tracks::generate_filename; 4 + use crate::tracks::import::{read_file_metadata, FileType}; 5 + use lofty::AudioFile; 6 + use napi::Result; 7 + use serde::{Deserialize, Serialize}; 8 + use std::collections::hash_map::Entry; 9 + use std::collections::HashMap; 10 + use std::path::{Path, PathBuf}; 11 + use time::serde::iso8601; 12 + use time::serde::iso8601::option as iso8601_opt; 13 + use time::OffsetDateTime; 14 + 15 + #[derive(Serialize, Deserialize, Debug)] 16 + struct XmlLibrary { 17 + #[serde(rename = "Major Version")] 18 + major_version: u16, 19 + 20 + #[serde(rename = "Minor Version")] 21 + minor_version: u16, 22 + 23 + #[serde(rename = "Date")] 24 + date: String, // 2022-10-14T05:12:37Z 25 + 26 + #[serde(rename = "Application Version")] 27 + application_version: String, 28 + 29 + #[serde(rename = "Features")] 30 + features: u16, 31 + 32 + #[serde(rename = "Show Content Ratings")] 33 + show_content_ratings: bool, 34 + 35 + #[serde(rename = "Music Folder")] 36 + music_folder: String, 37 + 38 + #[serde(rename = "Library Persistent ID")] 39 + library_persistent_id: String, 40 + 41 + #[serde(rename = "Tracks")] 42 + tracks: HashMap<String, XmlTrack>, 43 + 44 + #[serde(rename = "Playlists")] 45 + playlists: Vec<XmlPlaylist>, 46 + } 47 + impl XmlLibrary { 48 + fn get_music_playlist(playlists: &Vec<XmlPlaylist>) -> Result<&XmlPlaylist> { 49 + let mut xml_music_playlist = None; 50 + for xml_playlist in playlists { 51 + if xml_playlist.distinguished_kind == Some(4) { 52 + if xml_music_playlist.is_some() { 53 + throw!("Found two iTunes-generated Music playlists"); 54 + } else { 55 + xml_music_playlist = Some(xml_playlist); 56 + } 57 + } 58 + } 59 + xml_music_playlist.ok_or(nerr!("No Music playlist found")) 60 + } 61 + fn take_importable_playlists(&mut self) -> Vec<XmlPlaylist> { 62 + let playlists = std::mem::take(&mut self.playlists); 63 + let (importable, remaining) = playlists 64 + .into_iter() 65 + .partition(|xml_playlist| xml_playlist.is_importable_playlist()); 66 + self.playlists = remaining; 67 + importable 68 + } 69 + } 70 + 71 + fn get_none<T>() -> Option<T> { 72 + None 73 + } 74 + 75 + #[derive(Serialize, Deserialize, Debug)] 76 + struct XmlTrack { 77 + // Artist 78 + #[serde(rename = "Artist")] 79 + artist: Option<String>, 80 + 81 + // Name 82 + #[serde(rename = "Name")] 83 + name: Option<String>, 84 + 85 + // Composer 86 + #[serde(rename = "Composer")] 87 + composer: Option<String>, 88 + 89 + // Sort Name 90 + #[serde(rename = "Sort Name")] 91 + sort_name: Option<String>, 92 + 93 + // Sort Artist 94 + #[serde(rename = "Sort Artist")] 95 + sort_artist: Option<String>, 96 + 97 + // Sort Composer 98 + #[serde(rename = "Sort Composer")] 99 + sort_composer: Option<String>, 100 + 101 + // Genre 102 + #[serde(rename = "Genre")] 103 + genre: Option<String>, 104 + 105 + // Size 106 + #[serde(rename = "Size")] 107 + size: Option<u64>, 108 + 109 + // Rating 110 + #[serde(rename = "Rating")] 111 + rating: Option<u8>, 112 + 113 + // Year 114 + #[serde(rename = "Year")] 115 + year: Option<i64>, 116 + 117 + // BPM 118 + #[serde(rename = "BPM")] 119 + bpm: Option<u32>, 120 + 121 + // Date Modified 122 + #[serde(with = "iso8601", rename = "Date Modified")] 123 + date_modified: OffsetDateTime, 124 + 125 + // Date Added 126 + #[serde(with = "iso8601", rename = "Date Added")] 127 + date_added: OffsetDateTime, 128 + 129 + // Comments 130 + #[serde(rename = "Comments")] 131 + comments: Option<String>, 132 + 133 + // Grouping 134 + #[serde(rename = "Grouping")] 135 + grouping: Option<String>, 136 + 137 + // Play Date 138 + #[serde(rename = "Play Date")] 139 + play_date: Option<u64>, 140 + 141 + // Play Date UTC 142 + #[serde(with = "iso8601_opt", default = "get_none", rename = "Play Date UTC")] 143 + play_date_utc: Option<OffsetDateTime>, 144 + 145 + // Play Count 146 + #[serde(rename = "Play Count")] 147 + play_count: Option<u32>, 148 + 149 + // Skip Date 150 + #[serde(with = "iso8601_opt", default = "get_none", rename = "Skip Date")] 151 + skip_date: Option<OffsetDateTime>, 152 + 153 + // Skip Count 154 + #[serde(rename = "Skip Count")] 155 + skip_count: Option<u32>, 156 + 157 + // Volume Adjustment (-255 to 255) 158 + #[serde(rename = "Volume Adjustment")] 159 + volume_adjustment: Option<i16>, 160 + 161 + // Loved 162 + #[serde(rename = "Loved")] 163 + loved: Option<bool>, 164 + 165 + // Disliked 166 + #[serde(rename = "Disliked")] 167 + disliked: Option<bool>, 168 + 169 + // Disabled 170 + #[serde(rename = "Disabled")] 171 + disabled: Option<bool>, 172 + 173 + // Compilation 174 + #[serde(rename = "Compilation")] 175 + compilation: Option<bool>, 176 + 177 + // Album 178 + #[serde(rename = "Album")] 179 + album: Option<String>, 180 + 181 + // Album Artist 182 + #[serde(rename = "Album Artist")] 183 + album_artist: Option<String>, 184 + 185 + // Sort Album 186 + #[serde(rename = "Sort Album")] 187 + sort_album: Option<String>, 188 + 189 + // Sort Album Artist 190 + #[serde(rename = "Sort Album Artist")] 191 + sort_album_artist: Option<String>, 192 + 193 + // Track Number 194 + #[serde(rename = "Track Number")] 195 + track_number: Option<u32>, 196 + 197 + // Track Count 198 + #[serde(rename = "Track Count")] 199 + track_count: Option<u32>, 200 + 201 + // Disc Number 202 + #[serde(rename = "Disc Number")] 203 + disc_number: Option<u32>, 204 + 205 + // Disc Count 206 + #[serde(rename = "Disc Count")] 207 + disc_count: Option<u32>, 208 + 209 + // Persistent ID 210 + #[serde(rename = "Persistent ID")] 211 + persistent_id: String, 212 + 213 + // Track Type 214 + #[serde(rename = "Track Type")] 215 + track_type: Option<String>, 216 + 217 + // Location 218 + #[serde(rename = "Location")] 219 + location: Option<String>, 220 + } 221 + impl XmlTrack { 222 + fn artist_title(&self) -> String { 223 + self.artist.as_deref().unwrap_or_default().to_string() 224 + + self.name.as_deref().unwrap_or_default() 225 + } 226 + } 227 + 228 + fn datetime_to_timestamp_millis(datetime: OffsetDateTime) -> i64 { 229 + datetime.unix_timestamp() * 1000 + datetime.millisecond() as i64 230 + } 231 + fn keep_true(value: Option<bool>) -> Option<bool> { 232 + value.filter(|v| *v == true) 233 + } 234 + fn keep_filled(value: Option<String>) -> Option<String> { 235 + value.filter(|v| v != "") 236 + } 237 + 238 + struct CountInfo { 239 + count: Option<u32>, 240 + imported: Option<u32>, 241 + dates: Option<Vec<i64>>, 242 + } 243 + impl CountInfo { 244 + fn new(count: Option<u32>, date: Option<OffsetDateTime>) -> CountInfo { 245 + let mut imported_count = count.unwrap_or(0); 246 + if imported_count == 0 { 247 + return CountInfo { 248 + count, 249 + imported: None, 250 + dates: None, 251 + }; 252 + } 253 + // If we have a date (like play_date), add a play for it 254 + let dates = date.map(|date| { 255 + imported_count -= 1; 256 + vec![datetime_to_timestamp_millis(date)] 257 + }); 258 + CountInfo { 259 + count, 260 + imported: Some(imported_count), 261 + dates, 262 + } 263 + } 264 + } 265 + 266 + fn parse_file_url(value: &str) -> Result<PathBuf> { 267 + let file_url = match url::Url::parse(value) { 268 + Ok(url) => url, 269 + Err(e) => throw!("Invalid track location: {}", e), 270 + }; 271 + match file_url.scheme() { 272 + "file" => {} 273 + _ => throw!("Invalid track location scheme: {}", value), 274 + } 275 + match file_url.to_file_path() { 276 + Ok(path) => Ok(path), 277 + Err(()) => throw!("Invalid track location host: {}", value), 278 + } 279 + } 280 + 281 + /// Parses track but does not move it to `tracks_dir` 282 + fn parse_track(xml_track: XmlTrack, start_time: i64, tracks_dir: &Path) -> Result<Track> { 283 + let xml_location = match xml_track.location { 284 + Some(ref location) => location, 285 + None => throw!("Missing track location"), 286 + }; 287 + if xml_track.track_type != Some("File".to_string()) { 288 + return Err(nerr!( 289 + "Track with type {}, expected \"File\"", 290 + xml_track.track_type.as_deref().unwrap_or("unknown"), 291 + )); 292 + } 293 + 294 + // Unlike "Skip Date" etc, "Play Date" is a non-UTC Mac HFS+ timestamp, but 295 + // luckily "Play Date UTC" is a normal date. 296 + let play = CountInfo::new(xml_track.play_count, xml_track.play_date_utc); 297 + 298 + let skip = CountInfo::new(xml_track.skip_count, xml_track.skip_date); 299 + 300 + let xml_track_path = parse_file_url(xml_location)?; 301 + 302 + // this will also checks if the file exists 303 + let file_md = read_file_metadata(&xml_track_path)?; 304 + 305 + let tagged_file = match lofty::read_from_path(&xml_track_path, true) { 306 + Ok(tagged_file) => tagged_file, 307 + Err(e) => throw!("Failed to read file information: {}", e), 308 + }; 309 + let audio_properties = tagged_file.properties(); 310 + 311 + let file_type = FileType::from_path(&xml_track_path)?; 312 + { 313 + // check that file type matches file extension 314 + let lofty_file_type = FileType::from_lofty_file_type(tagged_file.file_type())?; 315 + if file_type != lofty_file_type { 316 + println!("File type mismatch: {} vs {}", file_type, lofty_file_type); 317 + } 318 + } 319 + 320 + let name = xml_track.name.unwrap_or_default(); 321 + let artist = xml_track.artist.unwrap_or_default(); 322 + let filename = generate_filename(tracks_dir, &artist, &name, file_type.file_extension()); 323 + 324 + let track = Track { 325 + size: file_md.len() as i64, 326 + duration: audio_properties.duration().as_secs_f64(), 327 + bitrate: audio_properties 328 + .audio_bitrate() 329 + .ok_or(nerr!("Unknown bitrate"))? 330 + .into(), 331 + sampleRate: audio_properties 332 + .audio_bitrate() 333 + .ok_or(nerr!("Unknown sample rate"))? 334 + .into(), 335 + file: filename, 336 + dateModified: datetime_to_timestamp_millis(xml_track.date_modified), 337 + dateAdded: datetime_to_timestamp_millis(xml_track.date_added), 338 + name, 339 + importedFrom: Some("itunes".to_string()), 340 + originalId: Some(xml_track.persistent_id), 341 + artist, 342 + composer: keep_filled(xml_track.composer), 343 + sortName: keep_filled(xml_track.sort_name), 344 + sortArtist: keep_filled(xml_track.sort_artist), 345 + sortComposer: keep_filled(xml_track.sort_composer), 346 + genre: keep_filled(xml_track.genre), 347 + rating: xml_track.rating, 348 + year: xml_track.year, 349 + bpm: xml_track.bpm.map(|bpm| bpm.into()), 350 + comments: keep_filled(xml_track.comments), 351 + grouping: keep_filled(xml_track.grouping), 352 + liked: keep_true(xml_track.loved), 353 + disliked: keep_true(xml_track.disliked), 354 + disabled: keep_true(xml_track.disabled), 355 + compilation: keep_true(xml_track.compilation), 356 + albumName: keep_filled(xml_track.album), 357 + albumArtist: keep_filled(xml_track.album_artist), 358 + sortAlbumName: keep_filled(xml_track.sort_album), 359 + sortAlbumArtist: keep_filled(xml_track.sort_album_artist), 360 + trackNum: xml_track.track_number, 361 + trackCount: xml_track.track_count, 362 + discNum: xml_track.disc_number, 363 + discCount: xml_track.disc_count, 364 + dateImported: Some(start_time), 365 + playCount: play.count, 366 + plays: play.dates, 367 + playsImported: play.imported.map(|count| { 368 + vec![CountObject { 369 + count: count.into(), 370 + fromDate: datetime_to_timestamp_millis(xml_track.date_added), 371 + toDate: start_time, 372 + }] 373 + }), 374 + skipCount: skip.count, 375 + skips: skip.dates, 376 + skipsImported: skip.imported.map(|count| { 377 + vec![CountObject { 378 + count: count.into(), 379 + fromDate: datetime_to_timestamp_millis(xml_track.date_added), 380 + toDate: start_time, 381 + }] 382 + }), 383 + volume: match xml_track.volume_adjustment { 384 + Some(0) | None => None, 385 + Some(volume_adjustment) => { 386 + let float: f32 = volume_adjustment.into(); 387 + let vol = (float / 2.55).round() as i8; 388 + if vol < -100 || vol > 100 { 389 + throw!("Invalid volume adjustment: {}", volume_adjustment); 390 + } 391 + Some(vol) 392 + } 393 + }, 394 + }; 395 + 396 + Ok(track) 397 + } 398 + 399 + #[derive(Serialize, Deserialize, Clone, Debug)] 400 + struct XmlPlaylist { 401 + #[serde(rename = "Name")] 402 + name: String, 403 + 404 + #[serde(rename = "Description")] 405 + description: Option<String>, 406 + 407 + #[serde(rename = "Loved")] 408 + loved: Option<bool>, 409 + 410 + #[serde(rename = "Disliked")] 411 + disliked: Option<bool>, 412 + 413 + #[serde(rename = "Visible")] 414 + visible: Option<bool>, 415 + 416 + #[serde(rename = "Smart Info")] 417 + smart_info: Option<plist::Value>, 418 + 419 + #[serde(rename = "Playlist Persistent ID")] 420 + playlist_persistent_id: String, 421 + 422 + #[serde(rename = "Parent Persistent ID")] 423 + parent_persistent_id: Option<String>, 424 + 425 + /// Matches the type from the docs, but is 1-indexed instead of 0-indexed 426 + /// https://developer.apple.com/documentation/ituneslibrary/itlibdistinguishedplaylistkind 427 + #[serde(rename = "Distinguished Kind")] 428 + distinguished_kind: Option<u64>, 429 + 430 + #[serde(rename = "Folder")] 431 + folder: Option<bool>, 432 + 433 + #[serde(rename = "Playlist Items")] 434 + playlist_items: Vec<XmlPlaylistItem>, 435 + } 436 + impl XmlPlaylist { 437 + fn is_importable_playlist(&self) -> bool { 438 + // invisible playlists (should just be the "Library" playlist) 439 + if self.visible == Some(false) { 440 + return false; 441 + }; 442 + // smart playlists 443 + if self.smart_info.is_some() { 444 + return false; 445 + }; 446 + match self.distinguished_kind { 447 + None | Some(1) => return true, 448 + Some(_) => return false, // ignore special iTunes playlists 449 + } 450 + } 451 + } 452 + 453 + #[derive(Serialize, Deserialize, Clone, Debug)] 454 + struct XmlPlaylistItem { 455 + #[serde(rename = "Track ID")] 456 + track_id: u64, 457 + } 458 + 459 + struct MappedXmlPlaylist { 460 + xml_playlist: XmlPlaylist, 461 + children: Vec<String>, 462 + } 463 + 464 + fn parse_playlist( 465 + xml_playlist: &XmlPlaylist, 466 + id: String, 467 + start_time: i64, 468 + errors: &mut Vec<String>, 469 + xml_track_id_map: &HashMap<String, String>, 470 + ) -> TrackList { 471 + if xml_playlist.folder == Some(true) { 472 + return TrackList::Folder(Folder { 473 + id, 474 + name: xml_playlist.name.clone(), 475 + description: xml_playlist.description.clone(), 476 + liked: xml_playlist.loved.unwrap_or_default(), 477 + disliked: xml_playlist.disliked.unwrap_or_default(), 478 + importedFrom: Some("itunes".to_string()), 479 + originalId: Some(xml_playlist.playlist_persistent_id.clone()), 480 + dateImported: Some(start_time), 481 + dateCreated: None, 482 + children: vec![], 483 + }); 484 + } else { 485 + let mut track_ids = Vec::new(); 486 + for playlist_item in &xml_playlist.playlist_items { 487 + let track_id = xml_track_id_map.get(&playlist_item.track_id.to_string()); 488 + match track_id { 489 + Some(track_id) => track_ids.push(track_id.clone()), 490 + None => errors.push(format!( 491 + "Track with id {} not found in playlist {}", 492 + playlist_item.track_id, xml_playlist.name 493 + )), 494 + } 495 + } 496 + return TrackList::Playlist(Playlist { 497 + id, 498 + name: xml_playlist.name.clone(), 499 + description: xml_playlist.description.clone(), 500 + liked: xml_playlist.loved.unwrap_or_default(), 501 + disliked: xml_playlist.disliked.unwrap_or_default(), 502 + importedFrom: Some("itunes".to_string()), 503 + originalId: Some(xml_playlist.playlist_persistent_id.clone()), 504 + dateImported: Some(start_time), 505 + dateCreated: None, 506 + tracks: track_ids, 507 + }); 508 + } 509 + } 510 + 511 + #[napi(js_name = "import_itunes")] 512 + #[allow(dead_code)] 513 + pub async fn import_itunes(path: String, tracks_dir: String) -> Result<()> { 514 + let mut library = Library::new(); 515 + let mut xml: XmlLibrary = match plist::from_file(path) { 516 + Ok(book) => book, 517 + Err(e) => throw!("Unable to parse: {e}"), 518 + }; 519 + let mut errors = Vec::new(); 520 + let start_time = get_now_timestamp(); 521 + 522 + // Library.xml version check 523 + let version = xml.major_version.to_string() + "." + xml.minor_version.to_string().as_str(); 524 + if (xml.major_version, xml.minor_version) != (1, 1) { 525 + errors.push(format!("Unsupported Library.xml version {version}")); 526 + } 527 + 528 + // iTunes ID -> Ferrum ID 529 + let mut xml_track_id_map = HashMap::<String, String>::new(); 530 + 531 + // We import the tracks that are in the "Music" playlist since xml.tracks 532 + // contains podcasts, etc. 533 + let xml_music_playlist = XmlLibrary::get_music_playlist(&xml.playlists)?; 534 + let track_count = xml_music_playlist.playlist_items.len(); 535 + for (i, playlist_item) in xml_music_playlist.playlist_items.iter().enumerate() { 536 + let xml_id = playlist_item.track_id.to_string(); 537 + println!("Parsing tracks {}/{}", i + 1, track_count); 538 + let xml_track = xml 539 + .tracks 540 + .remove(&xml_id) 541 + .ok_or(nerr!("Track with id {} not found", playlist_item.track_id))?; 542 + let artist_title = xml_track.artist_title(); 543 + 544 + if matches!(xml_track.name.as_deref(), Some("") | None) { 545 + errors.push("[{artist_title}] Missing track name".to_string()); 546 + } 547 + if matches!(xml_track.artist.as_deref(), Some("") | None) { 548 + errors.push("[{artist_title}] Missing track artist".to_string()); 549 + } 550 + 551 + match parse_track(xml_track, start_time, Path::new(&tracks_dir)) { 552 + Ok(track) => { 553 + let generated_id = library.generate_id(); 554 + // immediately insert into library so new generated ids are unique 555 + library.tracks.insert(generated_id.clone(), track); 556 + if xml_track_id_map.contains_key(&xml_id) { 557 + errors.push(format!("[{artist_title}] Duplicate track ids {}", xml_id)); 558 + } 559 + xml_track_id_map.insert(xml_id, generated_id); 560 + } 561 + Err(e) => { 562 + errors.push(format!("[{artist_title}] Skipped track: {e}")); 563 + } 564 + }; 565 + } 566 + 567 + let importable_xml_playlists = xml.take_importable_playlists(); 568 + let mut xml_playlists_map = HashMap::new(); 569 + let xml_playlist_ids: Vec<String> = importable_xml_playlists 570 + .iter() 571 + .map(|p| p.playlist_persistent_id.clone()) 572 + .collect(); 573 + // build hashmap 574 + for xml_playlist in importable_xml_playlists { 575 + match xml_playlists_map.entry(xml_playlist.playlist_persistent_id.clone()) { 576 + Entry::Occupied(_) => { 577 + errors.push(format!( 578 + "Duplicate playlist id {}", 579 + xml_playlist.playlist_persistent_id 580 + )); 581 + } 582 + Entry::Vacant(entry) => { 583 + entry.insert(MappedXmlPlaylist { 584 + xml_playlist, 585 + children: Vec::new(), 586 + }); 587 + } 588 + }; 589 + } 590 + let mut root_children = Vec::new(); 591 + for xml_playlist_id in xml_playlist_ids { 592 + let xml_parent_id; 593 + let xml_parent_name; 594 + { 595 + let xml_playlist = &xml_playlists_map 596 + .get(&xml_playlist_id) 597 + .unwrap() 598 + .xml_playlist; 599 + xml_parent_id = xml_playlist.parent_persistent_id.clone(); 600 + xml_parent_name = xml_playlist.name.clone(); 601 + } 602 + match xml_parent_id { 603 + None => { 604 + root_children.push(xml_playlist_id); 605 + } 606 + Some(xml_parent_id) => match xml_playlists_map.get_mut(&xml_parent_id) { 607 + Some(parent) => { 608 + parent.children.push(xml_playlist_id.to_string()); 609 + } 610 + None => { 611 + errors.push(format!( 612 + "Playlist \"{}\" has non-existent parent id {}", 613 + xml_parent_name, xml_parent_id 614 + )); 615 + continue; 616 + } 617 + }, 618 + } 619 + } 620 + 621 + for xml_playlist_id in root_children { 622 + let mapped_xml_playlist = mapped_xml_playlists.get(&xml_playlist_id).unwrap(); 623 + mapped_xml_playlist.children 624 + } 625 + 626 + for (_, mapped_xml_playlist) in mapped_xml_playlists { 627 + let tracklist = parse_playlist( 628 + &mapped_xml_playlist.xml_playlist, 629 + library.generate_id(), 630 + start_time, 631 + &mut errors, 632 + &xml_track_id_map, 633 + ); 634 + // immediately insert into library so new generated ids are unique 635 + library 636 + .trackLists 637 + .insert(tracklist.id().to_string(), tracklist); 638 + } 639 + 640 + Ok(()) 641 + } 642 + 643 + #[tokio::test] 644 + async fn import_itunes_test() { 645 + let library_xml_path = "src-native/tests/Library.xml".to_string(); 646 + let _xml: XmlLibrary = plist::from_file(library_xml_path).unwrap(); 647 + }
+214
src-native/tests/Library.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>Major Version</key><integer>1</integer> 6 + <key>Minor Version</key><integer>1</integer> 7 + <key>Date</key><date>2022-10-15T10:56:55Z</date> 8 + <key>Application Version</key><string>1.0.6.10</string> 9 + <key>Features</key><integer>5</integer> 10 + <key>Show Content Ratings</key><true/> 11 + <key>Music Folder</key><string>file:///Users/k/Music/Music/Media.localized/</string> 12 + <key>Library Persistent ID</key><string>9003D1E43533D0C6</string> 13 + <key>Tracks</key> 14 + <dict> 15 + <key>144</key> 16 + <dict> 17 + <key>Track ID</key><integer>144</integer> 18 + <key>Name</key><string>Crescendo (feat. MYLK)</string> 19 + <key>Artist</key><string>Muzzy</string> 20 + <key>Album Artist</key><string>MuzzyAlb</string> 21 + <key>Composer</key><string>MrMuzzMan</string> 22 + <key>Album</key><string>Some album</string> 23 + <key>Grouping</key><string>MonstercatGroup</string> 24 + <key>Genre</key><string>Drum &#38; Bass</string> 25 + <key>Kind</key><string>Matched AAC audio file</string> 26 + <key>Size</key><integer>10573312</integer> 27 + <key>Total Time</key><integer>298600</integer> 28 + <key>Start Time</key><integer>1000</integer> 29 + <key>Stop Time</key><integer>20000</integer> 30 + <key>Disc Number</key><integer>2</integer> 31 + <key>Disc Count</key><integer>3</integer> 32 + <key>Track Number</key><integer>1</integer> 33 + <key>Track Count</key><integer>10</integer> 34 + <key>Year</key><integer>2016</integer> 35 + <key>BPM</key><integer>126</integer> 36 + <key>Date Modified</key><date>2022-10-14T07:36:08Z</date> 37 + <key>Date Added</key><date>2022-07-25T17:03:37Z</date> 38 + <key>Bit Rate</key><integer>256</integer> 39 + <key>Sample Rate</key><integer>44100</integer> 40 + <key>Volume Adjustment</key><integer>255</integer> 41 + <key>Equalizer</key><string>Electronic</string> 42 + <key>Comments</key><string>Monstercat</string> 43 + <key>Play Count</key><integer>1</integer> 44 + <key>Play Date</key><integer>3748677813</integer> 45 + <key>Play Date UTC</key><date>2022-10-15T09:23:33Z</date> 46 + <key>Skip Count</key><integer>5</integer> 47 + <key>Skip Date</key><date>2022-10-11T10:16:59Z</date> 48 + <key>Rating</key><integer>60</integer> 49 + <key>Album Rating</key><integer>60</integer> 50 + <key>Album Rating Computed</key><true/> 51 + <key>Loved</key><true/> 52 + <key>Normalization</key><integer>11079</integer> 53 + <key>Artwork Count</key><integer>1</integer> 54 + <key>Sort Album</key><string>B</string> 55 + <key>Sort Album Artist</key><string>C</string> 56 + <key>Sort Artist</key><string>D</string> 57 + <key>Sort Composer</key><string>E</string> 58 + <key>Sort Name</key><string>A</string> 59 + <key>Persistent ID</key><string>5F7ACC3AEC98302A</string> 60 + <key>Track Type</key><string>File</string> 61 + <key>Matched</key><true/> 62 + <key>Location</key><string>file:///Users/k/Music/Music/Media.localized/MuzzyAlb/Some%20album/2-01%20Crescendo%20(feat.%20MYLK).m4a</string> 63 + <key>File Folder Count</key><integer>4</integer> 64 + <key>Library Folder Count</key><integer>1</integer> 65 + </dict> 66 + <key>350</key> 67 + <dict> 68 + <key>Track ID</key><integer>350</integer> 69 + <key>Name</key><string>inhuman</string> 70 + <key>Artist</key><string>ISOxo</string> 71 + <key>Kind</key><string>MPEG audio file</string> 72 + <key>Size</key><integer>8424597</integer> 73 + <key>Total Time</key><integer>197712</integer> 74 + <key>Date Modified</key><date>2022-10-15T10:54:46Z</date> 75 + <key>Date Added</key><date>2022-10-15T10:54:00Z</date> 76 + <key>Bit Rate</key><integer>320</integer> 77 + <key>Sample Rate</key><integer>48000</integer> 78 + <key>Volume Adjustment</key><integer>-255</integer> 79 + <key>Artwork Count</key><integer>1</integer> 80 + <key>Persistent ID</key><string>728BFAEAF7C0C8D7</string> 81 + <key>Track Type</key><string>File</string> 82 + <key>Location</key><string>file:///Users/k/Music/Music/Media.localized/ISOxo/Unknown%20Album/inhuman.mp3</string> 83 + <key>File Folder Count</key><integer>4</integer> 84 + <key>Library Folder Count</key><integer>1</integer> 85 + </dict> 86 + </dict> 87 + <key>Playlists</key> 88 + <array> 89 + <dict> 90 + <key>Name</key><string>Library</string> 91 + <key>Description</key><string></string> 92 + <key>Master</key><true/> 93 + <key>Playlist ID</key><integer>64</integer> 94 + <key>Playlist Persistent ID</key><string>0000000000000005</string> 95 + <key>Visible</key><false/> 96 + <key>All Items</key><true/> 97 + <key>Playlist Items</key> 98 + <array> 99 + <dict> 100 + <key>Track ID</key><integer>144</integer> 101 + </dict> 102 + <dict> 103 + <key>Track ID</key><integer>350</integer> 104 + </dict> 105 + </array> 106 + </dict> 107 + <dict> 108 + <key>Name</key><string>Downloaded</string> 109 + <key>Description</key><string></string> 110 + <key>Playlist ID</key><integer>147</integer> 111 + <key>Playlist Persistent ID</key><string>533817C18A096868</string> 112 + <key>Distinguished Kind</key><integer>65</integer> 113 + <key>All Items</key><true/> 114 + <key>Playlist Items</key> 115 + <array> 116 + <dict> 117 + <key>Track ID</key><integer>144</integer> 118 + </dict> 119 + <dict> 120 + <key>Track ID</key><integer>350</integer> 121 + </dict> 122 + </array> 123 + </dict> 124 + <dict> 125 + <key>Name</key><string>Music</string> 126 + <key>Description</key><string></string> 127 + <key>Playlist ID</key><integer>151</integer> 128 + <key>Playlist Persistent ID</key><string>D613EC438867A085</string> 129 + <key>Distinguished Kind</key><integer>4</integer> 130 + <key>Music</key><true/> 131 + <key>All Items</key><true/> 132 + <key>Playlist Items</key> 133 + <array> 134 + <dict> 135 + <key>Track ID</key><integer>144</integer> 136 + </dict> 137 + <dict> 138 + <key>Track ID</key><integer>350</integer> 139 + </dict> 140 + </array> 141 + </dict> 142 + <dict> 143 + <key>Name</key><string>cool</string> 144 + <key>Description</key><string></string> 145 + <key>Playlist ID</key><integer>161</integer> 146 + <key>Playlist Persistent ID</key><string>1176ABF0C527C1D4</string> 147 + <key>All Items</key><true/> 148 + <key>Folder</key><true/> 149 + <key>Playlist Items</key> 150 + <array> 151 + <dict> 152 + <key>Track ID</key><integer>144</integer> 153 + </dict> 154 + </array> 155 + </dict> 156 + <dict> 157 + <key>Name</key><string>k</string> 158 + <key>Description</key><string>sample text</string> 159 + <key>Playlist ID</key><integer>165</integer> 160 + <key>Playlist Persistent ID</key><string>BC4E3B8BEF50A180</string> 161 + <key>Parent Persistent ID</key><string>1176ABF0C527C1D4</string> 162 + <key>All Items</key><true/> 163 + <key>Playlist Items</key> 164 + <array> 165 + <dict> 166 + <key>Track ID</key><integer>144</integer> 167 + </dict> 168 + </array> 169 + </dict> 170 + <dict> 171 + <key>Name</key><string>MySmartPlaylist</string> 172 + <key>Description</key><string></string> 173 + <key>Playlist ID</key><integer>169</integer> 174 + <key>Playlist Persistent ID</key><string>C7121C245F83DF41</string> 175 + <key>All Items</key><true/> 176 + <key>Smart Info</key> 177 + <data> 178 + AQEBAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 179 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 180 + AAAAAA== 181 + </data> 182 + <key>Smart Criteria</key> 183 + <data> 184 + U0xzdAABAAEAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 185 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 186 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQBAAACAAAAAAAAAAAAAAAAAAAAAAAA 187 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAE0= 188 + </data> 189 + <key>Playlist Items</key> 190 + <array> 191 + <dict> 192 + <key>Track ID</key><integer>144</integer> 193 + </dict> 194 + </array> 195 + </dict> 196 + <dict> 197 + <key>Name</key><string>x</string> 198 + <key>Description</key><string></string> 199 + <key>Playlist ID</key><integer>173</integer> 200 + <key>Playlist Persistent ID</key><string>E718DE8281D556DC</string> 201 + <key>All Items</key><true/> 202 + <key>Playlist Items</key> 203 + <array> 204 + <dict> 205 + <key>Track ID</key><integer>144</integer> 206 + </dict> 207 + <dict> 208 + <key>Track ID</key><integer>144</integer> 209 + </dict> 210 + </array> 211 + </dict> 212 + </array> 213 + </dict> 214 + </plist>
+54 -5
src-native/tracks/import.rs
··· 9 9 use std::fs::{self, File}; 10 10 use std::path::Path; 11 11 12 + #[derive(PartialEq)] 13 + pub enum FileType { 14 + Mp3, 15 + M4a, 16 + Opus, 17 + } 18 + impl FileType { 19 + pub fn from_path(path: &Path) -> UniResult<Self> { 20 + let ext = path.extension().unwrap_or_default().to_string_lossy(); 21 + match ext.as_ref() { 22 + "mp3" => Ok(FileType::Mp3), 23 + "m4a" => Ok(FileType::M4a), 24 + "opus" => Ok(FileType::Opus), 25 + _ => throw!("Unsupported file extension {}", ext), 26 + } 27 + } 28 + pub fn from_lofty_file_type(lofty_type: lofty::FileType) -> UniResult<Self> { 29 + match lofty_type { 30 + lofty::FileType::MP3 => Ok(FileType::Mp3), 31 + lofty::FileType::MP4 => Ok(FileType::M4a), 32 + lofty::FileType::Opus => Ok(FileType::Opus), 33 + _ => throw!("Unsupported file type {:?}", lofty_type), 34 + } 35 + } 36 + pub fn file_extension(&self) -> &'static str { 37 + match self { 38 + FileType::Mp3 => "mp3", 39 + FileType::M4a => "m4a", 40 + FileType::Opus => "opus", 41 + } 42 + } 43 + } 44 + impl std::fmt::Display for FileType { 45 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 46 + write!(f, "{}", self.file_extension()) 47 + } 48 + } 49 + pub fn import_auto(data: &Data, path: &Path, now: i64) -> UniResult<Track> { 50 + let track = match FileType::from_path(path)? { 51 + FileType::Mp3 => import_mp3(&data, &path, now)?, 52 + FileType::M4a => import_m4a(&data, &path, now)?, 53 + FileType::Opus => import_opus(&data, &path, now)?, 54 + }; 55 + Ok(track) 56 + } 57 + 12 58 fn get_and_fix_id3_year(tag: &mut id3::Tag) -> Option<i64> { 13 59 match tag.date_recorded() { 14 60 Some(tdrc) => Some(i64::from(tdrc.year)), ··· 42 88 } 43 89 } 44 90 45 - fn read_file_metadata(path: &Path) -> UniResult<fs::Metadata> { 46 - match fs::metadata(&path) { 47 - Ok(md) => Ok(md), 48 - Err(e) => throw!("Unable to read file metadata: {}", e), 91 + pub fn read_file_metadata(path: &Path) -> UniResult<fs::Metadata> { 92 + match std::fs::metadata(path) { 93 + Ok(file_md) => Ok(file_md), 94 + Err(err) => match err.kind() { 95 + std::io::ErrorKind::NotFound => throw!("File does not exist"), 96 + _ => throw!("Unable to access file: {}", err), 97 + }, 49 98 } 50 99 } 51 100 ··· 362 411 sortComposer: get_first_text_id3(&tag, "TSOC"), 363 412 genre: tag.genre().map(|s| s.to_owned()), 364 413 rating: None, 365 - year: year, 414 + year, 366 415 bpm: match get_first_text_id3(&tag, "TBPM") { 367 416 Some(n) => n.parse().ok(), 368 417 None => None,
+2 -9
src-native/tracks/mod.rs
··· 7 7 use std::fs; 8 8 use std::path::{Path, PathBuf}; 9 9 10 - mod import; 10 + pub mod import; 11 11 mod md; 12 12 mod tag; 13 13 ··· 144 144 #[allow(dead_code)] 145 145 pub fn import_file(path: String, now: MsSinceUnixEpoch, env: Env) -> Result<()> { 146 146 let data: &mut Data = get_data(&env)?; 147 - let path = Path::new(&path); 148 - let ext = path.extension().unwrap_or_default().to_string_lossy(); 149 - let track = match ext.as_ref() { 150 - "mp3" => import::import_mp3(&data, &path, now)?, 151 - "m4a" => import::import_m4a(&data, &path, now)?, 152 - "opus" => import::import_opus(&data, &path, now)?, 153 - _ => panic!("Unsupported file extension: {}", ext), 154 - }; 155 147 let id = data.library.generate_id(); 148 + let track = import::import_auto(&data, Path::new(&path), now)?; 156 149 data.library.tracks.insert(id, track); 157 150 Ok(()) 158 151 }
+13
src/App.svelte
··· 12 12 import { isMac, paths, importTracks, PlaylistInfo, trackLists } from './lib/data' 13 13 import { playPause } from './lib/player' 14 14 import DragGhost from './components/DragGhost.svelte' 15 + import ItunesImport from './components/ItunesImport.svelte' 15 16 import type { TrackID } from 'ferrum-addon/addon' 16 17 import { modalCount } from './components/Modal.svelte' 17 18 ··· 121 122 } 122 123 } 123 124 125 + let showItunesImport = true 126 + onDestroy( 127 + ipcListen('itunesImportNew', () => { 128 + if ($modalCount === 0) { 129 + showItunesImport = true 130 + } 131 + }) 132 + ) 133 + 124 134 let trackInfoList: TrackInfoList | null = null 125 135 function onTrackInfo(ids: TrackID[], trackIndex: number) { 126 136 if ($modalCount === 0) { ··· 210 220 {/if} 211 221 {#if playlistInfo} 212 222 <PlaylistInfoModal info={playlistInfo} cancel={() => (playlistInfo = null)} /> 223 + {/if} 224 + {#if showItunesImport} 225 + <ItunesImport cancel={() => (showItunesImport = false)} /> 213 226 {/if} 214 227 215 228 <DragGhost />
+1 -1
src/components/Button.svelte
··· 1 1 <script lang="ts"> 2 2 export let secondary = false 3 3 export let danger = false 4 - export let type = 'button' 4 + export let type: 'button' | 'submit' | 'reset' = 'button' 5 5 let normal = !danger && !secondary 6 6 $: normal = !danger && !secondary 7 7 </script>
+82
src/components/ItunesImport.svelte
··· 1 + <script lang="ts"> 2 + import { addon, ipcRenderer } from '@/lib/window' 3 + import Button from './Button.svelte' 4 + import Modal from './Modal.svelte' 5 + 6 + export let cancel: () => void 7 + let filePath = '' 8 + let locked = false 9 + 10 + function cancelHandler() { 11 + if (!locked) { 12 + cancel() 13 + } 14 + } 15 + 16 + async function selectFile() { 17 + locked = true 18 + const open = await ipcRenderer.invoke('showOpenDialog', true, { 19 + properties: ['openFile'], 20 + // filters: [{ name: 'Audio', extensions: ['mp3', 'm4a', 'opus'] }], 21 + }) 22 + if (!open.canceled && open.filePaths[0]) { 23 + filePath = open.filePaths[0] 24 + try { 25 + addon.import_itunes(filePath) 26 + } catch (e) { 27 + console.error(e) 28 + } 29 + } 30 + locked = false 31 + } 32 + </script> 33 + 34 + <Modal onCancel={cancelHandler} cancelOnEscape form={selectFile}> 35 + <main> 36 + {#if filePath === ''} 37 + <h3>Import iTunes Library</h3> 38 + <p> 39 + Select an iTunes <strong>Library.xml</strong> file. To get that file, open iTunes and click 40 + on 41 + <strong>File > Library > Export Library...</strong> 42 + </p> 43 + <p> 44 + All your tracks need to be downloaded for this to work. If you have tracks from iTunes 45 + Store/Apple Music, it might not work. 46 + </p> 47 + <p>The following will not be imported:</p> 48 + <ul> 49 + <li> 50 + Lyrics, Equalizer, Skip when shuffling, Remember playback position, Start time, Stop time 51 + </li> 52 + <li>Album ratings, album likes and album dislikes</li> 53 + <li>The following track metadata:</li> 54 + <li>Music videos, podcasts, audiobooks, voice memos etc.</li> 55 + <li>Smart playlists, Genius playlists and Genius Mix playlists</li> 56 + <li>View options</li> 57 + </ul> 58 + <div class="buttons"> 59 + <Button secondary on:click={cancelHandler}>Cancel</Button> 60 + <Button type="submit">Select File</Button> 61 + </div> 62 + {:else} 63 + Scanning... 64 + {/if} 65 + </main> 66 + </Modal> 67 + 68 + <style lang="sass"> 69 + main 70 + font-size: 0.95rem 71 + width: 530px 72 + line-height: 1.5 73 + strong 74 + font-weight: normal 75 + background-color: hsl(0, 0%, 100%, 0.1) 76 + border: 1px solid hsl(0, 0%, 100%, 0.05) 77 + padding: 0.05em 0.25em 78 + border-radius: 3px 79 + .buttons 80 + display: flex 81 + justify-content: flex-end 82 + </style>
+38 -8
src/components/Modal.svelte
··· 9 9 import { checkShortcut } from '../lib/helpers' 10 10 11 11 export let onCancel: () => void 12 + export let cancelOnEscape = false 13 + export let form: (() => void) | undefined = undefined 14 + $: tag = form === undefined ? 'div' : 'form' 12 15 13 16 $modalCount += 1 14 17 onDestroy(() => { ··· 25 28 } 26 29 27 30 function focusTrap(el: HTMLElement) { 28 - let lastActiveElementFallback = document.activeElement || document.body 29 - 30 31 function getFocusElements() { 31 32 return el.querySelectorAll( 32 33 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 33 34 ) 34 35 } 35 36 37 + if (lastActiveElement === null) { 38 + lastActiveElement = document.activeElement || document.body 39 + el.focus() 40 + } 41 + 36 42 function handleKeydown(e: KeyboardEvent) { 37 43 if (checkShortcut(e, 'Tab', { shift: true })) { 38 44 const focusElements = getFocusElements() ··· 55 61 focusElements[0].focus() 56 62 e.preventDefault() 57 63 } 64 + } else if (checkShortcut(e, 'Escape') && cancelOnEscape) { 65 + onCancel() 58 66 } 59 67 } 60 68 el.addEventListener('keydown', handleKeydown) ··· 63 71 el.removeEventListener('keydown', handleKeydown) 64 72 if (lastActiveElement instanceof HTMLElement) { 65 73 lastActiveElement.focus() 66 - } else if (lastActiveElementFallback instanceof HTMLElement) { 67 - lastActiveElementFallback.focus() 68 74 } 69 75 }, 70 76 } 71 77 } 78 + 79 + function boxKeydown(e: KeyboardEvent) { 80 + if (form && checkShortcut(e, 'Enter')) { 81 + form() 82 + e.preventDefault() 83 + } 84 + } 72 85 </script> 73 86 74 - <div class="container" use:focusTrap> 75 - <div class="backdrop" on:click|self={onCancel} /> 76 - <div class="box"> 87 + <div class="container" on:keydown> 88 + <div class="backdrop" on:click={onCancel} on:mousedown|preventDefault /> 89 + <svelte:element 90 + this={tag} 91 + class="box" 92 + on:submit|preventDefault={form} 93 + use:focusTrap 94 + tabindex="-1" 95 + on:keydown|self={boxKeydown} 96 + > 77 97 <slot {focus} /> 78 - </div> 98 + {#if $$slots.buttons} 99 + <div class="buttons"> 100 + <slot name="buttons" /> 101 + </div> 102 + {/if} 103 + </svelte:element> 79 104 </div> 80 105 81 106 <style lang="sass"> ··· 84 109 outline: none 85 110 .backdrop, .container 86 111 position: fixed 112 + user-select: text 87 113 z-index: 90 88 114 width: 100% 89 115 height: 100% ··· 105 131 box-shadow: 0px 0px 30px 0px rgba(#000000, 0.5) 106 132 overflow: auto 107 133 z-index: 100 134 + outline: none 135 + .buttons 136 + display: flex 137 + justify-content: flex-end 108 138 </style>
+8 -11
src/components/PlaylistInfo.svelte
··· 37 37 38 38 <svelte:window on:keydown={keydown} /> 39 39 40 - <Modal onCancel={cancel} let:focus> 41 - <form class="modal" on:submit|preventDefault={save} on:keydown={formKeydown}> 40 + <Modal onCancel={cancel} let:focus form={save} on:keydown={formKeydown}> 41 + <main> 42 42 <h3> 43 43 {info.editMode ? 'Edit' : 'New'} Playlist {#if info.isFolder}Folder{/if} 44 44 </h3> ··· 50 50 placeholder="Description" 51 51 /> 52 52 <div class="spacer" /> 53 - <div class="bottom"> 54 - <Button secondary on:click={cancel}>Cancel</Button> 55 - <Button type="submit">Save</Button> 56 - </div> 57 - </form> 53 + </main> 54 + <svelte:fragment slot="buttons"> 55 + <Button secondary on:click={cancel}>Cancel</Button> 56 + <Button on:click={save}>Save</Button> 57 + </svelte:fragment> 58 58 </Modal> 59 59 60 60 <style lang="sass"> 61 - form 61 + main 62 62 display: flex 63 63 flex-direction: column 64 64 background-color: inherit 65 65 .spacer 66 66 height: 15px 67 - .bottom 68 - display: flex 69 - justify-content: flex-end 70 67 input, textarea 71 68 resize: none 72 69 width: 300px
+4 -6
src/components/TrackInfo.svelte
··· 418 418 <input type="text" bind:value={comments} /> 419 419 </div> 420 420 <div class="spacer" /> 421 - <div class="bottom"> 422 - <Button secondary on:click={cancel}>Cancel</Button><Button type="submit">Save</Button> 423 - </div> 424 421 </form> 422 + <svelte:fragment slot="buttons"> 423 + <Button secondary on:click={cancel}>Cancel</Button> 424 + <Button on:click={() => save()}>Save</Button> 425 + </svelte:fragment> 425 426 </Modal> 426 427 427 428 <style lang="sass"> ··· 541 542 display: inline-block 542 543 width: 20px 543 544 text-align: center 544 - .bottom 545 - display: flex 546 - justify-content: flex-end 547 545 </style>
+74 -74
src/electron/import_itunes.ts
··· 213 213 } 214 214 215 215 async function parseTrack( 216 - xmlTrack: XmlTrack, 216 + xml_track: XmlTrack, 217 217 warn: (msg: string) => void, 218 218 startTime: number, 219 219 dryRun: boolean, ··· 223 223 importedFrom: 'itunes', 224 224 dateImported: startTime, 225 225 } 226 - const logPrefix = '[' + xmlTrack['Artist'] + ' - ' + xmlTrack['Name'] + ']' 226 + const logPrefix = '[' + xml_track['Artist'] + ' - ' + xml_track['Name'] + ']' 227 227 function addIfTruthy(prop: string, value: string | Date | undefined, info?: Info) { 228 228 if (value instanceof Date) { 229 229 track[prop] = value.getTime() ··· 235 235 warn(logPrefix + ` Missing recommended field "${prop}"`) 236 236 } 237 237 } 238 - addIfTruthy('name', xmlTrack['Name'], Info.Recommended) 239 - addIfTruthy('originalId', xmlTrack['Persistent ID']) 240 - addIfTruthy('artist', xmlTrack['Artist'], Info.Recommended) 241 - addIfTruthy('composer', xmlTrack['Composer']) 242 - addIfTruthy('sortName', xmlTrack['Sort Name']) 243 - addIfTruthy('sortArtist', xmlTrack['Sort Artist']) 244 - addIfTruthy('sortComposer', xmlTrack['Sort Composer']) 245 - addIfTruthy('genre', xmlTrack['Genre']) 246 - addIfTruthy('rating', xmlTrack['Rating']) 247 - addIfTruthy('year', xmlTrack['Year']) 248 - addIfTruthy('bpm', xmlTrack['BPM']) 249 - addIfTruthy('dateModified', xmlTrack['Date Modified'], Info.Required) 250 - addIfTruthy('dateAdded', xmlTrack['Date Added'], Info.Required) 251 - addIfTruthy('comments', xmlTrack['Comments']) 252 - addIfTruthy('grouping', xmlTrack['Grouping']) 253 - if (xmlTrack['Play Count'] && xmlTrack['Play Count'] >= 1) { 254 - track['playCount'] = xmlTrack['Play Count'] 238 + addIfTruthy('name', xml_track['Name'], Info.Recommended) 239 + addIfTruthy('originalId', xml_track['Persistent ID']) 240 + addIfTruthy('artist', xml_track['Artist'], Info.Recommended) 241 + addIfTruthy('composer', xml_track['Composer']) 242 + addIfTruthy('sortName', xml_track['Sort Name']) 243 + addIfTruthy('sortArtist', xml_track['Sort Artist']) 244 + addIfTruthy('sortComposer', xml_track['Sort Composer']) 245 + addIfTruthy('genre', xml_track['Genre']) 246 + addIfTruthy('rating', xml_track['Rating']) 247 + addIfTruthy('year', xml_track['Year']) 248 + addIfTruthy('bpm', xml_track['BPM']) 249 + addIfTruthy('dateModified', xml_track['Date Modified'], Info.Required) 250 + addIfTruthy('dateAdded', xml_track['Date Added'], Info.Required) 251 + addIfTruthy('comments', xml_track['Comments']) 252 + addIfTruthy('grouping', xml_track['Grouping']) 253 + if (xml_track['Play Count'] && xml_track['Play Count'] >= 1) { 254 + track['playCount'] = xml_track['Play Count'] 255 255 // Unlike "Skip Date" etc, "Play Date" is a non-UTC Mac HFS+ timestamp, but 256 256 // luckily "Play Date UTC" is a normal date. 257 - const playDate = xmlTrack['Play Date UTC'] 258 - let importedPlayCount = xmlTrack['Play Count'] 257 + const playDate = xml_track['Play Date UTC'] 258 + let imported_play_count = xml_track['Play Count'] 259 259 if (playDate !== undefined) { 260 260 // if we have a playDate, add a play for it 261 261 track['plays'] = [playDate.getTime()] 262 - importedPlayCount-- 262 + imported_play_count-- 263 263 } 264 - if (importedPlayCount >= 1) { 264 + if (imported_play_count >= 1) { 265 265 track['playsImported'] = [ 266 266 { 267 - count: importedPlayCount, 268 - fromDate: xmlTrack['Date Added'].getTime(), 267 + count: imported_play_count, 268 + fromDate: xml_track['Date Added'].getTime(), 269 269 toDate: playDate === undefined ? startTime : playDate.getTime(), 270 270 }, 271 271 ] 272 272 } 273 273 } 274 - if (xmlTrack['Skip Count'] && xmlTrack['Skip Count'] >= 1) { 275 - track['skipCount'] = xmlTrack['Skip Count'] 276 - const skipDate = xmlTrack['Skip Date'] 277 - let importedSkipCount = xmlTrack['Skip Count'] 274 + if (xml_track['Skip Count'] && xml_track['Skip Count'] >= 1) { 275 + track['skipCount'] = xml_track['Skip Count'] 276 + const skipDate = xml_track['Skip Date'] 277 + let importedSkipCount = xml_track['Skip Count'] 278 278 if (skipDate !== undefined) { 279 279 // if we have a skipDate, add a skip for it 280 280 track['skips'] = [skipDate.getTime()] ··· 284 284 track['skipsImported'] = [ 285 285 { 286 286 count: importedSkipCount, 287 - fromDate: xmlTrack['Date Added'].getTime(), 287 + fromDate: xml_track['Date Added'].getTime(), 288 288 toDate: skipDate === undefined ? startTime : skipDate.getTime(), 289 289 }, 290 290 ] ··· 293 293 // Play Time? 294 294 // Probably don't calculate play time from imported plays 295 295 // Location (use to get file and extract cover) 296 - if (xmlTrack['Volume Adjustment']) { 296 + if (xml_track['Volume Adjustment']) { 297 297 // In iTunes, you can choose volume adjustment at 10% increments. The XML 298 298 // value Seems like it should go from -255 to 255. However, when set to 299 299 // 100%, I got 255 on one track, but 254 on another. We'll just 300 300 // convert it to a -100 to 100 range and round off decimals. 301 - const vol = Math.round(xmlTrack['Volume Adjustment'] / 2.55) 301 + const vol = Math.round(xml_track['Volume Adjustment'] / 2.55) 302 302 if (vol && vol >= -100 && vol <= 100) { 303 303 track['volume'] = vol 304 304 } else { 305 305 warn(logPrefix + ` Unable to import Volume Adjustment of value "${vol}"`) 306 306 } 307 307 } 308 - addIfTruthy('liked', xmlTrack['Loved']) 309 - addIfTruthy('disliked', xmlTrack['Disliked']) 310 - addIfTruthy('disabled', xmlTrack['Disabled']) 308 + addIfTruthy('liked', xml_track['Loved']) 309 + addIfTruthy('disliked', xml_track['Disliked']) 310 + addIfTruthy('disabled', xml_track['Disabled']) 311 311 312 - if (xmlTrack['Compilation']) track.compilation = true 313 - if (xmlTrack['Album']) track.albumName = xmlTrack['Album'] 314 - if (xmlTrack['Album Artist']) track.albumArtist = xmlTrack['Album Artist'] 315 - if (xmlTrack['Sort Album']) track.sortAlbumName = xmlTrack['Sort Album'] 316 - if (xmlTrack['Sort Album Artist']) track.sortAlbumArtist = xmlTrack['Sort Album Artist'] 312 + if (xml_track['Compilation']) track.compilation = true 313 + if (xml_track['Album']) track.albumName = xml_track['Album'] 314 + if (xml_track['Album Artist']) track.albumArtist = xml_track['Album Artist'] 315 + if (xml_track['Sort Album']) track.sortAlbumName = xml_track['Sort Album'] 316 + if (xml_track['Sort Album Artist']) track.sortAlbumArtist = xml_track['Sort Album Artist'] 317 317 318 - if (xmlTrack['Track Number']) track.trackNum = xmlTrack['Track Number'] 319 - if (xmlTrack['Track Count']) track.trackCount = xmlTrack['Track Count'] 320 - if (xmlTrack['Disc Number']) track.discNum = xmlTrack['Disc Number'] 321 - if (xmlTrack['Disc Count']) track.discCount = xmlTrack['Disc Count'] 318 + if (xml_track['Track Number']) track.trackNum = xml_track['Track Number'] 319 + if (xml_track['Track Count']) track.trackCount = xml_track['Track Count'] 320 + if (xml_track['Disc Number']) track.discNum = xml_track['Disc Number'] 321 + if (xml_track['Disc Count']) track.discCount = xml_track['Disc Count'] 322 322 323 - if (xmlTrack['Track Type'] !== 'File') { 324 - const trackType = xmlTrack['Track Type'] 323 + if (xml_track['Track Type'] !== 'File') { 324 + const trackType = xml_track['Track Type'] 325 325 throw new Error(logPrefix + ` Expected track type "File", was "${trackType}"`) 326 326 } 327 - if (!xmlTrack['Location']) { 327 + if (!xml_track['Location']) { 328 328 throw new Error(logPrefix + ' Missing required field "Location"') 329 329 } 330 - const xmlTrackPath = url.fileURLToPath(xmlTrack['Location']) 330 + const xmlTrackPath = url.fileURLToPath(xml_track['Location']) 331 331 if (!fs.existsSync(xmlTrackPath)) { 332 332 throw new Error(logPrefix + ' File does not exist') 333 333 } ··· 402 402 } 403 403 404 404 if ( 405 - xmlTrack['Persistent ID'] === 'A7F64F85A799AA1C' || // init.seq 406 - xmlTrack['Persistent ID'] === '033D11C37D8F07CA' || // test track 407 - xmlTrack['Persistent ID'] === '7B468E51DD4EC3DB' // test track2 405 + xml_track['Persistent ID'] === 'A7F64F85A799AA1C' || // init.seq 406 + xml_track['Persistent ID'] === '033D11C37D8F07CA' || // test track 407 + xml_track['Persistent ID'] === '7B468E51DD4EC3DB' // test track2 408 408 ) { 409 - console.log(xmlTrack['Name'], { track, xmlTrack }) 409 + console.log(xml_track['Name'], { track, xmlTrack: xml_track }) 410 410 } 411 411 412 412 return track ··· 461 461 console.log('music folder:', xml['Music Folder']) 462 462 463 463 status('Parsing tracks...') 464 - const xmlPlaylists = [] 465 - let xmlMusicPlaylist 464 + const xml_playlists = [] 465 + let xml_music_playlist 466 466 for (const key of Object.keys(xml.Playlists)) { 467 - const xmlPlaylist = xml.Playlists[key] 467 + const xml_playlist = xml.Playlists[key] 468 468 // skip invisible playlists (should just be the "Library" playlist) 469 - if (xmlPlaylist['Visible'] === false) continue 469 + if (xml_playlist['Visible'] === false) continue 470 470 // skip smart playlists 471 - if (xmlPlaylist['Smart Info']) continue 472 - if (xmlPlaylist['Distinguished Kind'] && xmlPlaylist['Distinguished Kind'] !== 1) { 471 + if (xml_playlist['Smart Info']) continue 472 + if (xml_playlist['Distinguished Kind'] && xml_playlist['Distinguished Kind'] !== 1) { 473 473 // ignore iTunes-generated playlists 474 - if (xmlPlaylist['Distinguished Kind'] === 4) { 474 + if (xml_playlist['Distinguished Kind'] === 4) { 475 475 // but keep the Music playlist 476 - if (xmlMusicPlaylist) throw new Error('Found two iTunes-generated "Music" playlists') 477 - xmlMusicPlaylist = xmlPlaylist 476 + if (xml_music_playlist) throw new Error('Found two iTunes-generated "Music" playlists') 477 + xml_music_playlist = xml_playlist 478 478 } 479 479 } else { 480 - xmlPlaylists.push(xmlPlaylist) 480 + xml_playlists.push(xml_playlist) 481 481 } 482 482 } 483 483 // We import the tracks that are in the "Music" playlist since xml.Tracks 484 484 // contains podcasts, etc. 485 - const xmlMusicPlaylistItems = xmlMusicPlaylist['Playlist Items'] 485 + const xml_music_playlist_items = xml_music_playlist['Playlist Items'] 486 486 const startTime = new Date().getTime() 487 487 const parsedTracks: Record<string, Track> = {} 488 488 /** iTunes ID -> Ferrum ID */ 489 489 const trackIdMap: Record<string, string> = {} 490 - for (let i = 0; i < xmlMusicPlaylistItems.length; i++) { 491 - status(`Parsing tracks... (${i + 1}/${xmlMusicPlaylistItems.length})`) 492 - const xmlPlaylistItem = xmlMusicPlaylistItems[i] 490 + for (let i = 0; i < xml_music_playlist_items.length; i++) { 491 + status(`Parsing tracks... (${i + 1}/${xml_music_playlist_items.length})`) 492 + const xmlPlaylistItem = xml_music_playlist_items[i] 493 493 const iTunesId = xmlPlaylistItem['Track ID'] 494 494 const xmlTrack = xml.Tracks[iTunesId] 495 495 const track = await parseTrack(xmlTrack, warn, startTime, dryRun, paths) ··· 514 514 }, 515 515 } 516 516 const folderIdMap = {} 517 - for (const xmlPlaylist of xmlPlaylists) { 518 - if (xmlPlaylist['Folder'] !== true) continue 517 + for (const xml_playlist of xml_playlists) { 518 + if (xml_playlist['Folder'] !== true) continue 519 519 const playlist: Folder = { type: 'folder', children: [] } 520 - addCommonPlaylistFields(playlist, xmlPlaylist, startTime) 520 + addCommonPlaylistFields(playlist, xml_playlist, startTime) 521 521 let id 522 522 do { 523 523 // prevent duplicate IDs ··· 527 527 const itunesId = playlist.originalId 528 528 folderIdMap[itunesId] = id 529 529 } 530 - for (const xmlPlaylist of xmlPlaylists) { 531 - if (xmlPlaylist['Folder'] !== true) continue 532 - const itunesId = xmlPlaylist['Playlist Persistent ID'] 530 + for (const xml_playlist of xml_playlists) { 531 + if (xml_playlist['Folder'] !== true) continue 532 + const itunesId = xml_playlist['Playlist Persistent ID'] 533 533 const id = folderIdMap[itunesId] 534 534 const playlist = parsedPlaylists[id] 535 - const parentItunesId = xmlPlaylist['Parent Persistent ID'] 535 + const parentItunesId = xml_playlist['Parent Persistent ID'] 536 536 const parentId = folderIdMap[parentItunesId] 537 537 if (parentId) { 538 538 const parent = parsedPlaylists[parentId] ··· 546 546 } 547 547 548 548 status('Parsing playlists...') 549 - for (const xmlPlaylist of xmlPlaylists) { 549 + for (const xmlPlaylist of xml_playlists) { 550 550 if (xmlPlaylist['Folder'] === true) continue 551 551 const playlist = { type: 'playlist', tracks: [] } 552 552 addCommonPlaylistFields(playlist, xmlPlaylist, startTime)
+6
src/electron/menubar.ts
··· 52 52 webContents.send('itunesImport') 53 53 }, 54 54 }, 55 + { 56 + label: 'Import iTunes Library... (New)', 57 + click: () => { 58 + webContents.send('itunesImportNew') 59 + }, 60 + }, 55 61 { type: 'separator' }, 56 62 is.mac ? { role: 'close' } : { role: 'quit' }, 57 63 ],
+1
src/electron/typed_ipc.ts
··· 90 90 type Events = { 91 91 newPlaylist: (id: string, isFolder: boolean) => void 92 92 itunesImport: () => void 93 + itunesImportNew: () => void 93 94 import: () => void 94 95 filter: () => void 95 96