Skip to content

Commit c7f68a4

Browse files
committed
Fix Spotify search feature
1 parent 6f85b1a commit c7f68a4

File tree

10 files changed

+316
-274
lines changed

10 files changed

+316
-274
lines changed

Sources/LyricsService/JSONModel/LRCLIB/LRCLIBResponse.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12

23
public struct LRCLIBResponse: Codable {
34
let id: Int

Sources/LyricsService/JSONModel/Spotify/SpotifyLyricsResponse.swift renamed to Sources/LyricsService/JSONModel/Spotify/SpotifyResponseSingleLyrics.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ struct SpotifyResponseSingleLyrics: Codable {
1818
let language: String
1919
let isRtlLanguage: Bool
2020
let capStatus: String
21-
let isSnippet: Bool
2221

2322
private enum CodingKeys: String, CodingKey {
2423
case syncType
@@ -31,7 +30,6 @@ struct SpotifyResponseSingleLyrics: Codable {
3130
case language
3231
case isRtlLanguage
3332
case capStatus
34-
case isSnippet
3533
}
3634
}
3735

Sources/LyricsService/Provider/Service.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ extension LyricsProviders {
1616
case qq
1717
case kugou
1818
case lrclib
19-
case spotify(accessToken: String)
19+
case spotify(searchAccessToken: String, lyricsAccessToken: String)
2020

2121
public var displayName: String {
2222
switch self {
@@ -46,7 +46,7 @@ extension LyricsProviders.Service {
4646
case .netease: return LyricsProviders.NetEase()
4747
case .qq: return LyricsProviders.QQMusic()
4848
case .kugou: return LyricsProviders.Kugou()
49-
case .spotify(let accessToken): return LyricsProviders.Spotify(accessToken: accessToken)
49+
case .spotify(let searchAccessToken, let lyricsAccessToken): return LyricsProviders.Spotify(searchAccessToken: searchAccessToken, lyricsAccessToken: lyricsAccessToken)
5050
case .lrclib: return LyricsProviders.LRCLIB()
5151
// default: return LyricsProviders.Unsupported()
5252
}

Sources/LyricsService/Provider/Services/Spotify.swift

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,14 @@ extension LyricsProviders {
88
final class Spotify: _LyricsProvider {
99
typealias LyricsToken = SpotifyResponseSearchResult.Track.Item
1010

11-
let accessToken: String
11+
let searchAccessToken: String
1212

13-
init(accessToken: String) {
14-
self.accessToken = accessToken
15-
}
16-
17-
static let fakeSpotifyUserAgentconfig: URLSessionConfiguration = {
18-
let fakeSpotifyUserAgentconfig = URLSessionConfiguration.default
19-
fakeSpotifyUserAgentconfig.httpAdditionalHeaders = ["User-Agent": "Spotify/121000760 Win32/0 (PC laptop)"]
20-
return fakeSpotifyUserAgentconfig
21-
}()
13+
let lyricsAccessToken: String
2214

23-
static let fakeSpotifyUserAgentSession: URLSession = .init(configuration: fakeSpotifyUserAgentconfig)
15+
init(searchAccessToken: String, lyricsAccessToken: String) {
16+
self.searchAccessToken = searchAccessToken
17+
self.lyricsAccessToken = lyricsAccessToken
18+
}
2419
}
2520
}
2621

@@ -30,34 +25,45 @@ extension LyricsProviders.Spotify {
3025
func lyricsSearchPublisher(request: LyricsSearchRequest) -> AnyPublisher<LyricsToken, Never> {
3126
let url: URL
3227
switch request.searchTerm {
33-
case let .keyword(string):
28+
case .keyword(let string):
3429
url = URL(string: "https://api.spotify.com/v1/search?q=track:\(string)&type=track&limit=10")!
35-
case let .info(title, artist):
30+
case .info(let title, let artist):
3631
url = URL(string: "https://api.spotify.com/v1/search?q=track:\(title) artist:\(artist)&type=track&limit=10")!
3732
}
3833

3934
var req = URLRequest(url: url)
4035
req.addValue("WebPlayer", forHTTPHeaderField: "app-platform")
41-
req.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
36+
req.addValue("Bearer \(searchAccessToken)", forHTTPHeaderField: "Authorization")
4237
return sharedURLSession.cx.dataTaskPublisher(for: req)
4338
.map(\.data)
4439
.decode(type: SpotifyResponseSearchResult.self, decoder: JSONDecoder().cx)
4540
.map(\.tracks.items)
4641
.replaceError(with: [])
4742
.flatMap(Publishers.Sequence.init)
48-
.map { $0 as LyricsToken }
43+
.map {
44+
$0 as LyricsToken
45+
}
4946
.eraseToAnyPublisher()
5047
}
5148

5249
func lyricsFetchPublisher(token: LyricsToken) -> AnyPublisher<Lyrics, Never> {
53-
let url = URL(string: "https://spclient.wg.spotify.com/color-lyrics/v2/track/\(token.id)?format=json&vocalRemoval=false")!
50+
let url = URL(string: "https://spclient.wg.spotify.com/color-lyrics/v2/track/\(token.id)?format=json&vocalRemoval=false&market=from_token")!
5451
var request = URLRequest(url: url)
5552
request.addValue("WebPlayer", forHTTPHeaderField: "app-platform")
56-
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
53+
request.addValue("Bearer \(lyricsAccessToken)", forHTTPHeaderField: "Authorization")
5754

58-
return Self.fakeSpotifyUserAgentSession.cx.dataTaskPublisher(for: request)
55+
return sharedURLSession.cx.dataTaskPublisher(for: request)
5956
.map(\.data)
57+
.handleEvents(receiveOutput: { data in
58+
if let jsonString = String(data: data, encoding: .utf8) {
59+
print("Received JSON: \(jsonString)")
60+
}
61+
})
6062
.decode(type: SpotifyResponseSingleLyrics.self, decoder: JSONDecoder().cx)
63+
.catch { error -> AnyPublisher<SpotifyResponseSingleLyrics, Error> in
64+
print("Decode error: \(error)")
65+
return Fail(error: error).eraseToAnyPublisher()
66+
}
6167
.map {
6268
let lyrics = Lyrics(lines: $0.lyrics.lines.map {
6369
LyricsLine(content: $0.words, position: (Double($0.startTimeMs) ?? 0) / 1000)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
import KeychainAccess
3+
4+
@propertyWrapper
5+
struct Keychain<T: Codable> {
6+
private let keychain: KeychainAccess.Keychain
7+
8+
var wrappedValue: T {
9+
set {
10+
do {
11+
keychain[data: key] = try JSONEncoder().encode(newValue)
12+
_cacheWrappedValue = newValue
13+
} catch {
14+
print(error)
15+
}
16+
}
17+
mutating get {
18+
if let _cacheWrappedValue {
19+
return _cacheWrappedValue
20+
} else {
21+
if let data = keychain[data: key],
22+
let value = try? JSONDecoder().decode(T.self, from: data) {
23+
_cacheWrappedValue = value
24+
return value
25+
} else {
26+
return defaultValue
27+
}
28+
}
29+
}
30+
}
31+
32+
private var _cacheWrappedValue: T?
33+
34+
private let defaultValue: T
35+
36+
private let key: String
37+
38+
init(key: String, service: String, defaultValue: T) {
39+
self.keychain = .init(service: service).synchronizable(true)
40+
self.key = key
41+
self.defaultValue = defaultValue
42+
}
43+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
3+
struct SpotifyAccessToken: Codable {
4+
let accessToken: String
5+
let accessTokenExpirationTimestampMs: TimeInterval
6+
let isAnonymous: Bool
7+
8+
var expirationDate: Date {
9+
return Date(timeIntervalSince1970: accessTokenExpirationTimestampMs / 1000)
10+
}
11+
12+
static func searchAccessToken(forCookie cookie: String) async throws -> Self {
13+
try await accessToken(forCookie: cookie, reason: "init", productType: "mobile-web-player")
14+
}
15+
16+
static func lyricsAccessToken(forCookie cookie: String) async throws -> Self {
17+
try await accessToken(forCookie: cookie, reason: "transport", productType: "web-player")
18+
}
19+
20+
private static func accessToken(forCookie cookie: String, reason: String, productType: String) async throws -> Self {
21+
struct ServerTime: Codable {
22+
var serverTime: Int
23+
}
24+
struct SecretKeyEntry: Codable {
25+
let version: Int
26+
let secret: String
27+
}
28+
enum Error: Swift.Error {
29+
case totpGenerationFailed
30+
}
31+
let secretKeyURL = URL(string: "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secrets.json")!
32+
let serverTimeRequest = URLRequest(url: .init(string: "https://open.spotify.com/api/server-time")!)
33+
let serverTimeData = try await URLSession.shared.data(for: serverTimeRequest).0
34+
let serverTime = try JSONDecoder().decode(ServerTime.self, from: serverTimeData).serverTime
35+
let (data, _) = try await URLSession.shared.data(from: secretKeyURL)
36+
let secretEntries = try JSONDecoder().decode([SecretKeyEntry].self, from: data)
37+
guard let lastEntry = secretEntries.last else {
38+
throw Error.totpGenerationFailed
39+
}
40+
guard let totp = TOTPGenerator.generate(secretCipher: .init(lastEntry.secret.utf8), serverTimeSeconds: serverTime) else {
41+
throw Error.totpGenerationFailed
42+
}
43+
let tokenURL = URL(string: "https://open.spotify.com/api/token")!
44+
let params: [String: String] = [
45+
"reason": reason,
46+
"productType": productType,
47+
"totp": totp,
48+
"totpVer": lastEntry.version.description,
49+
"ts": String(Int(Date().timeIntervalSince1970)),
50+
]
51+
var components = URLComponents(url: tokenURL, resolvingAgainstBaseURL: false)!
52+
components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
53+
54+
var request = URLRequest(url: components.url!)
55+
request.httpMethod = "GET"
56+
request.setValue("sp_dc=\(cookie)", forHTTPHeaderField: "Cookie")
57+
request.setValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
58+
let accessTokenData = try await URLSession.shared.data(for: request).0
59+
try print(JSONSerialization.jsonObject(with: accessTokenData))
60+
return try JSONDecoder().decode(SpotifyAccessToken.self, from: accessTokenData)
61+
}
62+
}

0 commit comments

Comments
 (0)