v3.0: LRCLIB and Musixmatch lyrics sources, settings menu, normalized colors, fixed Genius translation issue

This commit is contained in:
eevee
2024-05-10 01:30:12 +03:00
parent 9e194033ff
commit 47c35d2531
34 changed files with 726 additions and 60 deletions
@@ -27,56 +27,83 @@ class EncoreButtonHook: ClassHook<UIButton> {
}
}
class ProfileSettingsSectionHook: ClassHook<NSObject> {
static let targetName = "ProfileSettingsSection"
func numberOfRows() -> Int {
return 2
}
func didSelectRow(_ row: Int) {
if row == 1 {
let rootSettingsController = WindowHelper.shared.findFirstViewController(
"RootSettingsViewController"
)!
let eeveeSettingsController = EeveeSettingsViewController()
eeveeSettingsController.title = "EeveeSpotify"
rootSettingsController.navigationController!.pushViewController(
eeveeSettingsController,
animated: true
)
return
}
orig.didSelectRow(row)
}
func cellForRow(_ row: Int) -> UITableViewCell {
if row == 1 {
let settingsTableCell = Dynamic.SPTSettingsTableViewCell
.alloc(interface: SPTSettingsTableViewCell.self)
.initWithStyle(3, reuseIdentifier: "EeveeSpotify")
let tableViewCell = Dynamic.convert(settingsTableCell, to: UITableViewCell.self)
tableViewCell.accessoryView = type(
of: Dynamic.SPTDisclosureAccessoryView
.alloc(interface: SPTDisclosureAccessoryView.self)
)
.disclosureAccessoryView()
tableViewCell.textLabel?.text = "EeveeSpotify"
return tableViewCell
}
return orig.cellForRow(row)
}
}
func getCurrentTrackLyricsData() throws -> Data {
guard let track = HookedInstances.currentTrack else {
throw GeniusLyricsError.NoCurrentTrack
throw LyricsError.NoCurrentTrack
}
let title = track.trackTitle()
.removeMatches("\\(.*\\)")
.prefix(30)
let source = UserDefaults.lyricsSource
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 plainLyrics = try LyricsRepository.getLyrics(
title: track.trackTitle(),
artist: track.artistTitle(),
spotifyTrackId: track.URI().spt_trackIdentifier(),
source: source
)
let lyrics = Lyrics.with {
$0.colors = LyricsColors.with {
$0.backgroundColor = Color(hex: track.extractedColorHex()).uInt32
$0.backgroundColor = Color(hex: track.extractedColorHex()).normalized.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 }
}
}
$0.data = try! LyricsHelper.composeLyricsData(plainLyrics, source: source)
}
return try lyrics.serializedData()
@@ -0,0 +1,112 @@
import UIKit
import Foundation
class LyricsHelper {
static func composeLyricsData(_ plain: PlainLyrics, source: LyricsSource) throws -> LyricsData {
var lyricLines: [LyricsLine] = []
switch source {
case .genius:
var lines = plain
.content
.components(separatedBy: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
lines.removeAll { $0 ~= "\\[.*\\]" }
lines = Array(
lines
.drop(while: { $0.isEmpty })
.dropLast(while: { $0.isEmpty })
)
lyricLines = lines.map { line in
LyricsLine.with { $0.content = line }
}
case .lrclib:
let lines = plain
.content
.components(separatedBy: "\n")
.dropLast()
if plain.timeSynced {
lyricLines = lines.map { line in
let match = line.firstMatch(
"\\[(?<minute>\\d{2}):(?<seconds>\\d{2}\\.\\d{2})\\] ?(?<content>.*)"
)!
var captures: [String: String] = [:]
for name in ["minute", "seconds", "content"] {
let matchRange = match.range(withName: name)
if let substringRange = Range(matchRange, in: line) {
let capture = String(line[substringRange])
captures[name] = capture
}
}
let minute = Int(captures["minute"]!)!
let seconds = Float(captures["seconds"]!)!
let content = captures["content"]!
return LyricsLine.with {
$0.offsetMs = Int32(minute * 60 * 1000 + Int(seconds * 1000))
$0.content = content.lyricsNoteIfEmpty
}
}
break
}
lyricLines = lines.map { line in
LyricsLine.with { $0.content = line }
}
case .musixmatch:
if plain.timeSynced {
let lines = try JSONDecoder().decode(
[MusixmatchSubtitle].self,
from: plain.content.data(using: .utf8)!
)
.dropLast()
lyricLines = lines.map { line in
LyricsLine.with {
$0.offsetMs = Int32(Int(line.time.total * 1000))
$0.content = line.text.lyricsNoteIfEmpty
}
}
break
}
let lines = plain
.content
.components(separatedBy: "\n")
.dropLast()
lyricLines = lines.map { line in
LyricsLine.with { $0.content = line }
}
}
return LyricsData.with {
$0.timeSynchronized = plain.timeSynced
$0.restriction = .unrestricted
$0.providedBy = "\(source) (EeveeSpotify)"
$0.lines = lyricLines
}
}
}
@@ -13,6 +13,36 @@ class WindowHelper {
self.rootViewController = window.rootViewController!
}
func present(_ viewController: UIViewController) {
rootViewController.present(viewController, animated: true)
}
func findFirstViewController(_ regex: String) -> UIViewController? {
let rootView = self.rootViewController.view!
var result: UIViewController?
func searchViews(_ view: UIView) {
if let viewController = self.viewController(for: view) {
if String(describing: type(of: viewController)) ~= regex {
result = viewController
return
}
}
for subview in view.subviews {
searchViews(subview)
}
}
searchViews(rootView)
return result
}
func overrideUserInterfaceStyle(_ style: UIUserInterfaceStyle) {
window.overrideUserInterfaceStyle = style
}
func viewController(for view: UIView) -> UIViewController? {
var responder: UIResponder? = view
while let nextResponder = responder?.next {
@@ -1,10 +1,18 @@
import Foundation
struct GeniusApi {
struct GeniusLyricsDataSource {
private static let apiUrl = "https://api.genius.com"
private let apiUrl = "https://api.genius.com"
private let session: URLSession
init() {
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = ["X-Genius-iOS-Version": "6.19.1"]
session = URLSession(configuration: configuration)
}
private static func perform(
private func perform(
_ path: String,
query: [String:Any] = [:]
) throws -> GeniusDataResponse? {
@@ -12,7 +20,6 @@ struct GeniusApi {
var stringUrl = "\(apiUrl)\(path)"
if !query.isEmpty {
let queryString = query.queryString.addingPercentEncoding(
withAllowedCharacters: .urlHostAllowed
)!
@@ -20,13 +27,14 @@ struct GeniusApi {
stringUrl += "?\(queryString)"
}
var request = URLRequest(url: URL(string: stringUrl)!)
request.addValue("6.19.1", forHTTPHeaderField: "X-Genius-iOS-Version")
let request = URLRequest(url: URL(string: stringUrl)!)
let semaphore = DispatchSemaphore(value: 0)
var data: Data?
var error: Error?
let task = URLSession.shared.dataTask(with: request) { response, _, _ in
let task = session.dataTask(with: request) { response, _, err in
error = err
data = response
semaphore.signal()
}
@@ -34,30 +42,33 @@ struct GeniusApi {
task.resume()
semaphore.wait()
if let error = error {
throw error
}
let rootResponse = try JSONDecoder().decode(GeniusRootResponse.self, from: data!)
return rootResponse.response
}
static func search(_ query: String) throws -> [GeniusHit] {
func search(_ query: String) throws -> [GeniusHit] {
let data = try perform("/search", query: ["q": query])
guard case .hits(let hitsResponse) = data else {
throw GeniusLyricsError.DecodingError
throw LyricsError.DecodingError
}
return hitsResponse.hits
}
static func getSongInfo(_ songId: Int) throws -> GeniusSong {
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
throw LyricsError.DecodingError
}
return songResponse.song
}
}
@@ -0,0 +1,59 @@
import Foundation
struct LrcLibLyricsDataSource {
private let apiUrl = "https://lrclib.net/api"
private let session: URLSession
init() {
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"User-Agent": "EeveeSpotify v3.0 https://github.com/whoeevee/EeveeSpotify"
]
session = URLSession(configuration: configuration)
}
private func perform(
_ path: String,
query: [String:Any] = [:]
) throws -> Data {
var stringUrl = "\(apiUrl)\(path)"
if !query.isEmpty {
let queryString = query.queryString.addingPercentEncoding(
withAllowedCharacters: .urlHostAllowed
)!
stringUrl += "?\(queryString)"
}
let request = URLRequest(url: URL(string: stringUrl)!)
let semaphore = DispatchSemaphore(value: 0)
var data: Data?
var error: Error?
let task = session.dataTask(with: request) { response, _, err in
error = err
data = response
semaphore.signal()
}
task.resume()
semaphore.wait()
if let error = error {
throw error
}
return data!
}
func search(_ query: String) throws -> [LrclibSong] {
let data = try perform("/search", query: ["q": query])
return try JSONDecoder().decode([LrclibSong].self, from: data)
}
}
@@ -0,0 +1,91 @@
import Foundation
struct MusixmatchLyricsDataSource {
private let apiUrl = "https://apic.musixmatch.com"
private func perform(
_ path: String,
query: [String:Any] = [:]
) throws -> Data {
var stringUrl = "\(apiUrl)\(path)"
var finalQuery = query
finalQuery["usertoken"] = UserDefaults.musixmatchToken
finalQuery["app_id"] = "mac-ios-v2.0"
let queryString = finalQuery.queryString.addingPercentEncoding(
withAllowedCharacters: .urlHostAllowed
)!
stringUrl += "?\(queryString)"
let request = URLRequest(url: URL(string: stringUrl)!)
let semaphore = DispatchSemaphore(value: 0)
var data: Data?
var error: Error?
let task = URLSession.shared.dataTask(with: request) { response, _, err in
error = err
data = response
semaphore.signal()
}
task.resume()
semaphore.wait()
if let error = error {
throw error
}
return data!
}
func getLyrics(_ spotifyTrackId: String) throws -> PlainLyrics {
let data = try perform(
"/ws/1.1/macro.subtitles.get",
query: [
"track_spotify_id": spotifyTrackId,
"subtitle_format": "mxm",
"q_track": " "
]
)
// 😭😭😭
guard
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let message = json["message"] as? [String: Any],
let body = message["body"] as? [String: Any],
let macroCalls = body["macro_calls"] as? [String: Any]
else {
throw LyricsError.DecodingError
}
if let trackSubtitlesGet = macroCalls["track.subtitles.get"] as? [String: Any],
let subtitlesMessage = trackSubtitlesGet["message"] as? [String: Any],
let subtitlesBody = subtitlesMessage["body"] as? [String: Any],
let subtitlesList = subtitlesBody["subtitle_list"] as? [Any],
let firstSubtitle = subtitlesList.first as? [String: Any],
let subtitle = firstSubtitle["subtitle"] as? [String: Any],
let subtitleBody = subtitle["subtitle_body"] as? String {
return PlainLyrics(content: subtitleBody, timeSynced: true)
}
guard
let trackLyricsGet = macroCalls["track.lyrics.get"] as? [String: Any],
let lyricsMessage = trackLyricsGet["message"] as? [String: Any],
let lyricsBody = lyricsMessage["body"] as? [String: Any],
let lyrics = lyricsBody["lyrics"] as? [String: Any],
let plainLyrics = lyrics["lyrics_body"] as? String
else {
throw LyricsError.DecodingError
}
return PlainLyrics(content: plainLyrics, timeSynced: false)
}
}
@@ -0,0 +1,56 @@
import Foundation
struct LyricsRepository {
private static let geniusDataSource = GeniusLyricsDataSource()
private static let lrclibDataSource = LrcLibLyricsDataSource()
private static let musixmatchDataSource = MusixmatchLyricsDataSource()
static func getLyrics(
title: String,
artist: String,
spotifyTrackId: String,
source: LyricsSource
) throws -> PlainLyrics {
let query = "\(title.strippedTrackTitle) \(artist)"
switch source {
case .genius:
let hits = try geniusDataSource.search(query)
guard let song = (
hits.first(
where: { $0.result.title.containsInsensitive(title) }
) ?? hits.first
)?.result else {
throw LyricsError.NoSuchSong
}
let songInfo = try geniusDataSource.getSongInfo(song.id)
return PlainLyrics(content: songInfo.lyrics.plain, timeSynced: false)
case .lrclib:
let hits = try lrclibDataSource.search(query)
guard let song = (
hits.first(
where: { $0.name.containsInsensitive(title) }
) ?? hits.first
) else {
throw LyricsError.NoSuchSong
}
return PlainLyrics(
content: song.syncedLyrics ?? song.plainLyrics,
timeSynced: song.syncedLyrics != nil
)
case .musixmatch:
return try musixmatchDataSource.getLyrics(spotifyTrackId)
}
}
}
@@ -0,0 +1,7 @@
import Foundation
struct LrclibSong: Decodable {
var name: String
var plainLyrics: String
var syncedLyrics: String?
}
@@ -1,6 +1,6 @@
import Foundation
enum GeniusLyricsError: Swift.Error {
enum LyricsError: Swift.Error {
case NoCurrentTrack
case DecodingError
case NoSuchSong
@@ -0,0 +1,15 @@
import Foundation
enum LyricsSource : Int, CustomStringConvertible {
case genius
case lrclib
case musixmatch
var description : String {
switch self {
case .genius: "Genius"
case .lrclib: "LRCLIB"
case .musixmatch: "Musixmatch"
}
}
}
@@ -0,0 +1,6 @@
import Foundation
struct MusixmatchSubtitle: Decodable {
var text: String
var time: MusixmatchTime
}
@@ -0,0 +1,5 @@
import Foundation
struct MusixmatchTime: Decodable {
var total: Float
}
@@ -0,0 +1,6 @@
import Foundation
struct PlainLyrics {
var content: String
var timeSynced: Bool
}
@@ -30,20 +30,34 @@ extension Color {
}
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 {
guard UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) else {
return (0, 0, 0, 0)
}
return (r, g, b, a)
}
func lighter(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).lighter(by: amount)) }
func darker(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).darker(by: amount)) }
var brightness: CGFloat {
(
components.red * 299
+ components.green * 587
+ components.blue * 114
) / 1000
}
var normalized: Color {
brightness < 0.5
? self.lighter(by: 0.5)
: self.darker(by: brightness - 0.5)
}
var uInt32: UInt32 {
UInt32(components.alpha * 255) << 24
@@ -0,0 +1,16 @@
import Foundation
extension Data {
static func random(_ length: Int) -> Data {
return Data((0 ..< length).map { _ in UInt8.random(in: UInt8.min ... UInt8.max) })
}
var hexEncodedString: String {
map { String(format: "%02hhx", $0) }.joined()
}
static var musixmatchTokenPlaceholder: String {
"2" + self.random(53).hexEncodedString
}
}
@@ -3,22 +3,38 @@ 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
lhs.firstMatch(rhs) != nil
}
var range: NSRange {
NSRange(self.startIndex..., in: self)
}
var strippedTrackTitle: String {
String(
self
.removeMatches("\\(.*\\)")
//.removeMatches("- .*")
.prefix(30)
//.trimmingCharacters(in: .whitespaces)
)
}
var isHex: Bool {
self ~= "^[a-f0-9]+$"
}
var lyricsNoteIfEmpty: String {
self.isEmpty ? "" : self
}
func containsInsensitive<S: StringProtocol>(_ s: S) -> Bool {
self.range(of: s, options: .caseInsensitive) != nil
}
func matches(_ pattern: String) -> Bool {
func firstMatch(_ pattern: String) -> NSTextCheckingResult? {
try! NSRegularExpression(pattern: pattern)
.firstMatch(in: self, range: self.range) != nil
.firstMatch(in: self, range: self.range)
}
func removeMatches(_ pattern: String) -> String {
@@ -0,0 +1,23 @@
import SwiftUI
import UIKit
extension UIColor {
func mix(with target: UIColor, amount: CGFloat) -> Self {
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
self.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
target.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
return Self(
red: r1 * (1.0 - amount) + r2 * amount,
green: g1 * (1.0 - amount) + g2 * amount,
blue: b1 * (1.0 - amount) + b2 * amount,
alpha: a1
)
}
func lighter(by amount: CGFloat = 0.2) -> Self { mix(with: .white, amount: amount) }
func darker(by amount: CGFloat = 0.2) -> Self { mix(with: .black, amount: amount) }
}
@@ -0,0 +1,33 @@
import Foundation
extension UserDefaults {
private static let defaults = UserDefaults.standard
private static let lyricsSourceKey = "lyricsSource"
private static let musixmatchTokenKey = "musixmatchToken"
static var lyricsSource: LyricsSource {
get {
if let rawValue = defaults.object(forKey: lyricsSourceKey) as? Int {
return LyricsSource(rawValue: rawValue)!
}
return .lrclib
}
set (newSource) {
defaults.set(newSource.rawValue, forKey: lyricsSourceKey)
}
}
static var musixmatchToken: String {
get {
defaults.string(forKey: musixmatchTokenKey) ?? ""
}
set (token) {
defaults.set(token, forKey: musixmatchTokenKey)
}
}
}
@@ -0,0 +1,6 @@
import Foundation
import UIKit
@objc protocol SPTDisclosureAccessoryView {
static func disclosureAccessoryView() -> UIView
}
@@ -5,4 +5,5 @@ import Foundation
func extractedColorHex() -> String
func trackTitle() -> String
func artistTitle() -> String
func URI() -> SPTURL
}
@@ -0,0 +1,5 @@
import Foundation
@objc protocol SPTSettingsTableViewCell {
func initWithStyle(_ style: Int, reuseIdentifier: String) -> SPTSettingsTableViewCell
}
@@ -0,0 +1,6 @@
import Foundation
// it's NSURL actually, just with Spotify extensions
@objc protocol SPTURL {
func spt_trackIdentifier() -> String
}
@@ -0,0 +1,106 @@
import SwiftUI
import UIKit
struct EeveeSettingsView: View {
@State private var musixmatchToken = UserDefaults.musixmatchToken
@State private var lyricsSource = UserDefaults.lyricsSource
private func showMusixmatchTokenAlert(_ oldSource: LyricsSource) {
let alert = UIAlertController(
title: "Enter User Token",
message: "In order to use Musixmatch, you need to retrieve your user token from the Musixmatch app. Please enter it here.",
preferredStyle: .alert
)
alert.addTextField() { textField in
textField.placeholder = Data.musixmatchTokenPlaceholder
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
lyricsSource = oldSource
})
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
let token = alert.textFields!.first!.text!
if !(token ~= "^[a-f0-9]+$") {
lyricsSource = oldSource
return
}
musixmatchToken = token
UserDefaults.lyricsSource = .musixmatch
})
WindowHelper.shared.present(alert)
}
var body: some View {
List {
Section(footer: Text("""
You can select the lyrics source you prefer.
Genius: Offers the best quality lyrics, provides the most songs, and updates lyrics the fastest. Does not and will never be time-synced.
LRCLIB: The most open service, offering time-synced lyrics. However, it lacks lyrics for many songs.
Musixmatch: The service Spotify uses. Provides time-synced lyrics for many songs, but you'll need a user token to use this source.
If the tweak is unable to find a song or process the lyrics, you'll see the original Spotify one or a "Couldn't load the lyrics for this song" message. The lyrics might be wrong for some songs (e.g. another song, song article) when using Genius due to how the tweak searches songs. I've made it work in most cases.
""")) {
Picker(
"Lyrics Source",
selection: $lyricsSource
) {
Text("Genius").tag(LyricsSource.genius)
Text("LRCLIB").tag(LyricsSource.lrclib)
Text("Musixmatch").tag(LyricsSource.musixmatch)
}
if lyricsSource == .musixmatch {
VStack(alignment: .leading, spacing: 5) {
Text("Musixmatch User Token")
TextField("Enter User Token", text: $musixmatchToken)
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding(.bottom, 50)
.animation(.default, value: lyricsSource)
.onChange(of: musixmatchToken) { token in
UserDefaults.musixmatchToken = token
}
.onChange(of: lyricsSource) { [lyricsSource] newSource in
if newSource == .musixmatch && musixmatchToken.isEmpty {
showMusixmatchTokenAlert(lyricsSource)
return
}
UserDefaults.lyricsSource = newSource
}
.listStyle(GroupedListStyle())
.onAppear {
UIView.appearance(
whenContainedInInstancesOf: [UIAlertController.self]
).tintColor = UIColor(Color(hex: "#1ed760"))
WindowHelper.shared.overrideUserInterfaceStyle(.dark)
}
}
}
@@ -0,0 +1,15 @@
import SwiftUI
import UIKit
class EeveeSettingsViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let hostingController = UIHostingController(rootView: EeveeSettingsView())
hostingController.view.frame = view.bounds
view.addSubview(hostingController.view)
addChild(hostingController)
hostingController.didMove(toParent: self)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
Package: com.eevee.spotify
Name: EeveeSpotify
Version: 2.1
Version: 3.0
Architecture: iphoneos-arm
Description: A tweak to get Spotify Premium for free, just like Spotilife
Maintainer: Eevee