diff --git a/Spottie/Backend/EventBroker.swift b/Spottie/Backend/EventBroker.swift index 7593a6a..ed959a8 100644 --- a/Spottie/Backend/EventBroker.swift +++ b/Spottie/Backend/EventBroker.swift @@ -9,13 +9,14 @@ import Foundation import Combine class EventBroker : ObservableObject { - private let websocketClient = WebsocketClient(url: URL(string: "ws://localhost:24879/events")!) +// private let websocketClient = WebsocketClient(url: URL(string: "ws://localhost:24879/events")!) private var cancellables = [AnyCancellable]() private let decoder = JSONDecoder() let onEventReceived = PassthroughSubject() init() { + /* websocketClient .onMessageReceived .receive(on: DispatchQueue.main) @@ -32,5 +33,6 @@ class EventBroker : ObservableObject { print(error) } }.store(in: &cancellables) + */ } } diff --git a/Spottie/Backend/SpotifyAPI.swift b/Spottie/Backend/SpotifyAPI.swift index 5c76caf..b335587 100644 --- a/Spottie/Backend/SpotifyAPI.swift +++ b/Spottie/Backend/SpotifyAPI.swift @@ -8,6 +8,10 @@ import Foundation import Combine +enum APIError: Error { + case unknown +} + enum SpotifyAPI { static let client = HTTPClient() static let base = URL(string: "http://localhost:24879")! @@ -160,8 +164,8 @@ extension SpotifyAPI { return token("user-read-private") .flatMap { tokenObj -> AnyPublisher, Error> in - let token = tokenObj!.token - req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let token = "\(tokenObj!.tokenType) \(tokenObj!.accessToken)" + req.addValue(token, forHTTPHeaderField: "Authorization") return client.run(req, decoder) } .map(\.value) diff --git a/Spottie/Backend/SpotifyWebAPI.swift b/Spottie/Backend/SpotifyWebAPI.swift new file mode 100644 index 0000000..d79ff90 --- /dev/null +++ b/Spottie/Backend/SpotifyWebAPI.swift @@ -0,0 +1,158 @@ +// +// SpotifyWebAPI.swift +// SpotifyWebAPI +// +// Created by Lee Jun Kit on 21/8/21. +// + +import Foundation +import Combine + +enum WebAPIError: Error { + case unknown + case authTokenError(CurlError) + case decodingError(Error) +} + +actor TrackCache { + var cachedTracks = [String: WebAPITrackObject]() + func get(_ id: String) -> WebAPITrackObject? { + return cachedTracks[id] + } + + func set(tracks: [WebAPITrackObject]) { + for track in tracks { + cachedTracks[track.id] = track + } + } +} + +struct SpotifyWebAPI { + let playerCore: PlayerCore + let trackCache = TrackCache() + let decoder = JSONDecoder() + + init(playerCore: PlayerCore) { + self.playerCore = playerCore + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + self.decoder.dateDecodingStrategy = .iso8601 + } + + func getTrack(_ id: String) async -> Result { + if let track = await trackCache.get(id) { + return .success(track) + } + + let url = "https://api.spotify.com/v1/tracks/\(id)" + var req = URLRequest(url: URL(string: url)!) + req = await addAuthorizationHeader(request: &req) + + do { + let (data, _) = try await URLSession.shared.data(for: req) + let track = try decoder.decode(WebAPITrackObject.self, from: data) + await trackCache.set(tracks: [track]) + return .success(track) + } catch { + return .failure(.unknown) + } + } + + func getSavedTracks() -> AnyPublisher<[WebAPISavedTrackObject], WebAPIError> { + let subject = PassthroughSubject<[WebAPISavedTrackObject], WebAPIError>() + let endpoint = URL(string: "https://api.spotify.com/v1/me/tracks?limit=50&offset=0")! + Task.init { + await sendItemsToSubjectFromPagedEndpoint( + type: WebAPISavedTrackObject.self, + endpoint: endpoint, + subject: subject + ) + } + + return subject.eraseToAnyPublisher() + } + + func getLibraryPlaylists() -> AnyPublisher<[WebAPISimplifiedPlaylistObject], WebAPIError> { + let subject = PassthroughSubject<[WebAPISimplifiedPlaylistObject], WebAPIError>() + let endpoint = URL(string: "https://api.spotify.com/v1/me/playlists")! + Task.init { + await sendItemsToSubjectFromPagedEndpoint( + type: WebAPISimplifiedPlaylistObject.self, + endpoint: endpoint, + subject: subject + ) + } + + return subject.eraseToAnyPublisher() + } + + func sendItemsToSubjectFromPagedEndpoint(type: T.Type, endpoint: URL, subject: PassthroughSubject<[T], WebAPIError>) async { + var url: URL? = endpoint + while let u = url { + var req = URLRequest(url: u) + req = await addAuthorizationHeader(request: &req) + + do { + let (data, _) = try await URLSession.shared.data(for: req) + let pager = try decoder.decode(WebAPIPagingObject.self, from: data) + subject.send(pager.items) + + // set next url + if let nextURLString = pager.next { + url = URL(string: nextURLString) + } else { + url = nil + subject.send(completion: .finished) + } + } catch { + // TODO: handle errors + subject.send(completion: .failure(.unknown)) + } + } + } + + func getPersonalizedRecommendations() async -> Result { + // get the current date + let date = Date() + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + + var comps = URLComponents(string: "https://api.spotify.com/v1/views/personalized-recommendations")! + comps.queryItems = [ + URLQueryItem(name: "timestamp", value: formatter.string(from: date)), + URLQueryItem(name: "platform", value: "web"), + URLQueryItem(name: "content_limit", value: "10"), + URLQueryItem(name: "limit", value: "20"), + URLQueryItem(name: "types", value: "album,playlist,artist,show,station,episode"), + URLQueryItem(name: "image_style", value: "gradient_overlay"), + URLQueryItem(name: "country", value: "SG"), + URLQueryItem(name: "locale", value: "en"), + URLQueryItem(name: "market", value: "from_token") + ] + + var req = URLRequest(url: comps.url!) + req.httpMethod = "GET" + req = await addAuthorizationHeader(request: &req) + + do { + let (data, _) = try await URLSession.shared.data(for: req) + let recommendations = try decoder.decode(RecommendationsResponse.self, from: data) + return .success(recommendations) + } catch { + print(error) + return .failure(.unknown) + } + } + + private func addAuthorizationHeader(request: inout URLRequest) async -> URLRequest { + let result = await playerCore.token() + if case let .success(tokenObj) = result { + print(tokenObj.tokenType) + request.setValue("Bearer \(tokenObj.accessToken)", forHTTPHeaderField: "Authorization") + } else { + + // TODO: handle errors here + } + + return request + } +} diff --git a/Spottie/Backend/Types/Base/TokenObject.swift b/Spottie/Backend/Types/Base/TokenObject.swift index f1e3a43..879c5dc 100644 --- a/Spottie/Backend/Types/Base/TokenObject.swift +++ b/Spottie/Backend/Types/Base/TokenObject.swift @@ -8,5 +8,14 @@ import Foundation struct TokenObject: Decodable { - let token: String + let accessToken: String + let tokenType: String + let expiresIn: Int + let scope: [String] + + var authHeader: String { + get { + return "\(tokenType) \(accessToken)" + } + } } diff --git a/Spottie/Backend/Types/WebAPI/WebAPISavedTrackObject.swift b/Spottie/Backend/Types/WebAPI/WebAPISavedTrackObject.swift new file mode 100644 index 0000000..b87cda4 --- /dev/null +++ b/Spottie/Backend/Types/WebAPI/WebAPISavedTrackObject.swift @@ -0,0 +1,13 @@ +// +// WebAPISavedTrackObject.swift +// WebAPISavedTrackObject +// +// Created by Lee Jun Kit on 21/8/21. +// + +import Foundation + +struct WebAPISavedTrackObject: Decodable { + var addedAt: Date + var track: WebAPITrackObject +} diff --git a/Spottie/Backend/Types/WebAPI/WebAPISimplifiedPlaylistObject.swift b/Spottie/Backend/Types/WebAPI/WebAPISimplifiedPlaylistObject.swift new file mode 100644 index 0000000..b9634b3 --- /dev/null +++ b/Spottie/Backend/Types/WebAPI/WebAPISimplifiedPlaylistObject.swift @@ -0,0 +1,19 @@ +// +// WebAPISimplifiedPlaylistObject.swift +// WebAPISimplifiedPlaylistObject +// +// Created by Lee Jun Kit on 22/9/21. +// + +import Foundation + +struct WebAPISimplifiedPlaylistObject: Decodable, Identifiable, WebAPIImageCollection { + let collaborative: Bool + let description: String? + let id: String + let uri: String + let images: [WebAPIImageObject] + let name: String + let snapshotId: String + let tracks: WebAPIPlaylistTracksRefObject +} diff --git a/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift b/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift index 4c8e497..57a2974 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPITrackObject.swift @@ -5,7 +5,7 @@ // Created by Lee Jun Kit on 24/5/21. // -struct WebAPITrackObject: Decodable { +struct WebAPITrackObject: Decodable, Identifiable { var id: String var uri: String var album: WebAPISimplifiedAlbumObject @@ -16,4 +16,17 @@ struct WebAPITrackObject: Decodable { var popularity: Int var discNumber: Int var trackNumber: Int + + var artistString: String { + get { + return artists.map({$0.name}).joined(separator: ", ") + } + } + + var durationString: String { + get { + let durationSeconds = Double(durationMs / 1000) + return DurationFormatter.shared.format(durationSeconds) + } + } } diff --git a/Spottie/Commands/PlayerCommands.swift b/Spottie/Commands/PlayerCommands.swift index d6e80e2..8bad23a 100644 --- a/Spottie/Commands/PlayerCommands.swift +++ b/Spottie/Commands/PlayerCommands.swift @@ -9,25 +9,17 @@ import SwiftUI import Combine struct PlayerCommands: Commands { - struct MenuContent: View { - private var viewModel: ViewModel = ViewModel() - var body: some View { - Button("Toggle Play/Pause") { - SpotifyAPI.togglePlayPause().sink { _ in } receiveValue: { _ in }.store(in: &viewModel.cancellables) - } - .keyboardShortcut(" ", modifiers: []) - } - } + @Inject var playerCore: PlayerCore + @Inject var webAPI: SpotifyWebAPI var body: some Commands { CommandMenu("Player") { - MenuContent() + Button("Toggle Playback") { + Task.init { + await playerCore.togglePlayback() + } + } + .keyboardShortcut(" ", modifiers: []) } } } - -extension PlayerCommands.MenuContent { - class ViewModel: ObservableObject { - var cancellables = [AnyCancellable]() - } -} diff --git a/Spottie/Persistence/DBService.swift b/Spottie/Persistence/DBService.swift new file mode 100644 index 0000000..867616a --- /dev/null +++ b/Spottie/Persistence/DBService.swift @@ -0,0 +1,21 @@ +// +// DBService.swift +// DBService +// +// Created by Lee Jun Kit on 25/8/21. +// + +import Foundation +import CoreData + +struct DBService { + let container: NSPersistentContainer + init() { + container = NSPersistentContainer(name: "v1") + container.loadPersistentStores { storeDescription, error in + if let error = error { + fatalError("Fatal: Cannot load persistent stores: \(error)") + } + } + } +} diff --git a/Spottie/Persistence/v1.xcdatamodeld/v1.xcdatamodel/contents b/Spottie/Persistence/v1.xcdatamodeld/v1.xcdatamodel/contents new file mode 100644 index 0000000..3cd938d --- /dev/null +++ b/Spottie/Persistence/v1.xcdatamodeld/v1.xcdatamodel/contents @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Spottie/PlayerCore/player_core.h b/Spottie/PlayerCore/player_core.h deleted file mode 100644 index d0193bc..0000000 --- a/Spottie/PlayerCore/player_core.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// player_core.h -// Spottie -// -// Created by Lee Jun Kit on 14/8/21. -// - -#ifndef player_core_h -#define player_core_h - -#include -#include -#include -#include - -void librespot_init(const void *user_data, - void (*event_callback)(const void*, uint8_t*, uintptr_t)); - - -#endif /* player_core_h */