From: Ruben Beltran del Rio Date: Fri, 15 Mar 2024 21:10:06 +0000 (+0100) Subject: Add macos sources X-Git-Tag: 3.0.0~21 X-Git-Url: https://git.r.bdr.sh/rbdr/lyricli/commitdiff_plain/44e7b4de4073e6dc25681bb2fa6977bf5869689a?ds=sidebyside;hp=b2096fb3958d9e9cdcca77931aadfb947cab24ee Add macos sources --- diff --git a/.build.yml b/.build.yml new file mode 100644 index 0000000..1123577 --- /dev/null +++ b/.build.yml @@ -0,0 +1,28 @@ +image: archlinux +packages: + - make + - rsync + - coreutils + - clang + - lld + - rustup + - aarch64-linux-gnu-gcc + - tar + - gzip +sources: + - git@git.sr.ht:~rbdr/lyricli +environment: + GPG_TTY: /dev/pts/0 +secrets: + - 89d3b676-25d6-4942-8231-38b73aa62bf6 + - 0b0d3e5e-fbdc-41d0-97ed-ee654fe797ff +tasks: + - set_rust: | + cd lyricli + make set_rust + - install_builders: | + cargo install cargo-generate-rpm + cargo install cargo-deb + - package: | + cd lyricli + make ci diff --git a/Cargo.lock b/Cargo.lock index 5a286a0..95c6f91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,12 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "bumpalo" version = "3.15.4" @@ -187,6 +193,36 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + [[package]] name = "colorchoice" version = "1.0.0" @@ -209,6 +245,30 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "core-graphics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "cssparser" version = "0.31.2" @@ -307,7 +367,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", ] [[package]] @@ -316,6 +397,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -611,8 +698,10 @@ name = "lyricli" version = "3.0.0" dependencies = [ "clap", + "cocoa", "libc", "objc", + "objc_id", "percent-encoding", "reqwest", "scraper", @@ -725,6 +814,15 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.32.2" @@ -748,7 +846,7 @@ checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ "bitflags 2.4.2", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", diff --git a/Cargo.toml b/Cargo.toml index 89aacad..ba316a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,6 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5.2", features = ["derive"] } -libc = "0.2.153" -objc = "0.2.7" percent-encoding = "2.3.1" reqwest = { version = "0.11", features = ["json"] } scraper = "0.19.0" @@ -22,6 +20,12 @@ serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" tokio = { version = "1", features = ["full"] } +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2.153" +objc = "0.2.7" +cocoa = "0.25.0" +objc_id = "0.1.1" + [profile.release] strip = true lto = true diff --git a/Makefile b/Makefile index 3416abd..4d414c8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ profile := dev target = $(shell rustc -vV | grep host | awk '{print $$2}') architectures := x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu -app_name := blog +app_name := lrc default: build diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 54140ee..0000000 --- a/Package.resolved +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", - "version" : "1.2.2" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup.git", - "state" : { - "revision" : "f707b8680cddb96dc1855632340a572ef37bbb98", - "version" : "2.5.3" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift deleted file mode 100644 index 508b5fb..0000000 --- a/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version:5.8 - -import PackageDescription - -let package = Package( - name: "lyricli", - dependencies: [ - /// HTML Parsing - .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.5.3"), - - /// 🚩 Command Line Arguments - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2") - ], - targets: [ - .executableTarget( - name: "lyricli", - dependencies: [ - .product(name: "SwiftSoup", package: "SwiftSoup"), - .product(name: "ArgumentParser", package: "swift-argument-parser") - ]) - ] -) diff --git a/Sources/lyricli/configuration.swift b/Sources/lyricli/configuration.swift deleted file mode 100644 index b2defc1..0000000 --- a/Sources/lyricli/configuration.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation - -// Reads and writes the configuration. Config keys are accessed as a dictionary. -class Configuration { - // Location of the global configuration file - private let configurationPath = NSString(string: "~/.lyricli.conf").expandingTildeInPath - - // Default options, will be automatically written to the global config if - // not found. - private var configuration: [String: Any] = [ - "enabled_sources": ["apple_music", "spotify"] - ] - - // The shared instance of the object - static let shared: Configuration = Configuration() - - private init() { - - // Read the config file and attempt to set any of the values. Otherwise - // don't do anything. - - if let data = try? NSData(contentsOfFile: configurationPath) as Data { - if let parsedConfig = try? JSONSerialization.jsonObject(with: data) { - if let parsedConfig = parsedConfig as? [String: Any] { - for (key, value) in parsedConfig { - - if key == "enabled_sources" { - if let value = value as? [String] { - configuration[key] = value - } - } else { - if let value = value as? String { - configuration[key] = value - } - } - } - } - } - } - - writeConfiguration() - } - - // Write the configuration back to the file - private func writeConfiguration() { - - var error: NSError? - - if let outputStream = OutputStream(toFileAtPath: configurationPath, append: false) { - outputStream.open() - JSONSerialization.writeJSONObject(configuration, - to: outputStream, - options: [JSONSerialization.WritingOptions.prettyPrinted], - error: &error) - outputStream.close() - } - } - - // Allow access to the config properties as a dictionary - subscript(index: String) -> Any? { - get { - return configuration[index] - } - - set(newValue) { - configuration[index] = newValue - writeConfiguration() - } - } -} diff --git a/Sources/lyricli/errors/configuration_could_not_be_read.swift b/Sources/lyricli/errors/configuration_could_not_be_read.swift deleted file mode 100644 index fb2b61a..0000000 --- a/Sources/lyricli/errors/configuration_could_not_be_read.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct ConfigurationCouldNotBeRead: Error { - var localizedDescription = "The configuration could not be read, check ~/.lyricli.conf" -} diff --git a/Sources/lyricli/errors/source_could_not_be_disabled.swift b/Sources/lyricli/errors/source_could_not_be_disabled.swift deleted file mode 100644 index b19f3c5..0000000 --- a/Sources/lyricli/errors/source_could_not_be_disabled.swift +++ /dev/null @@ -1,6 +0,0 @@ -// Future improvement: At the moment the sources don't need any special -// configuration. Once we do have more operations it would make sense -// to throw more descriptive errors. -struct SourceCouldNotBeDisabled: Error { - var localizedDescription = "The selected source failed while disabling" -} diff --git a/Sources/lyricli/errors/source_could_not_be_enabled.swift b/Sources/lyricli/errors/source_could_not_be_enabled.swift deleted file mode 100644 index 7797478..0000000 --- a/Sources/lyricli/errors/source_could_not_be_enabled.swift +++ /dev/null @@ -1,6 +0,0 @@ -// Future improvement: At the moment the sources don't need any special -// configuration. Once we do have more operations it would make sense -// to throw more descriptive errors. -struct SourceCouldNotBeEnabled: Error { - var localizedDescription = "The selected source failed while enabling" -} diff --git a/Sources/lyricli/errors/source_could_not_be_reset.swift b/Sources/lyricli/errors/source_could_not_be_reset.swift deleted file mode 100644 index beecf54..0000000 --- a/Sources/lyricli/errors/source_could_not_be_reset.swift +++ /dev/null @@ -1,6 +0,0 @@ -// Future improvement: At the moment the sources don't need any special -// configuration. Once we do have more operations it would make sense -// to throw more descriptive errors. -struct SourceCouldNotBeReset: Error { - var localizedDescription = "The selected source failed while resetting" -} diff --git a/Sources/lyricli/errors/source_not_available.swift b/Sources/lyricli/errors/source_not_available.swift deleted file mode 100644 index e3d598d..0000000 --- a/Sources/lyricli/errors/source_not_available.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct SourceNotAvailable: Error { - var localizedDescription = "The selected source wasn't available" -} diff --git a/Sources/lyricli/lyricli.swift b/Sources/lyricli/lyricli.swift deleted file mode 100644 index d5e5b91..0000000 --- a/Sources/lyricli/lyricli.swift +++ /dev/null @@ -1,102 +0,0 @@ -// The main class, handles all the actions that the executable will call -class Lyricli { - - // Version of the application - static var version = "2.0.1" - - // Flag that controls whether we should show the track artist and name before - // the lyrics - static var showTitle = false - - // Obtains the name of the current track from a source, fetches the lyrics - // from an engine and prints them - static func printLyrics() { - - let sourceManager = SourceManager() - - if let currentTrack = sourceManager.currentTrack { - printLyrics(currentTrack) - } else { - print("No Artist/Song could be found :(") - } - } - - // fetches the lyrics from an engine and prints them - static func printLyrics(_ currentTrack: Track) { - let engine = LyricsEngine(withTrack: currentTrack) - - if showTitle { - printTitle(currentTrack) - } - if let lyrics = engine.lyrics { - print(lyrics) - } else { - print("Lyrics not found :(") - } - } - - // Print the currently available sources - static func printSources() { - let sourceManager = SourceManager() - for (sourceName, _) in sourceManager.availableSources { - if (Configuration.shared["enabled_sources"] as? [String] ?? []).contains(sourceName) { - print("\(sourceName) (enabled)") - } else { - print(sourceName) - } - } - } - - // Runs the enable method of a source and writes the configuration to set it - // as enabled - static func enableSource(_ sourceName: String) throws { - let sourceManager = SourceManager() - if let source = sourceManager.availableSources[sourceName] { - if let enabledSources = Configuration.shared["enabled_sources"] as? [String] { - if source.enable() == false { - throw SourceCouldNotBeEnabled() - } - if !enabledSources.contains(sourceName) { - Configuration.shared["enabled_sources"] = enabledSources + [sourceName] - } - return - } - throw ConfigurationCouldNotBeRead() - } - throw SourceNotAvailable() - } - - // Remove a source from the enabled sources configuration - static func disableSource(_ sourceName: String) throws { - let sourceManager = SourceManager() - if let source = sourceManager.availableSources[sourceName] { - if let enabledSources = Configuration.shared["enabled_sources"] as? [String] { - if source.disable() == false { - throw SourceCouldNotBeDisabled() - } - Configuration.shared["enabled_sources"] = enabledSources.filter { $0 != sourceName } - return - } - throw ConfigurationCouldNotBeRead() - } - throw SourceNotAvailable() - } - - // Removes any configuration for a source, and disables it - static func resetSource(_ sourceName: String) throws { - let sourceManager = SourceManager() - if let source = sourceManager.availableSources[sourceName] { - if source.reset() == false { - throw SourceCouldNotBeReset() - } - try disableSource(sourceName) - return - } - throw SourceNotAvailable() - } - - // Prints the track artist and name - private static func printTitle(_ track: Track) { - print("\(track.artist) - \(track.name)") - } -} diff --git a/Sources/lyricli/lyricli_command.swift b/Sources/lyricli/lyricli_command.swift deleted file mode 100644 index 872ab6e..0000000 --- a/Sources/lyricli/lyricli_command.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Darwin -import ArgumentParser - -@main -struct LyricliCommand: ParsableCommand { - - // Positional Arguments - @Argument var artist: String? - @Argument var trackName: String? - - // Flags - @Flag(name: .shortAndLong, help: "Prints the version.") - var version = false - - @Flag(name: [.long, .customShort("t")], help: "Shows title of track if true") - var showTitle = false - - @Flag(name: .shortAndLong, help: "Lists all sources") - var listSources = false - - // Named Arguments - @Option(name: .shortAndLong, help: ArgumentHelp("Enables a source", valueName: "source")) - var enableSource: String? - - @Option(name: .shortAndLong, help: ArgumentHelp("Disables a source", valueName: "source")) - var disableSource: String? - - @Option(name: .shortAndLong, help: ArgumentHelp("Resets a source", valueName: "source")) - var resetSource: String? - - mutating func run() throws { - - // Handle the version flag - if version { - print(Lyricli.version) - Darwin.exit(0) - } - - // Handle the list sources flag - if listSources { - Lyricli.printSources() - Darwin.exit(0) - } - - // Handle the enable source option - if let source = enableSource { - do { - try Lyricli.enableSource(source) - } catch let error { - handleErrorAndQuit(error) - } - Darwin.exit(0) - } - - // Handle the disable source option - if let source = disableSource { - do { - try Lyricli.disableSource(source) - } catch let error { - handleErrorAndQuit(error) - } - Darwin.exit(0) - } - - // Handle the reset source flag - if let source = resetSource { - do { - try Lyricli.resetSource(source) - } catch let error { - handleErrorAndQuit(error) - } - Darwin.exit(0) - } - - Lyricli.showTitle = showTitle - - if let artist { - let currentTrack: Track - if let trackName { - currentTrack = Track(withName: trackName, andArtist: artist) - } else { - currentTrack = Track(withName: "", andArtist: artist) - } - Lyricli.printLyrics(currentTrack) - Darwin.exit(0) - } - - Lyricli.printLyrics() - } - - private func handleErrorAndQuit(_ error: Error) { - fputs(error.localizedDescription, stderr) - Darwin.exit(1) - } -} diff --git a/Sources/lyricli/lyrics_engine.swift b/Sources/lyricli/lyrics_engine.swift deleted file mode 100644 index e0752d7..0000000 --- a/Sources/lyricli/lyrics_engine.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation -import SwiftSoup - -// Given a track, attempts to fetch the lyrics from lyricswiki -class LyricsEngine { - - private let clientToken = - - // URL of the API endpoint to use - private let apiURL = "https://api.genius.com/search" - - // Method used to call the API - private let apiMethod = "GET" - - // The track we'll be looking for - private let track: Track - - // Fetches the lyrics and returns if found - var lyrics: String? { - - var lyrics: String? - - // Encode the track artist and name and finish building the API call URL - - if let artist = track.artist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - if let name = track.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - - if let url = URL(string: "\(apiURL)?access_token=\(clientToken)&q=\(artist)%20\(name)") { - - // We'll lock until the async call is finished - - var requestFinished = false - let asyncLock = NSCondition() - asyncLock.lock() - - // Call the API and unlock when you're done - - searchLyricsUsingAPI(withURL: url, completionHandler: {lyricsResult -> Void in - lyrics = lyricsResult - requestFinished = true - asyncLock.signal() - }) - - while !requestFinished { - asyncLock.wait() - } - asyncLock.unlock() - } - } - } - - return lyrics - } - - // Initializes with a track - init(withTrack targetTrack: Track) { - - track = targetTrack - } - - // Fetch the lyrics URL from the API, triggers the request to fetch the - // lyrics page - private func searchLyricsUsingAPI(withURL url: URL, completionHandler: @escaping (String?) -> Void) { - - var apiRequest = URLRequest(url: url) - apiRequest.httpMethod = "GET" - - let task = URLSession.shared.dataTask(with: apiRequest, completionHandler: {data, _, _ -> Void in - - // If the response is parseable JSON, and has a url, we'll look for - // the lyrics in there - - if let data = data { - if let jsonResponse = try? JSONSerialization.jsonObject(with: data) { - if let jsonResponse = jsonResponse as? [String: Any] { - if let response = jsonResponse["response"] as? [String: Any] { - if let hits = response["hits"] as? [[String: Any]] { - let filteredHits = hits.filter { $0["type"] as? String == "song" } - if filteredHits.count > 0 { - let firstHit = hits[0] - if let firstHitData = firstHit["result"] as? [String: Any] { - if let lyricsUrlString = firstHitData["url"] as? String { - if let lyricsUrl = URL(string: lyricsUrlString) { - - // At this point we have a valid wiki url - self.fetchLyricsFromPage( - withURL: lyricsUrl, - completionHandler: completionHandler - ) - return - } - } - } - } - } - } - } - } - } - - completionHandler(nil) - }) - task.resume() - } - - // Fetch the lyrics from the page and send it to the parser - private func fetchLyricsFromPage(withURL url: URL, completionHandler: @escaping (String?) -> Void) { - - var pageRequest = URLRequest(url: url) - pageRequest.httpMethod = "GET" - - let task = URLSession.shared.dataTask(with: pageRequest, completionHandler: {data, _, _ -> Void in - - // If the response is parseable JSON, and has a url, we'll look for - // the lyrics in there - - if let data = data { - if let htmlBody = String(data: data, encoding: String.Encoding.utf8) { - self.parseHtmlBody(htmlBody, completionHandler: completionHandler) - return - } - } - - completionHandler(nil) - }) - task.resume() - } - - // Parses the wiki to find the lyrics, decodes the lyrics object - private func parseHtmlBody(_ body: String, completionHandler: @escaping (String?) -> Void) { - - do { - let document: Document = try SwiftSoup.parse(body) - let lyricsBox = try document.select("div[data-lyrics-container=\"true\"]") - try lyricsBox.select("br").after("\\n") - let lyrics = try lyricsBox.text() - completionHandler(lyrics.replacingOccurrences(of: "\\n", with: "\r\n")) - } catch { - completionHandler(nil) - } - } -} diff --git a/Sources/lyricli/source_manager.swift b/Sources/lyricli/source_manager.swift deleted file mode 100644 index 481d327..0000000 --- a/Sources/lyricli/source_manager.swift +++ /dev/null @@ -1,51 +0,0 @@ -// Collect and manage the available and enabled source -class SourceManager { - - // List of sources enabled for the crurent platform - var availableSources: [String: Source] = [ - "apple_music": AppleMusicSource(), - "spotify": SpotifySource() - ] - - // Iterate over the sources until we find a track or run out of sources - var currentTrack: Track? { - for source in enabledSources { - if let currentTrack = source.currentTrack { - return currentTrack - } - } - - return nil - } - - // Return the list of enabled sources based on the configuration - var enabledSources: [Source] { - - // Checks the config and returns an array of sources based on the - // enabled and available ones - - var sources = [Source]() - - if let sourceNames = Configuration.shared["enabled_sources"] as? [String] { - for sourceName in sourceNames { - if let source = availableSources[sourceName] { - sources.append(source) - } - } - } - - return sources - } - - // Given a source name, it will enable it and add it to the enabled sources config - func enable(sourceName: String) { - } - - // Given a source name, it will remove it from the enabled sources config - func disable(sourceName: String) { - } - - // Given a source name, it removes any stored configuration and disables it - func reset(sourceName: String) { - } -} diff --git a/Sources/lyricli/sources/apple_music_source.swift b/Sources/lyricli/sources/apple_music_source.swift deleted file mode 100644 index 5eee588..0000000 --- a/Sources/lyricli/sources/apple_music_source.swift +++ /dev/null @@ -1,80 +0,0 @@ -import ScriptingBridge -import Foundation - -// Protocol to obtain the track from -@objc protocol AppleMusicTrack { - @objc optional var name: String {get} - @objc optional var artist: String {get} -} - -// Protocol to interact with Apple Music -@objc protocol AppleMusicApplication { - @objc optional var currentTrack: AppleMusicTrack? {get} - @objc optional var currentStreamTitle: String? {get} -} - -extension SBApplication: AppleMusicApplication {} - -// Source that reads track artist and name from current itunes track -class AppleMusicSource: Source { - - // Calls the spotify API and returns the current track - var currentTrack: Track? { - - if let appleMusic: AppleMusicApplication = SBApplication(bundleIdentifier: bundleIdentifier) { - if let application = appleMusic as? SBApplication { - if !application.isRunning { - return nil - } - } - - // Attempt to fetch the title from a stream - if let currentStreamTitle = appleMusic.currentStreamTitle { - if let track = currentStreamTitle { - - let trackComponents = track.split(separator: "-").map(String.init) - - if trackComponents.count == 2 { - let artist = trackComponents[0].trimmingCharacters(in: .whitespaces) - let name = trackComponents[1].trimmingCharacters(in: .whitespaces) - - return Track(withName: name, andArtist: artist) - } - - } - } - - // Attempt to fetch the title from a song - if let currentTrack = appleMusic.currentTrack { - if let track = currentTrack { - if let name = track.name { - if let artist = track.artist { - - // track properties are empty strings if itunes is closed - if name == "" || artist == "" { - return nil - } - return Track(withName: name, andArtist: artist) - } - } - } - } - } - - return nil - } - - private var bundleIdentifier: String { - if ProcessInfo().isOperatingSystemAtLeast( - OperatingSystemVersion(majorVersion: 10, minorVersion: 15, patchVersion: 0) - ) { - return "com.apple.Music" - } - - return "com.apple.iTunes" - } - - func enable() -> Bool { return true } - func disable() -> Bool { return true } - func reset() -> Bool { return true } -} diff --git a/Sources/lyricli/sources/source_protocol.swift b/Sources/lyricli/sources/source_protocol.swift deleted file mode 100644 index 8d52603..0000000 --- a/Sources/lyricli/sources/source_protocol.swift +++ /dev/null @@ -1,9 +0,0 @@ -// All sources should comply with this protocol. The currentTrack computed -// property will return a track if the conditions are met -protocol Source { - var currentTrack: Track? { get } - - func disable() -> Bool - func enable() -> Bool - func reset() -> Bool -} diff --git a/Sources/lyricli/track.swift b/Sources/lyricli/track.swift deleted file mode 100644 index ead4359..0000000 --- a/Sources/lyricli/track.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Holds the name and artist of a track -class Track { - - // The name of the track to search for - let name: String - - // The name of the artist - let artist: String - - init(withName trackName: String, andArtist trackArtist: String) { - - name = trackName - artist = trackArtist - } -} diff --git a/man/lrc.1 b/man/lrc.1 new file mode 100644 index 0000000..e69de29 diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..f678461 --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,98 @@ +use std::env; +use std::fs::{create_dir_all, write, File}; +use std::io::{Read, Result}; +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use serde_json; + +const CONFIG_ENV_VARIABLE: &str = "LYRICLI_CONFIG_DIRECTORY"; +const CONFIG_DEFAULT_LOCATION: &str = "XDG_CONFIG_HOME"; +const CONFIG_FALLBACK_LOCATION: &str = ".config"; +const CONFIG_SUBDIRECTORY: &str = ".config"; +const CONFIG_FILENAME: &str = "lyricli.conf"; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Configuration { + enabled_sources: Vec +} + +impl Configuration { + + pub fn new() -> Self { + + if let Some(configuration) = Configuration::read() { + return configuration; + } + + let configuration = Configuration::default(); + + // Write the defaults. + Configuration::write(&configuration).ok(); + configuration + } + + // Public API + + pub fn is_enabled(&self, source_name: &String) -> bool { + self.enabled_sources.contains(source_name) + } + + pub fn enable_source(&mut self, source_name: &String) -> Result<()> { + if !self.enabled_sources.contains(source_name) { + self.enabled_sources.push(source_name.to_owned()) + } + Configuration::write(self) + } + + pub fn disable_source(&mut self, source_name: &String) -> Result<()> { + self.enabled_sources.retain(|source| source != source_name); + Configuration::write(self) + } + + // Helpers + + fn default() -> Configuration { + Configuration { + enabled_sources: vec![ + "apple_music".to_string(), + "spotify".to_string() + ] + } + } + + fn read() -> Option { + let config_file_path = Configuration::file_path(); + + let mut config_file = File::open(&config_file_path).ok()?; + let mut config_contents = String::new(); + config_file.read_to_string(&mut config_contents).ok()?; + serde_json::from_str(&config_contents).ok()? + } + + fn write(configuration: &Configuration) -> Result<()> { + let config_file_path = Configuration::file_path(); + if let Ok(serialized_configuration) = serde_json::to_string(&configuration) { + write(&config_file_path, serialized_configuration)?; + } + Ok(()) + } + + fn file_path() -> PathBuf { + let config_directory = Configuration::directory(); + create_dir_all(&config_directory).ok(); + config_directory.join(CONFIG_FILENAME) + } + + fn directory() -> PathBuf { + match env::var(CONFIG_ENV_VARIABLE) { + Ok(directory) => PathBuf::from(directory), + Err(_) => match env::var(CONFIG_DEFAULT_LOCATION) { + Ok(directory) => PathBuf::from(directory), + Err(_) => match env::var("HOME") { + Ok(directory) => PathBuf::from(directory).join(CONFIG_FALLBACK_LOCATION), + Err(_) => panic!("Could not find required directory, {} or {} should be set and readable.", CONFIG_ENV_VARIABLE, CONFIG_DEFAULT_LOCATION), + }, + }, + }.join(CONFIG_SUBDIRECTORY) + } +} diff --git a/src/main.rs b/src/main.rs index 13b7e37..a611926 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ -// mod configuration; +mod configuration; mod lyrics_engine; mod sources; use std::io::{Result, Error, ErrorKind::Other}; -use clap::{Parser}; +use clap::Parser; +use configuration::Configuration; use sources::{enable, disable, get_track, reset, list}; use lyrics_engine::print_lyrics; @@ -45,22 +46,37 @@ pub struct Track { #[tokio::main] async fn main() -> Result<()> { + let mut configuration = Configuration::new(); let arguments = Arguments::parse(); if arguments.list_sources { - return list(); + let sources = list(); + for source in sources { + print!("{}", source); + if configuration.is_enabled(&source) { + print!(" (enabled)"); + } + println!(""); + } + return Ok(()); } if let Some(source_name) = arguments.enable_source { - return enable(source_name); + if !configuration.is_enabled(&source_name) { + enable(&source_name)?; + } + return configuration.enable_source(&source_name); } if let Some(source_name) = arguments.disable_source { - return disable(source_name); + if configuration.is_enabled(&source_name) { + disable(&source_name)?; + } + return configuration.disable_source(&source_name); } if let Some(source_name) = arguments.reset_source { - return reset(source_name); + return reset(&source_name); } let current_track: Track; diff --git a/src/sources/apple_music.rs b/src/sources/apple_music.rs new file mode 100644 index 0000000..cb36f5c --- /dev/null +++ b/src/sources/apple_music.rs @@ -0,0 +1,71 @@ +use std::ffi::CStr; +use std::io::Result; + +use cocoa::{base::nil, foundation::NSString}; +use objc::{class, msg_send, sel, sel_impl, runtime::Object}; +use objc_id::Id; + +use crate::Track; + +use super::LyricsSource; + +pub struct AppleMusic; + +impl AppleMusic { + pub fn new() -> Self { + AppleMusic + } +} + +impl LyricsSource for AppleMusic { + + fn name(&self) -> String { + "apple_music".to_string() + } + + fn current_track(&self) -> Option { + unsafe { + let app: Id = { + let cls = class!(SBApplication); + let bundle_identifier = NSString::alloc(nil).init_str("com.apple.Music"); + let app: *mut Object = msg_send![cls, applicationWithBundleIdentifier:bundle_identifier]; + Id::from_ptr(app) + }; + + if msg_send![app, isRunning] { + let current_track: *mut Object = msg_send![app, currentTrack]; + if !current_track.is_null() { + let name_raw: *mut Object = msg_send![current_track, name]; + let artist_raw: *mut Object = msg_send![current_track, artist]; + + let name_ptr: *const i8 = msg_send![name_raw, UTF8String]; + let artist_ptr: *const i8 = msg_send![artist_raw, UTF8String]; + + let name = CStr::from_ptr(name_ptr).to_string_lossy().into_owned(); + let artist = CStr::from_ptr(artist_ptr).to_string_lossy().into_owned(); + + + return Some(Track { + name, + artist + }) + } + } + } + None + } + + fn disable(&self) -> Result<()> { + Ok(()) + } + + fn enable(&self) -> Result<()> { + Ok(()) + } + + fn reset(&self) -> Result<()> { + Ok(()) + } +} + + diff --git a/src/sources/mod.rs b/src/sources/mod.rs index fb7af8f..78f7499 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -1,9 +1,10 @@ -use std::io::Result; +use std::io::{Result, Error, ErrorKind::Other}; + +#[cfg(target_os = "macos")] +mod apple_music; +#[cfg(target_os = "macos")] +mod spotify; -// #[cfg(target_os = "macos")] -// mod applie_music; -// #[cfg(target_os = "macos")] -// mod spotify; // #[cfg(not(target_os = "macos"))] // mod rhythmbox; // #[cfg(not(target_os = "macos"))] @@ -13,10 +14,10 @@ use std::io::Result; // #[cfg(not(target_os = "macos"))] // mod tauon; -// #[cfg(target_os = "macos")] -// use apple_music::AppleMusic; -// #[cfg(target_os = "macos")] -// use spotify::Spotify; +#[cfg(target_os = "macos")] +use apple_music::AppleMusic; +#[cfg(target_os = "macos")] +use spotify::Spotify; // #[cfg(not(target_os = "macos"))] // use rhythmbox::Rhythmbox; @@ -39,26 +40,47 @@ pub trait LyricsSource { fn reset(&self) -> Result<()>; } -pub fn list() -> Result<()> { - Ok(()) +pub fn list() -> Vec { + available_sources().into_iter().map(|source| source.name()).collect() } -pub fn enable(source_name: String) -> Result<()> { - println!("Enabling {}", source_name); - Ok(()) +pub fn enable(source_name: &String) -> Result<()> { + let sources = available_sources(); + for source in sources { + if &source.name() == source_name { + return source.enable() + } + } + Err(Error::new(Other, "No such source was available.")) } -pub fn disable(source_name: String) -> Result<()> { - println!("Disabling {}", source_name); - Ok(()) +pub fn disable(source_name: &String) -> Result<()> { + let sources = available_sources(); + for source in sources { + if &source.name() == source_name { + return source.disable() + } + } + Err(Error::new(Other, "No such source was available.")) } -pub fn reset(source_name: String) -> Result<()> { - println!("Reset {}", source_name); - Ok(()) +pub fn reset(source_name: &String) -> Result<()> { + let sources = available_sources(); + for source in sources { + if &source.name() == source_name { + return source.reset() + } + } + Err(Error::new(Other, "No such source was available.")) } pub fn get_track() -> Option { + let sources = available_sources(); + for source in sources { + if let Some(track) = source.current_track() { + return Some(track); + } + } return None } @@ -66,8 +88,8 @@ pub fn available_sources() -> Vec> { let mut sources: Vec> = Vec::new(); #[cfg(target_os = "macos")] { - // sources.push(Box::new(AppleMusic::new())); - // sources.push(Box::new(Spotify::new())); + sources.push(Box::new(AppleMusic::new())); + sources.push(Box::new(Spotify::new())); } #[cfg(not(target_os = "macos"))] diff --git a/src/sources/spotify.rs b/src/sources/spotify.rs new file mode 100644 index 0000000..b23ea73 --- /dev/null +++ b/src/sources/spotify.rs @@ -0,0 +1,69 @@ +use std::ffi::CStr; +use std::io::Result; + +use cocoa::{base::nil, foundation::NSString}; +use objc::{class, msg_send, sel, sel_impl, runtime::Object}; +use objc_id::Id; + +use crate::Track; + +use super::LyricsSource; + +pub struct Spotify; + +impl Spotify { + pub fn new() -> Self { + Spotify + } +} + +impl LyricsSource for Spotify { + + fn name(&self) -> String { + "spotify".to_string() + } + + fn current_track(&self) -> Option { + unsafe { + let app: Id = { + let cls = class!(SBApplication); + let bundle_identifier = NSString::alloc(nil).init_str("com.spotify.Client"); + let app: *mut Object = msg_send![cls, applicationWithBundleIdentifier:bundle_identifier]; + Id::from_ptr(app) + }; + + if msg_send![app, isRunning] { + let current_track: *mut Object = msg_send![app, currentTrack]; + if !current_track.is_null() { + let name_raw: *mut Object = msg_send![current_track, name]; + let artist_raw: *mut Object = msg_send![current_track, artist]; + + let name_ptr: *const i8 = msg_send![name_raw, UTF8String]; + let artist_ptr: *const i8 = msg_send![artist_raw, UTF8String]; + + let name = CStr::from_ptr(name_ptr).to_string_lossy().into_owned(); + let artist = CStr::from_ptr(artist_ptr).to_string_lossy().into_owned(); + + + return Some(Track { + name, + artist + }) + } + } + } + None + } + + fn disable(&self) -> Result<()> { + Ok(()) + } + + fn enable(&self) -> Result<()> { + Ok(()) + } + + fn reset(&self) -> Result<()> { + Ok(()) + } +}