Created
April 1, 2026 13:10
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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