projects
Malachite
Note: The standalone malachite repository has been archived. Development continues in the
@ewanc26/pkgsmonorepo — CLI atpackages/malachite/, web frontend atpackages/malachite-web/.
Malachite is a tool for importing your Last.fm and Spotify listening history to the AT Protocol network as fm.teal.alpha.feed.play records. It's designed to be safe, resumable, and smart about rate limits — so you don't accidentally hammer your PDS.
The name is a deliberate nod to the teal lexicon it publishes to: malachite is a greenish-blue copper mineral associated with preservation and transformation, sitting squarely in that teal/green colour range.
Usage Options
Malachite comes in two forms:
Web interface — the easiest way to get started. Visit malachite.croft.click, authenticate via ATProto OAuth (recommended) or an app password, upload your export files, and import. Everything runs locally in your browser — no data is sent to any server other than your own PDS.
CLI — a Node.js command-line tool for local use. Useful if you want full control over batch settings, need to automate imports, or simply prefer the terminal. Requires cloning the repository and building from source.
Web Interface
No installation required. Open malachite.croft.click and follow the wizard:
- Choose a mode — Last.fm, Spotify, combined, sync, or deduplicate
- Sign in — via ATProto OAuth (recommended; redirects to your PDS and back, your credentials are never shared with Malachite) or an app password
- Upload your export — CSV or JSON, parsed entirely in the browser (skipped in deduplicate mode)
- Options — optionally enable dry run, reverse chronological order, or skip the Teal duplicate check
- Run — records are published directly to your PDS with automatic rate-limit handling
The web app is built with SvelteKit and uses @ewanc26/malachite as an npm dependency for all shared logic.
CLI
Prerequisites
Install and Build
# Clone the monorepo
git clone https://github.com/ewanc26/pkgs.git
cd pkgs
# Install all workspace dependencies
pnpm install
# Build the malachite CLI
pnpm --filter @ewanc26/malachite build
Authentication
The CLI supports two authentication methods. OAuth is recommended.
OAuth (recommended)
Signs in via your browser — your password is never shared with Malachite. The session is persisted at ~/.malachite/oauth.json and refreshes automatically.
# From the pkgs root
pnpm --filter @ewanc26/malachite start -- --oauth-login
This opens your browser at your PDS's OAuth authorisation screen. After approving, you're redirected back and the session is saved. Subsequent imports will use the stored session automatically — no flags required.
App password
If you prefer not to use OAuth, pass your handle and an app password directly. The credentials are encrypted and saved for future runs.
pnpm --filter @ewanc26/malachite start -- -h alice.bsky.social -p xxxx-xxxx-xxxx-xxxx -i lastfm.csv -y
Quick Start
# Sign in with OAuth first (one-time)
pnpm --filter @ewanc26/malachite start -- --oauth-login
# Then run in interactive mode (recommended for first-time use)
pnpm --filter @ewanc26/malachite start
# Or pass arguments directly — stored OAuth session is used automatically
pnpm --filter @ewanc26/malachite start -- -i lastfm.csv -y
Interactive mode walks you through everything: choosing a mode, checking for stored sessions, picking files, and setting optional flags.
Common Invocations
All commands run from the pkgs root. These assume a stored OAuth session; pass -h and -p instead if using app-password auth.
# Import from Last.fm CSV
pnpm --filter @ewanc26/malachite start -- -i lastfm.csv -y
# Import from Spotify JSON export
pnpm --filter @ewanc26/malachite start -- -i spotify-export/ -m spotify -y
# Merge both sources
pnpm --filter @ewanc26/malachite start -- -i lastfm.csv --spotify-input spotify-export/ -m combined -y
# Sync (skip already-imported records)
pnpm --filter @ewanc26/malachite start -- -i lastfm.csv -m sync -y
# Remove duplicates from your Teal feed
pnpm --filter @ewanc26/malachite start -- -m deduplicate
# Preview without publishing
pnpm --filter @ewanc26/malachite start -- -i lastfm.csv --dry-run
# List stored OAuth sessions
pnpm --filter @ewanc26/malachite start -- --list-sessions
# Sign out
pnpm --filter @ewanc26/malachite start -- --logout
# Sign out a specific session by DID
pnpm --filter @ewanc26/malachite start -- --logout -h did:plc:xxxx
Import Modes
| Mode | Flag | Description |
|---|---|---|
| Last.fm | -m lastfm (default) |
Import a Last.fm CSV export |
| Spotify | -m spotify |
Import Spotify Extended Streaming History JSON |
| Combined | -m combined |
Merge both sources with deduplication |
| Sync | -m sync |
Skip records that already exist on Teal |
| Deduplicate | -m deduplicate |
Remove duplicate records already on Teal |
Command Line Options
Authentication
| Option | Description |
|---|---|
--oauth-login |
Sign in via OAuth — opens browser, saves session (recommended) |
--logout |
Remove stored OAuth session (--handle <did> to target a specific one) |
--list-sessions |
List all stored OAuth sessions |
--handle <handle> / -h |
ATProto handle or DID (for app-password auth, or to target a specific OAuth session) |
--password <pass> / -p |
ATProto app password |
--pds <url> |
Skip identity resolution and connect to a known PDS URL directly |
did:web identifiers are supported in addition to handles and did:plc DIDs.
Input
| Option | Short | Description |
|---|---|---|
--input <path> |
-i |
Path to Last.fm CSV or Spotify JSON file/directory |
--spotify-input <path> |
Spotify export path (combined mode) |
Import
| Option | Short | Description |
|---|---|---|
--mode <mode> |
-m |
Import mode (see table above) |
--reverse |
-r |
Process newest tracks first |
--yes |
-y |
Skip confirmation prompts |
--dry-run |
Preview records without publishing | |
--aggressive |
Use 85% of the daily limit (8,500/day) instead of 75% | |
--fresh |
Ignore previous import state and cached records |
Output
| Option | Short | Description |
|---|---|---|
--verbose |
-v |
Debug-level logging |
--quiet |
-q |
Warnings and errors only |
--dev |
Verbose + file logging + smaller batches |
Maintenance
| Option | Description |
|---|---|
--clear-cache |
Clear cached Teal records for the current account |
--clear-all-caches |
Clear all cached records |
--clear-credentials |
Clear saved app-password credentials |
Getting Your Data
Last.fm: Export your scrobbles from lastfm.ghan.nl/export as a CSV.
Spotify: Go to Spotify Privacy Settings, request your "Extended streaming history" (takes up to 30 days), then use either a single Streaming_History_Audio_*.json file or the whole extracted directory. Malachite automatically filters out podcasts and non-music content.
Duplicate Prevention
Malachite has two layers of protection against duplicates:
Input deduplication — before publishing anything, it removes entries within your source file that share the same track name, artist, and timestamp.
Teal comparison via CAR export — it downloads your entire repo as a single CARv1 file using com.atproto.sync.getRepo (the sync namespace, not the AppView), parses it locally, and skips anything already imported. This costs zero AppView write-quota points. It runs automatically for every mode.
Rate Limiting
Bluesky's AppView enforces rate limits on PDS instances. Exceeding 10K records per day can rate-limit your entire PDS — affecting all users on it, not just your account.
Malachite handles this automatically:
- Monitors your rate limit quota in real-time from response headers
- Dynamically adjusts batch size between 1 and 200 records
- Maintains a 15% headroom buffer so the quota is never fully exhausted
- Hard daily cap of 7,500 records (75% safety margin)
- Pauses 24 hours between days for large imports
- Scales immediately back to maximum speed after a quota reset
File Storage
All CLI data is stored in ~/.malachite/:
~/.malachite/
├── cache/ # Cached Teal records (24-hour TTL)
├── state/ # Import state for resume support
├── logs/ # Logs when --dev is active
├── credentials.json # AES-256-GCM encrypted app-password credentials (optional)
└── oauth.json # OAuth session state (chmod 600)
App-password credentials are saved automatically after every successful login and encrypted using a key derived from your hostname and username, making them machine-specific. Clear them with --clear-credentials.
OAuth sessions are saved automatically after --oauth-login and refresh automatically when the access token expires. Clear them with --logout.
Record Format
Each scrobble is published as an fm.teal.alpha.feed.play record. Required fields are trackName, artists, playedTime, submissionClientAgent, and musicServiceBaseDomain. Last.fm imports also include MusicBrainz IDs when available.
Example Last.fm record:
{
"$type": "fm.teal.alpha.feed.play",
"trackName": "Paint My Masterpiece",
"artists": [{ "artistName": "Cjbeards", "artistMbId": "c8d4f4bf-..." }],
"releaseName": "Masquerade",
"playedTime": "2025-11-13T23:49:36Z",
"originUrl": "https://www.last.fm/music/Cjbeards/_/Paint+My+Masterpiece",
"submissionClientAgent": "malachite/v0.12.0",
"musicServiceBaseDomain": "last.fm"
}
Development
All commands run from the pkgs root:
pnpm --filter @ewanc26/malachite run type-check # Type checking
pnpm --filter @ewanc26/malachite run build # Build
pnpm --filter @ewanc26/malachite run dev # Rebuild and run
pnpm --filter @ewanc26/malachite run test # Run tests
pnpm --filter @ewanc26/malachite run clean # Clean build artifacts
# Web frontend
pnpm --filter @ewanc26/malachite-web dev # Dev server at http://127.0.0.1:5173
pnpm --filter @ewanc26/malachite-web build # Build for deployment
Lexicon
Malachite publishes to the fm.teal.alpha lexicon. The schema definitions live in /lexicons/fm.teal.alpha/ in the repository.
License
AGPL-3.0-only.
← all docs