Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ea6fdae
tui: plugins' commands in the palette and the drive menu, a form for …
botandbang Oct 15, 2026
10ca8fc
tui: plugins' operations in the palette, the drive menu and the actio…
botandbang Oct 15, 2026
56c64f2
plugins' fields in filters and sorts: tracks, tracks.query and the pl…
botandbang Oct 15, 2026
43686e2
tui: plugins' settings in a form from the palette, filled as they're …
botandbang Oct 15, 2026
9e543d3
plugin exports: written into folders that aren't there yet, which a p…
botandbang Oct 15, 2026
6fabdcb
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 16, 2026
c6d297f
tui: plugins' export formats in the palette and a drive's menu, with …
botandbang Oct 16, 2026
cf2b429
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 16, 2026
4018037
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 16, 2026
3cb66df
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 16, 2026
d60876d
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 16, 2026
6b8e493
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 17, 2026
e98f3e9
tui plugin tests: the missing tracklist is a path the platform names,…
botandbang Oct 17, 2026
ea98304
tui form tests: ~ is checked against the home folder as the platform …
botandbang Oct 17, 2026
9f65fe9
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 17, 2026
ea22059
Merge branch 'bnb/plugin-integration' into bnb/plugin-tui
botandbang Oct 17, 2026
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
11 changes: 9 additions & 2 deletions apps/locallibrary/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,15 @@ Windows builds are experimental and untested on hardware (see Platforms in the R
sidecar has all of it as `plugins.*` methods, their plans kept apart from locallibrary's own,
and `track.fields` reads the installed plugins' versions. A plugin whose id or commands clash
with locallibrary's or another plugin's is left out, with a warning saying why, and the others
work; a plugin's errors keep their kinds (`not_found`, `io`...). See docs/plugins.md. The ones
it ships:
work; a plugin's errors keep their kinds (`not_found`, `io`...). In the player, plugins'
commands, operations and export formats are in the palette and a drive's menu (operations in
the actions menu too), asking for their params or options in a form drawn from their schemas;
a command answers in a panel, an operation's plan is confirmed, written and undone like any
edit, an export shows what it would write and asks before overwriting, analyzers run from the
palette and their fields are columns to sort and search by, plugins' settings are a form in the
palette, and what plugins log goes to its messages (`!`). `tracks --filter`, `tracks --sort`
and the sidecar's `tracks.query` take analyzers' fields (`sound.bpm:>120`, `-sound.bpm`). See
docs/plugins.md. The ones it ships:
- `tracklist`: `playlist import` makes a playlist from a tracklist (M3U, CSV exports of
streaming playlists, rekordbox text or XML, "Artist - Title" lines), and `tracklist match`
shows what it finds in one, entry by entry, changing nothing.
Expand Down
4 changes: 3 additions & 1 deletion apps/locallibrary/docs/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,16 @@ 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) |

`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`.
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.

### When rekordbox exports again

Expand Down
6 changes: 4 additions & 2 deletions apps/locallibrary/docs/other-software.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ files the folder already holds is asked about first unless `--replace` (the fold
program's own), and they're replaced only once every new file is written beside them;
`--option skip_analysis=true` leaves beat grids and cues out (Traktor, and Serato's tags), and
`--dry-run` lists each file with its size, what goes in and what the format leaves out, without
writing. RPC: `plugins.export.plan` and `plugins.export.apply` with `plugin: "interop"`, the
format as `exporter` and the options as `options` (see [Building a GUI](sidecar.md)).
writing. In the player, "export for traktor…" (serato, engine) in the palette does the same,
for the marked playlists or the whole drive (see [the player](player.md)). RPC:
`plugins.export.plan` and `plugins.export.apply` with `plugin: "interop"`, the format as
`exporter` and the options as `options` (see [Building a GUI](sidecar.md)).

- **Traktor** (`collection.nml`): tracks with their details, tempo and key, the beat grid as one
grid marker on the first downbeat (locked, so Traktor doesn't analyse it again), hot cues A-H
Expand Down
25 changes: 23 additions & 2 deletions apps/locallibrary/docs/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ listening to your library the way the players see it:
`fsp` finds "fill the smart playlists") and enter. It also has the commands with no key of their
own: pull, check, set prep, sync, pack, restore, replay, export, xml, smart, smart apply, smart
new and eject.
- **Plugins' commands and operations** are in the palette by title, with the plugin's name beside
them, and at the end of a drive's `e` menu in the Drives view; operations, which change the
drive, end the actions menu (`e`) too. One that takes params asks for them in a form first
(`↑↓` or `tab` between fields, `←→` or `space` to pick a choice, `enter` to run; lists take
commas), a list of tracks starting with the marked or selected ones. It runs on the drive open,
with the marked or selected tracks and the playlist selected, in the background (`esc` stops
it). What a command answers shows in a panel, or as a message when it's a line; an operation's
plan, with what the plugin says about it first, is confirmed and written like any edit, and `u`
takes it back. 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
[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), sorted by
a click on its header like any column, and the search box filters by it: `sound.bpm:>120`.
- **Plugins' settings**: "Cover art plugin settings…" in the palette opens a plugin's settings in
the same form, as they're set now; `enter` saves what changed, and a field left empty goes back
to its default.
- **Problems**: ✖ marks a track whose audio file isn't on the drive, ≈ one the drive holds another
copy of. Browse › Problems lists them.
- **Playback** of MP3, AAC/ALAC (`.m4a`), FLAC, WAV and AIFF, with a queue built from the list you
Expand Down Expand Up @@ -126,7 +144,10 @@ listening to your library the way the players see it:
signs in (type your handle, approve in the browser), and signed in, `L` twice signs out.
- **Export**: in the palette, "export" writes the playlist, set, session or all tracks shown as
M3U8, CSV or text, and "xml" writes rekordbox XML of the drive (just the marked playlists when
some are), asking where first.
some are), asking where first. A plugin's format ("export for traktor…", in a drive's `e` menu
too) asks where and for the format's options in a form, then shows what it would write (each
file, what goes in, what the format can't hold) and writes it on `y`, asking first when that
overwrites files there already; it takes the marked playlists too.
- **Mixes** (`5`): a recorded set and the tracks it plays, like [`locallibrary mix`](sets.md#mixes)
against the drive open in the library. Pick one of the drive's recordings (XDJ-AZ and OPUS-QUAD
MASTER REC files), or `O` to type the path of a recording or a folder of them on this computer. A
Expand Down Expand Up @@ -174,7 +195,7 @@ output, `o` goes to a track, `w` writes a file, `e` opens the Library and Drives
| `l` | loop here / exit the loop (`(` `)`: halve / double it) |
| `+` `-` | volume (`=` `_` too) |
| `d` | the output to play through |
| `:` | commands: every action by name, and those with no key |
| `:` | commands: every action by name, those with no key, and plugins' commands |
| `s` `r` | shuffle / repeat (off, all, one) |
| `S` | autoplay off (single play: stop after each track) / on |
| `A` | auto cue: start tracks at their first memory cue or beat |
Expand Down
8 changes: 7 additions & 1 deletion apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,17 @@ with each other are both left out.
- **Analyzers** run with `locallibrary analyze [LIBRARY] [--plugin ID] [--analyzer NAME]`, which
works out each track's values once (again only for a changed audio file) and keeps them in the
store. `locallibrary track LIBRARY --track ID` shows them under the track's details, each as
`ID.FIELD` (`sound.bpm`).
`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.
- **Export formats** are `locallibrary export LIBRARY --format FORMAT -o PATH`, with the
plugin's options as `--option KEY=VALUE`. `--dry-run` lists what it would write (each file,
what goes in, what the format can't hold), and overwriting a file is asked about first unless
`--replace`.
- **In the player** (`locallibrary tui`), commands, operations, analyzers and export formats are
in the palette (`:`) and a drive's menu, operations in the actions menu (`e`) too, with a form
for their params or options; an export shows what it would write and asks before writing it;
analyzers' fields are columns to show, sort and search by, and each plugin's settings are a
form in the palette too (see [the player](player.md)).
- **Settings**: `locallibrary plugin settings ID` shows them, `plugin settings ID KEY VALUE` sets
one and `plugin settings ID KEY --reset` puts it back to its default. They're kept in the store
(`plugins/ID/settings.json`).
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/collection_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ pub fn run(args: CollectionArgs, store: &Option<PathBuf>, out: Output, ctx: &Ctx
if let Some(f) = &filter {
let f = Filter::parse(f)?;
let tags = f.uses_tags().then(|| c.tags_by_file());
let ctx = api::filter_context(lib, &f, tags.as_ref())?;
let ctx = api::filter_context(lib, &f, tags.as_ref(), None)?;
tracks.retain(|t| f.matches(t, &ctx));
}
if let Some(s) = &sort {
Expand Down
23 changes: 15 additions & 8 deletions apps/locallibrary/src/export_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use clap::{Args, ValueEnum};
use locallibrary::api;
use locallibrary::export::{Format, PathStyle};
use locallibrary::op::Planned;
use locallibrary::plugin::PluginExport;
use locallibrary::plugin::{ExportFile, PluginExport};
use locallibrary::progress::Ctx;
use loclib_plugin::ExportSource;

Expand Down Expand Up @@ -191,20 +191,27 @@ fn run_plugin(
}))
}

/// How many of a plugin's export's files are listed before "and N more" (here and in the player).
pub(crate) const FILES_SHOWN: usize = 20;

/// What one of a plugin's export's files holds and its size, as far as the plugin says.
pub(crate) fn about(f: &ExportFile) -> String {
let about =
[(!f.file.what.is_empty()).then(|| f.file.what.clone()), (f.file.bytes > 0).then(|| bytes(f.file.bytes))];
about.into_iter().flatten().collect::<Vec<_>>().join(", ")
}

/// A plugin's export as locallibrary's own were shown: what it writes and what goes in, its first
/// files (what each holds, and those there already), what the format can't hold, then problems
/// with the library.
fn render_export(p: &Planned<PluginExport>) {
crate::output::summary(&p.summary, &[]);
for f in p.files.iter().take(20) {
let about =
[(!f.file.what.is_empty()).then(|| f.file.what.clone()), (f.file.bytes > 0).then(|| bytes(f.file.bytes))];
let about = about.into_iter().flatten().collect::<Vec<_>>().join(", ");
for f in p.files.iter().take(FILES_SHOWN) {
let replaces = if f.exists { " (already there)" } else { "" };
say!(" {} {}{replaces}", f.file.path.display(), dim(about));
say!(" {} {}{replaces}", f.file.path.display(), dim(about(f)));
}
if p.files.len() > 20 {
say!(" {}", dim(format!("... and {} more", p.files.len() - 20)));
if p.files.len() > FILES_SHOWN {
say!(" {}", dim(format!("... and {} more", p.files.len() - FILES_SHOWN)));
}
for n in &p.notes {
say!(" {}", dim(n));
Expand Down
14 changes: 5 additions & 9 deletions apps/locallibrary/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ macro_rules! filter_help {
const TRACKS_LONG_HELP: &str = concat!(
filter_help!(),
"
sound.bpm:>120 sound.key:8A what an analyzer plugin worked out (`locallibrary analyze`); --sort takes them too

Examples:
locallibrary tracks -s \"daft punk\"
Expand Down Expand Up @@ -605,7 +606,7 @@ struct TracksArgs {
/// Only tracks matching a filter, e.g. "bpm:120-128 key:8A~ rating:>=4" (syntax below)
#[arg(long, short, allow_hyphen_values = true)]
filter: Option<String>,
/// Order, e.g. key,bpm or -rating (title, artist, album, genre, label, key, bpm, rating, color, year, plays, added, time, bitrate, path)
/// Order, e.g. key,bpm or -rating (title, artist, album, genre, label, key, bpm, rating, color, year, plays, added, time, bitrate, path, or a plugin's field like sound.bpm)
#[arg(long, allow_hyphen_values = true)]
sort: Option<String>,
/// Show at most this many
Expand Down Expand Up @@ -1187,16 +1188,11 @@ 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 reads = each_drive::read(a.drive, |drive| {
let (filter, playlist, sort) = (filter.clone(), a.playlist.clone(), a.sort.clone());
api::tracks_query(&api::TracksQuery {
library: drive.into(),
filter,
playlist,
sort,
offset: 0,
limit: a.limit,
})
let req = api::TracksQuery { library: drive.into(), filter, playlist, sort, offset: 0, limit: a.limit };
api::tracks_query_with(api::LibraryCache::none(), &req, Some(fields.source()))
})?;
out.page();
// Several drives' TSV or CSV is one listing, for the program reading it.
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/plugin_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
mod analyze;
mod contributed;
mod listing;
mod view;
pub(crate) mod view;

use std::path::PathBuf;
use std::process::ExitCode;
Expand Down
50 changes: 31 additions & 19 deletions apps/locallibrary/src/plugin_cmd/view.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! What a plugin's command answers, drawn as the CLI draws its own results.

use loclib_plugin::View;
use loclib_plugin::{Pair, View};
use serde_json::Value;
use unicode_width::UnicodeWidthStr;

Expand All @@ -19,6 +19,29 @@ pub(super) fn headless(v: &Value) {
/// The widest a table's column is.
const MAX_COLUMN: usize = 48;

/// A table's lines, its columns' headings first: each column as wide as its widest cell (at most
/// [`MAX_COLUMN`]), two spaces apart. The CLI and the player draw them.
pub(crate) fn table(columns: &[String], rows: &[Vec<Value>]) -> Vec<String> {
let cells: Vec<Vec<String>> = rows.iter().map(|r| r.iter().map(cell).collect()).collect();
let widths: Vec<usize> = (0..columns.len())
.map(|i| {
let widest = cells.iter().filter_map(|r| r.get(i)).map(|c| c.width()).max().unwrap_or(0);
widest.max(columns[i].width()).min(MAX_COLUMN)
})
.collect();
let line = |row: &[String]| {
let cells = row.iter().zip(&widths).map(|(c, w)| output::fit(c, *w)).collect::<Vec<_>>();
cells.join(" ").trim_end().to_string()
};
std::iter::once(line(columns)).chain(cells.iter().map(|r| line(r))).collect()
}

/// Labelled values' lines, the labels aligned. The CLI and the player draw them.
pub(crate) fn pairs(pairs: &[Pair]) -> Vec<String> {
let width = pairs.iter().map(|p| p.label.width()).max().unwrap_or(0);
pairs.iter().map(|p| format!("{} {}", output::fit(&p.label, width), cell(&p.value))).collect()
}

/// A view a plugin's command answered, drawn as the CLI's lists and tables are.
pub(super) fn view(v: &View) {
let (View::List { title, .. } | View::Table { title, .. } | View::Kv { title, .. } | View::Text { title, .. }) = v;
Expand All @@ -35,26 +58,15 @@ pub(super) fn view(v: &View) {
}
}
View::Table { columns, rows, .. } => {
let cells: Vec<Vec<String>> = rows.iter().map(|r| r.iter().map(cell).collect()).collect();
let widths: Vec<usize> = (0..columns.len())
.map(|i| {
let widest = cells.iter().filter_map(|r| r.get(i)).map(|c| c.width()).max().unwrap_or(0);
widest.max(columns[i].width()).min(MAX_COLUMN)
})
.collect();
let line = |row: &[String]| {
let cells = row.iter().zip(&widths).map(|(c, w)| output::fit(c, *w)).collect::<Vec<_>>();
cells.join(" ").trim_end().to_string()
};
say!("{}", dim(line(columns)));
for row in &cells {
say!("{}", line(row));
let lines = table(columns, rows);
say!("{}", dim(&lines[0]));
for line in &lines[1..] {
say!("{line}");
}
}
View::Kv { pairs, .. } => {
let width = pairs.iter().map(|p| p.label.width()).max().unwrap_or(0);
for p in pairs {
say!("{} {}", output::fit(&p.label, width), cell(&p.value));
View::Kv { pairs: kv, .. } => {
for line in pairs(kv) {
say!("{line}");
}
}
View::Text { text, .. } => say!("{text}"),
Expand Down
Loading
Loading