Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Persistent Library Folders #780

Merged
merged 4 commits into from
Sep 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/src/plugins/app_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
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())
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/plugins/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>, // Not used yet
pub library_folders: Vec<PathBuf>,
pub library_autorefresh: bool,
pub sleepblocker: bool,
pub auto_update_checker: bool,
pub minimize_to_tray: bool,
Expand All @@ -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,
Expand Down
139 changes: 81 additions & 58 deletions src-tauri/src/plugins/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,32 +341,43 @@ 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<R: Runtime>(
window: tauri::Window<R>,
db: State<'_, DB>,
import_paths: Vec<PathBuf>,
) -> AnyResult<Vec<Track>> {
) -> AnyResult<ScanResult> {
let webview_window = window.get_webview_window("main").unwrap();

info!("Importing paths to library:");
for path in &import_paths {
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();
Expand All @@ -393,7 +404,7 @@ async fn import_tracks_to_library<R: Runtime>(
webview_window
.emit(
IPCEvent::LibraryScanProgress.as_ref(),
Progress {
ScanProgress {
current: 0,
total: track_paths.len(),
},
Expand All @@ -416,7 +427,7 @@ async fn import_tracks_to_library<R: Runtime>(
webview_window
.emit(
IPCEvent::LibraryScanProgress.as_ref(),
Progress {
ScanProgress {
current: p_current,
total: p_total,
},
Expand Down Expand Up @@ -487,14 +498,16 @@ async fn import_tracks_to_library<R: Runtime>(
.flatten()
.collect::<Vec<Track>>();

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;

Expand All @@ -510,59 +523,69 @@ async fn import_tracks_to_library<R: Runtime>(
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<PathBuf> = 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::<Vec<String>>();

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<PathBuf> = 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::<Vec<String>>();

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]
Expand Down
1 change: 0 additions & 1 deletion src-tauri/src/plugins/shell_extension.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/components/Events/LibraryEvents.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -11,7 +11,7 @@ function LibraryEvents() {
const { setRefresh } = useLibraryAPI();

useEffect(() => {
const promise = listen<Progress>(
const promise = listen<ScanProgress>(
'LibraryScanProgress' satisfies IPCEvent,
({ payload }) => {
setRefresh(payload.current, payload.total);
Expand Down
5 changes: 3 additions & 2 deletions src/elements/Flexbox/Flexbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});

Expand All @@ -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}
</div>
Expand Down
8 changes: 5 additions & 3 deletions src/generated/typings/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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<string>, library_autorefresh: boolean, sleepblocker: boolean, auto_update_checker: boolean, minimize_to_tray: boolean, notifications: boolean, track_view_density: string, };

export type DefaultView = "Library" | "Playlists";

Expand All @@ -14,12 +14,14 @@ export type NumberOf = { no: number | null, of: number | null, };
* -------------------------------------------------------------------------- */
export type Playlist = { _id: string, name: string, tracks: Array<string>, 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";

Expand Down
35 changes: 34 additions & 1 deletion src/lib/__tests__/utils-library.test.ts
Original file line number Diff line number Diff line change
@@ -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<Track> = [
{
Expand Down Expand Up @@ -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']);
});
});
4 changes: 2 additions & 2 deletions src/lib/database.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,7 +32,7 @@ const database = {
});
},

async importTracks(importPaths: Array<string>): Promise<void> {
async importTracks(importPaths: Array<string>): Promise<ScanResult> {
return invoke('plugin:database|import_tracks_to_library', {
importPaths,
});
Expand Down
16 changes: 16 additions & 0 deletions src/lib/utils-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string>): Array<string> => {
return uniq(
paths.filter((path) => {
const isDuplicate = paths.some((otherPath) => {
return path.startsWith(otherPath) && path !== otherPath;
});

return !isDuplicate;
}),
);
};
Loading