From 66636c529db07c9179527aa4bdcc009534674a65 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 08:40:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20SongObject=20schema=20v2=20?= =?UTF-8?q?=E2=80=94=20userPicked=20+=20sourceProvider=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight CoreData migration. Adds two fields to SongObject: - userPicked: Bool (default false) — true when the user manually selected lyrics via the search window; sticky flag that bypasses the network chain on subsequent plays. - sourceProvider: String? — which provider succeeded ("spotify", "lrclib", "user_picked", etc.); used by the reset button. --- .../Lyrics.xcdatamodeld/.xccurrentversion | 8 ++++ .../Lyrics 2.xcdatamodel/contents | 45 +++++++++++++++++++ .../CoreData/SongObjectExtensions.swift | 2 + 3 files changed, 55 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..9c45a3a --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LyricFever/Models/CoreData/SongObjectExtensions.swift b/LyricFever/Models/CoreData/SongObjectExtensions.swift index 54e9007..0719cbf 100644 --- a/LyricFever/Models/CoreData/SongObjectExtensions.swift +++ b/LyricFever/Models/CoreData/SongObjectExtensions.swift @@ -22,6 +22,8 @@ extension SongObject { @NSManaged public var language: String @NSManaged public var lyricsWords: [String] @NSManaged public var lyricsTimestamps: [TimeInterval] + @NSManaged public var sourceProvider: String? + @NSManaged public var userPicked: Bool } From ec64b7a467161ff968d627ed8bfce470d568247a Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 03:51:37 +0900 Subject: [PATCH 2/5] feat: AM provider in chain; CoreData lookup by appleMusicID; none_found cache Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/ViewModel.swift | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 7eb6658..05b6214 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -272,7 +272,8 @@ import MediaRemoteAdapter var localFileUploadProvider = LocalFileUploadProvider() #endif @ObservationIgnored lazy var allNetworkLyricProviders: [LyricProvider] = [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] - + + // custom order because LRCLIB is tweaking for the time being @ObservationIgnored lazy var allNetworkLyricProvidersForSearch: [LyricProvider] = [spotifyLyricProvider, netEaseLyricProvider, lRCLyricProvider] @@ -370,7 +371,9 @@ import MediaRemoteAdapter 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.sourceProvider = networkLyricProvider.providerName + song.userPicked = false saveCoreData() return lyrics } else if networkLyricProvider is SpotifyLyricProvider { @@ -383,6 +386,7 @@ import MediaRemoteAdapter print("Caught exception on \(networkLyricProvider.providerName): \(error)") } } + saveCoreData() return NetworkFetchReturn(lyrics: [], colorData: nil) } @@ -921,7 +925,24 @@ import MediaRemoteAdapter func fetchLyrics(for trackID: String, _ trackName: String, checkCoreDataFirst: Bool) async throws -> [LyricLine] { let initiatingTrackID = trackID - if checkCoreDataFirst, let lyrics = fetchFromCoreData(for: trackID) { + // Sticky check: if the user previously picked lyrics manually (via search window), + // skip the network chain entirely and return the cached lyrics as-is. + if checkCoreDataFirst { + let request = SongObject.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", trackID) + if let existing = try? coreDataContainer.viewContext.fetch(request).first, + existing.userPicked, !existing.lyricsWords.isEmpty { + let lyrics = zip(existing.lyricsTimestamps, existing.lyricsWords).map { LyricLine(startTime: $0, words: $1) } + try Task.checkCancellation() + amplitude.track(eventType: "CoreData Fetch (userPicked sticky)") + 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 52a0254c23f4d58f1f8ab4207ee0e984acc4896e Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 04:14:41 +0900 Subject: [PATCH 3/5] feat: SearchWindow marks saved lyrics as userPicked (sticky) Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/Views/SearchView/SearchWindow.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/LyricFever/Views/SearchView/SearchWindow.swift b/LyricFever/Views/SearchView/SearchWindow.swift index 7acc283..00359eb 100644 --- a/LyricFever/Views/SearchView/SearchWindow.swift +++ b/LyricFever/Views/SearchView/SearchWindow.swift @@ -74,7 +74,12 @@ struct SearchWindow: View { return } // thats how i save to coredata - let _ = SongObject(from: cleanLyrics, with: viewmodel.coreDataContainer.viewContext, trackID: spotifyID, trackName: trackName) + let song = SongObject(from: cleanLyrics, with: viewmodel.coreDataContainer.viewContext, trackID: spotifyID, trackName: trackName) + song.userPicked = true + song.sourceProvider = "user_picked" + // Preserve catalog ID if known (Apple Music) + song.appleMusicID = viewmodel.appleMusicPlayer.lastObservedCatalogID + song.albumID = viewmodel.appleMusicPlayer.lastObservedAlbumCatalogID viewmodel.saveCoreData() lyricsAreApplied = true } label: { From c5d470fad50c8ba921ab1c2ba60b73623eca3216 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 04:17:25 +0900 Subject: [PATCH 4/5] feat: 'Reset lyrics for this track' menubar item Adds resetLyricsForCurrentTrack() to ViewModel (deletes CoreData entry by track ID, cancels in-flight fetch, re-triggers setCurrentProperties) and wires it to a new arrow.counterclockwise SmallMenubarButton placed between the search (magnifyingglass) and translate buttons. Co-Authored-By: Claude Sonnet 4.6 --- LyricFever/ViewModel.swift | 18 ++++++++++++++++++ .../MenubarWindowView/MenubarWindowView.swift | 14 +++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 05b6214..573a447 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -1013,6 +1013,24 @@ import MediaRemoteAdapter } } + /// Deletes the CoreData entry for the current track (including userPicked and + /// none_found entries) and resets in-memory state so the next tick re-runs + /// the full lyric-fetch chain from scratch. + func resetLyricsForCurrentTrack() { + guard let trackID = currentlyPlaying else { return } + let ctx = coreDataContainer.viewContext + let request: NSFetchRequest = SongObject.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", trackID) + if let existing = try? ctx.fetch(request).first { + ctx.delete(existing) + saveCoreData() + } + // Reset in-memory state; the next player-change tick will re-fetch. + currentlyPlayingLyrics = [] + currentFetchTask?.cancel() + setCurrentPropertiesPublic() + } + func fetchFromCoreData(for trackID: String) -> [LyricLine]? { let fetchRequest: NSFetchRequest = SongObject.fetchRequest() fetchRequest.predicate = NSPredicate(format: "id == %@", trackID) // Replace trackID with the desired value diff --git a/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift b/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift index d27c66a..7a12654 100644 --- a/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift +++ b/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift @@ -380,7 +380,14 @@ struct MenubarWindowView: View { } return .clickable } - + + var resetState: ButtonState { + guard viewmodel.userDefaultStorage.hasOnboarded else { + return .disabled + } + return .clickable + } + var deleteOrUploadState: ButtonState { guard viewmodel.userDefaultStorage.hasOnboarded else { return .disabled @@ -433,6 +440,11 @@ struct MenubarWindowView: View { currentHoveredItem = .none } } + SmallMenubarButton(buttonText: "", imageText: "arrow.counterclockwise", buttonState: resetState) { + viewmodel.resetLyricsForCurrentTrack() + } + .help("Reset lyrics for this track (clears cache, re-runs chain)") + .disabled(resetState == .disabled) Menu { translationAndRomanizationView } label: { From ee9f224ffa14ff515bac22e726d61a7e8fd6f4af Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 2 Jun 2026 08:45:25 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20upstream=20compat=20=E2=80=94=20remo?= =?UTF-8?q?ve=20AM-specific=20fields=20from=20SearchWindow=20+=20use=20cor?= =?UTF-8?q?rect=20method=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchWindow.swift: drop song.appleMusicID + song.albumID (those fields are added by the separate Apple Music PR; this PR only adds userPicked + sourceProvider to the schema) - ViewModel: MediaController() call no longer takes bundleIdentifier param (ejbills/mediaremote-adapter removed it in b8ce5d1); call setCurrentProperties() not setCurrentPropertiesPublic() which doesn't exist upstream --- LyricFever/ViewModel.swift | 5 +++-- LyricFever/Views/SearchView/SearchWindow.swift | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 573a447..ea235ff 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? @@ -1028,7 +1029,7 @@ import MediaRemoteAdapter // Reset in-memory state; the next player-change tick will re-fetch. currentlyPlayingLyrics = [] currentFetchTask?.cancel() - setCurrentPropertiesPublic() + setCurrentProperties() } func fetchFromCoreData(for trackID: String) -> [LyricLine]? { diff --git a/LyricFever/Views/SearchView/SearchWindow.swift b/LyricFever/Views/SearchView/SearchWindow.swift index 00359eb..94df4c9 100644 --- a/LyricFever/Views/SearchView/SearchWindow.swift +++ b/LyricFever/Views/SearchView/SearchWindow.swift @@ -77,9 +77,6 @@ struct SearchWindow: View { let song = SongObject(from: cleanLyrics, with: viewmodel.coreDataContainer.viewContext, trackID: spotifyID, trackName: trackName) song.userPicked = true song.sourceProvider = "user_picked" - // Preserve catalog ID if known (Apple Music) - song.appleMusicID = viewmodel.appleMusicPlayer.lastObservedCatalogID - song.albumID = viewmodel.appleMusicPlayer.lastObservedAlbumCatalogID viewmodel.saveCoreData() lyricsAreApplied = true } label: {