mirror of
https://github.com/whoeevee/EeveeSpotifyReborn.git
synced 2026-01-09 00:23:20 +01:00
updated lyrics architecture
This commit is contained in:
@@ -119,18 +119,28 @@ func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data {
|
||||
throw LyricsError.NoCurrentTrack
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
let searchQuery = LyricsSearchQuery(
|
||||
title: track.trackTitle(),
|
||||
primaryArtist: track.artistTitle(),
|
||||
spotifyTrackId: track.URI().spt_trackIdentifier()
|
||||
)
|
||||
|
||||
var source = UserDefaults.lyricsSource
|
||||
|
||||
let plainLyrics: PlainLyrics?
|
||||
var repository: LyricsRepository = switch source {
|
||||
case .genius: GeniusLyricsRepository()
|
||||
case .lrclib: LrcLibLyricsRepository()
|
||||
case .musixmatch: MusixmatchLyricsRepository()
|
||||
}
|
||||
|
||||
let lyricsDto: LyricsDto
|
||||
|
||||
//
|
||||
|
||||
do {
|
||||
plainLyrics = try LyricsRepository.getLyrics(
|
||||
title: track.trackTitle(),
|
||||
artist: track.artistTitle(),
|
||||
spotifyTrackId: track.URI().spt_trackIdentifier(),
|
||||
source: source
|
||||
)
|
||||
|
||||
lyricsDto = try repository.getLyrics(searchQuery)
|
||||
lastLyricsError = nil
|
||||
}
|
||||
|
||||
@@ -175,25 +185,21 @@ func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data {
|
||||
}
|
||||
|
||||
NSLog("[EeveeSpotify] Unable to load lyrics from \(source): \(error), trying Genius as fallback")
|
||||
source = .genius
|
||||
|
||||
plainLyrics = try LyricsRepository.getLyrics(
|
||||
title: track.trackTitle(),
|
||||
artist: track.artistTitle(),
|
||||
spotifyTrackId: track.URI().spt_trackIdentifier(),
|
||||
source: source
|
||||
)
|
||||
source = .genius
|
||||
repository = GeniusLyricsRepository()
|
||||
|
||||
lyricsDto = try repository.getLyrics(searchQuery)
|
||||
}
|
||||
|
||||
let lyrics = try Lyrics.with {
|
||||
let lyrics = Lyrics.with {
|
||||
$0.colors = getLyricsColors()
|
||||
$0.data = try LyricsHelper.composeLyricsData(plainLyrics!, source: source)
|
||||
$0.data = lyricsDto.toLyricsData(source: source.description)
|
||||
}
|
||||
|
||||
return try lyrics.serializedData()
|
||||
|
||||
func getLyricsColors() -> LyricsColors {
|
||||
|
||||
let lyricsColorsSettings = UserDefaults.lyricsColors
|
||||
|
||||
if lyricsColorsSettings.displayOriginalColors, let originalLyrics = originalLyrics {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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 v\(EeveeSpotify.version) 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)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
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.compactMap { line in
|
||||
|
||||
guard let match = line.firstMatch(
|
||||
"\\[(?<minute>\\d*):(?<seconds>\\d*\\.?\\d*)\\] ?(?<content>.*)"
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var captures: [String: String] = [:]
|
||||
|
||||
for name in ["minute", "seconds", "content"] {
|
||||
|
||||
let matchRange = match.range(withName: name)
|
||||
|
||||
if let substringRange = Range(matchRange, in: line) {
|
||||
captures[name] = String(line[substringRange])
|
||||
}
|
||||
}
|
||||
|
||||
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.lyricsNoteIfEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LyricsData.with {
|
||||
$0.timeSynchronized = plain.timeSynced
|
||||
$0.restriction = .unrestricted
|
||||
$0.providedBy = "\(source) (EeveeSpotify)"
|
||||
$0.lines = lyricLines
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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 strippedTitle = title.strippedTrackTitle
|
||||
let query = "\(strippedTitle) \(artist)"
|
||||
|
||||
switch source {
|
||||
|
||||
case .genius:
|
||||
|
||||
let hits = try geniusDataSource.searchSong(query)
|
||||
|
||||
guard let song = (
|
||||
hits.first(
|
||||
where: { $0.result.title.containsInsensitive(strippedTitle) }
|
||||
) ?? 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(strippedTitle) }
|
||||
) ?? 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,20 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsDto {
|
||||
var lines: [LyricsLineDto]
|
||||
var timeSynced: Bool
|
||||
|
||||
func toLyricsData(source: String) -> LyricsData {
|
||||
return LyricsData.with {
|
||||
$0.timeSynchronized = timeSynced
|
||||
$0.restriction = .unrestricted
|
||||
$0.providedBy = "\(source) (EeveeSpotify)"
|
||||
$0.lines = lines.map { line in
|
||||
LyricsLine.with {
|
||||
$0.content = line.content
|
||||
$0.offsetMs = Int32(line.offsetMs ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsLineDto {
|
||||
var content: String
|
||||
var offsetMs: Int?
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsSearchQuery {
|
||||
var title: String
|
||||
var primaryArtist: String
|
||||
var spotifyTrackId: String
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct PlainLyrics {
|
||||
var content: String
|
||||
var timeSynced: Bool
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol LyricsRepository {
|
||||
func getLyrics(_ query: LyricsSearchQuery) throws -> LyricsDto
|
||||
}
|
||||
+50
-8
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct GeniusLyricsDataSource {
|
||||
|
||||
struct GeniusLyricsRepository: LyricsRepository {
|
||||
private let apiUrl = "https://api.genius.com"
|
||||
private let session: URLSession
|
||||
|
||||
@@ -20,7 +19,6 @@ struct GeniusLyricsDataSource {
|
||||
_ path: String,
|
||||
query: [String:Any] = [:]
|
||||
) throws -> GeniusDataResponse? {
|
||||
|
||||
var stringUrl = "\(apiUrl)\(path)"
|
||||
|
||||
if !query.isEmpty {
|
||||
@@ -54,11 +52,12 @@ struct GeniusLyricsDataSource {
|
||||
return rootResponse.response
|
||||
}
|
||||
|
||||
func searchSong(_ query: String) throws -> [GeniusHit] {
|
||||
|
||||
//
|
||||
|
||||
private func searchSong(_ query: String) throws -> [GeniusHit] {
|
||||
let data = try perform("/search/song", query: ["q": query])
|
||||
|
||||
guard
|
||||
guard
|
||||
case .sections(let sectionsResponse) = data,
|
||||
let section = sectionsResponse.sections.first
|
||||
else {
|
||||
@@ -68,8 +67,7 @@ struct GeniusLyricsDataSource {
|
||||
return section.hits
|
||||
}
|
||||
|
||||
func getSongInfo(_ songId: Int) throws -> GeniusSong {
|
||||
|
||||
private func getSongInfo(_ songId: Int) throws -> GeniusSong {
|
||||
let data = try perform("/songs/\(songId)", query: ["text_format": "plain"])
|
||||
|
||||
guard case .song(let songResponse) = data else {
|
||||
@@ -78,4 +76,48 @@ struct GeniusLyricsDataSource {
|
||||
|
||||
return songResponse.song
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
private func mostRelevantHitResult(hits: [GeniusHit], strippedTitle: String) -> GeniusHitResult? {
|
||||
return (
|
||||
hits.first(
|
||||
where: { $0.result.title.containsInsensitive(strippedTitle) }
|
||||
) ?? hits.first
|
||||
)?.result
|
||||
}
|
||||
|
||||
private func mapLyricsLines(_ rawLines: [String]) -> [String] {
|
||||
var lines = rawLines
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
|
||||
lines.removeAll { $0 ~= "\\[.*\\]" }
|
||||
|
||||
lines = Array(
|
||||
lines
|
||||
.drop(while: { $0.isEmpty })
|
||||
.dropLast(while: { $0.isEmpty })
|
||||
)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func getLyrics(_ query: LyricsSearchQuery) throws -> LyricsDto {
|
||||
let strippedTitle = query.title.strippedTrackTitle
|
||||
let hits = try searchSong("\(strippedTitle) \(query.primaryArtist)")
|
||||
|
||||
guard let song = mostRelevantHitResult(hits: hits, strippedTitle: strippedTitle) else {
|
||||
throw LyricsError.NoSuchSong
|
||||
}
|
||||
|
||||
let songInfo = try getSongInfo(song.id)
|
||||
let plainLines = songInfo.lyrics.plain.components(separatedBy: "\n")
|
||||
|
||||
return LyricsDto(
|
||||
lines: mapLyricsLines(plainLines).map {
|
||||
line in LyricsLineDto(content: line)
|
||||
},
|
||||
timeSynced: false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
struct LrcLibLyricsRepository: LyricsRepository {
|
||||
private let apiUrl = "https://lrclib.net/api"
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.httpAdditionalHeaders = [
|
||||
"User-Agent": "EeveeSpotify v\(EeveeSpotify.version) 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!
|
||||
}
|
||||
|
||||
private func searchSong(_ query: String) throws -> [LrclibSong] {
|
||||
let data = try perform("/search", query: ["q": query])
|
||||
return try JSONDecoder().decode([LrclibSong].self, from: data)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
private func mostRelevantSong(songs: [LrclibSong], strippedTitle: String) -> LrclibSong? {
|
||||
return songs.first(
|
||||
where: { $0.name.containsInsensitive(strippedTitle) }
|
||||
) ?? songs.first
|
||||
}
|
||||
|
||||
private func mapSyncedLyricsLines(_ lines: [String]) -> [LyricsLineDto] {
|
||||
return lines.compactMap { line in
|
||||
guard let match = line.firstMatch(
|
||||
"\\[(?<minute>\\d*):(?<seconds>\\d*\\.?\\d*)\\] ?(?<content>.*)"
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var captures: [String: String] = [:]
|
||||
|
||||
for name in ["minute", "seconds", "content"] {
|
||||
|
||||
let matchRange = match.range(withName: name)
|
||||
|
||||
if let substringRange = Range(matchRange, in: line) {
|
||||
captures[name] = String(line[substringRange])
|
||||
}
|
||||
}
|
||||
|
||||
let minute = Int(captures["minute"]!)!
|
||||
let seconds = Float(captures["seconds"]!)!
|
||||
let content = captures["content"]!
|
||||
|
||||
return LyricsLineDto(
|
||||
content: content.lyricsNoteIfEmpty,
|
||||
offsetMs: Int(minute * 60 * 1000 + Int(seconds * 1000))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func getLyrics(_ query: LyricsSearchQuery) throws -> LyricsDto {
|
||||
let strippedTitle = query.title.strippedTrackTitle
|
||||
let songs = try searchSong("\(strippedTitle) \(query.primaryArtist)")
|
||||
|
||||
guard let song = mostRelevantSong(songs: songs, strippedTitle: strippedTitle) else {
|
||||
throw LyricsError.NoSuchSong
|
||||
}
|
||||
|
||||
if let syncedLyrics = song.syncedLyrics {
|
||||
return LyricsDto(
|
||||
lines: mapSyncedLyricsLines(
|
||||
syncedLyrics.components(separatedBy: "\n").dropLast()
|
||||
),
|
||||
timeSynced: true
|
||||
)
|
||||
}
|
||||
|
||||
guard let plainLyrics = song.plainLyrics else {
|
||||
throw LyricsError.DecodingError
|
||||
}
|
||||
|
||||
return LyricsDto(
|
||||
lines: plainLyrics.components(separatedBy: "\n").dropLast().map { content in
|
||||
LyricsLineDto(content: content)
|
||||
},
|
||||
timeSynced: false
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
-6
@@ -1,8 +1,7 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct MusixmatchLyricsDataSource {
|
||||
|
||||
struct MusixmatchLyricsRepository: LyricsRepository {
|
||||
private let apiUrl = "https://apic.musixmatch.com"
|
||||
|
||||
private func perform(
|
||||
@@ -47,12 +46,12 @@ struct MusixmatchLyricsDataSource {
|
||||
return data!
|
||||
}
|
||||
|
||||
func getLyrics(_ spotifyTrackId: String) throws -> PlainLyrics {
|
||||
func getLyrics(_ query: LyricsSearchQuery) throws -> LyricsDto {
|
||||
|
||||
let data = try perform(
|
||||
"/ws/1.1/macro.subtitles.get",
|
||||
query: [
|
||||
"track_spotify_id": spotifyTrackId,
|
||||
"track_spotify_id": query.spotifyTrackId,
|
||||
"subtitle_format": "mxm",
|
||||
"q_track": " "
|
||||
]
|
||||
@@ -93,7 +92,23 @@ struct MusixmatchLyricsDataSource {
|
||||
}
|
||||
|
||||
if let subtitleBody = subtitle["subtitle_body"] as? String {
|
||||
return PlainLyrics(content: subtitleBody, timeSynced: true)
|
||||
|
||||
guard let subtitles = try? JSONDecoder().decode(
|
||||
[MusixmatchSubtitle].self,
|
||||
from: subtitleBody.data(using: .utf8)!
|
||||
).dropLast() else {
|
||||
throw LyricsError.DecodingError
|
||||
}
|
||||
|
||||
return LyricsDto(
|
||||
lines: subtitles.map { subtitle in
|
||||
LyricsLineDto(
|
||||
content: subtitle.text.lyricsNoteIfEmpty,
|
||||
offsetMs: Int(subtitle.time.total * 1000)
|
||||
)
|
||||
},
|
||||
timeSynced: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +130,13 @@ struct MusixmatchLyricsDataSource {
|
||||
throw LyricsError.MusixmatchRestricted
|
||||
}
|
||||
|
||||
return PlainLyrics(content: plainLyrics, timeSynced: false)
|
||||
return LyricsDto(
|
||||
lines: plainLyrics
|
||||
.components(separatedBy: "\n")
|
||||
.dropLast()
|
||||
.map { LyricsLineDto(content: $0.lyricsNoteIfEmpty) },
|
||||
timeSynced: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user