Genius Lyrics (I so like it)

This commit is contained in:
eevee
2024-05-04 20:13:19 +03:00
parent aca33323f5
commit 04bad04ac9
24 changed files with 765 additions and 2 deletions
+63
View File
@@ -0,0 +1,63 @@
import Foundation
struct GeniusApi {
private static let apiUrl = "https://api.genius.com"
private static func perform(
_ path: String,
query: [String:Any] = [:]
) throws -> GeniusDataResponse? {
var stringUrl = "\(apiUrl)\(path)"
if !query.isEmpty {
let queryString = query.queryString.addingPercentEncoding(
withAllowedCharacters: .urlHostAllowed
)!
stringUrl += "?\(queryString)"
}
var request = URLRequest(url: URL(string: stringUrl)!)
request.addValue("6.19.1", forHTTPHeaderField: "X-Genius-iOS-Version")
let semaphore = DispatchSemaphore(value: 0)
var data: Data?
let task = URLSession.shared.dataTask(with: request) { response, _, _ in
data = response
semaphore.signal()
}
task.resume()
semaphore.wait()
let rootResponse = try JSONDecoder().decode(GeniusRootResponse.self, from: data!)
return rootResponse.response
}
static func search(_ query: String) throws -> [GeniusHit] {
let data = try perform("/search", query: ["q": query])
guard case .hits(let hitsResponse) = data else {
throw GeniusLyricsError.DecodingError
}
return hitsResponse.hits
}
static func getSongInfo(_ songId: Int) throws -> GeniusSong {
let data = try perform("/songs/\(songId)", query: ["text_format": "plain"])
guard case .song(let songResponse) = data else {
throw GeniusLyricsError.DecodingError
}
return songResponse.song
}
}
+162
View File
@@ -0,0 +1,162 @@
import Orion
import SwiftUI
class SPTPlayerTrackHook: ClassHook<NSObject> {
static let targetName = "SPTPlayerTrack"
func setMetadata(_ metadata: [String:String]) {
var meta = metadata
meta["has_lyrics"] = "true"
orig.setMetadata(meta)
}
}
class EncoreButtonHook: ClassHook<UIButton> {
static let targetName = "_TtC12EncoreMobileP33_6EF3A3C098E69FB1E331877B69ACBF8512EncoreButton"
func intrinsicContentSize() -> CGSize {
if target.accessibilityIdentifier == "Components.UI.LyricsHeader.ReportButton" {
target.isEnabled = false
}
return orig.intrinsicContentSize()
}
}
func getCurrentTrackLyricsData() throws -> Data {
let track = HookedInstances.currentTrack!
let title = track.trackTitle()
.removeMatches("\\(.*\\)")
.prefix(30)
let artist = track.artistTitle()
let geniusHits = try GeniusApi.search("\(title) \(artist)")
guard let geniusSong = (
geniusHits.first(
where: { $0.result.title.containsInsensitive(title) }
) ?? geniusHits.first
)?.result else {
throw GeniusLyricsError.NoSuchSong
}
let geniusSongInfo = try GeniusApi.getSongInfo(geniusSong.id)
var geniusLyrics = geniusSongInfo.lyrics.plain
.components(separatedBy: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
geniusLyrics.removeAll { $0.matches("\\[.*\\]") }
geniusLyrics = Array(
geniusLyrics
.drop(while: { $0.isEmpty })
.dropLast(while: { $0.isEmpty })
)
let lyrics = Lyrics.with {
$0.colors = LyricsColors.with {
$0.backgroundColor = Color(hex: track.extractedColorHex()).uInt32
$0.lineColor = Color.black.uInt32
$0.activeLineColor = Color.white.uInt32
}
$0.data = LyricsData.with {
$0.timeSynchronized = false
$0.restriction = .unrestricted
$0.providedBy = "Genius (EeveeSpotify)"
$0.lines = geniusLyrics.map { line in
LyricsLine.with { $0.content = line }
}
}
}
return try lyrics.serializedData()
}
class SPTDataLoaderServiceHook: ClassHook<NSObject> {
static let targetName = "SPTDataLoaderService"
func URLSession(
_ session: URLSession,
dataTask task: URLSessionDataTask,
didReceiveResponse response: HTTPURLResponse,
completionHandler handler: Any
) {
let url = response.url!
if url.isLyrics, response.statusCode != 200 {
let okResponse = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "2.0",
headerFields: [:]
)!
do {
let lyricsData = try getCurrentTrackLyricsData()
orig.URLSession(
session,
dataTask: task,
didReceiveResponse: okResponse,
completionHandler: handler
)
orig.URLSession(
session,
dataTask: task,
didReceiveData: lyricsData
)
return
}
catch {
NSLog("[EeveeSpotify] Unable to load lyrics: \(error)")
}
}
orig.URLSession(
session,
dataTask: task,
didReceiveResponse: response,
completionHandler: handler
)
}
func URLSession(
_ session: URLSession,
dataTask task: URLSessionDataTask,
didReceiveData data: Data
) {
let request = task.currentRequest!
let url = request.url!
if url.isLyrics {
do {
orig.URLSession(
session,
dataTask: task,
didReceiveData: try getCurrentTrackLyricsData()
)
return
}
catch {
NSLog("[EeveeSpotify] Unable to load lyrics: \(error)")
}
}
orig.URLSession(session, dataTask: task, didReceiveData: data)
}
}
@@ -2,6 +2,22 @@ import Orion
class HookedInstances {
static var productState: SPTCoreProductState?
static var currentTrack: SPTPlayerTrack?
}
class SPTNowPlayingContentLayerViewModelHook: ClassHook<NSObject> {
static let targetName = "SPTNowPlayingContentLayerViewModel"
func currentTrack() -> SPTPlayerTrack? {
if let track = orig.currentTrack() {
HookedInstances.currentTrack = track
return track
}
return nil
}
}
class SPTCoreProductStateInstanceHook: ClassHook<NSObject> {
@@ -0,0 +1,10 @@
import Foundation
extension Collection {
func dropLast(while predicate: (Element) throws -> Bool) rethrows -> SubSequence {
guard let index = try indices.reversed().first(where: { try !predicate(self[$0]) }) else {
return self[startIndex..<startIndex]
}
return self[...index]
}
}
@@ -0,0 +1,55 @@
import SwiftUI
import UIKit
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3:
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8:
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
var components: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
typealias NativeColor = UIColor
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
guard NativeColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) else {
return (0, 0, 0, 0)
}
return (r, g, b, a)
}
var uInt32: UInt32 {
UInt32(components.alpha * 255) << 24
| UInt32(components.red * 255) << 16
| UInt32(components.green * 255) << 8
| UInt32(components.blue * 255)
}
}
@@ -0,0 +1,11 @@
import Foundation
extension Dictionary {
var queryString: String {
return self
.compactMap({ (key, value) -> String in
return "\(key)=\(value)"
})
.joined(separator: "&")
}
}
@@ -1,10 +1,32 @@
import Foundation
extension String {
static func ~= (lhs: String, rhs: String) -> Bool {
guard let regex = try? NSRegularExpression(pattern: rhs) else { return false }
let range = NSRange(location: 0, length: lhs.utf16.count)
return regex.firstMatch(in: lhs, options: [], range: range) != nil
}
var range: NSRange {
NSRange(self.startIndex..., in: self)
}
func containsInsensitive<S: StringProtocol>(_ s: S) -> Bool {
self.range(of: s, options: .caseInsensitive) != nil
}
func matches(_ pattern: String) -> Bool {
try! NSRegularExpression(pattern: pattern)
.firstMatch(in: self, range: self.range) != nil
}
func removeMatches(_ pattern: String) -> String {
try! NSRegularExpression(pattern: pattern)
.stringByReplacingMatches(
in: self,
range: self.range,
withTemplate: ""
)
}
}
@@ -0,0 +1,7 @@
import Foundation
extension URL {
var isLyrics: Bool {
self.path.contains("color-lyrics/v2")
}
}
@@ -0,0 +1,26 @@
import Foundation
enum GeniusDataResponse: Decodable {
case hits(GeniusHitsResponse)
case song(GeniusSongResponse)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let hits = try? container.decode(GeniusHitsResponse.self) {
self = .hits(hits)
}
else if let song = try? container.decode(GeniusSongResponse.self) {
self = .song(song)
}
else {
throw DecodingError.typeMismatch(
GeniusDataResponse.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Invalid data format"
)
)
}
}
}
@@ -0,0 +1,5 @@
import Foundation
struct GeniusHit: Decodable {
var result: GeniusHitResult
}
@@ -0,0 +1,6 @@
import Foundation
struct GeniusHitResult: Decodable {
var id: Int
var title: String
}
@@ -0,0 +1,5 @@
import Foundation
struct GeniusHitsResponse: Decodable {
var hits: [GeniusHit]
}
@@ -0,0 +1,5 @@
import Foundation
struct GeniusLyrics: Decodable {
var plain: String
}
@@ -0,0 +1,5 @@
import Foundation
struct GeniusRootResponse : Decodable {
var response: GeniusDataResponse?
}
@@ -0,0 +1,5 @@
import Foundation
struct GeniusSong: Decodable {
var lyrics: GeniusLyrics
}
@@ -0,0 +1,5 @@
import Foundation
struct GeniusSongResponse: Decodable {
var song: GeniusSong
}
@@ -0,0 +1,6 @@
import Foundation
enum GeniusLyricsError: Swift.Error {
case DecodingError
case NoSuchSong
}
@@ -0,0 +1,8 @@
import Foundation
@objc protocol SPTPlayerTrack {
func setMetadata(_ metadata: [String:String])
func extractedColorHex() -> String
func trackTitle() -> String
func artistTitle() -> String
}
+341
View File
@@ -0,0 +1,341 @@
// DO NOT EDIT.
// swift-format-ignore-file
//
// Generated by the Swift generator plugin for the protocol buffer compiler.
// Source: lyrics.proto
//
// For information on using the generated types, please see the documentation:
// https://github.com/apple/swift-protobuf/
import Foundation
import SwiftProtobuf
// If the compiler emits an error on this type, it is because this file
// was generated by a version of the `protoc` Swift plug-in that is
// incompatible with the version of SwiftProtobuf to which you are linking.
// Please ensure that you are building against the same version of the API
// that was used to generate this file.
fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
typealias Version = _2
}
enum LyricsRestriction: SwiftProtobuf.Enum {
typealias RawValue = Int
case unrestricted // = 0
case showLyrics // = 1
case showLyricsMonthlyLimit // = 2
case monthlyLimitReached // = 3
case UNRECOGNIZED(Int)
init() {
self = .unrestricted
}
init?(rawValue: Int) {
switch rawValue {
case 0: self = .unrestricted
case 1: self = .showLyrics
case 2: self = .showLyricsMonthlyLimit
case 3: self = .monthlyLimitReached
default: self = .UNRECOGNIZED(rawValue)
}
}
var rawValue: Int {
switch self {
case .unrestricted: return 0
case .showLyrics: return 1
case .showLyricsMonthlyLimit: return 2
case .monthlyLimitReached: return 3
case .UNRECOGNIZED(let i): return i
}
}
}
#if swift(>=4.2)
extension LyricsRestriction: CaseIterable {
// The compiler won't synthesize support with the UNRECOGNIZED case.
static let allCases: [LyricsRestriction] = [
.unrestricted,
.showLyrics,
.showLyricsMonthlyLimit,
.monthlyLimitReached,
]
}
#endif // swift(>=4.2)
struct LyricsLine {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var offsetMs: Int32 = 0
var content: String = String()
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
}
struct LyricsColors {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var backgroundColor: UInt32 = 0
var lineColor: UInt32 = 0
var activeLineColor: UInt32 = 0
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
}
struct LyricsData {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var timeSynchronized: Bool = false
var lines: [LyricsLine] = []
var providedBy: String = String()
var restriction: LyricsRestriction = .unrestricted
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
}
struct Lyrics {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var data: LyricsData {
get {return _data ?? LyricsData()}
set {_data = newValue}
}
/// Returns true if `data` has been explicitly set.
var hasData: Bool {return self._data != nil}
/// Clears the value of `data`. Subsequent reads from it will return its default value.
mutating func clearData() {self._data = nil}
var colors: LyricsColors {
get {return _colors ?? LyricsColors()}
set {_colors = newValue}
}
/// Returns true if `colors` has been explicitly set.
var hasColors: Bool {return self._colors != nil}
/// Clears the value of `colors`. Subsequent reads from it will return its default value.
mutating func clearColors() {self._colors = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
fileprivate var _data: LyricsData? = nil
fileprivate var _colors: LyricsColors? = nil
}
#if swift(>=5.5) && canImport(_Concurrency)
extension LyricsRestriction: @unchecked Sendable {}
extension LyricsLine: @unchecked Sendable {}
extension LyricsColors: @unchecked Sendable {}
extension LyricsData: @unchecked Sendable {}
extension Lyrics: @unchecked Sendable {}
#endif // swift(>=5.5) && canImport(_Concurrency)
// MARK: - Code below here is support for the SwiftProtobuf runtime.
extension LyricsRestriction: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
0: .same(proto: "UNRESTRICTED"),
1: .same(proto: "SHOW_LYRICS"),
2: .same(proto: "SHOW_LYRICS_MONTHLY_LIMIT"),
3: .same(proto: "MONTHLY_LIMIT_REACHED"),
]
}
extension LyricsLine: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "LyricsLine"
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "offsetMs"),
2: .same(proto: "content"),
]
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt32Field(value: &self.offsetMs) }()
case 2: try { try decoder.decodeSingularStringField(value: &self.content) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if self.offsetMs != 0 {
try visitor.visitSingularInt32Field(value: self.offsetMs, fieldNumber: 1)
}
if !self.content.isEmpty {
try visitor.visitSingularStringField(value: self.content, fieldNumber: 2)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: LyricsLine, rhs: LyricsLine) -> Bool {
if lhs.offsetMs != rhs.offsetMs {return false}
if lhs.content != rhs.content {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension LyricsColors: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "LyricsColors"
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "backgroundColor"),
2: .same(proto: "lineColor"),
3: .same(proto: "activeLineColor"),
]
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularUInt32Field(value: &self.backgroundColor) }()
case 2: try { try decoder.decodeSingularUInt32Field(value: &self.lineColor) }()
case 3: try { try decoder.decodeSingularUInt32Field(value: &self.activeLineColor) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if self.backgroundColor != 0 {
try visitor.visitSingularUInt32Field(value: self.backgroundColor, fieldNumber: 1)
}
if self.lineColor != 0 {
try visitor.visitSingularUInt32Field(value: self.lineColor, fieldNumber: 2)
}
if self.activeLineColor != 0 {
try visitor.visitSingularUInt32Field(value: self.activeLineColor, fieldNumber: 3)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: LyricsColors, rhs: LyricsColors) -> Bool {
if lhs.backgroundColor != rhs.backgroundColor {return false}
if lhs.lineColor != rhs.lineColor {return false}
if lhs.activeLineColor != rhs.activeLineColor {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension LyricsData: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "LyricsData"
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "timeSynchronized"),
2: .same(proto: "lines"),
5: .same(proto: "providedBy"),
14: .same(proto: "restriction"),
]
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularBoolField(value: &self.timeSynchronized) }()
case 2: try { try decoder.decodeRepeatedMessageField(value: &self.lines) }()
case 5: try { try decoder.decodeSingularStringField(value: &self.providedBy) }()
case 14: try { try decoder.decodeSingularEnumField(value: &self.restriction) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if self.timeSynchronized != false {
try visitor.visitSingularBoolField(value: self.timeSynchronized, fieldNumber: 1)
}
if !self.lines.isEmpty {
try visitor.visitRepeatedMessageField(value: self.lines, fieldNumber: 2)
}
if !self.providedBy.isEmpty {
try visitor.visitSingularStringField(value: self.providedBy, fieldNumber: 5)
}
if self.restriction != .unrestricted {
try visitor.visitSingularEnumField(value: self.restriction, fieldNumber: 14)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: LyricsData, rhs: LyricsData) -> Bool {
if lhs.timeSynchronized != rhs.timeSynchronized {return false}
if lhs.lines != rhs.lines {return false}
if lhs.providedBy != rhs.providedBy {return false}
if lhs.restriction != rhs.restriction {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Lyrics: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "Lyrics"
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "data"),
2: .same(proto: "colors"),
]
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularMessageField(value: &self._data) }()
case 2: try { try decoder.decodeSingularMessageField(value: &self._colors) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._data {
try visitor.visitSingularMessageField(value: v, fieldNumber: 1)
} }()
try { if let v = self._colors {
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Lyrics, rhs: Lyrics) -> Bool {
if lhs._data != rhs._data {return false}
if lhs._colors != rhs._colors {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ Description: A tweak to get Spotify Premium for free, just like Spotilife
Maintainer: Eevee
Author: Eevee
Section: Tweaks
Depends: ${ORION}, firmware (>= 14.0)
Depends: ${ORION}, firmware (>= 14.0), org.swift.protobuf.SwiftProtobuf