Keep track of every song you want, own, identify, and acquire.
TuneLedger turns messy, scattered evidence of "music I want" — Spotify CSV exports, Windows Media Player playlists, and folders of music you've already ripped or downloaded — into one clean, deduplicated, persistent Artist → Album → Track manifest. It does not download anything itself — it identifies what a track actually is (via MusicBrainz), organizes it, tracks what you already have vs. still need, and lets you pick up exactly where you left off, indefinitely, across as many imports as you throw at it, from as many sources as you have.
Spotify CSV ┐
WPL playlist ├─→ normalize → identify (MusicBrainz) → deduplicate → organize → ledger → check off
Local files ┘
See Roadmap for where this is headed (Apple Music/YouTube import, automated acquisition via Lidarr).
- Quick start
- Docker
- How the CSV maps to the data model
- The four data layers
- Database schema
- Deduplication algorithm
- Sorting
- Using the app
- Import / export workflow example
- Importing additional CSVs later
- Local library scanning & WPL import
- Roadmap
- Backup & restore
- Configuration
- Tests
- Project layout
Requires Node.js 18+ (tested on Node 24). No external database server needed — everything lives in a single SQLite file.
cd app
npm install
npm startOpen http://localhost:3000. Go to the Import tab, choose your Spotify CSV, click
Import CSV. Then use the Queue tab to browse Artist → Album → Track and check things
off as you download them.
The database file is created at app/data/library.db on first run. Set DB_PATH to put it
somewhere else, and PORT to change the port:
DB_PATH=/path/to/library.db PORT=8080 npm startcd app
docker compose up --buildThis builds the image, starts the server on http://localhost:3000, and stores the
database in ./app/data on the host (bind-mounted into the container at /data), so it
survives container rebuilds. Edit docker-compose.yml if you want a different port or
data location.
The importer does not assume a fixed Spotify CSV layout. It inspects the header row and
maps known column-name variants to internal fields; anything it doesn't recognize is still
preserved (see below). This matters because "a Spotify CSV" isn't
one fixed format — this project ships with a real-world example (spotify liked songs rj.csv, an Exportify-style "Liked Songs" export) that turned out
to differ from the sample field list in the original spec: it has no Track Number or
Disc Number column, but does include per-track audio features (Danceability, Energy,
Tempo, etc.), Genres, Record Label, and a stable Track URI.
| Internal field | Accepted header spellings (case-insensitive) |
|---|---|
trackUri |
Track URI, Spotify URI, URI |
trackId |
Track ID, Spotify ID, Spotify Track ID |
trackName |
Track Name, Name, Title, Track |
albumName |
Album Name, Album |
artistNames |
Artist Name(s), Artist Names, Artist(s), Artist |
albumArtist |
Album Artist Name(s), Album Artist, Album Artist(s) |
trackNumber |
Track Number, Track #, #, Track No |
discNumber |
Disc Number, Disc |
releaseDate |
Release Date, Album Release Date |
durationMs |
Duration (ms), Duration Ms, Duration |
popularity |
Popularity |
explicit |
Explicit |
addedAt |
Added At, Date Added, Added |
genres |
Genres, Artist Genre(s), Artist Genres |
recordLabel |
Record Label, Label |
isrc |
ISRC |
Add more spellings in server/lib/importer.js's FIELD_ALIASES if you have an export
variant that uses different header text — nothing else about the pipeline needs to change,
because unrecognized columns are never discarded (they land in raw_rows and in
normalized_entries.extra_json).
If a future CSV does include Track Number/Disc Number, the importer picks them up
automatically and the queue sorts by them; when they're absent (as in the real fixture
here), tracks within an album fall back to alphabetical order rather than being left
unordered.
Per the design brief, source data is never mutated or thrown away just because it got deduplicated:
RAW SOURCE DATA raw_rows - verbatim copy of every CSV row (as JSON), forever
↓
NORMALIZED DATA normalized_entries - one parsed/cleaned row per raw row (1:1)
↓
CANONICAL TRACKS canonical_tracks - one row per distinct recording you'll download
↓
DOWNLOAD QUEUE (canonical_tracks.status + ignored)
raw_rows— every row from every import, byte-for-byte as parsed, plus aparse_status/parse_errorif something looked off (missing title, wrong column count, etc). Nothing is ever deleted from here as a side effect of deduplication.normalized_entries— the same rows after cleanup: split multi-artist fields, version-tag extraction (see below), unicode/case/punctuation-normalized sort keys, parsed numeric fields. Still one row per raw row.canonical_tracks— the actual queue. One row per song you intend to download. Multiplenormalized_entriesfold into onecanonical_tracksrow viatrack_sources, which is what lets the UI say "Duplicate occurrences: 3" without losing the individual source records.- Download queue is just
canonical_tracks.status(not_started/in_progress/downloaded/skipped/problem) plus anignoredflag — there's no separate queue table because the canonical track is the queue item.
SQLite, via better-sqlite3. Full DDL across migrations/001_init.sql,
migrations/002_local_library.sql, and
migrations/003_mark_downloaded_toggle.sql.
imports one row per CSV/scan/WPL import (filename, counts, source_type)
raw_rows → imports verbatim source rows (or a JSON snapshot of a scanned file's identify() result)
normalized_entries → raw_rows (1:1) cleaned/parsed rows - also carries file_path/media_kind/
musicbrainz_recording_id/identify_method for local-scan-sourced rows
artists canonical artist list (dedup grouping key)
albums → artists canonical album list, per artist
canonical_tracks → artists, albums THE QUEUE — one row per distinct recording
track_sources → canonical_tracks, normalized_entries duplicate traceability
status_history → canonical_tracks every status change, timestamped
manual_overrides → canonical_tracks audit log of every user-initiated edit/merge/split
possible_duplicates → canonical_tracks×2 fuzzy-stage flags awaiting human review
scan_jobs one row per directory scan / WPL import (progress, for polling)
file_moves → scan_jobs every file staged/moved to the review folder, with why
Key design choices:
- A track can have multiple sources (
track_sources, one row per contributingnormalized_entriesrow) — that's how "this song appeared on the album, a reissue, and a compilation" is represented without three queue entries. - Download status belongs to
canonical_tracks, not to any individual source, because there's exactly one thing to download regardless of how many releases it appeared on. - Manual decisions persist in
manual_overrides(an append-only audit log) and directly in the row they affected — merges, splits, preferred-source changes, field edits, ignores, and duplicate confirm/reject decisions are all logged with before/after values. possible_duplicatesusesON DELETE SET NULL(notCASCADE) on its foreign keys, so a resolved (confirmed/rejected) flag survives as history even after the tracks it referenced get merged away by that same confirmation. Only still-pending flags are pruned when a track they reference disappears.
A database from any past version of TuneLedger will always open cleanly with any future version. This is a hard rule the project follows, not just current behavior:
- Migrations are strictly additive and numbered (
001_,002_,003_, ...). A shipped migration file is never edited or deleted after release — a schema change always arrives as a new numbered file. server/db.jstracks which migrations have run in a_migrationstable and applies only the ones a given database hasn't seen yet, in order, inside a transaction, on every startup. Opening an old database with a newer build of the app just runs whatever new migrations have shipped since; opening a current database with the same version is a no-op.- New columns are added with
ALTER TABLE ... ADD COLUMNwith a sensibleDEFAULT, so existing rows (and any code that doesn't know about the new column yet) keep working unmodified. Nothing is ever silently dropped, renamed in place, or backfilled destructively. - If a future change ever genuinely requires reshaping existing data (not just adding to
it), that migration will transform data forward, in place, inside the same transaction —
never require a manual export/reimport, and never touch
raw_rows(the original source-of-truth layer - see The four data layers) since that's what makes such a transform re-derivable/auditable in the first place.
Practically: back up data/library.db before a major version bump the same way you'd back
up anything (see Backup & restore), but you should never need to
migrate data by hand between versions.
Implemented in server/lib/dedupe.js, run as a separate pass (runDedupe) after import, and
designed to be safe to re-run at any time: it only processes normalized_entries that
aren't linked to a canonical track yet, so already-decided tracks (and their download status)
are never touched or recreated.
Matching happens in stages, most confident first, all scoped to entries sharing the same canonical artist (cheap and correct, since the whole point is "same song, same artist"):
- Exact identifier — same
spotify_track_id. Mainly useful for literal re-imports (the same CSV row twice) since Spotify actually assigns different track IDs to the same recording on different releases, so this alone doesn't catch cross-release duplicates. - Exact normalized metadata — same artist + same full normalized title text + duration within 1.5s. Catches "the same song, byte-identical title, appears on two releases" (e.g. a plain "Money" on both the studio album and a compilation).
- Normalized title stem — same artist + same title with version annotations stripped
(
"Money (2011 Remaster)"→ stem"Money") + same detected version type + duration within 4s. A "Live" version and a "Remaster" have different version types and will not merge here, by design — see the "song identity vs. release identity" note below. - Fuzzy scoring — bigram Dice-coefficient similarity on the title stem, still scoped to
same artist + same version type + duration within tolerance. Scores ≥ the configured
auto-merge threshold (default
0.92) merge automatically; scores ≥ the review threshold (default0.78) get written topossible_duplicatesas "possible duplicate — review" instead of being merged or dropped. A duration gap that's otherwise disqualifying still generates a review flag (with the gap noted) rather than silently doing nothing — see theTOKYO HEAT - Club MixvsTOKYO HEAT (Tokyo Drift)case in the real fixture, which are two different mixes 59 seconds apart in length that share an identical stripped title. Titles that differ only by a number ("Track 1"vs"Track 12","Interlude 1"vs"Interlude 2") are explicitly excluded from fuzzy auto-merge, since differing digits are usually exactly what distinguishes two different tracks, and raw string similarity can't tell "close in edit distance" from "different track." - Manual resolution — the Review Duplicates tab lets you confirm or reject anything stage 4 flagged; merge/split/set-preferred are available on any track at any time (see Using the app).
Titles are parsed for trailing annotations — "Title (Annotation)", "Title [Annotation]",
or "Title - Annotation" — and classified into a fixed vocabulary: remaster, live,
acoustic, radio_edit, extended, remix, demo, instrumental, mono, stereo,
cover, single_version, edit, alternate, or other (an annotation that doesn't match
a known keyword, kept as its own type so it isn't silently treated as identical to the plain
title). This satisfies the "normalize the notation, don't blindly merge the recording"
distinction the design is built around: "Song (Remastered)", "Song - Remastered", and
"Song [Remastered]" are all recognized as the same annotation type consistently, but a
remaster is still kept as a separate canonical track from the original by default (stages
2-4 all require matching version types) — because remasters, live takes, and remixes are
often genuinely different masters/recordings, not just formatting differences. Auto-merging
them by default would risk exactly the kind of "genuinely different recording" collapse the
brief warns against; the fuzzy/review stage and manual merge are there for when they really
are close enough.
"Money" on The Dark Side of the Moon and "Money" on Echoes: The Best of Pink Floyd
resolve to one canonical track (same recording, two releases) — the compilation
appearance is retained as a second row in track_sources, not deleted. "Money",
"Money (Live)", and "Money (2011 Remaster)" remain three separate canonical tracks,
because their version types differ.
When multiple sources fold into one canonical track, the display fields (title, track/disc
number, duration, and which album the track is filed under) come from whichever source is
currently "primary." Preference order is configurable in
config/dedupe.json:
{
"releaseCategoryPreference": {
"album": 0, "deluxe": 1, "soundtrack": 2, "unknown": 2.5, "compilation": 3, "single": 4
}
}Lower wins. Release category is guessed from the album name ("Greatest Hits" →
compilation, "... (Deluxe Edition)" → deluxe, album name equal to track name → single,
etc. — see classifyReleaseCategory in server/lib/normalize.js) since the real-world CSV
this project ships with has no explicit release-type column. Ties break on earliest release
date. Edit the JSON file and restart the server to change the ranking — no code changes
needed. You can also override the choice per-track any time via "Make preferred" in the
duplicate-sources panel.
Primary ordering is always Artist → Album → Disc → Track:
- Artists and albums sort by a normalized key that strips a leading "The"/"A"/"An" so "The Beatles" files under B, not T.
- Within an album: disc number, then track number, both numeric (not string) so track 10 doesn't sort before track 2.
- When track numbers are missing entirely (as in the shipped example CSV), tracks fall back to alphabetical order by title rather than being left in import order.
- Queue tab — Artist rows expand to Album rows expand to Track rows, each lazily loaded
(only the artist list is loaded up front; album/track data is fetched on expand), so the
UI stays fast with thousands of tracks. Checkbox = toggle downloaded. A status dropdown
next to it gives access to the full
not_started/in_progress/downloaded/skipped/problemrange without cluttering the primary checkbox interaction. - Duplicate badge (
×N) — click to expand the contributing source releases for that track, with Make preferred and Split out actions. - ✎ — edit artist / album / track number / disc number. Click a track title directly to rename it inline.
- ⊘ / ↺ — ignore / restore a track (removed from progress counts without deleting it).
- Search tab — flat, filterable, paginated view across the whole library: text search, artist/album filters, status, version type, duplicates-only, missing-metadata, ignored.
- Review Duplicates tab — anything the fuzzy stage flagged instead of guessing at; confirm to merge, reject to keep separate permanently.
- Import / Import History tabs — upload a CSV, see exactly what happened (row counts, warnings for unparseable rows, dedup stats), and review past imports.
- Local Library tab — scan a directory of music you already have, or import a
.wplplaylist; see Local library scanning & WPL import.
1. Import tab → choose "spotify liked songs rj.csv" → Import CSV
→ 1603 rows read, 1600 usable, 3 warnings (blank titles - still preserved in raw data)
→ 1557 unique tracks after dedup, 43 duplicate source rows folded in, 2 flagged for review
2. Review Duplicates tab → inspect the 2 flagged pairs → confirm or reject
3. Queue tab → work through Artist → Album → Track, checking things off as you download them
4. Close the app. Reopen it later (or on another day) - everything is exactly as you left it.
5. Export tab (via API): GET /api/export.csv for a spreadsheet-friendly export, or
GET /api/export-remaining.txt for a plain "Artist/Album/## - Track" list of everything
NOT yet downloaded, suitable for feeding into another tool.
Re-importing is always safe and never creates duplicate queue entries:
- Rows that match an existing canonical track (by ID, exact metadata, or title+duration) are
linked as an additional source — the existing
downloadedstatus is never touched or reset. - Only genuinely new songs create new canonical tracks.
- Re-importing the exact same file twice is a no-op for the queue (verified by the idempotency tests) — you can safely re-run an export whenever you like without fear of bloating the list.
Existing database: 1,557 unique tracks, 312 downloaded
New CSV: 2,100 records
Result: some rows matched existing tracks (linked as extra sources, status untouched),
the rest became new canonical tracks; 312 downloaded stays 312 downloaded.
Beyond importing a Spotify CSV, the app can also scan music you already have and reconcile it against the queue — so anything you already own gets checked off automatically instead of you doing it by hand, and anything it can't recognize gets set aside for a human to look at instead of being silently ignored or misfiled.
Three independent signals are gathered for each file, then merged field-by-field (real
tags always win over inferred data) — in priority order (server/lib/identify.js):
- Embedded tags (ID3/Vorbis/MP4/ASF/etc, read via
music-metadata). If both artist and title are present, they're confirmed/canonicalized against MusicBrainz where possible (tags_mb), else trusted alone (tags_only). - Folder structure, when tags are missing or incomplete:
Artist/Album/File.extis an extremely common library layout (this app was tested against a real Windows Media Player library export using exactly this convention), and fills gaps a file's own tags leave open. - The filename, parsed against the two conventions that actually show up in real
libraries (
"NN Title - Artist"for numbered rips,"Artist - Title"for single downloaded videos) — used to fill in a missing title, or a missing artist when the folder didn't provide one either.- If some real tag data contributed (e.g. a tag had the artist but not the title),
the merged result is confirmed against MusicBrainz where possible (
assisted_mb) or trusted at a reduced-but-still-meaningful confidence (assisted_only). - If there were no usable tags at all and everything came from folder/filename, the
same applies at lower confidence (
filename_mb/filename_only).
- If some real tag data contributed (e.g. a tag had the artist but not the title),
the merged result is confirmed against MusicBrainz where possible (
- Title-only MusicBrainz search, as an absolute last resort when there's a title but
no artist from any source (tags, folder, or filename). An artist-less search is much
lower-precision, so this only accepts a very high-confidence (≥0.92), duration-
corroborated hit (
title_only_mb) — it is never used to merely guess an artist. - Unresolved — routed to manual review. Either it's music MusicBrainz genuinely doesn't know about with no filename/folder hint either, or it isn't music at all (a stray voice memo, a document that got mixed into the folder, etc).
A few real-world data problems this pipeline specifically handles (found via the ~380
example files this project was tested against, kept in test files/ outside the repo's
tracked fixtures — a real, messy, years-old Windows Media Player library export):
- Corrupt tags: one sample file had a WMA year tag of literal
2232. Implausible years (outside 1860–next year) are discarded rather than trusted. - Redundant tagging: a title tag of
"What'd I Say - Ray Charles"with a separate, correctartisttag of"Ray Charles"— the echoed artist suffix is stripped from the title. - Incomplete tags filled from folder structure: a file tagged with artist
"Alanis Morissette"but no title tag at all, sitting inAlanis Morissette/Everything - Single/Everything.wma— the title comes from the filename, the album from the folder. Folder inference deliberately requires two directory levels (Artist/Album/File) and ignores known generic folder names (Karaoke,Unknown Artist,Unknown Album— the literal folder names Windows Media Player itself creates for an unidentified CD rip) so a single-level category folder never gets mistaken for an artist. - Unlabeled-rip placeholders: filenames/titles like
"Track 2"or"Track_03"— a common artifact when ripping software finds no CDDB/metadata match — are recognized as non-titles rather than trusted at face value. - YouTube-rip noise:
"Man in the Box (Official Video)"is recognized as the same recording as"Man in the Box", not a distinct "version" —(Official Video),(Official Audio),(Lyric Video),(HD)etc. describe the file's source, not the song. - Karaoke/instrumental-backing tracks are recognized as their own distinct version type (not merged with the original vocal recording) — this library's test data included an entire folder of them.
- Deceptively similar titles: MusicBrainz text search can return a very different recording (a live take, a different mix) that happens to share an identical or near-identical title — a large duration mismatch measurably lowers confidence rather than being ignored just because the text matched perfectly.
Matching a local file against the existing queue reuses the exact same dedup engine described above, plus one additional highest-priority stage: an exact MusicBrainz recording ID match, since that identifies the same canonical recording regardless of text differences.
When a local file links to a canonical track — whether that track already existed from a CSV import or was just created by the scan — you choose what that means, per scan:
- "Mark it downloaded" (default) — you already have this, don't ask you to download it again. This is what makes local scanning function as a real cross-reference against the wishlist instead of just another list to check off by hand.
- "Link it but keep queued" — record the file as a known source (visible in the track's duplicate-sources panel) without changing its status. Use this for a quality-upgrade pass: scan a folder of low-bitrate rips you specifically want to replace, and they stay on the to-do list instead of being marked complete.
This choice is recorded per scan (scan_jobs.mark_downloaded) and per matched file
(normalized_entries.mark_downloaded_on_match), so different scans of different folders
can make different choices without affecting each other.
Local Library tab → enter a directory path → Start Scan. The whole tree is walked recursively. Files are classified by extension:
- Audio:
.mp3 .flac .m4a .aac .wav .wma .ogg .opus .alac .aiff .ape .wv - Video:
.mp4 .mkv .webm .avi .mov .m4v .flv .wmv - Anything else (album art,
.nfo,.txt, etc.) is left alone — not moved, not reported.
Scans run as a background job (MusicBrainz's usage policy caps lookups at ~1/sec, so a large library can take a while) — progress polls live in the UI and the job can be cancelled mid-scan.
Nothing is moved on disk by default. Unidentified files are staged (recorded in the
file_moves table with what would happen and why) but left in place until you click
"Apply N pending moves", or check "Move unidentified files immediately" before
starting the scan. Identified files, by contrast, are written to the database
unconditionally during the scan itself — that's a safe, reversible action (undo by
ignoring/deleting the import), unlike moving real files around on disk, which is why only
the latter defaults to a dry run.
Unidentified files move into _needs_review/ inside the scanned folder (configurable),
preserving their original relative subpath so nothing collides and origin stays traceable.
Re-scanning the same directory later automatically skips that folder, so applied moves
don't get reprocessed in a loop.
Local Library tab → Import a .wpl playlist → enter the .wpl file's path.
Windows Media Player playlists come in two genuinely different shapes:
- A static playlist lists actual files (
<media src="/api/v1/web-embed/proxy?proxyUrl=https%3A%2F%2Fgithub.com%2Fslammingprogramming%2F...&mode=full">) — each referenced file is resolved (relative paths are resolved against the playlist's own folder; Windows-style backslash paths are normalized so this also works if the app is running in a Linux Docker container) and run through the identical identify → insert → dedupe pipeline as a directory scan. A reference to a file that no longer exists on disk is reported as missing, distinctly from a file that was found but couldn't be identified. - A smart/auto playlist (e.g. "recently added, unrated" — the example
.wplthis project was built against is exactly this kind) is a saved query, not a file list. There's nothing to import; the app reports this plainly (with the rule it found) instead of silently returning zero results or erroring.
Edit config/musicbrainz.json before doing a large scan:
MusicBrainz's usage policy requires a descriptive User-Agent with a real contact method
(email or URL) for their free web service — an anonymous one can get rate-limited. No
account or API key is required for text-based recording lookup (this app does not do
acoustic/audio fingerprinting — identification is tag- and filename-based, confirmed via
MusicBrainz's metadata search, not by analyzing the audio itself).
TuneLedger's job is to be the single, durable manifest of every song you want, own, identify, and acquire — no matter how many different tools and platforms that evidence is scattered across, or how many times you re-import it. What's implemented today:
- Spotify CSV export import (any Exportify-style export - playlist, Liked Songs, etc.)
- WPL (Windows Media Player playlist) import, static and smart/dynamic
- Local file scanning: recursive directory scan, tag/folder/filename identification confirmed against MusicBrainz, with a choice per scan of whether a match completes the queue item or just links it as a known source (see Marking matches as downloaded (or not))
Planned:
- More import sources — Apple Music exports, YouTube exports/playlists (with the
non-music carved out automatically, the same way local scanning already separates music
from things that just happened to be in the folder), and other platforms as they come up.
Each new source is designed to slot into the same
raw_rows → normalized_entries → canonical_trackspipeline every existing source already uses, so it gets deduplication, cross-referencing, and the persistent manifest for free rather than needing its own parallel logic. - Automated acquisition via Lidarr — hook the queue up to a running Lidarr instance
(the
*arrapp for music, same family as Sonarr/Radarr) so TuneLedger can hand off everything stillnot_startedand let Lidarr search/grab it automatically, closing the loop from "I found evidence I want this song" all the way to "it's in Jellyfin." The queue/status model (one canonical track, one status, full history) is already shaped to support this — this is additive on the acquisition side, not a rework of the manifest.
Everything lives in one SQLite file (plus its WAL/SHM sidecar files while the server is running). To back up:
# stop the server first (or use `sqlite3 library.db ".backup backup.db"` for a live copy)
cp app/data/library.db app/data/library.db.bakTo restore, stop the server, replace app/data/library.db with your backup, and start the
server again — the migration runner is idempotent and will not touch existing data.
With Docker, the whole ./data folder on the host is the thing to back up (docker compose down first if you want a guaranteed-consistent copy, though .backup works live too).
| Env var | Default | Purpose |
|---|---|---|
DB_PATH |
app/data/library.db |
Path to the SQLite database file |
PORT |
3000 |
HTTP port |
LIBRARY_ROOTS |
unset (unrestricted) | Confine directory scans/WPL imports to these directories - see below |
Deduplication weights/thresholds: config/dedupe.json (see
Deduplication algorithm above). MusicBrainz User-Agent/
confidence threshold: config/musicbrainz.json (see
MusicBrainz setup above).
By design, directory scans and WPL imports accept any path the operator names - the same trust model as pointing Jellyfin, Sonarr, Radarr, or Lidarr at a library folder: the person calling this API is this app's own operator, not an untrusted third party, so there's no single "correct" root to confine it to by default.
If you'd rather lock a running instance down anyway - e.g. it's reachable by more than just
you - set LIBRARY_ROOTS to one or more absolute directories, separated by the OS path-list
delimiter (: on Linux/macOS, ; on Windows). Every rootPath, wplPath, and
reviewFolder must then resolve to one of those directories (or something inside them), or
the request is rejected with a 400:
LIBRARY_ROOTS="/mnt/music:/mnt/music-backup" npm start # Linux/macOS
LIBRARY_ROOTS="D:\Music;E:\Music Backup" npm start # WindowsLeaving it unset preserves today's behavior of scanning whatever directory you point it at.
See server/lib/safe-path.js for the implementation.
cd app
npm test116 tests (Node's built-in node:test runner, no extra test framework dependency) covering:
CSV parsing, unicode handling, missing/malformed fields, exact and fuzzy deduplication,
version-type preservation (remaster/live/remix/karaoke all stay separate), the
duration-mismatch guard, the numeric-token guard, disc/track/artist/album sorting, status
persistence, every manual override (merge/split/set-preferred/edit/ignore/confirm/reject),
single- and repeated-import idempotency, importing a second overlapping CSV, an HTTP-level
server smoke test, larger synthetic imports (5,000 rows) for performance/regression
coverage, tag reading and its cleanup heuristics (corrupt-year rejection, echoed-artist
stripping), folder-structure inference (and its "Unknown Artist"/single-level-folder
guardrails), filename-guessing including unlabeled-rip placeholder rejection and the
weaker MB-verification-required secondary patterns (no-space/asymmetric dashes, "Title by
Artist", underscore-wrapped titles, karaoke "in the style of X" credits), a MusicBrainz
client test suite against a fake fetch (no live network dependency, so the suite stays
fast/deterministic/offline-safe), the full identify → insert → dedupe pipeline including
the MBID matching stage, the title-only last-resort MusicBrainz fallback (accept/reject
cases), and the local-file-marks-existing-queue-item-downloaded cross-reference (both the
default "mark downloaded" behavior and the "link but keep queued" quality-upgrade-pass
toggle), WPL parsing for static playlists, single- and multi-<querySet> smart/dynamic
playlists, recursive directory scanning (including the review-folder move/apply/
re-scan-exclusion flow), and the async scan-job HTTP API.
A handful of tests are bonus sanity checks against real media files and a real Spotify
export, never checked into the repo (copyrighted/personal data) — if you don't have a
test files/ folder or a spotify liked songs rj.csv sitting next to this repo, those
tests skip cleanly rather than failing; npm test and CI never depend on them. Destructive
operations in the local-scan/WPL tests (file moves) always run against temp-directory
copies, never the real files.
app/
server/
index.js Express app entry point
db.js SQLite connection + migration runner
lib/
importer.js CSV → raw_rows + normalized_entries
normalize.js text/version normalization helpers
dedupe.js multi-stage matching engine (CSV + local-file sources)
mutations.js status changes + all manual overrides
queries.js read-side queries backing the API
exporter.js CSV / plain-text export
tags.js embedded audio/video tag reading + cleanup
filename-guess.js last-resort filename → artist/title parsing
folder-guess.js Artist/Album/File folder-structure inference
musicbrainz.js rate-limited MusicBrainz recording-search client
identify.js tags -> folder -> filename -> MusicBrainz identification pipeline
library-scanner.js recursive directory scan + safe file-move-to-review
wpl.js Windows Media Player playlist (.wpl) import
routes/
api.js REST API (CSV import, queue, search, export, overrides)
library.js REST API (directory scans, WPL import, scan-job polling)
migrations/
001_init.sql base schema
002_local_library.sql local-scan/WPL columns + scan_jobs/file_moves tables
003_mark_downloaded_toggle.sql per-scan/per-entry mark-downloaded-on-match toggle
config/
dedupe.json tunable dedup weights/thresholds
musicbrainz.json MusicBrainz User-Agent/confidence threshold
public/ frontend (vanilla HTML/CSS/JS, no build step)
fixtures/ synthetic CSVs/WPL used by the test suite
tests/ automated tests (node:test)
Dockerfile, docker-compose.yml
See CONTRIBUTING.md for local setup, the additive-only migration policy, and PR expectations. This project follows the Contributor Covenant.
Found a vulnerability? Please don't file it as a public issue - see SECURITY.md for private, verified reporting.
- MusicBrainz — the open, community-maintained recording database that TuneLedger's identification pipeline confirms tags/folder/filename guesses against. No audio fingerprinting, no account, no API key - just polite, rate-limited use of their free metadata search per their usage policy.
- Exportify — the export tool this project's CSV field-mapping was built and tested against.
- Everyone who files an issue, opens a PR, or reports a vulnerability responsibly through SECURITY.md.
AGPL-3.0-or-later. If you host a modified version of TuneLedger as a network service, the AGPL requires you to make your modified source available to the people using it
- see the license for the exact terms.