Vendor helgoboss/helgobox (ReaLearn) as basis for custom UI fork
Stripped upstream git history; starting point for replacing the native SWELL/Win32 mapping UI with something more suited to bulk editing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "pot"
|
||||
version = "0.1.0"
|
||||
authors = ["Benjamin Klum <benjamin.klum@helgoboss.org>"]
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# Own
|
||||
base.workspace = true
|
||||
reaper-high.workspace = true
|
||||
reaper-medium.workspace = true
|
||||
helgobox-api.workspace = true
|
||||
rppxml-parser.workspace = true
|
||||
|
||||
# 3rd-party
|
||||
enumset.workspace = true
|
||||
itertools.workspace = true
|
||||
wildmatch.workspace = true
|
||||
serde.workspace = true
|
||||
derive_more.workspace = true
|
||||
tokio.workspace = true
|
||||
enum-map.workspace = true
|
||||
once_cell.workspace = true
|
||||
lexical-sort.workspace = true
|
||||
strum.workspace = true
|
||||
splitty.workspace = true
|
||||
rust-ini.workspace = true
|
||||
walkdir.workspace = true
|
||||
either.workspace = true
|
||||
riff-io.workspace = true
|
||||
rusqlite = { workspace = true, features = ["bundled"] }
|
||||
rmp-serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
futures.workspace = true
|
||||
derivative.workspace = true
|
||||
tempfile.workspace = true
|
||||
sanitize-filename.workspace = true
|
||||
tracing.workspace = true
|
||||
regex.workspace = true
|
||||
nanoid.workspace = true
|
||||
chrono.workspace = true
|
||||
anyhow.workspace = true
|
||||
camino.workspace = true
|
||||
|
||||
[lints.clippy]
|
||||
enum_glob_use = "deny"
|
||||
@@ -0,0 +1,710 @@
|
||||
use crate::plugins::ProductKind;
|
||||
use crate::provider_database::{
|
||||
DatabaseId, FIL_HAS_PREVIEW_TRUE, FIL_IS_FAVORITE_TRUE, FIL_IS_USER_PRESET_FALSE,
|
||||
FIL_IS_USER_PRESET_TRUE,
|
||||
};
|
||||
use crate::{FilterItem, PotPreset};
|
||||
use enum_map::EnumMap;
|
||||
use enumset::EnumSet;
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet};
|
||||
use std::fmt;
|
||||
use std::fmt::{Display, Formatter, Write};
|
||||
use std::str::FromStr;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
/// An ID for uniquely identifying a preset along with its corresponding database.
|
||||
///
|
||||
/// This ID is stable only at runtime and only until the database is refreshed.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
|
||||
pub struct PresetId {
|
||||
pub database_id: DatabaseId,
|
||||
pub preset_id: InnerPresetId,
|
||||
}
|
||||
|
||||
impl PresetId {
|
||||
pub fn new(database_id: DatabaseId, preset_id: InnerPresetId) -> Self {
|
||||
Self {
|
||||
database_id,
|
||||
preset_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An ID for uniquely identifying a preset within a certain database.
|
||||
///
|
||||
/// This ID is stable only at runtime and only until the database is refreshed.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct InnerPresetId(pub u32);
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
|
||||
pub struct FilterItemId(pub Option<Fil>);
|
||||
|
||||
impl FilterItemId {
|
||||
pub const NONE: Self = Self(None);
|
||||
}
|
||||
|
||||
/// Filter value.
|
||||
///
|
||||
/// These can be understood as possible types of a filter item kind. Not all types make sense
|
||||
/// for a particular filter item kind.
|
||||
///
|
||||
/// Many of these types are not suitable for persistence because their values are not stable.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
|
||||
pub enum Fil {
|
||||
/// A typical integer filter value to refer to a filter item in a specific Komplete database.
|
||||
///
|
||||
/// Makes sense for all filter kinds supported by Komplete.
|
||||
///
|
||||
/// This needs a pot filter item kind and a specific Komplete database to make full sense.
|
||||
/// The integers are not suited for being persisted because different Komplete scans can yield
|
||||
/// different integers! So they should only be used at runtime and translated to something
|
||||
/// more stable for persistence.
|
||||
Komplete(u32),
|
||||
/// Refers to a specific pot database.
|
||||
///
|
||||
/// Only makes sense for the pot filter kind "Database".
|
||||
///
|
||||
/// Only valid at runtime, not suitable for persistence.
|
||||
Database(DatabaseId),
|
||||
/// Refers to something that can be true of false, e.g. "favorite" or "not favorite"
|
||||
/// or "available" or "not available".
|
||||
///
|
||||
/// Makes sense for all boolean filter kinds.
|
||||
///
|
||||
/// Suitable for persistence.
|
||||
Boolean(bool),
|
||||
/// Refers to a kind of product.
|
||||
///
|
||||
/// Only makes sense for the pot filter kind "Product kind".
|
||||
ProductKind(ProductKind),
|
||||
/// Refers to a product.
|
||||
///
|
||||
/// Only makes sense for the pot filter kind "Product".
|
||||
///
|
||||
/// Not suitable for persistence because product IDs are created at runtime.
|
||||
Product(ProductId),
|
||||
/// Refers to a project.
|
||||
///
|
||||
/// Only makes sense for the pot filter kind "Project".
|
||||
///
|
||||
/// Not suitable for persistence because project IDs are created at runtime.
|
||||
Project(ProjectId),
|
||||
}
|
||||
|
||||
/// Runtime ID for a [`Product`].
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, derive_more::Display)]
|
||||
pub struct ProductId(pub u32);
|
||||
|
||||
/// Runtime ID for a REAPER project.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, derive_more::Display)]
|
||||
pub struct ProjectId(pub u32);
|
||||
|
||||
pub type FilterItemCollections = GenericFilterItemCollections<FilterItem>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GenericFilterItemCollections<T>(EnumMap<PotFilterKind, Vec<T>>);
|
||||
|
||||
pub trait HasFilterItemId {
|
||||
fn id(&self) -> FilterItemId;
|
||||
}
|
||||
|
||||
impl HasFilterItemId for FilterItem {
|
||||
fn id(&self) -> FilterItemId {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for GenericFilterItemCollections<T> {
|
||||
fn default() -> Self {
|
||||
Self(enum_map::enum_map! { _ => vec![] })
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GenericFilterItemCollections<T> {
|
||||
pub fn empty() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn get(&self, kind: PotFilterKind) -> &[T] {
|
||||
&self.0[kind]
|
||||
}
|
||||
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (PotFilterKind, &mut Vec<T>)> {
|
||||
self.0.iter_mut()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, kind: PotFilterKind, items: Vec<T>) {
|
||||
self.0[kind] = items;
|
||||
}
|
||||
|
||||
pub fn extend(&mut self, kind: PotFilterKind, items: impl Iterator<Item = T>) {
|
||||
self.0[kind].extend(items);
|
||||
}
|
||||
|
||||
pub fn are_filled_already(&self) -> bool {
|
||||
// Just take any of of the constant filters that should be filled.
|
||||
!self.get(PotFilterKind::IsFavorite).is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoIterator for GenericFilterItemCollections<T> {
|
||||
type Item = (PotFilterKind, Vec<T>);
|
||||
type IntoIter = <EnumMap<PotFilterKind, Vec<T>> as IntoIterator>::IntoIter;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: HasFilterItemId> GenericFilterItemCollections<T> {
|
||||
pub fn narrow_down(&mut self, kind: PotFilterKind, includes: &NonCryptoHashSet<FilterItemId>) {
|
||||
self.0[kind].retain(|item| includes.contains(&item.id()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some` means a filter is set (can also be the `<None>` filter).
|
||||
/// `None` means no filter is set (`<Any>`).
|
||||
pub type OptFilter = Option<FilterItemId>;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct Filters(EnumMap<PotFilterKind, OptFilter>);
|
||||
|
||||
impl Filters {
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn wants_preview(&self) -> Option<bool> {
|
||||
if let Some(FilterItemId(Some(fil))) = self.get(PotFilterKind::HasPreview) {
|
||||
Some(fil == FIL_HAS_PREVIEW_TRUE)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn database_matches(&self, db_id: DatabaseId) -> bool {
|
||||
self.matches(PotFilterKind::Database, Fil::Database(db_id))
|
||||
}
|
||||
|
||||
pub fn wants_user_presets_only(&self) -> bool {
|
||||
self.wants_only(PotFilterKind::IsUser, FIL_IS_USER_PRESET_TRUE)
|
||||
}
|
||||
|
||||
pub fn wants_factory_presets_only(&self) -> bool {
|
||||
self.wants_only(PotFilterKind::IsUser, FIL_IS_USER_PRESET_FALSE)
|
||||
}
|
||||
|
||||
pub fn wants_favorites_only(&self) -> bool {
|
||||
self.wants_only(PotFilterKind::IsFavorite, FIL_IS_FAVORITE_TRUE)
|
||||
}
|
||||
|
||||
pub fn any_unsupported_filter_is_set_to_concrete_value(
|
||||
&self,
|
||||
supported_advanced_kinds: EnumSet<PotFilterKind>,
|
||||
) -> bool {
|
||||
let supported_kinds = supported_advanced_kinds.union(PotFilterKind::core_kinds());
|
||||
supported_kinds
|
||||
.complement()
|
||||
.iter()
|
||||
.any(|k| self.is_set_to_concrete_value(k))
|
||||
}
|
||||
|
||||
/// To be used with filter kinds where `<None>` is **not** a valid filter value. In this case,
|
||||
/// <None> is considered an invalid value and it never matches (no reason to panic but almost).
|
||||
pub fn matches(&self, kind: PotFilterKind, fil: Fil) -> bool {
|
||||
match self.get(kind) {
|
||||
None => true,
|
||||
Some(FilterItemId(None)) => false,
|
||||
Some(FilterItemId(Some(wanted_fil))) => fil == wanted_fil,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn favorite_matches(
|
||||
&self,
|
||||
favorites: &NonCryptoHashSet<InnerPresetId>,
|
||||
preset_id: InnerPresetId,
|
||||
) -> bool {
|
||||
match self.get(PotFilterKind::IsFavorite) {
|
||||
None => true,
|
||||
Some(FilterItemId(None)) => false,
|
||||
Some(FilterItemId(Some(fil))) => {
|
||||
if fil == FIL_IS_FAVORITE_TRUE {
|
||||
favorites.contains(&preset_id)
|
||||
} else {
|
||||
!favorites.contains(&preset_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// To be used with filter kinds where `<None>` is a valid filter value.
|
||||
pub fn matches_optional(&self, kind: PotFilterKind, fil: Option<Fil>) -> bool {
|
||||
match self.get(kind) {
|
||||
// <Any>
|
||||
None => true,
|
||||
// <None> or a specific value
|
||||
Some(FilterItemId(wanted_fil)) => fil == wanted_fil,
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_only(&self, kind: PotFilterKind, fil: Fil) -> bool {
|
||||
self.get(kind) == Some(FilterItemId(Some(fil)))
|
||||
}
|
||||
|
||||
/// Returns `false` if set to `None`
|
||||
pub fn is_set_to_concrete_value(&self, kind: PotFilterKind) -> bool {
|
||||
matches!(self.0[kind], Some(FilterItemId(Some(_))))
|
||||
}
|
||||
|
||||
pub fn get(&self, kind: PotFilterKind) -> OptFilter {
|
||||
self.0[kind]
|
||||
}
|
||||
|
||||
pub fn get_ref(&self, kind: PotFilterKind) -> &OptFilter {
|
||||
&self.0[kind]
|
||||
}
|
||||
|
||||
pub fn set(&mut self, kind: PotFilterKind, value: OptFilter) {
|
||||
self.0[kind] = value;
|
||||
}
|
||||
|
||||
pub fn effective_sub_bank(&self) -> &OptFilter {
|
||||
self.effective_sub_item(PotFilterKind::Bank, PotFilterKind::SubBank)
|
||||
}
|
||||
|
||||
pub fn clear_excluded_ones(&mut self, exclude_list: &PotFilterExcludes) {
|
||||
for kind in PotFilterKind::iter() {
|
||||
if let Some(id) = self.0[kind] {
|
||||
if exclude_list.contains(kind, id) {
|
||||
self.0[kind] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_if_not_available_anymore(
|
||||
&mut self,
|
||||
affected_kinds: EnumSet<PotFilterKind>,
|
||||
collections: &FilterItemCollections,
|
||||
) {
|
||||
for kind in affected_kinds {
|
||||
if let Some(id) = self.0[kind] {
|
||||
let valid_items = collections.get(kind);
|
||||
if !valid_items.iter().any(|item| item.id == id) {
|
||||
self.0[kind] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (PotFilterKind, OptFilter)> {
|
||||
self.0.into_iter()
|
||||
}
|
||||
|
||||
pub fn effective_sub_category(&self) -> &OptFilter {
|
||||
self.effective_sub_item(PotFilterKind::Category, PotFilterKind::SubCategory)
|
||||
}
|
||||
|
||||
pub fn clear_this_and_dependent_filters(&mut self, kind: PotFilterKind) {
|
||||
self.set(kind, None);
|
||||
for dependent_kind in kind.dependent_kinds() {
|
||||
self.set(dependent_kind, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_sub_item(
|
||||
&self,
|
||||
parent_kind: PotFilterKind,
|
||||
sub_kind: PotFilterKind,
|
||||
) -> &OptFilter {
|
||||
let category = &self.0[parent_kind];
|
||||
if category == &Some(FilterItemId::NONE) {
|
||||
category
|
||||
} else {
|
||||
&self.0[sub_kind]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PotFavorites {
|
||||
favorites: NonCryptoHashMap<DatabaseId, NonCryptoHashSet<InnerPresetId>>,
|
||||
}
|
||||
|
||||
impl PotFavorites {
|
||||
pub fn is_favorite(&self, preset_id: PresetId) -> bool {
|
||||
if let Some(db_favorites) = self.favorites.get(&preset_id.database_id) {
|
||||
db_favorites.contains(&preset_id.preset_id)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_favorite(&mut self, preset_id: PresetId) {
|
||||
let db_favorites = self.favorites.entry(preset_id.database_id).or_default();
|
||||
if db_favorites.contains(&preset_id.preset_id) {
|
||||
db_favorites.remove(&preset_id.preset_id);
|
||||
} else {
|
||||
db_favorites.insert(preset_id.preset_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db_favorites(&self, db_id: DatabaseId) -> &NonCryptoHashSet<InnerPresetId> {
|
||||
static EMPTY_HASH_SET: Lazy<NonCryptoHashSet<InnerPresetId>> = Lazy::new(HashSet::default);
|
||||
self.favorites.get(&db_id).unwrap_or(&EMPTY_HASH_SET)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PotFilterExcludes {
|
||||
exluded_items: EnumMap<PotFilterKind, NonCryptoHashSet<FilterItemId>>,
|
||||
}
|
||||
|
||||
impl PotFilterExcludes {
|
||||
pub fn contains(&self, kind: PotFilterKind, id: FilterItemId) -> bool {
|
||||
self.exluded_items[kind].contains(&id)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, kind: PotFilterKind, id: FilterItemId) {
|
||||
self.exluded_items[kind].remove(&id);
|
||||
}
|
||||
|
||||
pub fn add(&mut self, kind: PotFilterKind, id: FilterItemId) {
|
||||
self.exluded_items[kind].insert(id);
|
||||
}
|
||||
|
||||
pub fn is_empty(&self, kind: PotFilterKind) -> bool {
|
||||
self.exluded_items[kind].is_empty()
|
||||
}
|
||||
|
||||
pub fn contains_database(&self, db_id: DatabaseId) -> bool {
|
||||
self.contains(
|
||||
PotFilterKind::Database,
|
||||
FilterItemId(Some(Fil::Database(db_id))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn contains_product(&self, product_id: Option<ProductId>) -> bool {
|
||||
self.contains(
|
||||
PotFilterKind::Bank,
|
||||
FilterItemId(product_id.map(Fil::Product)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn normal_excludes_by_kind(&self, kind: PotFilterKind) -> impl Iterator<Item = &Fil> + '_ {
|
||||
self.exluded_items[kind]
|
||||
.iter()
|
||||
.filter_map(|id| id.0.as_ref())
|
||||
}
|
||||
|
||||
pub fn contains_none(&self, kind: PotFilterKind) -> bool {
|
||||
self.exluded_items[kind].contains(&FilterItemId::NONE)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CurrentPreset {
|
||||
pub preset: PotPreset,
|
||||
pub macro_param_banks: Vec<MacroParamBank>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MacroParamBank {
|
||||
params: Vec<MacroParam>,
|
||||
}
|
||||
|
||||
impl MacroParamBank {
|
||||
pub fn new(params: Vec<MacroParam>) -> Self {
|
||||
Self { params }
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
let mut name = String::with_capacity(32);
|
||||
for p in &self.params {
|
||||
if let Some(section) = &p.section {
|
||||
name += " / ";
|
||||
name += section;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Returns the index of the section to which the parameter in the given slot belongs.
|
||||
pub fn resolve_param_section_index(&self, slot_index: u32) -> Option<u32> {
|
||||
self.params[0..=slot_index as usize]
|
||||
.iter()
|
||||
.filter(|param| param.section.is_some())
|
||||
.enumerate()
|
||||
.map(|(i, _)| i as u32)
|
||||
.last()
|
||||
}
|
||||
|
||||
/// Returns the section to which the parameter in the given slot belongs.
|
||||
///
|
||||
/// The parameter itself only carries its section name if it's the beginning of a new section.
|
||||
pub fn resolve_param_section(&self, slot_index: u32) -> Option<&str> {
|
||||
// Search from right to left
|
||||
self.params[0..=slot_index as usize]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|param| Some(param.section.as_ref()?.as_str()))
|
||||
}
|
||||
|
||||
pub fn params(&self) -> &[MacroParam] {
|
||||
&self.params
|
||||
}
|
||||
|
||||
pub fn params_mut(&mut self) -> &mut [MacroParam] {
|
||||
&mut self.params
|
||||
}
|
||||
|
||||
pub fn find_macro_param_at(&self, slot_index: u32) -> Option<&MacroParam> {
|
||||
self.params.get(slot_index as usize)
|
||||
}
|
||||
|
||||
/// Returns slot index and param.
|
||||
pub fn find_first_param_with_fx_param_index(&self, index: u32) -> Option<(u32, &MacroParam)> {
|
||||
self.params.iter().enumerate().find_map(|(i, p)| {
|
||||
let resolved_index = p.fx_param?.resolved_param_index?;
|
||||
if index == resolved_index {
|
||||
Some((i as u32, p))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn param_count(&self) -> u32 {
|
||||
self.params.len() as _
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MacroParam {
|
||||
pub name: String,
|
||||
/// The parameter itself only carries its section name if it's the beginning of a new section.
|
||||
pub section: Option<String>,
|
||||
pub fx_param: Option<PotFxParam>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct PotFxParam {
|
||||
pub param_id: PotFxParamId,
|
||||
/// Only resolved on demand (when the FX is available).
|
||||
pub resolved_param_index: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, derive_more::Display)]
|
||||
pub enum PotFxParamId {
|
||||
/// Positional/index ID.
|
||||
///
|
||||
/// Some plug-in standards such as VST2 only support positional IDs.
|
||||
Index(u32),
|
||||
/// Real ID, independent of the position.
|
||||
///
|
||||
/// Supported by VST3 plug-ins.
|
||||
Id(u32),
|
||||
}
|
||||
|
||||
impl CurrentPreset {
|
||||
pub fn preset(&self) -> &PotPreset {
|
||||
&self.preset
|
||||
}
|
||||
|
||||
pub fn find_macro_param_bank_at(&self, bank_index: u32) -> Option<&MacroParamBank> {
|
||||
self.macro_param_banks.get(bank_index as usize)
|
||||
}
|
||||
|
||||
/// Finds macro parameters across all banks, assuming a bank size of exactly 8.
|
||||
pub fn find_macro_param_at(&self, slot_index: u32) -> Option<&MacroParam> {
|
||||
let bank_index = slot_index / 8;
|
||||
let bank_slot_index = slot_index % 8;
|
||||
self.find_bank_macro_param_at(bank_index, bank_slot_index)
|
||||
}
|
||||
|
||||
pub fn find_bank_macro_param_at(
|
||||
&self,
|
||||
bank_index: u32,
|
||||
bank_slot_index: u32,
|
||||
) -> Option<&MacroParam> {
|
||||
self.macro_param_banks
|
||||
.get(bank_index as usize)?
|
||||
.find_macro_param_at(bank_slot_index)
|
||||
}
|
||||
|
||||
/// Returns bank, slot index and param.
|
||||
pub fn find_first_macro_param_with_fx_param_index(
|
||||
&self,
|
||||
index: u32,
|
||||
) -> Option<(&MacroParamBank, u32, &MacroParam)> {
|
||||
self.macro_param_banks.iter().find_map(|b| {
|
||||
let (i, p) = b.find_first_param_with_fx_param_index(index)?;
|
||||
Some((b, i, p))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn macro_param_bank_count(&self) -> u32 {
|
||||
self.macro_param_banks.len() as _
|
||||
}
|
||||
|
||||
pub fn has_params(&self) -> bool {
|
||||
!self.macro_param_banks.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct PersistentDatabaseId(String);
|
||||
|
||||
impl PersistentDatabaseId {
|
||||
pub fn random() -> Self {
|
||||
Self(nanoid::nanoid!())
|
||||
}
|
||||
|
||||
pub const fn new(raw_id: String) -> Self {
|
||||
PersistentDatabaseId(raw_id)
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct PersistentInnerPresetId(String);
|
||||
|
||||
impl PersistentInnerPresetId {
|
||||
pub fn new(raw_id: String) -> Self {
|
||||
Self(raw_id)
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset ID that survives restarts and rescans.
|
||||
///
|
||||
/// It's more expensive to clone, hash etc. than [`PresetId`]. That's why we should only use it
|
||||
/// for purposes where persistence matters, e.g. when saving favorites, currently selected preset
|
||||
/// or for associating preview files.
|
||||
///
|
||||
/// The schema is `<DATABASE_ID>|<INNER_PRESET_ID>`. The pipe character `|` can also be used within
|
||||
/// the inner preset ID, so in order to extract the database ID, it's important to split at the
|
||||
/// first pipe character and ignore the other ones. In general, it should be avoided to use
|
||||
/// the pipe character in the database ID or inner preset ID, but it's still important to escape it.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - `defaults|vst2|1967946098`
|
||||
/// - `track-templates|Synths/Lead.RTrackTemplate`
|
||||
/// - `fx-chains|Synths/Sun.RfxChain`
|
||||
/// - `fx-presets|vst3-Surge XT.ini|My Preset`
|
||||
/// - `komplete|77c5507f5d0b421ea93eeb4cee4b6f99`
|
||||
/// - `n98h1f9unp92|maojiao/2023-02-03-ben/2023-02-03-ben.RPP|0FF9F738-7CF6-8A49-9AEA-A9AF26DF9C46`
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct PersistentPresetId {
|
||||
db_id: PersistentDatabaseId,
|
||||
inner_preset_id: PersistentInnerPresetId,
|
||||
}
|
||||
|
||||
impl PersistentPresetId {
|
||||
pub fn new(db_id: PersistentDatabaseId, inner_preset_id: PersistentInnerPresetId) -> Self {
|
||||
Self {
|
||||
db_id,
|
||||
inner_preset_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PersistentPresetId {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
// Database IDs ideally shouldn't contain pipe characters, but if they do, we escaped them.
|
||||
let escaped_db_id = PipeEscaped(self.db_id.get());
|
||||
write!(f, "{escaped_db_id}|{}", self.inner_preset_id.get())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PersistentPresetId {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let (escaped_db_id, inner_preset_id) = s
|
||||
.split_once(unescaped_pipe_pattern())
|
||||
.ok_or("no | separator found in persistent preset ID")?;
|
||||
// Unescaped pipe character (in the unlikely case that there was any)
|
||||
let db_id = unescape_pipes(escaped_db_id);
|
||||
let id = Self {
|
||||
db_id: PersistentDatabaseId(db_id),
|
||||
inner_preset_id: PersistentInnerPresetId(inner_preset_id.to_string()),
|
||||
};
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unescaped_pipe_pattern() -> impl FnMut(char) -> bool {
|
||||
unescaped_char_pattern('|')
|
||||
}
|
||||
|
||||
/// A Rust string matching pattern that matches the given character, but only if it's not preceded
|
||||
/// by a backslash.
|
||||
fn unescaped_char_pattern(needle: char) -> impl FnMut(char) -> bool {
|
||||
let mut prev_char = None;
|
||||
move |c: char| {
|
||||
let matches = if c == needle {
|
||||
prev_char != Some('\\')
|
||||
} else {
|
||||
false
|
||||
};
|
||||
prev_char = Some(c);
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts "hello\|fellow" to "hello|fellow"
|
||||
pub fn unescape_pipes(escaped: &str) -> String {
|
||||
escaped.replace(r"\|", "|")
|
||||
}
|
||||
|
||||
pub struct PipeEscaped<'a>(pub &'a str);
|
||||
|
||||
impl Display for PipeEscaped<'_> {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
for c in self.0.chars() {
|
||||
if c == '|' {
|
||||
f.write_str(r"\|")?;
|
||||
} else {
|
||||
f.write_char(c)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{PersistentDatabaseId, PersistentInnerPresetId, PersistentPresetId};
|
||||
|
||||
#[test]
|
||||
fn format_persistent_preset_id() {
|
||||
let id = PersistentPresetId::new(
|
||||
PersistentDatabaseId::new("test|hello".into()),
|
||||
PersistentInnerPresetId::new("vst2|124135".into()),
|
||||
);
|
||||
assert_eq!(id.to_string(), r#"test\|hello|vst2|124135"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_persistent_preset_id() {
|
||||
let expression = r#"test\|hello|vst2|124135"#;
|
||||
let parsed_id: PersistentPresetId = expression.parse().unwrap();
|
||||
let expected_id = PersistentPresetId::new(
|
||||
PersistentDatabaseId::new("test|hello".into()),
|
||||
PersistentInnerPresetId::new("vst2|124135".into()),
|
||||
);
|
||||
assert_eq!(parsed_id, expected_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use reaper_high::Reaper;
|
||||
use reaper_medium::{
|
||||
AccelMsgKind, AcceleratorBehavior, AcceleratorKeyCode, AcceleratorPosition, RegistrationHandle,
|
||||
TranslateAccel, TranslateAccelArgs, TranslateAccelResult,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct EscapeCatcher {
|
||||
accel_handle: RegistrationHandle<EscapeCatcherAccel>,
|
||||
escape_was_pressed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TranslateAccel for EscapeCatcherAccel {
|
||||
fn call(&mut self, args: TranslateAccelArgs) -> TranslateAccelResult {
|
||||
let is_escape = args.msg.behavior().contains(AcceleratorBehavior::VirtKey)
|
||||
&& args.msg.message() == AccelMsgKind::KeyDown
|
||||
&& args.msg.key() == AcceleratorKeyCode::new(0x1b);
|
||||
if !is_escape {
|
||||
return TranslateAccelResult::NotOurWindow;
|
||||
}
|
||||
self.escape_was_pressed.store(true, Ordering::Relaxed);
|
||||
TranslateAccelResult::Eat
|
||||
}
|
||||
}
|
||||
|
||||
struct EscapeCatcherAccel {
|
||||
escape_was_pressed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Default for EscapeCatcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EscapeCatcher {
|
||||
pub fn new() -> Self {
|
||||
let escape_was_pressed = Arc::new(AtomicBool::new(false));
|
||||
let accel = EscapeCatcherAccel {
|
||||
escape_was_pressed: escape_was_pressed.clone(),
|
||||
};
|
||||
let accel_handle = Reaper::get()
|
||||
.medium_session()
|
||||
.plugin_register_add_accelerator_register(Box::new(accel), AcceleratorPosition::Front)
|
||||
.expect("couldn't register escape accelerator");
|
||||
Self {
|
||||
accel_handle,
|
||||
escape_was_pressed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn escape_was_pressed(&self) -> bool {
|
||||
self.escape_was_pressed.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EscapeCatcher {
|
||||
fn drop(&mut self) {
|
||||
Reaper::get()
|
||||
.medium_session()
|
||||
.plugin_register_remove_accelerator(self.accel_handle)
|
||||
.expect("escape accelerator was not registered");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
use base::default_util::{deserialize_null_default, is_default};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PersistentNksFilterSettings {
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_null_default",
|
||||
skip_serializing_if = "is_default"
|
||||
)]
|
||||
pub bank: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_null_default",
|
||||
skip_serializing_if = "is_default"
|
||||
)]
|
||||
pub sub_bank: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_null_default",
|
||||
skip_serializing_if = "is_default"
|
||||
)]
|
||||
pub category: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_null_default",
|
||||
skip_serializing_if = "is_default"
|
||||
)]
|
||||
pub sub_category: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_null_default",
|
||||
skip_serializing_if = "is_default"
|
||||
)]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use base::LimitedAsciiString;
|
||||
use std::fmt;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
|
||||
pub enum PluginId {
|
||||
Vst2 { vst_magic_number: i32 },
|
||||
Vst3 { vst_uid: [u32; 4] },
|
||||
Clap { clap_id: LimitedAsciiString<100> },
|
||||
Js { js_id: LimitedAsciiString<100> },
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn vst2(vst_magic_number: i32) -> Self {
|
||||
Self::Vst2 { vst_magic_number }
|
||||
}
|
||||
|
||||
pub fn vst3(vst_uid: [u32; 4]) -> Self {
|
||||
Self::Vst3 { vst_uid }
|
||||
}
|
||||
|
||||
pub fn clap(id_expression: &str) -> Result<Self, &'static str> {
|
||||
let id = Self::Clap {
|
||||
clap_id: LimitedAsciiString::try_from_str(id_expression)?,
|
||||
};
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn js(id_expression: &str) -> Result<Self, &'static str> {
|
||||
// It's better to normalize to lowercase. When loading the FX via TrackFX_AddByName,
|
||||
// REAPER is case-insensitive anyway and we should be as well when doing comparisons.
|
||||
let lowercase_id_expression = id_expression.to_lowercase();
|
||||
let id = Self::Js {
|
||||
js_id: LimitedAsciiString::try_from_str(&lowercase_id_expression)?,
|
||||
};
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn parse_from_rxml_line(line: &str) -> Result<PluginId, &'static str> {
|
||||
let line = line.trim();
|
||||
let mut tokens = splitty::split_unquoted_whitespace(line).unwrap_quotes(true);
|
||||
let tag_opener = tokens.next().ok_or("missing FX tag opener")?;
|
||||
match tag_opener {
|
||||
"<VST" => {
|
||||
// Examples:
|
||||
// - <VST "VSTi: Zebra2 (u-he)" Zebra2.vst 0 Schmackes 1397572658<565354534D44327A6562726132000000> ""
|
||||
// - <VST "VST3i: Pianoteq 8 (Modartt) (1->5ch)" "Pianoteq 8.vst3" 0 "" 1031062328{565354507438717069616E6F74657120} ""
|
||||
// Skip plug-in name, file, zero, custom name
|
||||
for _ in 0..4 {
|
||||
tokens.next();
|
||||
}
|
||||
// Process ID expression
|
||||
let id_expression = tokens.next().ok_or("missing VST ID expression")?;
|
||||
if let Some((_, remainder)) = id_expression.split_once('{') {
|
||||
// VST3
|
||||
let vst3_uid_string = remainder.strip_suffix('}').unwrap_or(remainder);
|
||||
let uid = parse_vst3_uid(vst3_uid_string)?;
|
||||
Ok(Self::vst3(uid))
|
||||
} else if let Some((magic_number_string, _)) = id_expression.split_once('<') {
|
||||
// VST2
|
||||
let magic_number = parse_vst2_magic_number(magic_number_string)?;
|
||||
Ok(Self::vst2(magic_number))
|
||||
} else {
|
||||
Err("couldn't process VST ID expression")
|
||||
}
|
||||
}
|
||||
"<CLAP" => {
|
||||
// Example: <CLAP "CLAPi: Surge XT (Surge Synth Team)" org.surge-synth-team.surge-xt Surgi
|
||||
// Skip plug-in name
|
||||
tokens.next();
|
||||
// Process ID expression
|
||||
let id_expression = tokens.next().ok_or("missing CLAP ID expression")?;
|
||||
Self::clap(id_expression)
|
||||
}
|
||||
"<JS" => {
|
||||
// Example: <JS analysis/hund ""
|
||||
let id_expression = tokens.next().ok_or("missing JS ID expression")?;
|
||||
Self::js(id_expression)
|
||||
}
|
||||
_ => Err("unknown FX tag opener"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> PluginKind {
|
||||
match self {
|
||||
PluginId::Vst2 { .. } => PluginKind::Vst2,
|
||||
PluginId::Vst3 { .. } => PluginKind::Vst3,
|
||||
PluginId::Clap { .. } => PluginKind::Clap,
|
||||
PluginId::Js { .. } => PluginKind::Js,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_formatted_for_reaper(&self) -> String {
|
||||
PluginIdContentInReaperFormat(self).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Example: `1967946098` for a VST2 plug-in ID.
|
||||
pub struct PluginIdContentInReaperFormat<'a>(pub &'a PluginId);
|
||||
|
||||
impl Display for PluginIdContentInReaperFormat<'_> {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
match self.0 {
|
||||
PluginId::Clap { clap_id } => clap_id.fmt(f),
|
||||
PluginId::Js { js_id } => js_id.fmt(f),
|
||||
PluginId::Vst2 { vst_magic_number } => vst_magic_number.fmt(f),
|
||||
PluginId::Vst3 { vst_uid } => {
|
||||
// D39D5B69 D6AF42FA 12345678 534D4433
|
||||
write!(
|
||||
f,
|
||||
"{:X}{:X}{:X}{:X}",
|
||||
vst_uid[0], vst_uid[1], vst_uid[2], vst_uid[3],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Example: `vst|1967946098` for a VST2 plug-in ID.
|
||||
pub struct PluginIdInPipeFormat<'a>(pub &'a PluginId);
|
||||
|
||||
impl Display for PluginIdInPipeFormat<'_> {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
let kind = self.0.kind();
|
||||
let kind = kind.as_ref();
|
||||
let content = PluginIdContentInReaperFormat(self.0);
|
||||
write!(f, "{kind}|{content}")
|
||||
}
|
||||
}
|
||||
|
||||
/// When adding a new variant, the serialization should correspond to the string which is used
|
||||
/// as prefix for the ini file names in "REAPER_RESOURCE_PATH/presets".
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug, strum::AsRefStr, strum::EnumString)]
|
||||
pub enum PluginKind {
|
||||
#[strum(serialize = "vst")]
|
||||
Vst2,
|
||||
#[strum(serialize = "vst3")]
|
||||
Vst3,
|
||||
#[strum(serialize = "clap")]
|
||||
Clap,
|
||||
#[strum(serialize = "js")]
|
||||
Js,
|
||||
}
|
||||
|
||||
impl PluginKind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Vst2 => "VST",
|
||||
Self::Vst3 => "VST3",
|
||||
Self::Clap => "CLAP",
|
||||
Self::Js => "JS",
|
||||
}
|
||||
}
|
||||
|
||||
/// Need to put some random string in front of "<" due to bug in REAPER < 6.69,
|
||||
/// otherwise loading by VST2 magic number doesn't work.
|
||||
pub fn reaper_add_by_name_prefix_fix(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Vst2 | Self::Vst3 => "i7zh34z",
|
||||
Self::Clap | Self::Js => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn formatted_for_reaper(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Vst2 => "<",
|
||||
Self::Vst3 => "{",
|
||||
Self::Clap | Self::Js => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "1397572658" => 1397572658
|
||||
pub fn parse_vst2_magic_number(expression: &str) -> Result<i32, &'static str> {
|
||||
expression
|
||||
.parse()
|
||||
.map_err(|_| "couldn't parse VST2 magic number")
|
||||
}
|
||||
|
||||
/// "565354507438717069616E6F74657120" => [0x56535450, 0x74387170, 0x69616E6F, 0x74657120]
|
||||
pub fn parse_vst3_uid(expression: &str) -> Result<[u32; 4], &'static str> {
|
||||
fn parse_component(text: &str, i: usize) -> Result<u32, &'static str> {
|
||||
let from = i * 8;
|
||||
let until = from + 8;
|
||||
let parsed = u32::from_str_radix(&text[from..until], 16)
|
||||
.map_err(|_| "couldn't parse VST3 uid component")?;
|
||||
Ok(parsed)
|
||||
}
|
||||
let uid = [
|
||||
parse_component(expression, 0)?,
|
||||
parse_component(expression, 1)?,
|
||||
parse_component(expression, 2)?,
|
||||
parse_component(expression, 3)?,
|
||||
];
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::PluginId;
|
||||
|
||||
#[test]
|
||||
pub fn vst2() {
|
||||
assert_eq!(
|
||||
PluginId::parse_from_rxml_line(
|
||||
r#"<VST "VSTi: Zebra2 (u-he)" Zebra2.vst 0 Schmackes 1397572658<565354534D44327A6562726132000000> """#
|
||||
),
|
||||
Ok(PluginId::vst2(1397572658))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn vst3() {
|
||||
assert_eq!(
|
||||
PluginId::parse_from_rxml_line(
|
||||
r#"<VST "VST3i: Pianoteq 8 (Modartt) (1->5ch)" "Pianoteq 8.vst3" 0 "" 1031062328{565354507438717069616E6F74657120} """#
|
||||
),
|
||||
Ok(PluginId::vst3([
|
||||
0x56535450, 0x74387170, 0x69616E6F, 0x74657120
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn clap() {
|
||||
assert_eq!(
|
||||
PluginId::parse_from_rxml_line(
|
||||
r#"<CLAP "CLAPi: Surge XT (Surge Synth Team)" org.surge-synth-team.surge-xt Surgi"#
|
||||
),
|
||||
Ok(PluginId::clap("org.surge-synth-team.surge-xt").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn js() {
|
||||
assert_eq!(
|
||||
PluginId::parse_from_rxml_line(r#"<JS Analysis/hund """#),
|
||||
Ok(PluginId::js("analysis/hund").unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
use crate::{parse_vst2_magic_number, parse_vst3_uid, PluginId, ProductId};
|
||||
use base::file_util;
|
||||
use base::hash_util::NonCryptoHashMap;
|
||||
use camino::Utf8Path;
|
||||
use ini::Ini;
|
||||
use regex::Match;
|
||||
use std::fmt;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PluginDatabase {
|
||||
plugins: NonCryptoHashMap<PluginId, Plugin>,
|
||||
products: Vec<Product>,
|
||||
detected_legacy_vst3_scan: bool,
|
||||
}
|
||||
|
||||
/// Responsible for grouping similar plug-ins into products.
|
||||
#[derive(Default)]
|
||||
struct ProductAccumulator {
|
||||
products: Vec<Product>,
|
||||
}
|
||||
|
||||
impl ProductAccumulator {
|
||||
/// Adds a product with the given characteristics.
|
||||
pub fn add_other_product(&mut self, name: String, kind: Option<ProductKind>) -> ProductId {
|
||||
let product = Product { name, kind };
|
||||
self.products.push(product);
|
||||
ProductId(self.products.len() as u32 - 1)
|
||||
}
|
||||
|
||||
/// Tries to find an already added product matching the product name within the given plug-in
|
||||
/// expression (and product kind) or creates a new product.
|
||||
pub fn get_or_add_plugin_product(
|
||||
&mut self,
|
||||
name_expression: &str,
|
||||
kind: Option<ProductKind>,
|
||||
) -> ProductId {
|
||||
// Extract product name
|
||||
let name = if let Some(name) = ProductName::parse(name_expression) {
|
||||
normalize_product_main_name(name.main)
|
||||
} else {
|
||||
name_expression
|
||||
};
|
||||
// Find existing product
|
||||
let existing_product = self
|
||||
.products
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, product)| product.name == name && product.kind == kind);
|
||||
// Return existing or create new product
|
||||
let i = match existing_product {
|
||||
None => {
|
||||
let new_product = Product {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
};
|
||||
self.products.push(new_product);
|
||||
self.products.len() - 1
|
||||
}
|
||||
Some((i, _)) => i,
|
||||
};
|
||||
ProductId(i as u32)
|
||||
}
|
||||
|
||||
pub fn into_products(self) -> Vec<Product> {
|
||||
self.products
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginDatabase {
|
||||
pub fn crawl(reaper_resource_dir: &Utf8Path) -> Self {
|
||||
let mut product_accumulator = ProductAccumulator::default();
|
||||
let mut detected_legacy_vst3_scan = false;
|
||||
let shared_library_plugins = crawl_shared_library_plugins(
|
||||
&mut product_accumulator,
|
||||
reaper_resource_dir,
|
||||
&mut detected_legacy_vst3_scan,
|
||||
);
|
||||
let js_root_dir = reaper_resource_dir.join("Effects");
|
||||
let js_plugins = crawl_js_plugins(&mut product_accumulator, &js_root_dir);
|
||||
let plugin_map = shared_library_plugins
|
||||
.into_iter()
|
||||
.chain(js_plugins)
|
||||
.map(|p| (p.common.core.id, p))
|
||||
.collect();
|
||||
Self {
|
||||
plugins: plugin_map,
|
||||
products: product_accumulator.into_products(),
|
||||
detected_legacy_vst3_scan,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detected_legacy_vst3_scan(&self) -> bool {
|
||||
self.detected_legacy_vst3_scan
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> impl Iterator<Item = &Plugin> {
|
||||
self.plugins.values()
|
||||
}
|
||||
|
||||
pub fn products(&self) -> impl Iterator<Item = (ProductId, &Product)> {
|
||||
self.products
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (ProductId(i as _), p))
|
||||
}
|
||||
|
||||
pub fn find_plugin_by_id(&self, plugin_id: &PluginId) -> Option<&Plugin> {
|
||||
self.plugins.get(plugin_id)
|
||||
}
|
||||
|
||||
pub fn find_product_by_id(&self, product_id: &ProductId) -> Option<&Product> {
|
||||
self.products.get(product_id.0 as usize)
|
||||
}
|
||||
|
||||
pub fn detect_plugin_from_rxml_line(&self, line: &str) -> Option<&Plugin> {
|
||||
let is_fx_line = ["<VST ", "<CLAP ", "<JS "]
|
||||
.into_iter()
|
||||
.any(|suffix| line.starts_with(suffix));
|
||||
if !is_fx_line {
|
||||
return None;
|
||||
}
|
||||
let plugin_id = PluginId::parse_from_rxml_line(line).ok()?;
|
||||
self.find_plugin_by_id(&plugin_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A product - an abstraction over related plug-ins.
|
||||
///
|
||||
/// Example: The product "Zebra2 (u-he)". This ignores architecture and plug-in framework.
|
||||
/// So this product stands for all of: "Zebra VSTi", "Zebra VST3i", "Zebra CLAPi",
|
||||
/// "Zebra VST (x86_64)", and so on.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Product {
|
||||
pub name: String,
|
||||
pub kind: Option<ProductKind>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Plugin {
|
||||
/// Contains data relevant for all kinds of plug-ins.
|
||||
pub common: PluginCommon,
|
||||
/// Contains data specific to certain kinds of plug-ins.
|
||||
pub kind: SuperPluginKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PluginCommon {
|
||||
/// Full name of the plug-in without kind (for display purposes mainly).
|
||||
///
|
||||
/// E.g. "Zebra2 (u-he)"
|
||||
pub name: String,
|
||||
pub core: PluginCore,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct PluginCore {
|
||||
/// Uniquely identifies the plug-in in a rather cheap way (copyable).
|
||||
pub id: PluginId,
|
||||
/// Whether we have an effect or an instrument or unknown.
|
||||
pub product_kind: Option<ProductKind>,
|
||||
/// What product this plug-in belongs to.
|
||||
pub product_id: ProductId,
|
||||
}
|
||||
|
||||
impl Display for PluginCommon {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
f.write_str(self.core.id.kind().name())?;
|
||||
if let Some(ProductKind::Instrument) = self.core.product_kind {
|
||||
f.write_str("i")?;
|
||||
}
|
||||
write!(f, ": {}", &self.name)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SuperPluginKind {
|
||||
Vst(VstPlugin),
|
||||
Clap(ClapPlugin),
|
||||
Js(JsPlugin),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClapPlugin {
|
||||
/// Real file name, no characters replaced.
|
||||
pub file_name: String,
|
||||
/// According to Justin, this is a Win32 FILETIME timestamp (last write time and creation time,
|
||||
/// concatenated).
|
||||
pub filetime: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JsPlugin {
|
||||
/// Relative path from JS root dir.
|
||||
///
|
||||
/// This is a runtime path and it's not normalized to lower-case! So it shouldn't be used
|
||||
/// as an ID.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VstPlugin {
|
||||
/// Safe means: Each space and special character is replaced with an underscore.
|
||||
pub safe_file_name: String,
|
||||
/// A value which identifies the actual plug-in within a shell file.
|
||||
///
|
||||
/// Some files are just shells. That means, they contain multiple actual plug-ins.
|
||||
/// If this plug-in is part of a shell file, this value will be set. In practice, it's
|
||||
/// equal to the magic number / uid_hash.
|
||||
pub shell_qualifier: Option<String>,
|
||||
pub checksum: String,
|
||||
pub kind: VstPluginKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VstPluginKind {
|
||||
Vst2 { magic_number: String },
|
||||
Vst3 { uid_hash: String, uid: String },
|
||||
}
|
||||
|
||||
impl VstPluginKind {
|
||||
pub fn plugin_id(&self) -> Result<PluginId, &'static str> {
|
||||
let id = match self {
|
||||
VstPluginKind::Vst2 { magic_number } => PluginId::Vst2 {
|
||||
vst_magic_number: parse_vst2_magic_number(magic_number)?,
|
||||
},
|
||||
VstPluginKind::Vst3 { uid, .. } => PluginId::Vst3 {
|
||||
vst_uid: parse_vst3_uid(uid)?,
|
||||
},
|
||||
};
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, derive_more::Display)]
|
||||
pub enum ProductKind {
|
||||
Effect,
|
||||
Instrument,
|
||||
Loop,
|
||||
OneShot,
|
||||
}
|
||||
|
||||
impl ProductKind {
|
||||
pub fn komplete_id(&self) -> &'static u32 {
|
||||
const INSTRUMENT: u32 = 1;
|
||||
const EFFECT: u32 = 2;
|
||||
const ONE_SHOT: u32 = 4;
|
||||
const LOOP: u32 = 8;
|
||||
use ProductKind as K;
|
||||
match self {
|
||||
K::Effect => &EFFECT,
|
||||
K::Instrument => &INSTRUMENT,
|
||||
K::Loop => &LOOP,
|
||||
K::OneShot => &ONE_SHOT,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn crawl_js_plugins(
|
||||
product_accumulator: &mut ProductAccumulator,
|
||||
js_root_dir: &Utf8Path,
|
||||
) -> Vec<Plugin> {
|
||||
WalkDir::new(js_root_dir)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !file_util::is_hidden(e.file_name()))
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !entry.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let relative_path = entry.path().strip_prefix(js_root_dir).ok()?;
|
||||
let relative_path = relative_path.to_str()?;
|
||||
let product_kind = Some(ProductKind::Effect);
|
||||
let js_desc = read_js_desc_from_file(entry.path())?;
|
||||
let plugin = Plugin {
|
||||
common: PluginCommon {
|
||||
name: js_desc.clone(),
|
||||
core: PluginCore {
|
||||
id: PluginId::js(relative_path).ok()?,
|
||||
product_kind,
|
||||
product_id: product_accumulator.add_other_product(js_desc, product_kind),
|
||||
},
|
||||
},
|
||||
kind: SuperPluginKind::Js(JsPlugin {
|
||||
path: relative_path.to_string(),
|
||||
}),
|
||||
};
|
||||
Some(plugin)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn crawl_shared_library_plugins(
|
||||
product_accumulator: &mut ProductAccumulator,
|
||||
reaper_resource_dir: &Utf8Path,
|
||||
detected_legacy_vst3_scan: &mut bool,
|
||||
) -> Vec<Plugin> {
|
||||
WalkDir::new(reaper_resource_dir)
|
||||
.max_depth(1)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !entry.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let file_name = entry.file_name().to_str()?;
|
||||
enum PlugType {
|
||||
Vst,
|
||||
Clap,
|
||||
}
|
||||
let plug_type = match file_name {
|
||||
VST_CACHE_FILE => PlugType::Vst,
|
||||
CLAP_CACHE_FILE => PlugType::Clap,
|
||||
_ => return None,
|
||||
};
|
||||
let ini = Ini::load_from_file(entry.path()).ok()?;
|
||||
let plugins = match plug_type {
|
||||
PlugType::Vst => crawl_vst_plugins_in_ini_file(
|
||||
product_accumulator,
|
||||
ini,
|
||||
detected_legacy_vst3_scan,
|
||||
),
|
||||
PlugType::Clap => crawl_clap_plugins_in_ini_file(product_accumulator, ini),
|
||||
};
|
||||
Some(plugins)
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn crawl_clap_plugins_in_ini_file(
|
||||
product_accumulator: &mut ProductAccumulator,
|
||||
ini: Ini,
|
||||
) -> Vec<Plugin> {
|
||||
ini.iter()
|
||||
.filter_map(|(section, props)| {
|
||||
let file_name = section?;
|
||||
let checksum = props.get("_")?;
|
||||
let plugins: Vec<_> = props
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if key == "_" {
|
||||
return None;
|
||||
}
|
||||
let (product_kind_id, plugin_name) = value.split_once('|')?;
|
||||
let product_kind = match product_kind_id {
|
||||
"0" => Some(ProductKind::Effect),
|
||||
"1" => Some(ProductKind::Instrument),
|
||||
_ => None,
|
||||
};
|
||||
let plugin = Plugin {
|
||||
common: PluginCommon {
|
||||
core: PluginCore {
|
||||
id: PluginId::clap(key).ok()?,
|
||||
product_kind,
|
||||
product_id: product_accumulator
|
||||
.get_or_add_plugin_product(plugin_name, product_kind),
|
||||
},
|
||||
name: plugin_name.to_string(),
|
||||
},
|
||||
kind: SuperPluginKind::Clap(ClapPlugin {
|
||||
file_name: file_name.to_string(),
|
||||
filetime: checksum.to_string(),
|
||||
id: key.to_string(),
|
||||
}),
|
||||
};
|
||||
Some(plugin)
|
||||
})
|
||||
.collect();
|
||||
Some(plugins)
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn crawl_vst_plugins_in_ini_file(
|
||||
product_accumulator: &mut ProductAccumulator,
|
||||
ini: Ini,
|
||||
detected_legacy_vst3_scan: &mut bool,
|
||||
) -> Vec<Plugin> {
|
||||
let Some(section) = ini.section(Some("vstcache")) else {
|
||||
return vec![];
|
||||
};
|
||||
section
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let (safe_file_name, shell_qualifier) = match key.split_once('<') {
|
||||
Some((first, second)) => (first.to_string(), Some(second.to_string())),
|
||||
None => (key.to_string(), None),
|
||||
};
|
||||
let mut value_iter = value.splitn(3, ',');
|
||||
let checksum = value_iter.next()?.to_string();
|
||||
let plugin_id_expression = value_iter.next()?;
|
||||
if plugin_id_expression == "0" {
|
||||
// Must be a plug-in shell, not an actual plug-in.
|
||||
return None;
|
||||
}
|
||||
let plugin_name_kind_expression = value_iter.next()?;
|
||||
let vst_kind = match plugin_id_expression.split_once('{') {
|
||||
None => {
|
||||
if safe_file_name.ends_with(".vst3") {
|
||||
// We skip VST3 plug-ins that have been scanned with old versions of
|
||||
// REAPER (and therefore missing a UID) in order to avoid creating wrong
|
||||
// persistent plug-in IDs).
|
||||
*detected_legacy_vst3_scan = true;
|
||||
return None;
|
||||
}
|
||||
VstPluginKind::Vst2 {
|
||||
magic_number: plugin_id_expression.to_string(),
|
||||
}
|
||||
}
|
||||
Some((left, right)) => VstPluginKind::Vst3 {
|
||||
uid_hash: left.to_string(),
|
||||
uid: right.to_string(),
|
||||
},
|
||||
};
|
||||
let (name, product_kind) = match plugin_name_kind_expression.split_once("!!!") {
|
||||
None => (
|
||||
plugin_name_kind_expression.to_string(),
|
||||
Some(ProductKind::Effect),
|
||||
),
|
||||
Some((left, right)) => {
|
||||
let kind = match right {
|
||||
"VSTi" => Some(ProductKind::Instrument),
|
||||
_ => None,
|
||||
};
|
||||
(left.to_string(), kind)
|
||||
}
|
||||
};
|
||||
let plugin = Plugin {
|
||||
common: PluginCommon {
|
||||
core: PluginCore {
|
||||
id: vst_kind.plugin_id().ok()?,
|
||||
product_id: product_accumulator
|
||||
.get_or_add_plugin_product(name.as_str(), product_kind),
|
||||
product_kind,
|
||||
},
|
||||
name,
|
||||
},
|
||||
kind: SuperPluginKind::Vst(VstPlugin {
|
||||
safe_file_name,
|
||||
shell_qualifier,
|
||||
checksum,
|
||||
kind: vst_kind,
|
||||
}),
|
||||
};
|
||||
Some(plugin)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Debug)]
|
||||
struct ProductName<'a> {
|
||||
main: &'a str,
|
||||
arch: Option<&'a str>,
|
||||
company: &'a str,
|
||||
channels: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ProductName<'a> {
|
||||
pub fn parse(name_expression: &'a str) -> Option<Self> {
|
||||
let four_part_regex = base::regex!(r"(.*) \((.*)\) \((.*)\) \((.*)\)");
|
||||
let three_part_regex = base::regex!(r"(.*) \((.*)\) \((.*)\)");
|
||||
let two_part_regex = base::regex!(r"(.*) \((.*)\)");
|
||||
|
||||
if let Some(captures) = four_part_regex.captures(name_expression) {
|
||||
return Some(ProductName {
|
||||
main: s(captures.get(1)),
|
||||
arch: Some(s(captures.get(2))),
|
||||
company: s(captures.get(3)),
|
||||
channels: Some(s(captures.get(4))),
|
||||
});
|
||||
}
|
||||
if let Some(captures) = three_part_regex.captures(name_expression) {
|
||||
let name = if &captures[2] == "x86_64" {
|
||||
ProductName {
|
||||
main: s(captures.get(1)),
|
||||
arch: Some(s(captures.get(2))),
|
||||
company: s(captures.get(3)),
|
||||
channels: None,
|
||||
}
|
||||
} else {
|
||||
ProductName {
|
||||
main: s(captures.get(1)),
|
||||
arch: None,
|
||||
company: s(captures.get(2)),
|
||||
channels: Some(s(captures.get(3))),
|
||||
}
|
||||
};
|
||||
return Some(name);
|
||||
}
|
||||
if let Some(captures) = two_part_regex.captures(name_expression) {
|
||||
return Some(ProductName {
|
||||
main: s(captures.get(1)),
|
||||
arch: None,
|
||||
company: s(captures.get(2)),
|
||||
channels: None,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn s(m: Option<Match>) -> &str {
|
||||
m.unwrap().as_str()
|
||||
}
|
||||
|
||||
fn read_js_desc_from_file(path: &Path) -> Option<String> {
|
||||
let file = File::open(path).ok()?;
|
||||
let mut buffer = String::new();
|
||||
let mut reader = BufReader::new(&file);
|
||||
while let Ok(count) = reader.read_line(&mut buffer) {
|
||||
if count == 0 {
|
||||
// EOF
|
||||
break;
|
||||
}
|
||||
let line = buffer.trim();
|
||||
if let Some((left, right)) = line.split_once(':') {
|
||||
if left == "desc" {
|
||||
return Some(right.trim().to_string());
|
||||
}
|
||||
}
|
||||
buffer.clear();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Further normalizes the already extracted main name so it conforms to the way "Komplete"
|
||||
/// displays the products.
|
||||
///
|
||||
/// # Maybe crop version number
|
||||
///
|
||||
/// Example: So we have successfully extracted "Kontakt 5" as main name from an expression
|
||||
/// "VSTi: Kontakt 5 (x86_64) (Native Instruments GmbH) (64 out). So far a very neutral extraction.
|
||||
/// But Komplete's product filter section doesn't distinguish between multiple versions of Kontakt.
|
||||
/// So we want to normalize this to just "Kontakt".
|
||||
///
|
||||
/// There are other NKS-ready products such as Pianoteq that distinguish between version numbers,
|
||||
/// so as a general rule we distinguish between version numbers and just add a few exceptions here.
|
||||
fn normalize_product_main_name(main_name: &str) -> &str {
|
||||
maybe_crop_version_number(main_name)
|
||||
}
|
||||
|
||||
fn maybe_crop_version_number(main_name: &str) -> &str {
|
||||
const PRODUCT_NAMES_WITHOUT_VERSION_NUMBER: &[&str] =
|
||||
&["Kontakt", "Absynth", "Guitar Rig", "Reaktor", "Battery"];
|
||||
let Some((left, right)) = main_name.rsplit_once(' ') else {
|
||||
// No right part
|
||||
return main_name;
|
||||
};
|
||||
if PRODUCT_NAMES_WITHOUT_VERSION_NUMBER.contains(&left)
|
||||
&& right.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
// Conforms to pattern, e.g. "Kontakt 7"
|
||||
left
|
||||
} else {
|
||||
// Doesn't conform to pattern
|
||||
main_name
|
||||
}
|
||||
}
|
||||
|
||||
const VST_CACHE_FILE: &str = {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
"reaper-vstplugins64.ini"
|
||||
}
|
||||
#[cfg(target_arch = "x86")]
|
||||
{
|
||||
"reaper-vstplugins.ini"
|
||||
}
|
||||
#[cfg(target_arch = "arm64ec")]
|
||||
{
|
||||
"reaper-vstplugins64arwmin.ini"
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
"reaper-vstplugins64.ini"
|
||||
}
|
||||
#[cfg(target_arch = "x86")]
|
||||
{
|
||||
"reaper-vstplugins.ini"
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
"reaper-vstplugins_arm64.ini"
|
||||
}
|
||||
#[cfg(target_arch = "arm")]
|
||||
{
|
||||
"reaper-vstplugins.ini"
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
"reaper-vstplugins64.ini"
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
"reaper-vstplugins_arm64.ini"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CLAP_CACHE_FILE: &str = {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
"reaper-clap-win64"
|
||||
}
|
||||
#[cfg(target_arch = "x86")]
|
||||
{
|
||||
"reaper-clap-win32"
|
||||
}
|
||||
#[cfg(target_arch = "arm64ec")]
|
||||
{
|
||||
"reaper-clap-winarm64"
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
"reaper-clap-linux-x86_64"
|
||||
}
|
||||
#[cfg(target_arch = "x86")]
|
||||
{
|
||||
"reaper-clap-linux-i386"
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
"reaper-clap-linux-aarch64"
|
||||
}
|
||||
#[cfg(target_arch = "arm")]
|
||||
{
|
||||
"reaper-clap-linux-arm"
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
"reaper-clap-macos-x86_64"
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
"reaper-clap-macos-aarch64.ini"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::plugins::ProductName;
|
||||
|
||||
#[test]
|
||||
pub fn product_name_parsing_2() {
|
||||
assert_eq!(
|
||||
ProductName::parse("TDR Nova (Tokyo Dawn Labs)"),
|
||||
Some(ProductName {
|
||||
main: "TDR Nova",
|
||||
arch: None,
|
||||
company: "Tokyo Dawn Labs",
|
||||
channels: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn product_name_parsing_3_arch() {
|
||||
assert_eq!(
|
||||
ProductName::parse("VC 76 (x86_64) (Native Instruments GmbH)"),
|
||||
Some(ProductName {
|
||||
main: "VC 76",
|
||||
arch: Some("x86_64"),
|
||||
company: "Native Instruments GmbH",
|
||||
channels: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn product_name_parsing_3_ch() {
|
||||
assert_eq!(
|
||||
ProductName::parse("Surge XT (Surge Synth Team) (2->6ch)"),
|
||||
Some(ProductName {
|
||||
main: "Surge XT",
|
||||
arch: None,
|
||||
company: "Surge Synth Team",
|
||||
channels: Some("2->6ch"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn product_name_parsing_4() {
|
||||
assert_eq!(
|
||||
ProductName::parse("Sitala (x86_64) (Decomposer) (32 out)"),
|
||||
Some(ProductName {
|
||||
main: "Sitala",
|
||||
arch: Some("x86_64"),
|
||||
company: "Decomposer",
|
||||
channels: Some("32 out"),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
use crate::provider_database::{
|
||||
Database, DatabaseId, InnerFilterItem, ProviderContext, SortablePresetId,
|
||||
FIL_HAS_PREVIEW_FALSE, FIL_HAS_PREVIEW_TRUE, FIL_IS_AVAILABLE_FALSE, FIL_IS_AVAILABLE_TRUE,
|
||||
FIL_IS_FAVORITE_FALSE, FIL_IS_FAVORITE_TRUE, FIL_IS_SUPPORTED_FALSE, FIL_IS_SUPPORTED_TRUE,
|
||||
FIL_IS_USER_PRESET_FALSE, FIL_IS_USER_PRESET_TRUE, FIL_PRODUCT_KIND_EFFECT,
|
||||
FIL_PRODUCT_KIND_INSTRUMENT, FIL_PRODUCT_KIND_LOOP, FIL_PRODUCT_KIND_ONE_SHOT,
|
||||
};
|
||||
use crate::providers::directory::{DirectoryDatabase, DirectoryDbConfig};
|
||||
use crate::providers::komplete::KompleteDatabase;
|
||||
use crate::{
|
||||
preview_exists, BuildInput, Fil, FilterItem, FilterItemCollections, FilterItemId, Filters,
|
||||
InnerBuildInput, PersistentDatabaseId, PersistentPresetId, PluginId, PotFavorites, PotPreset,
|
||||
PresetId, PresetWithId, Stats,
|
||||
};
|
||||
use base::{blocking_read_lock, blocking_write_lock};
|
||||
|
||||
use crate::plugins::PluginDatabase;
|
||||
use crate::providers::defaults::DefaultsDatabase;
|
||||
use crate::providers::ini::IniDatabase;
|
||||
|
||||
use enumset::{enum_set, EnumSet};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use reaper_high::Reaper;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
|
||||
use base::hash_util::NonCryptoIndexSet;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::{RwLock, RwLockReadGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub fn pot_db() -> &'static PotDatabase {
|
||||
use once_cell::sync::Lazy;
|
||||
static POT_DB: Lazy<PotDatabase> = Lazy::new(PotDatabase::open);
|
||||
&POT_DB
|
||||
}
|
||||
|
||||
type BoxedDatabase = Box<dyn Database + Send + Sync>;
|
||||
type DatabaseOpeningResult = Result<BoxedDatabase, PotDatabaseError>;
|
||||
|
||||
// The pot database is thread-safe! We achieve this by using internal read-write locks. Making it
|
||||
// thread-safe greatly simplifies usage of PotDatabase from a consumer perspective, because we can
|
||||
// easily obtain a static reference to it and let the pot database internals decide how to deal with
|
||||
// concurrency.
|
||||
//
|
||||
// But not just that, we can also improve performance. If the pot database wouldn't be thread-safe,
|
||||
// we would have to expose it wrapped by a mutex or read-write lock - which means less fine-granular
|
||||
// locking. Either the whole thing is locked or not at all.
|
||||
//
|
||||
// By having the RwLocks around the provider databases and not around the database collection, we
|
||||
// can have "more" concurrent access. E.g. find_preset_by_id, a function which can be called very
|
||||
// often by the GUI, only read-locks one particular database, not all. So if another database
|
||||
// is currently written to, it doesn't matter. It's just more flexible. Also, in future we might
|
||||
// want to use some fork-join approach to refresh/search concurrently multiple databases.
|
||||
// This would require having a RwLock around the database itself
|
||||
// because we would need to pass the database reference to the async code with an Arc, but an Arc
|
||||
// alone doesn't allow mutation of its contents. That's true even if the async database access would
|
||||
// be read-only. The synchronous refresh would still need mutable access but we wouldn't be able to
|
||||
// get one directly within an Arc.
|
||||
pub struct PotDatabase {
|
||||
plugin_db: RwLock<PluginDatabase>,
|
||||
databases: RwLock<Databases>,
|
||||
revision: AtomicU8,
|
||||
detected_legacy_vst3_scan: AtomicBool,
|
||||
}
|
||||
|
||||
type Databases = BTreeMap<DatabaseId, RwLock<BoxedDatabase>>;
|
||||
|
||||
#[derive(Clone, Debug, derive_more::Display)]
|
||||
pub struct PotDatabaseError(String);
|
||||
|
||||
impl Error for PotDatabaseError {}
|
||||
|
||||
fn box_db_result<D: Database + Send + Sync + 'static>(
|
||||
opening_result: Result<D, Box<dyn Error>>,
|
||||
) -> DatabaseOpeningResult {
|
||||
let db = opening_result.map_err(|e| PotDatabaseError(e.to_string()))?;
|
||||
Ok(Box::new(db))
|
||||
}
|
||||
|
||||
impl PotDatabase {
|
||||
pub fn open() -> Self {
|
||||
let resource_path = Reaper::get().resource_path();
|
||||
let komplete_db = KompleteDatabase::open();
|
||||
let rfx_chain_db = {
|
||||
let config = DirectoryDbConfig {
|
||||
persistent_id: PersistentDatabaseId::new("fx-chains".to_string()),
|
||||
root_dir: resource_path.join("FXChains"),
|
||||
valid_extensions: &["RfxChain"],
|
||||
name: "FX chains",
|
||||
description: "All the RfxChain files in your FXChains directory",
|
||||
};
|
||||
DirectoryDatabase::open(config)
|
||||
};
|
||||
let track_template_db = {
|
||||
let config = DirectoryDbConfig {
|
||||
persistent_id: PersistentDatabaseId::new("track-templates".to_string()),
|
||||
root_dir: resource_path.join("TrackTemplates"),
|
||||
valid_extensions: &["RTrackTemplate"],
|
||||
name: "Track templates",
|
||||
description: "All the RTrackTemplate files in your TrackTemplates directory.\n\
|
||||
Doesn't load the complete track, only its FX chain!",
|
||||
};
|
||||
DirectoryDatabase::open(config)
|
||||
};
|
||||
let ini_db = IniDatabase::open(
|
||||
PersistentDatabaseId::new("fx-presets".to_string()),
|
||||
resource_path.join("presets"),
|
||||
);
|
||||
let defaults_db = DefaultsDatabase::open();
|
||||
let databases = [
|
||||
box_db_result(komplete_db),
|
||||
box_db_result(rfx_chain_db),
|
||||
box_db_result(track_template_db),
|
||||
box_db_result(ini_db),
|
||||
box_db_result(Ok(defaults_db)),
|
||||
];
|
||||
let databases = databases
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(i, db)| (DatabaseId(i as _), RwLock::new(db)))
|
||||
.collect();
|
||||
Self {
|
||||
plugin_db: Default::default(),
|
||||
databases: RwLock::new(databases),
|
||||
revision: Default::default(),
|
||||
detected_legacy_vst3_scan: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a number that will be increased with each database refresh.
|
||||
pub fn revision(&self) -> u8 {
|
||||
self.revision.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn refresh(&self) {
|
||||
// Build provider context
|
||||
let resource_path = Reaper::get().resource_path();
|
||||
// Crawl plug-ins
|
||||
let plugin_db = PluginDatabase::crawl(&resource_path);
|
||||
// In order to be able to query the legacy-vst3-scan result without having to lock the
|
||||
// plug-in DB (which could lead to unresponsive UI), we save it as atomic bool right here.
|
||||
self.detected_legacy_vst3_scan
|
||||
.store(plugin_db.detected_legacy_vst3_scan(), Ordering::Relaxed);
|
||||
let provider_context = ProviderContext::new(&plugin_db);
|
||||
// Refresh databases
|
||||
for db in self.read_lock_databases().values() {
|
||||
let mut db = blocking_write_lock(db, "pot db refresh provider db");
|
||||
let _ = db.refresh(&provider_context);
|
||||
}
|
||||
// Memorize plug-ins
|
||||
*blocking_write_lock(&self.plugin_db, "pot db refresh plugin db") = plugin_db;
|
||||
// Increment revision
|
||||
self.revision.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn detected_legacy_vst3_scan(&self) -> bool {
|
||||
self.detected_legacy_vst3_scan.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn read_lock_databases(&self) -> RwLockReadGuard<Databases> {
|
||||
blocking_read_lock(&self.databases, "read-lock pot-db databases")
|
||||
}
|
||||
|
||||
fn read_lock_plugin_db(&self) -> RwLockReadGuard<PluginDatabase> {
|
||||
blocking_read_lock(&self.plugin_db, "read-lock plug-in database")
|
||||
}
|
||||
|
||||
pub fn add_database(&self, db: impl Database + Send + Sync + 'static) -> DatabaseId {
|
||||
let mut databases = blocking_write_lock(&self.databases, "add_database");
|
||||
let new_db_id = DatabaseId(databases.len() as u32);
|
||||
databases.insert(new_db_id, RwLock::new(Box::new(db)));
|
||||
new_db_id
|
||||
}
|
||||
|
||||
pub fn build_collections(
|
||||
&self,
|
||||
mut input: BuildInput,
|
||||
affected_kinds: EnumSet<PotFilterKind>,
|
||||
) -> BuildOutput {
|
||||
// Preparation
|
||||
// TODO-high-pot Implement correctly as soon as favorites writable
|
||||
let favorites = PotFavorites::default();
|
||||
let plugin_db = self.read_lock_plugin_db();
|
||||
let provider_context = ProviderContext::new(&plugin_db);
|
||||
// Build constant filter collections
|
||||
let mut total_output = BuildOutput {
|
||||
supported_filter_kinds: enum_set!(
|
||||
PotFilterKind::Database
|
||||
| PotFilterKind::IsUser
|
||||
| PotFilterKind::IsFavorite
|
||||
| PotFilterKind::ProductKind
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
measure_duration(&mut total_output.stats.filter_query_duration, || {
|
||||
add_constant_filter_items(affected_kinds, &mut total_output.filter_item_collections);
|
||||
// Let all databases build filter collections and accumulate them
|
||||
let mut database_filter_items = Vec::new();
|
||||
let mut used_product_ids = HashSet::new();
|
||||
for (db_id, db) in self.read_lock_databases().deref() {
|
||||
// If the database is on the exclude list, we don't even want it to appear in the
|
||||
// database list.
|
||||
if input.filter_excludes.contains_database(*db_id) {
|
||||
continue;
|
||||
}
|
||||
// Acquire database access
|
||||
let db = blocking_read_lock(db, "pot db build_collections 1");
|
||||
// Create database filter item
|
||||
let filter_item = FilterItem {
|
||||
persistent_id: "".to_string(),
|
||||
id: FilterItemId(Some(Fil::Database(*db_id))),
|
||||
parent_name: None,
|
||||
name: Some(db.name().to_string()),
|
||||
icon: None,
|
||||
more_info: Some(db.description().to_string()),
|
||||
};
|
||||
database_filter_items.push(filter_item);
|
||||
// Don't continue if database doesn't match filter
|
||||
// (but it should appear on the list)
|
||||
if !input.filters.database_matches(*db_id) {
|
||||
continue;
|
||||
}
|
||||
// Add supported filter kinds
|
||||
total_output.supported_filter_kinds |= db.supported_advanced_filter_kinds();
|
||||
// Build and accumulate filters collections
|
||||
let inner_input = InnerBuildInput::new(&input, &favorites, *db_id);
|
||||
let Ok(filter_collections) =
|
||||
db.query_filter_collections(&provider_context, inner_input, affected_kinds)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Add unique filter items directly to the list of filters. Gather shared filter
|
||||
// items so we can deduplicate them later.
|
||||
for (kind, items) in filter_collections.into_iter() {
|
||||
let final_filter_items = items.into_iter().filter_map(|i| match i {
|
||||
InnerFilterItem::Unique(i) => Some(i),
|
||||
InnerFilterItem::Product(pid) => {
|
||||
used_product_ids.insert(pid);
|
||||
None
|
||||
}
|
||||
});
|
||||
total_output
|
||||
.filter_item_collections
|
||||
.extend(kind, final_filter_items);
|
||||
}
|
||||
}
|
||||
// Process shared filter items
|
||||
let product_filter_items = used_product_ids.into_iter().filter_map(|pid| {
|
||||
let product = plugin_db.find_product_by_id(&pid)?;
|
||||
let filter_item = FilterItem {
|
||||
persistent_id: "".to_string(),
|
||||
id: FilterItemId(Some(Fil::Product(pid))),
|
||||
parent_name: None,
|
||||
name: Some(product.name.clone()),
|
||||
icon: None,
|
||||
more_info: product.kind.map(|k| k.to_string()),
|
||||
};
|
||||
Some(filter_item)
|
||||
});
|
||||
total_output
|
||||
.filter_item_collections
|
||||
.extend(PotFilterKind::Bank, product_filter_items);
|
||||
// Add database filter items
|
||||
if affected_kinds.contains(PotFilterKind::Database) {
|
||||
total_output
|
||||
.filter_item_collections
|
||||
.set(PotFilterKind::Database, database_filter_items);
|
||||
}
|
||||
// Important: At this point, some previously selected filters might not exist anymore.
|
||||
// So we should reset them and not let them influence the preset query anymore!
|
||||
input.filters.clear_if_not_available_anymore(
|
||||
affected_kinds,
|
||||
&total_output.filter_item_collections,
|
||||
);
|
||||
});
|
||||
// Finally build
|
||||
let mut sortable_preset_ids: Vec<_> =
|
||||
measure_duration(&mut total_output.stats.preset_query_duration, || {
|
||||
self.gather_preset_ids_internal(&input, &provider_context, &favorites)
|
||||
});
|
||||
// Apply "has preview" filter if necessary (expensive!)
|
||||
measure_duration(&mut total_output.stats.preview_filter_duration, || {
|
||||
self.apply_has_preview_filter(&input.filters, &mut sortable_preset_ids);
|
||||
});
|
||||
// Sort filter items and presets
|
||||
measure_duration(&mut total_output.stats.sort_duration, || {
|
||||
for (kind, collection) in total_output.filter_item_collections.iter_mut() {
|
||||
if kind.wants_sorting() {
|
||||
collection.sort_by(|i1, i2| {
|
||||
lexical_sort::lexical_cmp(i1.sort_name(), i2.sort_name())
|
||||
});
|
||||
}
|
||||
}
|
||||
sortable_preset_ids.sort_by(|(_, p1), (_, p2)| {
|
||||
lexical_sort::lexical_cmp(&p1.preset_name, &p2.preset_name)
|
||||
});
|
||||
});
|
||||
// Index presets. Because later, we look up the preset index by the preset ID and vice versa
|
||||
// and we want that to happen without complexity O(n)! There can be tons of presets!
|
||||
measure_duration(&mut total_output.stats.index_duration, || {
|
||||
total_output.preset_collection = sortable_preset_ids
|
||||
.into_iter()
|
||||
.map(|(db_id, p)| PresetId::new(db_id, p.inner_preset_id))
|
||||
.collect();
|
||||
});
|
||||
total_output
|
||||
}
|
||||
|
||||
fn apply_has_preview_filter(
|
||||
&self,
|
||||
filters: &Filters,
|
||||
sortable_preset_ids: &mut Vec<(DatabaseId, SortablePresetId)>,
|
||||
) {
|
||||
if let Some(wants_preview) = filters.wants_preview() {
|
||||
let reaper_resource_dir = Reaper::get().resource_path();
|
||||
sortable_preset_ids.retain(|(db_id, sortable_preset_id)| {
|
||||
let preset_id = PresetId::new(*db_id, sortable_preset_id.inner_preset_id);
|
||||
if let Some(preset) = self.find_preset_by_id(preset_id) {
|
||||
preview_exists(&preset, &reaper_resource_dir) == wants_preview
|
||||
} else {
|
||||
// Preset doesn't exist? Shouldn't happen, but treat it like a missing preview.
|
||||
!wants_preview
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Gathers an unsorted list of preset respecting all pre-filters.
|
||||
pub fn gather_presets(&self, input: BuildInput) -> Vec<PresetWithId> {
|
||||
// TODO-high-pot Implement correctly as soon as favorites writable
|
||||
let favorites = PotFavorites::default();
|
||||
let plugin_db = self.read_lock_plugin_db();
|
||||
let provider_context = ProviderContext::new(&plugin_db);
|
||||
self.gather_preset_ids_internal(&input, &provider_context, &favorites)
|
||||
.into_iter()
|
||||
.filter_map(|(db_id, sortable_preset_id)| {
|
||||
let preset_id = PresetId::new(db_id, sortable_preset_id.inner_preset_id);
|
||||
let preset = self.find_preset_by_id(preset_id)?;
|
||||
Some(PresetWithId::new(preset_id, preset))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gather_preset_ids_internal(
|
||||
&self,
|
||||
input: &BuildInput,
|
||||
provider_context: &ProviderContext,
|
||||
favorites: &PotFavorites,
|
||||
) -> Vec<(DatabaseId, SortablePresetId)> {
|
||||
self.read_lock_databases()
|
||||
.deref()
|
||||
.iter()
|
||||
.filter(|(db_id, _)| {
|
||||
input.filters.database_matches(**db_id)
|
||||
&& !input.filter_excludes.contains_database(**db_id)
|
||||
})
|
||||
.filter_map(|(db_id, db)| {
|
||||
// Acquire database access
|
||||
let db = blocking_read_lock(db, "pot db build_collections 2");
|
||||
// Don't even try to get presets if one filter is set which is not
|
||||
// supported by database.
|
||||
if input
|
||||
.filters
|
||||
.any_unsupported_filter_is_set_to_concrete_value(
|
||||
db.supported_advanced_filter_kinds(),
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Let database build presets
|
||||
let inner_input = InnerBuildInput::new(input, favorites, *db_id);
|
||||
let preset_ids = db.query_presets(provider_context, inner_input).ok()?;
|
||||
Some((*db_id, preset_ids))
|
||||
})
|
||||
.flat_map(|(db_id, preset_ids)| preset_ids.into_iter().map(move |p| (db_id, p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn find_preset_by_id(&self, preset_id: PresetId) -> Option<PotPreset> {
|
||||
let plugin_db = self.read_lock_plugin_db();
|
||||
let provider_context = ProviderContext::new(&plugin_db);
|
||||
let databases = self.read_lock_databases();
|
||||
let db = databases.get(&preset_id.database_id)?;
|
||||
let db = blocking_read_lock(db, "pot db find_preset_by_id 1");
|
||||
db.find_preset_by_id(&provider_context, preset_id.preset_id)
|
||||
}
|
||||
|
||||
pub fn with_plugin_db<R>(&self, f: impl FnOnce(&PluginDatabase) -> R) -> R {
|
||||
f(&self.read_lock_plugin_db())
|
||||
}
|
||||
|
||||
pub fn try_with_db<R>(
|
||||
&self,
|
||||
db_id: DatabaseId,
|
||||
f: impl FnOnce(&dyn Database) -> R,
|
||||
) -> Result<R, &'static str> {
|
||||
let databases = self.read_lock_databases();
|
||||
let db = databases.get(&db_id).ok_or("database not found")?;
|
||||
let db = db
|
||||
.try_read()
|
||||
.map_err(|_| "couldn't acquire provider db lock")?;
|
||||
let r = f(&**db);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub fn try_find_preset_by_id(
|
||||
&self,
|
||||
preset_id: PresetId,
|
||||
) -> Result<Option<PotPreset>, &'static str> {
|
||||
let plugin_db = self
|
||||
.plugin_db
|
||||
.try_read()
|
||||
.map_err(|_| "couldn't acquire plugin db lock")?;
|
||||
let provider_context = ProviderContext::new(&plugin_db);
|
||||
let databases = self.read_lock_databases();
|
||||
let db = databases
|
||||
.get(&preset_id.database_id)
|
||||
.ok_or("database not found")?;
|
||||
let db = db
|
||||
.try_read()
|
||||
.map_err(|_| "couldn't acquire provider db lock")?;
|
||||
Ok(db.find_preset_by_id(&provider_context, preset_id.preset_id))
|
||||
}
|
||||
|
||||
/// Ignores exclude lists.
|
||||
pub fn find_unsupported_preset_matching(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
preset_name: &str,
|
||||
) -> Option<PersistentPresetId> {
|
||||
let product_id = {
|
||||
let plugin_db = self.read_lock_plugin_db();
|
||||
let plugin = plugin_db.find_plugin_by_id(plugin_id)?;
|
||||
plugin.common.core.product_id
|
||||
};
|
||||
self.read_lock_databases().values().find_map(|db| {
|
||||
// Acquire database access
|
||||
let db = blocking_read_lock(db, "pot db find_unsupported_preset_matching");
|
||||
// Find preset
|
||||
let preset = db.find_unsupported_preset_matching(product_id, preset_name)?;
|
||||
Some(preset.common.persistent_id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BuildOutput {
|
||||
pub supported_filter_kinds: EnumSet<PotFilterKind>,
|
||||
pub filter_item_collections: FilterItemCollections,
|
||||
pub preset_collection: NonCryptoIndexSet<PresetId>,
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
fn measure_duration<R>(duration: &mut Duration, f: impl FnOnce() -> R) -> R {
|
||||
let start = Instant::now();
|
||||
let r = f();
|
||||
*duration = start.elapsed();
|
||||
r
|
||||
}
|
||||
|
||||
fn add_constant_filter_items(
|
||||
affected_kinds: EnumSet<PotFilterKind>,
|
||||
filter_item_collections: &mut FilterItemCollections,
|
||||
) {
|
||||
if affected_kinds.contains(PotFilterKind::IsAvailable) {
|
||||
filter_item_collections.set(
|
||||
PotFilterKind::IsAvailable,
|
||||
create_filter_items_is_available(),
|
||||
);
|
||||
}
|
||||
if affected_kinds.contains(PotFilterKind::IsSupported) {
|
||||
filter_item_collections.set(
|
||||
PotFilterKind::IsSupported,
|
||||
create_filter_items_is_supported(),
|
||||
);
|
||||
}
|
||||
if affected_kinds.contains(PotFilterKind::IsFavorite) {
|
||||
filter_item_collections.set(PotFilterKind::IsFavorite, create_filter_items_is_favorite());
|
||||
}
|
||||
if affected_kinds.contains(PotFilterKind::IsUser) {
|
||||
filter_item_collections.set(PotFilterKind::IsUser, create_filter_items_is_user());
|
||||
}
|
||||
if affected_kinds.contains(PotFilterKind::HasPreview) {
|
||||
filter_item_collections.set(PotFilterKind::HasPreview, create_filter_items_has_preview());
|
||||
}
|
||||
if affected_kinds.contains(PotFilterKind::ProductKind) {
|
||||
filter_item_collections.set(
|
||||
PotFilterKind::ProductKind,
|
||||
create_filter_items_product_kind(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_filter_items_is_available() -> Vec<FilterItem> {
|
||||
vec![
|
||||
FilterItem::simple(FIL_IS_AVAILABLE_FALSE, "Not available", '❌', ""),
|
||||
FilterItem::simple(
|
||||
FIL_IS_AVAILABLE_TRUE,
|
||||
"Available",
|
||||
'✔',
|
||||
"Usually means that the \
|
||||
corresponding plug-in has been scanned before by REAPER.\n\
|
||||
For Komplete, it means that the preset file itself is available.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn create_filter_items_is_supported() -> Vec<FilterItem> {
|
||||
vec![
|
||||
FilterItem::simple(FIL_IS_SUPPORTED_FALSE, "Not supported", '☹', ""),
|
||||
FilterItem::simple(
|
||||
FIL_IS_SUPPORTED_TRUE,
|
||||
"Supported",
|
||||
'☺',
|
||||
"Means that Pot Browser \
|
||||
can automatically load the preset into the corresponding plug-in.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn create_filter_items_is_favorite() -> Vec<FilterItem> {
|
||||
vec![
|
||||
FilterItem::simple(FIL_IS_FAVORITE_FALSE, "Not favorite", '☆', ""),
|
||||
FilterItem::simple(FIL_IS_FAVORITE_TRUE, "Favorite", '★', ""),
|
||||
]
|
||||
}
|
||||
|
||||
fn create_filter_items_product_kind() -> Vec<FilterItem> {
|
||||
vec![
|
||||
FilterItem::none(),
|
||||
FilterItem::simple(FIL_PRODUCT_KIND_INSTRUMENT, "Instrument", '🎹', ""),
|
||||
FilterItem::simple(FIL_PRODUCT_KIND_EFFECT, "Effect", '✨', ""),
|
||||
FilterItem::simple(FIL_PRODUCT_KIND_LOOP, "Loop", '➿', ""),
|
||||
FilterItem::simple(FIL_PRODUCT_KIND_ONE_SHOT, "One shot", '💥', ""),
|
||||
]
|
||||
}
|
||||
|
||||
fn create_filter_items_is_user() -> Vec<FilterItem> {
|
||||
vec![
|
||||
FilterItem::simple(FIL_IS_USER_PRESET_FALSE, "Factory preset", '🏭', ""),
|
||||
FilterItem::simple(FIL_IS_USER_PRESET_TRUE, "User preset", '🕵', ""),
|
||||
]
|
||||
}
|
||||
|
||||
fn create_filter_items_has_preview() -> Vec<FilterItem> {
|
||||
vec![
|
||||
FilterItem::simple(FIL_HAS_PREVIEW_FALSE, "No preview", '🔇', "Display only presets that have no preview. This filter can take very long when operating on a large preset list because it checks whether the preview files actually exist!"),
|
||||
FilterItem::simple(FIL_HAS_PREVIEW_TRUE, "Has preview", '🔊', "Display only presets that have a preview. This filter can take very long when operating on a large preset list because it checks whether the preview files actually exist!"),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
use crate::{
|
||||
parse_vst2_magic_number, parse_vst3_uid, pot_db, EscapeCatcher, PersistentPresetId, PluginId,
|
||||
};
|
||||
use base::enigo::EnigoMouse;
|
||||
use base::future_util::millis;
|
||||
use base::hash_util::NonCryptoIndexMap;
|
||||
use base::{blocking_lock_arc, file_util, hash_util};
|
||||
use base::{Mouse, MouseCursorPosition};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use helgobox_api::persistence::MouseButton;
|
||||
use reaper_high::{Fx, FxInfo, Reaper};
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub type SharedPresetCrawlingState = Arc<Mutex<PresetCrawlingState>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PresetCrawlingState {
|
||||
crawled_presets: NonCryptoIndexMap<String, CrawledPreset>,
|
||||
duplicate_preset_names: Vec<String>,
|
||||
same_preset_name_in_a_row: Option<String>,
|
||||
same_preset_name_in_a_row_attempts: u32,
|
||||
same_preset_names_like_beginning: Vec<String>,
|
||||
same_preset_name_like_beginning_attempts: u32,
|
||||
total_bytes_crawled: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PresetCrawlingOutcome {
|
||||
/// One temporary file that holds the chunks of all FXs when crawling finished.
|
||||
/// Will be copied to separate destination files at a later stage.
|
||||
/// If not set when stopped, this means at first it's a failure. Later we take the
|
||||
/// file out of here for processing, in that case it's also `None`.
|
||||
pub chunks_file: File,
|
||||
pub reason: PresetCrawlerStopReason,
|
||||
}
|
||||
|
||||
impl PresetCrawlingOutcome {
|
||||
pub fn new(chunks_file: File, reason: PresetCrawlerStopReason) -> Self {
|
||||
Self {
|
||||
chunks_file,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PresetCrawlingState {
|
||||
pub fn new() -> SharedPresetCrawlingState {
|
||||
let state = Self {
|
||||
crawled_presets: Default::default(),
|
||||
duplicate_preset_names: Default::default(),
|
||||
same_preset_name_in_a_row: None,
|
||||
same_preset_name_in_a_row_attempts: 0,
|
||||
same_preset_names_like_beginning: Default::default(),
|
||||
same_preset_name_like_beginning_attempts: 0,
|
||||
total_bytes_crawled: 0,
|
||||
};
|
||||
Arc::new(Mutex::new(state))
|
||||
}
|
||||
|
||||
pub fn last_crawled_preset(&self) -> Option<&CrawledPreset> {
|
||||
let last = self.crawled_presets.last()?;
|
||||
Some(last.1)
|
||||
}
|
||||
|
||||
pub fn pop_crawled_preset(&mut self) -> Option<CrawledPreset> {
|
||||
let last = self.crawled_presets.pop()?;
|
||||
Some(last.1)
|
||||
}
|
||||
|
||||
pub fn bytes_crawled(&self) -> usize {
|
||||
self.total_bytes_crawled
|
||||
}
|
||||
|
||||
pub fn crawled_presets(&self) -> &NonCryptoIndexMap<String, CrawledPreset> {
|
||||
&self.crawled_presets
|
||||
}
|
||||
|
||||
pub fn preset_count(&self) -> u32 {
|
||||
self.crawled_presets.len() as _
|
||||
}
|
||||
|
||||
pub fn duplicate_preset_name_count(&self) -> u32 {
|
||||
self.duplicate_preset_names.len() as _
|
||||
}
|
||||
|
||||
pub fn duplicate_preset_names(&self) -> &[String] {
|
||||
&self.duplicate_preset_names
|
||||
}
|
||||
|
||||
fn add_preset(&mut self, preset: CrawledPreset, never_stop_crawling: bool) -> NextCrawlStep {
|
||||
// Give stop signal if we reached the end of the list or are at its beginning again.
|
||||
if !never_stop_crawling {
|
||||
if let Some(step) = self.make_stop_check(&preset) {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
// Reset "same preset name attempts" logic
|
||||
self.same_preset_name_in_a_row_attempts = 0;
|
||||
if let Some(last_same_preset_name) = self.same_preset_name_in_a_row.take() {
|
||||
// Turns out that the last discovered same preset name was actually not the end
|
||||
// of the preset list but just an intermediate duplicate. Treat it as such!
|
||||
self.duplicate_preset_names.push(last_same_preset_name);
|
||||
}
|
||||
// Reset "same preset name like beginning" logic
|
||||
self.same_preset_name_like_beginning_attempts = 0;
|
||||
self.duplicate_preset_names
|
||||
.append(&mut self.same_preset_names_like_beginning);
|
||||
// Add or skip
|
||||
if self.crawled_presets.contains_key(&preset.name) {
|
||||
// Duplicate name. Skip preset!
|
||||
self.duplicate_preset_names.push(preset.name);
|
||||
} else {
|
||||
// Add preset
|
||||
self.total_bytes_crawled += preset.size_in_bytes;
|
||||
self.crawled_presets.insert(preset.name.clone(), preset);
|
||||
}
|
||||
NextCrawlStep::Continue
|
||||
}
|
||||
|
||||
/// This executes a heuristic to check whether the end of the preset list has been reached and
|
||||
/// crawling should therefore stop.
|
||||
///
|
||||
/// It looks at the preset names only. I also tried to take the chunk into account but it's not
|
||||
/// deterministic. Getting the chunk for one preset multiple times can yield different results!
|
||||
fn make_stop_check(&mut self, preset: &CrawledPreset) -> Option<NextCrawlStep> {
|
||||
// If we haven't crawled anything yet, there's nothing to check.
|
||||
let (_, last_preset) = self.crawled_presets.last()?;
|
||||
// Check if we get multiple equally named presets in a row.
|
||||
if preset.name == last_preset.name {
|
||||
// Same name like last crawled preset
|
||||
if self.same_preset_name_in_a_row_attempts <= MAX_SAME_PRESET_NAME_IN_A_ROW_ATTEMPTS {
|
||||
// Let's tolerate that right now and still continue crawling.
|
||||
// It's possible that the plug-in crops the preset name and therefore
|
||||
// presets that seemingly have the same name, in fact have different ones
|
||||
// but have the same prefix. This happened with Zebra2 VSTi, for example.
|
||||
self.same_preset_name_in_a_row_attempts += 1;
|
||||
// Don't add it to the list of duplicates right away because it *might* really
|
||||
// turn out to be the end of the preset list! If it turns out it isn't, we still add
|
||||
// it to the list of duplicates later.
|
||||
self.same_preset_name_in_a_row = Some(preset.name.clone());
|
||||
return Some(NextCrawlStep::Continue);
|
||||
} else {
|
||||
// More than max same preset names in a row! That either means the
|
||||
// "Next preset" button doesn't work at all or we have reached the end of the
|
||||
// preset list.
|
||||
return Some(NextCrawlStep::Stop(
|
||||
PresetCrawlerStopReason::PresetNameNotChangingAnymore,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Now check if the presets that we crawl are the same ones that we crawled in the beginning.
|
||||
if let Some((_, reference_preset)) = self
|
||||
.crawled_presets
|
||||
.get_index(self.same_preset_name_like_beginning_attempts as usize)
|
||||
{
|
||||
if preset.name == reference_preset.name {
|
||||
// This preset has the same name as the reference preset, which is one of the
|
||||
// presets crawled right at the beginning.
|
||||
if self.same_preset_name_like_beginning_attempts
|
||||
<= MAX_SAME_PRESET_NAME_LIKE_BEGINNING_ATTEMPTS
|
||||
{
|
||||
// Let's tolerate that right now and still continue crawling.
|
||||
// It's possible that the plug-in doesn't navigate through the preset list in
|
||||
// a linear way.
|
||||
self.same_preset_name_like_beginning_attempts += 1;
|
||||
// Don't add it to the list of duplicates right away because it *might* really
|
||||
// turn out to be the beginning of the preset list! If it turns out it isn't,
|
||||
// we still add it to the list of duplicates later.
|
||||
self.same_preset_names_like_beginning
|
||||
.push(preset.name.clone());
|
||||
return Some(NextCrawlStep::Continue);
|
||||
} else {
|
||||
// More than max matches with the beginning! That either means the plug-in
|
||||
// navigates in a *very* non-linear fashion through the preset list or we have
|
||||
// reached the end of the preset list and restarted at its beginning.
|
||||
return Some(NextCrawlStep::Stop(
|
||||
PresetCrawlerStopReason::PresetNameLikeBeginning,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
enum NextCrawlStep {
|
||||
Continue,
|
||||
Stop(PresetCrawlerStopReason),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CrawledPreset {
|
||||
name: String,
|
||||
offset: u64,
|
||||
size_in_bytes: usize,
|
||||
destination: Utf8PathBuf,
|
||||
}
|
||||
|
||||
impl CrawledPreset {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn destination(&self) -> &Utf8Path {
|
||||
&self.destination
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CrawlPresetArgs<F> {
|
||||
pub fx: Fx,
|
||||
pub next_preset_cursor_pos: MouseCursorPosition,
|
||||
pub state: SharedPresetCrawlingState,
|
||||
pub stop_if_destination_exists: bool,
|
||||
pub never_stop_crawling: bool,
|
||||
pub bring_focus_back_to_crawler: F,
|
||||
}
|
||||
|
||||
pub async fn crawl_presets<F>(
|
||||
args: CrawlPresetArgs<F>,
|
||||
) -> Result<PresetCrawlingOutcome, Box<dyn Error + Send + Sync>>
|
||||
where
|
||||
F: Fn() + 'static,
|
||||
{
|
||||
let reaper_resource_dir = Reaper::get().resource_path();
|
||||
// No need to fall back to chunk-based FX info because Pot is experimental, and we can assume it's used
|
||||
// with recent REAPER versions.
|
||||
let fx_info = args.fx.info()?;
|
||||
let plugin_id = get_plugin_id_from_fx_info(&fx_info);
|
||||
let mut mouse = EnigoMouse::new();
|
||||
let escape_catcher = EscapeCatcher::new();
|
||||
let mut chunks_file = tempfile::tempfile()?;
|
||||
let mut current_file_offset = 0u64;
|
||||
loop {
|
||||
// Check if escape has been pressed
|
||||
if escape_catcher.escape_was_pressed() {
|
||||
return Ok(PresetCrawlingOutcome::new(
|
||||
chunks_file,
|
||||
PresetCrawlerStopReason::Interrupted,
|
||||
));
|
||||
}
|
||||
// Get preset name
|
||||
let name = args
|
||||
.fx
|
||||
.preset_name()
|
||||
.ok_or("couldn't get preset name")?
|
||||
.into_string();
|
||||
{
|
||||
// Query chunk and save it in temporary file
|
||||
let fx_chunk = args.fx.chunk()?;
|
||||
let fx_chunk_content = fx_chunk.content();
|
||||
let fx_chunk_bytes = fx_chunk_content.as_bytes();
|
||||
chunks_file.write_all(fx_chunk_bytes)?;
|
||||
chunks_file.flush()?;
|
||||
// Determine where on the disk the RfxChain file should end up
|
||||
let destination = determine_preset_file_destination(
|
||||
&fx_info,
|
||||
&reaper_resource_dir,
|
||||
&name,
|
||||
plugin_id.as_ref(),
|
||||
);
|
||||
if args.stop_if_destination_exists && destination.exists() {
|
||||
return Ok(PresetCrawlingOutcome::new(
|
||||
chunks_file,
|
||||
PresetCrawlerStopReason::DestinationFileExists,
|
||||
));
|
||||
}
|
||||
// Build crawled preset
|
||||
let crawled_preset = CrawledPreset {
|
||||
destination,
|
||||
name,
|
||||
offset: current_file_offset,
|
||||
size_in_bytes: fx_chunk_bytes.len(),
|
||||
};
|
||||
current_file_offset += fx_chunk_bytes.len() as u64;
|
||||
let next_step = blocking_lock_arc(&args.state, "crawl_presets 3")
|
||||
.add_preset(crawled_preset, args.never_stop_crawling);
|
||||
match next_step {
|
||||
NextCrawlStep::Stop(reason) => {
|
||||
return Ok(PresetCrawlingOutcome::new(chunks_file, reason));
|
||||
}
|
||||
NextCrawlStep::Continue => {}
|
||||
}
|
||||
}
|
||||
// Click "Next preset" button
|
||||
args.fx.show_in_floating_window()?;
|
||||
mouse.set_cursor_position(args.next_preset_cursor_pos)?;
|
||||
moment().await;
|
||||
mouse.press(MouseButton::Left)?;
|
||||
moment().await;
|
||||
mouse.release(MouseButton::Left)?;
|
||||
a_bit_longer().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_preset_file_destination(
|
||||
fx_info: &FxInfo,
|
||||
reaper_resource_dir: &Utf8Path,
|
||||
preset_name: &str,
|
||||
plugin_id: Option<&PluginId>,
|
||||
) -> Utf8PathBuf {
|
||||
if let Some(persistent_preset_id) = find_shimmable_preset(plugin_id, preset_name) {
|
||||
// Matched with existing unsupported preset. Create RfxChain file, a so called shim file,
|
||||
// but not in the FX chain directory because we don't want it to show up in the FX chain
|
||||
// database. Instead, we want the original preset (probably in the Komplete database)
|
||||
// to become loadable. There's logic in our preset loading mechanism that looks for
|
||||
// a shim file if it realizes that the preset can't be loaded. A kind of fallback!
|
||||
get_shim_file_path(reaper_resource_dir, &persistent_preset_id)
|
||||
} else {
|
||||
// No match with existing unsupported preset
|
||||
let sanitized_effect_name = sanitize_filename::sanitize(&fx_info.effect_name);
|
||||
let file_name = format!("{}.RfxChain", &preset_name);
|
||||
let sanitized_file_name = sanitize_filename::sanitize(file_name);
|
||||
reaper_resource_dir
|
||||
.join("FXChains/Pot")
|
||||
.join(sanitized_effect_name)
|
||||
.join(sanitized_file_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the file name of the original preset.
|
||||
fn find_shimmable_preset(
|
||||
plugin_id: Option<&PluginId>,
|
||||
preset_name: &str,
|
||||
) -> Option<PersistentPresetId> {
|
||||
let plugin_id = plugin_id?;
|
||||
pot_db().find_unsupported_preset_matching(plugin_id, preset_name)
|
||||
}
|
||||
|
||||
fn get_plugin_id_from_fx_info(fx_info: &FxInfo) -> Option<PluginId> {
|
||||
let plugin_id = match fx_info.sub_type_expression.as_str() {
|
||||
"VST" | "VSTi" => PluginId::vst2(parse_vst2_magic_number(&fx_info.id).ok()?),
|
||||
"VST3" | "VST3i" => PluginId::vst3(parse_vst3_uid(&fx_info.id).ok()?),
|
||||
// Komplete doesn't support CLAP or JS anyway, so not important right now.
|
||||
_ => return None,
|
||||
};
|
||||
Some(plugin_id)
|
||||
}
|
||||
|
||||
pub async fn import_crawled_presets(
|
||||
state: SharedPresetCrawlingState,
|
||||
mut chunks_file: File,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
loop {
|
||||
let p = blocking_lock_arc(&state, "import_crawled_presets").pop_crawled_preset();
|
||||
let Some(p) = p else {
|
||||
break;
|
||||
};
|
||||
let dest_file_path = &p.destination;
|
||||
let dest_dir_path = p.destination.parent().ok_or("destination without parent")?;
|
||||
fs::create_dir_all(dest_dir_path)?;
|
||||
chunks_file.seek(SeekFrom::Start(p.offset))?;
|
||||
let mut buf = vec![0; p.size_in_bytes];
|
||||
chunks_file.read_exact(&mut buf)?;
|
||||
fs::write(dest_file_path, buf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn a_bit_longer() {
|
||||
millis(100).await;
|
||||
}
|
||||
|
||||
async fn moment() {
|
||||
millis(50).await;
|
||||
}
|
||||
|
||||
const MAX_SAME_PRESET_NAME_IN_A_ROW_ATTEMPTS: u32 = 10;
|
||||
const MAX_SAME_PRESET_NAME_LIKE_BEGINNING_ATTEMPTS: u32 = 10;
|
||||
|
||||
pub fn get_shim_file_path(
|
||||
reaper_resource_dir: &Utf8Path,
|
||||
preset_id: &PersistentPresetId,
|
||||
) -> Utf8PathBuf {
|
||||
// We don't need to
|
||||
let hash =
|
||||
hash_util::calculate_persistent_non_crypto_hash_one_shot(preset_id.to_string().as_bytes());
|
||||
let file_name = file_util::convert_hash_to_dir_structure(hash, ".RfxChain");
|
||||
reaper_resource_dir
|
||||
.join("Helgoboss/Pot/shims")
|
||||
.join(file_name)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
pub enum PresetCrawlerStopReason {
|
||||
Interrupted,
|
||||
DestinationFileExists,
|
||||
PresetNameNotChangingAnymore,
|
||||
PresetNameLikeBeginning,
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use crate::provider_database::{
|
||||
FIL_IS_AVAILABLE_TRUE, FIL_IS_SUPPORTED_TRUE, FIL_PRODUCT_KIND_INSTRUMENT,
|
||||
};
|
||||
use crate::{
|
||||
pot_db, preview_exists, BuildInput, Destination, EscapeCatcher, FilterItemId,
|
||||
LoadPresetOptions, LoadPresetWindowBehavior, PluginId, PotPreset, PotPresetKind, PresetWithId,
|
||||
ProductId, SharedRuntimePotUnit,
|
||||
};
|
||||
use base::future_util::millis;
|
||||
use base::hash_util::PersistentHash;
|
||||
use base::{blocking_lock_arc, blocking_write_lock, file_util};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use reaper_high::{Project, Reaper};
|
||||
use reaper_medium::{CommandId, OpenProjectBehavior, ProjectContext, ProjectInfoAttributeKey};
|
||||
use std::error::Error;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub type SharedPreviewRecorderState = Arc<RwLock<PreviewRecorderState>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PreviewRecorderState {
|
||||
pub todos: Vec<PresetWithId>,
|
||||
pub failures: Vec<PreviewRecorderFailure>,
|
||||
}
|
||||
|
||||
impl PreviewRecorderState {
|
||||
pub fn new(todos: Vec<PresetWithId>) -> Self {
|
||||
Self {
|
||||
todos,
|
||||
failures: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PreviewRecorderFailure {
|
||||
pub preset: PresetWithId,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl AsRef<PotPreset> for PreviewRecorderFailure {
|
||||
fn as_ref(&self) -> &PotPreset {
|
||||
&self.preset.preset
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecordPreviewsArgs<'a> {
|
||||
pub shared_pot_unit: SharedRuntimePotUnit,
|
||||
pub state: SharedPreviewRecorderState,
|
||||
/// RPP file that is used to render the previews
|
||||
pub preview_rpp: &'a Utf8Path,
|
||||
pub config: PreviewOutputConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PreviewOutputConfig {
|
||||
/// For playback within Pot Browser.
|
||||
ForPotBrowserPlayback,
|
||||
/// For export to custom location.
|
||||
Export(ExportPreviewOutputConfig),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportPreviewOutputConfig {
|
||||
/// Where to put the preview files.
|
||||
pub base_dir: Utf8PathBuf,
|
||||
}
|
||||
|
||||
pub async fn record_previews(args: RecordPreviewsArgs<'_>) -> Result<(), Box<dyn Error>> {
|
||||
let reaper = Reaper::get();
|
||||
let reaper_resource_dir = reaper.resource_path();
|
||||
// Open preview project template in new tab
|
||||
let project = open_preview_project_in_new_tab(args.preview_rpp);
|
||||
moment().await;
|
||||
// Prepare destination (first track, first FX)
|
||||
let first_track = project
|
||||
.first_track()
|
||||
.ok_or("preview must have at least one track")?;
|
||||
let destination = Destination {
|
||||
chain: first_track.normal_fx_chain(),
|
||||
fx_index: 0,
|
||||
};
|
||||
let cloned_state = args.state.clone();
|
||||
let report_failure = move |preset: PresetWithId, reason: String| {
|
||||
let mut state = blocking_write_lock(&cloned_state, "record_previews state 2");
|
||||
let failure = PreviewRecorderFailure { preset, reason };
|
||||
state.failures.push(failure);
|
||||
};
|
||||
let escape_catcher = EscapeCatcher::new();
|
||||
// Loop over the preset list
|
||||
loop {
|
||||
// Check if escape has been pressed
|
||||
if escape_catcher.escape_was_pressed() {
|
||||
break;
|
||||
}
|
||||
// Take new preset to be recorded
|
||||
moment().await;
|
||||
let Some(preset_with_id) = blocking_write_lock(&args.state, "record_previews state")
|
||||
.todos
|
||||
.pop()
|
||||
else {
|
||||
// Done!
|
||||
break;
|
||||
};
|
||||
// Determine destination file
|
||||
let preset = &preset_with_id.preset;
|
||||
let preview_file_path = match &args.config {
|
||||
PreviewOutputConfig::ForPotBrowserPlayback => {
|
||||
// Prefer creating preview file name based on preset content. That means whenever the
|
||||
// content changes, we get a different preview file name. That is cool.
|
||||
let hash = preset.common.content_or_id_hash();
|
||||
get_preview_file_path_from_hash(&reaper_resource_dir, hash)
|
||||
}
|
||||
PreviewOutputConfig::Export(c) => {
|
||||
let product_dir_name = preset
|
||||
.common
|
||||
.product_name
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown product");
|
||||
c.base_dir.join(product_dir_name).join(&preset.common.name)
|
||||
}
|
||||
};
|
||||
// Load preset
|
||||
let options = LoadPresetOptions {
|
||||
window_behavior_override: Some(LoadPresetWindowBehavior::AlwaysShow),
|
||||
audio_sample_behavior: Default::default(),
|
||||
};
|
||||
{
|
||||
let load_result = blocking_lock_arc(&args.shared_pot_unit, "record_previews pot unit")
|
||||
.load_preset_at(preset, options, &|_| Ok(destination.clone()));
|
||||
if let Err(e) = load_result {
|
||||
report_failure(preset_with_id, e.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
moment().await;
|
||||
// Record preview
|
||||
if let Err(e) = render_to_file(project, &preview_file_path) {
|
||||
report_failure(preset_with_id, e.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_file(project: Project, full_path: &Utf8Path) -> Result<(), Box<dyn Error>> {
|
||||
let reaper = Reaper::get();
|
||||
let medium_reaper = reaper.medium_reaper();
|
||||
let dir = full_path.parent().ok_or("render path has not parent")?;
|
||||
let file_name = full_path
|
||||
.file_name()
|
||||
.ok_or("render path has no file name")?;
|
||||
medium_reaper.get_set_project_info_string_set(
|
||||
ProjectContext::Proj(project.raw()),
|
||||
ProjectInfoAttributeKey::RenderFile,
|
||||
dir.as_str(),
|
||||
)?;
|
||||
medium_reaper.get_set_project_info_string_set(
|
||||
ProjectContext::Proj(project.raw()),
|
||||
ProjectInfoAttributeKey::RenderPattern,
|
||||
file_name,
|
||||
)?;
|
||||
// "File: Render project, using the most recent render settings, auto-close render dialog"
|
||||
reaper
|
||||
.main_section()
|
||||
.action_by_command_id(CommandId::new(42230))
|
||||
.invoke_as_trigger(Some(project), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_preview_project_in_new_tab(preview_rpp: &Utf8Path) -> Project {
|
||||
let reaper = Reaper::get();
|
||||
let project = reaper.create_empty_project_in_new_tab();
|
||||
let mut behavior = OpenProjectBehavior::default();
|
||||
behavior.prompt = false;
|
||||
behavior.open_as_template = true;
|
||||
reaper
|
||||
.medium_reaper()
|
||||
.main_open_project(preview_rpp, behavior);
|
||||
project
|
||||
}
|
||||
|
||||
async fn moment() {
|
||||
millis(200).await;
|
||||
}
|
||||
|
||||
pub fn get_preview_file_path_from_hash(
|
||||
reaper_resource_dir: &Utf8Path,
|
||||
hash: PersistentHash,
|
||||
) -> Utf8PathBuf {
|
||||
let file_name = file_util::convert_hash_to_dir_structure(hash, ".ogg");
|
||||
reaper_resource_dir
|
||||
.join("Helgoboss/Pot/previews")
|
||||
.join(file_name)
|
||||
}
|
||||
|
||||
/// Can take long.
|
||||
pub fn prepare_preview_recording(
|
||||
mut build_input: BuildInput,
|
||||
output_config: &PreviewOutputConfig,
|
||||
) -> Vec<PresetWithId> {
|
||||
// We want only available and supported instruments
|
||||
build_input.filters.set(
|
||||
PotFilterKind::ProductKind,
|
||||
Some(FilterItemId(Some(FIL_PRODUCT_KIND_INSTRUMENT))),
|
||||
);
|
||||
build_input.filters.set(
|
||||
PotFilterKind::IsAvailable,
|
||||
Some(FilterItemId(Some(FIL_IS_AVAILABLE_TRUE))),
|
||||
);
|
||||
build_input.filters.set(
|
||||
PotFilterKind::IsSupported,
|
||||
Some(FilterItemId(Some(FIL_IS_SUPPORTED_TRUE))),
|
||||
);
|
||||
// Gather
|
||||
let mut presets = pot_db().gather_presets(build_input);
|
||||
if matches!(output_config, PreviewOutputConfig::ForPotBrowserPlayback) {
|
||||
// Take only those that don't have a preview within Pot Browser yet
|
||||
let reaper_resource_dir = Reaper::get().resource_path();
|
||||
presets.retain(|p| !preview_exists(&p.preset, &reaper_resource_dir));
|
||||
}
|
||||
// Reverse, so we can efficiently pop from the front later on
|
||||
presets.reverse();
|
||||
// Sort by plug-in
|
||||
presets.sort_by(|left, right| bucket(left).cmp(&bucket(right)));
|
||||
presets
|
||||
}
|
||||
|
||||
fn bucket(preset_with_id: &PresetWithId) -> BucketId {
|
||||
let preset = &preset_with_id.preset;
|
||||
if !preset.common.plugin_ids.is_empty() {
|
||||
return BucketId::Plugin(&preset.common.plugin_ids);
|
||||
}
|
||||
if !preset.common.product_ids.is_empty() {
|
||||
return BucketId::ProductId(&preset.common.product_ids);
|
||||
}
|
||||
if let PotPresetKind::FileBased(kind) = &preset.kind {
|
||||
return BucketId::FileExtension(&kind.file_ext);
|
||||
}
|
||||
BucketId::Remaining
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Ord, PartialOrd)]
|
||||
enum BucketId<'a> {
|
||||
/// The plug-in ID is certainly the best criteria here! We have that for all databases except
|
||||
/// Komplete. For FX chains and track templates, we might have multiple plug-ins. The order of
|
||||
/// these plug-ins is important because different orders will reload the plug-ins.
|
||||
Plugin(&'a [PluginId]),
|
||||
/// The next best bet is
|
||||
ProductId(&'a [ProductId]),
|
||||
/// And finally: File extension.
|
||||
FileExtension(&'a str),
|
||||
Remaining,
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use crate::plugins::{PluginDatabase, ProductKind};
|
||||
use crate::{
|
||||
Fil, FilterItem, FilterItemId, GenericFilterItemCollections, HasFilterItemId, InnerBuildInput,
|
||||
InnerPresetId, PersistentDatabaseId, PotPreset, ProductId,
|
||||
};
|
||||
use enumset::{enum_set, EnumSet};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use std::borrow::Cow;
|
||||
use std::error::Error;
|
||||
|
||||
/// A database ID that's only stable during the runtime of ReaLearn.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
|
||||
pub struct DatabaseId(pub u32);
|
||||
|
||||
pub trait Database {
|
||||
fn persistent_id(&self) -> &PersistentDatabaseId;
|
||||
|
||||
// TODO-medium-performace Maybe we should require this to be a reference.
|
||||
fn name(&self) -> Cow<str>;
|
||||
|
||||
// TODO-medium-performace Maybe we should require this to be a reference.
|
||||
fn description(&self) -> Cow<str>;
|
||||
|
||||
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
|
||||
enum_set!()
|
||||
}
|
||||
|
||||
fn refresh(&mut self, context: &ProviderContext) -> Result<(), Box<dyn Error>>;
|
||||
|
||||
fn query_filter_collections(
|
||||
&self,
|
||||
context: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
affected_kinds: EnumSet<PotFilterKind>,
|
||||
) -> Result<InnerFilterItemCollections, Box<dyn Error>>;
|
||||
|
||||
fn query_presets(
|
||||
&self,
|
||||
context: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
) -> Result<Vec<SortablePresetId>, Box<dyn Error>>;
|
||||
|
||||
fn find_preset_by_id(
|
||||
&self,
|
||||
context: &ProviderContext,
|
||||
preset_id: InnerPresetId,
|
||||
) -> Option<PotPreset>;
|
||||
|
||||
/// Tries to find a preset that belongs to the given product and has the given name *and*
|
||||
/// most importantly a preset file format that can't be loaded by Pot Browser.
|
||||
///
|
||||
/// This is used by the preset crawler to identify whether a crawled preset can be used to
|
||||
/// make a preset with an unsupported format actually loadable. Only makes sense for Komplete
|
||||
/// at the moment because this is the only database which exposes unsupported presets.
|
||||
fn find_unsupported_preset_matching(
|
||||
&self,
|
||||
product_id: ProductId,
|
||||
preset_name: &str,
|
||||
) -> Option<PotPreset> {
|
||||
let _ = (product_id, preset_name);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub type InnerFilterItemCollections = GenericFilterItemCollections<InnerFilterItem>;
|
||||
|
||||
pub enum InnerFilterItem {
|
||||
/// A unique final filter item. Only makes sense within a specific database and within the
|
||||
/// context of a specific pot filter item kind. Not deduplicated.
|
||||
Unique(FilterItem),
|
||||
/// A filter item representing a particular product (product for which the preset is made).
|
||||
///
|
||||
/// Will be deduplicated by the pot database!
|
||||
Product(ProductId),
|
||||
}
|
||||
|
||||
impl HasFilterItemId for InnerFilterItem {
|
||||
fn id(&self) -> FilterItemId {
|
||||
match self {
|
||||
InnerFilterItem::Unique(i) => i.id,
|
||||
InnerFilterItem::Product(i) => FilterItemId(Some(Fil::Product(*i))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SortablePresetId {
|
||||
pub inner_preset_id: InnerPresetId,
|
||||
pub preset_name: String,
|
||||
}
|
||||
|
||||
impl SortablePresetId {
|
||||
pub fn new(i: u32, preset_name: String) -> Self {
|
||||
Self {
|
||||
inner_preset_id: InnerPresetId(i),
|
||||
preset_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct ProviderContext<'a> {
|
||||
pub plugin_db: &'a PluginDatabase,
|
||||
}
|
||||
|
||||
impl<'a> ProviderContext<'a> {
|
||||
pub fn new(plugin_db: &'a PluginDatabase) -> Self {
|
||||
Self { plugin_db }
|
||||
}
|
||||
}
|
||||
|
||||
/// Komplete content path state ID = 1
|
||||
pub const FIL_IS_AVAILABLE_TRUE: Fil = Fil::Boolean(true);
|
||||
/// Komplete content path state ID = 4
|
||||
pub const FIL_IS_AVAILABLE_FALSE: Fil = Fil::Boolean(false);
|
||||
pub const FIL_IS_SUPPORTED_TRUE: Fil = Fil::Boolean(true);
|
||||
pub const FIL_IS_SUPPORTED_FALSE: Fil = Fil::Boolean(false);
|
||||
pub const FIL_IS_FAVORITE_TRUE: Fil = Fil::Boolean(true);
|
||||
pub const FIL_IS_FAVORITE_FALSE: Fil = Fil::Boolean(false);
|
||||
/// Komplete content type ID = 1
|
||||
pub const FIL_IS_USER_PRESET_TRUE: Fil = Fil::Boolean(true);
|
||||
/// Komplete content type ID = 2
|
||||
pub const FIL_IS_USER_PRESET_FALSE: Fil = Fil::Boolean(false);
|
||||
/// Komplete product type ID = 1
|
||||
pub const FIL_PRODUCT_KIND_INSTRUMENT: Fil = Fil::ProductKind(ProductKind::Instrument);
|
||||
/// Komplete product type ID = 2
|
||||
pub const FIL_PRODUCT_KIND_EFFECT: Fil = Fil::ProductKind(ProductKind::Effect);
|
||||
/// Komplete product type ID = 4
|
||||
pub const FIL_PRODUCT_KIND_LOOP: Fil = Fil::ProductKind(ProductKind::Loop);
|
||||
/// Komplete product type ID = 8
|
||||
pub const FIL_PRODUCT_KIND_ONE_SHOT: Fil = Fil::ProductKind(ProductKind::OneShot);
|
||||
pub const FIL_HAS_PREVIEW_TRUE: Fil = Fil::Boolean(true);
|
||||
pub const FIL_HAS_PREVIEW_FALSE: Fil = Fil::Boolean(false);
|
||||
@@ -0,0 +1,151 @@
|
||||
use crate::provider_database::{
|
||||
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
|
||||
};
|
||||
use crate::{
|
||||
create_plugin_factory_preset, FilterInput, InnerBuildInput, InnerPresetId,
|
||||
PersistentDatabaseId, PersistentInnerPresetId, PersistentPresetId, PluginId,
|
||||
PluginIdInPipeFormat, PotPreset, SearchInput,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::plugins::PluginCommon;
|
||||
use either::Either;
|
||||
use enumset::{enum_set, EnumSet};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use itertools::Itertools;
|
||||
use std::error::Error;
|
||||
use std::iter;
|
||||
|
||||
pub struct DefaultsDatabase {
|
||||
persistent_id: PersistentDatabaseId,
|
||||
plugins: Vec<PluginCommon>,
|
||||
}
|
||||
|
||||
impl Default for DefaultsDatabase {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
persistent_id: PersistentDatabaseId::new("fx-defaults".to_string()),
|
||||
plugins: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultsDatabase {
|
||||
pub fn open() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn query_presets_internal<'a>(
|
||||
&'a self,
|
||||
filter_input: &'a FilterInput,
|
||||
) -> impl Iterator<Item = (usize, &'a PluginCommon)> + 'a {
|
||||
let matches = !filter_input.filters.wants_user_presets_only();
|
||||
if !matches {
|
||||
return Either::Left(iter::empty());
|
||||
}
|
||||
let iter = self.plugins.iter().enumerate().filter(|(i, p)| {
|
||||
let id = InnerPresetId(*i as _);
|
||||
filter_input.everything_matches(Some(&p.core), id)
|
||||
});
|
||||
Either::Right(iter)
|
||||
}
|
||||
}
|
||||
|
||||
impl Database for DefaultsDatabase {
|
||||
fn persistent_id(&self) -> &PersistentDatabaseId {
|
||||
&self.persistent_id
|
||||
}
|
||||
|
||||
fn name(&self) -> Cow<str> {
|
||||
"FX defaults".into()
|
||||
}
|
||||
|
||||
fn description(&self) -> Cow<str> {
|
||||
"Default factory presets for all of your plug-ins".into()
|
||||
}
|
||||
|
||||
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
|
||||
enum_set!(PotFilterKind::Bank)
|
||||
}
|
||||
|
||||
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
|
||||
// We clone the plug-in list so we can create own own order and maintain stable IDs.
|
||||
self.plugins = ctx.plugin_db.plugins().map(|p| p.common.clone()).collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_filter_collections(
|
||||
&self,
|
||||
_: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
_: EnumSet<PotFilterKind>,
|
||||
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
|
||||
let mut new_filters = *input.filter_input.filters;
|
||||
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
|
||||
let product_items = self
|
||||
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
|
||||
.map(|(_, plugin)| plugin.core.product_id)
|
||||
.unique()
|
||||
.map(InnerFilterItem::Product)
|
||||
.collect();
|
||||
let mut collections = InnerFilterItemCollections::empty();
|
||||
collections.set(PotFilterKind::Bank, product_items);
|
||||
Ok(collections)
|
||||
}
|
||||
|
||||
fn query_presets(
|
||||
&self,
|
||||
_: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
|
||||
let preset_ids = self
|
||||
.query_presets_internal(&input.filter_input)
|
||||
.filter(|(_, entry)| {
|
||||
let search_input = DefaultSearchInput { entry };
|
||||
input.search_evaluator.matches(search_input)
|
||||
})
|
||||
.map(|(i, _)| SortablePresetId::new(i as _, PRESET_NAME.to_string()))
|
||||
.collect();
|
||||
Ok(preset_ids)
|
||||
}
|
||||
|
||||
fn find_preset_by_id(
|
||||
&self,
|
||||
_: &ProviderContext,
|
||||
preset_id: InnerPresetId,
|
||||
) -> Option<PotPreset> {
|
||||
let plugin = self.plugins.get(preset_id.0 as usize)?;
|
||||
let persistent_id = PersistentPresetId::new(
|
||||
self.persistent_id.clone(),
|
||||
create_persistent_inner_id(&plugin.core.id),
|
||||
);
|
||||
let preset = create_plugin_factory_preset(plugin, persistent_id, PRESET_NAME.to_string());
|
||||
Some(preset)
|
||||
}
|
||||
}
|
||||
|
||||
const PRESET_NAME: &str = "<Default>";
|
||||
|
||||
/// Example: `vst2|1967946098`
|
||||
fn create_persistent_inner_id(plugin_id: &PluginId) -> PersistentInnerPresetId {
|
||||
let id = PluginIdInPipeFormat(plugin_id).to_string();
|
||||
PersistentInnerPresetId::new(id)
|
||||
}
|
||||
|
||||
struct DefaultSearchInput<'a> {
|
||||
entry: &'a PluginCommon,
|
||||
}
|
||||
|
||||
impl SearchInput for DefaultSearchInput<'_> {
|
||||
fn preset_name(&self) -> &str {
|
||||
PRESET_NAME
|
||||
}
|
||||
|
||||
fn product_name(&self) -> Option<Cow<str>> {
|
||||
Some(self.entry.to_string().into())
|
||||
}
|
||||
|
||||
fn file_extension(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
use crate::provider_database::{
|
||||
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
|
||||
};
|
||||
use crate::{
|
||||
FiledBasedPotPresetKind, FilterInput, InnerBuildInput, InnerPresetId, PersistentDatabaseId,
|
||||
PersistentInnerPresetId, PersistentPresetId, PipeEscaped, PluginId, PotPreset, PotPresetCommon,
|
||||
PotPresetKind, SearchInput,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::plugins::{PluginCore, PluginDatabase};
|
||||
use base::hash_util::{NonCryptoHashSet, NonCryptoIndexMap, PersistentHash, PersistentHasher};
|
||||
use camino::Utf8PathBuf;
|
||||
use either::Either;
|
||||
use enumset::{enum_set, EnumSet};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use itertools::Itertools;
|
||||
use std::error::Error;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::hash::Hasher;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::iter;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub struct DirectoryDatabase {
|
||||
persistent_id: PersistentDatabaseId,
|
||||
root_dir: Utf8PathBuf,
|
||||
valid_extensions: NonCryptoHashSet<&'static OsStr>,
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
entries: Vec<PresetEntry>,
|
||||
}
|
||||
|
||||
pub struct DirectoryDbConfig {
|
||||
pub persistent_id: PersistentDatabaseId,
|
||||
pub root_dir: Utf8PathBuf,
|
||||
pub valid_extensions: &'static [&'static str],
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl DirectoryDatabase {
|
||||
pub fn open(config: DirectoryDbConfig) -> Result<Self, Box<dyn Error>> {
|
||||
if !config.root_dir.try_exists()? {
|
||||
return Err("path to root directory doesn't exist".into());
|
||||
}
|
||||
let db = Self {
|
||||
persistent_id: config.persistent_id,
|
||||
name: config.name,
|
||||
entries: Default::default(),
|
||||
root_dir: config.root_dir,
|
||||
valid_extensions: config.valid_extensions.iter().map(OsStr::new).collect(),
|
||||
description: config.description,
|
||||
};
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn query_presets_internal<'a>(
|
||||
&'a self,
|
||||
filter_input: &'a FilterInput,
|
||||
) -> impl Iterator<Item = (usize, &'a PresetEntry)> + 'a {
|
||||
let matches = !filter_input.filters.wants_factory_presets_only();
|
||||
if !matches {
|
||||
return Either::Left(iter::empty());
|
||||
}
|
||||
let iter = self.entries.iter().enumerate().filter(|(id, e)| {
|
||||
let id = InnerPresetId(*id as _);
|
||||
e.plugin_cores
|
||||
.values()
|
||||
.any(|core| filter_input.everything_matches(Some(core), id))
|
||||
});
|
||||
Either::Right(iter)
|
||||
}
|
||||
}
|
||||
|
||||
struct PresetEntry {
|
||||
preset_name: String,
|
||||
relative_path: String,
|
||||
plugin_cores: NonCryptoIndexMap<PluginId, PluginCore>,
|
||||
content_hash: PersistentHash,
|
||||
}
|
||||
|
||||
impl Database for DirectoryDatabase {
|
||||
fn persistent_id(&self) -> &PersistentDatabaseId {
|
||||
&self.persistent_id
|
||||
}
|
||||
|
||||
fn name(&self) -> Cow<str> {
|
||||
self.name.into()
|
||||
}
|
||||
|
||||
fn description(&self) -> Cow<str> {
|
||||
self.description.into()
|
||||
}
|
||||
|
||||
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
|
||||
enum_set!(PotFilterKind::Bank)
|
||||
}
|
||||
|
||||
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
|
||||
self.entries = WalkDir::new(&self.root_dir)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !entry.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let extension = entry.path().extension()?;
|
||||
if !self.valid_extensions.contains(extension) {
|
||||
return None;
|
||||
}
|
||||
let relative_path = entry.path().strip_prefix(&self.root_dir).ok()?;
|
||||
// Immediately exclude relative paths that can't be represented as valid UTF-8.
|
||||
// Otherwise we will potentially open a can of worms (regarding persistence etc.).
|
||||
let processing_output = process_file(entry.path(), ctx.plugin_db).ok()?;
|
||||
let preset_entry = PresetEntry {
|
||||
preset_name: entry.path().file_stem()?.to_str()?.to_string(),
|
||||
relative_path: relative_path.to_str()?.to_string(),
|
||||
plugin_cores: processing_output.used_plugins,
|
||||
content_hash: processing_output.content_hash,
|
||||
};
|
||||
Some(preset_entry)
|
||||
})
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_filter_collections(
|
||||
&self,
|
||||
_: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
_: EnumSet<PotFilterKind>,
|
||||
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
|
||||
let mut new_filters = *input.filter_input.filters;
|
||||
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
|
||||
let product_items = self
|
||||
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
|
||||
.flat_map(|(_, entry)| entry.plugin_cores.values().map(|core| core.product_id))
|
||||
.unique()
|
||||
.map(InnerFilterItem::Product)
|
||||
.collect();
|
||||
let mut collections = InnerFilterItemCollections::empty();
|
||||
collections.set(PotFilterKind::Bank, product_items);
|
||||
Ok(collections)
|
||||
}
|
||||
|
||||
fn query_presets(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
|
||||
let preset_ids = self
|
||||
.query_presets_internal(&input.filter_input)
|
||||
.filter(|(_, entry)| {
|
||||
let search_input = DirectorySearchInput {
|
||||
ctx,
|
||||
preset_entry: entry,
|
||||
};
|
||||
input.search_evaluator.matches(search_input)
|
||||
})
|
||||
.map(|(i, entry)| SortablePresetId::new(i as _, entry.preset_name.clone()))
|
||||
.collect();
|
||||
Ok(preset_ids)
|
||||
}
|
||||
|
||||
fn find_preset_by_id(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
preset_id: InnerPresetId,
|
||||
) -> Option<PotPreset> {
|
||||
let preset_entry = self.entries.get(preset_id.0 as usize)?;
|
||||
let preset = PotPreset {
|
||||
common: PotPresetCommon {
|
||||
persistent_id: PersistentPresetId::new(
|
||||
self.persistent_id().clone(),
|
||||
create_persistent_inner_id(preset_entry),
|
||||
),
|
||||
name: preset_entry.preset_name.clone(),
|
||||
context_name: Path::new(&preset_entry.relative_path)
|
||||
.parent()
|
||||
.and_then(|p| Some(p.to_str()?.to_string())),
|
||||
plugin_ids: preset_entry.plugin_cores.values().map(|c| c.id).collect(),
|
||||
product_ids: preset_entry
|
||||
.plugin_cores
|
||||
.values()
|
||||
.map(|c| c.product_id)
|
||||
.collect(),
|
||||
product_name: build_product_name(ctx, preset_entry),
|
||||
content_hash: Some(preset_entry.content_hash),
|
||||
db_specific_preview_file: None,
|
||||
is_supported: true,
|
||||
is_available: !preset_entry.plugin_cores.is_empty(),
|
||||
metadata: Default::default(),
|
||||
},
|
||||
kind: PotPresetKind::FileBased(FiledBasedPotPresetKind {
|
||||
file_ext: get_file_extension(&preset_entry.relative_path).to_string(),
|
||||
path: self.root_dir.join(&preset_entry.relative_path),
|
||||
}),
|
||||
};
|
||||
Some(preset)
|
||||
}
|
||||
}
|
||||
|
||||
struct FileProcessingOutput {
|
||||
content_hash: PersistentHash,
|
||||
used_plugins: NonCryptoIndexMap<PluginId, PluginCore>,
|
||||
}
|
||||
|
||||
/// Finds used plug-ins in a REAPER-XML-like text file (e.g. RPP, RfxChain, RTrackTemplate).
|
||||
///
|
||||
/// Examples entries:
|
||||
///
|
||||
/// ```text
|
||||
/// <VST "VSTi: Zebra2 (u-he)" Zebra2.vst 0 Schmackes 1397572658<565354534D44327A6562726132000000> ""
|
||||
/// <VST "VSTi: ReaSamplOmatic5000 (Cockos)"
|
||||
/// <CLAP "CLAPi: Surge XT (Surge Synth Team)"
|
||||
/// ```
|
||||
fn process_file(
|
||||
path: &Path,
|
||||
plugin_db: &PluginDatabase,
|
||||
) -> Result<FileProcessingOutput, Box<dyn Error>> {
|
||||
let file = File::open(path)?;
|
||||
let mut used_plugins = NonCryptoIndexMap::default();
|
||||
let mut buffer = String::new();
|
||||
let mut reader = BufReader::new(&file);
|
||||
let mut hasher = PersistentHasher::new();
|
||||
while let Ok(count) = reader.read_line(&mut buffer) {
|
||||
if count == 0 {
|
||||
// EOF
|
||||
break;
|
||||
}
|
||||
hasher.write(buffer.as_bytes());
|
||||
let line = buffer.trim();
|
||||
if let Some(plugin) = plugin_db.detect_plugin_from_rxml_line(line) {
|
||||
used_plugins.insert(plugin.common.core.id, plugin.common.core);
|
||||
}
|
||||
buffer.clear();
|
||||
}
|
||||
let output = FileProcessingOutput {
|
||||
content_hash: hasher.digest_128(),
|
||||
used_plugins,
|
||||
};
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Example: `Synths/Lead.RTrackTemplate`
|
||||
fn create_persistent_inner_id(preset_entry: &PresetEntry) -> PersistentInnerPresetId {
|
||||
let escaped_path = PipeEscaped(preset_entry.relative_path.as_str());
|
||||
PersistentInnerPresetId::new(escaped_path.to_string())
|
||||
}
|
||||
|
||||
struct DirectorySearchInput<'a> {
|
||||
ctx: &'a ProviderContext<'a>,
|
||||
preset_entry: &'a PresetEntry,
|
||||
}
|
||||
|
||||
impl SearchInput for DirectorySearchInput<'_> {
|
||||
fn preset_name(&self) -> &str {
|
||||
&self.preset_entry.preset_name
|
||||
}
|
||||
|
||||
fn product_name(&self) -> Option<Cow<str>> {
|
||||
let product_name = build_product_name(self.ctx, self.preset_entry)?;
|
||||
Some(product_name.into())
|
||||
}
|
||||
|
||||
fn file_extension(&self) -> Option<&str> {
|
||||
Some(get_file_extension(&self.preset_entry.relative_path))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_product_name(ctx: &ProviderContext, preset_entry: &PresetEntry) -> Option<String> {
|
||||
if preset_entry.plugin_cores.len() > 1 {
|
||||
Some("<Multiple>".to_string())
|
||||
} else if let Some(first) = preset_entry.plugin_cores.values().next() {
|
||||
ctx.plugin_db
|
||||
.find_plugin_by_id(&first.id)
|
||||
.map(|p| p.common.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_file_extension(relative_path: &str) -> &str {
|
||||
Path::new(relative_path)
|
||||
.extension()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use crate::provider_database::{
|
||||
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
|
||||
};
|
||||
use crate::{
|
||||
FilterInput, InnerBuildInput, InnerPresetId, InternalPotPresetKind, PersistentDatabaseId,
|
||||
PersistentInnerPresetId, PersistentPresetId, PipeEscaped, PluginKind, PotPreset,
|
||||
PotPresetCommon, PotPresetKind, SearchInput,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::plugins::{Plugin, PluginCore, SuperPluginKind};
|
||||
use base::hash_util::{PersistentHash, PersistentHasher};
|
||||
use camino::Utf8PathBuf;
|
||||
use either::Either;
|
||||
use enumset::{enum_set, EnumSet};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use ini::Ini;
|
||||
use itertools::Itertools;
|
||||
use std::error::Error;
|
||||
use std::hash::Hasher;
|
||||
use std::iter;
|
||||
use std::str::FromStr;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub struct IniDatabase {
|
||||
persistent_id: PersistentDatabaseId,
|
||||
root_dir: Utf8PathBuf,
|
||||
entries: Vec<PresetEntry>,
|
||||
}
|
||||
|
||||
impl IniDatabase {
|
||||
pub fn open(
|
||||
persistent_id: PersistentDatabaseId,
|
||||
root_dir: Utf8PathBuf,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
if !root_dir.try_exists()? {
|
||||
return Err("path to presets root directory doesn't exist".into());
|
||||
}
|
||||
let db = Self {
|
||||
persistent_id,
|
||||
entries: Default::default(),
|
||||
root_dir,
|
||||
};
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn query_presets_internal<'a>(
|
||||
&'a self,
|
||||
filter_input: &'a FilterInput,
|
||||
) -> impl Iterator<Item = (usize, &'a PresetEntry)> + 'a {
|
||||
let matches = !filter_input.filters.wants_factory_presets_only();
|
||||
if !matches {
|
||||
return Either::Left(iter::empty());
|
||||
}
|
||||
let iter = self.entries.iter().enumerate().filter(|(i, e)| {
|
||||
let id = InnerPresetId(*i as _);
|
||||
filter_input.everything_matches(e.plugin.as_ref(), id)
|
||||
});
|
||||
Either::Right(iter)
|
||||
}
|
||||
}
|
||||
|
||||
struct PresetEntry {
|
||||
preset_name: String,
|
||||
plugin_kind: PluginKind,
|
||||
/// Example: "Massive"
|
||||
plugin_identifier: String,
|
||||
/// If `None`, it means the corresponding plug-in is not installed/scanned.
|
||||
plugin: Option<PluginCore>,
|
||||
content_hash: Option<PersistentHash>,
|
||||
}
|
||||
|
||||
impl Database for IniDatabase {
|
||||
fn persistent_id(&self) -> &PersistentDatabaseId {
|
||||
&self.persistent_id
|
||||
}
|
||||
|
||||
fn name(&self) -> Cow<str> {
|
||||
"FX presets".into()
|
||||
}
|
||||
|
||||
fn description(&self) -> Cow<str> {
|
||||
"All FX presets that you saved via \"Save preset...\" in REAPER's FX window.\n\".vstpreset\"-style presets are not yet supported!"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
|
||||
enum_set!(PotFilterKind::Bank)
|
||||
}
|
||||
|
||||
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
|
||||
let file_name_regex = base::regex!(r#"(?i)(.*?)-(.*).ini"#);
|
||||
self.entries = WalkDir::new(&self.root_dir)
|
||||
.max_depth(1)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !entry.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let file_name = entry.file_name().to_str()?;
|
||||
// Example file names:
|
||||
// - vst-Zebra2.ini
|
||||
// - vst3-FM8-1168312232-builtin.ini
|
||||
// - vst-TDR Nova-builtin.ini
|
||||
// - vst-reacomp.ini
|
||||
// - vst3-Massive.ini
|
||||
// - clap-org_surge-synth-team_surge-xt.ini
|
||||
// - js-analysis_hund.ini
|
||||
let captures = file_name_regex.captures(file_name)?;
|
||||
let plugin_kind_str = captures.get(1)?.as_str();
|
||||
let plugin_kind = PluginKind::from_str(plugin_kind_str).ok()?;
|
||||
let plugin_identifier = captures.get(2)?.as_str();
|
||||
if plugin_identifier.ends_with("-builtin") {
|
||||
return None;
|
||||
}
|
||||
let (main_plugin_identifier, shell_qualifier) =
|
||||
match plugin_identifier.rsplit_once('-') {
|
||||
// Example: vst3-Zebra2-959560201.ini
|
||||
// (interpret the number behind the dash as shell qualifier)
|
||||
Some((left, right))
|
||||
if right.len() >= 5 && right.chars().all(|ch| ch.is_ascii_digit()) =>
|
||||
{
|
||||
(left, Some(right))
|
||||
}
|
||||
// Examples: "vst-Tritik-Irid.ini", "vst-Zebra2.ini"
|
||||
_ => (plugin_identifier, None),
|
||||
};
|
||||
let plugin = ctx.plugin_db.plugins().find(|p| {
|
||||
if p.common.core.id.kind() != plugin_kind {
|
||||
return false;
|
||||
}
|
||||
match &p.kind {
|
||||
SuperPluginKind::Vst(k) => {
|
||||
let unsafe_char_regex = base::regex!(r#"[^a-zA-Z0-9_]"#);
|
||||
let safe_main_plugin_identifier =
|
||||
unsafe_char_regex.replace_all(main_plugin_identifier, "_");
|
||||
let file_name_prefix = format!("{safe_main_plugin_identifier}.");
|
||||
tracing::trace!(
|
||||
"Test VST '{}' should start with INI plug-in file name prefix '{file_name_prefix}'",
|
||||
k.safe_file_name
|
||||
);
|
||||
if !k.safe_file_name.starts_with(&file_name_prefix) {
|
||||
return false;
|
||||
}
|
||||
let plugin_shell_qualifier = k.shell_qualifier.as_deref();
|
||||
if shell_qualifier != plugin_shell_qualifier {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
SuperPluginKind::Clap(k) => {
|
||||
let safe_plugin_id = k.id.replace('.', "_");
|
||||
if plugin_identifier != safe_plugin_id {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
SuperPluginKind::Js(k) => {
|
||||
let lowercase_safe_path = k.path.replace(['/', '\\', '.'], "_").to_lowercase();
|
||||
let lowercase_plugin_identifier = plugin_identifier.to_lowercase();
|
||||
tracing::trace!(
|
||||
"Test JS '{lowercase_safe_path}' vs. INI plug-in identifier '{lowercase_plugin_identifier}'"
|
||||
);
|
||||
if lowercase_plugin_identifier != lowercase_safe_path {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
});
|
||||
let ini_file = Ini::load_from_file(entry.path()).ok()?;
|
||||
let general_section = ini_file.section(Some("General"))?;
|
||||
let nb_presets = general_section.get("NbPresets")?;
|
||||
let preset_count: u32 = nb_presets.parse().ok()?;
|
||||
let plugin_identifier = plugin_identifier.to_string();
|
||||
let iter = (0..preset_count).filter_map(move |i| {
|
||||
let section_name = format!("Preset{i}");
|
||||
let section = ini_file.section(Some(section_name))?;
|
||||
let name = section.get("Name")?;
|
||||
// Calculate hash. At first add info about the plug-in. Without that info,
|
||||
// the content could be ambiguous.
|
||||
let mut hasher = PersistentHasher::new();
|
||||
let plugin_kind_str = plugin_kind.as_ref();
|
||||
let plugin_info = format!("{plugin_kind_str}-{plugin_identifier}");
|
||||
hasher.write(plugin_info.as_bytes());
|
||||
// Calculate hash out of data properties ("Data", "Data_1", "Data_2", ...)
|
||||
let data = section.get("Data")?;
|
||||
hasher.write(data.as_bytes());
|
||||
let mut i = 1;
|
||||
while let Some(more_data) = section.get(format!("Data{i}")) {
|
||||
hasher.write(more_data.as_bytes());
|
||||
i += 1;
|
||||
}
|
||||
// Build entry
|
||||
let preset_entry = PresetEntry {
|
||||
preset_name: name.to_string(),
|
||||
plugin_kind,
|
||||
plugin_identifier: plugin_identifier.clone(),
|
||||
plugin: plugin.map(|p| p.common.core),
|
||||
content_hash: Some(hasher.digest_128()),
|
||||
};
|
||||
Some(preset_entry)
|
||||
});
|
||||
Some(iter)
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_filter_collections(
|
||||
&self,
|
||||
_: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
_: EnumSet<PotFilterKind>,
|
||||
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
|
||||
let mut new_filters = *input.filter_input.filters;
|
||||
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
|
||||
let product_items = self
|
||||
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
|
||||
.filter_map(|(_, entry)| Some(entry.plugin.as_ref()?.product_id))
|
||||
.unique()
|
||||
.map(InnerFilterItem::Product)
|
||||
.collect();
|
||||
let mut collections = InnerFilterItemCollections::empty();
|
||||
collections.set(PotFilterKind::Bank, product_items);
|
||||
Ok(collections)
|
||||
}
|
||||
|
||||
fn query_presets(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
|
||||
let preset_ids = self
|
||||
.query_presets_internal(&input.filter_input)
|
||||
.filter(|(_, preset_entry)| {
|
||||
let search_input = IniSearchInput { ctx, preset_entry };
|
||||
input.search_evaluator.matches(search_input)
|
||||
})
|
||||
.map(|(i, entry)| SortablePresetId::new(i as _, entry.preset_name.clone()))
|
||||
.collect();
|
||||
Ok(preset_ids)
|
||||
}
|
||||
|
||||
fn find_preset_by_id(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
preset_id: InnerPresetId,
|
||||
) -> Option<PotPreset> {
|
||||
let preset_entry = self.entries.get(preset_id.0 as usize)?;
|
||||
let plugin = preset_entry
|
||||
.plugin
|
||||
.as_ref()
|
||||
.and_then(|entry| ctx.plugin_db.find_plugin_by_id(&entry.id));
|
||||
let preset = PotPreset {
|
||||
common: PotPresetCommon {
|
||||
persistent_id: PersistentPresetId::new(
|
||||
self.persistent_id().clone(),
|
||||
create_persistent_inner_id(preset_entry),
|
||||
),
|
||||
name: preset_entry.preset_name.clone(),
|
||||
context_name: None,
|
||||
plugin_ids: preset_entry
|
||||
.plugin
|
||||
.as_ref()
|
||||
.map(|p| p.id)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
product_ids: preset_entry
|
||||
.plugin
|
||||
.as_ref()
|
||||
.map(|p| p.product_id)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
product_name: Some(build_product_name(preset_entry, plugin).to_string()),
|
||||
content_hash: preset_entry.content_hash,
|
||||
db_specific_preview_file: None,
|
||||
is_supported: true,
|
||||
is_available: preset_entry.plugin.is_some(),
|
||||
metadata: Default::default(),
|
||||
},
|
||||
kind: PotPresetKind::Internal(InternalPotPresetKind {
|
||||
plugin_id: preset_entry.plugin.as_ref().map(|p| p.id),
|
||||
}),
|
||||
};
|
||||
Some(preset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Example: `vst3-Surge XT.ini|My Preset`
|
||||
fn create_persistent_inner_id(preset_entry: &PresetEntry) -> PersistentInnerPresetId {
|
||||
let plugin_kind = preset_entry.plugin_kind.as_ref();
|
||||
let plugin_identifier = &preset_entry.plugin_identifier;
|
||||
let escaped_preset_name = PipeEscaped(&preset_entry.preset_name);
|
||||
let id = format!("{plugin_kind}-{plugin_identifier}.ini|{escaped_preset_name}");
|
||||
PersistentInnerPresetId::new(id)
|
||||
}
|
||||
|
||||
struct IniSearchInput<'a> {
|
||||
ctx: &'a ProviderContext<'a>,
|
||||
preset_entry: &'a PresetEntry,
|
||||
}
|
||||
|
||||
impl SearchInput for IniSearchInput<'_> {
|
||||
fn preset_name(&self) -> &str {
|
||||
&self.preset_entry.preset_name
|
||||
}
|
||||
|
||||
fn product_name(&self) -> Option<Cow<str>> {
|
||||
let plugin = self
|
||||
.preset_entry
|
||||
.plugin
|
||||
.as_ref()
|
||||
.and_then(|entry| self.ctx.plugin_db.find_plugin_by_id(&entry.id));
|
||||
Some(build_product_name(self.preset_entry, plugin))
|
||||
}
|
||||
|
||||
fn file_extension(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_product_name<'a>(preset_entry: &'a PresetEntry, plugin: Option<&Plugin>) -> Cow<'a, str> {
|
||||
match plugin {
|
||||
None => preset_entry.plugin_identifier.as_str().into(),
|
||||
Some(p) => p.common.to_string().into(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
pub mod defaults;
|
||||
pub mod directory;
|
||||
pub mod ini;
|
||||
pub mod komplete;
|
||||
pub mod projects;
|
||||
@@ -0,0 +1,430 @@
|
||||
use crate::provider_database::{
|
||||
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
|
||||
};
|
||||
use crate::{
|
||||
Fil, FilterInput, FilterItem, FilterItemId, InnerBuildInput, InnerPresetId,
|
||||
PersistentDatabaseId, PersistentInnerPresetId, PersistentPresetId, PipeEscaped, PluginId,
|
||||
PotPreset, PotPresetCommon, PotPresetKind, ProjectBasedPotPresetKind, ProjectId, SearchInput,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::plugins::{PluginCore, PluginDatabase};
|
||||
use base::hash_util::{
|
||||
calculate_persistent_non_crypto_hash_one_shot, NonCryptoIndexMap, PersistentHash,
|
||||
};
|
||||
use either::Either;
|
||||
use enumset::{enum_set, EnumSet};
|
||||
use helgobox_api::persistence::PotFilterKind;
|
||||
use itertools::Itertools;
|
||||
|
||||
use std::error::Error;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{fs, iter};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub struct ProjectDatabase {
|
||||
persistent_id: PersistentDatabaseId,
|
||||
root_dir: PathBuf,
|
||||
name: String,
|
||||
description: String,
|
||||
projects: Vec<Proj>,
|
||||
preset_entries: Vec<PresetEntry>,
|
||||
}
|
||||
|
||||
pub struct ProjectDbConfig {
|
||||
pub persistent_id: PersistentDatabaseId,
|
||||
pub root_dir: PathBuf,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl ProjectDatabase {
|
||||
pub fn open(config: ProjectDbConfig) -> Result<Self, Box<dyn Error>> {
|
||||
if !config.root_dir.try_exists()? {
|
||||
return Err("path to projects root directory doesn't exist".into());
|
||||
}
|
||||
let db = Self {
|
||||
persistent_id: config.persistent_id,
|
||||
name: config.name,
|
||||
preset_entries: Default::default(),
|
||||
description: format!("Projects in {}", config.root_dir.to_string_lossy()),
|
||||
root_dir: config.root_dir,
|
||||
projects: vec![],
|
||||
};
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn query_presets_internal<'a>(
|
||||
&'a self,
|
||||
filter_input: &'a FilterInput,
|
||||
) -> impl Iterator<Item = (usize, &'a PresetEntry)> + 'a {
|
||||
let matches = !filter_input.filters.wants_factory_presets_only();
|
||||
if !matches {
|
||||
return Either::Left(iter::empty());
|
||||
}
|
||||
let iter = self.preset_entries.iter().enumerate().filter(|(id, e)| {
|
||||
if let Some(FilterItemId(Some(Fil::Project(id)))) =
|
||||
filter_input.filters.get(PotFilterKind::Project)
|
||||
{
|
||||
if e.project_id != id {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let id = InnerPresetId(*id as _);
|
||||
e.track_preset
|
||||
.used_plugins
|
||||
.values()
|
||||
.any(|core| filter_input.everything_matches(Some(core), id))
|
||||
});
|
||||
Either::Right(iter)
|
||||
}
|
||||
}
|
||||
|
||||
struct PresetEntry {
|
||||
project_id: ProjectId,
|
||||
track_preset: TrackPreset,
|
||||
}
|
||||
|
||||
struct Proj {
|
||||
name: String,
|
||||
relative_path_to_rpp: String,
|
||||
}
|
||||
|
||||
impl Database for ProjectDatabase {
|
||||
fn persistent_id(&self) -> &PersistentDatabaseId {
|
||||
&self.persistent_id
|
||||
}
|
||||
|
||||
fn name(&self) -> Cow<str> {
|
||||
self.name.as_str().into()
|
||||
}
|
||||
|
||||
fn description(&self) -> Cow<str> {
|
||||
self.description.as_str().into()
|
||||
}
|
||||
|
||||
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
|
||||
enum_set!(PotFilterKind::Bank | PotFilterKind::Project)
|
||||
}
|
||||
|
||||
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
|
||||
self.preset_entries = WalkDir::new(&self.root_dir)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !entry.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let extension = entry.path().extension()?;
|
||||
if extension != OsStr::new("RPP") {
|
||||
return None;
|
||||
}
|
||||
let relative_path = entry.path().strip_prefix(&self.root_dir).ok()?;
|
||||
let stem = entry.path().file_stem()?;
|
||||
// Immediately exclude relative paths that can't be represented as valid UTF-8.
|
||||
// Otherwise we will potentially open a can of worms (regarding persistence etc.).
|
||||
let project = Proj {
|
||||
name: stem.to_str()?.to_string(),
|
||||
relative_path_to_rpp: relative_path.to_str()?.to_string(),
|
||||
};
|
||||
self.projects.push(project);
|
||||
let project_id = ProjectId(self.projects.len() as u32 - 1);
|
||||
process_file(entry.path(), ctx.plugin_db, project_id).ok()
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_filter_collections(
|
||||
&self,
|
||||
_: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
affected_kinds: EnumSet<PotFilterKind>,
|
||||
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
|
||||
let mut collections = InnerFilterItemCollections::empty();
|
||||
if affected_kinds.contains(PotFilterKind::Project) {
|
||||
let mut new_filters = *input.filter_input.filters;
|
||||
new_filters.clear_this_and_dependent_filters(PotFilterKind::Project);
|
||||
let project_items = self
|
||||
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
|
||||
.map(|(_, entry)| entry.project_id)
|
||||
.unique()
|
||||
.filter_map(|project_id| {
|
||||
let project = self.projects.get(project_id.0 as usize)?;
|
||||
let item = FilterItem {
|
||||
persistent_id: "".to_string(),
|
||||
id: FilterItemId(Some(Fil::Project(project_id))),
|
||||
parent_name: None,
|
||||
name: Some(project.name.clone()),
|
||||
icon: None,
|
||||
more_info: Some(project.relative_path_to_rpp.to_string()),
|
||||
};
|
||||
Some(InnerFilterItem::Unique(item))
|
||||
})
|
||||
.collect();
|
||||
collections.set(PotFilterKind::Project, project_items);
|
||||
}
|
||||
if affected_kinds.contains(PotFilterKind::Bank) {
|
||||
let mut new_filters = *input.filter_input.filters;
|
||||
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
|
||||
let product_items = self
|
||||
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
|
||||
.flat_map(|(_, entry)| {
|
||||
entry
|
||||
.track_preset
|
||||
.used_plugins
|
||||
.values()
|
||||
.map(|core| core.product_id)
|
||||
})
|
||||
.unique()
|
||||
.map(InnerFilterItem::Product)
|
||||
.collect();
|
||||
collections.set(PotFilterKind::Bank, product_items);
|
||||
}
|
||||
Ok(collections)
|
||||
}
|
||||
|
||||
fn query_presets(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
input: InnerBuildInput,
|
||||
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
|
||||
let preset_ids = self
|
||||
.query_presets_internal(&input.filter_input)
|
||||
.filter(|(_, preset_entry)| {
|
||||
let search_input = ProjectSearchInput { ctx, preset_entry };
|
||||
input.search_evaluator.matches(search_input)
|
||||
})
|
||||
.map(|(i, entry)| SortablePresetId::new(i as _, entry.track_preset.preset_name.clone()))
|
||||
.collect();
|
||||
Ok(preset_ids)
|
||||
}
|
||||
|
||||
fn find_preset_by_id(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
preset_id: InnerPresetId,
|
||||
) -> Option<PotPreset> {
|
||||
let preset_entry = self.preset_entries.get(preset_id.0 as usize)?;
|
||||
let project = self.projects.get(preset_entry.project_id.0 as usize)?;
|
||||
let relative_path = PathBuf::from(&project.relative_path_to_rpp);
|
||||
let preset = PotPreset {
|
||||
common: PotPresetCommon {
|
||||
persistent_id: PersistentPresetId::new(
|
||||
self.persistent_id().clone(),
|
||||
create_persistent_inner_id(project, preset_entry),
|
||||
),
|
||||
name: preset_entry.track_preset.preset_name.clone(),
|
||||
context_name: Some(project.name.clone()),
|
||||
plugin_ids: preset_entry
|
||||
.track_preset
|
||||
.used_plugins
|
||||
.values()
|
||||
.map(|c| c.id)
|
||||
.collect(),
|
||||
product_ids: preset_entry
|
||||
.track_preset
|
||||
.used_plugins
|
||||
.values()
|
||||
.map(|c| c.product_id)
|
||||
.collect(),
|
||||
product_name: build_product_name(ctx, preset_entry).map(|n| n.to_string()),
|
||||
content_hash: Some(preset_entry.track_preset.content_hash),
|
||||
db_specific_preview_file: None,
|
||||
is_supported: true,
|
||||
is_available: !preset_entry.track_preset.used_plugins.is_empty(),
|
||||
metadata: Default::default(),
|
||||
},
|
||||
kind: PotPresetKind::ProjectBased(ProjectBasedPotPresetKind {
|
||||
path_to_rpp: self.root_dir.join(relative_path),
|
||||
fx_chain_range: preset_entry.track_preset.fx_chain_range.clone(),
|
||||
}),
|
||||
};
|
||||
Some(preset)
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackPreset {
|
||||
preset_name: String,
|
||||
track_id: String,
|
||||
fx_chain_range: Range<usize>,
|
||||
used_plugins: NonCryptoIndexMap<PluginId, PluginCore>,
|
||||
content_hash: PersistentHash,
|
||||
}
|
||||
|
||||
fn process_file(
|
||||
path: &Path,
|
||||
plugin_db: &PluginDatabase,
|
||||
project_id: ProjectId,
|
||||
) -> Result<Vec<PresetEntry>, Box<dyn Error>> {
|
||||
let rppxml = fs::read_to_string(path)?;
|
||||
Ok(extract_presets(&rppxml, plugin_db, project_id))
|
||||
}
|
||||
|
||||
/// Example: `maojiao/2023-02-03-ben/2023-02-03-ben.RPP|0FF9F738-7CF6-8A49-9AEA-A9AF26DF9C46`
|
||||
fn create_persistent_inner_id(
|
||||
project: &Proj,
|
||||
preset_entry: &PresetEntry,
|
||||
) -> PersistentInnerPresetId {
|
||||
let escaped_path = PipeEscaped(project.relative_path_to_rpp.as_str());
|
||||
let id = format!("{escaped_path}|{}", preset_entry.track_preset.track_id);
|
||||
PersistentInnerPresetId::new(id)
|
||||
}
|
||||
|
||||
fn extract_presets(
|
||||
rppxml: &str,
|
||||
plugin_db: &PluginDatabase,
|
||||
project_id: ProjectId,
|
||||
) -> Vec<PresetEntry> {
|
||||
use rppxml_parser::*;
|
||||
let parser = OneShotParser::new(rppxml);
|
||||
#[derive(Debug, Default)]
|
||||
struct P<'a> {
|
||||
track_id: &'a str,
|
||||
name: Option<&'a str>,
|
||||
rfx_chain_start: Option<usize>,
|
||||
rfx_chain_end: Option<usize>,
|
||||
used_plugins: NonCryptoIndexMap<PluginId, PluginCore>,
|
||||
}
|
||||
impl<'a> P<'a> {
|
||||
pub fn new(track_id: &'a str) -> Self {
|
||||
Self {
|
||||
track_id,
|
||||
name: None,
|
||||
rfx_chain_start: None,
|
||||
rfx_chain_end: None,
|
||||
used_plugins: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stack: Vec<&str> = Vec::with_capacity(10);
|
||||
let mut preset: Option<P> = None;
|
||||
let mut presets: Vec<P> = vec![];
|
||||
for e in parser.events() {
|
||||
let line = e.line();
|
||||
match e.item {
|
||||
Item::StartTag(el) => {
|
||||
stack.push(el.name());
|
||||
match *stack.as_slice() {
|
||||
["REAPER_PROJECT", "TRACK"] => {
|
||||
let track_id = el.into_values().next().unwrap_or_default();
|
||||
preset = Some(P::new(track_id));
|
||||
}
|
||||
["REAPER_PROJECT", "TRACK", "FXCHAIN", _] => {
|
||||
if let Some(p) = &mut preset {
|
||||
if let Some(plugin) =
|
||||
plugin_db.detect_plugin_from_rxml_line(line.trim())
|
||||
{
|
||||
p.used_plugins
|
||||
.insert(plugin.common.core.id, plugin.common.core);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Item::EndTag => {
|
||||
match *stack.as_slice() {
|
||||
["REAPER_PROJECT", "TRACK"] => {
|
||||
presets.extend(preset.take());
|
||||
}
|
||||
["REAPER_PROJECT", "TRACK", "FXCHAIN"] => {
|
||||
if let Some(p) = &mut preset {
|
||||
p.rfx_chain_end = Some(e.start);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
Item::Attribute(el) => match *stack.as_slice() {
|
||||
["REAPER_PROJECT", "TRACK"] => match el.name() {
|
||||
"NAME" => {
|
||||
if let Some(p) = &mut preset {
|
||||
let name = el.into_values().next().unwrap_or_default();
|
||||
p.name = Some(name)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
["REAPER_PROJECT", "TRACK", "FXCHAIN"] => match el.name() {
|
||||
"BYPASS" => {
|
||||
if let Some(p) = &mut preset {
|
||||
if p.rfx_chain_start.is_none() {
|
||||
p.rfx_chain_start = Some(e.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
},
|
||||
Item::Content(_) => {}
|
||||
Item::Empty => {}
|
||||
}
|
||||
}
|
||||
presets
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
if p.used_plugins.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fx_chain_range = p.rfx_chain_start?..p.rfx_chain_end?;
|
||||
let track_id = p.track_id.strip_prefix('{')?.strip_suffix('}')?.to_string();
|
||||
let preset_name = p.name?.to_string();
|
||||
let content_hash = calculate_persistent_non_crypto_hash_one_shot(
|
||||
rppxml[fx_chain_range.clone()].as_bytes(),
|
||||
);
|
||||
let track_preset = TrackPreset {
|
||||
preset_name,
|
||||
track_id,
|
||||
fx_chain_range,
|
||||
used_plugins: p.used_plugins,
|
||||
content_hash,
|
||||
};
|
||||
let preset_entry = PresetEntry {
|
||||
project_id,
|
||||
track_preset,
|
||||
};
|
||||
Some(preset_entry)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ProjectSearchInput<'a> {
|
||||
ctx: &'a ProviderContext<'a>,
|
||||
preset_entry: &'a PresetEntry,
|
||||
}
|
||||
|
||||
impl SearchInput for ProjectSearchInput<'_> {
|
||||
fn preset_name(&self) -> &str {
|
||||
&self.preset_entry.track_preset.preset_name
|
||||
}
|
||||
|
||||
fn product_name(&self) -> Option<Cow<str>> {
|
||||
build_product_name(self.ctx, self.preset_entry)
|
||||
}
|
||||
|
||||
fn file_extension(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_product_name(
|
||||
ctx: &ProviderContext,
|
||||
preset_entry: &PresetEntry,
|
||||
) -> Option<Cow<'static, str>> {
|
||||
if preset_entry.track_preset.used_plugins.len() > 1 {
|
||||
Some("<Multiple>".into())
|
||||
} else if let Some(first) = preset_entry.track_preset.used_plugins.values().next() {
|
||||
ctx.plugin_db
|
||||
.find_plugin_by_id(&first.id)
|
||||
.map(|p| p.common.to_string().into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use base::Global;
|
||||
use futures::channel::oneshot;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::any::Any;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::future::Future;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
pub type PotWorkerDispatcher<C> = WorkerDispatcher<C, PotWorkerSpawner>;
|
||||
pub type MainThreadDispatcher<C> = WorkerDispatcher<C, MainThreadSpawner>;
|
||||
|
||||
type PotWorkerResult<R> = Result<R, Box<dyn Error>>;
|
||||
|
||||
/// Helper for easily dispatching background work from a non-asynchronous context and executing
|
||||
/// code as soon as the result of the background work is available.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkerDispatcher<C, S> {
|
||||
tasks: VecDeque<Task<C, Box<dyn Any + Send + 'static>>>,
|
||||
spawner: S,
|
||||
}
|
||||
|
||||
impl<C, S> WorkerDispatcher<C, S>
|
||||
where
|
||||
S: Spawner,
|
||||
{
|
||||
pub fn new(spawner: S) -> Self {
|
||||
Self {
|
||||
tasks: Default::default(),
|
||||
spawner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for each background task if a result is available and if yes, executes the
|
||||
/// corresponding result handler.
|
||||
///
|
||||
/// Must be called repeatedly from the non-asynchronous code.
|
||||
pub fn poll(&mut self, state: &mut C) {
|
||||
// Take next task from queue. Do nothing if no task enqueued.
|
||||
let Some(mut task) = self.tasks.pop_front() else {
|
||||
return;
|
||||
};
|
||||
// Check if task has already produced some result.
|
||||
match task.result_receiver.try_recv() {
|
||||
// Result available. Handle it, done.
|
||||
Ok(Some(result)) => {
|
||||
(task.result_handler)(state, result);
|
||||
}
|
||||
// Result not yet available. Push task back on queue.
|
||||
Ok(None) => {
|
||||
self.tasks.push_back(task);
|
||||
}
|
||||
// Sender dropped. Discard task.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules the given work for execution by the Pot worker and registers a handler that
|
||||
/// will be executed as soon as the work has produced a result.
|
||||
pub fn do_in_background_and_then<R>(
|
||||
&mut self,
|
||||
work: impl Future<Output = R> + Send + 'static,
|
||||
result_handler: impl FnOnce(&mut C, R) + Send + 'static,
|
||||
) where
|
||||
R: Send + 'static,
|
||||
{
|
||||
// Create one-shot channel for transferring task result from background thread to
|
||||
// polling thread.
|
||||
let (sender, receiver) = oneshot::channel::<Box<dyn Any + Send + 'static>>();
|
||||
// Combine this and the corresponding handler into a so-called task.
|
||||
let task = Task {
|
||||
result_receiver: receiver,
|
||||
result_handler: Box::new(|context, result| {
|
||||
if let Ok(result) = result.downcast::<R>() {
|
||||
result_handler(context, *result);
|
||||
}
|
||||
}),
|
||||
};
|
||||
// Enqueue that task for repeated polling.
|
||||
self.tasks.push_back(task);
|
||||
// Schedule work to be done in background.
|
||||
self.spawner.spawn(async move {
|
||||
// Start executing work
|
||||
let result = work.await;
|
||||
// Work done. Send result.
|
||||
let _ = sender.send(Box::new(result));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_in_pot_worker(f: impl Future<Output = PotWorkerResult<()>> + Send + 'static) {
|
||||
POT_WORKER_RUNTIME.as_ref().unwrap().spawn(async {
|
||||
f.await.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
pub trait Spawner {
|
||||
fn spawn(&self, f: impl Future<Output = PotWorkerResult<()>> + Send + 'static);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PotWorkerSpawner;
|
||||
|
||||
impl Spawner for PotWorkerSpawner {
|
||||
fn spawn(&self, f: impl Future<Output = PotWorkerResult<()>> + Send + 'static) {
|
||||
spawn_in_pot_worker(f);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MainThreadSpawner;
|
||||
|
||||
impl Spawner for MainThreadSpawner {
|
||||
fn spawn(&self, f: impl Future<Output = PotWorkerResult<()>> + Send + 'static) {
|
||||
Global::future_support().spawn_in_main_thread_from_main_thread(f);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(derivative::Derivative)]
|
||||
#[derivative(Debug)]
|
||||
struct Task<C, R> {
|
||||
result_receiver: oneshot::Receiver<R>,
|
||||
#[derivative(Debug = "ignore")]
|
||||
result_handler: ResultHandler<C, R>,
|
||||
}
|
||||
|
||||
type ResultHandler<C, R> = Box<dyn FnOnce(&mut C, R) + Send>;
|
||||
|
||||
static POT_WORKER_RUNTIME: Lazy<std::io::Result<Runtime>> = Lazy::new(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_time()
|
||||
.thread_name("Helgobox Pot Worker")
|
||||
.worker_threads(1)
|
||||
.build()
|
||||
});
|
||||
Reference in New Issue
Block a user