(feat.) add last.fm API

This commit is contained in:
Jesus Chapman 2026-06-20 20:46:11 -05:00
parent 957a4ab7e7
commit 6a7f1cb9cb
7 changed files with 785 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Local Configuration Files
Valentine/Config/Secrets.swift

View File

@ -0,0 +1,8 @@
// import Foundation
//
// struct Secrets {
// // Last.fm API Credentials
// // Rename this file to 'Secrets.swift', uncomment the code, and insert your actual keys.
// static let lastFMApiKey = ""
// static let lastFMSharedSecret = ""
// }

View File

@ -117,6 +117,42 @@
}
}
},
"Account Status" : {
"comment" : "A section that shows the connection status of a user's Last.fm account.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Estado de la cuenta"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "खाते की स्थिति"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アカウントのステータス"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Status da Conta"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "账户状态"
}
}
}
},
"Add File..." : {
"extractionState" : "manual",
"localizations" : {
@ -304,6 +340,41 @@
}
}
},
"Authorization failed. Did you approve in the browser?" : {
"extractionState" : "manual",
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Error de autorización. ¿Aprobaste en el navegador?"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "प्राधिकरण विफल रहा। क्या आपने ब्राउज़र में स्वीकृत किया?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "承認に失敗しました。ブラウザで承認しましたか?"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Falha na autorização. Você aprovou no navegador?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "授权失败。您在浏览器中批准了吗?"
}
}
}
},
"Both Themes" : {
"extractionState" : "manual",
"localizations" : {
@ -476,6 +547,114 @@
}
}
},
"Connect to Last.fm" : {
"comment" : "A button that connects to the Last.fm API.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conectar a Last.fm"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm से कनेक्ट करें"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm に接続"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conectar ao Last.fm"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "连接到 Last.fm"
}
}
}
},
"Connected as **%@**" : {
"comment" : "A line that shows that the user is connected to their Last.fm account. The argument is the user's Last.fm username.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conectado como **%@**"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "**%@** के रूप में जुड़े हैं"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "**%@** として接続済み"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conectado como **%@**"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已作为 **%@** 连接"
}
}
}
},
"Continue" : {
"comment" : "A button that continues the process of connecting to Last.fm.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Continuar"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "जारी रखें"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "続行"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Continuar"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "继续"
}
}
}
},
"Copy version" : {
"extractionState" : "manual",
"localizations" : {
@ -515,6 +694,41 @@
"comment" : "Copyright notice for the app.",
"isCommentAutoGenerated" : true
},
"Could not connect to Last.fm API." : {
"extractionState" : "manual",
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No se pudo conectar a la API de Last.fm."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm API से कनेक्ट नहीं हो सका।"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm APIに接続できませんでした。"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Não foi possível conectar à API do Last.fm."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无法连接到 Last.fm API。"
}
}
}
},
"Dark" : {
"extractionState" : "manual",
"localizations" : {
@ -690,6 +904,42 @@
}
}
},
"Disconnect" : {
"comment" : "A button that disconnects a Last.fm account.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Desconectar"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "डिस्कनेक्ट करें"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "切断"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Desconectar"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "断开连接"
}
}
}
},
"Donate" : {
"extractionState" : "manual",
"localizations" : {
@ -785,6 +1035,42 @@
}
}
},
"Enable Last.fm Integration" : {
"comment" : "A toggle that enables or disables Last.fm integration.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Habilitar Integración con Last.fm"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm एकीकरण सक्षम करें"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm連携を有効にする"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Habilitar Integração com Last.fm"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "启用 Last.fm 集成"
}
}
}
},
"ENGINEERING AND DESIGN" : {
"extractionState" : "manual",
"localizations" : {
@ -1076,10 +1362,81 @@
}
}
},
"Integrations" : {
"extractionState" : "manual",
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integraciones"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "एकीकरण (Integrations)"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "連携"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integrações"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "集成"
}
}
}
},
"Jesús David Chapman Vélez" : {
"comment" : "Name of the app's creator.",
"isCommentAutoGenerated" : true
},
"Last.fm" : {
"comment" : "A section in the settings view for Last.fm.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Last.fm"
}
}
}
},
"Learn more..." : {
"extractionState" : "manual",
"localizations" : {
@ -1608,6 +1965,42 @@
}
}
},
"Please authorize Valentine in your browser, then click continue." : {
"comment" : "A message that appears when the user is not yet connected to Last.fm.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Por favor, autoriza a Valentine en tu navegador y luego haz clic en continuar."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "कृपया अपने ब्राउज़र में Valentine को अधिकृत करें, फिर जारी रखें पर क्लिक करें।"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ブラウザでValentineを承認してから、続行をクリックしてください。"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Autorize o Valentine no seu navegador e clique em continuar."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请在浏览器中授权 Valentine然后点击继续。"
}
}
}
},
"Please install Python3 via Homebrew to continue:" : {
"extractionState" : "manual",
"localizations" : {
@ -2658,6 +3051,78 @@
}
}
}
},
"When enabled, Valentine will automatically share your listening history (scrobble) to your Last.fm profile." : {
"comment" : "A description of the Last.fm integration.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cuando esté habilitado, Valentine compartirá automáticamente tu historial de reproducción (scrobble) con tu perfil de Last.fm."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सक्षम होने पर, Valentine स्वचालित रूप से आपके लास्ट.एफएम प्रोफ़ाइल पर आपका सुनने का इतिहास साझा करेगा।"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "有効にすると、Valentine は再生履歴(スクロブル)を Last.fm プロフィールに自動的に共有します。"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quando ativado, Valentine compartilhará automaticamente seu histórico de reprodução (scrobble) com seu perfil do Last.fm."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "启用后Valentine 将自动分享您的收听历史scrobble到您的 Last.fm 个人资料中。"
}
}
}
},
"You are not connected. Connect your Last.fm account to keep track of your listening history and show what you're playing in real-time." : {
"comment" : "A description of the Last.fm integration.",
"isCommentAutoGenerated" : true,
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No estás conectado. Conecta tu cuenta de Last.fm para llevar un registro de tu historial de reproducción y mostrar lo que escuchas en tiempo real."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "आप जुड़े नहीं हैं। अपने सुनने के इतिहास को ट्रैक करने और वास्तविक समय में आप क्या बजा रहे हैं यह दिखाने के लिए अपने Last.fm खाते को कनेक्ट करें।"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "接続されていません。Last.fmアカウントを接続して、再生履歴を記録し、現在再生中の曲をリアルタイムで表示します。"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Você não está conectado. Conecte sua conta Last.fm para manter um registro do seu histórico de audição e mostrar o que está tocando em tempo real."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "您尚未连接。连接您的 Last.fm 账户以跟踪您的收听历史,并实时显示您正在播放的内容。"
}
}
}
}
},
"version" : "1.2"

View File

@ -44,6 +44,9 @@ class AudioEngine: ObservableObject {
private var player: AVPlayer?
private var timeObserver: Any?
private var hasScrobbledCurrentTrack = false
private var currentTrackStartTime: Int = 0
var currentTrack: Track? {
guard let index = currentTrackIndex, queue.indices.contains(index) else { return nil }
return queue[index]
@ -212,12 +215,24 @@ class AudioEngine: ObservableObject {
currentTrackIndex = index
duration = track.duration
hasScrobbledCurrentTrack = false
currentTrackStartTime = Int(Date().timeIntervalSince1970)
LastFMService.shared.updateNowPlaying(track: track.title, artist: track.artist, album: track.album, duration: Int(track.duration))
let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
Task { @MainActor in
guard let self = self else { return }
self.currentTime = time.seconds
if let currentTrack = self.currentTrack, self.duration > 30 && !self.hasScrobbledCurrentTrack {
let scrobblePoint = min(self.duration / 2.0, 240.0) // 50% or 4 minutes
if self.currentTime >= scrobblePoint {
self.hasScrobbledCurrentTrack = true
LastFMService.shared.scrobble(track: currentTrack.title, artist: currentTrack.artist, album: currentTrack.album, timestamp: self.currentTrackStartTime)
}
}
if self.duration > 0 && self.currentTime >= self.duration - 0.1 {
self.nextTrack(isAutomatic: true)
}

View File

@ -0,0 +1,160 @@
import Foundation
import CryptoKit
import SwiftUI
import Combine
class LastFMService: ObservableObject {
static let shared = LastFMService()
private let apiKey = Secrets.lastFMApiKey
private let sharedSecret = Secrets.lastFMSharedSecret
@Published var sessionKey: String = UserDefaults.standard.string(forKey: "lastFMSessionKey") ?? ""
@Published var username: String = UserDefaults.standard.string(forKey: "lastFMUsername") ?? ""
@Published var isEnabled: Bool = UserDefaults.standard.object(forKey: "lastFMIsEnabled") as? Bool ?? true
private var cancellables = Set<AnyCancellable>()
private init() {
$sessionKey
.sink { UserDefaults.standard.set($0, forKey: "lastFMSessionKey") }
.store(in: &cancellables)
$username
.sink { UserDefaults.standard.set($0, forKey: "lastFMUsername") }
.store(in: &cancellables)
$isEnabled
.sink { UserDefaults.standard.set($0, forKey: "lastFMIsEnabled") }
.store(in: &cancellables)
}
var isConnected: Bool {
return !sessionKey.isEmpty
}
// MARK: - Utilities
private func generateSignature(params: [String: String]) -> String {
let sortedKeys = params.keys.sorted()
var signatureString = ""
for key in sortedKeys {
signatureString += "\(key)\(params[key]!)"
}
signatureString += sharedSecret
let digest = Insecure.MD5.hash(data: signatureString.data(using: .utf8) ?? Data())
return digest.map { String(format: "%02x", $0) }.joined()
}
private func makeRequest(method: String, params: [String: String], requiresSignature: Bool = true) async throws -> Data {
var allParams = params
allParams["api_key"] = apiKey
allParams["method"] = method
if requiresSignature {
allParams["api_sig"] = generateSignature(params: allParams)
}
allParams["format"] = "json"
var components = URLComponents(string: "https://ws.audioscrobbler.com/2.0/")!
components.queryItems = allParams.map { URLQueryItem(name: $0.key, value: $0.value) }
guard let url = components.url else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST" // Last.fm recommends POST for scrobbling and now playing
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
// MARK: - Authentication
func getToken() async throws -> String {
let data = try await makeRequest(method: "auth.getToken", params: [:], requiresSignature: true)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["token"] as? String {
return token
}
throw URLError(.cannotParseResponse)
}
func getSession(token: String) async throws {
let data = try await makeRequest(method: "auth.getSession", params: ["token": token], requiresSignature: true)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let session = json["session"] as? [String: Any],
let key = session["key"] as? String,
let name = session["name"] as? String {
DispatchQueue.main.async {
self.sessionKey = key
self.username = name
}
} else {
throw URLError(.cannotParseResponse)
}
}
func disconnect() {
self.sessionKey = ""
self.username = ""
}
// MARK: - Scrobbling & Now Playing
func updateNowPlaying(track: String, artist: String, album: String?, duration: Int) {
guard isEnabled && isConnected else { return }
guard !sharedSecret.isEmpty else { return } // Safe check
var params: [String: String] = [
"track": track,
"artist": artist,
"sk": sessionKey
]
if let album = album {
params["album"] = album
}
if duration > 0 {
params["duration"] = "\(duration)"
}
Task {
do {
_ = try await makeRequest(method: "track.updateNowPlaying", params: params, requiresSignature: true)
print("LastFM: Now playing updated (\(artist) - \(track))")
} catch {
print("LastFM: Error updating now playing: \(error)")
}
}
}
func scrobble(track: String, artist: String, album: String?, timestamp: Int) {
guard isEnabled && isConnected else { return }
guard !sharedSecret.isEmpty else { return } // Safe check
var params: [String: String] = [
"track": track,
"artist": artist,
"timestamp": "\(timestamp)",
"sk": sessionKey
]
if let album = album {
params["album"] = album
}
Task {
do {
_ = try await makeRequest(method: "track.scrobble", params: params, requiresSignature: true)
print("LastFM: Scrobble successful (\(artist) - \(track))")
} catch {
print("LastFM: Error scrobbling: \(error)")
}
}
}
}

View File

@ -0,0 +1,128 @@
import SwiftUI
struct IntegrationsSettingsView: View {
@ObservedObject private var lastFM = LastFMService.shared
@State private var isConnecting = false
@State private var webAuthToken: String? = nil
@State private var errorMessage: String? = nil
var body: some View {
Form {
Section {
Toggle("Enable Last.fm Integration", isOn: $lastFM.isEnabled)
} header: {
Text("Last.fm")
} footer: {
Text("When enabled, Valentine will automatically share your listening history (scrobble) to your Last.fm profile.")
}
if lastFM.isEnabled {
Section("Account Status") {
if lastFM.isConnected {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Connected as **\(lastFM.username)**")
Spacer()
Button("Disconnect", role: .destructive) {
lastFM.disconnect()
}
.buttonStyle(.bordered)
.tint(.red)
}
} else {
VStack(alignment: .leading, spacing: 12) {
Text("You are not connected. Connect your Last.fm account to keep track of your listening history and show what you're playing in real-time.")
.foregroundColor(.secondary)
if let token = webAuthToken {
Text("Please authorize Valentine in your browser, then click continue.")
.font(.caption)
.foregroundColor(.orange)
HStack {
Button("Continue") {
finishAuthentication(token: token)
}
.buttonStyle(.borderedProminent)
.disabled(isConnecting)
Button("Cancel") {
webAuthToken = nil
isConnecting = false
}
.buttonStyle(.bordered)
}
} else {
Button("Connect to Last.fm") {
startAuthentication()
}
.buttonStyle(.borderedProminent)
.disabled(isConnecting)
}
if let error = errorMessage {
Text(LocalizedStringKey(error))
.font(.caption)
.foregroundColor(.red)
}
}
.padding(.vertical, 4)
}
}
}
}
.formStyle(.grouped)
}
private func startAuthentication() {
isConnecting = true
errorMessage = nil
Task {
do {
let token = try await lastFM.getToken()
// Open browser
DispatchQueue.main.async {
let apiKey = Secrets.lastFMApiKey
if let authURL = URL(string: "https://www.last.fm/api/auth/?api_key=\(apiKey)&token=\(token)") {
#if os(macOS)
NSWorkspace.shared.open(authURL)
#endif
}
self.webAuthToken = token
self.isConnecting = false
}
} catch {
DispatchQueue.main.async {
self.errorMessage = "Could not connect to Last.fm API."
self.isConnecting = false
}
}
}
}
private func finishAuthentication(token: String) {
isConnecting = true
errorMessage = nil
Task {
do {
try await lastFM.getSession(token: token)
DispatchQueue.main.async {
self.webAuthToken = nil
self.isConnecting = false
}
} catch {
DispatchQueue.main.async {
self.errorMessage = "Authorization failed. Did you approve in the browser?"
self.isConnecting = false
}
}
}
}
}

View File

@ -3,6 +3,7 @@ import SwiftUI
enum SettingsTab: String, CaseIterable, Identifiable {
case general = "General"
case lyrics = "Lyrics"
case integrations = "Integrations"
var id: String { self.rawValue }
@ -10,6 +11,7 @@ enum SettingsTab: String, CaseIterable, Identifiable {
switch self {
case .general: return "gear"
case .lyrics: return "textformat.alt"
case .integrations: return "network"
}
}
@ -17,6 +19,7 @@ enum SettingsTab: String, CaseIterable, Identifiable {
switch self {
case .general: return .gray
case .lyrics: return .blue
case .integrations: return .red
}
}
}
@ -52,6 +55,9 @@ struct SettingsView: View {
case .lyrics:
LyricsAppearanceView()
.navigationTitle(LocalizedStringKey(SettingsTab.lyrics.rawValue))
case .integrations:
IntegrationsSettingsView()
.navigationTitle(LocalizedStringKey(SettingsTab.integrations.rawValue))
case .none:
Text("Select a setting")
}