(feat.) add last.fm API
This commit is contained in:
parent
957a4ab7e7
commit
6a7f1cb9cb
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
# Local Configuration Files
|
||||
Valentine/Config/Secrets.swift
|
||||
8
Valentine/Config/Secrets.example.swift
Normal file
8
Valentine/Config/Secrets.example.swift
Normal 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 = ""
|
||||
// }
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
160
Valentine/Services/LastFMService.swift
Normal file
160
Valentine/Services/LastFMService.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
Valentine/Views/Settings/IntegrationsSettingsView.swift
Normal file
128
Valentine/Views/Settings/IntegrationsSettingsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user