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
5 changes: 5 additions & 0 deletions apps/locallibrary/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ _Avoid_: stick, device, volume, USB
A saved copy of a drive's library in the store.
_Avoid_: backup, version, commit

**View**:
A temporary, read-only folder of links into the store, laid out as one snapshot's drive tree, with
nothing copied.
_Avoid_: mount, browse folder, checkout

**Backup**:
What a change to a drive keeps on that drive so `undo` can put it back: the databases and the files
the change replaces or deletes.
Expand Down
1 change: 1 addition & 0 deletions apps/locallibrary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Every change shows what it will do and asks first. It ends with the commands you
| You want to check your drives for a particular player | `locallibrary check --players CDJ-3000X,CDJ-2000NXS2` |
| You changed your mind about the last change | `locallibrary undo` |
| You want to browse and listen | `locallibrary` |
| You want a past version of a drive as a folder, nothing copied | `locallibrary store browse <snapshot>` |
| You want to know what a command does | `locallibrary help sync` |
| Something went wrong | `locallibrary bug` (reports it, with your version and system filled in) |

Expand Down
7 changes: 7 additions & 0 deletions apps/locallibrary/changes/store-browse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Added

- `store browse` opens a past version of a drive as a folder of read-only links into the store, with nothing copied, and removes it on Ctrl-C. `--to` chooses the folder, `--keep` leaves it open, `store views` lists what's open and `store unbrowse` removes it. Not on Windows.

### Changed

- Files in the store are read-only from now on, so a write to one fails instead of leaving it under the wrong hash. Older ones change when a view shows them.
1 change: 1 addition & 0 deletions apps/locallibrary/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ its drives.
| `locallibrary store index-limit [MIB\|default]` | The largest fingerprint index `mix` keeps for a drive (512 MiB unless set), see [Mixes](sets.md#mixes) |
| `locallibrary store serve` / `store fetch <code>` | Copy snapshots between computers over the network: one serves its store, the other fetches what it lacks, see [From another computer](store.md#from-another-computer) |
| `locallibrary store copy <dir>` / `store copies` | Copy the store somewhere else (a NAS, another disk, a cloud-synced folder), only what's new; where copies were made, see [Off-site copies](store.md#off-site-copies) |
| `locallibrary store browse <snapshot> [--to DIR] [--keep]` / `store views` / `store unbrowse [DIR]` | Open a past version of a drive as a folder of read-only links into the store (macOS and Linux), see [Browsing a snapshot](store.md#browsing-a-snapshot) |
| `locallibrary store copy --to PLUGIN:DESTINATION [--dry-run]` / `store restore --from PLUGIN:DESTINATION [--library L]... [--dry-run]` | Copy the store to a plugin's store destination, and bring back what a copy there has that this store lacks (nothing here is replaced), see [Plugins](plugins.md) |
| `locallibrary diff <a> <b> [--playlist P]` | Playlist entries added, removed and moved between libraries (a library's newest drive plugged in, else its latest snapshot), drives or `snapshot:<id>`s |
| `locallibrary diff <a> <b> --summary` | Which drive is newer, the evidence, and the differences (in both databases) |
Expand Down
3 changes: 2 additions & 1 deletion apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ A store destination keeps a copy of the store, as a copy in a folder does (`stor
some, `--dry-run`), after losing this computer say, and `locallibrary drive verify DRIVE --from
PLUGIN:DESTINATION --repair` rewrites damaged files from it when nothing nearer has them. Every
file that comes back is checked against its BLAKE3 hash before it goes in the store, and only
locallibrary writes the store.
locallibrary writes the store. Stored files reach a plugin read-only on macOS and Linux, and a
system copy call keeps that, so a plugin that may write its copy again copies the bytes.

A set destination publishes a set's recording with its tracklist (tracks marked unnamed, tracks
your profile hides and those `--hide 3,7` names go without their names, notes stay private):
Expand Down
33 changes: 33 additions & 0 deletions apps/locallibrary/docs/store.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ The plan is shown first, and exactly that is removed (a pull or label in between
again); history still runs through what was removed (`library log` skips it), and a removed
snapshot's id says when it was pruned.

## Browsing a snapshot

`store browse` opens a past version of a drive as an ordinary folder, laid out as the drive was,
with nothing copied:

```bash
locallibrary store browse "Friday warmup" # in a temporary folder, until Ctrl-C
locallibrary store browse 3f9a2c --to ~/Desktop/friday # in a new or empty folder of your choice
locallibrary store browse 3f9a2c --keep # stays after the command ends
locallibrary store views # what's open, and any broken links
locallibrary store unbrowse # remove them (or name one)
```

It prints where the folder is. The files in it are links into the store, so they take no room:
hard links when the folder is on the store's disk, symlinks otherwise. Hard links open and play like
any file. A symlink leads to a stored file named by its hash, so on macOS a double-click opens the
text editor: drag it into a player, or use Open With.

The view is read-only, since a stored file is named after what it holds. Writing to one fails with
"permission denied", and so does saving a new file there, so an edit can't vanish with the view.
Stored files are read-only from this version on; older ones become read-only when a view shows them.
An app that sets the permissions back can still get through, as can one that follows a symlink and
replaces the stored file itself: `store verify` finds what changed.

A view goes when the command stops, on Ctrl-C or when its terminal closes. One a crash left stays
listed in `store views` and warned of by `store info` and `store browse` until `store unbrowse`
removes it. `store unbrowse` only removes what the view made, so a file someone added to it stays and
is reported. Pruning a view's snapshot warns first and goes ahead. Hard links keep working, and the
space their files take isn't freed until the view is removed. Symlinks to files nothing else uses
break, and the rest keep working.

Views aren't available on Windows, where links take administrator rights.

## From another computer

```bash
Expand Down
30 changes: 29 additions & 1 deletion apps/locallibrary/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,25 @@ enum StoreCommand {
},
/// Where the store has been copied, when, and whether each copy is up to date
Copies,
/// Open a past version of a drive as a folder of read-only links into the store, nothing
/// copied: it's there until Ctrl-C, then removed (macOS and Linux)
Browse {
/// Snapshot id (prefix) or label
snapshot: String,
/// Make it in this new or empty folder [default: a temporary one]
#[arg(long, value_name = "DIR")]
to: Option<PathBuf>,
/// Leave it there until `store unbrowse` instead of waiting for Ctrl-C
#[arg(long)]
keep: bool,
},
/// The folders `store browse` has open, and any broken links in them
Views,
/// Remove a folder `store browse` left open [default: all of them]
Unbrowse {
/// The folder
path: Option<PathBuf>,
},
/// Bring back what a copy of the store at a plugin's destination has that this store lacks
/// (after losing a computer, say): nothing this store has is replaced
Restore {
Expand Down Expand Up @@ -1419,7 +1438,11 @@ fn run(cli: Cli, out: Output, contributed: Option<plugin_cmd::Contributed>) -> R
// Reads about the store, and changes to what it holds already, never make one.
let existing = || diff_cmd::store_to_read(&cli.store);
match command.unwrap_or(StoreCommand::Info) {
StoreCommand::Info => out.emit(&api::store_info(&existing()?)?, render::store_info),
StoreCommand::Info => {
let store = existing()?;
out.emit(&api::store_info(&store)?, render::store_info);
store_cmd::views_open(&store)?;
}
StoreCommand::Gc { dry_run } => {
let store = existing()?;
let plan = api::store_gc_plan(&store)?;
Expand Down Expand Up @@ -1465,6 +1488,11 @@ fn run(cli: Cli, out: Output, contributed: Option<plugin_cmd::Contributed>) -> R
};
}
StoreCommand::Copies => return store_cmd::copies(&cli.store, out),
StoreCommand::Browse { snapshot, to, keep } => {
return store_cmd::browse(&existing()?, &snapshot, to.as_deref(), keep, out, &ctx);
}
StoreCommand::Views => return store_cmd::views(&cli.store, out),
StoreCommand::Unbrowse { path } => return store_cmd::unbrowse(&existing()?, path, out),
StoreCommand::IndexLimit { limit } => return store_cmd::index_limit(&cli.store, limit, out),
StoreCommand::Restore { from, libraries, dry_run } => {
let options = locallibrary::offsite::RestoreOptions { libraries, dry_run };
Expand Down
110 changes: 108 additions & 2 deletions apps/locallibrary/src/store_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! `pull` and the store's upkeep: `store label|unlabel|prune|copy|copies|restore|serve|fetch|index-limit`.
//! `pull` and the store's upkeep:
//! `store label|unlabel|prune|copy|copies|browse|views|unbrowse|restore|serve|fetch|index-limit`.

use std::io::IsTerminal;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anyhow::Result;
Expand All @@ -12,6 +13,7 @@ use locallibrary::catalog::{FetchReport, LibraryOutcome};
use locallibrary::local_time;
use locallibrary::mix::prefs::{DEFAULT_INDEX_LIMIT_MIB, MixPrefs};
use locallibrary::progress::Ctx;
use locallibrary::view;
use serde::Serialize;

use crate::output::{self, Outcome, Output};
Expand Down Expand Up @@ -354,6 +356,110 @@ pub fn fetch(
Ok(fetched(&r, "the other computer", "on both computers", out))
}

/// `store browse`: a view of the snapshot, there until Ctrl-C (or until another `store unbrowse`
/// removes it) unless `keep`.
pub fn browse(
store: &Store,
snapshot: &str,
to: Option<&Path>,
keep: bool,
out: Output,
ctx: &Ctx<'_>,
) -> Result<ExitCode> {
let id = store.resolve_snapshot(snapshot)?;
views_open(store)?;
let opened = view::open(store, &id, to)?;
let show = |o: &view::Opened, until: &str| {
output::ok(format!(
"view of snapshot {}: {} file(s), {}, nothing copied",
o.view.snapshot.short(),
o.files,
output::bytes(o.bytes)
));
say!(" {}", style(o.view.path.display()).bold());
for path in &o.missing {
output::warn(format!("{path} isn't in the store, so its link is broken (see `store verify`)"));
}
for path in &o.skipped {
output::warn(format!("{path} was left out: an unsafe path, or one differing from another only in case"));
}
let pruned = match o.symlinks {
0 => "pruning this snapshot leaves its space in use until the view is removed",
_ => "they break if this snapshot is pruned",
};
output::hint(format!("Read-only links into the store: {pruned}. {until}"));
};
if keep {
return Ok(out.done(&opened, |o| show(o, "Remove it with `locallibrary store unbrowse`.")));
}
match out.json {
true => say!("{}", output::with_schema(serde_json::to_value(&opened).expect("serialisable"))),
false => show(&opened, "It's removed on Ctrl-C."),
}
while ctx.check().is_ok() && view::is_open(store, &opened.view.path)? {
std::thread::sleep(std::time::Duration::from_millis(100));
}
let closed = match view::is_open(store, &opened.view.path)? {
true => view::close(store, &opened.view.path)?,
false => view::Closed { path: opened.view.path, left: 0 },
};
Ok(out.done(&closed, say_closed))
}

/// `store views`.
pub fn views(store: &Option<PathBuf>, out: Output) -> Result<ExitCode> {
let views = crate::diff_cmd::read_or_empty(store, view::list)?;
out.emit(&views, |views| {
if views.is_empty() {
say!("No views open. Make one with `locallibrary store browse <snapshot>`.");
}
for v in views {
let when = local_time(v.view.created, "%Y-%m-%d %H:%M");
say!("{}", style(v.view.path.display()).bold());
say!(" {}", output::dim(format!("snapshot {}, opened {when}", v.view.snapshot.short())));
if v.broken > 0 {
output::warn(format!("{} broken link(s): the store no longer has their files", v.broken));
}
}
});
Ok(ExitCode::SUCCESS)
}

/// `store unbrowse`.
pub fn unbrowse(store: &Store, path: Option<PathBuf>, out: Output) -> Result<ExitCode> {
let paths = match path {
Some(path) => vec![path],
None => view::list(store)?.into_iter().map(|v| v.view.path).collect(),
};
let closed = paths.iter().map(|p| view::close(store, p)).collect::<locallibrary::Result<Vec<_>>>()?;
Ok(out.done(&closed, |closed| {
if closed.is_empty() {
say!("No views open.");
}
closed.iter().for_each(say_closed);
}))
}

fn say_closed(c: &view::Closed) {
match c.left {
0 => output::ok(format!("removed the view at {}", c.path.display())),
n => output::warn(format!("left {n} file(s) in {} that aren't links", c.path.display())),
}
}

/// Warn of views still open: `store browse` removes its own, so one here is in use, kept or left
/// by a crash.
pub fn views_open(store: &Store) -> Result<()> {
for v in view::list(store)? {
output::warn(format!(
"a view of snapshot {} is open at {}; `locallibrary store unbrowse` removes it",
v.view.snapshot.short(),
v.view.path.display()
));
}
Ok(())
}

/// `store restore --from PLUGIN:DESTINATION`.
pub fn restore(
store: &Store,
Expand Down
12 changes: 12 additions & 0 deletions apps/locallibrary/tests/it/drives/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ fn the_store_says_what_it_holds() {
.flat_map(|d| std::fs::read_dir(d.unwrap().path()).unwrap())
.map(|f| f.unwrap().path())
.collect();
std::fs::remove_file(&objects[0]).unwrap();
std::fs::write(&objects[0], "damaged").unwrap();
std::fs::remove_file(&objects[1]).unwrap();
let r = env.run(&["store", "verify"]);
Expand Down Expand Up @@ -249,3 +250,14 @@ fn a_store_that_wont_open_is_named() {
assert!(err.contains(&named) && err.contains("newer than this version"), "{args:?}: {err}");
}
}

/// Windows has no view to give: links there take administrator rights.
#[cfg(windows)]
#[test]
fn windows_says_views_arent_available() {
let env = Env::new();
env.pulled("Main");
let id = env.ok(&["log", "Main"]).lines().last().unwrap().split_whitespace().next().unwrap().to_string();
let err = env.fails(1, &["store", "browse", &id]);
assert!(err.contains("isn't available on Windows"), "{err}");
}
2 changes: 2 additions & 0 deletions apps/locallibrary/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ mod props_cli_sets;
mod props_cli_smart;
mod props_cli_terminal;
mod props_cli_tracklist;
#[cfg(unix)]
mod props_cli_view;
mod props_rpc;
mod props_rpc_every;
mod props_rpc_flows;
Expand Down
1 change: 1 addition & 0 deletions apps/locallibrary/tests/it/props_cli_changes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fn by_hand(fx: &Env, step: &str) {
// A file in the store changes on disk.
"!corrupt-store" => {
if let Some(f) = same_in_both(&fx.store.join("objects")) {
std::fs::remove_file(&f).unwrap();
std::fs::write(f, "not what was stored").unwrap();
}
}
Expand Down
Loading
Loading