Skip to content

Commit 08633fe

Browse files
authored
feat: add Last.fm scrobbling support (#104)
1 parent 37ec2ae commit 08633fe

19 files changed

+3057
-0
lines changed

App/KasetApp.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ struct KasetApp: App {
3131
@State private var favoritesManager = FavoritesManager.shared
3232
@State private var likeStatusManager = SongLikeStatusManager.shared
3333
@State private var accountService: AccountService?
34+
@State private var scrobblingCoordinator: ScrobblingCoordinator
3435

3536
/// Triggers search field focus when set to true.
3637
@State private var searchFocusTrigger = false
@@ -76,6 +77,16 @@ struct KasetApp: App {
7677
_notificationService = State(initialValue: NotificationService(playerService: player))
7778
_accountService = State(initialValue: account)
7879

80+
// Create scrobbling coordinator
81+
let lastFMService = LastFMService(credentialStore: KeychainCredentialStore())
82+
let scrobblingCoordinator = ScrobblingCoordinator(
83+
playerService: player,
84+
services: [lastFMService]
85+
)
86+
scrobblingCoordinator.restoreAuthState()
87+
scrobblingCoordinator.startMonitoring()
88+
_scrobblingCoordinator = State(initialValue: scrobblingCoordinator)
89+
7990
// Wire up PlayerService to AppDelegate immediately (not in onAppear)
8091
// This ensures playerService is available for lifecycle events like queue restoration
8192
self.appDelegate.playerService = player
@@ -99,6 +110,7 @@ struct KasetApp: App {
99110
.environment(self.favoritesManager)
100111
.environment(self.likeStatusManager)
101112
.environment(self.accountService)
113+
.environment(self.scrobblingCoordinator)
102114
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
103115
.environment(\.navigationSelection, self.$navigationSelection)
104116
.environment(\.showCommandBar, self.$showCommandBar)
@@ -127,6 +139,7 @@ struct KasetApp: App {
127139
SettingsView()
128140
.environment(self.authService)
129141
.environment(self.updaterService)
142+
.environment(self.scrobblingCoordinator)
130143
}
131144
.commands {
132145
// Check for Updates command in app menu
@@ -347,6 +360,7 @@ struct KasetApp: App {
347360
@available(macOS 26.0, *)
348361
struct SettingsView: View {
349362
@Environment(UpdaterService.self) private var updaterService
363+
@Environment(ScrobblingCoordinator.self) private var scrobblingCoordinator
350364

351365
var body: some View {
352366
TabView {
@@ -359,6 +373,12 @@ struct SettingsView: View {
359373
.tabItem {
360374
Label("Intelligence", systemImage: "sparkles")
361375
}
376+
377+
ScrobblingSettingsView()
378+
.environment(self.scrobblingCoordinator)
379+
.tabItem {
380+
Label("Scrobbling", systemImage: "music.note.list")
381+
}
362382
}
363383
.frame(width: 450, height: 400)
364384
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import Foundation
2+
import Security
3+
4+
// MARK: - KeychainCredentialStore
5+
6+
/// Thin Keychain wrapper for storing scrobbling service credentials.
7+
/// Uses `kSecClassGenericPassword` with `kSecAttrAccessibleAfterFirstUnlock`.
8+
@MainActor
9+
final class KeychainCredentialStore {
10+
private let servicePrefix: String
11+
12+
/// Creates a credential store with the given service prefix.
13+
/// - Parameter servicePrefix: Prefix for Keychain entries (default: "com.sertacozercan.Kaset").
14+
init(servicePrefix: String = "com.sertacozercan.Kaset") {
15+
self.servicePrefix = servicePrefix
16+
}
17+
18+
// MARK: - Public API
19+
20+
/// Saves the Last.fm session key to Keychain.
21+
func saveLastFMSessionKey(_ sessionKey: String) throws {
22+
try self.save(key: "lastFMSessionKey", value: sessionKey)
23+
}
24+
25+
/// Retrieves the stored Last.fm session key, if any.
26+
func getLastFMSessionKey() -> String? {
27+
self.get(key: "lastFMSessionKey")
28+
}
29+
30+
/// Saves the Last.fm username to Keychain.
31+
func saveLastFMUsername(_ username: String) throws {
32+
try self.save(key: "lastFMUsername", value: username)
33+
}
34+
35+
/// Retrieves the stored Last.fm username, if any.
36+
func getLastFMUsername() -> String? {
37+
self.get(key: "lastFMUsername")
38+
}
39+
40+
/// Removes all Last.fm credentials from Keychain.
41+
func removeLastFMCredentials() {
42+
self.delete(key: "lastFMSessionKey")
43+
self.delete(key: "lastFMUsername")
44+
}
45+
46+
// MARK: - Private Helpers
47+
48+
private func save(key: String, value: String) throws {
49+
guard let data = value.data(using: .utf8) else {
50+
throw KeychainError.encodingFailed
51+
}
52+
53+
let account = "\(self.servicePrefix).\(key)"
54+
55+
// Try to update existing item first
56+
let updateQuery: [String: Any] = [
57+
kSecClass as String: kSecClassGenericPassword,
58+
kSecAttrAccount as String: account,
59+
kSecAttrService as String: self.servicePrefix,
60+
]
61+
62+
let updateAttributes: [String: Any] = [
63+
kSecValueData as String: data,
64+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
65+
]
66+
67+
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
68+
69+
if updateStatus == errSecItemNotFound {
70+
// Item doesn't exist, add it
71+
var addQuery = updateQuery
72+
addQuery[kSecValueData as String] = data
73+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
74+
75+
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
76+
guard addStatus == errSecSuccess else {
77+
throw KeychainError.saveFailed(status: addStatus)
78+
}
79+
} else if updateStatus != errSecSuccess {
80+
throw KeychainError.saveFailed(status: updateStatus)
81+
}
82+
}
83+
84+
private func get(key: String) -> String? {
85+
let account = "\(self.servicePrefix).\(key)"
86+
87+
let query: [String: Any] = [
88+
kSecClass as String: kSecClassGenericPassword,
89+
kSecAttrAccount as String: account,
90+
kSecAttrService as String: self.servicePrefix,
91+
kSecReturnData as String: true,
92+
kSecMatchLimit as String: kSecMatchLimitOne,
93+
]
94+
95+
var result: AnyObject?
96+
let status = SecItemCopyMatching(query as CFDictionary, &result)
97+
98+
guard status == errSecSuccess,
99+
let data = result as? Data,
100+
let value = String(data: data, encoding: .utf8)
101+
else {
102+
return nil
103+
}
104+
105+
return value
106+
}
107+
108+
private func delete(key: String) {
109+
let account = "\(self.servicePrefix).\(key)"
110+
111+
let query: [String: Any] = [
112+
kSecClass as String: kSecClassGenericPassword,
113+
kSecAttrAccount as String: account,
114+
kSecAttrService as String: self.servicePrefix,
115+
]
116+
117+
SecItemDelete(query as CFDictionary)
118+
}
119+
}
120+
121+
// MARK: - KeychainError
122+
123+
/// Errors that can occur during Keychain operations.
124+
enum KeychainError: Error, LocalizedError {
125+
case encodingFailed
126+
case saveFailed(status: OSStatus)
127+
128+
var errorDescription: String? {
129+
switch self {
130+
case .encodingFailed:
131+
"Failed to encode value for Keychain storage."
132+
case let .saveFailed(status):
133+
"Keychain save failed with status: \(status)"
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)