From 708d4478301d003dccddd564903fde270d2e40d6 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 08:35:07 +0900 Subject: [PATCH 01/14] config: add NSAppleMusicUsageDescription to Info.plist Required usage description string for MusicKit authorization on macOS. --- LyricFever/Support Files/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LyricFever/Support Files/Info.plist b/LyricFever/Support Files/Info.plist index bb98ca6..7ed8c19 100644 --- a/LyricFever/Support Files/Info.plist +++ b/LyricFever/Support Files/Info.plist @@ -4,6 +4,8 @@ + NSAppleMusicUsageDescription + LyricFever uses your Apple Music access to fetch synchronized lyrics for songs in Apple's catalog. SUAllowsAutomaticUpdates SUAutomaticallyUpdate From 77f501d0b5690353b69667a68288c30704692ed4 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 02:37:02 +0900 Subject: [PATCH 02/14] feat: TTMLParser for Apple Music lyrics TDD: 3 TTML fixtures (synced/plain/malformed) + failing tests first, then XMLDocument-based parser with HH:MM:SS.mmm timecode parsing. All 3 TTMLParserTests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../LyricProvider/AppleMusic/TTMLParser.swift | 54 +++++++++++++++++++ LyricFeverTests/Fixtures/ttml-malformed.xml | 1 + LyricFeverTests/Fixtures/ttml-plain.ttml | 9 ++++ LyricFeverTests/Fixtures/ttml-synced.ttml | 10 ++++ LyricFeverTests/TTMLParserTests.swift | 35 ++++++++++++ 5 files changed, 109 insertions(+) create mode 100644 LyricFever/LyricProvider/AppleMusic/TTMLParser.swift create mode 100644 LyricFeverTests/Fixtures/ttml-malformed.xml create mode 100644 LyricFeverTests/Fixtures/ttml-plain.ttml create mode 100644 LyricFeverTests/Fixtures/ttml-synced.ttml create mode 100644 LyricFeverTests/TTMLParserTests.swift diff --git a/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift b/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift new file mode 100644 index 0000000..4b07084 --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift @@ -0,0 +1,54 @@ +// +// TTMLParser.swift +// Lyric Fever +// + +import Foundation + +enum TTMLParserError: Error { + case invalidXML + case missingBody +} + +struct TTMLParser { + /// Parse Apple's TTML lyric document into LyricLine values. Synced timestamps + /// when present; otherwise startTimeMS == 0 for every line (caller can decide + /// to display them as a static block). + static func parse(_ data: Data) throws -> [LyricLine] { + let doc: XMLDocument + do { + doc = try XMLDocument(data: data) + } catch { + throw TTMLParserError.invalidXML + } + + // local-name() so we don't fight the TTML namespace prefix + let paragraphs = try doc.nodes(forXPath: "//*[local-name()='p']") + guard !paragraphs.isEmpty else { throw TTMLParserError.missingBody } + + return paragraphs.compactMap { node -> LyricLine? in + guard let el = node as? XMLElement else { return nil } + let beginAttr = el.attribute(forName: "begin")?.stringValue + let startMS = beginAttr.flatMap { parseTimecode($0) } ?? 0 + let text = (el.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + return LyricLine(startTime: TimeInterval(startMS), words: text) + } + } + + /// Parse `HH:MM:SS.mmm`, `MM:SS.mmm`, or `S.mmm` → milliseconds. + static func parseTimecode(_ s: String) -> Int? { + let parts = s.split(separator: ":").map(String.init) + let secondsStr: String + var hours = 0, minutes = 0 + switch parts.count { + case 1: secondsStr = parts[0] + case 2: minutes = Int(parts[0]) ?? 0; secondsStr = parts[1] + case 3: hours = Int(parts[0]) ?? 0; minutes = Int(parts[1]) ?? 0; secondsStr = parts[2] + default: return nil + } + guard let seconds = Double(secondsStr) else { return nil } + let total = Double(hours) * 3_600_000 + Double(minutes) * 60_000 + seconds * 1000 + return Int(total.rounded()) + } +} diff --git a/LyricFeverTests/Fixtures/ttml-malformed.xml b/LyricFeverTests/Fixtures/ttml-malformed.xml new file mode 100644 index 0000000..7468a73 --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-malformed.xml @@ -0,0 +1 @@ + valid XML at all diff --git a/LyricFeverTests/Fixtures/ttml-plain.ttml b/LyricFeverTests/Fixtures/ttml-plain.ttml new file mode 100644 index 0000000..ea48fca --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-plain.ttml @@ -0,0 +1,9 @@ + + + +
+

Line without timing

+

Another line without timing

+
+ +
diff --git a/LyricFeverTests/Fixtures/ttml-synced.ttml b/LyricFeverTests/Fixtures/ttml-synced.ttml new file mode 100644 index 0000000..81a0f37 --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-synced.ttml @@ -0,0 +1,10 @@ + + + +
+

First line of lyrics

+

Second line of lyrics

+

Third line of lyrics

+
+ +
diff --git a/LyricFeverTests/TTMLParserTests.swift b/LyricFeverTests/TTMLParserTests.swift new file mode 100644 index 0000000..9af01c2 --- /dev/null +++ b/LyricFeverTests/TTMLParserTests.swift @@ -0,0 +1,35 @@ +// +// TTMLParserTests.swift +// LyricFeverTests +// + +import XCTest +@testable import Lyric_Fever + +final class TTMLParserTests: XCTestCase { + private func fixture(_ name: String) -> Data { + let bundle = Bundle(for: type(of: self)) + let url = bundle.url(forResource: name, withExtension: nil)! + return try! Data(contentsOf: url) + } + + func test_synced_returnsLinesWithCorrectTimestamps() throws { + let lines = try TTMLParser.parse(fixture("ttml-synced.ttml")) + XCTAssertEqual(lines.count, 3) + XCTAssertEqual(lines[0].words, "First line of lyrics") + XCTAssertEqual(lines[0].startTimeMS, 12500) + XCTAssertEqual(lines[1].startTimeMS, 14500) + XCTAssertEqual(lines[2].startTimeMS, 16300) + } + + func test_plain_returnsLinesWithZeroTimestamps() throws { + let lines = try TTMLParser.parse(fixture("ttml-plain.ttml")) + XCTAssertEqual(lines.count, 2) + XCTAssertEqual(lines[0].startTimeMS, 0) + XCTAssertEqual(lines[1].startTimeMS, 0) + } + + func test_malformed_throws() { + XCTAssertThrowsError(try TTMLParser.parse(fixture("ttml-malformed.xml"))) + } +} From 2302ec78b8350b75d693851018fa6848e941ef25 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 02:42:40 +0900 Subject: [PATCH 03/14] refactor: TTMLParser quality fixes (Equatable error, empty TTML returns [], span fixture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TTMLParserError now Equatable; malformed test asserts .invalidXML specifically - Empty TTML (no

) returns [] instead of throwing .missingBody (caller-chain falls through naturally); removed unused .missingBody case - New ttml-synced-spans.ttml fixture mirrors Apple's word-level children with ttm:agent attributes; new test verifies stringValue assembles line text correctly with no XML leakage (no parser changes needed — XMLElement.stringValue concatenates child text nodes + Apple's inter-span whitespace) - Renamed local startMS -> startTimeMS; documented ms-vs-seconds convention - Expanded parseTimecode switch cases to multi-line - XCTUnwrap in fixture helper (no more force-unwraps) - Doc comment: "static block" -> "unsynced block" Co-Authored-By: Claude Sonnet 4.6 --- .../LyricProvider/AppleMusic/TTMLParser.swift | 31 ++++++++++++------- .../Fixtures/ttml-synced-spans.ttml | 14 +++++++++ LyricFeverTests/TTMLParserTests.swift | 29 ++++++++++++----- 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 LyricFeverTests/Fixtures/ttml-synced-spans.ttml diff --git a/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift b/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift index 4b07084..201037a 100644 --- a/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift +++ b/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift @@ -5,15 +5,15 @@ import Foundation -enum TTMLParserError: Error { +enum TTMLParserError: Error, Equatable { case invalidXML - case missingBody } struct TTMLParser { /// Parse Apple's TTML lyric document into LyricLine values. Synced timestamps /// when present; otherwise startTimeMS == 0 for every line (caller can decide - /// to display them as a static block). + /// to display them as an unsynced block). Returns [] for empty/instrumental + /// documents (no

elements) — the caller-chain falls through naturally. static func parse(_ data: Data) throws -> [LyricLine] { let doc: XMLDocument do { @@ -24,15 +24,16 @@ struct TTMLParser { // local-name() so we don't fight the TTML namespace prefix let paragraphs = try doc.nodes(forXPath: "//*[local-name()='p']") - guard !paragraphs.isEmpty else { throw TTMLParserError.missingBody } return paragraphs.compactMap { node -> LyricLine? in guard let el = node as? XMLElement else { return nil } let beginAttr = el.attribute(forName: "begin")?.stringValue - let startMS = beginAttr.flatMap { parseTimecode($0) } ?? 0 + let startTimeMS = beginAttr.flatMap { parseTimecode($0) } ?? 0 let text = (el.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } - return LyricLine(startTime: TimeInterval(startMS), words: text) + // Note: LyricLine.startTimeMS is TimeInterval but the codebase convention + // uses milliseconds, not seconds. parseTimecode returns ms; pass through. + return LyricLine(startTime: TimeInterval(startTimeMS), words: text) } } @@ -40,12 +41,20 @@ struct TTMLParser { static func parseTimecode(_ s: String) -> Int? { let parts = s.split(separator: ":").map(String.init) let secondsStr: String - var hours = 0, minutes = 0 + var hours = 0 + var minutes = 0 switch parts.count { - case 1: secondsStr = parts[0] - case 2: minutes = Int(parts[0]) ?? 0; secondsStr = parts[1] - case 3: hours = Int(parts[0]) ?? 0; minutes = Int(parts[1]) ?? 0; secondsStr = parts[2] - default: return nil + case 1: + secondsStr = parts[0] + case 2: + minutes = Int(parts[0]) ?? 0 + secondsStr = parts[1] + case 3: + hours = Int(parts[0]) ?? 0 + minutes = Int(parts[1]) ?? 0 + secondsStr = parts[2] + default: + return nil } guard let seconds = Double(secondsStr) else { return nil } let total = Double(hours) * 3_600_000 + Double(minutes) * 60_000 + seconds * 1000 diff --git a/LyricFeverTests/Fixtures/ttml-synced-spans.ttml b/LyricFeverTests/Fixtures/ttml-synced-spans.ttml new file mode 100644 index 0000000..d86596d --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-synced-spans.ttml @@ -0,0 +1,14 @@ + + + +

+

+ Hello + world + again +

+
+ + diff --git a/LyricFeverTests/TTMLParserTests.swift b/LyricFeverTests/TTMLParserTests.swift index 9af01c2..c95080a 100644 --- a/LyricFeverTests/TTMLParserTests.swift +++ b/LyricFeverTests/TTMLParserTests.swift @@ -7,14 +7,14 @@ import XCTest @testable import Lyric_Fever final class TTMLParserTests: XCTestCase { - private func fixture(_ name: String) -> Data { + private func fixture(_ name: String) throws -> Data { let bundle = Bundle(for: type(of: self)) - let url = bundle.url(forResource: name, withExtension: nil)! - return try! Data(contentsOf: url) + let url = try XCTUnwrap(bundle.url(forResource: name, withExtension: nil), "\(name) not found in test bundle") + return try Data(contentsOf: url) } func test_synced_returnsLinesWithCorrectTimestamps() throws { - let lines = try TTMLParser.parse(fixture("ttml-synced.ttml")) + let lines = try TTMLParser.parse(try fixture("ttml-synced.ttml")) XCTAssertEqual(lines.count, 3) XCTAssertEqual(lines[0].words, "First line of lyrics") XCTAssertEqual(lines[0].startTimeMS, 12500) @@ -23,13 +23,28 @@ final class TTMLParserTests: XCTestCase { } func test_plain_returnsLinesWithZeroTimestamps() throws { - let lines = try TTMLParser.parse(fixture("ttml-plain.ttml")) + let lines = try TTMLParser.parse(try fixture("ttml-plain.ttml")) XCTAssertEqual(lines.count, 2) XCTAssertEqual(lines[0].startTimeMS, 0) XCTAssertEqual(lines[1].startTimeMS, 0) } - func test_malformed_throws() { - XCTAssertThrowsError(try TTMLParser.parse(fixture("ttml-malformed.xml"))) + func test_malformed_throws() throws { + XCTAssertThrowsError(try TTMLParser.parse(try fixture("ttml-malformed.xml"))) { error in + XCTAssertEqual(error as? TTMLParserError, .invalidXML) + } + } + + func test_syncedWithSpans_assemblesLineText() throws { + let lines = try TTMLParser.parse(try fixture("ttml-synced-spans.ttml")) + XCTAssertEqual(lines.count, 1) + XCTAssertEqual(lines[0].startTimeMS, 20000) + // XMLElement.stringValue concatenates text nodes (including whitespace + // between spans), so we expect "Hello world again" or similar. + let text = lines[0].words + XCTAssertTrue(text.contains("Hello")) + XCTAssertTrue(text.contains("world")) + XCTAssertTrue(text.contains("again")) + XCTAssertFalse(text.contains("<"), "no XML should leak through") } } From 1fd199a128b4003eda413ae00e2652a02b2f73c2 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 02:48:33 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20AppleMusicAuthManager=20=E2=80=94?= =?UTF-8?q?=20wraps=20MusicAuthorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppleMusic/AppleMusicAuthManager.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift new file mode 100644 index 0000000..c68e419 --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift @@ -0,0 +1,35 @@ +import Foundation +import MusicKit +import Observation + +@MainActor +@Observable +final class AppleMusicAuthManager { + static let shared = AppleMusicAuthManager() + + private(set) var status: MusicAuthorization.Status = .notDetermined + private(set) var hasShownDeniedToast: Bool = false + + private init() { + self.status = MusicAuthorization.currentStatus + } + + var isAuthorized: Bool { status == .authorized } + + /// Show the system prompt. Call from `AppleMusicAuthView`. + func requestAuthorization() async { + let result = await MusicAuthorization.request() + self.status = result + } + + /// Called when a MusicDataRequest returns a 401/403 — re-prompts next track. + func invalidate() { + self.status = .notDetermined + self.hasShownDeniedToast = false + } + + /// Called by the toast UI once. + func markDeniedToastShown() { + self.hasShownDeniedToast = true + } +} From 310184eac899d4691a2c8da7003714b15cd503a4 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 02:50:31 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20AppleMusicAuthView=20=E2=80=94=20?= =?UTF-8?q?first-run=20consent=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/Views/AppleMusicAuthView.swift | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 LyricFever/Views/AppleMusicAuthView.swift diff --git a/LyricFever/Views/AppleMusicAuthView.swift b/LyricFever/Views/AppleMusicAuthView.swift new file mode 100644 index 0000000..91ad451 --- /dev/null +++ b/LyricFever/Views/AppleMusicAuthView.swift @@ -0,0 +1,55 @@ +import SwiftUI +import MusicKit + +struct AppleMusicAuthView: View { + @Environment(\.dismiss) private var dismiss + let authManager: AppleMusicAuthManager + + @State private var isRequesting = false + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "music.note") + .font(.system(size: 48)) + .foregroundStyle(.pink) + + Text("Connect Apple Music") + .font(.title2.weight(.semibold)) + + Text("LyricFever can fetch synced lyrics directly from Apple Music for songs in the catalog. This is far more accurate than third-party sources for recent releases.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 20) + + Text("Sign-in happens once and is stored by macOS in Keychain.") + .font(.caption) + .foregroundStyle(.tertiary) + + HStack { + Button("Not now") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button { + isRequesting = true + Task { + await authManager.requestAuthorization() + isRequesting = false + dismiss() + } + } label: { + if isRequesting { + ProgressView().scaleEffect(0.7) + } else { + Text("Connect") + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(isRequesting) + } + .padding(.top, 12) + } + .padding(28) + .frame(width: 380) + } +} From 865e6224cbc0cbc4442334fc716d66ee7570a4ea Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 08:36:02 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20SongObject=20schema=20v2=20?= =?UTF-8?q?=E2=80=94=20add=20appleMusicID=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight CoreData migration. Adds optional appleMusicID (String?) to SongObject so fetched lyrics can be keyed by Apple Music catalog ID (Adam ID) in addition to the existing Spotify trackID. This enables cross-player cache reuse and catalog-ID-based lookup for the AM provider. New fields: appleMusicID (optional String) --- .../Lyrics.xcdatamodeld/.xccurrentversion | 8 ++++ .../Lyrics 2.xcdatamodel/contents | 44 +++++++++++++++++++ .../CoreData/SongObjectExtensions.swift | 1 + 3 files changed, 53 insertions(+) create mode 100644 LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion create mode 100644 LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000..1424570 --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Lyrics 2.xcdatamodel + + diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents new file mode 100644 index 0000000..d134231 --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LyricFever/Models/CoreData/SongObjectExtensions.swift b/LyricFever/Models/CoreData/SongObjectExtensions.swift index 54e9007..b93c950 100644 --- a/LyricFever/Models/CoreData/SongObjectExtensions.swift +++ b/LyricFever/Models/CoreData/SongObjectExtensions.swift @@ -22,6 +22,7 @@ extension SongObject { @NSManaged public var language: String @NSManaged public var lyricsWords: [String] @NSManaged public var lyricsTimestamps: [TimeInterval] + @NSManaged public var appleMusicID: String? } From bc3378d6f5dee8ec7f90bd8e0e49dfdfa3d77b18 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 03:34:18 +0900 Subject: [PATCH 07/14] feat: capture Apple Music catalog + album IDs from MediaRemote (Task 6) - Pin koto9x/mediaremote-adapter to 6825821 (feat/expose-adam-ids branch) which adds contentItemIdentifier + albumiTunesStoreAdamIdentifier to TrackInfo.Payload, plus the missing CFSTR definitions that fix the linker. - Add lastObservedCatalogID / lastObservedAlbumCatalogID vars to AppleMusicPlayer, populated from the MediaRemote payload in ViewModel's onTrackInfoReceived callback. - Fix MediaController() call site: bundleIdentifier param was removed in adapter commit b8ce5d1 ("remove broken bundle ID filtering"). Co-Authored-By: Claude Opus 4.7 --- Lyric Fever.xcodeproj/project.pbxproj | 6 +-- .../Players/AppleMusic/AppleMusicPlayer.swift | 11 +++- LyricFever/ViewModel.swift | 51 +++++++++++++++---- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/Lyric Fever.xcodeproj/project.pbxproj b/Lyric Fever.xcodeproj/project.pbxproj index 1026c9e..3998888 100644 --- a/Lyric Fever.xcodeproj/project.pbxproj +++ b/Lyric Fever.xcodeproj/project.pbxproj @@ -552,10 +552,10 @@ }; 8F2922BE2E94DE0700B286FC /* XCRemoteSwiftPackageReference "mediaremote-adapter" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ejbills/mediaremote-adapter"; + repositoryURL = "https://github.com/koto9x/mediaremote-adapter"; requirement = { - branch = master; - kind = branch; + kind = revision; + revision = 682582165dbf60294604a5ee66e1826b18d4f250; }; }; 8F37323B2E6EC9C90075CDA7 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { diff --git a/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift b/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift index 4fe4b81..da353e1 100644 --- a/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift +++ b/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift @@ -94,8 +94,17 @@ class AppleMusicPlayer: Player { appleMusicScript?.nextTrack?() } + /// Most recently observed Apple Music catalog ID (Adam ID), sourced from + /// MediaRemote's now-playing payload. Nil for tracks not in the catalog + /// (imported MP3s, audiobooks, etc). + var lastObservedCatalogID: String? + + /// Most recently observed album catalog ID. Nil if the payload didn't + /// surface one or the album isn't in the catalog. + var lastObservedAlbumCatalogID: String? + var artworkImage: NSImage? - + // var artworkImage: NSImage? { // guard let artworkImage = (appleMusicScript?.currentTrack?.artworks?().firstObject as? MusicArtwork)?.data else { // print("AppleMusicPlayer artworkImage: nil data") diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 7eb6658..18fe455 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -24,7 +24,8 @@ import MediaRemoteAdapter static let shared = ViewModel() // Apple Music Tahoe broken AppleScript workaround - let musicController = MediaController(bundleIdentifier: "com.apple.Music") + // bundleIdentifier param removed from MediaController.init in adapter b8ce5d1 + let musicController = MediaController() // var appleMusicUniqueIdentifier: String? var currentlyPlaying: String? @@ -59,17 +60,49 @@ import MediaRemoteAdapter guard self.currentPlayer == .appleMusic else { return } - guard let artwork = data?.payload.artwork else { - if self.currentlyPlaying == nil { - self.artworkImage = nil - } - print("Apple Music Artwork Workaround: Ignoring No Artwork") + guard let payload = data?.payload else { return } + guard payload.applicationName == "Music" else { return } - guard data?.payload.applicationName == "Music" else { - return + // ── Source-of-truth track-change detection ────────────── + // MediaRemoteAdapter delivers the freshest "what's actually + // playing" snapshot — way more reliable than Music.app's + // com.apple.Music.playerInfo notification, which can lag or + // drop entirely on track transitions. If the title in the + // payload doesn't match our cached currentlyPlayingName, kick + // a re-detect through appleMusicNetworkFetch (which re-reads + // Music.app via AppleScript and re-maps to Spotify ID). + if let payloadTitle = payload.title, + !payloadTitle.isEmpty, + payloadTitle != self.currentlyPlayingName { + print("MediaRemote: track change detected (\(self.currentlyPlayingName ?? "nil") → \(payloadTitle)) — forcing chain refresh") + self.currentlyPlayingName = payloadTitle + self.currentlyPlayingArtist = payload.artist + self.currentAlbumName = payload.album + if let durationMicros = payload.durationMicros { + self.duration = Int(durationMicros / 1000) + } + // Refresh persistent ID directly off AppleScript — it + // catches up by the time MediaRemote fires (slightly + // after the playerInfo notification path). + let freshPID = self.appleMusicPlayer.persistentID + if let freshPID, freshPID != self.currentlyPlayingAppleMusicPersistentID { + self.currentlyPlayingAppleMusicPersistentID = freshPID + } + // Capture Apple Music catalog (Adam) IDs from MediaRemote payload. + // Nil for local files, audiobooks, or non-catalog tracks. + self.appleMusicPlayer.lastObservedCatalogID = payload.contentItemIdentifier + self.appleMusicPlayer.lastObservedAlbumCatalogID = payload.albumiTunesStoreAdamIdentifier + Task { + await self.appleMusicStarter() + } + } + // ── Artwork ───────────────────────────────────────────── + if let artwork = payload.artwork { + self.artworkImage = artwork + } else if self.currentlyPlaying == nil { + self.artworkImage = nil } - self.artworkImage = artwork } // This will only be called for Apple Music events } From 80cc777265cbe683bdcee4c0438c82076278a29e Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 03:39:32 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20AppleMusicLyricProvider=20?= =?UTF-8?q?=E2=80=94=20MusicKit-backed=20lyric=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../AppleMusic/AppleMusicLyricProvider.swift | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift new file mode 100644 index 0000000..da26702 --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift @@ -0,0 +1,110 @@ +// +// AppleMusicLyricProvider.swift +// Lyric Fever +// +// Top-tier lyric source for tracks playing through Music.app that have a +// known Apple Music catalog (Adam) ID. Returns Apple's TTML parsed into +// LyricLine values via TTMLParser. +// + +import Foundation +import MusicKit + +@MainActor +final class AppleMusicLyricProvider: LyricProvider { + let providerName: String = "apple_music" + + /// `trackID` is expected to be the Apple Music catalog ID captured by + /// AppleMusicMetadataObserver. If empty we return an empty result rather + /// than throwing, so the ViewModel chain falls through to the next provider. + func fetchNetworkLyrics( + trackName: String, + trackID: String, + currentlyPlayingArtist: String?, + currentAlbumName: String? + ) async throws -> NetworkFetchReturn { + guard !trackID.isEmpty else { + print("AppleMusicLyricProvider: no catalog ID, skipping") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + let auth = AppleMusicAuthManager.shared + guard auth.isAuthorized else { + print("AppleMusicLyricProvider: not authorized, skipping") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + // MusicDataRequest handles developer token + user token injection. + // {{storefront}} is substituted by MusicKit with the signed-in user's + // storefront code (e.g. "us", "jp"). If MusicKit doesn't substitute it, + // fall back to the literal "us" in the caller-fix path. + guard let url = URL(string: "https://api.music.apple.com/v1/catalog/{{storefront}}/songs/\(trackID)/lyrics") else { + print("AppleMusicLyricProvider: could not construct URL for trackID=\(trackID)") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + let request = MusicDataRequest(urlRequest: URLRequest(url: url)) + let response: MusicDataResponse + + do { + response = try await request.response() + } catch { + print("AppleMusicLyricProvider: MusicDataRequest error: \(error)") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + let statusCode = response.urlResponse.statusCode + guard statusCode == 200 else { + print("AppleMusicLyricProvider: HTTP \(statusCode) for trackID=\(trackID)") + if statusCode == 401 || statusCode == 403 { + auth.invalidate() + } + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + // Apple wraps TTML in: { "data": [ { "attributes": { "ttml": " [SongResult] { + return [] + } +} From 7c3b121f1d62ce4baa2154b8621a510000a83f9f Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 03:51:37 +0900 Subject: [PATCH 09/14] feat: AM provider in chain; CoreData lookup by appleMusicID; none_found cache Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/ViewModel.swift | 51 ++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 18fe455..14320e6 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -302,10 +302,23 @@ import MediaRemoteAdapter var lRCLyricProvider = LRCLIBLyricProvider() var netEaseLyricProvider = NetEaseLyricProvider() #if os(macOS) + @ObservationIgnored lazy var appleMusicLyricProvider = AppleMusicLyricProvider() var localFileUploadProvider = LocalFileUploadProvider() #endif - @ObservationIgnored lazy var allNetworkLyricProviders: [LyricProvider] = [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] - + var allNetworkLyricProviders: [LyricProvider] { + #if os(macOS) + if currentPlayer == .appleMusic, + let amID = appleMusicPlayer.lastObservedCatalogID, !amID.isEmpty, + AppleMusicAuthManager.shared.isAuthorized { + return [appleMusicLyricProvider, spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] + } + return [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] + #else + return [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] + #endif + } + + // custom order because LRCLIB is tweaking for the time being @ObservationIgnored lazy var allNetworkLyricProvidersForSearch: [LyricProvider] = [spotifyLyricProvider, netEaseLyricProvider, lRCLyricProvider] @@ -398,12 +411,19 @@ import MediaRemoteAdapter for networkLyricProvider in allNetworkLyricProviders { do { print("FetchAllNetworkLyrics: fetching from \(networkLyricProvider.providerName)") - let lyrics = try await networkLyricProvider.fetchNetworkLyrics(trackName: currentlyPlayingName, trackID: currentlyPlaying, currentlyPlayingArtist: currentlyPlayingArtist, currentAlbumName: currentAlbumName) + let providerTrackID: String = { + if networkLyricProvider.providerName == "apple_music" { + return appleMusicPlayer.lastObservedCatalogID ?? "" + } + return currentlyPlaying + }() + let lyrics = try await networkLyricProvider.fetchNetworkLyrics(trackName: currentlyPlayingName, trackID: providerTrackID, currentlyPlayingArtist: currentlyPlayingArtist, currentAlbumName: currentAlbumName) if !lyrics.lyrics.isEmpty { amplitude.track(eventType: "\(networkLyricProvider.providerName) Fetch") print("FetchAllNetworkLyrics: returning lyrics from \(networkLyricProvider.providerName)") // thats how i save to coredata - let _ = SongObject(from: lyrics.lyrics, with: coreDataContainer.viewContext, trackID: currentlyPlaying, trackName: currentlyPlayingName) + let song = SongObject(from: lyrics.lyrics, with: coreDataContainer.viewContext, trackID: currentlyPlaying, trackName: currentlyPlayingName) + song.appleMusicID = appleMusicPlayer.lastObservedCatalogID saveCoreData() return lyrics } else if networkLyricProvider is SpotifyLyricProvider { @@ -416,6 +436,7 @@ import MediaRemoteAdapter print("Caught exception on \(networkLyricProvider.providerName): \(error)") } } + saveCoreData() return NetworkFetchReturn(lyrics: [], colorData: nil) } @@ -954,7 +975,27 @@ import MediaRemoteAdapter func fetchLyrics(for trackID: String, _ trackName: String, checkCoreDataFirst: Bool) async throws -> [LyricLine] { let initiatingTrackID = trackID - if checkCoreDataFirst, let lyrics = fetchFromCoreData(for: trackID) { + // AppleMusic catalog-ID CoreData lookup: when the player is Apple Music + // and we have a catalog ID, check by appleMusicID first — this covers the + // case where the same track was previously fetched under a different + // Spotify/Plexamp trackID but is now playing via Apple Music. + if checkCoreDataFirst, + let amID = appleMusicPlayer.lastObservedCatalogID, !amID.isEmpty { + let request = SongObject.fetchRequest() + request.predicate = NSPredicate(format: "appleMusicID == %@", amID) + if let existing = try? coreDataContainer.viewContext.fetch(request).first, + !existing.lyricsWords.isEmpty { + let lyrics = zip(existing.lyricsTimestamps, existing.lyricsWords).map { LyricLine(startTime: $0, words: $1) } + try Task.checkCancellation() + amplitude.track(eventType: "CoreData Fetch (appleMusicID)") + if initiatingTrackID != self.currentlyPlaying { + throw FetchError.staleTrack + } + return lyrics + } + } + + if checkCoreDataFirst, let lyrics = fetchFromCoreData(for: trackID), !lyrics.isEmpty { print("ViewModel FetchLyrics: got lyrics from core data :D \(trackID) \(trackName)") try Task.checkCancellation() amplitude.track(eventType: "CoreData Fetch") From ba10fc7c8f4d52223257bd1b629e74c9a84e8259 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 03:56:09 +0900 Subject: [PATCH 10/14] feat: auto-show Apple Music auth sheet + denied-state toast Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/LyricFever.swift | 13 +++++++++++++ LyricFever/ViewModel.swift | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/LyricFever/LyricFever.swift b/LyricFever/LyricFever.swift index 6ee4903..be0aab9 100644 --- a/LyricFever/LyricFever.swift +++ b/LyricFever/LyricFever.swift @@ -92,6 +92,19 @@ struct LyricFever: App { .animation(.easeIn(duration: 0.2)) .environment(viewmodel) } + .sheet(isPresented: $viewmodel.showAppleMusicAuthSheet) { + AppleMusicAuthView(authManager: AppleMusicAuthManager.shared) + } + .alert("Apple Music access disabled", isPresented: $viewmodel.showAppleMusicDeniedToast) { + Button("Open System Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Media") { + NSWorkspace.shared.open(url) + } + } + Button("Dismiss", role: .cancel) { } + } message: { + Text("LyricFever can't fetch Apple Music's synced lyrics until you grant access in System Settings → Privacy & Security → Media & Apple Music.") + } .onAppear { viewmodel.onAppear(openWindow) } diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 14320e6..6430bfa 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -96,6 +96,20 @@ import MediaRemoteAdapter Task { await self.appleMusicStarter() } + // ── Auth prompts (once per install) ───────────────────── + // Only fire for Apple Music player — catalog ID is already + // captured above so we know this is a real streaming track. + if !self.hasOfferedAppleMusicAuth, + AppleMusicAuthManager.shared.status == .notDetermined { + self.hasOfferedAppleMusicAuth = true + self.showAppleMusicAuthSheet = true + } + if !AppleMusicAuthManager.shared.hasShownDeniedToast, + (AppleMusicAuthManager.shared.status == .denied || + AppleMusicAuthManager.shared.status == .restricted) { + AppleMusicAuthManager.shared.markDeniedToastShown() + self.showAppleMusicDeniedToast = true + } } // ── Artwork ───────────────────────────────────────────── if let artwork = payload.artwork { @@ -213,6 +227,9 @@ import MediaRemoteAdapter var chineseConversionLyrics: [String] = [] var translatedLyric: [String] = [] var showLyrics = true + var showAppleMusicAuthSheet: Bool = false + var hasOfferedAppleMusicAuth: Bool = false + var showAppleMusicDeniedToast: Bool = false #if os(macOS) var fullscreen = false var spotifyConnectDelay: Bool = false From 9c98cca095f5620835bc70cd8de7106e4c096820 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 04:12:08 +0900 Subject: [PATCH 11/14] fix: Apple Music auth uses Window scene, not sheet (MenuBarExtra context) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sheet attached to a MenuBarExtra label view is a silent no-op on macOS — the label has no NSWindow host. Route the auth trigger through an openWindow(id:) call to a dedicated Window scene, matching the existing onboarding/search/update window pattern. The auth trigger + denied alert are pulled into an AppleMusicAuthModifier to keep the MenuBarExtra label's modifier chain (and thereby the @SceneBuilder body) inside SwiftUI's type-check budget. Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/LyricFever.swift | 78 +++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/LyricFever/LyricFever.swift b/LyricFever/LyricFever.swift index be0aab9..fee8f12 100644 --- a/LyricFever/LyricFever.swift +++ b/LyricFever/LyricFever.swift @@ -92,19 +92,7 @@ struct LyricFever: App { .animation(.easeIn(duration: 0.2)) .environment(viewmodel) } - .sheet(isPresented: $viewmodel.showAppleMusicAuthSheet) { - AppleMusicAuthView(authManager: AppleMusicAuthManager.shared) - } - .alert("Apple Music access disabled", isPresented: $viewmodel.showAppleMusicDeniedToast) { - Button("Open System Settings") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Media") { - NSWorkspace.shared.open(url) - } - } - Button("Dismiss", role: .cancel) { } - } message: { - Text("LyricFever can't fetch Apple Music's synced lyrics until you grant access in System Settings → Privacy & Security → Media & Apple Music.") - } + .modifier(AppleMusicAuthModifier(viewmodel: viewmodel, openWindow: openWindow)) .onAppear { viewmodel.onAppear(openWindow) } @@ -249,7 +237,30 @@ struct LyricFever: App { } } .windowResizability(.contentSize) - Window("Lyric Fever: Update 2.3", id: "update") { // << here !! + auxiliaryScenes + } + + // Extracted to keep `body` under SwiftUI's @SceneBuilder type-check budget. + // The body was already at the limit; adding a 6th inline scene tipped it + // into "unable to type-check this expression in reasonable time." Bundling + // the auth + update windows together drops the body's scene count back to 5. + @SceneBuilder + private var auxiliaryScenes: some Scene { + Window("Connect Apple Music", id: "applemusic-auth") { + AppleMusicAuthView(authManager: AppleMusicAuthManager.shared) + .environment(viewmodel) + .preferredColorScheme(.dark) + .onAppear { + NSApp.setActivationPolicy(.regular) + } + .onDisappear { + if !viewmodel.fullscreen { + NSApp.setActivationPolicy(.accessory) + } + } + } + .windowResizability(.contentSize) + Window("Lyric Fever: Update 2.3", id: "update") { UpdateWindow().frame(minWidth: 700, maxWidth: 700, alignment: .center) .environment(viewmodel) .preferredColorScheme(.dark) @@ -262,9 +273,42 @@ struct LyricFever: App { } } } - .windowResizability(.contentSize) - .windowStyle(.hiddenTitleBar) - .windowLevel(.floating) + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .windowLevel(.floating) + } +} + +/// Pulled off the MenuBarExtra label's modifier chain so the @SceneBuilder body +/// can type-check in reasonable time. Bundles the auth-sheet trigger (routes to +/// the `applemusic-auth` Window scene) and the denied-state alert. Sheet-style +/// presentation doesn't work in a MenuBarExtra context — there's no NSWindow +/// host for it to attach to — so we openWindow instead. +private struct AppleMusicAuthModifier: ViewModifier { + @Bindable var viewmodel: ViewModel + let openWindow: OpenWindowAction + + func body(content: Content) -> some View { + content + .onChange(of: viewmodel.showAppleMusicAuthSheet) { _, newValue in + if newValue { + NSApp.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + openWindow(id: "applemusic-auth") + // Reset so the trigger can re-arm if the user dismisses without authorizing. + viewmodel.showAppleMusicAuthSheet = false + } + } + .alert("Apple Music access disabled", isPresented: $viewmodel.showAppleMusicDeniedToast) { + Button("Open System Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Media") { + NSWorkspace.shared.open(url) + } + } + Button("Dismiss", role: .cancel) { } + } message: { + Text("LyricFever can't fetch Apple Music's synced lyrics until you grant access in System Settings → Privacy & Security → Media & Apple Music.") + } } } From 8ba81d673211766fecd0d54a106dde8dc0f6f53c Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 08:47:06 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20SongObject=20schema=20v3=20?= =?UTF-8?q?=E2=80=94=20add=20albumID=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional albumID: String? to SongObject (lightweight migration). Used by AppleMusicPrefetcher to group cached lyrics by album and avoid re-fetching tracks already warmed for the current album. Depends on: schema v2 (appleMusicID, from Apple Music PR) --- .../Lyrics.xcdatamodeld/.xccurrentversion | 2 +- .../Lyrics 3.xcdatamodel/contents | 45 +++++++++++++++++++ .../CoreData/SongObjectExtensions.swift | 1 + 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion index 1424570..a91fe48 100644 --- a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Lyrics 2.xcdatamodel + Lyrics 3.xcdatamodel diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents new file mode 100644 index 0000000..f98e371 --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LyricFever/Models/CoreData/SongObjectExtensions.swift b/LyricFever/Models/CoreData/SongObjectExtensions.swift index b93c950..72b35a0 100644 --- a/LyricFever/Models/CoreData/SongObjectExtensions.swift +++ b/LyricFever/Models/CoreData/SongObjectExtensions.swift @@ -23,6 +23,7 @@ extension SongObject { @NSManaged public var lyricsWords: [String] @NSManaged public var lyricsTimestamps: [TimeInterval] @NSManaged public var appleMusicID: String? + @NSManaged public var albumID: String? } From 85e8ecc4b6afadeddd9d9e49c0da38761c15d07b Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 04:21:26 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20AppleMusicPrefetcher.warmAlbum=20?= =?UTF-8?q?=E2=80=94=20album-wide=20background=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On every Apple Music track change (when authorized + albumID known), fire-and-forget warmAlbum enumerates album tracks via MusicKit catalog API, diffs against CoreData, and prefetches lyrics for uncached tracks with a bounded TaskGroup (cap=4). All failures are silent; demand-fetch on next play recovers. Co-Authored-By: Claude Sonnet 4.6 --- .../AppleMusic/AppleMusicPrefetcher.swift | 137 ++++++++++++++++++ LyricFever/ViewModel.swift | 14 ++ 2 files changed, 151 insertions(+) create mode 100644 LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift new file mode 100644 index 0000000..5a29a7f --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift @@ -0,0 +1,137 @@ +// +// AppleMusicPrefetcher.swift +// Lyric Fever +// +// Background album-wide lyric prefetcher for Apple Music. On every track +// change, warmAlbum enumerates the album's tracks via the MusicKit catalog +// API, diffs against CoreData, and fetches lyrics for any uncached track using +// a bounded TaskGroup (cap = 4 concurrent fetches). All failures are silent — +// demand-fetch on the next play of an uncached track recovers automatically. +// + +import Foundation +import MusicKit +import CoreData + +actor AppleMusicPrefetcher { + private let container: NSPersistentContainer + private let provider: AppleMusicLyricProvider + private let concurrencyCap = 4 + + init(container: NSPersistentContainer, provider: AppleMusicLyricProvider) { + self.container = container + self.provider = provider + } + + /// Fetch lyrics for every track on the album that isn't already cached. + func warmAlbum(albumID: String) async { + guard await AppleMusicAuthManager.shared.isAuthorized else { return } + guard !albumID.isEmpty else { return } + + // 1. Enumerate album tracks via MusicDataRequest + let albumTracks = await enumerateAlbumTracks(albumID: albumID) + guard !albumTracks.isEmpty else { return } + + // 2. Diff against CoreData + let alreadyCached = await cachedAppleMusicIDs(in: albumTracks.map { $0.id }) + let toFetch = albumTracks.filter { !alreadyCached.contains($0.id) } + guard !toFetch.isEmpty else { return } + + print("AppleMusicPrefetcher.warmAlbum: \(toFetch.count) new tracks for album \(albumID)") + + // 3. Bounded TaskGroup with concurrency cap + await withTaskGroup(of: Void.self) { group in + var active = 0 + var iter = toFetch.makeIterator() + while let next = iter.next() { + if active >= concurrencyCap { + await group.next() + active -= 1 + } + group.addTask { [provider] in + let result = try? await provider.fetchNetworkLyrics( + trackName: next.name, + trackID: next.id, + currentlyPlayingArtist: next.artistName, + currentAlbumName: next.albumName + ) + await self.persist(result: result, albumTrack: next, albumID: albumID) + } + active += 1 + } + } + } + + // MARK: - Helpers + + private struct AlbumTrack { + let id: String + let name: String + let artistName: String? + let albumName: String? + } + + private func enumerateAlbumTracks(albumID: String) async -> [AlbumTrack] { + guard let url = URL(string: "https://api.music.apple.com/v1/catalog/{{storefront}}/albums/\(albumID)?include=tracks") else { + return [] + } + let request = MusicDataRequest(urlRequest: URLRequest(url: url)) + do { + let response = try await request.response() + guard response.urlResponse.statusCode == 200 else { return [] } + struct AlbumEnvelope: Decodable { + struct Datum: Decodable { + struct Relationships: Decodable { + struct Tracks: Decodable { + struct Item: Decodable { + struct Attributes: Decodable { + let name: String + let artistName: String? + let albumName: String? + } + let id: String + let attributes: Attributes + } + let data: [Item] + } + let tracks: Tracks + } + let relationships: Relationships + } + let data: [Datum] + } + let envelope = try JSONDecoder().decode(AlbumEnvelope.self, from: response.data) + return envelope.data.first?.relationships.tracks.data.map { + AlbumTrack( + id: $0.id, + name: $0.attributes.name, + artistName: $0.attributes.artistName, + albumName: $0.attributes.albumName + ) + } ?? [] + } catch { + return [] + } + } + + @MainActor + private func cachedAppleMusicIDs(in candidates: [String]) -> Set { + let ctx = container.viewContext + let request = SongObject.fetchRequest() + request.predicate = NSPredicate(format: "appleMusicID IN %@", candidates) + let existing = (try? ctx.fetch(request)) ?? [] + return Set(existing.compactMap { $0.appleMusicID }) + } + + @MainActor + private func persist(result: NetworkFetchReturn?, albumTrack: AlbumTrack, albumID: String) { + let ctx = container.viewContext + let lines = result?.lyrics ?? [] + let song = SongObject(from: lines, with: ctx, trackID: albumTrack.id, trackName: albumTrack.name) + song.appleMusicID = albumTrack.id + song.albumID = albumID + song.sourceProvider = lines.isEmpty ? "none_found" : "apple_music" + song.userPicked = false + try? ctx.save() + } +} diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 6430bfa..59a134f 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -110,6 +110,16 @@ import MediaRemoteAdapter AppleMusicAuthManager.shared.markDeniedToastShown() self.showAppleMusicDeniedToast = true } + // ── Album-wide background lyric prefetch ───────────────── + if let albumID = self.appleMusicPlayer.lastObservedAlbumCatalogID, + !albumID.isEmpty, + self.currentPlayer == .appleMusic, + AppleMusicAuthManager.shared.isAuthorized { + Task.detached { [weak self] in + guard let self else { return } + await self.appleMusicPrefetcher.warmAlbum(albumID: albumID) + } + } } // ── Artwork ───────────────────────────────────────────── if let artwork = payload.artwork { @@ -320,6 +330,10 @@ import MediaRemoteAdapter var netEaseLyricProvider = NetEaseLyricProvider() #if os(macOS) @ObservationIgnored lazy var appleMusicLyricProvider = AppleMusicLyricProvider() + @ObservationIgnored lazy var appleMusicPrefetcher = AppleMusicPrefetcher( + container: coreDataContainer, + provider: appleMusicLyricProvider + ) var localFileUploadProvider = LocalFileUploadProvider() #endif var allNetworkLyricProviders: [LyricProvider] { From d3ede1e245d6ccd9f0ec24e46b2ec7f9d3d6114f Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 08:49:12 +0900 Subject: [PATCH 14/14] feat: optional album lyric prefetch toggle (default OFF) + ViewModel gating - UserDefaultStorage: add prefetchAlbumLyrics: Bool (@AppStorage, default false) - MenubarWindowView: show 'Prefetch album lyrics in background' Toggle when Apple Music is the active player; hidden otherwise - ViewModel: gate warmAlbum trigger on userDefaultStorage.prefetchAlbumLyrics; also write song.albumID when demand-fetching succeeds (mirrors prefetcher behavior) - AppleMusicPrefetcher: drop sourceProvider/userPicked writes (those fields live in the separate sticky-picks PR; this PR's schema only adds albumID) --- .../LyricProvider/AppleMusic/AppleMusicPrefetcher.swift | 2 -- LyricFever/Support Files/UserDefaultStorage.swift | 8 ++++++++ LyricFever/ViewModel.swift | 6 +++++- .../Views/MenubarWindowView/MenubarWindowView.swift | 4 ++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift index 5a29a7f..955b9aa 100644 --- a/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift @@ -130,8 +130,6 @@ actor AppleMusicPrefetcher { let song = SongObject(from: lines, with: ctx, trackID: albumTrack.id, trackName: albumTrack.name) song.appleMusicID = albumTrack.id song.albumID = albumID - song.sourceProvider = lines.isEmpty ? "none_found" : "apple_music" - song.userPicked = false try? ctx.save() } } diff --git a/LyricFever/Support Files/UserDefaultStorage.swift b/LyricFever/Support Files/UserDefaultStorage.swift index 5821d02..182c529 100644 --- a/LyricFever/Support Files/UserDefaultStorage.swift +++ b/LyricFever/Support Files/UserDefaultStorage.swift @@ -71,4 +71,12 @@ class UserDefaultStorage { @ObservationIgnored var hasTranslated: Bool @ObservableUserDefault(.init(key: "truncationLength", defaultValue: 40, store: .standard)) @ObservationIgnored var truncationLength: Int + + #if os(macOS) + /// When enabled, LyricFever pre-fetches lyrics for every track on the + /// currently-playing album in the background (Apple Music only). + /// Off by default — background network activity is opt-in. + @ObservableUserDefault(.init(key: "prefetchAlbumLyrics", defaultValue: false, store: .standard)) + @ObservationIgnored var prefetchAlbumLyrics: Bool + #endif } diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 59a134f..1784fe8 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -111,7 +111,10 @@ import MediaRemoteAdapter self.showAppleMusicDeniedToast = true } // ── Album-wide background lyric prefetch ───────────────── - if let albumID = self.appleMusicPlayer.lastObservedAlbumCatalogID, + // Only fires when the user has enabled "Prefetch album lyrics + // in background" in Settings (off by default). + if self.userDefaultStorage.prefetchAlbumLyrics, + let albumID = self.appleMusicPlayer.lastObservedAlbumCatalogID, !albumID.isEmpty, self.currentPlayer == .appleMusic, AppleMusicAuthManager.shared.isAuthorized { @@ -455,6 +458,7 @@ import MediaRemoteAdapter // thats how i save to coredata let song = SongObject(from: lyrics.lyrics, with: coreDataContainer.viewContext, trackID: currentlyPlaying, trackName: currentlyPlayingName) song.appleMusicID = appleMusicPlayer.lastObservedCatalogID + song.albumID = appleMusicPlayer.lastObservedAlbumCatalogID saveCoreData() return lyrics } else if networkLyricProvider is SpotifyLyricProvider { diff --git a/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift b/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift index d27c66a..971d6a4 100644 --- a/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift +++ b/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift @@ -517,6 +517,10 @@ struct MenubarWindowView: View { var otherOptions: some View { @Bindable var viewmodel = viewmodel Toggle("Show Song Details in Menubar", isOn: $viewmodel.userDefaultStorage.showSongDetailsInMenubar) + if viewmodel.currentPlayer == .appleMusic { + Toggle("Prefetch album lyrics in background", isOn: $viewmodel.userDefaultStorage.prefetchAlbumLyrics) + .help("When enabled, LyricFever pre-fetches lyrics for every track on the currently-playing album so skips and auto-advance load instantly. Off by default.") + } Divider() streamingDelayView Button("Settings (New Karaoke Settings!)") {