├── Sources ├── LyricsService │ ├── LyricsService.swift │ ├── Utilities │ │ ├── Global.swift │ │ ├── Dictionary+HTTPParameter.swift │ │ ├── RegexPattern.swift │ │ ├── Lyrics+Merge.swift │ │ ├── LyricsMetadata+Extension.swift │ │ ├── String+XMLDecode.swift │ │ └── Lyrics+Quality.swift │ ├── Model │ │ ├── Kugou │ │ │ ├── KugouKrcHeaderFieldLanguage.swift │ │ │ ├── KugouResponseSingleLyrics.swift │ │ │ └── KugouResponseSearchResult.swift │ │ ├── LRCLIB │ │ │ └── LRCLIBResponse.swift │ │ ├── QQMusic │ │ │ ├── QQResponseSingleLyrics.swift │ │ │ └── QQResponseSearchResult.swift │ │ ├── NetEase │ │ │ ├── NetEaseResponseSingleLyrics.swift │ │ │ └── NetEaseResponseSearchResult.swift │ │ ├── Spotify │ │ │ ├── SpotifyResponseSingleLyrics.swift │ │ │ └── SpotifyResponseSearchResult.swift │ │ └── Musixmatch │ │ │ ├── MusixmatchResponseSearchResult.swift │ │ │ └── MusixmatchResponseSingleLyrics.swift │ ├── Parser │ │ ├── KugouKrcDecrypter.swift │ │ ├── QQMusicQrcParser.swift │ │ ├── KugouKrcParser.swift │ │ ├── NetEaseKLyricParser.swift │ │ └── QQMusicQrcDecrypter.swift │ ├── Provider │ │ ├── AuthenticationManager.swift │ │ ├── LyricsProviderError.swift │ │ ├── Group.swift │ │ ├── LyricsProvider.swift │ │ ├── Service.swift │ │ └── Services │ │ │ ├── LRCLIB.swift │ │ │ ├── Spotify.swift │ │ │ ├── QQMusic.swift │ │ │ ├── Kugou.swift │ │ │ ├── Musixmatch.swift │ │ │ └── NetEase.swift │ └── LyricsSearchRequest.swift ├── LyricsKit │ └── LyricsKit.swift ├── LyricsCore │ ├── LyricsMetadata.swift │ ├── RegexPattern.swift │ ├── LyricsLine.swift │ ├── Lyrics+Furigana.swift │ ├── Lyrics.swift │ └── LyricsLineAttachment.swift └── LyricsServiceUI │ ├── Controller │ ├── TOTPGenerator.swift │ ├── SpotifyLoginWindowController.swift │ ├── Keychain.swift │ ├── SpotifyLoginViewController.swift │ ├── SpotifyAccessToken.swift │ └── SpotifyLoginManager.swift │ └── Drawing │ └── LyricsSourceIconDrawing+Image.swift ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── Tests └── LyricsKitTests │ └── LyricsKitTests.swift ├── Package.swift ├── README.md └── LICENSE /Sources/LyricsService/LyricsService.swift: -------------------------------------------------------------------------------- 1 | @_exported import LyricsCore 2 | -------------------------------------------------------------------------------- /Sources/LyricsKit/LyricsKit.swift: -------------------------------------------------------------------------------- 1 | @_exported import LyricsCore 2 | @_exported import LyricsService 3 | @_exported import LyricsServiceUI 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .swiftpm 2 | Sources/LyricsService/JSONModel/.DS_Store 3 | Sources/LyricsService/.DS_Store 4 | Sources/.DS_Store 5 | .DS_Store 6 | Package.resolved 7 | .claude 8 | .build 9 | -------------------------------------------------------------------------------- /Sources/LyricsService/Utilities/Global.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | let sharedURLSession = URLSession(configuration: .ephemeral) 7 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/Kugou/KugouKrcHeaderFieldLanguage.swift: -------------------------------------------------------------------------------- 1 | struct KugouKrcHeaderFieldLanguage: Codable { 2 | let content: [Content] 3 | let version: Int 4 | 5 | struct Content: Codable { 6 | // TODO: resolve language/type code 7 | let language: Int 8 | let type: Int 9 | let lyricContent: [[String]] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/LRCLIB/LRCLIBResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LRCLIBResponse: Codable { 4 | let id: Int 5 | let name: String 6 | let trackName: String 7 | let artistName: String 8 | let albumName: String 9 | let duration: Double 10 | let instrumental: Bool 11 | let plainLyrics: String? 12 | let syncedLyrics: String? 13 | } 14 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/Kugou/KugouResponseSingleLyrics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct KugouResponseSingleLyrics: Decodable { 4 | let content: Data 5 | let fmt: Format 6 | 7 | // let info: String 8 | // let status: Int 9 | // let charset: String 10 | // 11 | 12 | enum Format: String, Decodable { 13 | case lrc 14 | case krc 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/LyricsCore/LyricsMetadata.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Lyrics.Metadata.Key { 4 | public static var attachmentTags = Lyrics.Metadata.Key("attachmentTags") 5 | } 6 | 7 | extension Lyrics.Metadata { 8 | public var attachmentTags: Set { 9 | get { return data[.attachmentTags] as? Set ?? [] } 10 | set { data[.attachmentTags] = newValue } 11 | } 12 | 13 | public var hasTranslation: Bool { 14 | return attachmentTags.contains(where: \.isTranslation) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/QQMusic/QQResponseSingleLyrics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct QQResponseSingleLyrics: Decodable { 4 | let retcode: Int 5 | let code: Int 6 | let subcode: Int 7 | let lyric: Data 8 | let trans: Data? 9 | } 10 | 11 | extension QQResponseSingleLyrics { 12 | var lyricString: String? { 13 | return String(data: lyric, encoding: .utf8)?.decodingXMLEntities() 14 | } 15 | 16 | var transString: String? { 17 | guard let data = trans, 18 | let string = String(data: data, encoding: .utf8) else { 19 | return nil 20 | } 21 | return string.decodingXMLEntities() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/LyricsService/Parser/KugouKrcDecrypter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let decodeKey: [UInt8] = [64, 71, 97, 119, 94, 50, 116, 71, 81, 54, 49, 45, 206, 210, 110, 105] 4 | private let flagKey = "krc1".data(using: .ascii)! 5 | 6 | func decryptKugouKrc(_ data: Data) -> String? { 7 | guard data.starts(with: flagKey) else { 8 | return nil 9 | } 10 | 11 | var decrypted = data.dropFirst(4).enumerated().map { index, byte in 12 | return byte ^ decodeKey[index & 0b1111] 13 | } 14 | 15 | decrypted.removeFirst(2) 16 | 17 | guard let unarchivedData = try? (Data(decrypted) as NSData).decompressed(using: .zlib) else { 18 | return nil 19 | } 20 | 21 | return String(bytes: unarchivedData, encoding: .utf8) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/NetEase/NetEaseResponseSingleLyrics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct NetEaseResponseSingleLyrics: Decodable { 4 | let lrc: Lyric? 5 | let klyric: Lyric? 6 | let tlyric: Lyric? 7 | let lyricUser: User? 8 | let yrc: Lyric? 9 | // let sgc: Bool 10 | // let sfy: Bool 11 | // let qfy: Bool 12 | // let code: Int 13 | // let transUser: User 14 | // 15 | 16 | struct User: Decodable { 17 | let nickname: String 18 | 19 | // let id: Int 20 | // let status: Int 21 | // let demand: Int 22 | // let userid: Int 23 | // let uptime: Int 24 | // 25 | } 26 | 27 | struct Lyric: Decodable { 28 | let lyric: String? 29 | 30 | // let version: Int 31 | // 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/LyricsService/Utilities/Dictionary+HTTPParameter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Dictionary where Key == String { 4 | var stringFromHttpParameters: String { 5 | let parameterArray = map { key, value -> String in 6 | let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .uriComponentAllowed)! 7 | let escapedValue = "\(value)".addingPercentEncoding(withAllowedCharacters: .uriComponentAllowed)! 8 | return escapedKey + "=" + escapedValue 9 | } 10 | return parameterArray.joined(separator: "&") 11 | } 12 | } 13 | 14 | extension CharacterSet { 15 | static var uriComponentAllowed: CharacterSet { 16 | // [-._~0-9a-zA-Z] in RFC 3986 17 | let unsafe = CharacterSet(charactersIn: "!*'();:&=+$,[]~") 18 | return CharacterSet.urlHostAllowed.subtracting(unsafe) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/LyricsService/Provider/AuthenticationManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol AuthenticationManager: Sendable { 4 | func isAuthenticated() async -> Bool 5 | func authenticate() async throws 6 | func getCredentials() async throws -> [String: String] 7 | } 8 | 9 | public enum AuthenticationError: Error { 10 | case notAuthenticated 11 | case credentialsNotFound 12 | case authenticationFailed(Error) 13 | } 14 | 15 | public actor AuthenticationManagerStore { 16 | public static let shared = AuthenticationManagerStore() 17 | 18 | private var musixmatchTokenValue: String? 19 | 20 | public init() {} 21 | 22 | /// Set (or clear) the stored Musixmatch user token. 23 | public func setMusixmatchToken(_ token: String?) { 24 | musixmatchTokenValue = token 25 | } 26 | 27 | /// Retrieve the stored Musixmatch user token, if any. 28 | public func musixmatchToken() -> String? { 29 | return musixmatchTokenValue 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/LyricsServiceUI/Controller/TOTPGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftOTP 3 | 4 | enum TOTPGenerator { 5 | static func generate(secretCipher: [UInt8], serverTimeSeconds: Int) -> String? { 6 | var processed = [UInt8]() 7 | 8 | for (i, byte) in secretCipher.enumerated() { 9 | processed.append(UInt8(byte ^ UInt8(i % 33 + 9))) 10 | } 11 | 12 | let processedStr = processed.map { String($0) }.joined() 13 | 14 | guard let utf8Bytes = processedStr.data(using: .utf8) else { 15 | return nil 16 | } 17 | 18 | let secretBase32 = utf8Bytes.base32EncodedString 19 | 20 | guard let secretData = base32DecodeToData(secretBase32) else { 21 | return nil 22 | } 23 | 24 | guard let totp = TOTP(secret: secretData, digits: 6, timeInterval: 30, algorithm: .sha1) else { 25 | return nil 26 | } 27 | 28 | return totp.generate(secondsPast1970: serverTimeSeconds) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/LyricsService/Provider/LyricsProviderError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // Custom error type for more specific error handling 4 | public enum LyricsProviderError: Error, LocalizedError { 5 | case invalidURL(urlString: String) 6 | case networkError(underlyingError: Error) 7 | case decodingError(underlyingError: Error) 8 | case processingFailed(reason: String) 9 | 10 | public var errorDescription: String? { 11 | switch self { 12 | case .invalidURL(let urlString): 13 | return "The provided URL is invalid: \(urlString)" 14 | case .networkError(let underlyingError): 15 | return "A network error occurred: \(underlyingError.localizedDescription)" 16 | case .decodingError(let underlyingError): 17 | return "Failed to decode the server response: \(underlyingError.localizedDescription)" 18 | case .processingFailed(let reason): 19 | return "Failed to process the lyrics data: \(reason)" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/LyricsService/Utilities/RegexPattern.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Regex 3 | 4 | private let timeTagRegex = Regex(#"\[([-+]?\d+):(\d+(?:\.\d+)?)\]"#) 5 | 6 | func resolveTimeTag(_ str: String) -> [TimeInterval] { 7 | let matchs = timeTagRegex.matches(in: str) 8 | return matchs.map { match in 9 | let min = Double(match[1]!.content)! 10 | let sec = Double(match[2]!.content)! 11 | return min * 60 + sec 12 | } 13 | } 14 | 15 | let id3TagRegex = Regex(#"^(?!\[[+-]?\d+:\d+(?:\.\d+)?\])\[(.+?):(.+)\]$"#, options: .anchorsMatchLines) 16 | 17 | let krcLineRegex = Regex(#"^\[(\d+),(\d+)\](.*)"#, options: .anchorsMatchLines) 18 | 19 | let qrcLineRegex = Regex(#"^\[(\d+),(\d+)\](.*)"#, options: [.anchorsMatchLines]) 20 | 21 | let netEaseYrcInlineTagRegex = Regex(#"\((\d+),(\d+),0\)([^(]*)"#) 22 | 23 | let netEaseInlineTagRegex = Regex(#"\(0,(\d+)\)([^(]+)(\(0,1\) )?"#) 24 | 25 | let kugouInlineTagRegex = Regex(#"<(\d+),(\d+),0>([^<]*)"#) 26 | 27 | let qqmusicInlineTagRegex = Regex(#"([^(]*)\((\d+),(\d+)\)"#) 28 | -------------------------------------------------------------------------------- /Sources/LyricsServiceUI/Controller/SpotifyLoginWindowController.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | public final class SpotifyLoginWindowController: NSWindowController { 4 | public init() { 5 | super.init(window: nil) 6 | } 7 | 8 | @available(*, unavailable) 9 | public required init?(coder: NSCoder) { 10 | fatalError("init(coder:) has not been implemented") 11 | } 12 | 13 | public override func loadWindow() { 14 | let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled, .closable], backing: .buffered, defer: false) 15 | window.title = "Spotify Login" 16 | window.setContentSize(NSSize(width: 800, height: 600)) 17 | self.window = window 18 | } 19 | 20 | lazy var loginViewController = SpotifyLoginViewController() 21 | 22 | public override var windowNibName: NSNib.Name? { "" } 23 | 24 | public override func windowDidLoad() { 25 | contentViewController = loginViewController 26 | window?.center() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/LyricsCore/RegexPattern.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Regex 3 | 4 | private let timeTagRegex = Regex(#"\[([-+]?\d+):(\d+(?:\.\d+)?)\]"#) 5 | 6 | func resolveTimeTag(_ str: String) -> [TimeInterval] { 7 | let matchs = timeTagRegex.matches(in: str) 8 | return matchs.map { match in 9 | let min = Double(match[1]!.content)! 10 | let sec = Double(match[2]!.content)! 11 | return min * 60 + sec 12 | } 13 | } 14 | 15 | let id3TagRegex = Regex(#"^(?!\[[+-]?\d+:\d+(?:\.\d+)?\])\[(.+?):(.+)\]$"#, options: .anchorsMatchLines) 16 | 17 | let lyricsLineRegex = Regex(#"^(\[[+-]?\d+:\d+(?:\.\d+)?\])+(?!\[)([^【\n\r]*)(?:【(.*)】)?"#, options: .anchorsMatchLines) 18 | 19 | let base60TimeRegex = Regex(#"^\s*(?:(\d+):)?(\d+(?:.\d+)?)\s*$"#) 20 | 21 | let lyricsLineAttachmentRegex = Regex(#"^(\[[+-]?\d+:\d+(?:\.\d+)?\])+\[(.+?)\](.*)"#, options: .anchorsMatchLines) 22 | 23 | let timeLineAttachmentRegex = Regex(#"<(\d+,\d+)>"#) 24 | 25 | let timeLineAttachmentDurationRegex = Regex(#"<(\d+)>"#) 26 | 27 | let rangeAttachmentRegex = Regex(#"<([^,]+,\d+,\d+)>"#) 28 | -------------------------------------------------------------------------------- /Sources/LyricsService/Provider/Group.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | extension LyricsProviders { 5 | public final class Group: LyricsProvider { 6 | public let providers: [LyricsProvider] 7 | 8 | public init(service: [LyricsProviders.Service] = LyricsProviders.Service.noAuthenticationRequiredServices) { 9 | self.providers = service.map { $0.create() } 10 | } 11 | 12 | public init(providers: [LyricsProvider]) { 13 | self.providers = providers 14 | } 15 | 16 | public func lyrics(for request: LyricsSearchRequest) -> AsyncThrowingStream { 17 | return AsyncThrowingStream { continuation in 18 | Task { 19 | for provider in providers { 20 | do { 21 | for try await lyric in provider.lyrics(for: request) { 22 | continuation.yield(lyric) 23 | } 24 | } catch { 25 | print("A provider in the group failed: \(error)") 26 | } 27 | } 28 | continuation.finish() 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/LyricsService/LyricsSearchRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LyricsSearchRequest: Equatable { 4 | public var searchTerm: SearchTerm 5 | public var duration: TimeInterval 6 | public var limit: Int 7 | public var userInfo: [String: String] 8 | 9 | public enum SearchTerm: Equatable { 10 | case keyword(String) 11 | case info(title: String, artist: String) 12 | } 13 | 14 | public init(searchTerm: SearchTerm, duration: TimeInterval, limit: Int = 6, userInfo: [String: String] = [:]) { 15 | self.searchTerm = searchTerm 16 | self.duration = duration 17 | self.limit = limit 18 | self.userInfo = userInfo 19 | } 20 | } 21 | 22 | extension LyricsSearchRequest.SearchTerm: CustomStringConvertible { 23 | public var description: String { 24 | switch self { 25 | case .keyword(let keyword): 26 | return keyword 27 | case .info(title: let title, artist: let artist): 28 | return title + " " + artist 29 | } 30 | } 31 | 32 | public var titleOnly: String { 33 | switch self { 34 | case .keyword(let string): 35 | return string 36 | case .info(let title, _): 37 | return title 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/LyricsServiceUI/Controller/Keychain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import KeychainAccess 3 | 4 | @propertyWrapper 5 | struct Keychain { 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 | } 44 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/Spotify/SpotifyResponseSingleLyrics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SpotifyResponseSingleLyrics: Codable { 4 | struct Lyric: Codable { 5 | struct Line: Codable { 6 | let startTimeMs: String 7 | let words: String 8 | let endTimeMs: String 9 | } 10 | 11 | let syncType: String 12 | let lines: [Line] 13 | let provider: String 14 | let providerLyricsID: String 15 | let providerDisplayName: String 16 | let syncLyricsUri: String 17 | let isDenseTypeface: Bool 18 | let language: String 19 | let isRtlLanguage: Bool 20 | let capStatus: String 21 | 22 | private enum CodingKeys: String, CodingKey { 23 | case syncType 24 | case lines 25 | case provider 26 | case providerLyricsID = "providerLyricsId" 27 | case providerDisplayName 28 | case syncLyricsUri 29 | case isDenseTypeface 30 | case language 31 | case isRtlLanguage 32 | case capStatus 33 | } 34 | } 35 | 36 | struct Color: Codable { 37 | let background: Int 38 | let text: Int 39 | let highlightText: Int 40 | } 41 | 42 | let lyrics: Lyric 43 | let colors: Color 44 | let hasVocalRemoval: Bool 45 | } 46 | -------------------------------------------------------------------------------- /Sources/LyricsCore/LyricsLine.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LyricsLine { 4 | public var content: String 5 | public var position: TimeInterval 6 | public var attachments: Attachments 7 | public var enabled: Bool = true 8 | 9 | public weak var lyrics: Lyrics? 10 | 11 | public var timeTag: String { 12 | let min = Int(position / 60) 13 | let sec = position - TimeInterval(min * 60) 14 | return String(format: "%02d:%06.3f", min, sec) 15 | } 16 | 17 | public init(content: String, position: TimeInterval, attachments: Attachments = Attachments()) { 18 | self.content = content 19 | self.position = position 20 | self.attachments = attachments 21 | } 22 | } 23 | 24 | extension LyricsLine: Equatable, Hashable { 25 | public static func == (lhs: LyricsLine, rhs: LyricsLine) -> Bool { 26 | return lhs.enabled == rhs.enabled && 27 | lhs.position == rhs.position && 28 | lhs.content == rhs.content && 29 | lhs.attachments == rhs.attachments 30 | } 31 | 32 | public func hash(into hasher: inout Hasher) { 33 | hasher.combine(enabled) 34 | hasher.combine(position) 35 | hasher.combine(content) 36 | } 37 | } 38 | 39 | extension LyricsLine: CustomStringConvertible { 40 | public var description: String { 41 | return ([content] + attachments.content.map { "[\($0.key)]\($0.value)" }).map { 42 | "[\(timeTag)]\($0)" 43 | }.joined(separator: "\n") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | mac: 7 | runs-on: macOS-latest 8 | env: 9 | CX_COMBINE_IMPLEMENTATION: CombineX 10 | CX_CONTINUOUS_INTEGRATION: "YES" 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Swift Version 14 | run: swift --version 15 | - name: Build and Test 16 | run: swift test 17 | 18 | ios: 19 | runs-on: macOS-latest 20 | env: 21 | CX_COMBINE_IMPLEMENTATION: CombineX 22 | CX_CONTINUOUS_INTEGRATION: "YES" 23 | steps: 24 | - uses: actions/checkout@v1 25 | - name: Xcode Version 26 | run: xcodebuild -version 27 | - name: Build and Test 28 | run: | 29 | set -o pipefail 30 | xcodebuild test \ 31 | -scheme LyricsKit \ 32 | -destination "platform=iOS Simulator,name=iPhone 12" | xcpretty 33 | 34 | linux: 35 | runs-on: ubuntu-latest 36 | container: 37 | image: swift:latest 38 | steps: 39 | - uses: actions/checkout@v1 40 | - name: Swift Version 41 | run: swift --version 42 | - name: Build and Test 43 | run: swift test --enable-test-discovery 44 | 45 | combine: 46 | runs-on: macOS-latest 47 | env: 48 | CX_COMBINE_IMPLEMENTATION: Combine 49 | CX_CONTINUOUS_INTEGRATION: "YES" 50 | steps: 51 | - uses: actions/checkout@v1 52 | - name: Swift Version 53 | run: swift --version 54 | - name: Build and Test 55 | run: swift test 56 | -------------------------------------------------------------------------------- /Sources/LyricsService/Utilities/Lyrics+Merge.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | private let mergeTimetagThreshold = 0.02 5 | 6 | extension Lyrics { 7 | func merge(translation: Lyrics) { 8 | var index = lines.startIndex 9 | var transIndex = translation.lines.startIndex 10 | while index < lines.endIndex, transIndex < translation.lines.endIndex { 11 | if abs(lines[index].position - translation.lines[transIndex].position) < mergeTimetagThreshold { 12 | let transStr = translation.lines[transIndex].content 13 | if !transStr.isEmpty, transStr != "//" { 14 | lines[index].attachments[.translation()] = transStr 15 | } 16 | lines.formIndex(after: &index) 17 | translation.lines.formIndex(after: &transIndex) 18 | } else if lines[index].position > translation.lines[transIndex].position { 19 | translation.lines.formIndex(after: &transIndex) 20 | } else { 21 | lines.formIndex(after: &index) 22 | } 23 | } 24 | metadata.attachmentTags.insert(.translation()) 25 | } 26 | 27 | /// merge without maching timetag 28 | func forceMerge(translation: Lyrics) { 29 | guard lines.count == translation.lines.count else { 30 | return 31 | } 32 | for idx in lines.indices { 33 | let transStr = translation.lines[idx].content 34 | if !transStr.isEmpty, transStr != "//" { 35 | lines[idx].attachments[.translation()] = transStr 36 | } 37 | } 38 | metadata.attachmentTags.insert(.translation()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/LyricsService/Utilities/LyricsMetadata+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | extension Lyrics.Metadata.Key { 5 | public static var request = Lyrics.Metadata.Key("request") 6 | public static var remoteURL = Lyrics.Metadata.Key("remoteURL") 7 | public static var artworkURL = Lyrics.Metadata.Key("artworkURL") 8 | public static var service = Lyrics.Metadata.Key("service") 9 | public static var serviceToken = Lyrics.Metadata.Key("serviceToken") 10 | static var quality = Lyrics.Metadata.Key("quality") 11 | 12 | static var searchIndex = Lyrics.Metadata.Key("searchIndex") 13 | } 14 | 15 | extension Lyrics.Metadata { 16 | public var request: LyricsSearchRequest? { 17 | get { return data[.request] as? LyricsSearchRequest } 18 | set { data[.request] = newValue } 19 | } 20 | 21 | public var remoteURL: URL? { 22 | get { return data[.remoteURL] as? URL } 23 | set { data[.remoteURL] = newValue } 24 | } 25 | 26 | public var artworkURL: URL? { 27 | get { return data[.artworkURL] as? URL } 28 | set { data[.artworkURL] = newValue } 29 | } 30 | 31 | public var service: String? { 32 | get { return data[.service] as? String } 33 | set { data[.service] = newValue } 34 | } 35 | 36 | public var serviceToken: String? { 37 | get { return data[.serviceToken] as? String } 38 | set { data[.serviceToken] = newValue } 39 | } 40 | 41 | var quality: Double? { 42 | get { return data[.quality] as? Double } 43 | set { data[.quality] = newValue } 44 | } 45 | 46 | var searchIndex: Int { 47 | get { return data[.searchIndex] as? Int ?? 0 } 48 | set { data[.searchIndex] = newValue } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/LyricsServiceUI/Drawing/LyricsSourceIconDrawing+Image.swift: -------------------------------------------------------------------------------- 1 | import LyricsService 2 | 3 | #if canImport(CoreGraphics) 4 | 5 | import CoreGraphics 6 | 7 | @available(OSX 10.10, iOS 8, tvOS 2, *) 8 | extension LyricsProviders.Service { 9 | fileprivate var drawingMethod: ((CGRect) -> Void)? { 10 | switch self { 11 | case .netease: 12 | return LyricsSourceIconDrawing.drawNetEaseMusic 13 | case .kugou: 14 | return LyricsSourceIconDrawing.drawKugou 15 | case .qq: 16 | return LyricsSourceIconDrawing.drawQQMusic 17 | default: 18 | return nil 19 | } 20 | } 21 | } 22 | 23 | #endif 24 | 25 | #if canImport(Cocoa) 26 | 27 | import Cocoa 28 | 29 | extension LyricsSourceIconDrawing { 30 | public static let defaultSize = CGSize(width: 48, height: 48) 31 | 32 | public static func icon(of service: LyricsProviders.Service, size: CGSize = defaultSize) -> NSImage { 33 | return NSImage(size: size, flipped: false) { NSRect -> Bool in 34 | service.drawingMethod?(CGRect(origin: .zero, size: size)) 35 | return true 36 | } 37 | } 38 | } 39 | 40 | #elseif canImport(UIKit) 41 | 42 | import UIKit 43 | 44 | extension LyricsSourceIconDrawing { 45 | public static let defaultSize = CGSize(width: 48, height: 48) 46 | 47 | public static func icon(of service: LyricsProviders.Service, size: CGSize = defaultSize) -> UIImage { 48 | UIGraphicsBeginImageContextWithOptions(size, false, 0) 49 | service.drawingMethod?(CGRect(origin: .zero, size: size)) 50 | let image = UIGraphicsGetImageFromCurrentImageContext()?.withRenderingMode(.alwaysOriginal) 51 | UIGraphicsEndImageContext() 52 | return image ?? UIImage() 53 | } 54 | } 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /Tests/LyricsKitTests/LyricsKitTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import LyricsService 4 | 5 | let testSong = "Over" 6 | let testArtist = "yihuik苡慧/白静晨" 7 | let duration = 155.0 8 | let searchReq = LyricsSearchRequest(searchTerm: .info(title: testSong, artist: testArtist), duration: duration) 9 | 10 | struct LyricsKitTests { 11 | private func test(provider: LyricsProvider) async throws { 12 | for try await lyrics in provider.lyrics(for: searchReq) { 13 | print(lyrics) 14 | } 15 | } 16 | 17 | @Test 18 | func qqMusicProvider() async throws { 19 | try await test(provider: LyricsProviders.QQMusic()) 20 | } 21 | 22 | @Test 23 | func LRCLIBProvider() async throws { 24 | try await test(provider: LyricsProviders.LRCLIB()) 25 | } 26 | 27 | @Test 28 | func kugouProvider() async throws { 29 | try await test(provider: LyricsProviders.Kugou()) 30 | } 31 | 32 | @Test 33 | func netEaseProvider() async throws { 34 | try await test(provider: LyricsProviders.NetEase()) 35 | } 36 | 37 | @Test 38 | func musixmatchProvider() async throws { 39 | // set MUSIXMATCH_TOKEN in env to enable test 40 | let env = ProcessInfo.processInfo.environment 41 | if let token = env["MUSIXMATCH_TOKEN"], !token.isEmpty { 42 | await AuthenticationManagerStore.shared.setMusixmatchToken(token) 43 | 44 | // Alternatively you can construct provider with explicit token: 45 | // let provider = LyricsProviders.Musixmatch(usertoken: token) 46 | 47 | try await test(provider: LyricsProviders.Musixmatch()) 48 | } else { 49 | print("Skipping MusixmatchProvider test: set MUSIXMATCH_TOKEN in env to enable") 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/LyricsService/Provider/LyricsProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | public enum LyricsProviders {} 5 | 6 | public protocol LyricsProvider { 7 | func lyrics(for request: LyricsSearchRequest) -> AsyncThrowingStream 8 | } 9 | 10 | public protocol _LyricsProvider: LyricsProvider { 11 | associatedtype LyricsToken 12 | 13 | static var service: String { get } 14 | 15 | func search(for request: LyricsSearchRequest) async throws -> [LyricsToken] 16 | 17 | func fetch(with token: LyricsToken) async throws -> Lyrics 18 | } 19 | 20 | extension _LyricsProvider { 21 | public func lyrics(for request: LyricsSearchRequest) -> AsyncThrowingStream { 22 | return AsyncThrowingStream { continuation in 23 | Task { 24 | do { 25 | let tokens = try await self.search(for: request) 26 | let limitedTokens = tokens.prefix(request.limit) 27 | 28 | let fetchTasks: [Task] = limitedTokens.map { token in 29 | Task { 30 | let lrc = try await self.fetch(with: token) 31 | lrc.metadata.request = request 32 | lrc.metadata.service = Self.service 33 | return lrc 34 | } 35 | } 36 | 37 | for task in fetchTasks { 38 | do { 39 | let lyric = try await task.value 40 | continuation.yield(lyric) 41 | } catch { 42 | print("A fetch task failed, skipping. Error: \(error)") 43 | } 44 | } 45 | 46 | continuation.finish() 47 | 48 | } catch { 49 | continuation.finish(throwing: error) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/LyricsService/Parser/QQMusicQrcParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | extension Lyrics { 5 | convenience init?(qqmusicQrcContent content: String) { 6 | var idTags: [IDTagKey: String] = [:] 7 | for match in id3TagRegex.matches(in: content) { 8 | guard let key = match[1]?.content.trimmingCharacters(in: .whitespaces), 9 | let value = match[2]?.content.trimmingCharacters(in: .whitespaces), 10 | !key.isEmpty, 11 | !value.isEmpty else { 12 | continue 13 | } 14 | idTags[.init(key)] = value 15 | } 16 | 17 | let lines: [LyricsLine] = qrcLineRegex.matches(in: content).map { match in 18 | let timeTagStr = match[1]!.content 19 | let timeTag = TimeInterval(timeTagStr)! / 1000 20 | 21 | let durationStr = match[2]!.content 22 | let duration = TimeInterval(durationStr)! / 1000 23 | 24 | var lineContent = "" 25 | var attachment = LyricsLine.Attachments.InlineTimeTag(tags: [.init(index: 0, time: 0)], duration: duration) 26 | for m in qqmusicInlineTagRegex.matches(in: content, range: match[3]!.range) { 27 | let t1 = Int(m[2]!.content)! - Int(timeTagStr)! 28 | let t2 = Int(m[3]!.content)! 29 | let t = TimeInterval(t1 + t2) / 1000 30 | let fragment = m[1]!.content 31 | let prevCount = lineContent.count 32 | lineContent += fragment 33 | if lineContent.count > prevCount { 34 | attachment.tags.append(.init(index: lineContent.count, time: t)) 35 | } 36 | } 37 | 38 | let att = LyricsLine.Attachments(attachments: [.timetag: attachment]) 39 | return LyricsLine(content: lineContent, position: timeTag, attachments: att) 40 | } 41 | guard !lines.isEmpty else { 42 | return nil 43 | } 44 | self.init(lines: lines, idTags: idTags) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/QQMusic/QQResponseSearchResult.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol QQMusicSongSearchResult { 4 | var id: String { get } 5 | var mid: String { get } 6 | var name: String { get } 7 | var singers: [String] { get } 8 | } 9 | 10 | struct QQResponseSearchResult: Decodable { 11 | let data: Data 12 | let code: Int 13 | 14 | struct Data: Decodable { 15 | let song: Song 16 | 17 | struct Song: Decodable { 18 | let list: [Item] 19 | enum CodingKeys: String, CodingKey { 20 | case list = "itemlist" 21 | } 22 | 23 | struct Item: Decodable, QQMusicSongSearchResult { 24 | var singers: [String] { [singer] } 25 | let mid: String 26 | let name: String 27 | let singer: String 28 | let id: String 29 | } 30 | } 31 | } 32 | } 33 | 34 | extension QQResponseSearchResult { 35 | var songs: [Data.Song.Item] { 36 | return data.song.list 37 | } 38 | } 39 | 40 | struct QQResponseSearchResult2: Decodable { 41 | struct Request: Decodable { 42 | struct Data: Decodable { 43 | struct Body: Decodable { 44 | struct Song: Decodable { 45 | struct Item: Decodable, QQMusicSongSearchResult { 46 | struct Singer: Decodable { 47 | let name: String 48 | } 49 | 50 | let mid: String 51 | let name: String 52 | let _id: Int 53 | let singer: [Singer] 54 | var singers: [String] { singer.map(\.name) } 55 | var id: String { .init(_id) } 56 | enum CodingKeys: String, CodingKey { 57 | case mid 58 | case name 59 | case _id = "id" 60 | case singer 61 | } 62 | } 63 | 64 | let list: [Item] 65 | } 66 | 67 | let song: Song 68 | } 69 | 70 | let body: Body 71 | } 72 | 73 | let data: Data 74 | let code: Int 75 | } 76 | 77 | let request: Request 78 | 79 | enum CodingKeys: String, CodingKey { 80 | case request = "req_1" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/LyricsServiceUI/Controller/SpotifyLoginViewController.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import WebKit 3 | 4 | public final class SpotifyLoginViewController: NSViewController { 5 | private let webView: WKWebView 6 | 7 | private static let loginURL = URL(string: "https://accounts.spotify.com/en/login?continue=https%3A%2F%2Fopen.spotify.com%2F")! 8 | 9 | private static let logoutURL = URL(string: "https://www.spotify.com/logout/")! 10 | 11 | public var didLogin: ((String) -> Void)? 12 | 13 | public init() { 14 | self.webView = WKWebView(frame: .zero, configuration: .init()) 15 | super.init(nibName: nil, bundle: nil) 16 | } 17 | 18 | @available(*, unavailable) 19 | public required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | public override func loadView() { 24 | view = webView 25 | view.frame = .init(x: 0, y: 0, width: 800, height: 600) 26 | } 27 | 28 | public override func viewDidLoad() { 29 | super.viewDidLoad() 30 | gotoLogin() 31 | webView.navigationDelegate = self 32 | } 33 | 34 | public func gotoLogin() { 35 | webView.load(.init(url: Self.loginURL)) 36 | } 37 | 38 | public func gotoLogout() { 39 | webView.load(.init(url: Self.logoutURL)) 40 | } 41 | } 42 | 43 | extension SpotifyLoginViewController: WKNavigationDelegate { 44 | public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { 45 | guard let url = webView.url else { return } 46 | if url.absoluteString.starts(with: "https://open.spotify.com") { 47 | Task.detached { 48 | if let cookie = await WKWebsiteDataStore.default().spotifyCookie() { 49 | await MainActor.run { 50 | self.didLogin?(cookie) 51 | } 52 | } 53 | } 54 | } 55 | if url.absoluteString.starts(with: "https://accounts.google.com/") { 56 | webView.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15" 57 | } 58 | } 59 | } 60 | 61 | extension WKWebsiteDataStore { 62 | public func spotifyCookie() async -> String? { 63 | let cookies = await httpCookieStore.allCookies() 64 | if let temporaryCookie = cookies.first(where: { $0.name == "sp_dc" }) { 65 | return temporaryCookie.value 66 | } 67 | return nil 68 | } 69 | } 70 | 71 | @available(macOS 14.0, *) 72 | #Preview { 73 | SpotifyLoginViewController() 74 | } 75 | -------------------------------------------------------------------------------- /Sources/LyricsService/Provider/Service.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension LyricsProviders { 4 | public enum Service: CaseIterable, Equatable, Hashable { 5 | case qq 6 | case netease 7 | case kugou 8 | case musixmatch 9 | case lrclib 10 | case spotify 11 | 12 | public var displayName: String { 13 | switch self { 14 | case .netease: return "Netease" 15 | case .qq: return "QQMusic" 16 | case .kugou: return "Kugou" 17 | case .musixmatch: return "Musixmatch" 18 | case .lrclib: return "LRCLIB" 19 | case .spotify: return "Spotify" 20 | } 21 | } 22 | 23 | public var requiresAuthentication: Bool { 24 | switch self { 25 | case .spotify: 26 | return true 27 | case .qq, 28 | .netease, 29 | .kugou, 30 | .musixmatch, 31 | .lrclib: 32 | return false 33 | } 34 | } 35 | 36 | public static var noAuthenticationRequiredServices: [Service] { 37 | [ 38 | .qq, 39 | .netease, 40 | .kugou, 41 | .musixmatch, 42 | .lrclib, 43 | ] 44 | } 45 | 46 | public static var authenticationRequiredServices: [Service] { 47 | [ 48 | .spotify, 49 | ] 50 | } 51 | } 52 | } 53 | 54 | extension LyricsProviders.Service { 55 | public func create() -> LyricsProvider { 56 | switch self { 57 | case .netease: return LyricsProviders.NetEase() 58 | case .qq: return LyricsProviders.QQMusic() 59 | case .kugou: return LyricsProviders.Kugou() 60 | case .musixmatch: return LyricsProviders.Musixmatch() 61 | case .spotify: return LyricsProviders.Spotify() 62 | case .lrclib: return LyricsProviders.LRCLIB() 63 | } 64 | } 65 | 66 | public func create(with authManager: AuthenticationManager?) async throws -> LyricsProvider { 67 | switch self { 68 | case .spotify: 69 | guard let authManager = authManager else { 70 | throw AuthenticationError.notAuthenticated 71 | } 72 | let provider = LyricsProviders.Spotify() 73 | provider.authenticationManager = authManager 74 | return provider 75 | case .netease, 76 | .qq, 77 | .kugou, 78 | .musixmatch, 79 | .lrclib: 80 | return create() 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "LyricsKit", 7 | platforms: [ 8 | .macOS(.v11), 9 | ], 10 | products: [ 11 | .library( 12 | name: "LyricsKit", 13 | targets: ["LyricsKit"] 14 | ), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/ddddxxx/Regex", from: "1.0.1"), 18 | .package(url: "https://github.com/MxIris-Library-Forks/SwiftCF", branch: "master"), 19 | .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", .upToNextMajor(from: "4.0.0")), 20 | .package(url: "https://github.com/MxIris-Library-Forks/Schedule", branch: "master"), 21 | .package(url: "https://github.com/lachlanbell/SwiftOTP", from: "3.0.2"), 22 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), 23 | .package(url: "https://github.com/attaswift/BigInt", from: "5.6.0"), 24 | .package(url: "https://github.com/krzyzanowskim/CryptoSwift", from: "1.9.0"), 25 | ], 26 | targets: [ 27 | .target( 28 | name: "LyricsKit", 29 | dependencies: [ 30 | "LyricsCore", 31 | "LyricsService", 32 | "LyricsServiceUI", 33 | ] 34 | ), 35 | .target( 36 | name: "LyricsCore", 37 | dependencies: [ 38 | .product(name: "Regex", package: "Regex"), 39 | .product(name: "SwiftCF", package: "SwiftCF"), 40 | ] 41 | ), 42 | .target( 43 | name: "LyricsService", 44 | dependencies: [ 45 | "LyricsCore", 46 | .product(name: "Regex", package: "Regex"), 47 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 48 | .product(name: "BigInt", package: "BigInt"), 49 | .product(name: "CryptoSwift", package: "CryptoSwift"), 50 | ] 51 | ), 52 | .target( 53 | name: "LyricsServiceUI", 54 | dependencies: [ 55 | "LyricsCore", 56 | "LyricsService", 57 | .product(name: "KeychainAccess", package: "KeychainAccess"), 58 | .product(name: "Schedule", package: "Schedule"), 59 | .product(name: "SwiftOTP", package: "SwiftOTP"), 60 | ] 61 | ), 62 | .testTarget( 63 | name: "LyricsKitTests", 64 | dependencies: [ 65 | "LyricsCore", 66 | "LyricsService", 67 | ] 68 | ), 69 | ], 70 | swiftLanguageModes: [.v5] 71 | ) 72 | -------------------------------------------------------------------------------- /Sources/LyricsService/Utilities/String+XMLDecode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | private static let xmlEntities: [Substring: Character] = [ 5 | """: "\"", 6 | "&": "&", 7 | "'": "'", 8 | "<": "<", 9 | ">": ">", 10 | ] 11 | 12 | func decodingXMLEntities() -> String { 13 | // ===== Utility functions ===== 14 | 15 | // Convert the number in the string to the corresponding 16 | // Unicode character, e.g. 17 | // decodeNumeric("64", 10) --> "@" 18 | // decodeNumeric("20ac", 16) --> "€" 19 | func decodeNumeric(_ string: Substring, base: Int) -> Character? { 20 | guard let code = UInt32(string, radix: base), 21 | let uniScalar = UnicodeScalar(code) else { return nil } 22 | return Character(uniScalar) 23 | } 24 | 25 | // Decode the HTML character entity to the corresponding 26 | // Unicode character, return `nil` for invalid input. 27 | // decode("@") --> "@" 28 | // decode("€") --> "€" 29 | // decode("<") --> "<" 30 | // decode("&foo;") --> nil 31 | func decode(_ entity: Substring) -> Character? { 32 | if entity.hasPrefix("&#x") || entity.hasPrefix("&#X") { 33 | return decodeNumeric(entity.dropFirst(3).dropLast(), base: 16) 34 | } else if entity.hasPrefix("&#") { 35 | return decodeNumeric(entity.dropFirst(2).dropLast(), base: 10) 36 | } else { 37 | return String.xmlEntities[entity] 38 | } 39 | } 40 | 41 | // ===== Method starts here ===== 42 | 43 | var result = "" 44 | var position = startIndex 45 | 46 | // Find the next '&' and copy the characters preceding it to `result`: 47 | while let ampRange = self[position...].range(of: "&") { 48 | result.append(contentsOf: self[position ..< ampRange.lowerBound]) 49 | position = ampRange.lowerBound 50 | 51 | // Find the next ';' and copy everything from '&' to ';' into `entity` 52 | guard let semiRange = self[position...].range(of: ";") else { 53 | // No matching ';'. 54 | break 55 | } 56 | let entity = self[position ..< semiRange.upperBound] 57 | position = semiRange.upperBound 58 | 59 | if let decoded = decode(entity) { 60 | // Replace by decoded character: 61 | result.append(decoded) 62 | } else { 63 | // Invalid entity, copy verbatim: 64 | result.append(contentsOf: entity) 65 | } 66 | } 67 | // Copy remaining characters to `result`: 68 | result.append(contentsOf: self[position...]) 69 | return result 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/LyricsService/Parser/KugouKrcParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | extension Lyrics { 5 | convenience init?(kugouKrcContent content: String) { 6 | var idTags: [IDTagKey: String] = [:] 7 | var languageHeader: KugouKrcHeaderFieldLanguage? 8 | for match in id3TagRegex.matches(in: content) { 9 | guard let key = match[1]?.content.trimmingCharacters(in: .whitespaces), 10 | let value = match[2]?.content.trimmingCharacters(in: .whitespaces), 11 | !key.isEmpty, 12 | !value.isEmpty else { 13 | continue 14 | } 15 | if key == "language" { 16 | if let data = Data(base64Encoded: value) { 17 | // TODO: error handler 18 | languageHeader = try? JSONDecoder().decode(KugouKrcHeaderFieldLanguage.self, from: data) 19 | } 20 | } else { 21 | idTags[.init(key)] = value 22 | } 23 | } 24 | 25 | var lines: [LyricsLine] = krcLineRegex.matches(in: content).map { match in 26 | let timeTagStr = match[1]!.content 27 | let timeTag = TimeInterval(timeTagStr)! / 1000 28 | 29 | let durationStr = match[2]!.content 30 | let duration = TimeInterval(durationStr)! / 1000 31 | 32 | var lineContent = "" 33 | var attachment = LyricsLine.Attachments.InlineTimeTag(tags: [.init(index: 0, time: 0)], duration: duration) 34 | for m in kugouInlineTagRegex.matches(in: content, range: match[3]!.range) { 35 | let t1 = Int(m[1]!.content)! 36 | let t2 = Int(m[2]!.content)! 37 | let t = TimeInterval(t1 + t2) / 1000 38 | let fragment = m[3]!.content 39 | let prevCount = lineContent.count 40 | lineContent += fragment 41 | if lineContent.count > prevCount { 42 | attachment.tags.append(.init(index: lineContent.count, time: t)) 43 | } 44 | } 45 | 46 | let att = LyricsLine.Attachments(attachments: [.timetag: attachment]) 47 | return LyricsLine(content: lineContent, position: timeTag, attachments: att) 48 | } 49 | guard !lines.isEmpty else { 50 | return nil 51 | } 52 | self.init(lines: lines, idTags: idTags) 53 | 54 | // TODO: multiple translation 55 | if let transContent = languageHeader?.content.first?.lyricContent { 56 | transContent.prefix(lines.count).enumerated().forEach { index, item in 57 | guard !item.isEmpty else { return } 58 | let str = item.joined(separator: " ") 59 | lines[index].attachments[.translation()] = str 60 | } 61 | metadata.attachmentTags.insert(.translation()) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/LyricsService/Provider/Services/LRCLIB.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | import Regex 4 | 5 | extension LyricsProviders { 6 | public final class LRCLIB { 7 | public init() {} 8 | } 9 | } 10 | 11 | extension LyricsProviders.LRCLIB: _LyricsProvider { 12 | public struct LyricsToken { 13 | let value: LRCLIBResponse 14 | } 15 | 16 | public static let service: String = "LRCLIB" 17 | 18 | public func search(for request: LyricsSearchRequest) async throws -> [LyricsToken] { 19 | let urlString: String 20 | switch request.searchTerm { 21 | case .keyword(let string): 22 | urlString = "https://lrclib.net/api/search?q=\(string)" 23 | case .info(let title, let artist): 24 | urlString = "https://lrclib.net/api/search?track_name=\(title)&artist_name=\(artist)" 25 | } 26 | 27 | guard let url = URL(string: urlString) else { 28 | throw LyricsProviderError.invalidURL(urlString: urlString) 29 | } 30 | 31 | do { 32 | let (data, _) = try await URLSession.shared.data(for: .init(url: url)) 33 | let results = try JSONDecoder().decode([LRCLIBResponse].self, from: data) 34 | return results.map { LyricsToken(value: $0) } 35 | } catch let error as DecodingError { 36 | throw LyricsProviderError.decodingError(underlyingError: error) 37 | } catch { 38 | throw LyricsProviderError.networkError(underlyingError: error) 39 | } 40 | } 41 | 42 | public func fetch(with token: LyricsToken) async throws -> Lyrics { 43 | if let lyrics = parseLyrics(for: token.value) { 44 | return lyrics 45 | } 46 | 47 | let urlString = "https://lrclib.net/api/get/\(token.value.id)" 48 | guard let url = URL(string: urlString) else { 49 | throw LyricsProviderError.invalidURL(urlString: urlString) 50 | } 51 | 52 | let fetchedToken: LRCLIBResponse 53 | do { 54 | let (data, _) = try await URLSession.shared.data(from: url) 55 | fetchedToken = try JSONDecoder().decode(LRCLIBResponse.self, from: data) 56 | } catch let error as DecodingError { 57 | throw LyricsProviderError.decodingError(underlyingError: error) 58 | } catch { 59 | throw LyricsProviderError.networkError(underlyingError: error) 60 | } 61 | 62 | if let lyrics = parseLyrics(for: fetchedToken) { 63 | return lyrics 64 | } else { 65 | throw LyricsProviderError.processingFailed(reason: "Synced lyrics not found in fetched LRCLIB response.") 66 | } 67 | } 68 | 69 | private func parseLyrics(for token: LRCLIBResponse) -> Lyrics? { 70 | guard let syncedLyrics = token.syncedLyrics, let lyrics = Lyrics(syncedLyrics) else { return nil } 71 | lyrics.idTags[.title] = token.trackName 72 | lyrics.idTags[.artist] = token.artistName 73 | lyrics.idTags[.album] = token.albumName 74 | lyrics.length = Double(token.duration) 75 | lyrics.metadata.serviceToken = "\(token.id)" 76 | return lyrics 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/LyricsServiceUI/Controller/SpotifyAccessToken.swift: -------------------------------------------------------------------------------- 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 | } 63 | -------------------------------------------------------------------------------- /Sources/LyricsService/Model/NetEase/NetEaseResponseSearchResult.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct NetEaseResponseSearchResult: Decodable { 4 | let result: Result 5 | let code: Int 6 | 7 | struct Result: Decodable { 8 | let songs: [Song] 9 | let songCount: Int 10 | 11 | struct Song: Decodable { 12 | let name: String 13 | let id: Int 14 | let duration: Int // msec 15 | let artists: [NetEaseResponseModelArtist] 16 | let album: NetEaseResponseModelAlbum 17 | 18 | // let position: Int 19 | // let status: Int 20 | // let fee: Int 21 | // let copyrightId: Int 22 | // let disc: String // Int 23 | // let no: Int 24 | // let starred: Bool 25 | // let popularity: Int 26 | // let score: Int 27 | // let starredNum: Int 28 | // let playedNum: Int 29 | // let dayPlays: Int 30 | // let hearTime: Int 31 | // let copyFrom: String 32 | // let commentThreadId: String 33 | // let ftype: Int 34 | // let copyright: Int 35 | // let mvid: Int 36 | // let hMusic: NetEaseResponseModelMusic 37 | // let mMusic: NetEaseResponseModelMusic 38 | // let lMusic: NetEaseResponseModelMusic 39 | // let bMusic: NetEaseResponseModelMusic 40 | // let mp3Url: URL? 41 | // let rtype: Int 42 | // 43 | 44 | // let alias: [Any], 45 | // let ringtone: Any? 46 | // let crbt: Any? 47 | // let audition: Any? 48 | // let rtUrl: Any? 49 | // let rtUrls: [Any] 50 | // let rurl: Any? 51 | } 52 | } 53 | } 54 | 55 | struct NetEaseResponseModelArtist: Decodable { 56 | let name: String 57 | let id: Int 58 | 59 | // let picId: Int 60 | // let img1v1Id: Int 61 | // let briefDesc: String 62 | // let picUrl: URL? 63 | // let img1v1Url: URL? 64 | // let albumSize: Int 65 | // let trans: String 66 | // let musicSize: Int 67 | // 68 | 69 | // let alias: [Any] 70 | } 71 | 72 | struct NetEaseResponseModelAlbum: Decodable { 73 | let name: String 74 | let id: Int 75 | let picUrl: URL? 76 | 77 | // let type: String 78 | // let size: Int 79 | // let picId: Int 80 | // let blurPicUrl: URL? 81 | // let companyId: Int 82 | // let pic: Int 83 | // let publishTime: Int 84 | // let description: String 85 | // let tags: String 86 | // let company: String 87 | // let briefDesc: String 88 | // let artist: NetEaseResponseModelArtist 89 | // let status: Int 90 | // let copyrightId: Int 91 | // let commentThreadId: String 92 | // let artists: [NetEaseResponseModelArtist] 93 | // 94 | 95 | // let songs: [Any] 96 | // let alias: [Any] 97 | } 98 | 99 | struct NetEaseResponseModelMusic: Decodable { 100 | // let id: Int 101 | // let size: Int 102 | // let `extension`: String 103 | // let sr: Int 104 | // let dfsId: Int 105 | // let bitrate: Int 106 | // let playTime: Int 107 | // let volumeDelta: Double 108 | // 109 | 110 | // let name: Any? 111 | } 112 | 113 | extension NetEaseResponseSearchResult { 114 | var songs: [Result.Song] { 115 | return result.songs 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/LyricsService/Parser/NetEaseKLyricParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import LyricsCore 3 | 4 | extension Lyrics { 5 | convenience init?(netEaseKLyricContent content: String) { 6 | var idTags: [IDTagKey: String] = [:] 7 | id3TagRegex.matches(in: content).forEach { match in 8 | if let key = match[1]?.content.trimmingCharacters(in: .whitespaces), 9 | let value = match[2]?.content.trimmingCharacters(in: .whitespaces), 10 | !key.isEmpty, 11 | !value.isEmpty { 12 | idTags[.init(key)] = value 13 | } 14 | } 15 | 16 | let lines: [LyricsLine] = krcLineRegex.matches(in: content).map { match in 17 | let timeTagStr = match[1]!.content 18 | let timeTag = TimeInterval(timeTagStr)! / 1000 19 | 20 | let durationStr = match[2]!.content 21 | let duration = TimeInterval(durationStr)! / 1000 22 | 23 | var lineContent = "" 24 | var attachment = LyricsLine.Attachments.InlineTimeTag(tags: [.init(index: 0, time: 0)], duration: duration) 25 | var dt = 0.0 26 | netEaseInlineTagRegex.matches(in: content, range: match[3]!.range).forEach { m in 27 | let timeTagStr = m[1]!.content 28 | var timeTag = TimeInterval(timeTagStr)! / 1000 29 | var fragment = m[2]!.content 30 | if m[3] != nil { 31 | timeTag += 0.001 32 | fragment += " " 33 | } 34 | lineContent += fragment 35 | dt += timeTag 36 | attachment.tags.append(.init(index: lineContent.count, time: dt)) 37 | } 38 | 39 | let att = LyricsLine.Attachments(attachments: [.timetag: attachment]) 40 | return LyricsLine(content: lineContent, position: timeTag, attachments: att) 41 | } 42 | guard !lines.isEmpty else { 43 | return nil 44 | } 45 | 46 | self.init(lines: lines, idTags: idTags) 47 | } 48 | 49 | convenience init?(netEaseYrcContent content: String) { 50 | var idTags: [IDTagKey: String] = [:] 51 | id3TagRegex.matches(in: content).forEach { match in 52 | if let key = match[1]?.content.trimmingCharacters(in: .whitespaces), 53 | let value = match[2]?.content.trimmingCharacters(in: .whitespaces), 54 | !key.isEmpty, 55 | !value.isEmpty { 56 | idTags[.init(key)] = value 57 | } 58 | } 59 | 60 | let lines: [LyricsLine] = krcLineRegex.matches(in: content).map { match in 61 | let timeTagStr = match[1]!.content 62 | let timeTag = TimeInterval(timeTagStr)! / 1000 63 | 64 | let durationStr = match[2]!.content 65 | let duration = TimeInterval(durationStr)! / 1000 66 | 67 | var lineContent = "" 68 | var attachment = LyricsLine.Attachments.InlineTimeTag(tags: [.init(index: 0, time: 0)], duration: duration) 69 | netEaseYrcInlineTagRegex.matches(in: content, range: match[3]!.range).forEach { m in 70 | let t1 = Int(m[1]!.content)! - Int(timeTagStr)! 71 | let t2 = Int(m[2]!.content)! 72 | let t = TimeInterval(t1 + t2) / 1000 73 | let fragment = m[3]!.content 74 | let prevCount = lineContent.count 75 | lineContent += fragment 76 | if lineContent.count > prevCount { 77 | attachment.tags.append(.init(index: lineContent.count, time: t)) 78 | } 79 | } 80 | 81 | let att = LyricsLine.Attachments(attachments: [.timetag: attachment]) 82 | return LyricsLine(content: lineContent, position: timeTag, attachments: att) 83 | } 84 | guard !lines.isEmpty else { 85 | return nil 86 | } 87 | 88 | self.init(lines: lines, idTags: idTags) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LyricsKit 2 | 3 | Lyrics submodule for [LyricsX](https://github.com/ddddxxx/LyricsX). 4 | 5 | ## Supported Sources 6 | 7 | - NetEase Music 8 | - QQ Music 9 | - Kugou Music 10 | - LRCLIB 11 | - Spotify 12 | - Musixmatch 13 | - TTPod 14 | - Gecimi 15 | - Syair 16 | - Xiami Music (discontinued) 17 | - ViewLyrics (not working anymore) 18 | 19 | ## Usage 20 | 21 | #### Search lyrics from the internet 22 | 23 | ```swift 24 | import LyricsService 25 | 26 | // create a search request 27 | let song = "Tranquilize" 28 | let artist = "The Killers" 29 | let duration = 225.2 30 | let searchReq = LyricsSearchRequest( 31 | searchTerm: .info(title: song, artist: artist), 32 | duration: duration 33 | ) 34 | 35 | // choose a lyrics service provider 36 | let provider = LyricsProviders.Kugou() 37 | // or search from multiple sources 38 | let provider = LyricsProviders.Group(service: [.kugou, .netease, .qq]) 39 | 40 | // search 41 | provider.lyricsPublisher(request: searchReq).sink { lyrics in 42 | print(lyrics) 43 | } 44 | ``` 45 | 46 | #### Musixmatch source requires a token 47 | 48 | The method for retrieving lyrics from Musixmatch is adapted from [LyricsPlus](https://github.com/spicetify/cli/tree/main/CustomApps/lyrics-plus). To use this feature properly, you need to follow the steps provided [here](https://gist.github.com/TrueMyst/0461aea999e347182486934fd83a4cf9) or [here](https://spicetify.app/docs/faq#sometimes-popup-lyrics-andor-lyrics-plus-seem-to-not-work) to obtain a usertoken. 49 | 50 | ## License 51 | 52 | LyricsKit is part of LyricsX and licensed under MPL 2.0. See the [LICENSE file](LICENSE). 53 | 54 | ## LRCX file 55 | 56 | ### Specification 57 | 58 | ``` 59 | ::= (NEWLINE )* 60 | ::= 61 | | 62 | | 63 | | "" 64 | 65 | ::= 66 | ::= "[" "]" 67 | ::= 68 | | ":" 69 | ::= [0-9a-zA-Z_-]+ 70 | ::= + 71 | 72 | ::=