From fcdee68b38c93874092065a1d64c809683810230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20de=20la=20Martini=C3=A8re?= Date: Fri, 6 Sep 2024 01:04:31 +0200 Subject: [PATCH 1/4] Persistent Library Folders --- src-tauri/src/plugins/app_menu.rs | 2 +- src-tauri/src/plugins/shell_extension.rs | 1 - src/elements/Flexbox/Flexbox.tsx | 5 +- src/lib/__tests__/utils-library.test.ts | 35 ++++++++- src/lib/utils-library.ts | 16 +++++ src/stores/SettingsAPI.ts | 4 +- src/stores/useLibraryStore.ts | 92 +++++++++++++++--------- src/views/ViewSettingsLibrary.module.css | 22 ++++++ src/views/ViewSettingsLibrary.tsx | 51 +++++++++++-- 9 files changed, 179 insertions(+), 49 deletions(-) create mode 100644 src/views/ViewSettingsLibrary.module.css diff --git a/src-tauri/src/plugins/app_menu.rs b/src-tauri/src/plugins/app_menu.rs index 959eba69..3b2c73f6 100644 --- a/src-tauri/src/plugins/app_menu.rs +++ b/src-tauri/src/plugins/app_menu.rs @@ -49,7 +49,7 @@ pub fn init() -> TauriPlugin { let icon = Image::from_path(icon_path).unwrap(); let about_metadata = AboutMetadataBuilder::new() - .version(version) // TODO: Automate all that? + .version(version) .authors(Some(vec![package_info.authors.to_string()])) .license("MIT".into()) .website("https://museeks.io".into()) diff --git a/src-tauri/src/plugins/shell_extension.rs b/src-tauri/src/plugins/shell_extension.rs index 96c2263d..a91f28ee 100644 --- a/src-tauri/src/plugins/shell_extension.rs +++ b/src-tauri/src/plugins/shell_extension.rs @@ -1,5 +1,4 @@ // Stolen and adapted from https://github.com/tauri-apps/plugins-workspace/issues/999 -// TODO: make sure it works on Windows and Linux use std::process::Command; diff --git a/src/elements/Flexbox/Flexbox.tsx b/src/elements/Flexbox/Flexbox.tsx index 59133285..8b5a3c9b 100644 --- a/src/elements/Flexbox/Flexbox.tsx +++ b/src/elements/Flexbox/Flexbox.tsx @@ -5,12 +5,13 @@ import styles from './Flexbox.module.css'; type Props = { gap?: 4 | 8 | 16; children: React.ReactNode; + className?: string; direction?: 'vertical' | 'horizontal'; align?: 'center'; }; export default function Flexbox(props: Props) { - const classNames = cx(styles.flexbox, { + const classNames = cx(styles.flexbox, props.className, { [styles.vertical]: props.direction === 'vertical', }); @@ -20,7 +21,7 @@ export default function Flexbox(props: Props) { style={{ gap: props.gap ?? 0, alignItems: props.align, - }} + }} // Eventually, move that to real classes, but I am lazy > {props.children} diff --git a/src/lib/__tests__/utils-library.test.ts b/src/lib/__tests__/utils-library.test.ts index e9c8338e..be633bed 100644 --- a/src/lib/__tests__/utils-library.test.ts +++ b/src/lib/__tests__/utils-library.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test'; import type { Track } from '../../generated/typings'; -import { getStatus, stripAccents } from '../utils-library'; +import { + getStatus, + removeRedundantFolders, + stripAccents, +} from '../utils-library'; const TEST_TRACKS_ALBUM: Array = [ { @@ -84,3 +88,32 @@ describe('stripAccents', () => { ); }); }); + +describe('removeRedundantFolders', () => { + test('should return the same array if there are no duplicates or subpaths', () => { + expect( + removeRedundantFolders(['/users/me/music', '/users/me/videos']), + ).toStrictEqual(['/users/me/music', '/users/me/videos']); + }); + + test('should remove duplicate entries', () => { + expect( + removeRedundantFolders([ + '/users/me/music', + '/users/me/videos', + '/users/me/music', + ]), + ).toStrictEqual(['/users/me/music', '/users/me/videos']); + }); + + test('should remove subpaths', () => { + expect( + removeRedundantFolders([ + '/tmp/data/music', + '/users/me/music', + '/users/me/music/archive', + '/tmp/data', + ]), + ).toStrictEqual(['/users/me/music', '/tmp/data']); + }); +}); diff --git a/src/lib/utils-library.ts b/src/lib/utils-library.ts index dbc2bfba..1ce9c752 100644 --- a/src/lib/utils-library.ts +++ b/src/lib/utils-library.ts @@ -2,6 +2,7 @@ import orderBy from 'lodash/orderBy'; import type { SortOrder, Track } from '../generated/typings'; +import uniq from 'lodash/uniq'; import { parseDuration } from '../hooks/useFormattedDuration'; import type { SortConfig } from './sort-orders'; @@ -74,3 +75,18 @@ const ACCENT_MAP = new Map(); for (let i = 0; i < ACCENTS.length; i++) { ACCENT_MAP.set(ACCENTS[i], ACCENT_REPLACEMENTS[i]); } + +/** + * Given multiple paths as string, remove duplicates or child paths in case on parent exist in the array + */ +export const removeRedundantFolders = (paths: Array): Array => { + return uniq( + paths.filter((path) => { + const isDuplicate = paths.some((otherPath) => { + return path.startsWith(otherPath) && path !== otherPath; + }); + + return !isDuplicate; + }), + ); +}; diff --git a/src/stores/SettingsAPI.ts b/src/stores/SettingsAPI.ts index c9e2b2a5..6fd85b9e 100644 --- a/src/stores/SettingsAPI.ts +++ b/src/stores/SettingsAPI.ts @@ -15,11 +15,11 @@ interface UpdateCheckOptions { silentFail?: boolean; } -async function setTheme(themeID: string): Promise { +const setTheme = async (themeID: string): Promise => { await config.set('theme', themeID); await applyThemeToUI(themeID); invalidate(); -} +}; /** * Apply theme colors to the BrowserWindow diff --git a/src/stores/useLibraryStore.ts b/src/stores/useLibraryStore.ts index 82e8cac6..05c67320 100644 --- a/src/stores/useLibraryStore.ts +++ b/src/stores/useLibraryStore.ts @@ -6,7 +6,7 @@ import database from '../lib/database'; import { invalidate } from '../lib/query'; import { logAndNotifyError } from '../lib/utils'; -import { getStatus } from '../lib/utils-library'; +import { getStatus, removeRedundantFolders } from '../lib/utils-library'; import { createStore } from './store-helpers'; import usePlayerStore from './usePlayerStore'; import useToastsStore from './useToastsStore'; @@ -26,6 +26,9 @@ type LibraryState = { search: (value: string) => void; sort: (sortBy: SortBy) => void; add: () => Promise; + addLibraryFolder: () => Promise; + removeLibraryFolder: (path: string) => Promise; + refresh: () => Promise; remove: (tracksIDs: string[]) => Promise; reset: () => Promise; setRefresh: (processed: number, total: number) => void; @@ -112,6 +115,56 @@ const useLibraryStore = createStore((set, get) => ({ } }, + refresh: async (): Promise => { + try { + set({ refreshing: true }); + + const libraryFolders = await config.get('library_folders'); + await database.importTracks(libraryFolders); + + invalidate(); + } catch (err) { + logAndNotifyError(err); + } finally { + set({ + refreshing: false, + refresh: { current: 0, total: 0 }, + }); + } + }, + + addLibraryFolder: async (): Promise => { + try { + const path = await open({ + directory: true, + }); + + if (path == null) { + return; + } + + const musicFolders = await config.get('library_folders'); + const newFolders = removeRedundantFolders([ + ...musicFolders, + path, + ]).sort(); + await config.set('library_folders', newFolders); + + invalidate(); + } catch (err) { + logAndNotifyError(err); + } + }, + + removeLibraryFolder: async (path: string): Promise => { + const musicFolders = await config.get('library_folders'); + const index = musicFolders.indexOf(path); + musicFolders.splice(index, 1); + await config.set('library_folders', musicFolders); + + invalidate(); + }, + setRefresh: (current: number, total: number) => { set({ refresh: { @@ -218,46 +271,15 @@ const useLibraryStore = createStore((set, get) => ({ set({ highlightPlayingTrack: highlight }); }, + /** + * Manually set the footer content based on a list of tracks + */ setTracksStatus: (tracks: Array | null): void => { set({ tracksStatus: tracks !== null ? getStatus(tracks) : '', }); }, }, - - // Old code used to manage folders to be scanned, to be re-enabled one day - // case (types.LIBRARY_ADD_FOLDERS): { // TODO Redux -> move to a thunk - // const { folders } = action.payload; - // let musicFolders = window.MuseeksAPI.config.get('musicFolders'); - - // // Check if we received folders - // if (folders !== undefined) { - // musicFolders = musicFolders.concat(folders); - - // // Remove duplicates, useless children, ect... - // musicFolders = utils.removeUselessFolders(musicFolders); - - // musicFolders.sort(); - - // config.set('musicFolders', musicFolders); - // } - - // return { ...state }; - // } - - // case (types.LIBRARY_REMOVE_FOLDER): { // TODO Redux -> move to a thunk - // if (!state.library.refreshing) { - // const musicFolders = window.MuseeksAPI.config.get('musicFolders'); - - // musicFolders.splice(action.index, 1); - - // config.set('musicFolders', musicFolders); - - // return { ...state }; - // } - - // return state; - // } })); export default useLibraryStore; diff --git a/src/views/ViewSettingsLibrary.module.css b/src/views/ViewSettingsLibrary.module.css new file mode 100644 index 00000000..757e3df6 --- /dev/null +++ b/src/views/ViewSettingsLibrary.module.css @@ -0,0 +1,22 @@ +.libraryFolders { + list-style: none; + padding: 4px 8px; + margin: 0; + font-family: monospace; + border: solid 1px var(--border-color); + overflow-x: auto; + white-space: pre; + width: 150%; + /* overflow-x: auto; + white-space: nowrap; */ + /* overflow: hidden; */ + /* white-space: pre; */ +} + +.libraryFoldersRemove { + color: red; + border: none; + background: transparent; + appearance: none; + font-size: 16px; +} diff --git a/src/views/ViewSettingsLibrary.tsx b/src/views/ViewSettingsLibrary.tsx index ebb2d7cd..263da08d 100644 --- a/src/views/ViewSettingsLibrary.tsx +++ b/src/views/ViewSettingsLibrary.tsx @@ -1,24 +1,61 @@ +import { useLoaderData } from 'react-router-dom'; import * as Setting from '../components/Setting/Setting'; import Button from '../elements/Button/Button'; import Flexbox from '../elements/Flexbox/Flexbox'; import useLibraryStore, { useLibraryAPI } from '../stores/useLibraryStore'; +import type { SettingsLoaderData } from './ViewSettings'; + +import styles from './ViewSettingsLibrary.module.css'; export default function ViewSettingsLibrary() { const libraryAPI = useLibraryAPI(); const isLibraryRefreshing = useLibraryStore((state) => state.refreshing); + const { config } = useLoaderData() as SettingsLoaderData; return (
- Import music - - playlists from .m3u files will also be created. - - - + {folder} + + + ); + })} + + )} + + + + + .m3u files will also be imported as playlists. + Danger zone From ae082393682a4df34199e0cd57514b4c1495c069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20de=20la=20Martini=C3=A8re?= Date: Thu, 12 Sep 2024 12:53:41 +0200 Subject: [PATCH 2/4] Reset library folders when resetting library --- src/stores/useLibraryStore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stores/useLibraryStore.ts b/src/stores/useLibraryStore.ts index 05c67320..5460533c 100644 --- a/src/stores/useLibraryStore.ts +++ b/src/stores/useLibraryStore.ts @@ -218,6 +218,7 @@ const useLibraryStore = createStore((set, get) => ({ if (confirmed) { await database.reset(); + await config.set('library_folders', []); useToastsStore.getState().api.add('success', 'Library was reset'); invalidate(); } From 44a08a49e585e27fc3084029a3ca009eefa482e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20de=20la=20Martini=C3=A8re?= Date: Thu, 12 Sep 2024 15:33:42 +0200 Subject: [PATCH 3/4] Add autorefresh on startup option --- src-tauri/src/plugins/config.rs | 4 +- src/generated/typings/index.ts | 2 +- src/stores/SettingsAPI.ts | 67 +++++++++++++++++++++---------- src/views/Root.tsx | 6 +-- src/views/ViewSettingsLibrary.tsx | 12 +++++- 5 files changed, 62 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/plugins/config.rs b/src-tauri/src/plugins/config.rs index 660d1a87..0bc0144c 100644 --- a/src-tauri/src/plugins/config.rs +++ b/src-tauri/src/plugins/config.rs @@ -57,7 +57,8 @@ pub struct Config { pub default_view: DefaultView, pub library_sort_by: SortBy, pub library_sort_order: SortOrder, - pub library_folders: Vec, // Not used yet + pub library_folders: Vec, + pub library_autorefresh: bool, pub sleepblocker: bool, pub auto_update_checker: bool, pub minimize_to_tray: bool, @@ -79,6 +80,7 @@ impl Config { library_sort_by: SortBy::Artist, library_sort_order: SortOrder::Asc, library_folders: vec![], + library_autorefresh: false, sleepblocker: false, auto_update_checker: true, minimize_to_tray: false, diff --git a/src/generated/typings/index.ts b/src/generated/typings/index.ts index 507d2903..97b6e55d 100644 --- a/src/generated/typings/index.ts +++ b/src/generated/typings/index.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Config = { theme: string, audio_volume: number, audio_playback_rate: number | null, audio_output_device: string, audio_muted: boolean, audio_shuffle: boolean, audio_repeat: Repeat, default_view: DefaultView, library_sort_by: SortBy, library_sort_order: SortOrder, library_folders: Array, sleepblocker: boolean, auto_update_checker: boolean, minimize_to_tray: boolean, notifications: boolean, track_view_density: string, }; +export type Config = { theme: string, audio_volume: number, audio_playback_rate: number | null, audio_output_device: string, audio_muted: boolean, audio_shuffle: boolean, audio_repeat: Repeat, default_view: DefaultView, library_sort_by: SortBy, library_sort_order: SortOrder, library_folders: Array, library_autorefresh: boolean, sleepblocker: boolean, auto_update_checker: boolean, minimize_to_tray: boolean, notifications: boolean, track_view_density: string, }; export type DefaultView = "Library" | "Playlists"; diff --git a/src/stores/SettingsAPI.ts b/src/stores/SettingsAPI.ts index 6fd85b9e..701fd723 100644 --- a/src/stores/SettingsAPI.ts +++ b/src/stores/SettingsAPI.ts @@ -7,12 +7,28 @@ import config from '../lib/config'; import { getTheme } from '../lib/themes'; import { logAndNotifyError } from '../lib/utils'; +import { getCurrentWindow } from '@tauri-apps/api/window'; import { invalidate } from '../lib/query'; -import router from '../views/router'; +import useLibraryStore from './useLibraryStore'; import useToastsStore from './useToastsStore'; -interface UpdateCheckOptions { - silentFail?: boolean; +/** + * Init all settings, then show the app + */ +async function init(): Promise { + // This is non-blocking + checkForLibraryRefresh().catch(logAndNotifyError); + + // Blocking (the window should not be shown until it's done) + await Promise.allSettled([ + checkTheme(), + checkSleepBlocker(), + checkForUpdate({ silentFail: true }), + ]); + + // Show the app once everything is loaded + const currentWindow = await getCurrentWindow(); + await currentWindow.show(); } const setTheme = async (themeID: string): Promise => { @@ -48,7 +64,7 @@ async function setTracksDensity( density: Config['track_view_density'], ): Promise { await config.set('track_view_density', density); - router.revalidate(); + invalidate(); } /** @@ -63,7 +79,9 @@ async function checkSleepBlocker(): Promise { /** * Check if a new release is available */ -async function checkForUpdate(options: UpdateCheckOptions = {}): Promise { +async function checkForUpdate( + options: { silentFail?: boolean } = {}, +): Promise { const shouldCheck = await config.get('auto_update_checker'); if (!shouldCheck) { @@ -116,17 +134,6 @@ async function checkForUpdate(options: UpdateCheckOptions = {}): Promise { } } -/** - * Init all settings - */ -async function checkAllSettings(): Promise { - await Promise.allSettled([ - checkTheme(), - checkSleepBlocker(), - checkForUpdate({ silentFail: true }), - ]); -} - /** * Toggle sleep blocker */ @@ -136,7 +143,7 @@ async function toggleSleepBlocker(value: boolean): Promise { } else { await invoke('plugin:sleepblocker|disable'); } - router.revalidate(); + invalidate(); } /** @@ -146,7 +153,24 @@ async function setDefaultView(defaultView: DefaultView): Promise { await invoke('plugin:default-view|set', { defaultView, }); - router.revalidate(); + invalidate(); +} + +/** + * Toggle library refresh on startup + */ +async function toggleLibraryAutorefresh(value: boolean): Promise { + await config.set('library_autorefresh', value); + invalidate(); +} + +async function checkForLibraryRefresh(): Promise { + const autorefreshEnabled = await config.getInitial('library_autorefresh'); + + console.log('autorefresh', autorefreshEnabled); + if (autorefreshEnabled) { + useLibraryStore.getState().api.refresh(); + } } /** @@ -154,7 +178,7 @@ async function setDefaultView(defaultView: DefaultView): Promise { */ async function toggleAutoUpdateChecker(value: boolean): Promise { await config.set('auto_update_checker', value); - router.revalidate(); + invalidate(); } /** @@ -162,18 +186,19 @@ async function toggleAutoUpdateChecker(value: boolean): Promise { */ async function toggleDisplayNotifications(value: boolean): Promise { await config.set('notifications', value); - router.revalidate(); + invalidate(); } // Should we use something else to harmonize between zustand and non-store APIs? const SettingsAPI = { + init, setTheme, applyThemeToUI, setTracksDensity, - checkAllSettings, checkForUpdate, toggleSleepBlocker, setDefaultView, + toggleLibraryAutorefresh, toggleAutoUpdateChecker, toggleDisplayNotifications, }; diff --git a/src/views/Root.tsx b/src/views/Root.tsx index 164eb7d4..e542f267 100644 --- a/src/views/Root.tsx +++ b/src/views/Root.tsx @@ -1,4 +1,3 @@ -import { getCurrentWindow } from '@tauri-apps/api/window'; import { Suspense, useEffect } from 'react'; import { Outlet } from 'react-router-dom'; @@ -20,10 +19,7 @@ import type { LoaderData } from './router'; export default function ViewRoot() { useEffect(() => { - SettingsAPI.checkAllSettings() - // Show the app once everything is loaded - .then(() => getCurrentWindow()) - .then((window) => window.show()); + SettingsAPI.init(); }, []); return ( diff --git a/src/views/ViewSettingsLibrary.tsx b/src/views/ViewSettingsLibrary.tsx index 263da08d..6f9760e5 100644 --- a/src/views/ViewSettingsLibrary.tsx +++ b/src/views/ViewSettingsLibrary.tsx @@ -5,6 +5,8 @@ import Flexbox from '../elements/Flexbox/Flexbox'; import useLibraryStore, { useLibraryAPI } from '../stores/useLibraryStore'; import type { SettingsLoaderData } from './ViewSettings'; +import CheckboxSetting from '../components/SettingCheckbox/SettingCheckbox'; +import SettingsAPI from '../stores/SettingsAPI'; import styles from './ViewSettingsLibrary.module.css'; export default function ViewSettingsLibrary() { @@ -18,7 +20,7 @@ export default function ViewSettingsLibrary() { Files {config.library_folders.length === 0 && ( - There are no folders in your library + There are no folders in your library. )} {config.library_folders.length > 0 && ( @@ -57,6 +59,14 @@ export default function ViewSettingsLibrary() { .m3u files will also be imported as playlists. + + + Danger zone From 6a9ca449b7d660bb3fb805dd8d5b08f5bf2b45c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20de=20la=20Martini=C3=A8re?= Date: Fri, 13 Sep 2024 16:31:55 +0200 Subject: [PATCH 4/4] Report created track and playlist count at the end of a scan --- src-tauri/src/plugins/database.rs | 139 ++++++++++++++---------- src/components/Events/LibraryEvents.tsx | 4 +- src/generated/typings/index.ts | 6 +- src/lib/database.ts | 4 +- src/stores/SettingsAPI.ts | 7 ++ src/stores/useLibraryStore.ts | 22 +++- 6 files changed, 117 insertions(+), 65 deletions(-) diff --git a/src-tauri/src/plugins/database.rs b/src-tauri/src/plugins/database.rs index f4a48844..05479316 100644 --- a/src-tauri/src/plugins/database.rs +++ b/src-tauri/src/plugins/database.rs @@ -341,25 +341,34 @@ pub struct Playlist { */ #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "../../src/generated/typings/index.ts")] -pub struct Progress { +pub struct ScanProgress { current: usize, total: usize, } +#[derive(Default, Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/generated/typings/index.ts")] +pub struct ScanResult { + track_count: usize, + track_failures: usize, + playlist_count: usize, + playlist_failures: usize, +} + /** ---------------------------------------------------------------------------- * Commands * -------------------------------------------------------------------------- */ /** - * Popup a directory picker dialog, scan the selected folders, extract all - * ID3 tags from it, and update the DB accordingly. + * Scan the selected folders, extract all ID3 tags from it, and update the DB + * accordingly. */ #[tauri::command] async fn import_tracks_to_library( window: tauri::Window, db: State<'_, DB>, import_paths: Vec, -) -> AnyResult> { +) -> AnyResult { let webview_window = window.get_webview_window("main").unwrap(); info!("Importing paths to library:"); @@ -367,6 +376,8 @@ async fn import_tracks_to_library( info!(" - {:?}", path) } + let mut scan_result = ScanResult::default(); + // Scan all directories for valid files to be scanned and imported let mut track_paths = scan_dirs(&import_paths, &SUPPORTED_TRACKS_EXTENSIONS); let scanned_paths_count = track_paths.len(); @@ -393,7 +404,7 @@ async fn import_tracks_to_library( webview_window .emit( IPCEvent::LibraryScanProgress.as_ref(), - Progress { + ScanProgress { current: 0, total: track_paths.len(), }, @@ -416,7 +427,7 @@ async fn import_tracks_to_library( webview_window .emit( IPCEvent::LibraryScanProgress.as_ref(), - Progress { + ScanProgress { current: p_current, total: p_total, }, @@ -487,14 +498,16 @@ async fn import_tracks_to_library( .flatten() .collect::>(); + let track_failures = track_paths.len() - tracks.len(); + scan_result.track_count = tracks.len(); + scan_result.track_failures = track_failures; info!("{} tracks successfully scanned", tracks.len()); - info!( - "{} tracks failed to be scanned", - track_paths.len() - tracks.len() - ); + info!("{} tracks failed to be scanned", track_failures); + scan_logger.complete(); - // Insert all tracks in the DB + // Insert all tracks in the DB, we'are kind of assuming it cannot fail (regarding scan progress information), but + // it technically could. let db_insert_logger: TimeLogger = TimeLogger::new("Inserted tracks".into()); let result = db.insert_tracks(tracks).await; @@ -510,59 +523,69 @@ async fn import_tracks_to_library( info!("Found {} playlist(s) to import", playlist_paths.len()); for playlist_path in playlist_paths { - let mut reader = m3u::Reader::open(&playlist_path).unwrap(); - let playlist_dir_path = playlist_path.parent().unwrap(); - - let track_paths: Vec = reader - .entries() - .filter_map(|entry| { - let Ok(entry) = entry else { - return None; - }; + match { + let mut reader = m3u::Reader::open(&playlist_path).unwrap(); + let playlist_dir_path = playlist_path.parent().unwrap(); - match entry { - m3u::Entry::Path(path) => Some(playlist_dir_path.join(path)), - _ => return None, // We don't support (yet?) URLs in playlists - } - }) - .collect(); - - // Ok, this is sketchy. To avoid having to create a TrackByPath DB View, - // let's guess the ID of the track with UUID::v3 - let track_ids = track_paths - .iter() - .flat_map(|path| db.get_track_id_for_path(path)) - .collect::>(); - - let playlist_name = playlist_path - .file_stem() - .unwrap() - .to_str() - .unwrap_or("unknown playlist") - .to_owned(); - - let tracks = db.get_tracks(&track_ids).await?; - - if tracks.len() != track_ids.len() { - warn!( - "Playlist track mismatch ({} from playlist, {} from library)", - track_paths.len(), - tracks.len() - ); - } + let track_paths: Vec = reader + .entries() + .filter_map(|entry| { + let Ok(entry) = entry else { + return None; + }; + + match entry { + m3u::Entry::Path(path) => Some(playlist_dir_path.join(path)), + _ => return None, // We don't support (yet?) URLs in playlists + } + }) + .collect(); - info!( - r#"Creating playlist "{}" ({} tracks)"#, - &playlist_name, - &track_ids.len() - ); + // Ok, this is sketchy. To avoid having to create a TrackByPath DB View, + // let's guess the ID of the track with UUID::v3 + let track_ids = track_paths + .iter() + .flat_map(|path| db.get_track_id_for_path(path)) + .collect::>(); + + let playlist_name = playlist_path + .file_stem() + .unwrap() + .to_str() + .unwrap_or("unknown playlist") + .to_owned(); + + let tracks = db.get_tracks(&track_ids).await?; + + if tracks.len() != track_ids.len() { + warn!( + "Playlist track mismatch ({} from playlist, {} from library)", + track_paths.len(), + tracks.len() + ); + } - db.create_playlist(playlist_name, track_ids).await?; + info!( + r#"Creating playlist "{}" ({} tracks)"#, + &playlist_name, + &track_ids.len() + ); + + db.create_playlist(playlist_name, track_ids).await?; + Ok::<(), MuseeksError>(()) + } { + Ok(_) => { + scan_result.playlist_count += 1; + } + Err(err) => { + warn!("Failed to import playlist: {}", err); + scan_result.playlist_failures += 1; + } + } } // All good :] - let tracks = db.get_all_tracks().await?; - Ok(tracks) + Ok(scan_result) } #[tauri::command] diff --git a/src/components/Events/LibraryEvents.tsx b/src/components/Events/LibraryEvents.tsx index 4b33e5c2..438e707b 100644 --- a/src/components/Events/LibraryEvents.tsx +++ b/src/components/Events/LibraryEvents.tsx @@ -1,7 +1,7 @@ import { listen } from '@tauri-apps/api/event'; import { useEffect } from 'react'; -import type { IPCEvent, Progress } from '../../generated/typings'; +import type { IPCEvent, ScanProgress } from '../../generated/typings'; import { useLibraryAPI } from '../../stores/useLibraryStore'; /** @@ -11,7 +11,7 @@ function LibraryEvents() { const { setRefresh } = useLibraryAPI(); useEffect(() => { - const promise = listen( + const promise = listen( 'LibraryScanProgress' satisfies IPCEvent, ({ payload }) => { setRefresh(payload.current, payload.total); diff --git a/src/generated/typings/index.ts b/src/generated/typings/index.ts index 97b6e55d..9461b6e2 100644 --- a/src/generated/typings/index.ts +++ b/src/generated/typings/index.ts @@ -14,12 +14,14 @@ export type NumberOf = { no: number | null, of: number | null, }; * -------------------------------------------------------------------------- */ export type Playlist = { _id: string, name: string, tracks: Array, import_path: string | null, }; +export type Repeat = "All" | "One" | "None"; + /** * Scan progress */ -export type Progress = { current: number, total: number, }; +export type ScanProgress = { current: number, total: number, }; -export type Repeat = "All" | "One" | "None"; +export type ScanResult = { track_count: number, track_failures: number, playlist_count: number, playlist_failures: number, }; export type SortBy = "Artist" | "Album" | "Title" | "Duration" | "Genre"; diff --git a/src/lib/database.ts b/src/lib/database.ts index 850c2d3f..c8bfffcc 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; -import type { Playlist, Track } from '../generated/typings'; +import type { Playlist, ScanResult, Track } from '../generated/typings'; /** * Bridge for the UI to communicate with the backend and manipulate the Database @@ -32,7 +32,7 @@ const database = { }); }, - async importTracks(importPaths: Array): Promise { + async importTracks(importPaths: Array): Promise { return invoke('plugin:database|import_tracks_to_library', { importPaths, }); diff --git a/src/stores/SettingsAPI.ts b/src/stores/SettingsAPI.ts index 701fd723..d0c73b94 100644 --- a/src/stores/SettingsAPI.ts +++ b/src/stores/SettingsAPI.ts @@ -12,10 +12,17 @@ import { invalidate } from '../lib/query'; import useLibraryStore from './useLibraryStore'; import useToastsStore from './useToastsStore'; +// Manual prevention of a useEffect being called twice (to avoid refreshing the +// library twice on startup in dev mode). +let did_init = false; + /** * Init all settings, then show the app */ async function init(): Promise { + if (did_init) return; + + did_init = true; // This is non-blocking checkForLibraryRefresh().catch(logAndNotifyError); diff --git a/src/stores/useLibraryStore.ts b/src/stores/useLibraryStore.ts index 5460533c..53ebf956 100644 --- a/src/stores/useLibraryStore.ts +++ b/src/stores/useLibraryStore.ts @@ -120,7 +120,27 @@ const useLibraryStore = createStore((set, get) => ({ set({ refreshing: true }); const libraryFolders = await config.get('library_folders'); - await database.importTracks(libraryFolders); + const scanResult = await database.importTracks(libraryFolders); + + if (scanResult.track_count > 0) { + useToastsStore + .getState() + .api.add( + 'success', + `${scanResult.track_count} track(s) were added to the library.`, + 5000, + ); + } + + if (scanResult.playlist_count > 0) { + useToastsStore + .getState() + .api.add( + 'success', + `${scanResult.playlist_count} playlist(s) were added to the library.`, + 5000, + ); + } invalidate(); } catch (err) {