updated lyrics architecture

This commit is contained in:
eevee
2024-07-03 03:35:22 +03:00
parent 88a7ea6159
commit 00920de57a
14 changed files with 262 additions and 268 deletions
@@ -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
}
@@ -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
)
}
}
@@ -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
)
}
}