6.2 KiB
Fix bitrate = 0 Tracks — Design
Date: 2026-05-30 Status: Approved (design)
Problem
Many tracks in the library have a stored bitrate of 0. Bitrate of 0 is
displayed literally and is meaningless. Users want these tracks to show their
real bitrate, and want new imports to stop producing the problem.
Root Cause
ScannerService.extractMetadata() extracts bitrate via AVFoundation:
// Music/Services/ScannerService.swift (~line 151)
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Int(estimatedRate / 1000) // bits/sec -> kbps
For some files (observed: long / VBR MP3s such as 2-hour DJ "Essential Mix"
recordings), AVAsset.estimatedDataRate returns 0. Int(0 / 1000) is 0, so
a literal 0 is written to the DB. The duration field is extracted correctly
for the same files, which is what makes recovery reliable.
Tracks with bitrate IS NULL exist as well — the importer never produced any
value. These display as "—" and are treated as equally "missing" for this work.
Evidence
Validated against a real bitrate = 0 row (a ~120 min MP3):
| Method | Result |
|---|---|
fileSize × 8 ÷ duration ÷ 1000 |
256.0 kbps |
ffprobe -show_entries format=bit_rate |
256005 → 256.0 kbps |
The two agree to the kbps. For VBR, both methods yield the true average bitrate, which is the meaningful value.
Scope
Both halves of the problem:
- Backfill existing rows where
bitrate = 0 OR bitrate IS NULL. - Fix the importer so future imports never store
0again.
No mass rescan is required: the script repairs today's rows; the importer fix only needs to guarantee correctness for new imports.
Component 1 — Importer Fix (ScannerService)
Extract the bitrate decision into a pure, unit-testable function:
/// Resolve a track's bitrate (kbps) from the OS estimate, with a
/// file-size/duration fallback. Returns nil when no value can be derived —
/// never 0.
static func resolveBitrate(estimatedDataRate: Double,
fileSizeBytes: Int64?,
durationSeconds: Double?) -> Int?
Logic:
estimatedDataRate > 0→Int((estimatedDataRate / 1000).rounded())(current behaviour, now rounded rather than truncated).- else if
fileSizeBytes != nilanddurationSeconds != nil && > 0→Int((Double(fileSizeBytes) * 8 / durationSeconds / 1000).rounded()). - else →
nil.
Key invariant: the importer never stores 0. When nothing can be derived it
stores nil, which the UI already renders as "—".
extractMetadata() is updated to:
- read the file size it can already obtain via
FileManager.attributesOfItem(atPath:), - pass
estimatedDataRate, file size, and the loadeddurationintoresolveBitrate, - assign the result to
bitrate.
This keeps the AVFoundation I/O in extractMetadata and the arithmetic in a pure
function that tests can drive directly.
Component 2 — Backfill Script (scripts/backfill_bitrate.py)
Mirrors the conventions of the existing scripts/backfill_itunes_dates.py:
- Same
DEFAULT_DBresolution (~/Library/Containers/com.staxriver.mu/Data/ Library/Application Support/Music/db.sqlite), with--dboverride. - Reuses the
norm_path/ percent-decoding approach to turn a storedfileURLinto a POSIX path. - Dry-run by default;
--applywrites after a timestamped backup of the DB. --self-testruns offline unit checks and exits.- Stdlib only (
sqlite3,subprocess,os,urllib, …), plusffprobeas an optional external tool.
Selection
SELECT id, fileURL, duration, bitrate
FROM tracks
WHERE bitrate = 0 OR bitrate IS NULL;
Per-row bitrate determination
- Resolve
fileURL→ POSIX path. If the file does not exist on disk → report as skipped (missing file), do not update. - Run
ffprobe -v error -show_entries format=bit_rate -of default=nw=1:nk=1 <path>.- Parse an integer bps →
round(bps / 1000)kbps.
- Parse an integer bps →
- If ffprobe is absent, errors, or returns
N/A/empty, fall back to the formula:round(fileSizeBytes * 8 / durationSeconds / 1000).- If there is also no usable duration → report as skipped (undeterminable), do not update.
Output
- Dry-run: a table of
path · old → newplus a summary. --apply: createdb.sqlite.bak-<timestamp>, thenUPDATE tracks SET bitrate = ? WHERE id = ?per resolved row, in a single transaction.- Summary (both modes): counts for updated, skipped-missing-file, skipped-undeterminable, and whether ffprobe was available.
Testing (TDD — tests written before implementation)
Swift (MusicTests)
Unit tests for ScannerService.resolveBitrate:
- Positive
estimatedDataRate→ rounded kbps (e.g.320450.0→320). estimatedDataRate == 0with valid size + duration → formula result (e.g. 230_358_479 bytes, 7198.54 s →256).estimatedDataRate == 0, valid size, no/zero duration →nil.estimatedDataRate == 0, no file size →nil.- Confirms the function never returns
0.
Each test carries a step-by-step comment describing what it exercises.
Python (--self-test)
- ffprobe-output parsing:
"256005\n"→256. N/A/empty ffprobe output → triggers formula fallback.- Formula math:
(230_358_479 * 8 / 7198.54 / 1000)rounds to256. norm_pathedge cases (percent-encoding,file://localhost/, NFC, trailing slash) — mirrored from the existing script's expectations.
Manual verification
Run the script in dry-run against the real library DB and eyeball a sample
(ffprobe vs formula agreement) before --apply.
Operational Notes
--applywrites directly to the SQLite file. Quit the app first to avoid WAL/lock contention — same caveat asbackfill_itunes_dates.py.- A timestamped backup is created before any write; restore by copying it back.
Out of Scope
- No new UI (no in-app "repair bitrates" command); the script covers existing rows and the importer covers future ones.
- No change to how bitrate is displayed.
- No re-encoding or modification of audio files — read-only analysis only.