Skip to content

Commit 2e1a536

Browse files
committed
Initial
0 parents  commit 2e1a536

67 files changed

Lines changed: 3135 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
9+
Package.resolved
10+
\#*\#
11+
*.swiftdeps*

.swift-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
6

.swiftformat

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
--self init-only
2+
--disable wrapMultilineStatementBraces
3+
--enable wrapMultilineConditionalAssignment
4+
--xcodeindentation enabled
5+
--ifdef outdent
6+
7+
--header "// This Source Code Form is subject to the terms of the Mozilla Public\n// License, v. 2.0. If a copy of the MPL was not distributed with this\n// file, You can obtain one at https://mozilla.org/MPL/2.0/."

.swiftlint.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
disabled_rules:
2+
- trailing_comma
3+
4+
identifier_name:
5+
min_length: 2

LICENSE.md

Lines changed: 373 additions & 0 deletions
Large diffs are not rendered by default.

Package.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// swift-tools-version: 6.0
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "ListenBrainzKit",
7+
platforms: [
8+
.macOS(.v13),
9+
.iOS(.v16),
10+
.tvOS(.v16),
11+
.visionOS(.v1),
12+
.watchOS(.v9),
13+
],
14+
products: [
15+
.library(
16+
name: "ListenBrainzKit",
17+
targets: ["ListenBrainzKit"]
18+
),
19+
],
20+
dependencies: [
21+
.package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.57.0"),
22+
],
23+
targets: [
24+
.target(
25+
name: "ListenBrainzKit",
26+
dependencies: [],
27+
path: "Sources",
28+
plugins: [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins")]
29+
),
30+
.testTarget(
31+
name: "ListenBrainzKitTests",
32+
dependencies: ["ListenBrainzKit"]
33+
),
34+
]
35+
)

README.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# ListenBrainzKit
2+
3+
ListenBrainzKit is a Swift wrapper around the [ListenBrainz API](https://listenbrainz.readthedocs.io/en/latest/users/api/index.html) originally built for my iOS music app, [Jewelcase](https://apps.apple.com/us/app/jewelcase/id6642683626).
4+
5+
## Requirements
6+
- Swift 6
7+
- iOS 16+
8+
- MacOS 13+
9+
- tvOS 16+
10+
- visionOS 1+
11+
- watchOS 9+
12+
13+
## Setup
14+
15+
Add ListenBrainzKit as a dependency in your Package.swift
16+
17+
```swift
18+
dependencies: [
19+
.package(url: "https://github.com/samglt/ListenBrainzKit", from: "0.1.0"),
20+
],
21+
```
22+
23+
Or add it in XCode by selecting `File > Add Package Dependencies...` and entering `https://github.com/samglt/ListenBrainzKit`
24+
25+
## Usage
26+
27+
You can initialize a client with a ListenBrainz token and an optional custom root URL if you're not using the official ListenBrainz endpoint (eg. if you're running your own instance).
28+
29+
```swift
30+
import ListenBrainzKit
31+
32+
let client = LBClient(token: token)
33+
// or:
34+
let client = LBClient(token: token, customRoot: URL(string: "https://10.0.0.10:1234")!)
35+
```
36+
37+
Submitting listens:
38+
39+
```swift
40+
// Submit a single listen
41+
try await client.core.submitListen(meta: .init(artist: "Kylie Minogue",
42+
track: "Come Into My World",
43+
release: "Fever",
44+
tracknumber: 7),
45+
at: time)
46+
47+
// Submit a batch of past listens
48+
try await client.core.submitListens([
49+
.init(meta: .init(artist: "Kylie Minogue",
50+
track: "Fever"),
51+
listenedAt: Date.now.addingTimeInterval(-180)),
52+
.init(meta: .init(artist: "Kylie Minogue",
53+
track: "More More More"),
54+
listenedAt: Date.now.addingTimeInterval(-360))
55+
])
56+
```
57+
58+
## Supported Endpoints
59+
60+
- Core
61+
- [x] GET /1/search/users/
62+
- [x] POST /1/submit-listens
63+
- [x] GET /1/user/(user_name)/listens
64+
- [x] GET /1/user/(user_name)/listen-count
65+
- [x] GET /1/user/(user_name)/playing-now
66+
- [x] GET /1/user/(user_name)/similar-users
67+
- [x] GET /1/user/(user_name)/similar-to/(other_user_name)
68+
- [x] GET /1/validate-token
69+
- [x] POST /1/delete-listen
70+
- [x] GET /1/user/(playlist_user_name)/playlists
71+
- [x] GET /1/user/(playlist_user_name)/playlists/createdfor
72+
- [x] GET /1/user/(playlist_user_name)/playlists/collaborator
73+
- [x] GET /1/user/(playlist_user_name)/playlists/recommendations
74+
- [x] GET /1/user/(playlist_user_name)/playlists/search
75+
- [x] GET /1/user/(user_name)/services
76+
- [x] GET /1/lb-radio/tags
77+
- [x] GET /1/lb-radio/artist/(seed_artist_mbid)
78+
- [x] GET /1/latest-import
79+
- [x] POST /1/latest-import
80+
- Metadata
81+
- [x] GET /1/metadata/recording/
82+
- [x] POST /1/metadata/recording/
83+
- [x] GET /1/metadata/release_group/
84+
- [x] GET /1/metadata/lookup/
85+
- [x] POST /1/metadata/lookup/
86+
- [x] GET /1/metadata/artist/
87+
- [ ] POST /1/metadata/submit_manual_mapping/
88+
- [ ] GET /1/metadata/get_manual_mapping/
89+
- Statistics
90+
- [ ] GET /1/stats/user/(user_name)/artists
91+
- [ ] GET /1/stats/user/(user_name)/releases
92+
- [ ] GET /1/stats/user/(user_name)/release-groups
93+
- [ ] GET /1/stats/user/(user_name)/recordings
94+
- [ ] GET /1/stats/user/(user_name)/listening-activity
95+
- [ ] GET /1/stats/user/(user_name)/daily-activity
96+
- [ ] GET /1/stats/user/(user_name)/artist-map
97+
- [ ] GET /1/stats/artist/(artist_mbid)/listeners
98+
- [ ] GET /1/stats/release-group/(release_group_mbid)/listeners
99+
- [ ] GET /1/stats/sitewide/artists
100+
- [ ] GET /1/stats/sitewide/releases
101+
- [ ] GET /1/stats/sitewide/release-groups
102+
- [ ] GET /1/stats/sitewide/recordings
103+
- [ ] GET /1/stats/sitewide/listening-activity
104+
- [ ] GET /1/stats/sitewide/artist-map
105+
- [ ] GET /1/stats/user/(user_name)/year-in-music/(int: year)
106+
- [ ] GET /1/stats/user/(user_name)/year-in-music
107+
- Popularity
108+
- [ ] GET /1/popularity/top-recordings-for-artist/(artist_mbid)
109+
- [ ] GET /1/popularity/top-release-groups-for-artist/(artist_mbid)
110+
- [ ] POST /1/popularity/recording
111+
- [ ] POST /1/popularity/artist
112+
- [ ] POST /1/popularity/release
113+
- [ ] POST /1/popularity/release-group
114+
- Playlists
115+
- [ ] GET /1/user/(playlist_user_name)/playlists
116+
- [ ] GET /1/user/(playlist_user_name)/playlists/createdfor
117+
- [ ] GET /1/user/(playlist_user_name)/playlists/collaborator
118+
- [ ] POST /1/playlist/create
119+
- [ ] GET /1/playlist/search
120+
- [ ] POST /1/playlist/edit/(playlist_mbid)
121+
- [ ] GET /1/playlist/(playlist_mbid)
122+
- [ ] GET /1/playlist/(playlist_mbid)/xspf
123+
- [ ] POST /1/playlist/(playlist_mbid)/item/add
124+
- [ ] POST /1/playlist/(playlist_mbid)/item/add/(int: offset)
125+
- [ ] POST /1/playlist/(playlist_mbid)/item/move
126+
- [ ] POST /1/playlist/(playlist_mbid)/item/delete
127+
- [ ] POST /1/playlist/(playlist_mbid)/delete
128+
- [ ] POST /1/playlist/(playlist_mbid)/copy
129+
- [ ] POST /1/playlist/(playlist_mbid)/export/(service)
130+
- [ ] GET /1/playlist/import/(service)
131+
- [ ] GET /1/playlist/(service)/(playlist_id)/tracks
132+
- [ ] POST /1/playlist/export-jspf/(service)
133+
- Recordings
134+
- [ ] POST /1/feedback/recording-feedback
135+
- [ ] GET /1/feedback/user/(user_name)/get-feedback
136+
- [ ] GET /1/feedback/recording/(recording_mbid)/get-feedback-mbid
137+
- [ ] GET /1/feedback/recording/(recording_msid)/get-feedback
138+
- [ ] GET /1/feedback/user/(user_name)/get-feedback-for-recordings
139+
- [ ] POST /1/feedback/user/(user_name)/get-feedback-for-recordings
140+
- [ ] POST /1/feedback/import
141+
- Pinned Recording API
142+
- [ ] POST /1/pin
143+
- [ ] POST /1/pin/unpin
144+
- [ ] POST /1/pin/delete/(row_id)
145+
- [ ] GET /1/(user_name)/pins
146+
- [ ] GET /1/(user_name)/pins/following
147+
- [ ] GET /1/(user_name)/pins/current
148+
- [ ] POST /1/pin/update/(row_id)
149+
- Social
150+
- [ ] POST /1/user/(user_name)/timeline-event/create/recording
151+
- [ ] POST /1/user/(user_name)/timeline-event/create/notification
152+
- [ ] POST /1/user/(user_name)/timeline-event/create/review
153+
- [ ] GET /1/user/(user_name)/feed/events
154+
- [ ] GET /1/user/(user_name)/feed/events/listens/following
155+
- [ ] GET /1/user/(user_name)/feed/events/listens/similar
156+
- [ ] POST /1/user/(user_name)/feed/events/delete
157+
- [ ] POST /1/user/(user_name)/feed/events/hide
158+
- [ ] POST /1/user/(user_name)/feed/events/unhide
159+
- [ ] POST /1/user/(user_name)/timeline-event/create/recommend-personal
160+
- Follow API
161+
- [ ] GET /1/user/(user_name)/followers
162+
- [ ] GET /1/user/(user_name)/following
163+
- [ ] POST /1/user/(user_name)/follow
164+
- [ ] POST /1/user/(user_name)/unfollow
165+
- Recommendations
166+
- [ ] GET /1/cf/recommendation/user/(user_name)/recording
167+
- Feedback
168+
- [ ] POST /1/recommendation/feedback/submit
169+
- [ ] POST /1/recommendation/feedback/delete
170+
- [ ] GET /1/recommendation/feedback/user/(user_name)
171+
- [ ] GET /1/recommendation/feedback/user/(user_name)/recordings
172+
- Art
173+
- [ ] POST /1/art/grid/
174+
- [ ] GET /1/art/grid-stats/(user_name)/(time_range)/(int: dimension)/(int: layout)/(int: image_size)
175+
- [ ] GET /1/art/(custom_name)/(user_name)/(time_range)/(int: image_size)
176+
- [ ] GET /1/art/year-in-music/(int: year)/(user_name)
177+
- Misc
178+
- Explore
179+
- [ ] GET /1/explore/fresh-releases/
180+
- [ ] GET /1/explore/color/(color)
181+
- [ ] GET /1/explore/lb-radio
182+
- Status
183+
- [ ] GET /1/status/get-dump-info
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
import Foundation
6+
7+
protocol APIClient: Sendable {
8+
func execute<Request: APIRequest>(_ request: Request) async throws -> Request.Result
9+
}
10+
11+
struct ListenBrainzAPIClient: APIClient {
12+
let token: String
13+
let root: URL
14+
15+
init(token: String, root: URL?) {
16+
self.token = token
17+
self.root = root ?? URL(string: "https://api.listenbrainz.org")!
18+
}
19+
20+
func execute<Request: APIRequest>(_ request: Request) async throws -> Request.Result {
21+
let url = root
22+
.appending(component: request.data.path)
23+
.appending(queryItems: request.data.queryItems.flatMap { entry in
24+
entry.value.map { URLQueryItem(name: entry.key, value: $0) }
25+
})
26+
27+
var req = URLRequest(url: url)
28+
req.httpMethod = request.data.method.rawValue
29+
req.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
30+
31+
for (header, value) in request.data.headers {
32+
req.addValue(value, forHTTPHeaderField: header)
33+
}
34+
35+
if let body = request.data.body {
36+
let bodyData = try JSONEncoder.ListenBrainz.encode(body)
37+
req.httpBody = bodyData
38+
}
39+
40+
let httpClient = URLSession(configuration: URLSessionConfiguration.default)
41+
let (data, resp) = try await httpClient.data(for: req)
42+
43+
if let httpResp = resp as? HTTPURLResponse,
44+
httpResp.statusCode != 200 {
45+
let code = httpResp.statusCode
46+
if code == 429 { throw LBError.rateLimited }
47+
throw request.data.statusErrors[httpResp.statusCode] ?? .unknownError
48+
}
49+
50+
return try JSONDecoder.ListenBrainz.decode(Request.Result.self, from: data)
51+
}
52+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
import Foundation
6+
7+
protocol APIRequest {
8+
associatedtype Body: Encodable
9+
associatedtype Result: Decodable
10+
11+
var data: APIRequestData<Body> { get }
12+
}
13+
14+
class NoBody: Encodable {}
15+
class NoResult: Decodable {}
16+
17+
enum Method: String {
18+
case get = "GET"
19+
case post = "POST"
20+
}
21+
22+
struct APIRequestData<Body: Encodable> {
23+
let path: String
24+
let method: Method
25+
let queryItems: [String: [String]]
26+
let headers: [String: String]
27+
let body: Body?
28+
let statusErrors: [Int: LBError]
29+
30+
init(path: String,
31+
method: Method,
32+
queryItems: [String: [String]] = [:],
33+
headers: [String: String] = [:],
34+
body: Body? = nil,
35+
statusErrors: [Int: LBError] = [:]) {
36+
self.path = path
37+
self.method = method
38+
self.queryItems = queryItems
39+
self.headers = headers
40+
self.body = body
41+
self.statusErrors = statusErrors
42+
}
43+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
public enum LBError: Error {
6+
case unknownError
7+
8+
case invalidJSON
9+
case invalidAuth
10+
11+
case invalidParam
12+
case badRequest
13+
14+
case noToken
15+
16+
case userNotFound
17+
case forbidden
18+
19+
case invalidResponse
20+
21+
case rateLimited
22+
}

0 commit comments

Comments
 (0)