From 2b5d270dec5bd1d992fae0b66e21be4f82d13c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20de=20la=20Martini=C3=A8re?= Date: Sun, 8 Sep 2024 01:22:46 +0200 Subject: [PATCH] Implement file association for tracks --- src-tauri/Info.plist | 13 +++ src-tauri/src/libs/events.rs | 1 + src-tauri/src/libs/file_associations.rs | 94 ++++++++++++++++ src-tauri/src/libs/mod.rs | 2 + src-tauri/src/libs/track.rs | 126 ++++++++++++++++++++++ src-tauri/src/libs/utils.rs | 40 ++----- src-tauri/src/main.rs | 17 ++- src-tauri/src/plugins/database.rs | 125 ++------------------- src-tauri/tauri.conf.json | 44 +++++++- src/components/Events/IPCPlayerEvents.tsx | 6 +- src/generated/typings/index.ts | 6 +- 11 files changed, 318 insertions(+), 156 deletions(-) create mode 100644 src-tauri/src/libs/file_associations.rs create mode 100644 src-tauri/src/libs/track.rs diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index fe253ec7b..61e8c7229 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -6,5 +6,18 @@ Request camera access for WebRTC NSMicrophoneUsageDescription Request microphone access for WebRTC + CFBundleURLTypes + + + CFBundleURLName + + Museeks + CFBundleURLSchemes + + + museeks + + + diff --git a/src-tauri/src/libs/events.rs b/src-tauri/src/libs/events.rs index 0ebeb0118..e92dedfa9 100644 --- a/src-tauri/src/libs/events.rs +++ b/src-tauri/src/libs/events.rs @@ -12,6 +12,7 @@ pub enum IPCEvent<'a> { PlaybackPlayPause, PlaybackPrevious, PlaybackNext, + PlaybackStart, // Scan-related events LibraryScanProgress, // Menu-related events diff --git a/src-tauri/src/libs/file_associations.rs b/src-tauri/src/libs/file_associations.rs new file mode 100644 index 000000000..171f1027a --- /dev/null +++ b/src-tauri/src/libs/file_associations.rs @@ -0,0 +1,94 @@ +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; + +use crate::libs::track::get_track_from_file; +use crate::libs::utils::is_file_valid; +use crate::plugins::database::SUPPORTED_TRACKS_EXTENSIONS; + +use super::events::IPCEvent; + +/** + * Linux + Windows + */ +#[cfg(any(windows, target_os = "linux"))] +pub fn setup_file_associations(app: tauri::App) { + let mut files = Vec::new(); + + // NOTICE: `args` may include URL protocol (`your-app-protocol://`) + // or arguments (`--`) if your app supports them. + // files may aslo be passed as `file://path/to/file` + for maybe_file in std::env::args().skip(1) { + // skip flags like -f or --flag + if maybe_file.starts_with("-") { + continue; + } + + // handle `file://` path urls and skip other urls + if let Ok(url) = url::Url::parse(&maybe_file) { + if let Ok(path) = url.to_file_path() { + files.push(path); + } + } else { + files.push(PathBuf::from(maybe_file)) + } + } + + handle_file_associations(app.handle().clone(), files); +} + +/** + * macOS + */ +#[cfg(target_os = "macos")] +pub fn setup_file_associations(app: &AppHandle, event: tauri::RunEvent) { + if let tauri::RunEvent::Opened { urls } = event { + let files = urls + .into_iter() + .filter_map(|url| url.to_file_path().ok()) + .collect::>(); + + handle_file_associations(app.clone(), files); + } +} + +/** + * Handle the app file association. + * For audio files, it will scan the files, create a queue and play it, *without* adding the tracks to the library. + * For playlists files, not implemented. + */ +fn handle_file_associations(app_handle: AppHandle, mut files: Vec) { + files = files + .into_iter() + .filter(|path| is_file_valid(path, &SUPPORTED_TRACKS_EXTENSIONS)) + .collect(); + + // This is for the `asset:` protocol to work, ensuring access to the files + let asset_protocol_scope = app_handle.asset_protocol_scope(); + + for file in &files { + let _ = asset_protocol_scope.allow_file(file); + } + + // Build a list of tracks, without importing them to the library + let queue = files + .par_iter() + .map(|path| get_track_from_file(&path)) + .flatten() + .collect::>(); + + let window = app_handle.get_webview_window("main").unwrap(); + + match window.emit(IPCEvent::PlaybackStart.as_ref(), queue) { + Ok(_) => (), + Err(err) => app_handle + .dialog() + .message(format!( + "Something went wrong when attempting to play this file: {}", + err + )) + .kind(MessageDialogKind::Error) + .show(|_| {}), + }; +} diff --git a/src-tauri/src/libs/mod.rs b/src-tauri/src/libs/mod.rs index 7947cb112..0e5a3d9b9 100644 --- a/src-tauri/src/libs/mod.rs +++ b/src-tauri/src/libs/mod.rs @@ -1,3 +1,5 @@ pub mod error; pub mod events; +pub mod file_associations; +pub mod track; pub mod utils; diff --git a/src-tauri/src/libs/track.rs b/src-tauri/src/libs/track.rs new file mode 100644 index 000000000..1fafb913c --- /dev/null +++ b/src-tauri/src/libs/track.rs @@ -0,0 +1,126 @@ +use std::path::PathBuf; + +use bonsaidb::core::schema::Collection; +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::tag::{Accessor, ItemKey}; +use log::{error, warn}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +/** + * Track + * represent a single track, id and path should be unique + */ +#[derive(Debug, Clone, Serialize, Deserialize, Collection, TS)] +#[collection(name="tracks", primary_key = String)] +#[ts(export, export_to = "../../src/generated/typings/index.ts")] +pub struct Track { + #[natural_id] + pub _id: String, + pub title: String, + pub album: String, + pub artists: Vec, + pub genres: Vec, + pub year: Option, + pub duration: u32, + pub track: NumberOf, + pub disk: NumberOf, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/generated/typings/index.ts")] +pub struct NumberOf { + pub no: Option, + pub of: Option, +} + +/** + * Generate a Track struct from a Path, or nothing if it is not a valid audio + * file + */ +pub fn get_track_from_file(path: &PathBuf) -> Option { + match lofty::read_from_path(&path) { + Ok(tagged_file) => { + let tag = tagged_file.primary_tag()?; + + // IMPROVE ME: Is there a more idiomatic way of doing the following? + let mut artists: Vec = tag + .get_strings(&ItemKey::TrackArtist) + .map(ToString::to_string) + .collect(); + + if artists.is_empty() { + artists = tag + .get_strings(&ItemKey::AlbumArtist) + .map(ToString::to_string) + .collect(); + } + + if artists.is_empty() { + artists = vec!["Unknown Artist".into()]; + } + + let Some(id) = get_track_id_for_path(path) else { + return None; + }; + + Some(Track { + _id: id, + title: tag + .get_string(&ItemKey::TrackTitle) + .unwrap_or("Unknown") + .to_string(), + album: tag + .get_string(&ItemKey::AlbumTitle) + .unwrap_or("Unknown") + .to_string(), + artists, + genres: tag + .get_strings(&ItemKey::Genre) + .map(ToString::to_string) + .collect(), + year: tag.year(), + duration: u32::try_from(tagged_file.properties().duration().as_secs()).unwrap_or(0), + track: NumberOf { + no: tag.track(), + of: tag.track_total(), + }, + disk: NumberOf { + no: tag.disk(), + of: tag.disk_total(), + }, + path: path.to_owned(), + }) + } + Err(err) => { + warn!("Failed to get ID3 tags: \"{}\". File {:?}", err, path); + None + } + } +} + +/** + * Generate an ID for a track based on its location. + * + * We leverage UUID v3 on tracks paths to easily retrieve tracks by path. + * This is not great and ideally we should use a DB view instead. One day. + */ +pub fn get_track_id_for_path(path: &PathBuf) -> Option { + match std::fs::canonicalize(path) { + Ok(canonicalized_path) => { + return Some( + Uuid::new_v3( + &Uuid::NAMESPACE_OID, + canonicalized_path.to_string_lossy().as_bytes(), + ) + .to_string(), + ); + } + Err(err) => { + error!(r#"ID could not be generated for path {:?}: {}"#, path, err); + return None; + } + }; +} diff --git a/src-tauri/src/libs/utils.rs b/src-tauri/src/libs/utils.rs index 24ad3e591..d5a609ced 100644 --- a/src-tauri/src/libs/utils.rs +++ b/src-tauri/src/libs/utils.rs @@ -43,37 +43,11 @@ fn is_dir_visible(entry: &walkdir::DirEntry) -> bool { } /** - * Take an entry and filter out: - * - directories - * - non-allowed extensions + * Take an entry and filter out non-allowed extensions */ -fn is_entry_valid( - result: std::result::Result, - allowed_extensions: &[&str], -) -> Option { - // If the user does not have access to the file - if result.is_err() { - return None; - } - - let entry = result.unwrap(); - let file_type = entry.file_type(); - - let extension = entry - .path() - .extension() - .and_then(OsStr::to_str) - .unwrap_or(""); - - let is_file = file_type.is_file(); - let has_valid_extension = allowed_extensions.contains(&extension); - - if is_file && has_valid_extension { - // Only return the file path, that's what we're interested in - return Some(entry.into_path()); - } - - return None; +pub fn is_file_valid(path: &PathBuf, allowed_extensions: &[&str]) -> bool { + let extension = path.extension().and_then(OsStr::to_str).unwrap_or(""); + allowed_extensions.contains(&extension) } /** @@ -94,8 +68,10 @@ pub fn scan_dir(path: &PathBuf, allowed_extensions: &[&str]) -> Vec { WalkDir::new(path) .follow_links(true) .into_iter() - .filter_entry(|entry| is_dir_visible(entry)) - .filter_map(|entry| is_entry_valid(entry, allowed_extensions)) + .filter_entry(|entry| is_dir_visible(entry) && entry.file_type().is_file()) + .filter_map(Result::ok) + .map(|entry| entry.into_path()) + .filter(|path| is_file_valid(path, allowed_extensions)) .collect() } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f417b6358..544166c0b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ mod libs; mod plugins; +use libs::file_associations::setup_file_associations; use libs::utils::{get_theme_from_name, show_window}; use log::LevelFilter; use plugins::config::ConfigManager; @@ -58,6 +59,9 @@ async fn main() { ) // TODO: tauri-plugin-theme to update the native theme at runtime .setup(|app| { + #[cfg(not(target_os = "macos"))] + setup_file_associations(app); + let config_manager = app.state::(); let conf = config_manager.get()?; @@ -85,6 +89,15 @@ async fn main() { Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run(|app, event| { + #[cfg(target_os = "macos")] + setup_file_associations(app, event); + + #[cfg(not(target_os = "macos"))] + { + drop(app); + } + }); } diff --git a/src-tauri/src/plugins/database.rs b/src-tauri/src/plugins/database.rs index c759a475e..47555b3ab 100644 --- a/src-tauri/src/plugins/database.rs +++ b/src-tauri/src/plugins/database.rs @@ -6,8 +6,6 @@ use bonsaidb::local::config::{Builder as BonsaiBuilder, StorageConfiguration}; use bonsaidb::local::AsyncDatabase; use bonsaidb::local::AsyncStorage; use itertools::Itertools; -use lofty::file::{AudioFile, TaggedFileExt}; -use lofty::tag::{Accessor, ItemKey}; use log::{error, info, warn}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; @@ -20,21 +18,22 @@ use tauri::Emitter; use tauri::{Manager, Runtime, State}; use tauri_plugin_dialog::{DialogExt, FilePath}; use ts_rs::TS; -use uuid::Uuid; use crate::libs::error::{AnyResult, MuseeksError}; use crate::libs::events::IPCEvent; +use crate::libs::track::{get_track_from_file, get_track_id_for_path, Track}; use crate::libs::utils::{scan_dirs, TimeLogger}; use super::config::get_storage_dir; const INSERTION_BATCH: usize = 200; -pub const SUPPORTED_TRACKS_EXTENSIONS: [&str; 12] = [ - "mp3", "mp4", "aac", "m4a", "3gp", "wav", /* mp3 / mp4 */ - "ogg", "ogv", "ogm", "opus", /* Opus */ +// KEEP THAT IN SYNC with Tauri's file associations in tauri.conf.json +pub const SUPPORTED_TRACKS_EXTENSIONS: [&str; 9] = [ + "mp3", "aac", "m4a", "3gp", "wav", /* mp3 / mp4 */ + "ogg", "opus", /* Opus */ "flac", /* Flac */ - "webm", /* Web media */ + "weba", /* Web media */ ]; pub const SUPPORTED_PLAYLISTS_EXTENSIONS: [&str; 1] = ["m3u"]; @@ -59,28 +58,6 @@ impl DB { self.playlists.collection::() } - /** - * We leverage UUID v3 on tracks paths to easily retrieve tracks by path. - * This is not great and ideally we should use a DB view instead. One day. - */ - fn get_track_id_for_path(&self, path: &PathBuf) -> Option { - match std::fs::canonicalize(path) { - Ok(canonicalized_path) => { - return Some( - Uuid::new_v3( - &Uuid::NAMESPACE_OID, - canonicalized_path.to_string_lossy().as_bytes(), - ) - .to_string(), - ); - } - Err(err) => { - error!(r#"ID could not be generated for path {:?}: {}"#, path, err); - return None; - } - }; - } - /** * Get all the tracks (and their content) from the database */ @@ -293,34 +270,6 @@ impl DB { } } -/** ---------------------------------------------------------------------------- - * Track - * represent a single track, id and path should be unique - * -------------------------------------------------------------------------- */ -#[derive(Debug, Clone, Serialize, Deserialize, Collection, TS)] -#[collection(name="tracks", primary_key = String)] -#[ts(export, export_to = "../../src/generated/typings/index.ts")] -pub struct Track { - #[natural_id] - pub _id: String, - pub title: String, - pub album: String, - pub artists: Vec, - pub genres: Vec, - pub year: Option, - pub duration: u32, - pub track: NumberOf, - pub disk: NumberOf, - pub path: PathBuf, -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/generated/typings/index.ts")] -pub struct NumberOf { - pub no: Option, - pub of: Option, -} - /** ---------------------------------------------------------------------------- * Playlist * represent a playlist, that has a name and a list of tracks @@ -436,65 +385,7 @@ async fn import_tracks_to_library( .unwrap(); } - match lofty::read_from_path(&path) { - Ok(tagged_file) => { - let tag = tagged_file.primary_tag()?; - - // IMPROVE ME: Is there a more idiomatic way of doing the following? - let mut artists: Vec = tag - .get_strings(&ItemKey::TrackArtist) - .map(ToString::to_string) - .collect(); - - if artists.is_empty() { - artists = tag - .get_strings(&ItemKey::AlbumArtist) - .map(ToString::to_string) - .collect(); - } - - if artists.is_empty() { - artists = vec!["Unknown Artist".into()]; - } - - let Some(id) = db.get_track_id_for_path(path) else { - return None; - }; - - Some(Track { - _id: id, - title: tag - .get_string(&ItemKey::TrackTitle) - .unwrap_or("Unknown") - .to_string(), - album: tag - .get_string(&ItemKey::AlbumTitle) - .unwrap_or("Unknown") - .to_string(), - artists, - genres: tag - .get_strings(&ItemKey::Genre) - .map(ToString::to_string) - .collect(), - year: tag.year(), - duration: u32::try_from(tagged_file.properties().duration().as_secs()) - .unwrap_or(0), - track: NumberOf { - no: tag.track(), - of: tag.track_total(), - }, - disk: NumberOf { - no: tag.disk(), - of: tag.disk_total(), - }, - path: path.to_owned(), - }) - } - Err(err) => { - warn!("Failed to get ID3 tags: \"{}\". File {:?}", err, path); - None - } - } + get_track_from_file(path) }) .flatten() .collect::>(); @@ -558,7 +449,7 @@ async fn import_tracks_to_library( // 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)) + .flat_map(|path| get_track_id_for_path(path)) .collect::>(); let playlist_name = playlist_path diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6f5621d3c..a705fc5bd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,6 +28,48 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": ["icons/icon.png"] + "resources": ["icons/icon.png"], + "fileAssociations": [ + { + "ext": ["aac"], + "mimeType": "audio/aac", + "role": "Viewer" + }, + { + "ext": ["mp3"], + "mimeType": "audio/mpeg", + "role": "Viewer" + }, + { + "ext": ["m4a"], + "mimeType": "audio/mp4", + "role": "Viewer" + }, + { + "ext": ["3gp"], + "mimeType": "audio/3gp", + "role": "Viewer" + }, + { + "ext": ["ogg", "opus"], + "mimeType": "audio/ogg", + "role": "Viewer" + }, + { + "ext": ["flac"], + "mimeType": "audio/flac", + "role": "Viewer" + }, + { + "ext": ["wav"], + "mimeType": "audio/wav", + "role": "Viewer" + }, + { + "ext": ["weba"], + "mimeType": "audio/weba", + "role": "Viewer" + } + ] } } diff --git a/src/components/Events/IPCPlayerEvents.tsx b/src/components/Events/IPCPlayerEvents.tsx index 76151fa3e..8220b7627 100644 --- a/src/components/Events/IPCPlayerEvents.tsx +++ b/src/components/Events/IPCPlayerEvents.tsx @@ -1,7 +1,7 @@ import { listen } from '@tauri-apps/api/event'; import { useEffect } from 'react'; -import type { IPCEvent } from '../../generated/typings'; +import type { IPCEvent, Track } from '../../generated/typings'; import { usePlayerAPI } from '../../stores/usePlayerStore'; /** @@ -18,6 +18,10 @@ function IPCPlayerEvents() { listen('PlaybackPrevious' satisfies IPCEvent, playerAPI.previous), listen('PlaybackNext' satisfies IPCEvent, playerAPI.next), listen('PlaybackStop' satisfies IPCEvent, playerAPI.stop), + listen( + 'PlaybackStart' satisfies IPCEvent, + ({ payload }: { payload: Track[] }) => playerAPI.start(payload), + ), ]; return function cleanup() { diff --git a/src/generated/typings/index.ts b/src/generated/typings/index.ts index 9461b6e20..d753a3b1c 100644 --- a/src/generated/typings/index.ts +++ b/src/generated/typings/index.ts @@ -4,7 +4,7 @@ export type Config = { theme: string, audio_volume: number, audio_playback_rate: export type DefaultView = "Library" | "Playlists"; -export type IPCEvent = { "Unknown": string } | "PlaybackPlay" | "PlaybackPause" | "PlaybackStop" | "PlaybackPlayPause" | "PlaybackPrevious" | "PlaybackNext" | "LibraryScanProgress" | "GoToLibrary" | "GoToPlaylists" | "GoToSettings" | "JumpToPlayingTrack"; +export type IPCEvent = { "Unknown": string } | "PlaybackPlay" | "PlaybackPause" | "PlaybackStop" | "PlaybackPlayPause" | "PlaybackPrevious" | "PlaybackNext" | "PlaybackStart" | "LibraryScanProgress" | "GoToLibrary" | "GoToPlaylists" | "GoToSettings" | "JumpToPlayingTrack"; export type NumberOf = { no: number | null, of: number | null, }; @@ -27,8 +27,8 @@ export type SortBy = "Artist" | "Album" | "Title" | "Duration" | "Genre"; export type SortOrder = "Asc" | "Dsc"; -/** ---------------------------------------------------------------------------- +/** * Track * represent a single track, id and path should be unique - * -------------------------------------------------------------------------- */ + */ export type Track = { _id: string, title: string, album: string, artists: Array, genres: Array, year: number | null, duration: number, track: NumberOf, disk: NumberOf, path: string, };