Skip to content

Instantly share code, notes, and snippets.

@GeneralD
Created April 1, 2026 13:10
Show Gist options
  • Select an option

  • Save GeneralD/580787bf770800d2cef6eb51eb9e32ae to your computer and use it in GitHub Desktop.

Select an option

Save GeneralD/580787bf770800d2cef6eb51eb9e32ae to your computer and use it in GitHub Desktop.
macOS now-playing CLI — fetches current track info via MediaRemote with LRCLIB lyrics support
#!/usr/bin/env swift
// NOTE: Must run via swift interpreter — MediaRemote requires platform-signed binaries.
import Foundation
import SQLite3
extension Double {
func rounded(to place: Int) -> Double {
let factor = pow(10, Double(place))
return (self * factor).rounded() / factor
}
}
struct TimedLine: Codable {
let time: TimeInterval
let text: String
}
struct NowPlayingInfo: Codable {
let title: String?
let artist: String?
let album: String?
let duration: TimeInterval?
let elapsedTime: TimeInterval?
let lyrics: String?
let syncedLyrics: [TimedLine]?
let currentLyric: String?
}
struct LyricsResult: Codable {
let id: Int?
let trackName: String?
let artistName: String?
let albumName: String?
let duration: Double?
let instrumental: Bool?
let plainLyrics: String?
let syncedLyrics: String?
static let empty = LyricsResult(id: nil, trackName: nil, artistName: nil, albumName: nil, duration: nil, instrumental: nil, plainLyrics: nil, syncedLyrics: nil)
}
typealias SearchCandidate = (title: String, artist: String)
// MARK: - MediaRemote
struct MediaRemote {
private typealias GetNowPlayingInfoFunction =
@convention(c) (DispatchQueue, @escaping (CFDictionary?) -> Void) -> Void
func fetchNowPlaying() -> [String: Any]? {
let path = "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"
guard let handle = dlopen(path, RTLD_NOW),
let symbol = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") else { return nil }
let function = unsafeBitCast(symbol, to: GetNowPlayingInfoFunction.self)
var result: [String: Any]?
function(DispatchQueue.main) { dict in
result = dict as? [String: Any]
CFRunLoopStop(CFRunLoopGetMain())
}
CFRunLoopRun()
return result
}
}
// MARK: - LRCLIB API
struct LRCLib {
private func httpGet(_ url: URL) -> Data? {
var request = URLRequest(url: url)
request.setValue("now-playing/1.0", forHTTPHeaderField: "User-Agent")
let semaphore = DispatchSemaphore(value: 0)
var responseData: Data?
URLSession.shared.dataTask(with: request) { data, _, _ in
responseData = data
semaphore.signal()
}.resume()
semaphore.wait()
return responseData
}
private func buildURL(_ path: String, queryItems: [URLQueryItem]) -> URL? {
var components = URLComponents(string: "https://lrclib.net/api/\(path)")!
components.queryItems = queryItems
return components.url
}
func get(title: String, artist: String, duration: TimeInterval?) -> LyricsResult? {
let items = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist),
duration.map { URLQueryItem(name: "duration", value: String(Int($0))) },
].compactMap { $0 }
guard let url = buildURL("get", queryItems: items), let data = httpGet(url) else { return nil }
return try? JSONDecoder().decode(LyricsResult.self, from: data)
}
func search(query: String) -> LyricsResult? {
guard let url = buildURL("search", queryItems: [URLQueryItem(name: "q", value: query)]),
let data = httpGet(url) else { return nil }
return (try? JSONDecoder().decode([LyricsResult].self, from: data))?
.first { $0.plainLyrics != nil }
}
}
// MARK: - Title Parsing
struct TitleParser {
private let noiseWords: Set<String> = [
"mv", "pv", "official video", "official music video", "music video",
"lyric video", "lyrics video", "the first take", "audio", "official audio",
"full ver.", "full version", "short ver.", "short version",
"topic", "vevo",
]
private let bracketPatterns = [
"【[^】]*】", "「[^」]*」", "『[^』]*』",
"\\([^)]*\\)", "([^)]*)", "\\[[^\\]]*\\]",
]
func stripBrackets(_ s: String) -> String {
bracketPatterns.reduce(s) { result, pattern in
result.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}.trimmingCharacters(in: .whitespaces)
}
func isNoise(_ s: String) -> Bool {
let trimmed = s.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return true }
return noiseWords.contains(trimmed.lowercased())
}
func splitTitle(_ title: String) -> [String] {
[" - ", " / ", " | ", "|"]
.reduce([title]) { parts, sep in parts.flatMap { $0.components(separatedBy: sep) } }
.map { stripBrackets($0).trimmingCharacters(in: .whitespaces) }
.filter { !isNoise($0) }
}
func generateCandidates(title: String, artist: String) -> [SearchCandidate] {
let parts = splitTitle(title)
let cleaned = stripBrackets(title)
let artistUsable = !isNoise(artist)
let candidates: [SearchCandidate] = [
artistUsable ? [(cleaned, artist)] : [],
parts.count >= 2 ? [(parts[1], parts[0]), (parts[0], parts[1])] : [],
artistUsable ? parts.filter { $0 != cleaned }.map { ($0, artist) } : [],
parts.count == 1 && !artistUsable ? [(parts[0], "")] : [],
].flatMap { $0 }
var seen = Set<String>()
return candidates.filter { c in
seen.insert("\(c.title.lowercased())|\(c.artist.lowercased())").inserted
}
}
}
// MARK: - Lyrics Cache
struct LyricsCache {
private let db: OpaquePointer?
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
private func bindOptionalText(_ stmt: OpaquePointer?, _ index: Int32, _ value: String?) {
_ = value.map { sqlite3_bind_text(stmt, index, $0, -1, transient) } ?? sqlite3_bind_null(stmt, index)
}
private func bindOptionalDouble(_ stmt: OpaquePointer?, _ index: Int32, _ value: Double?) {
_ = value.map { sqlite3_bind_double(stmt, index, $0) } ?? sqlite3_bind_null(stmt, index)
}
private func bindOptionalInt(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int32?) {
_ = value.map { sqlite3_bind_int(stmt, index, $0) } ?? sqlite3_bind_null(stmt, index)
}
init() {
let cacheDir = URL(fileURLWithPath:
ProcessInfo.processInfo.environment["XDG_CACHE_HOME"] ?? NSHomeDirectory() + "/.cache")
var handle: OpaquePointer?
guard sqlite3_open(cacheDir.appendingPathComponent("lyrics.db").path, &handle) == SQLITE_OK else {
db = nil
return
}
db = handle
// Migrate: drop legacy single-table schema
sqlite3_exec(db, "DROP TABLE IF EXISTS lyrics", nil, nil, nil)
sqlite3_exec(db, """
CREATE TABLE IF NOT EXISTS lrclib_tracks (
id INTEGER PRIMARY KEY,
track_name TEXT,
artist_name TEXT,
album_name TEXT,
duration REAL,
instrumental INTEGER,
plain_lyrics TEXT,
synced_lyrics TEXT
)
""", nil, nil, nil)
sqlite3_exec(db, """
CREATE TABLE IF NOT EXISTS lyrics_lookup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
artist TEXT NOT NULL,
lrclib_id INTEGER NOT NULL REFERENCES lrclib_tracks(id),
UNIQUE (title, artist)
)
""", nil, nil, nil)
// Migrate: remove legacy now-playing cache directory
try? FileManager.default.removeItem(at: cacheDir.appendingPathComponent("now-playing"))
}
func read(title: String, artist: String) -> LyricsResult? {
guard let db else { return nil }
var stmt: OpaquePointer?
defer { sqlite3_finalize(stmt) }
let sql = """
SELECT t.id, t.track_name, t.artist_name, t.album_name, t.duration,
t.instrumental, t.plain_lyrics, t.synced_lyrics
FROM lyrics_lookup l JOIN lrclib_tracks t ON l.lrclib_id = t.id
WHERE l.title = ? AND l.artist = ?
"""
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
sqlite3_bind_text(stmt, 1, title, -1, transient)
sqlite3_bind_text(stmt, 2, artist, -1, transient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return LyricsResult(
id: sqlite3_column_type(stmt, 0) != SQLITE_NULL ? Int(sqlite3_column_int64(stmt, 0)) : nil,
trackName: sqlite3_column_text(stmt, 1).map(String.init(cString:)),
artistName: sqlite3_column_text(stmt, 2).map(String.init(cString:)),
albumName: sqlite3_column_text(stmt, 3).map(String.init(cString:)),
duration: sqlite3_column_type(stmt, 4) != SQLITE_NULL ? sqlite3_column_double(stmt, 4) : nil,
instrumental: sqlite3_column_type(stmt, 5) != SQLITE_NULL ? sqlite3_column_int(stmt, 5) != 0 : nil,
plainLyrics: sqlite3_column_text(stmt, 6).map(String.init(cString:)),
syncedLyrics: sqlite3_column_text(stmt, 7).map(String.init(cString:))
)
}
func write(title: String, artist: String, lyrics: LyricsResult) {
guard let db, let lrclibId = lyrics.id else { return }
var trackStmt: OpaquePointer?
defer { sqlite3_finalize(trackStmt) }
let trackSQL = """
INSERT OR REPLACE INTO lrclib_tracks
(id, track_name, artist_name, album_name, duration, instrumental, plain_lyrics, synced_lyrics)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"""
guard sqlite3_prepare_v2(db, trackSQL, -1, &trackStmt, nil) == SQLITE_OK else { return }
sqlite3_bind_int64(trackStmt, 1, Int64(lrclibId))
bindOptionalText(trackStmt, 2, lyrics.trackName)
bindOptionalText(trackStmt, 3, lyrics.artistName)
bindOptionalText(trackStmt, 4, lyrics.albumName)
bindOptionalDouble(trackStmt, 5, lyrics.duration)
bindOptionalInt(trackStmt, 6, lyrics.instrumental.map { $0 ? Int32(1) : Int32(0) })
bindOptionalText(trackStmt, 7, lyrics.plainLyrics)
bindOptionalText(trackStmt, 8, lyrics.syncedLyrics)
guard sqlite3_step(trackStmt) == SQLITE_DONE else { return }
var lookupStmt: OpaquePointer?
defer { sqlite3_finalize(lookupStmt) }
guard sqlite3_prepare_v2(db, "INSERT OR REPLACE INTO lyrics_lookup (title, artist, lrclib_id) VALUES (?, ?, ?)", -1, &lookupStmt, nil) == SQLITE_OK else { return }
sqlite3_bind_text(lookupStmt, 1, title, -1, transient)
sqlite3_bind_text(lookupStmt, 2, artist, -1, transient)
sqlite3_bind_int64(lookupStmt, 3, Int64(lrclibId))
sqlite3_step(lookupStmt)
}
}
// MARK: - Lyrics
struct LyricsFetcher {
let api = LRCLib()
let parser = TitleParser()
let cache = LyricsCache()
func search(title: String, artist: String, duration: TimeInterval?) -> LyricsResult? {
let candidates = parser.generateCandidates(title: title, artist: artist)
let exactMatch = candidates
.lazy
.filter { !$0.artist.isEmpty }
.compactMap { api.get(title: $0.title, artist: $0.artist, duration: duration) }
.first { $0.plainLyrics != nil }
if let result = exactMatch { return result }
return candidates
.lazy
.map { $0.artist.isEmpty ? $0.title : "\($0.title) \($0.artist)" }
.compactMap { api.search(query: $0) }
.first { $0.plainLyrics != nil }
}
func fetch(title: String, artist: String, duration: TimeInterval?) -> LyricsResult {
guard !artist.isEmpty else {
return search(title: title, artist: artist, duration: duration)
?? .empty
}
if let cached = cache.read(title: title, artist: artist) { return cached }
guard let result = search(title: title, artist: artist, duration: duration) else {
return .empty
}
cache.write(title: title, artist: artist, lyrics: result)
return result
}
func parseSyncedLyrics(_ raw: String) -> [TimedLine] {
let re = #/\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)/#
return raw.split(separator: "\n").compactMap { line in
guard let match = try? re.firstMatch(in: line),
let min = Double(String(match.1)),
let sec = Double(String(match.2)) else { return nil }
return TimedLine(time: (min * 60 + sec).rounded(to: 2),
text: String(match.3).trimmingCharacters(in: .whitespaces))
}
}
func currentLyric(from lines: [TimedLine], at elapsed: TimeInterval) -> String? {
lines.last { $0.time <= elapsed }?.text
}
}
// MARK: - Elapsed Time
func computeElapsed(from dict: [String: Any]) -> TimeInterval? {
guard let elapsed = dict["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval else { return nil }
let rate = dict["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1.0
guard let timestamp = dict["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date else { return elapsed }
return elapsed + rate * Date().timeIntervalSince(timestamp)
}
// MARK: - CLI
let args = Set(CommandLine.arguments.dropFirst())
if args.contains("--help") || args.contains("-h") {
print("""
Usage: now-playing [options]
Options:
-l, --lyrics Include lyrics (fetches from LRCLIB)
-h, --help Show this help
""")
exit(0)
}
let withLyrics = args.contains("--lyrics") || args.contains("-l")
let dict = MediaRemote().fetchNowPlaying() ?? [:]
let title = dict["kMRMediaRemoteNowPlayingInfoTitle"] as? String
let artist = dict["kMRMediaRemoteNowPlayingInfoArtist"] as? String
let duration = dict["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval
let lyricsFetcher = LyricsFetcher()
let lyrics: LyricsResult? = {
guard withLyrics, let title, let artist else { return nil }
return lyricsFetcher.fetch(title: title, artist: artist, duration: duration)
}()
let elapsedTime = computeElapsed(from: dict)
let syncedLines = lyrics?.syncedLyrics.map { lyricsFetcher.parseSyncedLyrics($0) }
let info = NowPlayingInfo(
title: lyrics?.trackName ?? title,
artist: lyrics?.artistName ?? artist,
album: dict["kMRMediaRemoteNowPlayingInfoAlbum"] as? String,
duration: duration.map { $0.rounded(to: 2) },
elapsedTime: elapsedTime.map { $0.rounded(to: 2) },
lyrics: lyrics?.plainLyrics,
syncedLyrics: syncedLines,
currentLyric: syncedLines.flatMap { lines in
elapsedTime.flatMap { lyricsFetcher.currentLyric(from: lines, at: $0) }
}
)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let json = try encoder.encode(info)
print(String(data: json, encoding: .utf8)!)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment