Metadata-Version: 2.4
Name: baccy
Version: 0.2.0
Summary: Permanent backup daemon for media and other assets
Author: Tom Ritchford
Author-email: Tom Ritchford <tom@swirly.com>
License-Expression: MIT
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Requires-Dist: boto3>=1.42.0
Requires-Dist: jinja2>=3.1.6
Requires-Dist: pydantic>=2.13.5
Requires-Dist: reccy
Requires-Dist: simpleeval>=1.0.6
Requires-Dist: tomlkit>=0.15.1
Requires-Dist: tyro>=1.0.16
Requires-Python: >=3.14
Project-URL: Repository, https://github.com/rec/baccy
Description-Content-Type: text/markdown

# baccy

`baccy` is a permanent backup library and macOS command-line application for
audio files, photos, and other assets. It copies configured local directories,
memory cards, and already-mounted network shares to one central backup root.
It can run once from a terminal or continuously as a per-user LaunchAgent.

Baccy never deletes backups because a source disappears. A changed source
replaces its previous backup copy; baccy does not retain overwritten versions.

## Install

Use `uv` from a checkout:

```sh
uv sync
```

The `baccy` command is then available through `uv run baccy`.

## Configuration

By default baccy looks for:

```text
~/Library/Application Support/baccy/config.toml
```

Without an installed daemon, the default configuration would use automatic
removable-drive discovery and write selected backups to:

```text
~/baccy
```

CLI commands use the installed daemon's recorded configuration by default.
Use `--config PATH` to run without an installed daemon. A missing path supplied
explicitly with `--config` is an error.

Pass `--config PATH` before the command to use a different file.

`--daemon` explicitly selects the default daemon configuration. It cannot be
combined with `--config`. Global flags must precede the command.

```toml
backup_root = "/path/to/baccy"
discover_removable = true
poll_seconds = 60
stability_seconds = 60
s3_max_bandwidth = 1_000_000
verbose = true

[[uploads]]
name = "main-mp3"
match = "main and duration > 120"
encoding = { format = "mp3", bitrate_kbps = 128 }
destination = "ssh:user@example.org:/srv/shows"

[[uploads]]
name = "channel-archive"
match = "True"
encoding = { format = "flac" }
destination = "s3:show-recordings"

[[sources]]
kind = "path"
name = "recs"
path = "/Volumes/Recordings/recs"
exclude = [".DS_Store"]

[[sources]]
kind = "volume"
name = "camera-card"
uuid = "F0B5CF26-1C8C-4E33-B36D-4D4F84C2F685"
relative_path = "DCIM"
expected_name = "CAMERA"
```

Path sources are fixed local directories or network shares that macOS has
already mounted. Volume sources are found below `/Volumes` by volume UUID, not
by their display names. Find the UUID for a mounted card with:

```sh
diskutil info -plist /Volumes/CAMERA
```

With `discover_removable = true`, the default, baccy also examines newly
mounted removable or ejectable volumes that are not already configured. It
selects content only when either:

- the volume root contains a `DCIM` directory, identifying a camera card; or
- any directory contains a valid recs `recording.toml` or
  `session-record.jsonl`, identifying recs sessions.

For a camera volume, only recognized photo files below `DCIM` are backed up;
videos, sidecars, manuals, and unrelated files are ignored. Supported photo
families include JPEG, HEIC/HEIF, PNG, TIFF, DNG, and common camera RAW formats.
For a recs volume, every file inside each detected session directory is backed
up, preserving the portable session; unrelated files elsewhere on the volume
are ignored.

Other unconfigured removable drives are ignored. Automatically discovered
volumes use their filesystem UUID as part of the backup source identity, so a
renamed volume continues in the same destination. Volumes without a UUID and
the volume containing `backup_root` are ignored. Set
`discover_removable = false` to use configured sources only.

`include` and `exclude` are optional lists of path-match patterns. Sources use
`include = ["**"]` and no exclusions by default. Source names must be unique,
and neither a source nor the backup root may contain the other.

Set `verbose = true` to include unchanged files in command output. When the
installed service is running, it also sends macOS notifications when it
recognizes a backup disk and when a disk or network-machine backup pass
finishes. Machine recognition is logged without a notification. The current
default includes unchanged individual results while
retaining the `unchanged` count.

## Project publication

Projects are the first directory below any recs source baccy has backed up.
Each `uploads` rule independently selects finalized audio segments, so one
recording can create several artifacts. Publication always reads the completed
copy in the backup root, never an active recorder or removable drive.

`match` is a restricted expression over `duration`, `main`, `device`,
`channels`, `track`, `format`, `player`, and `has_player`. It supports
comparisons, membership, `and`, `or`, and `not`, but no calls, attributes,
subscripts, arithmetic, or Python evaluation. The default `main` tracks are
the two highest numbered channels of the device with the greatest observed
channel number. If devices tie, rules referring to `main` are deferred.

Rules encode `source`, `flac`, or `mp3`. MP3 rules require `bitrate_kbps`.
Derived FLAC files are written atomically to `artifacts/` under the backup
root, keyed by source content and the complete rule definition. MP3 files are
encoded in `/tmp` and deleted after each upload attempt. The permanent source
backup remains authoritative.

Uploads preserve their session-relative paths. Encoded derivatives change only
the file extension. Baccy rejects targets that collide among the artifacts
and landing pages planned in the same pass before it starts an encoder or
upload.

SSH destinations use the existing non-interactive SSH configuration and keys.
S3 destinations use boto3 and the host's normal AWS credential chain. Baccy
accepts `ssh:USER@[IPv6]:/absolute/path` for IPv6 hosts and
`s3:BUCKET/PREFIX` for keys under a bucket prefix. An S3 endpoint override
comes from the default profile in `~/.aws/config` (`endpoint_url`), not from
the destination string. Baccy records an artifact identity in S3 object
metadata and skips an object with the same identity. `s3_max_bandwidth` limits
all S3 uploads in bytes per second.
Credentials never appear in the TOML or event log.

## Run once

Run one complete scan and exit:

```sh
uv run baccy backup
uv run baccy --config /path/to/baccy.toml backup
uv run baccy --dry-run backup
```

The first form uses the installed daemon's recorded configuration. To run
without an installed daemon, pass `--config PATH` before the command.

The command prints one path per completed file, with status and detail for
failed or deferred work. It exits with status 1 for failures or unavailable
sources, 3 for deferred work without failures, and 0 for a complete pass. A
missing removable card or mounted share does not delete prior backups.

For recs sessions, baccy appends only newly completed lines from
`session-record.jsonl` after verifying its already backed-up prefix. WAV and
FLAC files named by an unfinished `file_started` record are deferred. They are
copied atomically once recs writes a matching `file_finished` record.

Use `-d` or `--dry-run` before the command to preview `would_copy` and
`would_upload` results without creating the backup root, lock, event log,
artifact cache, temporary files, or network connections.
`baccy -d watch` repeatedly performs the same non-writing preview.

## Repair zero frame counts

Repair historical completed-audio records whose `frame_count` is zero from the
FLAC or WAV file headers:

```sh
uv run python scripts/repair_zero_frame_counts.py ~/baccy/audio/totm
```

## Import recs sessions

Import one or more recs project directories or individual session directories:

```sh
uv run baccy --config /path/to/baccy.toml import /Volumes/Recordings/concert
uv run baccy --config /path/to/baccy.toml import /Volumes/Recordings/2026/09/24/20-00-00 --project concert
uv run baccy --config /path/to/baccy.toml import /Volumes/Recordings/concert --copy
```

By default `import` moves each session into its canonical location under the
backup root. `--copy` leaves the original directory unchanged. It uses a
session header's `project_name` first; `--project NAME` overrides it. For a
project directory whose session headers omit the project, its directory name is
used. An individual session without a header project requires `--project`.

For a project directory, the existing `YEAR/MONTH/DAY/TIMESTAMP` layout is
preserved. For an individual session, baccy derives `YEAR/MONTH/DAY` from the
session's `started_at` header and keeps its timestamp directory name. Import
does not publish anything. Run `baccy sync` after import to apply configured
upload rules.

## Sync publication

Reconcile every publishable session, or only selected project/directory paths
below the backup root:

```sh
uv run baccy sync
uv run baccy sync concert
uv run baccy sync concert/2026/09
uv run baccy sync --verify
```

`sync` lists each configured remote destination once and compares remote file
sizes with the local source files or recorded artifact sizes when available.
For S3 objects with a matching catalog record, it also checks the remote
`baccy-identity` metadata. It does not download or hash remote files. An SSH
file replaced with different content of the same size can still go undetected;
derived files without a recorded size can only be checked for nonzero size.
Normal publication trusts the local catalog and can therefore skip a remote file
that was deleted outside baccy. Run `sync` to repair missing remote files.
Use `sync --verify` to compare SHA-256 hashes of local artifacts and remote
content that would otherwise be considered unchanged, including same-size SSH
replacements. This reads those remote files and may take a long time or incur
transfer charges. It runs in the foreground,
including when `--daemon` supplies the configuration; ordinary `sync` still
schedules a daemon pass and returns promptly.

## Rename direct S3 backups

`baccy rename PATTERN REPLACEMENT` previews direct-source audio renames and
asks for confirmation. Use `--re` for a regular-expression pattern and put
`--dry-run` before `rename` for a preview without changes. The command copies
and verifies all new S3 objects before renaming local files and rewriting
session journals; it deletes the old S3 objects last. Progress is saved in
`BACKUP_ROOT/rename-progress.json`. If interrupted or failed, rerun the same
command and arguments to resume. Backups, imports, and syncs will not modify
the backup root while a rename is pending. Rename events are written as
timestamped JSON lines in `BACKUP_ROOT/events.jsonl`.

## Test upload access

Test authentication and access to every configured SSH and S3 destination
without scanning or uploading files:

```sh
uv run baccy test
uv run baccy --config /path/to/baccy.toml test
```

It prints `ok` on success. On failure, it prints each inaccessible destination
and its error to standard error, then exits with status `-1` (reported by macOS
as `255`).

## Watch in the foreground

Run repeated scans in the current terminal:

```sh
uv run baccy watch
```

`SIGINT` and `SIGTERM` stop the watcher after its current copy operation. The
watcher calls the same one-pass backup engine as `baccy backup`; it is not a
second backup implementation.

Every 10 seconds, watch reads the local ARP table for newly visible systems. It
uses non-interactive SSH and requires the host's verified key to be in the
user's SSH `known_hosts` file. Unknown or changed keys are rejected. Verify
each recording machine's host-key fingerprint independently before trusting
it; an unverified `ssh-keyscan` result alone is not proof of identity. The
machine must also accept the user's existing SSH credentials. Configured
`kind = "network"` sources are rejected because they were never resolved; use
trusted-host automatic discovery instead.
Each newly seen MAC address gets an immediate SSH attempt, with at most eight
hosts probed concurrently and, for connection failures, one retry after two
seconds and another after four seconds. A previously discovered recording
source that disappears from the ARP table is reported as unavailable.
Authentication rejections and hosts without a `~/recs` directory are not
retried until baccy restarts. A host that accepts SSH but does not yet have
`~/recs` is checked again on each later ARP scan. With `verbose = true`, baccy
logs newly recognized SSH-capable machines without sending a notification.
Multicast and broadcast ARP entries are ignored.
Qualifying `~/recs` directories are copied as network sources, with the catalog
skipping files whose remote size and modification time have not changed.

With `verbose = true`, the service log records each newly observed network
host, whether SSH failed or `~/recs` was absent, and recognized-source backup
start and completion. Watch it with:

```sh
tail -f ~/Library/Logs/baccy/baccy.log
```

When running as the installed service, baccy sends a macOS notification for a
new copy failure. It does not notify for unavailable configured sources,
unplugged removable media, or rejected network hosts. Repeated identical
failures are reported once until they recover or change.

## Start automatically after login

Install the per-user LaunchAgent:

```sh
uv run baccy --config /path/to/baccy.toml service install
uv run baccy service status
```

`baccy install` is shorthand for `baccy service install` and accepts the same
flags, including `--no-sync`.

Installation waits for the daemon to start, then schedules a sync. Pass
`--no-sync` to install without scheduling that initial sync.
On success, installation prints `ok`. Pass the global `--verbose` or `-v` flag
before the command to print the full service status instead. Build and install
output is shown only if installation fails.

The agent uses `launchd` with `RunAtLoad` and `KeepAlive`. Installation builds
an isolated release environment under `~/Library/Application Support/baccy/`,
so the service does not run code from this checkout. It starts after the user
logs in after a restart and restarts after an unexpected exit. It has the same
user access to mounted volumes and network shares as `baccy watch`.

```sh
uv run baccy service stop
uv run baccy service start
uv run baccy service restart
uv run baccy service uninstall
```

Run `uv run baccy service install` again to explicitly deploy a newer checkout.
`service restart` only restarts the installed release.

This is intentionally a per-user LaunchAgent, not a privileged LaunchDaemon.
It does not run before login. Pre-login backups would require a separate
system-wide deployment and credentials policy.

## recs sessions

baccy treats a recs session as ordinary portable files and never imports recs,
rewrites its files, or attempts finalization or migration. It preserves the
whole session-relative layout, so paths in `recording.toml` and
`session-record.jsonl` continue to be meaningful in a restored session.

Within a scan baccy copies TOML before JSONL, then copies other files. That
means `recording.toml`, `session-record.jsonl`, native MIDI, OSC, and key event
streams are backed up before large audio assets.

JSONL is handled as an append-only snapshot: baccy copies the complete prefix
that existed at the start of the copy, requires it to end on a newline, and
verifies that prefix again before committing it. This permits backing up an
active recs journal without waiting for recording to stop. TOML, audio, photos,
and other ordinary files must remain unchanged for `stability_seconds` before
they are copied.

## Backup layout and recovery

Project sessions are stored in their one canonical location:

```text
BACKUP_ROOT/audio/PROJECT/SESSION/RELATIVE_PATH
```

New recs sessions are copied directly to the canonical project directory.

Non-project assets, such as photos, remain source-specific:

```text
BACKUP_ROOT/photo/SOURCE_NAME/RELATIVE_PATH
```

At the backup root:

- `events.jsonl` records copied, uploaded, failed, and deferred files.
  A deferred file is recorded once until it is successfully copied.
- `.lock` prevents concurrent backup passes against one backup root.

Each new file is copied to a temporary file beside its destination, flushed to
disk, and atomically renamed only after the source has passed its snapshot
checks. A partial copy is never promoted to the visible destination. When a
file changes, baccy atomically replaces the existing destination.

To restore a project session, copy it from `PROJECT/SESSION/`.

## Version-one boundaries

baccy does not mount shares, connect to remote hosts, manage credentials, or
publish to a public server. Configure a mounted share as a path source instead.
It also does not preserve ownership or extended attributes in this version.

## License

`baccy` is licensed under the [MIT License](LICENSE).
