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

Working iTunes Library.xml parsing

Kasper (Oct 17, 2022, 3:07 PM +0200) f624036e 21b5e66a

+212 -791
+2 -3
Cargo.lock
··· 553 553 554 554 [[package]] 555 555 name = "plist" 556 - version = "1.3.1" 557 - source = "registry+https://github.com/rust-lang/crates.io-index" 558 - checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225" 556 + version = "1.3.2" 557 + source = "git+https://github.com/ebarnard/rust-plist?rev=0e9a85a4de3a7882157519d3f253960261fd6d08#0e9a85a4de3a7882157519d3f253960261fd6d08" 559 558 dependencies = [ 560 559 "base64", 561 560 "indexmap",
+1 -1
Cargo.toml
··· 30 30 atomicwrites = "0.3" 31 31 trash = "2.0" 32 32 base64 = "0.13" 33 - plist = "1.3" 33 + plist = { git = "https://github.com/ebarnard/rust-plist", rev = "0e9a85a4de3a7882157519d3f253960261fd6d08" } 34 34 tokio = { version = "1.21", features = ["macros"] } 35 35 time = { version = "0.3", features = ["serde", "serde-well-known"] } 36 36 url = "2.3"
+12 -6
ferrum-addon/addon.d.ts
··· 13 13 export function get_paths(): PathsJs 14 14 export function save(): void 15 15 export function filter_open_playlist(query: string): void 16 + export interface ImportStatus { 17 + errors: Array<string> 18 + tracksCount: number 19 + playlistsCount: number 20 + } 21 + export function import_itunes(path: string, tracksDir: string): Promise<ImportStatus> 16 22 export function copyFile(from: string, to: string): void 17 23 export function atomicFileSave(filePath: string, content: string): void 18 - export function import_itunes(path: string): Promise<void> 19 24 export interface Track { 20 25 size: number 21 26 duration: number ··· 58 63 skipCount?: number 59 64 skips?: Array<MsSinceUnixEpoch> 60 65 skipsImported?: Array<CountObject> 61 - volume?: PercentInteger 66 + /** -100 to 100 */ 67 + volume?: number 62 68 } 63 69 export interface CountObject { 64 70 count: number ··· 69 75 id: TrackListID 70 76 name: string 71 77 description?: string 72 - liked?: string 73 - disliked?: string 78 + liked: boolean 79 + disliked: boolean 74 80 importedFrom?: string 75 81 originalId?: string 76 82 dateImported?: MsSinceUnixEpoch ··· 81 87 id: TrackListID 82 88 name: string 83 89 description?: string 84 - liked?: string 85 - disliked?: string 90 + liked: boolean 91 + disliked: boolean 86 92 /** For example "itunes" */ 87 93 importedFrom?: string 88 94 /** For example iTunes Persistent ID */
+1 -1
src-native/lib.rs
··· 22 22 mod data; 23 23 mod data_js; 24 24 mod filter; 25 + mod itunes_import; 25 26 mod js; 26 27 mod library; 27 - mod library_import; 28 28 mod library_types; 29 29 mod page; 30 30 mod playlists;
+165 -86
src-native/library_import.rs src-native/itunes_import.rs
··· 5 5 use lofty::AudioFile; 6 6 use napi::Result; 7 7 use serde::{Deserialize, Serialize}; 8 - use std::collections::hash_map::Entry; 9 8 use std::collections::HashMap; 10 9 use std::path::{Path, PathBuf}; 11 10 use time::serde::iso8601; ··· 39 38 library_persistent_id: String, 40 39 41 40 #[serde(rename = "Tracks")] 42 - tracks: HashMap<String, XmlTrack>, 41 + tracks: HashMap<String, plist::Value>, 43 42 44 43 #[serde(rename = "Playlists")] 44 + playlists: Vec<plist::Value>, 45 + } 46 + impl XmlLibrary { 47 + fn deserialize_props(self) -> Result<XmlLibraryProps> { 48 + let mut tracks = HashMap::new(); 49 + for (key, value) in self.tracks { 50 + let podcast = value 51 + .as_dictionary() 52 + .and_then(|d| d.get("Podcast")) 53 + .and_then(|v| v.as_boolean()) 54 + .unwrap_or(false); 55 + if podcast { 56 + continue; 57 + } 58 + 59 + let track: XmlTrack = match plist::from_value(value) { 60 + Ok(track) => track, 61 + Err(e) => throw!("Could not read track with id \"{key}\": {e}"), 62 + }; 63 + tracks.insert(key, track); 64 + } 65 + let mut playlists = Vec::new(); 66 + for value in self.playlists { 67 + let name = value 68 + .as_dictionary() 69 + .and_then(|dict| dict.get("Name")) 70 + .and_then(|v| v.as_string()) 71 + .unwrap_or_default() 72 + .to_string(); 73 + let playlist: XmlPlaylist = match plist::from_value(value) { 74 + Ok(playlist) => playlist, 75 + Err(e) => throw!("Could not read playlist \"{name}\": {e}"), 76 + }; 77 + playlists.push(playlist); 78 + } 79 + Ok(XmlLibraryProps { tracks, playlists }) 80 + } 81 + } 82 + 83 + struct XmlLibraryProps { 84 + tracks: HashMap<String, XmlTrack>, 45 85 playlists: Vec<XmlPlaylist>, 46 86 } 47 - impl XmlLibrary { 87 + 88 + impl XmlLibraryProps { 48 89 fn get_music_playlist(playlists: &Vec<XmlPlaylist>) -> Result<&XmlPlaylist> { 49 90 let mut xml_music_playlist = None; 50 91 for xml_playlist in playlists { ··· 60 101 } 61 102 fn take_importable_playlists(&mut self) -> Vec<XmlPlaylist> { 62 103 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()); 104 + let (importable, remaining) = playlists.into_iter().partition(|xml_playlist| { 105 + return xml_playlist.is_importable_playlist(); 106 + }); 66 107 self.playlists = remaining; 67 108 importable 68 109 } ··· 431 472 folder: Option<bool>, 432 473 433 474 #[serde(rename = "Playlist Items")] 434 - playlist_items: Vec<XmlPlaylistItem>, 475 + playlist_items: Option<Vec<XmlPlaylistItem>>, 435 476 } 436 477 impl XmlPlaylist { 437 478 fn is_importable_playlist(&self) -> bool { ··· 456 497 track_id: u64, 457 498 } 458 499 459 - struct MappedXmlPlaylist { 500 + struct XmlPlaylistInfo { 460 501 xml_playlist: XmlPlaylist, 461 - children: Vec<String>, 502 + child_indexes: Vec<usize>, 462 503 } 463 504 464 - fn parse_playlist( 465 - xml_playlist: &XmlPlaylist, 466 - id: String, 505 + /// Returns id of the imported playlist 506 + fn import_playlist( 507 + infos: &Vec<XmlPlaylistInfo>, 508 + i: usize, 509 + library: &mut Library, 467 510 start_time: i64, 468 511 errors: &mut Vec<String>, 469 512 xml_track_id_map: &HashMap<String, String>, 470 - ) -> TrackList { 513 + ) -> String { 514 + let tracklist; 515 + let xml_playlist = &infos[i].xml_playlist; 516 + let id = library.generate_id(); 517 + 471 518 if xml_playlist.folder == Some(true) { 472 - return TrackList::Folder(Folder { 473 - id, 519 + let folder = TrackList::Folder(Folder { 520 + id: id.clone(), 474 521 name: xml_playlist.name.clone(), 475 522 description: xml_playlist.description.clone(), 476 523 liked: xml_playlist.loved.unwrap_or_default(), ··· 479 526 originalId: Some(xml_playlist.playlist_persistent_id.clone()), 480 527 dateImported: Some(start_time), 481 528 dateCreated: None, 482 - children: vec![], 529 + children: infos[i] 530 + .child_indexes 531 + .iter() 532 + .map(|child_i| { 533 + return import_playlist( 534 + &infos, 535 + *child_i, 536 + library, 537 + start_time, 538 + errors, 539 + xml_track_id_map, 540 + ); 541 + }) 542 + .collect(), 483 543 }); 544 + // immediately insert into library so new generated ids are unique 545 + library.trackLists.insert(id.clone(), folder); 484 546 } else { 485 547 let mut track_ids = Vec::new(); 486 - for playlist_item in &xml_playlist.playlist_items { 548 + for playlist_item in xml_playlist.playlist_items.as_ref().unwrap() { 487 549 let track_id = xml_track_id_map.get(&playlist_item.track_id.to_string()); 488 550 match track_id { 489 551 Some(track_id) => track_ids.push(track_id.clone()), ··· 493 555 )), 494 556 } 495 557 } 496 - return TrackList::Playlist(Playlist { 497 - id, 558 + 559 + tracklist = TrackList::Playlist(Playlist { 560 + id: id.clone(), 498 561 name: xml_playlist.name.clone(), 499 562 description: xml_playlist.description.clone(), 500 563 liked: xml_playlist.loved.unwrap_or_default(), ··· 505 568 dateCreated: None, 506 569 tracks: track_ids, 507 570 }); 571 + // immediately insert into library so new generated ids are unique 572 + library.trackLists.insert(id.clone(), tracklist); 508 573 } 574 + id 575 + } 576 + 577 + #[napi(object)] 578 + pub struct ImportStatus { 579 + pub errors: Vec<String>, 580 + pub tracks_count: u32, 581 + pub playlists_count: u32, 509 582 } 510 583 511 584 #[napi(js_name = "import_itunes")] 512 585 #[allow(dead_code)] 513 - pub async fn import_itunes(path: String, tracks_dir: String) -> Result<()> { 586 + pub async fn import_itunes(path: String, tracks_dir: String) -> Result<ImportStatus> { 514 587 let mut library = Library::new(); 515 - let mut xml: XmlLibrary = match plist::from_file(path) { 588 + let xml_lib: XmlLibrary = match plist::from_file(path) { 516 589 Ok(book) => book, 517 590 Err(e) => throw!("Unable to parse: {e}"), 518 591 }; ··· 520 593 let start_time = get_now_timestamp(); 521 594 522 595 // 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) { 596 + let version = 597 + xml_lib.major_version.to_string() + "." + xml_lib.minor_version.to_string().as_str(); 598 + if (xml_lib.major_version, xml_lib.minor_version) != (1, 1) { 525 599 errors.push(format!("Unsupported Library.xml version {version}")); 526 600 } 601 + 602 + let mut xml = xml_lib.deserialize_props()?; 527 603 528 604 // iTunes ID -> Ferrum ID 529 605 let mut xml_track_id_map = HashMap::<String, String>::new(); 530 606 531 607 // We import the tracks that are in the "Music" playlist since xml.tracks 532 608 // 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() { 609 + let xml_music_playlist = XmlLibraryProps::get_music_playlist(&xml.playlists)?; 610 + let playlist_items = &xml_music_playlist.playlist_items.as_ref().unwrap(); 611 + let track_count = playlist_items.len(); 612 + for (i, playlist_item) in playlist_items.iter().enumerate() { 536 613 let xml_id = playlist_item.track_id.to_string(); 537 614 println!("Parsing tracks {}/{}", i + 1, track_count); 538 615 let xml_track = xml ··· 564 641 }; 565 642 } 566 643 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(_) => { 644 + let importable_xml_playlists = { 645 + let mut list = xml.take_importable_playlists(); 646 + for xml_playlist in &mut list { 647 + if xml_playlist.playlist_items.is_none() { 577 648 errors.push(format!( 578 - "Duplicate playlist id {}", 579 - xml_playlist.playlist_persistent_id 649 + "No playlist items list in playlist {}", 650 + xml_playlist.name 580 651 )); 581 - } 582 - Entry::Vacant(entry) => { 583 - entry.insert(MappedXmlPlaylist { 584 - xml_playlist, 585 - children: Vec::new(), 586 - }); 652 + xml_playlist.playlist_items = Some(Vec::new()); 587 653 } 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(); 654 + } 655 + list 656 + }; 657 + 658 + let xml_playlist_id_map = { 659 + let mut map = HashMap::<String, usize>::new(); 660 + for (i, playlist) in importable_xml_playlists.iter().enumerate() { 661 + map.insert(playlist.playlist_persistent_id.clone(), i); 601 662 } 602 - match xml_parent_id { 603 - None => { 604 - root_children.push(xml_playlist_id); 663 + map 664 + }; 665 + 666 + let (xml_playlist_infos, root_child_indexes) = { 667 + // create infos 668 + let mut xml_playlist_infos: Vec<_> = importable_xml_playlists 669 + .iter() 670 + .map(|p| { 671 + return XmlPlaylistInfo { 672 + xml_playlist: p.clone(), 673 + child_indexes: Vec::new(), 674 + }; 675 + }) 676 + .collect(); 677 + 678 + let mut root_child_indexes = Vec::new(); 679 + 680 + // add children to infos 681 + for (i, xml_playlist) in importable_xml_playlists.iter().enumerate() { 682 + if let Some(parent_id) = &xml_playlist.parent_persistent_id { 683 + let parent_index = match xml_playlist_id_map.get(parent_id) { 684 + Some(index) => index, 685 + None => { 686 + errors.push(format!( 687 + "Playlist \"{}\" has non-existent parent id {}", 688 + xml_playlist.name, parent_id 689 + )); 690 + continue; 691 + } 692 + }; 693 + let parent = &mut xml_playlist_infos[*parent_index]; 694 + parent.child_indexes.push(i); 695 + } else { 696 + root_child_indexes.push(i); 605 697 } 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 698 } 619 - } 620 699 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 - } 700 + (xml_playlist_infos, root_child_indexes) 701 + }; 625 702 626 - for (_, mapped_xml_playlist) in mapped_xml_playlists { 627 - let tracklist = parse_playlist( 628 - &mapped_xml_playlist.xml_playlist, 629 - library.generate_id(), 703 + // recursively import playlists 704 + for i in root_child_indexes { 705 + import_playlist( 706 + &xml_playlist_infos, 707 + i, 708 + &mut library, 630 709 start_time, 631 710 &mut errors, 632 711 &xml_track_id_map, 633 712 ); 634 - // immediately insert into library so new generated ids are unique 635 - library 636 - .trackLists 637 - .insert(tracklist.id().to_string(), tracklist); 638 713 } 639 714 640 - Ok(()) 715 + Ok(ImportStatus { 716 + errors, 717 + tracks_count: library.tracks.len() as u32, 718 + playlists_count: library.trackLists.len() as u32, 719 + }) 641 720 } 642 721 643 722 #[tokio::test]
+4 -76
src/App.svelte
··· 8 8 import TrackInfo, { TrackInfoList } from './components/TrackInfo.svelte' 9 9 import PlaylistInfoModal from './components/PlaylistInfo.svelte' 10 10 import { queueVisible } from './lib/queue' 11 - import { ipcListen, iTunesImport, ipcRenderer } from '@/lib/window' 12 - import { isMac, paths, importTracks, PlaylistInfo, trackLists } from './lib/data' 11 + import { ipcListen, ipcRenderer } from '@/lib/window' 12 + import { isMac, importTracks, PlaylistInfo, trackLists } from './lib/data' 13 13 import { playPause } from './lib/player' 14 14 import DragGhost from './components/DragGhost.svelte' 15 15 import ItunesImport from './components/ItunesImport.svelte' 16 16 import type { TrackID } from 'ferrum-addon/addon' 17 17 import { modalCount } from './components/Modal.svelte' 18 18 19 - let pageStatus = '' 20 - let pageStatusWarnings = '' 21 - let pageStatusErr = '' 22 - async function itunesImport() { 23 - const result = await iTunesImport( 24 - { 25 - library_dir: paths.libraryDir, 26 - library_json: paths.libraryJson, 27 - tracks_dir: paths.tracksDir, 28 - }, 29 - (status: string) => { 30 - pageStatus = status 31 - }, 32 - (warning: string) => { 33 - console.warn(warning) 34 - pageStatusWarnings += warning + '\n' 35 - } 36 - ) 37 - if (result.err) pageStatusErr = String(result.err.stack) 38 - else if (result.cancelled) pageStatus = '' 39 - else pageStatus = 'Done. Restart Ferrum' 40 - } 41 - ipcRenderer.on('itunesImport', itunesImport) 42 - onDestroy(() => { 43 - ipcRenderer.removeListener('itunesImport', itunesImport) 44 - }) 45 - 46 19 ipcRenderer.emit('appLoaded') 47 20 48 21 async function openImportDialog() { ··· 124 97 125 98 let showItunesImport = true 126 99 onDestroy( 127 - ipcListen('itunesImportNew', () => { 100 + ipcListen('itunesImport', () => { 128 101 if ($modalCount === 0) { 129 102 showItunesImport = true 130 103 } ··· 185 158 {/if} 186 159 </div> 187 160 <Player /> 188 - {#if pageStatus || pageStatusWarnings || pageStatusErr} 189 - <div class="page-status-bg"> 190 - <div class="page-status"> 191 - {#if pageStatus} 192 - <div class="page-status-item">{pageStatus}</div> 193 - {/if} 194 - {#if pageStatusWarnings} 195 - <div class="page-status-item"> 196 - <b>Warnings:</b> 197 - <pre>{pageStatusWarnings}</pre> 198 - </div> 199 - {/if} 200 - {#if pageStatusErr} 201 - <div class="page-status-item"> 202 - <b>Error:</b> 203 - <pre>{pageStatusErr}</pre> 204 - </div> 205 - {/if} 206 - </div> 207 - </div> 208 - {/if} 209 161 {#if droppable} 210 162 <!-- if the overlay is always visible, it's not possible to scroll while dragging tracks --> 211 163 <div class="drag-overlay" transition:fade={{ duration: 100 }}> ··· 284 236 display: flex 285 237 flex-direction: row 286 238 flex-grow: 1 287 - .page-status-bg 288 - position: absolute 289 - top: 0px 290 - left: 0px 291 - width: 100% 292 - height: 100% 293 - background-color: rgba(#1a1a1a, 0.3) 294 - display: flex 295 - justify-content: center 296 - align-items: center 297 - .page-status 298 - background-color: #3b3b3b 299 - min-width: 300px 300 - max-width: 800px 301 - min-height: 100px 302 - max-height: calc(100% - 100px) 303 - overflow-y: scroll 304 - padding: 10px 20px 305 - user-select: text 306 - .page-status-item 307 - margin: 6px 0px 308 - pre 309 - white-space: pre-wrap 310 - overflow: hidden 311 - overflow-wrap: anywhere 312 239 .titlebar 313 240 height: var(--titlebar-height) 314 241 width: 100% ··· 316 243 left: 0px 317 244 position: absolute 318 245 -webkit-app-region: drag 246 + z-index: 500 319 247 </style>
+22 -3
src/components/ItunesImport.svelte
··· 1 1 <script lang="ts"> 2 - import { addon, ipcRenderer } from '@/lib/window' 2 + import { importItunes } from '@/lib/data' 3 + import { ipcRenderer } from '@/lib/window' 4 + import type { ImportStatus } from 'ferrum-addon/addon' 3 5 import Button from './Button.svelte' 4 6 import Modal from './Modal.svelte' 5 7 6 8 export let cancel: () => void 7 9 let filePath = '' 8 10 let locked = false 11 + let status: ImportStatus | null = null 9 12 10 13 function cancelHandler() { 11 14 if (!locked) { ··· 22 25 if (!open.canceled && open.filePaths[0]) { 23 26 filePath = open.filePaths[0] 24 27 try { 25 - addon.import_itunes(filePath) 28 + status = await importItunes(filePath) 26 29 } catch (e) { 27 30 console.error(e) 28 31 } ··· 59 62 <Button secondary on:click={cancelHandler}>Cancel</Button> 60 63 <Button type="submit">Select File</Button> 61 64 </div> 65 + {:else if status} 66 + <div class="error-box"> 67 + <h4>Errors</h4> 68 + {#each status.errors as error} 69 + <p>{error}</p> 70 + {/each} 71 + </div> 72 + <p>Playlists: {status.playlistsCount}</p> 73 + <p>Tracks: {status.tracksCount}</p> 74 + {:else if locked} 75 + Scanning... 62 76 {:else} 63 - Scanning... 77 + Done 64 78 {/if} 65 79 </main> 66 80 </Modal> ··· 76 90 border: 1px solid hsl(0, 0%, 100%, 0.05) 77 91 padding: 0.05em 0.25em 78 92 border-radius: 3px 93 + .error-box 94 + background-color: hsla(0, 100%, 49%, 0.2) 95 + border: 1px solid hsl(0, 100%, 49%) 96 + border-radius: 5px 97 + padding: 0px 10px 79 98 .buttons 80 99 display: flex 81 100 justify-content: flex-end
-598
src/electron/import_itunes.ts
··· 1 - import { ipcRenderer } from 'electron' 2 - import simplePlist from 'simple-plist' 3 - import path from 'path' 4 - import url from 'url' 5 - import fs from 'fs' 6 - import mm from 'music-metadata' 7 - import * as addon from 'ferrum-addon' 8 - import { 9 - Playlist as LibraryPlaylist, 10 - Folder as LibraryFolder, 11 - Track as LibraryTrack, 12 - TrackListsHashMap, 13 - SpecialTrackListName, 14 - } from 'ferrum-addon' 15 - 16 - type Track = LibraryTrack & { 17 - importedFrom: 'itunes' 18 - dateImported: number 19 - duration: number 20 - bitrate: number 21 - sampleRate: number 22 - name: string 23 - originalId: string 24 - } 25 - 26 - type XmlTrack = { 27 - 'Date Modified': Date 28 - 'Date Added': Date 29 - 'Play Date UTC': Date 30 - importedFrom: 'itunes' 31 - Artist?: string 32 - Name?: string 33 - 'Persistent ID'?: string 34 - Composer?: string 35 - 'Sort Name'?: string 36 - 'Sort Artist'?: string 37 - 'Sort Composer'?: string 38 - Genre?: string 39 - Rating?: unknown // TODO 40 - Year?: unknown // TODO 41 - BPM?: unknown // TODO 42 - Comments?: string 43 - Grouping?: string 44 - 'Play Count'?: unknown // TODO 45 - 'Volume Adjustment': number 46 - } 47 - 48 - type TrackList = Playlist | Folder 49 - 50 - type Playlist = LibraryPlaylist & { 51 - importedFrom: 'itunes' 52 - originalId: string 53 - dateImported: number 54 - } 55 - 56 - type Folder = LibraryFolder & { 57 - name: string 58 - description?: string 59 - liked?: string 60 - disliked?: string 61 - /** For example "itunes" */ 62 - importedFrom?: string 63 - /** For example iTunes Persistent ID */ 64 - originalId?: string 65 - dateImported?: number 66 - dateCreated?: number 67 - children: string[] 68 - } 69 - 70 - type Paths = { 71 - library_dir: string 72 - tracks_dir: string 73 - library_json: string 74 - } 75 - 76 - type Result = { cancelled: boolean; warnings: string[]; err?: Error } 77 - export async function iTunesImport( 78 - paths: Paths, 79 - status: (status: string) => void, 80 - warn: (status: string) => void 81 - ): Promise<Result> { 82 - const warnings: string[] = [] 83 - try { 84 - const result = await start(paths, status, (warning) => { 85 - warnings.push(warning) 86 - warn(warning) 87 - }) 88 - return { ...result, warnings } 89 - } catch (err) { 90 - console.error(err) 91 - return { err: new Error('Unknown'), warnings, cancelled: true } 92 - } 93 - } 94 - 95 - function sanitizeFilename(str: string) { 96 - str = str.replaceAll('/', '_') 97 - str = str.replaceAll('?', '_') 98 - str = str.replaceAll('<', '_') 99 - str = str.replaceAll('>', '_') 100 - str = str.replaceAll('\\', '_') 101 - str = str.replaceAll(':', '_') 102 - str = str.replaceAll('*', '_') 103 - str = str.replaceAll('"', '_') 104 - // prevent control characters: 105 - str = str.replaceAll('0x', '__') 106 - // Filenames can be max 255 bytes. We use 230 to give 107 - // margin for the fileNum and file extension. 108 - return str.substring(0, 230) 109 - } 110 - 111 - function generateFilename(track: Track, originalPath: string, tracksDir: string): string { 112 - const name = track.name || '' 113 - const artist = track.artist || '' 114 - const beginning = sanitizeFilename(artist + ' - ' + name) 115 - 116 - const ext = path.extname(originalPath) 117 - const allowedExt = ['.mp3', '.m4a'] 118 - if (!allowedExt.includes(ext)) { 119 - // by having an approved set of file extensions, we avoid unsafe filenames: 120 - // - Unix reserved filenames `.` and `..` 121 - // - Windows reserved filenames `CON`, `PRN` etc. 122 - // - Trailing `.` and ` ` 123 - throw new Error(`Unsupported file extension "${ext}"`) 124 - } 125 - 126 - let fileNum = 0 127 - let ending = '' 128 - 129 - let filename 130 - for (let i = 0; i < 999; i++) { 131 - if (i === 500) { 132 - break 133 - } 134 - filename = beginning + ending + ext 135 - const filepath = path.join(tracksDir, filename) 136 - if (fs.existsSync(filepath)) { 137 - fileNum++ 138 - ending = ' ' + fileNum 139 - } else { 140 - return filename 141 - } 142 - } 143 - throw new Error('Already got 500 tracks with that artist and title') 144 - } 145 - 146 - function readPlist(filePath: string) { 147 - return new Promise((resolve, reject) => { 148 - simplePlist.readFile(filePath, (err, data) => { 149 - if (err) reject(err) 150 - else resolve(data) 151 - }) 152 - }) 153 - } 154 - 155 - function makeId(length = 10) { 156 - let result = '' 157 - const characters = 'abcdefghijklmnopqrstuvwxyz234567' 158 - const charactersLength = characters.length 159 - for (let i = 0; i < length; i++) { 160 - result += characters.charAt(Math.floor(Math.random() * charactersLength)) 161 - } 162 - return result 163 - } 164 - 165 - function buffersEqual(buf1: Buffer, buf2: Buffer) { 166 - return Buffer.compare(buf1, buf2) === 0 167 - } 168 - 169 - async function popup() { 170 - const m = 171 - 'WARNING: This will reset/delete your Ferrum library!' + 172 - '\n' + 173 - '\nSelect an iTunes "Library.xml" file. To get that file, open iTunes and click on "File > Library > Export Library..."' + 174 - '\n' + 175 - '\nAll your tracks need to be downloaded for this to work.' + 176 - ' If you have tracks from iTunes Store/Apple Music, it might not work.' + 177 - '\n' + 178 - '\nThe following will not be imported:' + 179 - '\n- Music videos, podcasts, audiobooks, voice memos etc.' + 180 - '\n- Smart playlists, Genius playlists and Genius Mix playlists' + 181 - '\n- View options' + 182 - '\n- Album ratings, album likes and album dislikes' + 183 - '\n- The following track metadata:' + 184 - '\n - Lyrics' + 185 - '\n - Equalizer' + 186 - '\n - Skip when shuffling' + 187 - '\n - Remember playback position' + 188 - '\n - Disc Count' + 189 - '\n - Start time' + 190 - '\n - Stop time' 191 - const info = await ipcRenderer.invoke('showMessageBox', true, { 192 - type: 'info', 193 - title: 'iTunes Import', 194 - message: m, 195 - checkboxLabel: 'Dry run', 196 - checkboxChecked: true, 197 - buttons: ['OK', 'Cancel'], 198 - }) 199 - if (info.response === 1) return {} 200 - const dryRun = info.checkboxChecked 201 - const open = await ipcRenderer.invoke('showOpenDialog', true, { 202 - properties: ['openFile'], 203 - }) 204 - if (!open.canceled && open.canceled.filePaths && open.canceled.filePaths[0]) { 205 - return { dryRun, filePath: open.canceled.filePaths[0] } 206 - } 207 - return {} 208 - } 209 - 210 - enum Info { 211 - Required = 1, 212 - Recommended = 1, 213 - } 214 - 215 - async function parseTrack( 216 - xml_track: XmlTrack, 217 - warn: (msg: string) => void, 218 - startTime: number, 219 - dryRun: boolean, 220 - paths: Paths 221 - ) { 222 - const track: Track = { 223 - importedFrom: 'itunes', 224 - dateImported: startTime, 225 - } 226 - const logPrefix = '[' + xml_track['Artist'] + ' - ' + xml_track['Name'] + ']' 227 - function addIfTruthy(prop: string, value: string | Date | undefined, info?: Info) { 228 - if (value instanceof Date) { 229 - track[prop] = value.getTime() 230 - } else if (value) { 231 - track[prop] = value 232 - } else if (info === Info.Required) { 233 - throw new Error(logPrefix + ` Track missing required field "${prop}"`) 234 - } else if (info === Info.Recommended) { 235 - warn(logPrefix + ` Missing recommended field "${prop}"`) 236 - } 237 - } 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 - // Unlike "Skip Date" etc, "Play Date" is a non-UTC Mac HFS+ timestamp, but 256 - // luckily "Play Date UTC" is a normal date. 257 - const playDate = xml_track['Play Date UTC'] 258 - let imported_play_count = xml_track['Play Count'] 259 - if (playDate !== undefined) { 260 - // if we have a playDate, add a play for it 261 - track['plays'] = [playDate.getTime()] 262 - imported_play_count-- 263 - } 264 - if (imported_play_count >= 1) { 265 - track['playsImported'] = [ 266 - { 267 - count: imported_play_count, 268 - fromDate: xml_track['Date Added'].getTime(), 269 - toDate: playDate === undefined ? startTime : playDate.getTime(), 270 - }, 271 - ] 272 - } 273 - } 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 - if (skipDate !== undefined) { 279 - // if we have a skipDate, add a skip for it 280 - track['skips'] = [skipDate.getTime()] 281 - importedSkipCount-- 282 - } 283 - if (importedSkipCount >= 1) { 284 - track['skipsImported'] = [ 285 - { 286 - count: importedSkipCount, 287 - fromDate: xml_track['Date Added'].getTime(), 288 - toDate: skipDate === undefined ? startTime : skipDate.getTime(), 289 - }, 290 - ] 291 - } 292 - } 293 - // Play Time? 294 - // Probably don't calculate play time from imported plays 295 - // Location (use to get file and extract cover) 296 - if (xml_track['Volume Adjustment']) { 297 - // In iTunes, you can choose volume adjustment at 10% increments. The XML 298 - // value Seems like it should go from -255 to 255. However, when set to 299 - // 100%, I got 255 on one track, but 254 on another. We'll just 300 - // convert it to a -100 to 100 range and round off decimals. 301 - const vol = Math.round(xml_track['Volume Adjustment'] / 2.55) 302 - if (vol && vol >= -100 && vol <= 100) { 303 - track['volume'] = vol 304 - } else { 305 - warn(logPrefix + ` Unable to import Volume Adjustment of value "${vol}"`) 306 - } 307 - } 308 - addIfTruthy('liked', xml_track['Loved']) 309 - addIfTruthy('disliked', xml_track['Disliked']) 310 - addIfTruthy('disabled', xml_track['Disabled']) 311 - 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 - 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 - 323 - if (xml_track['Track Type'] !== 'File') { 324 - const trackType = xml_track['Track Type'] 325 - throw new Error(logPrefix + ` Expected track type "File", was "${trackType}"`) 326 - } 327 - if (!xml_track['Location']) { 328 - throw new Error(logPrefix + ' Missing required field "Location"') 329 - } 330 - const xmlTrackPath = url.fileURLToPath(xml_track['Location']) 331 - if (!fs.existsSync(xmlTrackPath)) { 332 - throw new Error(logPrefix + ' File does not exist') 333 - } 334 - const stats = fs.statSync(xmlTrackPath) 335 - track.size = stats.size 336 - 337 - const md = await mm.parseFile(xmlTrackPath) 338 - // Warnings are in md.quality.warnings 339 - if (!md.format.duration) { 340 - throw new Error( 341 - logPrefix + ' Could not read duration from file. Probably unusual or badly encoded file' 342 - ) 343 - } 344 - if (!md.format.bitrate) { 345 - throw new Error( 346 - logPrefix + ' Could not read bitrate from file. Probably unusual or badly encoded file' 347 - ) 348 - } 349 - if (!md.format.sampleRate) { 350 - throw new Error( 351 - logPrefix + ' Could not read sample rate from file. Probably unusual or badly encoded file' 352 - ) 353 - } 354 - track.duration = md.format.duration 355 - track.bitrate = Math.round(md.format.bitrate) 356 - track.sampleRate = md.format.sampleRate 357 - const picture = md.common.picture 358 - const newFilename = generateFilename(track, xmlTrackPath, paths.tracks_dir) 359 - track.file = newFilename 360 - let artworkPath, artworkData 361 - if (picture && picture[0]) { 362 - // if the track has multiple artworks, check if if they're equal. If 363 - // yes, use the first one, otherwise warn 364 - if (picture.length > 1) { 365 - // Start at 1 since we're comparing two elements in the array 366 - for (let i = 1; i < picture.length; i++) { 367 - const equal = buffersEqual(picture[i - 1].data, picture[i].data) 368 - if (!equal) { 369 - warn(logPrefix + ' Found multiple unique artworks. Using the first one') 370 - } 371 - } 372 - // // this code is for writing the multiple artworks 373 - // if (!allEqual) { 374 - // for (let i = 0; i < picture.length; i++) { 375 - // let ext = '.jpg' 376 - // if (picture[0].format === 'image/png') ext = '.png' 377 - // const dir = path.join(libraryPath, 'Export', newFilename+' '+i+ext) 378 - // fs.writeFileSync(dir, picture[i].data) 379 - // } 380 - // } 381 - } 382 - const thePicture = picture[0] 383 - let ext = '.jpg' 384 - if (thePicture.format === 'image/png') ext = '.png' 385 - const imgFormat = thePicture.format 386 - if (!['image/jpeg', 'image/png'].includes(imgFormat)) { 387 - warn(logPrefix + ` Skipping unsupported cover format "${imgFormat}"`) 388 - } 389 - artworkPath = path.join(paths.artworks_dir, newFilename + ext) 390 - artworkData = thePicture.data 391 - } 392 - const newPath = path.join(paths.tracks_dir, newFilename) 393 - if (fs.existsSync(newPath)) { 394 - throw new Error(logPrefix + ' File already exists: ' + newPath) 395 - } 396 - if (fs.existsSync(artworkPath)) { 397 - throw new Error(logPrefix + ' File already exists: ' + artworkPath) 398 - } 399 - if (!dryRun) { 400 - if (artworkPath) fs.writeFileSync(artworkPath, artworkData) 401 - addon.copy_file(xmlTrackPath, newPath) 402 - } 403 - 404 - if ( 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 - ) { 409 - console.log(xml_track['Name'], { track, xmlTrack: xml_track }) 410 - } 411 - 412 - return track 413 - } 414 - 415 - type XmlPlaylist = { 416 - Name: string 417 - Description?: string 418 - Loved?: string 419 - Disliked?: string 420 - 'Playlist Persistent ID': string 421 - } 422 - 423 - function addCommonPlaylistFields(playlist: TrackList, xmlPlaylist: XmlPlaylist, startTime: number) { 424 - if (!xmlPlaylist['Name']) { 425 - throw new Error('Playlist missing required field "Name": ' + String(xmlPlaylist)) 426 - } 427 - playlist.name = xmlPlaylist['Name'] 428 - if (xmlPlaylist['Description']) playlist.description = xmlPlaylist['Description'] 429 - if (xmlPlaylist['Loved']) playlist.liked = xmlPlaylist['Loved'] 430 - if (xmlPlaylist['Disliked']) playlist.disliked = xmlPlaylist['Disliked'] 431 - playlist.originalId = xmlPlaylist['Playlist Persistent ID'] 432 - playlist.importedFrom = 'itunes' 433 - playlist.dateImported = startTime 434 - } 435 - 436 - type StartResult = { 437 - err?: Error 438 - cancelled: boolean 439 - } 440 - async function start( 441 - paths: Paths, 442 - status: (msg: string) => void, 443 - warn: (msg: string) => void 444 - ): Promise<StartResult> { 445 - // const filePath = '/Users/kasper/Downloads/Library.xml' 446 - // const dryRun = false 447 - const { filePath, dryRun } = await popup() 448 - if (!filePath) return { cancelled: true } 449 - 450 - status('Reading iTunes Library file...') 451 - const xml = await readPlist(filePath) 452 - 453 - status('Parsing iTunes Library file...') 454 - const version = xml['Major Version'] + '.' + xml['Minor Version'] 455 - if (version !== '1.1') { 456 - warn( 457 - `Library.xml version: Expected 1.1, was ${version}. You might have a too new/old iTunes verison` 458 - ) 459 - } 460 - console.log('xml:', xml) 461 - console.log('music folder:', xml['Music Folder']) 462 - 463 - status('Parsing tracks...') 464 - const xml_playlists = [] 465 - let xml_music_playlist 466 - for (const key of Object.keys(xml.Playlists)) { 467 - const xml_playlist = xml.Playlists[key] 468 - // skip invisible playlists (should just be the "Library" playlist) 469 - if (xml_playlist['Visible'] === false) continue 470 - // skip smart playlists 471 - if (xml_playlist['Smart Info']) continue 472 - if (xml_playlist['Distinguished Kind'] && xml_playlist['Distinguished Kind'] !== 1) { 473 - // ignore iTunes-generated playlists 474 - if (xml_playlist['Distinguished Kind'] === 4) { 475 - // but keep the Music playlist 476 - if (xml_music_playlist) throw new Error('Found two iTunes-generated "Music" playlists') 477 - xml_music_playlist = xml_playlist 478 - } 479 - } else { 480 - xml_playlists.push(xml_playlist) 481 - } 482 - } 483 - // We import the tracks that are in the "Music" playlist since xml.Tracks 484 - // contains podcasts, etc. 485 - const xml_music_playlist_items = xml_music_playlist['Playlist Items'] 486 - const startTime = new Date().getTime() 487 - const parsedTracks: Record<string, Track> = {} 488 - /** iTunes ID -> Ferrum ID */ 489 - const trackIdMap: Record<string, string> = {} 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 - const iTunesId = xmlPlaylistItem['Track ID'] 494 - const xmlTrack = xml.Tracks[iTunesId] 495 - const track = await parseTrack(xmlTrack, warn, startTime, dryRun, paths) 496 - let id 497 - 498 - do { 499 - // prevent duplicate IDs 500 - id = makeId(7) 501 - } while (parsedTracks[id]) 502 - parsedTracks[id] = track 503 - trackIdMap[iTunesId] = id 504 - } 505 - 506 - status('Parsing folders...') 507 - const parsedPlaylists: TrackListsHashMap = { 508 - root: { 509 - name: SpecialTrackListName.Root, 510 - id: 'root', 511 - type: 'special', 512 - dateCreated: startTime, 513 - children: [], 514 - }, 515 - } 516 - const folderIdMap = {} 517 - for (const xml_playlist of xml_playlists) { 518 - if (xml_playlist['Folder'] !== true) continue 519 - const playlist: Folder = { type: 'folder', children: [] } 520 - addCommonPlaylistFields(playlist, xml_playlist, startTime) 521 - let id 522 - do { 523 - // prevent duplicate IDs 524 - id = makeId(7) 525 - } while (parsedPlaylists[id]) 526 - parsedPlaylists[id] = playlist 527 - const itunesId = playlist.originalId 528 - folderIdMap[itunesId] = id 529 - } 530 - for (const xml_playlist of xml_playlists) { 531 - if (xml_playlist['Folder'] !== true) continue 532 - const itunesId = xml_playlist['Playlist Persistent ID'] 533 - const id = folderIdMap[itunesId] 534 - const playlist = parsedPlaylists[id] 535 - const parentItunesId = xml_playlist['Parent Persistent ID'] 536 - const parentId = folderIdMap[parentItunesId] 537 - if (parentId) { 538 - const parent = parsedPlaylists[parentId] 539 - if (!parent) { 540 - throw new Error(`Could not find folder of playlist "${playlist.name}"`) 541 - } 542 - parent.children.push(id) 543 - } else { 544 - parsedPlaylists.root.children.push(id) 545 - } 546 - } 547 - 548 - status('Parsing playlists...') 549 - for (const xmlPlaylist of xml_playlists) { 550 - if (xmlPlaylist['Folder'] === true) continue 551 - const playlist = { type: 'playlist', tracks: [] } 552 - addCommonPlaylistFields(playlist, xmlPlaylist, startTime) 553 - 554 - const parentItunesId = xmlPlaylist['Parent Persistent ID'] 555 - const parentId = folderIdMap[parentItunesId] 556 - let id 557 - do { 558 - // prevent duplicate IDs 559 - id = makeId(7) 560 - } while (parsedTracks[id]) 561 - 562 - if (parentId) { 563 - const parent = parsedPlaylists[parentId] 564 - if (!parent) { 565 - throw new Error(`Could not find folder of playlist "${playlist.name}"`) 566 - } 567 - parent.children.push(id) 568 - } else { 569 - parsedPlaylists.root.children.push(id) 570 - } 571 - if (xmlPlaylist['Playlist Items']) { 572 - for (const item of xmlPlaylist['Playlist Items']) { 573 - const itunesTrackId = item['Track ID'] 574 - const trackId = trackIdMap[itunesTrackId] 575 - // skip podcasts etc by checking if it's in parsedTracks 576 - if (parsedTracks[trackId]) { 577 - playlist.tracks.push(trackId) 578 - } 579 - } 580 - } 581 - parsedPlaylists[id] = playlist 582 - } 583 - console.log('parsedPlaylists:', parsedPlaylists) 584 - 585 - console.log('LIB', { tracks: parsedTracks, trackLists: parsedPlaylists }) 586 - if (dryRun) return { cancelled: true } 587 - 588 - status('Saving...') 589 - const newLibrary = { 590 - version: 1, 591 - tracks: parsedTracks, 592 - trackLists: parsedPlaylists, 593 - playTime: [], 594 - } 595 - const json = JSON.stringify(newLibrary, null, ' ') 596 - await addon.atomic_file_save(paths.library_json, json) 597 - return { cancelled: false } 598 - }
-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 - }, 61 55 { type: 'separator' }, 62 56 is.mac ? { role: 'close' } : { role: 'quit' }, 63 57 ],
-2
src/electron/preload.ts
··· 1 1 import path from 'path' 2 2 import is from './is' 3 3 import addon from 'ferrum-addon' 4 - import { iTunesImport } from './import_itunes' 5 4 6 5 window.addon = addon 7 6 window.isDev = is.dev 8 7 window.isMac = is.mac 9 8 window.isWindows = is.windows 10 - window.iTunesImport = iTunesImport 11 9 12 10 window.joinPaths = (...args) => { 13 11 const combinedPath = path.join(...args)
-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 94 93 import: () => void 95 94 filter: () => void 96 95
+5 -1
src/lib/data.ts
··· 1 1 import { writable } from 'svelte/store' 2 - import { addon as innerAddon, ipcRenderer } from '@/lib/window' 2 + import { ipcRenderer } from '@/lib/window' 3 3 import type { MsSinceUnixEpoch, TrackID, TrackListID, TrackMd } from 'ferrum-addon' 4 4 5 5 export const isDev = window.isDev 6 6 export const isMac = window.isMac 7 7 export const isWindows = window.isWindows 8 + const innerAddon = window.addon 8 9 9 10 call((addon) => addon.load_data(isDev)) 10 11 ··· 120 121 call((addon) => addon.move_playlist(id, fromParent, toParent)) 121 122 methods.save() 122 123 trackLists.refreshTrackIdList() 124 + } 125 + export function importItunes(path: string) { 126 + return call((addon) => addon.import_itunes(path, paths.tracksDir)) 123 127 } 124 128 125 129 export const paths = call((addon) => addon.get_paths())
-7
src/lib/window.ts
··· 18 18 isMac: boolean 19 19 isWindows: boolean 20 20 joinPaths: (...args: string[]) => string 21 - iTunesImport: ( 22 - paths: { library_dir: string; tracks_dir: string; library_json: string }, 23 - statusHandler: (status: string) => void, 24 - warningHandler: (status: string) => void 25 - ) => Promise<{ err?: Error; cancelled: boolean }> 26 21 } 27 22 } 28 23 29 - export const addon = window.addon 30 - export const iTunesImport = window.iTunesImport 31 24 export const joinPaths = window.joinPaths