From 00920de57a91c793c2e57513d34d1d33656354b9 Mon Sep 17 00:00:00 2001 From: eevee Date: Wed, 3 Jul 2024 03:35:22 +0300 Subject: [PATCH] updated lyrics architecture --- .../EeveeSpotify/Lyrics/CustomLyrics.x.swift | 42 +++--- .../DataSources/LrclibLyricsDataSource.swift | 59 --------- .../Lyrics/Helpers/LyricsHelper.swift | 114 ---------------- .../Lyrics/LyricsRepository.swift | 57 -------- .../Lyrics/Models/LyricsDto.swift | 20 +++ .../Lyrics/Models/LyricsLineDto.swift | 6 + .../Lyrics/Models/LyricsSearchQuery.swift | 7 + .../Lyrics/Models/PlainLyrics.swift | 6 - .../{ => Settings}/LyricsColorsSettings.swift | 0 .../Models/{ => Settings}/LyricsSource.swift | 0 .../Lyrics/Protocols/LyricsRepository.swift | 5 + .../GeniusLyricsRepository.swift} | 58 +++++++-- .../Repositories/LrclibLyricsRepository.swift | 123 ++++++++++++++++++ .../MusixmatchLyricsRepository.swift} | 33 ++++- 14 files changed, 262 insertions(+), 268 deletions(-) delete mode 100644 Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift delete mode 100644 Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift delete mode 100644 Sources/EeveeSpotify/Lyrics/LyricsRepository.swift create mode 100644 Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift create mode 100644 Sources/EeveeSpotify/Lyrics/Models/LyricsLineDto.swift create mode 100644 Sources/EeveeSpotify/Lyrics/Models/LyricsSearchQuery.swift delete mode 100644 Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift rename Sources/EeveeSpotify/Lyrics/Models/{ => Settings}/LyricsColorsSettings.swift (100%) rename Sources/EeveeSpotify/Lyrics/Models/{ => Settings}/LyricsSource.swift (100%) create mode 100644 Sources/EeveeSpotify/Lyrics/Protocols/LyricsRepository.swift rename Sources/EeveeSpotify/Lyrics/{DataSources/GeniusLyricsDataSource.swift => Repositories/GeniusLyricsRepository.swift} (57%) create mode 100644 Sources/EeveeSpotify/Lyrics/Repositories/LrclibLyricsRepository.swift rename Sources/EeveeSpotify/Lyrics/{DataSources/MusixmatchLyricsDataSource.swift => Repositories/MusixmatchLyricsRepository.swift} (75%) diff --git a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift index a00dcfb..af4e84b 100644 --- a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift +++ b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift @@ -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 { diff --git a/Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift b/Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift deleted file mode 100644 index 62dc297..0000000 --- a/Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift +++ /dev/null @@ -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) - } -} diff --git a/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift b/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift deleted file mode 100644 index f191796..0000000 --- a/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift +++ /dev/null @@ -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( - "\\[(?\\d*):(?\\d*\\.?\\d*)\\] ?(?.*)" - ) 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 - } - } -} diff --git a/Sources/EeveeSpotify/Lyrics/LyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/LyricsRepository.swift deleted file mode 100644 index 79179db..0000000 --- a/Sources/EeveeSpotify/Lyrics/LyricsRepository.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift new file mode 100644 index 0000000..1e7cd57 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift @@ -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) + } + } + } + } +} diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsLineDto.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsLineDto.swift new file mode 100644 index 0000000..eaaab7f --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsLineDto.swift @@ -0,0 +1,6 @@ +import Foundation + +struct LyricsLineDto { + var content: String + var offsetMs: Int? +} diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsSearchQuery.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsSearchQuery.swift new file mode 100644 index 0000000..1a18fa2 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsSearchQuery.swift @@ -0,0 +1,7 @@ +import Foundation + +struct LyricsSearchQuery { + var title: String + var primaryArtist: String + var spotifyTrackId: String +} diff --git a/Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift b/Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift deleted file mode 100644 index eca5bd1..0000000 --- a/Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -struct PlainLyrics { - var content: String - var timeSynced: Bool -} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsColorsSettings.swift b/Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsColorsSettings.swift similarity index 100% rename from Sources/EeveeSpotify/Lyrics/Models/LyricsColorsSettings.swift rename to Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsColorsSettings.swift diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsSource.swift b/Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsSource.swift similarity index 100% rename from Sources/EeveeSpotify/Lyrics/Models/LyricsSource.swift rename to Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsSource.swift diff --git a/Sources/EeveeSpotify/Lyrics/Protocols/LyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/Protocols/LyricsRepository.swift new file mode 100644 index 0000000..9d4184e --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Protocols/LyricsRepository.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol LyricsRepository { + func getLyrics(_ query: LyricsSearchQuery) throws -> LyricsDto +} diff --git a/Sources/EeveeSpotify/Lyrics/DataSources/GeniusLyricsDataSource.swift b/Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift similarity index 57% rename from Sources/EeveeSpotify/Lyrics/DataSources/GeniusLyricsDataSource.swift rename to Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift index e99d859..0af1b92 100644 --- a/Sources/EeveeSpotify/Lyrics/DataSources/GeniusLyricsDataSource.swift +++ b/Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift @@ -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 + ) + } } diff --git a/Sources/EeveeSpotify/Lyrics/Repositories/LrclibLyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/Repositories/LrclibLyricsRepository.swift new file mode 100644 index 0000000..434eb4f --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Repositories/LrclibLyricsRepository.swift @@ -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( + "\\[(?\\d*):(?\\d*\\.?\\d*)\\] ?(?.*)" + ) 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 + ) + } +} diff --git a/Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift b/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift similarity index 75% rename from Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift rename to Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift index 5ca5cf3..f64075d 100644 --- a/Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift +++ b/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift @@ -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 + ) } }