Skip to content

Commit f776064

Browse files
authored
Merge pull request #1 from rryam/lyrics
Add Support to Fetch Lyrics
2 parents cd73b72 + e7c383f commit f776064

13 files changed

+470
-53
lines changed

.swiftpm/xcode/xcshareddata/xcschemes/MusanovaKit.xcscheme

+7
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
debugDocumentVersioning = "YES"
6565
debugServiceExtension = "internal"
6666
allowLocationSimulation = "YES">
67+
<EnvironmentVariables>
68+
<EnvironmentVariable
69+
key = "DEVELOPER_TOKEN"
70+
value = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNzE4MDUyMDUxLCJleHAiOjE3MjUzMDk2NTEsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.nC_CMYwimOXXzbgMjaiqTSRM9pw_8eXp1byKSIw9ZbC62OqSc-6PaZrdBtWhzcMx7SNQuhq98x_-JhAwzd3h2Q"
71+
isEnabled = "YES">
72+
</EnvironmentVariable>
73+
</EnvironmentVariables>
6774
</LaunchAction>
6875
<ProfileAction
6976
buildConfiguration = "Release"

Package.resolved

+11-40
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,14 @@
11
{
2-
"object": {
3-
"pins": [
4-
{
5-
"package": "MusadoraKit",
6-
"repositoryURL": "https://github.com/rryam/MusadoraKit",
7-
"state": {
8-
"branch": "main",
9-
"revision": "557a538f88c27d459c5315528634ea97e5ffb474",
10-
"version": null
11-
}
12-
},
13-
{
14-
"package": "SwiftDocCPlugin",
15-
"repositoryURL": "https://github.com/apple/swift-docc-plugin",
16-
"state": {
17-
"branch": null,
18-
"revision": "9b1258905c21fc1b97bf03d1b4ca12c4ec4e5fda",
19-
"version": "1.2.0"
20-
}
21-
},
22-
{
23-
"package": "SymbolKit",
24-
"repositoryURL": "https://github.com/apple/swift-docc-symbolkit",
25-
"state": {
26-
"branch": null,
27-
"revision": "b45d1f2ed151d057b54504d653e0da5552844e34",
28-
"version": "1.0.0"
29-
}
30-
},
31-
{
32-
"package": "SwiftLintPlugin",
33-
"repositoryURL": "https://github.com/lukepistrol/SwiftLintPlugin",
34-
"state": {
35-
"branch": null,
36-
"revision": "f3586ed424d7bf5d94628332fbd0edebf1f5147f",
37-
"version": "0.2.3"
38-
}
2+
"pins" : [
3+
{
4+
"identity" : "musadorakit",
5+
"kind" : "remoteSourceControl",
6+
"location" : "https://github.com/rryam/MusadoraKit",
7+
"state" : {
8+
"branch" : "main",
9+
"revision" : "14e5e1c7c0410fed788b515f5dcfb27068ed295c"
3910
}
40-
]
41-
},
42-
"version": 1
11+
}
12+
],
13+
"version" : 2
4314
}

Package.swift

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1-
// swift-tools-version:5.5
1+
// swift-tools-version:5.9
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription
55

66
let package = Package(
77
name: "MusanovaKit",
8-
platforms: [.iOS(.v15), .macOS(.v12), .watchOS(.v8), .tvOS(.v15)],
8+
platforms: [.iOS(.v15), .macOS(.v12), .watchOS(.v8), .tvOS(.v15), .visionOS(.v1)],
99
products: [
1010
.library(name: "MusanovaKit", targets: ["MusanovaKit"]),
1111
],
1212
dependencies: [
13-
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
14-
.package(url: "https://github.com/lukepistrol/SwiftLintPlugin", from: "0.2.2"),
1513
.package(url: "https://github.com/rryam/MusadoraKit", branch: "main")
1614
],
1715
targets: [
18-
.target(name: "MusanovaKit", dependencies: ["MusadoraKit"], plugins: [.plugin(name: "SwiftLint", package: "SwiftLintPlugin")]),
16+
.target(name: "MusanovaKit", dependencies: ["MusadoraKit"]),
1917
.testTarget(name: "MusanovaKitTests", dependencies: ["MusanovaKit"], resources: [.process("Resources")]),
2018
]
2119
)

Sources/MusanovaKit/AppleMusicAMPURLComponents.swift

+5-5
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@ import MusadoraKit
99
import Foundation
1010

1111
/// A structure that implements the `MURLComponents` protocol, specifically for Apple Music API requests.
12-
struct AppleMusicAMPURLComponents: MURLComponents {
12+
public struct AppleMusicAMPURLComponents: MURLComponents {
1313

1414
/// The underlying `URLComponents` instance.
1515
private var components: URLComponents
1616

1717
/// Initializes a new `AppleMusicAMPURLComponents` instance with default values for the scheme and host.
18-
init() {
18+
public init() {
1919
self.components = URLComponents()
2020
components.scheme = "https"
2121
components.host = "amp-api.music.apple.com"
2222
}
2323

2424
/// The query items to include in the URL.
25-
var queryItems: [URLQueryItem]? {
25+
public var queryItems: [URLQueryItem]? {
2626
get {
2727
components.queryItems
2828
} set {
@@ -31,7 +31,7 @@ struct AppleMusicAMPURLComponents: MURLComponents {
3131
}
3232

3333
/// The path for the URL, excluding the base path.
34-
var path: String {
34+
public var path: String {
3535
get {
3636
return components.path
3737
} set {
@@ -40,7 +40,7 @@ struct AppleMusicAMPURLComponents: MURLComponents {
4040
}
4141

4242
/// The constructed URL, if valid.
43-
var url: URL? {
43+
public var url: URL? {
4444
components.url
4545
}
4646
}
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// LyricLine.swift
3+
// MusanovaKit
4+
//
5+
// Created by Rudrank Riyam on 21/06/24.
6+
//
7+
8+
import Foundation
9+
10+
/// A collection of `LyricLine` objects representing a sequence of lyric lines.
11+
public typealias LyricLines = [LyricLine]
12+
13+
/// Represents a single line of lyrics.
14+
public struct LyricLine: Identifiable {
15+
16+
/// A unique identifier for the lyric line.
17+
public let id = UUID()
18+
19+
/// The text content of the lyric line.
20+
public var text: String
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// LyricParagraph.swift
3+
// MusanovaKit
4+
//
5+
// Created by Rudrank Riyam on 21/06/24.
6+
//
7+
8+
import Foundation
9+
10+
/// A collection of `LyricParagraph` objects representing the entire lyrics of a song.
11+
public typealias LyricParagraphs = [LyricParagraph]
12+
13+
/// Represents a paragraph or section of lyrics.
14+
public struct LyricParagraph: Identifiable {
15+
/// A unique identifier for the lyric paragraph.
16+
public let id = UUID()
17+
18+
/// An array of `LyricLine` objects that make up this paragraph.
19+
public let lines: LyricLines
20+
21+
/// An optional string indicating the part of the song this paragraph represents (e.g., "Verse", "Chorus").
22+
public let songPart: String?
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//
2+
// LyricsParser.swift
3+
// MusanovaKit
4+
//
5+
// Created by Rudrank Riyam on 21/06/24.
6+
//
7+
8+
import Foundation
9+
10+
/// A parser for converting TTML (Timed Text Markup Language) lyrics into structured `LyricParagraph` objects.
11+
public class LyricsParser: NSObject, XMLParserDelegate {
12+
13+
/// The parsed lyric paragraphs.
14+
private var paragraphs: [LyricParagraph] = []
15+
16+
/// The current paragraph being parsed.
17+
private var currentParagraph: [LyricLine] = []
18+
19+
/// The song part (e.g., "Verse", "Chorus") of the current paragraph.
20+
private var currentSongPart: String?
21+
22+
/// The name of the current XML element being processed.
23+
private var currentElement: String = ""
24+
25+
/// Parses the given XML string into an array of `LyricParagraph` objects.
26+
///
27+
/// - Parameter xmlString: The TTML lyrics string to parse.
28+
/// - Returns: An array of parsed `LyricParagraph` objects.
29+
func parse(_ xmlString: String) -> [LyricParagraph] {
30+
paragraphs = []
31+
if let data = xmlString.data(using: .utf8) {
32+
let parser = XMLParser(data: data)
33+
parser.delegate = self
34+
parser.parse()
35+
}
36+
return paragraphs
37+
}
38+
39+
/// Called when the parser begins parsing an element.
40+
///
41+
/// - Parameters:
42+
/// - parser: The parser object.
43+
/// - elementName: The name of the element that is being parsed.
44+
/// - namespaceURI: The namespace URI or `nil` if none is available.
45+
/// - qName: The qualified name or `nil` if none is available.
46+
/// - attributeDict: A dictionary of attribute names and values.
47+
public func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
48+
currentElement = elementName
49+
if elementName == "div" {
50+
currentSongPart = attributeDict["itunes:songPart"]
51+
currentParagraph = []
52+
} else if elementName == "p" {
53+
currentParagraph.append(LyricLine(text: ""))
54+
}
55+
}
56+
57+
/// Called when the parser finds characters within an element.
58+
///
59+
/// - Parameters:
60+
/// - parser: The parser object.
61+
/// - string: The character string.
62+
public func parser(_ parser: XMLParser, foundCharacters string: String) {
63+
if currentElement == "p", !currentParagraph.isEmpty {
64+
currentParagraph[currentParagraph.count - 1].text += string.trimmingCharacters(in: .whitespacesAndNewlines)
65+
}
66+
}
67+
68+
/// Called when the parser ends parsing an element.
69+
///
70+
/// - Parameters:
71+
/// - parser: The parser object.
72+
/// - elementName: The name of the element.
73+
/// - namespaceURI: The namespace URI or `nil` if none is available.
74+
/// - qName: The qualified name or `nil` if none is available.
75+
public func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
76+
if elementName == "div" {
77+
let paragraph = LyricParagraph(lines: currentParagraph, songPart: currentSongPart)
78+
paragraphs.append(paragraph)
79+
}
80+
}
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// MusicLyricsRequest.swift
3+
// MusanovaKit
4+
//
5+
// Created by Rudrank Riyam on 26/05/24.
6+
//
7+
8+
import Foundation
9+
10+
/// A request object used to fetch lyrics for a specified song.
11+
struct MusicLyricsRequest {
12+
13+
/// The identifier of the song.
14+
let songID: MusicItemID
15+
16+
/// The privileged developer token used to authorize the request.
17+
let developerToken: String
18+
19+
/// Initializes a new `MusicLyricsRequest`.
20+
///
21+
/// - Parameters:
22+
/// - songID: The identifier of the song.
23+
/// - developerToken: The privileged developer token used to authorize the request.
24+
init(songID: MusicItemID, developerToken: String) {
25+
self.songID = songID
26+
self.developerToken = developerToken
27+
}
28+
29+
/// Sends the request and returns a response object containing the fetched lyrics.
30+
///
31+
/// - Returns: A `LyricsResponse` object.
32+
func response(countryCode: String? = nil) async throws -> MusicLyricsResponse {
33+
let url = try await lyricsEndpointURL(countryCode: countryCode)
34+
print(url)
35+
let request = MusicPrivilegedDataRequest(url: url, developerToken: developerToken)
36+
let response = try await request.response()
37+
38+
39+
if let jsonString = String(data: response.data, encoding: .utf8) {
40+
print("Raw JSON received:")
41+
print(jsonString)
42+
}
43+
44+
let lyricsResponse = try JSONDecoder().decode(MusicLyricsResponse.self, from: response.data)
45+
return lyricsResponse
46+
}
47+
}
48+
49+
extension MusicLyricsRequest {
50+
internal func lyricsEndpointURL(countryCode: String? = nil) async throws -> URL {
51+
var components = AppleMusicAMPURLComponents()
52+
53+
let resolvedCountryCode: String
54+
if let countryCode = countryCode {
55+
resolvedCountryCode = countryCode
56+
} else {
57+
resolvedCountryCode = try await MusicDataRequest.currentCountryCode
58+
}
59+
60+
components.path = "/catalog/\(resolvedCountryCode)/songs/\(songID.rawValue)/syllable-lyrics"
61+
62+
guard let url = components.url else {
63+
throw URLError(.badURL)
64+
}
65+
66+
return url
67+
}
68+
}
69+
70+
public extension MCatalog {
71+
/// Fetches and parses the lyrics for a specified song.
72+
///
73+
/// This method performs the following steps:
74+
/// 1. Creates a `MusicLyricsRequest` using the provided song ID and developer token.
75+
/// 2. Sends the request to fetch the lyrics data.
76+
/// 3. Extracts the TTML (Timed Text Markup Language) string from the response.
77+
/// 4. Parses the TTML string into a structured `LyricParagraphs` object.
78+
///
79+
/// - Parameters:
80+
/// - song: The `Song` object representing the song for which to fetch lyrics.
81+
/// This object must have a valid `id` property.
82+
/// - developerToken: A string containing the developer token used to authorize the request.
83+
/// This token must be valid and have the necessary permissions to fetch lyrics.
84+
///
85+
/// - Returns: A `LyricParagraphs` object containing the parsed lyrics.
86+
/// This object is an array of `LyricParagraph` structures, each representing
87+
/// a section of the song's lyrics.
88+
///
89+
/// - Throws: This method can throw errors in the following situations:
90+
/// - `MusicLyricsRequest.Error`: If there's an error creating or sending the lyrics request.
91+
/// - `DecodingError`: If the response cannot be properly decoded into the expected format.
92+
/// - `URLError`: If there's a network-related error during the request.
93+
/// - `LyricsParser.Error`: If there's an error parsing the TTML string into `LyricParagraphs`.
94+
///
95+
/// - Note: If no lyrics are found for the specified song, this method returns an empty `LyricParagraphs` array
96+
/// instead of throwing an error.
97+
///
98+
/// - Important: Ensure that you have the necessary permissions and a valid developer token
99+
/// before calling this method. Unauthorized or incorrect usage may result in errors or empty results.
100+
static func lyrics(for song: Song, developerToken: String) async throws -> LyricParagraphs {
101+
let request = MusicLyricsRequest(songID: song.id, developerToken: developerToken)
102+
let response = try await request.response()
103+
104+
guard let lyricsString = response.data.first?.attributes.ttml else {
105+
return []
106+
}
107+
108+
let parser = LyricsParser()
109+
return parser.parse(lyricsString)
110+
}
111+
}

0 commit comments

Comments
 (0)