From c53df649c817f770ae1750bb6d11113e0bcd2b18 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Mon, 10 Apr 2023 13:02:51 +0200 Subject: Update code, add source management config --- Package.resolved | 42 ++------ Package.swift | 11 +- README.md | 21 ++-- Sources/lyricli/configuration.swift | 2 +- .../errors/configuration_could_not_be_read.swift | 3 + .../errors/source_could_not_be_disabled.swift | 6 ++ .../errors/source_could_not_be_enabled.swift | 6 ++ .../lyricli/errors/source_could_not_be_reset.swift | 6 ++ Sources/lyricli/errors/source_not_available.swift | 3 + Sources/lyricli/lyricli.swift | 60 +++++++++-- Sources/lyricli/lyricli_command.swift | 117 +++++++++++++++------ Sources/lyricli/lyrics_engine.swift | 11 +- Sources/lyricli/main.swift | 107 ------------------- Sources/lyricli/source_manager.swift | 4 +- Sources/lyricli/sources/apple_music_source.swift | 80 ++++++++++++++ Sources/lyricli/sources/itunes_source.swift | 77 -------------- Sources/lyricli/sources/source_protocol.swift | 4 + Sources/lyricli/sources/spotify_source.swift | 3 + 18 files changed, 283 insertions(+), 280 deletions(-) create mode 100644 Sources/lyricli/errors/configuration_could_not_be_read.swift create mode 100644 Sources/lyricli/errors/source_could_not_be_disabled.swift create mode 100644 Sources/lyricli/errors/source_could_not_be_enabled.swift create mode 100644 Sources/lyricli/errors/source_could_not_be_reset.swift create mode 100644 Sources/lyricli/errors/source_not_available.swift delete mode 100644 Sources/lyricli/main.swift create mode 100644 Sources/lyricli/sources/apple_music_source.swift delete mode 100644 Sources/lyricli/sources/itunes_source.swift diff --git a/Package.resolved b/Package.resolved index a2a49b3..4d93f8f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,34 +1,14 @@ { - "object": { - "pins": [ - { - "package": "Bariloche", - "repositoryURL": "https://github.com/Subito-it/Bariloche", - "state": { - "branch": null, - "revision": "507f4121d2a7479522908dabf83aed78a6e5e268", - "version": "1.0.4" - } - }, - { - "package": "Rainbow", - "repositoryURL": "https://github.com/onevcat/Rainbow", - "state": { - "branch": null, - "revision": "9c52c1952e9b2305d4507cf473392ac2d7c9b155", - "version": "3.1.5" - } - }, - { - "package": "HTMLEntities", - "repositoryURL": "https://github.com/IBM-Swift/swift-html-entities.git", - "state": { - "branch": null, - "revision": "dc15f4d8eba5be23280a561c698fc36ab4fb6c76", - "version": "3.0.12" - } + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" } - ] - }, - "version": 1 + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index 9adbd39..2f49d10 100644 --- a/Package.swift +++ b/Package.swift @@ -1,19 +1,16 @@ -// swift-tools-version:5.0 +// swift-tools-version:5.8 import PackageDescription let package = Package( name: "lyricli", dependencies: [ - /// 🔡 Tools for working with HTML entities - .package(url: "https://github.com/IBM-Swift/swift-html-entities.git", from: "3.0.11"), - /// 🚩 Command Line Arguments - .package(url: "https://github.com/Subito-it/Bariloche", from: "1.0.4") + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2") ], targets: [ - .target( + .executableTarget( name: "lyricli", - dependencies: ["HTMLEntities", "Bariloche"]) + dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]) ] ) diff --git a/README.md b/README.md index 57dff9a..6d6046a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ A command line tool to show the lyrics of your current song. ## Usage -Lyricli can be invoked with the command `lrc`. It can be invoked without -arguments, with an artist and song or with a special command: +Lyricli can be invoked with the command `lrc`. ``` $ lrc [-t] @@ -26,20 +25,24 @@ song and artist names before the lyrics. ### Commands -In order to configure +In order to configure sources, lyricli provides a few commands: -* `lrc -l` or `lrc --list` lists the available sources. Enabled +* `lrc -l` or `lrc --list-sources` lists the available sources. Enabled sourcess will have a `*` -* `lrc -e` or `lrc --enable ` enables a source -* `lrc -d` or `lrc --disable ` disables a source -* `lrc -r` or `lrc --reset ` resets the configuration for - a source and disables it. +* `lrc -e` or `lrc --enable-source ` enables a source +* `lrc -d` or `lrc --disable-source ` disables a source without + resetting its configuration. +* `lrc -r` or `lrc --reset-source ` resets the configuration + for a source and disables it. + +And you can print the help or the version: + * `lrc -v` or `lrc --version` prints the version * `lrc -h` or `lrc --help` display built-in help ## Building -The build has only been tested on OSX using Swift 5.0.1. Building defaults +The build has only been tested on OSX using Swift 5.8 Building defaults to the debug configuration. ``` diff --git a/Sources/lyricli/configuration.swift b/Sources/lyricli/configuration.swift index 1b01034..b2defc1 100644 --- a/Sources/lyricli/configuration.swift +++ b/Sources/lyricli/configuration.swift @@ -8,7 +8,7 @@ class Configuration { // Default options, will be automatically written to the global config if // not found. private var configuration: [String: Any] = [ - "enabled_sources": ["itunes", "spotify"] + "enabled_sources": ["apple_music", "spotify"] ] // The shared instance of the object diff --git a/Sources/lyricli/errors/configuration_could_not_be_read.swift b/Sources/lyricli/errors/configuration_could_not_be_read.swift new file mode 100644 index 0000000..fb2b61a --- /dev/null +++ b/Sources/lyricli/errors/configuration_could_not_be_read.swift @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..b19f3c5 --- /dev/null +++ b/Sources/lyricli/errors/source_could_not_be_disabled.swift @@ -0,0 +1,6 @@ +// 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 new file mode 100644 index 0000000..7797478 --- /dev/null +++ b/Sources/lyricli/errors/source_could_not_be_enabled.swift @@ -0,0 +1,6 @@ +// 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 new file mode 100644 index 0000000..beecf54 --- /dev/null +++ b/Sources/lyricli/errors/source_could_not_be_reset.swift @@ -0,0 +1,6 @@ +// 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 new file mode 100644 index 0000000..e3d598d --- /dev/null +++ b/Sources/lyricli/errors/source_not_available.swift @@ -0,0 +1,3 @@ +struct SourceNotAvailable: Error { + var localizedDescription = "The selected source wasn't available" +} diff --git a/Sources/lyricli/lyricli.swift b/Sources/lyricli/lyricli.swift index 043c004..ad4bb86 100644 --- a/Sources/lyricli/lyricli.swift +++ b/Sources/lyricli/lyricli.swift @@ -25,11 +25,10 @@ class Lyricli { static func printLyrics(_ currentTrack: Track) { let engine = LyricsEngine(withTrack: currentTrack) + if showTitle { + printTitle(currentTrack) + } if let lyrics = engine.lyrics { - if showTitle { - printTitle(currentTrack) - } - print(lyrics) } else { print("Lyrics not found :(") @@ -38,23 +37,62 @@ class Lyricli { // Print the currently available sources static func printSources() { - print("Listing Sources: Not yet implemented") + 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) { - print("Enable source \(sourceName): Not yet implemented") + 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) { - print("Disable source \(sourceName): Not yet implemented") + 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) { - print("Reset source \(sourceName): Not yet implemented") + 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 diff --git a/Sources/lyricli/lyricli_command.swift b/Sources/lyricli/lyricli_command.swift index 88f9c7b..872ab6e 100644 --- a/Sources/lyricli/lyricli_command.swift +++ b/Sources/lyricli/lyricli_command.swift @@ -1,38 +1,95 @@ -import Bariloche +import Darwin +import ArgumentParser -class LyricliCommand: Command { - let usage: String? = "Fetch the lyrics for current playing track or the one specified via arguments" +@main +struct LyricliCommand: ParsableCommand { + + // Positional Arguments + @Argument var artist: String? + @Argument var trackName: String? // Flags - let version = Flag(short: "v", long: "version", help: "Prints the version.") - let showTitle = Flag(short: "t", long: "title", help: "Shows title of song if true") - let listSources = Flag(short: "l", long: "list", help: "Lists all sources") + @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 - let enableSource = Argument(name: "source", - kind: .named(short: "e", long: "enable"), - optional: true, - help: "Enables a source") - let disableSource = Argument(name: "source", - kind: .named(short: "d", long: "disable"), - optional: true, - help: "Disables a source") - let resetSource = Argument(name: "source", - kind: .named(short: "r", long: "reset"), - optional: true, - help: "Resets a source") + @Option(name: .shortAndLong, help: ArgumentHelp("Enables a source", valueName: "source")) + var enableSource: String? - // Positional Arguments - let artist = Argument(name: "artist", - kind: .positional, - optional: true, - help: "The name of the artist") - let trackName = Argument(name: "trackName", - kind: .positional, - optional: true, - help: "The name of the track") - - func run() -> Bool { - return true + @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 index 1a182d0..9ca0cad 100644 --- a/Sources/lyricli/lyrics_engine.swift +++ b/Sources/lyricli/lyrics_engine.swift @@ -1,5 +1,4 @@ import Foundation -import HTMLEntities // Given a track, attempts to fetch the lyrics from lyricswiki class LyricsEngine { @@ -82,7 +81,10 @@ class LyricsEngine { if let lyricsUrl = URL(string: lyricsUrlString) { // At this point we have a valid wiki url - self.fetchLyricsFromPage(withURL: lyricsUrl, completionHandler: completionHandler) + self.fetchLyricsFromPage( + withURL: lyricsUrl, + completionHandler: completionHandler + ) return } } @@ -145,10 +147,9 @@ class LyricsEngine { completionHandler(nil) } - // Escapes the HTML entities + // Escapes the HTML entities and HTML private func decodeLyrics(_ lyrics: String) -> String { - let unescapedLyrics = lyrics.htmlUnescape() - return unescapedLyrics.replacingOccurrences(of: "
", with: "\n") + return lyrics } } diff --git a/Sources/lyricli/main.swift b/Sources/lyricli/main.swift deleted file mode 100644 index be1c933..0000000 --- a/Sources/lyricli/main.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import Bariloche - -// Entry point of the application. This is the main executable -private func main() { - - // Bariloche assumes at least one argument, so bypass - // if that's the case. - if CommandLine.arguments.count > 1 { - let parser = Bariloche(command: LyricliCommand()) - let result = parser.parse() - - if result.count == 0 { - exit(EX_USAGE) - } - - if let lyricliCommand = result[0] as? LyricliCommand { - // Flags - checkVersionFlag(lyricliCommand) - checkListSourcesFlag(lyricliCommand) - checkTitleFlag(lyricliCommand) - - // String Options - - checkEnableSourceFlag(lyricliCommand) - checkDisableSourceFlag(lyricliCommand) - checkResetSourceFlag(lyricliCommand) - - checkPositionalArguments(lyricliCommand) - - } - } - - // Run Lyricli - Lyricli.printLyrics() -} - -// Handle the version flag - -private func checkVersionFlag(_ command: LyricliCommand) { - if command.version.value { - print(Lyricli.version) - exit(0) - } -} - -// Handle the list sources flag - -private func checkListSourcesFlag(_ command: LyricliCommand) { - if command.listSources.value { - Lyricli.printSources() - exit(0) - } -} - -// Handle the title flag - -private func checkTitleFlag(_ command: LyricliCommand) { - Lyricli.showTitle = command.showTitle.value -} - -// Handle the enable source flag - -private func checkEnableSourceFlag(_ command: LyricliCommand) { - if let source = command.enableSource.value { - Lyricli.enableSource(source) - exit(0) - } -} - -// Handle the disable source flag - -private func checkDisableSourceFlag(_ command: LyricliCommand) { - if let source = command.disableSource.value { - Lyricli.disableSource(source) - exit(0) - } -} - -// Handle the reset source flag - -private func checkResetSourceFlag(_ command: LyricliCommand) { - if let source = command.resetSource.value { - Lyricli.resetSource(source) - exit(0) - } -} - -// Handle the positional arguments - -private func checkPositionalArguments(_ command: LyricliCommand) { - if let artist = command.artist.value { - - let currentTrack: Track - - if let trackName = command.trackName.value { - currentTrack = Track(withName: trackName, andArtist: artist) - } else { - currentTrack = Track(withName: "", andArtist: artist) - } - - Lyricli.printLyrics(currentTrack) - exit(0) - } -} - -main() diff --git a/Sources/lyricli/source_manager.swift b/Sources/lyricli/source_manager.swift index 2f0b8f4..481d327 100644 --- a/Sources/lyricli/source_manager.swift +++ b/Sources/lyricli/source_manager.swift @@ -2,8 +2,8 @@ class SourceManager { // List of sources enabled for the crurent platform - private var availableSources: [String: Source] = [ - "itunes": ItunesSource(), + var availableSources: [String: Source] = [ + "apple_music": AppleMusicSource(), "spotify": SpotifySource() ] diff --git a/Sources/lyricli/sources/apple_music_source.swift b/Sources/lyricli/sources/apple_music_source.swift new file mode 100644 index 0000000..5eee588 --- /dev/null +++ b/Sources/lyricli/sources/apple_music_source.swift @@ -0,0 +1,80 @@ +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/itunes_source.swift b/Sources/lyricli/sources/itunes_source.swift deleted file mode 100644 index 92b9969..0000000 --- a/Sources/lyricli/sources/itunes_source.swift +++ /dev/null @@ -1,77 +0,0 @@ -import ScriptingBridge -import Foundation - -// Protocol to obtain the track from iTunes -@objc protocol iTunesTrack { - @objc optional var name: String {get} - @objc optional var artist: String {get} -} - -// Protocol to interact with iTunes -@objc protocol iTunesApplication { - @objc optional var currentTrack: iTunesTrack? {get} - @objc optional var currentStreamTitle: String? {get} -} - -extension SBApplication: iTunesApplication {} - -// Source that reads track artist and name from current itunes track -class ItunesSource: Source { - - // Calls the spotify API and returns the current track - var currentTrack: Track? { - - if let iTunes: iTunesApplication = SBApplication(bundleIdentifier: bundleIdentifier) { - if let application = iTunes as? SBApplication { - if !application.isRunning { - return nil - } - } - - // Attempt to fetch the title from a stream - if let currentStreamTitle = iTunes.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 = iTunes.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" - } - -} diff --git a/Sources/lyricli/sources/source_protocol.swift b/Sources/lyricli/sources/source_protocol.swift index 0885994..8d52603 100644 --- a/Sources/lyricli/sources/source_protocol.swift +++ b/Sources/lyricli/sources/source_protocol.swift @@ -2,4 +2,8 @@ // 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/sources/spotify_source.swift b/Sources/lyricli/sources/spotify_source.swift index 9c516c4..863a399 100644 --- a/Sources/lyricli/sources/spotify_source.swift +++ b/Sources/lyricli/sources/spotify_source.swift @@ -43,4 +43,7 @@ class SpotifySource: Source { return nil } + func enable() -> Bool { return true } + func disable() -> Bool { return true } + func reset() -> Bool { return true } } -- cgit