24 KiB
Fix bitrate = 0 Tracks Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stop the importer from ever storing a bitrate of 0, and backfill the real bitrate onto existing tracks where it is 0 or NULL.
Architecture: Two independent pieces. (1) A pure, unit-tested Swift function ScannerService.resolveBitrate that derives kbps from AVFoundation's estimate with a file-size/duration fallback, returning nil (never 0) when nothing is derivable; wired into extractMetadata. (2) A stdlib-only Python backfill script scripts/backfill_bitrate.py (modeled on the existing backfill_itunes_dates.py) that recomputes bitrate via ffprobe, falling back to the same formula, with dry-run default and --apply + timestamped backup.
Tech Stack: Swift (AVFoundation, swift-testing), Python 3 stdlib (sqlite3, subprocess), ffprobe (optional external tool).
Project conventions to respect:
- User's global rule: never auto-commit. Each "Commit checkpoint" step means stop and run the
/commitskill — do not rungit commitdirectly. - Tests use swift-testing (
import Testing,@Test,#expect), one struct per file, every test carries a step-by-step comment. - Swift static utilities in
ScannerServicearenonisolated static.
Spec: docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md
Task 1: ScannerService.resolveBitrate pure function (Swift, TDD)
Files:
-
Test:
MusicTests/ScannerServiceTests.swift(add tests to the existingScannerServiceTestsstruct) -
Modify:
Music/Services/ScannerService.swift(add the static function) -
Step 1: Write the failing tests
Add these five tests inside the existing struct ScannerServiceTests { ... } in MusicTests/ScannerServiceTests.swift, just before the closing brace:
// Verifies resolveBitrate uses the OS estimate when it is positive.
// 1. Passes a positive estimatedDataRate in bits/sec (320450).
// 2. Expects it rounded to kbps (320450/1000 = 320.45 -> 320), ignoring size/duration.
@Test func resolveBitrateUsesEstimateWhenPositive() {
let kbps = ScannerService.resolveBitrate(estimatedDataRate: 320_450,
fileSizeBytes: 5_000_000,
durationSeconds: 200)
#expect(kbps == 320)
}
// Verifies the size/duration fallback when the OS estimate is 0 (the AVFoundation bug).
// 1. Passes estimatedDataRate 0 with a real file size and duration.
// 2. Expects 230_358_479 * 8 / 7198.54 / 1000 -> ~256.0 -> 256 kbps (matches ffprobe).
@Test func resolveBitrateFallsBackToSizeAndDuration() {
let kbps = ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: 7198.5371428571425)
#expect(kbps == 256)
}
// Verifies nil (never 0) when the estimate is 0 and duration is unusable.
// 1. Zero duration cannot yield a value -> nil.
// 2. A NaN duration (CMTimeGetSeconds can return NaN) is also nil, not 0.
@Test func resolveBitrateReturnsNilWhenNoDuration() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: 0) == nil)
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: .nan) == nil)
}
// Verifies nil when the estimate is 0 and there is no file size.
// 1. Missing fileSizeBytes with estimate 0 -> nil (never 0).
@Test func resolveBitrateReturnsNilWhenNoFileSize() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: nil,
durationSeconds: 200) == nil)
}
// Verifies the core invariant: no input combination ever yields 0.
// 1. All-zero inputs return nil so the UI renders "—" instead of "0 kbps".
@Test func resolveBitrateNeverReturnsZero() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 0,
durationSeconds: 0) == nil)
}
- Step 2: Run the tests to verify they fail
Run:
xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' \
-only-testing:MusicTests/ScannerServiceTests 2>&1 | tail -30
Expected: compile failure — type 'ScannerService' has no member 'resolveBitrate'.
(If the destination errors, list options with xcodebuild -showdestinations -project Music.xcodeproj -scheme Music and use the macOS one.)
- Step 3: Write the minimal implementation
In Music/Services/ScannerService.swift, add this method right after the discoverAudioFiles static function (after line 35, inside the class):
/// Resolve a track's bitrate in kbps from the OS estimate, falling back to a
/// size/duration average. Returns nil when nothing can be derived — never 0,
/// so the UI shows "—" instead of a meaningless "0 kbps".
///
/// AVFoundation's `estimatedDataRate` returns 0 for some files (observed on
/// long/VBR MP3s); for those we compute the true average bitrate from the
/// file size and duration, which matches ffprobe to the kbps.
nonisolated static func resolveBitrate(estimatedDataRate: Double,
fileSizeBytes: Int64?,
durationSeconds: Double?) -> Int? {
if estimatedDataRate > 0 {
return Int((estimatedDataRate / 1000).rounded())
}
// NaN-safe: `dur > 0` is false for .nan, so we return nil rather than 0.
if let size = fileSizeBytes, size > 0,
let dur = durationSeconds, dur > 0 {
return Int((Double(size) * 8 / dur / 1000).rounded())
}
return nil
}
- Step 4: Run the tests to verify they pass
Run:
xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' \
-only-testing:MusicTests/ScannerServiceTests 2>&1 | tail -30
Expected: TEST SUCCEEDED, all ScannerServiceTests pass (the new five plus the existing discoverAudioFiles).
- Step 5: Commit checkpoint
Stop and run the /commit skill (do not git commit directly). Suggested message: feat: add ScannerService.resolveBitrate with size/duration fallback.
Task 2: Wire resolveBitrate into extractMetadata
Files:
-
Modify:
Music/Services/ScannerService.swift:146-162(the bitrate block insideextractMetadata) -
Step 1: Move the file-stats computation above the bitrate block
In extractMetadata, TrackFileStats.compute currently runs at line 162, after the bitrate block. Move it up so its fileSize is available to resolveBitrate. Replace the current block that spans from the duration load through the bitrate computation (lines 146-160):
let duration = try await asset.load(.duration)
let durationSeconds = CMTimeGetSeconds(duration)
var bitrate: Int?
var sampleRate: Int?
if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first {
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Int(estimatedRate / 1000)
let descriptions = try await audioTrack.load(.formatDescriptions)
if let desc = descriptions.first {
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) {
sampleRate = Int(asbd.pointee.mSampleRate)
}
}
}
with this:
let duration = try await asset.load(.duration)
let durationSeconds = CMTimeGetSeconds(duration)
let stats = try TrackFileStats.compute(for: url)
var bitrate: Int?
var sampleRate: Int?
if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first {
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Self.resolveBitrate(estimatedDataRate: estimatedRate,
fileSizeBytes: stats.fileSize,
durationSeconds: durationSeconds)
let descriptions = try await audioTrack.load(.formatDescriptions)
if let desc = descriptions.first {
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) {
sampleRate = Int(asbd.pointee.mSampleRate)
}
}
} else {
// No audio track loaded — still attempt the size/duration fallback
// so we never silently lose the bitrate.
bitrate = Self.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: stats.fileSize,
durationSeconds: durationSeconds)
}
- Step 2: Remove the now-duplicate
TrackFileStats.computecall
The original line 162 let stats = try TrackFileStats.compute(for: url) (now appearing just above the return Track( call) is a duplicate — delete that single line. The Track(...) initializer still references stats.fileSize, stats.dateModified, and stats.fileHash, which now come from the moved-up computation. Confirm exactly one let stats = try TrackFileStats.compute(for: url) remains in the function.
- Step 3: Build to verify it compiles
Run:
xcodebuild build -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' 2>&1 | tail -15
Expected: BUILD SUCCEEDED.
- Step 4: Re-run the full test target to confirm no regressions
Run:
xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' \
-only-testing:MusicTests 2>&1 | tail -20
Expected: TEST SUCCEEDED for the whole MusicTests target.
- Step 5: Commit checkpoint
Stop and run the /commit skill. Suggested message: fix: importer derives bitrate via resolveBitrate instead of storing 0.
Task 3: Backfill script — pure helpers + self-test (Python, TDD)
Files:
-
Create:
scripts/backfill_bitrate.py -
Step 1: Create the script with helpers and a failing self-test
Create scripts/backfill_bitrate.py with exactly this content:
#!/usr/bin/env python3
"""One-time backfill of real bitrate onto tracks stored with bitrate 0 or NULL.
ScannerService writes `bitrate = Int(estimatedDataRate / 1000)` at scan time.
AVFoundation's estimatedDataRate returns 0 for some files (long/VBR MP3s), so a
literal 0 gets stored; other tracks were imported before bitrate existed and are
NULL. This script recomputes bitrate for those rows using ffprobe, falling back
to fileSize*8/duration (the same average the app's importer now uses) when
ffprobe is unavailable or can't determine a value.
Dry-run by default. Pass --apply to write (a timestamped backup is made first).
Usage:
python3 backfill_bitrate.py [--db <path>] [--apply]
python3 backfill_bitrate.py --self-test
Stdlib only; uses ffprobe if present on PATH (optional).
"""
import argparse
import os
import shutil
import sqlite3
import subprocess
import sys
import unicodedata
from datetime import datetime
from urllib.parse import unquote
# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from
# $HOME so it resolves to the right user on whichever Mac the script runs on.
DEFAULT_DB = os.path.expanduser(
"~/Library/Containers/com.staxriver.mu/Data/Library/"
"Application Support/Music/db.sqlite"
)
def norm_path(u):
"""Reduce a file:// URL (or bare path) to a comparable, on-disk POSIX path.
The app stores `fileURL` as Foundation's url.absoluteString (a percent-encoded
file URL). Decode it, drop the file:// (or file://localhost) prefix, NFC-
normalize, and strip a trailing slash so it can be stat'd on APFS.
"""
s = u
if s.startswith("file://"):
s = s[len("file://"):]
if s.startswith("localhost/"):
s = s[len("localhost"):] # leaves the leading "/"
s = unquote(s)
s = unicodedata.normalize("NFC", s)
if len(s) > 1 and s.endswith("/"):
s = s[:-1]
return s
def parse_ffprobe_bitrate(stdout):
"""Parse ffprobe's bit_rate stdout (bits/sec) into integer kbps, or None.
Returns None for empty output, 'N/A', or any non-integer text so the caller
falls back to the formula.
"""
s = stdout.strip()
if not s or s == "N/A":
return None
try:
return round(int(s) / 1000)
except ValueError:
return None
def kbps_from_ffprobe(path):
"""Return integer kbps from ffprobe's format bit_rate, or None if unavailable.
None on: ffprobe not installed, ffprobe error, or N/A/empty/non-integer output.
"""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=bit_rate",
"-of", "default=nw=1:nk=1", path],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.SubprocessError):
return None
return parse_ffprobe_bitrate(out.stdout)
def kbps_from_formula(file_size, duration):
"""Average kbps from size (bytes) and duration (seconds): size*8/duration/1000.
Returns None when inputs can't yield a meaningful value (missing size, or
non-positive/missing duration).
"""
if not file_size or not duration or duration <= 0:
return None
return round(file_size * 8 / duration / 1000)
def resolve_bitrate(path, duration):
"""Best available kbps for an on-disk file: ffprobe first, formula fallback.
`duration` is the DB's stored seconds; file size is read from disk. Returns
None if neither method can produce a positive value.
"""
kbps = kbps_from_ffprobe(path)
if kbps and kbps > 0:
return kbps
try:
size = os.path.getsize(path)
except OSError:
size = None
return kbps_from_formula(size, duration)
def ffprobe_available():
return shutil.which("ffprobe") is not None
def self_test():
"""Fast smoke check of the pure helpers (no DB, no ffprobe needed)."""
# ffprobe stdout parsing
assert parse_ffprobe_bitrate("256005\n") == 256
assert parse_ffprobe_bitrate("N/A") is None
assert parse_ffprobe_bitrate("") is None
assert parse_ffprobe_bitrate("garbage") is None
# formula: 230_358_479 bytes over 7198.54 s -> 256 kbps (matches ffprobe sample)
assert kbps_from_formula(230_358_479, 7198.5371428571425) == 256
assert kbps_from_formula(None, 100) is None
assert kbps_from_formula(1000, 0) is None
assert kbps_from_formula(1000, None) is None
# path normalization (NFD vs NFC accents, percent-encoding, localhost host)
nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3")
nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3")
assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd)
assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3"
print("self-test OK")
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})")
p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).")
p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.")
args = p.parse_args(argv)
if args.self_test:
self_test()
return 0
if not os.path.exists(args.db):
p.error(f"DB not found: {args.db}")
run(args.db, args.apply)
return 0
if __name__ == "__main__":
sys.exit(main())
Note: run(...) is referenced by main but not yet defined — Task 4 adds it. The self-test does not call run, so --self-test works now.
- Step 2: Run the self-test to verify the helpers pass
Run:
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --self-test
Expected: self-test OK.
- Step 3: Verify the dry-run path fails cleanly (run undefined)
Run:
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --db /nonexistent.sqlite
Expected: argparse error DB not found: /nonexistent.sqlite (exit 2) — confirms arg handling before run is implemented.
- Step 4: Commit checkpoint
Stop and run the /commit skill. Suggested message: feat: add backfill_bitrate.py helpers + self-test.
Task 4: Backfill script — DB wiring, dry-run report, --apply
Files:
-
Modify:
scripts/backfill_bitrate.py(addfetch_rows,build_updates,backup_db,apply_updates,run) -
Step 1: Add the DB + reporting functions
Insert these functions into scripts/backfill_bitrate.py immediately before def main(:
def fetch_rows(db_path):
"""Return candidate rows: (id, fileURL, duration, bitrate) where bitrate is 0/NULL."""
con = sqlite3.connect(db_path)
try:
return con.execute(
"SELECT id, fileURL, duration, bitrate FROM tracks "
"WHERE bitrate = 0 OR bitrate IS NULL"
).fetchall()
finally:
con.close()
def build_updates(rows):
"""Resolve a new bitrate for each candidate row.
Returns (updates, missing, undeterminable):
- updates: list of {id, file_url, old, new} where new is a positive kbps
- missing: (id, path) for rows whose file is not on disk (left untouched)
- undeterminable: (id, path) for on-disk files whose bitrate couldn't be found
"""
updates, missing, undeterminable = [], [], []
for row_id, file_url, duration, old in rows:
path = norm_path(file_url)
if not os.path.exists(path):
missing.append((row_id, path))
continue
new = resolve_bitrate(path, duration)
if not new or new <= 0:
undeterminable.append((row_id, path))
continue
updates.append({"id": row_id, "file_url": file_url, "old": old, "new": new})
return updates, missing, undeterminable
def backup_db(db_path):
"""Copy db.sqlite (+ -wal, -shm) under backups/<timestamp>/ next to the DB."""
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp)
os.makedirs(backup_dir, exist_ok=True)
for suffix in ("", "-wal", "-shm"):
src = db_path + suffix
if os.path.exists(src):
shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src)))
return backup_dir
def apply_updates(db_path, updates):
"""Write bitrate updates in a single transaction, then checkpoint the WAL."""
con = sqlite3.connect(db_path)
try:
con.execute("BEGIN")
con.executemany("UPDATE tracks SET bitrate=:new WHERE id=:id", updates)
con.commit()
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
con.close()
def run(db_path, apply):
rows = fetch_rows(db_path)
updates, missing, undeterminable = build_updates(rows)
print(f"Candidate rows (bitrate 0 or NULL): {len(rows)}")
print(f"Resolvable (will set): {len(updates)}")
print(f"Skipped — file missing on disk: {len(missing)}")
print(f"Skipped — could not determine: {len(undeterminable)}")
if not ffprobe_available():
print("NOTE: ffprobe not on PATH — used the filesize/duration formula for all rows.")
print()
for u in updates[:15]:
name = os.path.basename(norm_path(u["file_url"]))
old = "NULL" if u["old"] is None else u["old"]
print(f" • {name}")
print(f" bitrate {old} -> {u['new']} kbps")
if len(updates) > 15:
print(f" ... and {len(updates) - 15} more")
print()
if missing[:5]:
print("Sample of skipped (file missing on disk, left untouched):")
for row_id, path in missing[:5]:
print(f" - [{row_id}] {os.path.basename(path)}")
print()
if undeterminable[:5]:
print("Sample of skipped (could not determine bitrate, left untouched):")
for row_id, path in undeterminable[:5]:
print(f" - [{row_id}] {os.path.basename(path)}")
print()
if not apply:
print("DRY RUN — nothing written. Re-run with --apply to commit these changes.")
return
if not updates:
print("Nothing to apply.")
return
backup_dir = backup_db(db_path)
print(f"Backup written to: {backup_dir}")
apply_updates(db_path, updates)
print(f"Applied {len(updates)} bitrate updates to {db_path}")
- Step 2: Re-run the self-test (ensure the new code didn't break helpers)
Run:
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --self-test
Expected: self-test OK.
- Step 3: Dry-run against a temp DB to verify end-to-end wiring
This builds a tiny throwaway DB with one real bitrate=0 row, so the run path is exercised without touching the app DB:
python3 - <<'PY'
import sqlite3, os, tempfile
d = tempfile.mkdtemp()
db = os.path.join(d, "db.sqlite")
con = sqlite3.connect(db)
con.execute("CREATE TABLE tracks (id INTEGER PRIMARY KEY, fileURL TEXT, duration REAL, bitrate INTEGER)")
# A file that does not exist -> should be reported as 'missing', not crash.
con.execute("INSERT INTO tracks (fileURL, duration, bitrate) VALUES (?,?,?)",
("file:///no/such/file.mp3", 100.0, 0))
con.commit(); con.close()
print(db)
PY
Take the printed path and run:
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --db <printed-path>
Expected: a summary with Candidate rows ... : 1, Skipped — file missing on disk: 1, and DRY RUN — nothing written. (no traceback).
- Step 4: Commit checkpoint
Stop and run the /commit skill. Suggested message: feat: backfill_bitrate.py DB wiring, dry-run report, --apply.
Task 5: Verify against the real library (manual, no code change)
Files: none.
- Step 1: Dry-run against the real app DB
Quit the Music app first (avoids WAL/lock contention). Then:
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py
Expected: a non-zero Resolvable (will set) count and a sample of bitrate 0 -> NNN kbps lines. Eyeball a few values for plausibility (typical 128–320 kbps).
- Step 2: Apply
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --apply
Expected: Backup written to: .../backups/<timestamp> then Applied N bitrate updates.
- Step 3: Confirm the DB no longer has 0/NULL bitrates (except undeterminable)
DB="$HOME/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite"
sqlite3 "$DB" "SELECT COUNT(*) AS still_zero_or_null FROM tracks WHERE bitrate = 0 OR bitrate IS NULL;"
Expected: 0, or the small count the dry-run reported as "could not determine"/"file missing".
- Step 4: Reopen the app and spot-check
Open a previously-0 track's Get Info (or the Bit Rate column) and confirm it now shows a real value.
Self-Review Notes
- Spec coverage: Root cause + invariant → Tasks 1–2 (
resolveBitrate, never stores 0). Backfill (0 and NULL, ffprobe→formula, missing-file skip, dry-run/backup/apply) → Tasks 3–4. Manual verification → Task 5. All spec sections covered. - Type consistency:
resolveBitrate(estimatedDataRate:fileSizeBytes:durationSeconds:)is defined identically in Task 1 and called identically in Task 2;stats.fileSizeisInt64(matchesfileSizeBytes: Int64?). Python helper names (parse_ffprobe_bitrate,kbps_from_ffprobe,kbps_from_formula,resolve_bitrate,fetch_rows,build_updates,backup_db,apply_updates,run) are defined once and referenced consistently. - No placeholders: every code step shows complete code; every run step shows the command and expected output.