an open source Navidrome client written in Swift — https://dub.sh/getflo

feat: init scrobble feature

+136
+12
flo/PlayerViewModel.swift
··· 190 190 title: self.nowPlaying.songName ?? "", 191 191 artist: self.nowPlaying.artistName ?? "", 192 192 playbackDuration: self.totalDuration) 193 + 194 + FloooService.shared.scrobbleToBuiltInEndpoint( 195 + submission: false, songId: self.nowPlaying.id ?? "" 196 + ) { result in 197 + // TODO: handle when this fail, maybe also move to viewModel? what if user have no account linked 198 + } 193 199 } 194 200 195 201 private func addPeriodicTimeObserver() { ··· 210 216 if !self.isLocallySaved && self.progress >= 0.5 { 211 217 Task { 212 218 FloooService.shared.saveListeningHistory(payload: self.nowPlaying) 219 + // TODO: refactor to group with saveListeningHistory? 220 + FloooService.shared.scrobbleToBuiltInEndpoint( 221 + submission: true, songId: self.nowPlaying.id ?? "" 222 + ) { result in 223 + // TODO: handle when this fail, maybe also move to viewModel? what if user have no account linked 224 + } 213 225 214 226 self.isLocallySaved = true 215 227 }
+121
flo/Shared/Services/FloooService.swift
··· 1 + import Alamofire 1 2 // 2 3 // FloooService.swift 3 4 // flo ··· 5 6 // Created by rizaldy on 22/11/24. 6 7 // 7 8 import Foundation 9 + 10 + struct AccountStatusResponse: Decodable { 11 + let status: Bool 12 + } 13 + 14 + struct AccountLinkStatus: Decodable { 15 + let listenBrainz: Bool 16 + let lastFM: Bool 17 + } 18 + 19 + struct SubsonicResponse: Codable { 20 + let status: String 21 + let version: String 22 + let type: String 23 + let serverVersion: String 24 + let openSubsonic: Bool 25 + } 26 + 27 + enum CodingKeys: String, CodingKey { 28 + // FIXME: constants? 29 + case subsonicResponse = "subsonic-response" 30 + } 8 31 9 32 class FloooService { 10 33 static let shared: FloooService = FloooService() ··· 57 80 58 81 return stats 59 82 }.value 83 + } 84 + 85 + func getAccountLinkStatuses(completion: @escaping (Result<AccountLinkStatus, Error>) -> Void) { 86 + let group = DispatchGroup() 87 + 88 + var listenBrainzStatus: Bool? 89 + var lastFMStatus: Bool? 90 + var error: Error? 91 + 92 + group.enter() 93 + 94 + checkListenBrainzAccountStatus { result in 95 + switch result { 96 + case .success(let status): 97 + listenBrainzStatus = status 98 + case .failure(let err): 99 + error = err 100 + } 101 + 102 + group.leave() 103 + } 104 + 105 + group.enter() 106 + 107 + checkLastFMAccountStatus { result in 108 + switch result { 109 + case .success(let status): 110 + lastFMStatus = status 111 + case .failure(let err): 112 + error = err 113 + } 114 + 115 + group.leave() 116 + } 117 + 118 + group.notify(queue: .main) { 119 + if let error = error { 120 + completion(.failure(error)) 121 + 122 + return 123 + } 124 + 125 + guard let listenBrainz = listenBrainzStatus, let lastFm = lastFMStatus else { 126 + completion(.failure(NSError(domain: "", code: -1))) 127 + 128 + return 129 + } 130 + 131 + completion(.success(AccountLinkStatus(listenBrainz: listenBrainz, lastFM: lastFm))) 132 + } 133 + } 134 + 135 + func checkListenBrainzAccountStatus(completion: @escaping (Result<Bool, Error>) -> Void) { 136 + APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.listenBrainzLink, parameters: [:]) 137 + { 138 + (response: DataResponse<AccountStatusResponse, AFError>) in 139 + switch response.result { 140 + case .success(let status): 141 + completion(.success(status.status)) 142 + case .failure(let error): 143 + completion(.failure(error)) 144 + } 145 + } 146 + } 147 + 148 + func checkLastFMAccountStatus(completion: @escaping (Result<Bool, Error>) -> Void) { 149 + APIManager.shared.NDEndpointRequest(endpoint: API.NDEndpoint.lastFMLink, parameters: [:]) { 150 + (response: DataResponse<AccountStatusResponse, AFError>) in 151 + switch response.result { 152 + case .success(let status): 153 + completion(.success(status.status)) 154 + case .failure(let error): 155 + completion(.failure(error)) 156 + } 157 + } 158 + } 159 + 160 + func scrobbleToBuiltInEndpoint( 161 + submission: Bool, songId: String, 162 + completion: @escaping (Result<SubsonicResponse, Error>) -> Void 163 + ) { 164 + var params: [String: Any] = ["submission": String(submission), "id": songId] 165 + 166 + if submission { 167 + params["time"] = Int(Date().timeIntervalSince1970 * 1000) 168 + } 169 + 170 + APIManager.shared.SubsonicEndpointRequest( 171 + endpoint: API.SubsonicEndpoint.scrobble, parameters: params 172 + ) { 173 + (response: DataResponse<SubsonicResponse, AFError>) in 174 + switch response.result { 175 + case .success(let response): 176 + completion(.success(response)) 177 + case .failure(let error): 178 + completion(.failure(error)) 179 + } 180 + } 60 181 } 61 182 }
+3
flo/Shared/Utils/Constants.swift
··· 17 17 static let getPlaylists = "/api/playlist" 18 18 static let getSong = "/api/song" 19 19 static let shareAlbum = "/api/share" 20 + static let listenBrainzLink = "/api/listenbrainz/link" 21 + static let lastFMLink = "/api/lastfm/link" 20 22 } 21 23 22 24 struct SubsonicEndpoint { ··· 25 27 static let albuminfo = "/rest/getAlbumInfo" 26 28 static let scanStatus = "/rest/getScanStatus" 27 29 static let download = "/rest/download" 30 + static let scrobble = "/rest/scrobble" 28 31 } 29 32 } 30 33