Skip to content

Commit

Permalink
Implement file association for tracks
Browse files Browse the repository at this point in the history
  • Loading branch information
martpie committed Sep 29, 2024
1 parent 1949543 commit 2b5d270
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 156 deletions.
13 changes: 13 additions & 0 deletions src-tauri/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,18 @@
<string>Request camera access for WebRTC</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<!-- app's bundle identifier -->
<string>Museeks</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- register the museeks:// schemes -->
<string>museeks</string>
</array>
</dict>
</array>
</dict>
</plist>
1 change: 1 addition & 0 deletions src-tauri/src/libs/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub enum IPCEvent<'a> {
PlaybackPlayPause,
PlaybackPrevious,
PlaybackNext,
PlaybackStart,
// Scan-related events
LibraryScanProgress,
// Menu-related events
Expand Down
94 changes: 94 additions & 0 deletions src-tauri/src/libs/file_associations.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>();

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<PathBuf>) {
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::<Vec<_>>();

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(|_| {}),
};
}
2 changes: 2 additions & 0 deletions src-tauri/src/libs/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod error;
pub mod events;
pub mod file_associations;
pub mod track;
pub mod utils;
126 changes: 126 additions & 0 deletions src-tauri/src/libs/track.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub genres: Vec<String>,
pub year: Option<u32>,
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<u32>,
pub of: Option<u32>,
}

/**
* 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<Track> {
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<String> = 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<String> {
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;
}
};
}
40 changes: 8 additions & 32 deletions src-tauri/src/libs/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<walkdir::DirEntry, walkdir::Error>,
allowed_extensions: &[&str],
) -> Option<PathBuf> {
// 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)
}

/**
Expand All @@ -94,8 +68,10 @@ pub fn scan_dir(path: &PathBuf, allowed_extensions: &[&str]) -> Vec<PathBuf> {
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()
}

Expand Down
17 changes: 15 additions & 2 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<ConfigManager>();
let conf = config_manager.get()?;

Expand Down Expand Up @@ -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);
}
});
}
Loading

0 comments on commit 2b5d270

Please sign in to comment.