Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/locallibrary/changes/player-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
### Fixed

- The player heads a plugin's panel with its view's own title after the panel's, as `track show`
does, and asks for the selected track's panels again once that plugin's settings are saved,
where it kept showing what they answered before until the selection moved.
- Smart playlist rules, edits' track selectors (`--filter`) and sorting a playlist on the drive
(`playlist sort`, the player's `s`) take analyzers' fields (`sound.bpm:>120`, `-sound.bpm`), as
`tracks` does, where they were refused. A rule naming a field no plugin installed declares, or
a value its field can't hold, is refused when it's added and when it's applied, saying so, and
an edit whose filter names a field no track has a value of yet warns that they need analyzing.
- Plugins' commands, operations, panels, analyzers, exporters, importers, library sources and
providers run on a folder the player (or any command) opened by path, where the player said
"only on a plugged-in drive". The plugin may name that folder as its hook's context does, only
while the hook runs, and is told its name rather than where it is; a path or another folder is
still refused.
8 changes: 5 additions & 3 deletions apps/locallibrary/docs/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,18 @@ track. `--filter` takes the same filter `tracks --filter` does:
| `rating:>=4`, `plays:0`, `year:1999`, `time:>6:00`, `bitrate:<320` | numbers, ranges, comparisons |
| `color:aqua`, `color:none`, `added:2026-09`, `added:>=2026-01-01` | colour, date added |
| `playlist:Sets/Friday`, `tag:Genre/Deep`, `is:unfiled`, `is:unplayed`, `has:artwork` | membership, My Tags, state |
| `sound.bpm:>120`, `sound.key:8A` | what an analyzer [plugin](plugins.md) worked out (`tracks` and the player's search only) |
| `sound.bpm:>120`, `sound.key:8A` | what an analyzer [plugin](plugins.md) worked out |

`playlist:` takes a path or a unique name, as `--playlist` does, and a folder means everything in
it; one that names no playlist is refused. So is a word before a colon that names no field
(`gnere:house`), which would otherwise match nothing (and negated, everything); in quotes
(`"re:zero"`) it's text. `tag:` reads the drive's My Tags wherever a filter goes, and `--history`
finds lists in both databases.

Sort orders are lists of fields, highest first with a `-`: `key,bpm`, `-rating,title`. `tracks
--sort` also takes a plugin's field (`-sound.bpm`); tracks without a value for it go last.
Sort orders are lists of fields, highest first with a `-`: `key,bpm`, `-rating,title`, and a
plugin's field (`-sound.bpm`); tracks without a value for it go last. A plugin's field is one an
analyzer installed declares, and a rule or an edit naming another is refused, saying so; an edit
whose filter names one no track has a value of yet warns that the tracks need analyzing first.

### When rekordbox exports again

Expand Down
8 changes: 5 additions & 3 deletions apps/locallibrary/docs/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@ listening to your library the way the players see it:
plan, with what the plugin says about it first, is confirmed and written like any edit, and `u`
takes it back. A question one asks first (which track an entry is) is a list to pick from
(`space` marks items when it takes several, `enter` takes them, `esc` stops the run). An
operation, or a command of a plugin that reads the drive, needs one plugged in, not a folder
opened by path. What plugins log goes to the messages (`!`). See
operation, or a command of a plugin that reads the drive, needs a drive open (a folder opened
by path too, which the plugin can read only while it runs on it). What plugins log goes to the
messages (`!`). See
[Plugins](plugins.md).
- **Analyzers**: "analyze the tracks: …" in the palette (or a drive's `e` menu) runs a plugin's
analyzer over the drive's tracks in the background, those not analysed already. "show the
sound.bpm column" adds a column of what it worked out (remembered for the next run; `-` where
it found nothing to show; shown as its analyzer says, in bytes or as a meter, say), sorted by a click on its header like any column, and the search box
filters by it: `sound.bpm:>120`. "write keys and BPMs to the tracks: …" writes what an
filters by it: `sound.bpm:>120`. Sorted by it, a playlist is sorted on the drive by it too
(`s` in the actions menu). "write keys and BPMs to the tracks: …" writes what an
analyzer heard to the tracks (a form asks which, and whether over what they have), confirmed
like any edit.
- **Checks**: a drive's check (in its `e` menu) shows what plugins' checks found too, and a fix
Expand Down
6 changes: 4 additions & 2 deletions apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ track's values once (again only for a changed audio file, or a new version of th
keeps them in the store. A value worked out with nothing to show (silence's key) shows as `-`,
matches no filter and sorts last. `locallibrary track LIBRARY --track ID` shows them under the
track's details, each as `ID.FIELD` (`sound.bpm`), and `locallibrary tracks` filters and sorts by
them (`--filter "sound.bpm:>120" --sort -sound.bpm`), as the sidecar's `tracks.query` does. In
them (`--filter "sound.bpm:>120" --sort -sound.bpm`), as the sidecar's `tracks.query`, smart
playlists' rules, edits' track selectors and playlist sorts do. In
the player, analyzers are in the palette and a drive's menu, and their fields are columns to
show, sort and search by. An analyzer may say how its fields are shown: a unit (`LUFS`), how many
places, as a key, a duration (`3:07`), decibels, a percentage or a meter, and how wide its column
Expand Down Expand Up @@ -541,7 +542,8 @@ locallibrary adds kinds of hooks without breaking older plugins.

A plugin is refused if it says it's something other than what locallibrary knows it as. It may
call only the reads its capabilities grant, naming drives by name or id (never a path), in pages of
bounded size, and what it gets back, errors too, doesn't say where things are on this computer:
bounded size. Those are the drives known here, and the one a hook under way runs on, a folder
opened by path too, named as the hook's context names it, until the hook is done. What it gets back, errors too, doesn't say where things are on this computer:
a drive is said by its name, where the store's copies, a set's or a mix's recording and
rekordbox's collection are is left out, and any other path is said from its drive, the store
(`<store>`) or your home folder (`~`). An exporter alone is told where the drive it exports is, as
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/edit_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ pub fn smart(args: SmartArgs, store_path: &Option<PathBuf>, out: Output, ctx: &C
limit,
drives: Vec::new(),
};
rule.validate()?;
rule.validate(&crate::plugins::host().map(|p| p.fields()).unwrap_or_default())?;
let change = match crate::diff_cmd::read_or_empty(store_path, api::smart_list)?.get(&path) {
Some(old) => format!("replace the rule for {path:?} ({}) with {filter:?}", old.filter),
None => format!("add a rule for {path:?}: {filter:?}"),
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,7 @@ fn run(cli: Cli, out: Output, contributed: Option<plugin_cmd::Contributed>) -> R
// --search is free text in the filter.
let filter: Vec<String> = a.search.as_deref().map(search_terms).into_iter().chain(a.filter).collect();
let filter = (!filter.is_empty()).then(|| filter.join(" "));
let fields = plugins::Installed::with(diff_cmd::existing_store(&cli.store)?);
let fields = plugins::installed(diff_cmd::existing_store(&cli.store)?);
let reads = each_drive::read(a.drive, |drive| {
let (filter, playlist, sort) = (filter.clone(), a.playlist.clone(), a.sort.clone());
let req = api::TracksQuery { library: drive.into(), filter, playlist, sort, offset: 0, limit: a.limit };
Expand Down
8 changes: 7 additions & 1 deletion apps/locallibrary/src/plugin_cmd/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,19 @@ pub(crate) fn view(v: &View, names: Names<'_>) {
/// A view under `title`, a section of what the CLI shows (a panel under a track's details): the
/// title (and the view's, after it), then its lines, saying so when it shows nothing.
pub(crate) fn section(title: &str, v: &View, names: Names<'_>) {
heading(&v.title().map_or_else(|| title.to_string(), |t| format!("{title} · {t}")));
heading(&titled(title, v.title()));
match says_nothing(v) {
true => draw(&[Drawn::Missing]),
false => draw(&lines(v, names)),
}
}

/// A panel's `title` with its view's (`view`), after it, when it has one: how the CLI and the
/// player head a panel.
pub(crate) fn titled(title: &str, view: Option<&str>) -> String {
view.map_or_else(|| title.to_string(), |v| format!("{title} · {v}"))
}

/// Lines as the CLI draws them: section titles in bold; a table's headings, details and what's
/// missing dim; an item indented two spaces a level, its detail beside it.
fn draw(lines: &[Drawn]) {
Expand Down
29 changes: 4 additions & 25 deletions apps/locallibrary/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,31 +348,10 @@ impl Reads for SidecarReads {
}
}

/// The analyzers installed and what they worked out, for filters and sorts naming their fields
/// (`sound.bpm:>120`): kept in `store`. Plugins that were refused have none.
pub struct Installed {
store: Option<locallibrary::Store>,
analyzers: Vec<locallibrary::fields::Analyzer>,
declared: locallibrary::fields::Declared,
}

impl Installed {
pub fn with(store: Option<locallibrary::Store>) -> Installed {
let host = host().ok();
Installed {
store,
analyzers: host.map(Plugins::analyzers).unwrap_or_default(),
declared: host.map(Plugins::fields).unwrap_or_default(),
}
}

pub fn source(&self) -> locallibrary::fields::Source<'_> {
locallibrary::fields::Source {
store: self.store.as_ref(),
analyzers: &self.analyzers,
declared: &self.declared,
}
}
/// The analyzers installed and what they worked out, kept in `store`, for filters and sorts
/// naming their fields (`sound.bpm:>120`).
pub fn installed(store: Option<locallibrary::Store>) -> locallibrary::fields::Installed {
locallibrary::fields::Installed::new(store, host().ok())
}

/// What analyzers worked out for a track on a drive, kept in `store`, that a page shows: each
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/rpc/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ fn build() -> Registry {
r.read("playlists.tree", |_, req| Ok(api::playlists_tree(&LIBRARIES, &req)?)).quick();
r.read("tracks.get", |_, req| Ok(api::tracks_get(&LIBRARIES, &req)?)).quick();
r.read("tracks.query", |c, req| {
let fields = crate::plugins::Installed::with(c.existing()?);
let fields = crate::plugins::installed(c.existing()?);
Ok(api::tracks_query_with(&LIBRARIES, &req, Some(fields.source()))?)
})
.quick();
Expand Down
8 changes: 4 additions & 4 deletions apps/locallibrary/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use super::crate_list::{Crate, CrateTrack};
use super::drives::{DriveKey, DriveRow};
use super::edits::{Action, Job, Menu, Modal, Pick};
use super::keymap::{self, Act};
use super::keys::{self, Camelot, Relation};
use super::keys::{Camelot, Relation};
use super::state::{self, Saved, SavedCrateTrack, SavedQueue};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -411,7 +411,7 @@ pub struct Mix {

impl Matches {
pub(super) fn find(library: Arc<Library>, root: PathBuf, track: &Track, limit: usize) -> Matches {
let items = keys::suggestions(&library, track, limit)
let items = api::matches_in(&library, track, limit)
.into_iter()
.filter_map(|s| {
let index = library.tracks.iter().position(|t| t.id == s.track.id)?;
Expand Down Expand Up @@ -462,7 +462,7 @@ pub struct TrackInfo {

fn track_info(root: &Path, track: &Track, library: Option<Arc<Library>>, store_root: &Path) -> TrackInfo {
let device = Device::open(root).ok();
let mut analysis = device.as_ref().map(|d| anlz::load(d, track)).unwrap_or_default();
let mut analysis = device.as_ref().map(|d| api::analysis_of(d, track)).unwrap_or_default();
let analyzers = crate::plugins::host().map(|p| p.analyzers()).unwrap_or_default();
let audio = device.as_ref().and_then(|d| d.checked_path(&paths::key(&track.file_path)).ok());
let from_plugins = audio.filter(|_| !analyzers.is_empty()).and_then(|audio| {
Expand Down Expand Up @@ -2297,7 +2297,7 @@ impl App {
let store_root = self.store_root.clone();
let job = move || {
let read = Device::open(&root).map(|device| {
let installed = crate::plugins::Installed::with(Store::open(&store_root).ok());
let installed = crate::plugins::installed(Store::open(&store_root).ok());
Box::new(TrackValues::read(installed.source(), &device, &library))
});
Msg::Fields(generation, read.map_err(|e| e.to_string()))
Expand Down
16 changes: 4 additions & 12 deletions apps/locallibrary/src/tui/edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use locallibrary::model::TrackColor;
use locallibrary::op::{AnyPlanned, Planned};
use locallibrary::plugin::PluginExport;
use locallibrary::progress::{Event, Phase, Progress};
use locallibrary::query::{SortField, SortKey, TrackSelector};
use locallibrary::query::{SortKey, TrackSelector};
use locallibrary::{CancelToken, Ctx, Error};
use loclib_plugin::Where;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
Expand Down Expand Up @@ -151,8 +151,6 @@ pub struct Target {
pub crate_total: usize,
/// The drive was unplugged.
pub stale: bool,
/// The drive is a folder opened by path, not one plugged in: plugins can't name it.
pub folder_drive: bool,
}

pub struct MenuItem {
Expand Down Expand Up @@ -242,12 +240,7 @@ pub fn menu_items(t: &Target) -> Vec<MenuItem> {
_ => "sort this playlist on the drive".into(),
},
Action::SortList,
stale.clone().or(not_list).or(because(t.sort.is_none(), "sort the list first (b, or click a column)")).or(
because(
t.sort.is_some_and(|k| matches!(k.field, SortField::Field(_))),
"a plugin's column can't sort the drive's playlists",
),
),
stale.clone().or(not_list).or(because(t.sort.is_none(), "sort the list first (b, or click a column)")),
),
item("N", format!("new playlist{into}…"), Action::NewPlaylist, stale.clone()),
item("F", format!("new folder{into}…"), Action::NewFolder, stale.clone()),
Expand Down Expand Up @@ -279,7 +272,7 @@ pub fn menu_items(t: &Target) -> Vec<MenuItem> {
item("y", keymap::name(Act::Replay).into(), Action::Replay, stale.clone()),
]
.into_iter()
.chain(super::plugins::offered(Some(!t.folder_drive), &[Where::Track, Where::Playlist]).into_iter().map(|o| {
.chain(super::plugins::offered(true, &[Where::Track, Where::Playlist]).into_iter().map(|o| {
// What it works on here: the tracks, or the playlist (not a folder).
let on_tracks = o.offered.contains(&Where::Track) && !t.tracks.is_empty();
let on_list = o.offered.contains(&Where::Playlist) && t.node.as_ref().is_some_and(|(_, folder)| !folder);
Expand All @@ -290,7 +283,7 @@ pub fn menu_items(t: &Target) -> Vec<MenuItem> {
let unavailable = stale.clone().or(o.unavailable).or(because(!on_tracks && !on_list, nothing));
item("", o.label, Action::Plugin(o.plugin, o.name), unavailable)
}))
.chain(super::plugins::providers::fetches(Some(!t.folder_drive)).into_iter().map(|(kind, label, _, why)| {
.chain(super::plugins::providers::fetches(true).into_iter().map(|(kind, label, _, why)| {
let unavailable = stale.clone().or(why).or(because(t.tracks.is_empty(), "no track selected"));
item("", label, Action::Fetch(kind), unavailable)
}))
Expand Down Expand Up @@ -619,7 +612,6 @@ impl App {
crated,
crate_total: self.crate_list.len(),
stale: l.stale,
folder_drive: self.pluggable(Some(l.root())) == Some(false),
})
}

Expand Down
4 changes: 2 additions & 2 deletions apps/locallibrary/src/tui/mix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use locallibrary::anlz::{self, TrackAnalysis, Waveform};
use locallibrary::anlz::{TrackAnalysis, Waveform};
use locallibrary::api;
use locallibrary::mix::export;
use locallibrary::mix::index::Cache;
Expand Down Expand Up @@ -507,7 +507,7 @@ fn identify(
let store = Store::open_or_init(store_root)?;
let id = Identifier::with_library(&device, library.clone(), &Cache::in_store(&store), &ctx)?;
let report = id.identify(path, &mix::Options::default(), &ctx)?;
let analyses = report.plays.iter().map(|p| anlz::load(&device, &p.track)).collect();
let analyses = report.plays.iter().map(|p| api::analysis_of(&device, &p.track)).collect();
let cover = report.mix.cover.as_ref().and_then(|c| c.read().ok()).and_then(|b| Art::decode(&b));
// The player plays the recording itself; for a project, the audio exported from it.
let audio = match mix::aup::is_project(&report.mix.path) {
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/tui/mytags/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ fn lists_the_rules_in_the_store() {
limit: Some(20),
drives: vec![uuid::Uuid::new_v4()],
};
all.set(rule).unwrap();
all.set(rule, &Default::default()).unwrap();
store.save_smart_playlists(&all).unwrap();
let lines = rules(&mut app);
assert_eq!(lines[..2], ["Smart/Peak", "bpm:>=128 · sorted by -bpm · at most 20 · some libraries only"]);
Expand Down
Loading
Loading