musica 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13cc496aefb62b54ba9970f25257f66a6456a7591b47d582f1345f21738e3b57
4
- data.tar.gz: a7494abfa0319eb4dd75bb073859caafaeed4aca124522636d73ce724a46ddf3
3
+ metadata.gz: b7def5733a1fb7f5b9d36a812d3cbf6e615d88ec49f34bdf4eb6bbcf3127f613
4
+ data.tar.gz: 1d07b3cf2461f9e5e91fe29b72272c49b53c06ce2d3e6ec0c56ce2663566d713
5
5
  SHA512:
6
- metadata.gz: e1e81612f175b0f752f5b473bd40ae969ef905589ac6484654f6ea663cfce4d69d76f92fe20c62deeb72e57e898732af904aba4fddd9215a93488423e620fde7
7
- data.tar.gz: 51006597adfb4d47658fecf15125f06078181b310b0f88c7521c9c57cf626c1faf0aa57d6d2b56727b08d63f676013a6996b6a20900aa75b533a006ec8d7b6ad
6
+ metadata.gz: 1341f4bf9097a0aba074697a3f9335fe36e73b113ef2fb841920f2b3bcca0ed711084437451ea68ac0b9f20b78c2d764b4d3916bb0cef149bbcdf359d10c4b42
7
+ data.tar.gz: 2688cf16ee45c9f0f676eea0f938b0c232968e1178b45a2459e88633f564fd239036d35ea7d7befe60451f56a224921007f72b82507b91647fcd72f80feacb44
data/CHANGELOG.md CHANGED
@@ -1,4 +1,11 @@
1
- ## [Unreleased]
1
+ ## [0.2.0] - 2026-10-19
2
+
3
+ - Add natural language commands with `-n`, using Apple's on-device model through `fm`
4
+ - Play tracks and albums from the Music library by name, artist or album
5
+
6
+ ## [0.1.2] - 2025-09-01
7
+
8
+ - Add YAML configuration support for player selection
2
9
 
3
10
  ## [0.0.2] - 2023-03-27
4
11
 
data/Gemfile CHANGED
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
+ gem "debug", "~> 1.7", ">= 1.7.1"
7
8
  gem "rake", "~> 13.0"
8
9
  gem "rspec", "~> 3.0"
9
10
  gem "standard", "~> 1.3"
data/Gemfile.lock CHANGED
@@ -1,22 +1,33 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- musica (0.0.2)
4
+ musica (0.2.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  ast (2.4.2)
10
+ debug (1.7.1)
11
+ irb (>= 1.5.0)
12
+ reline (>= 0.3.1)
10
13
  diff-lcs (1.5.0)
11
- json (2.6.3)
14
+ io-console (0.6.0)
15
+ irb (1.6.3)
16
+ reline (>= 0.3.0)
17
+ json (3.1.0)
12
18
  language_server-protocol (3.17.0.3)
19
+ lint_roller (1.1.0)
13
20
  parallel (1.22.1)
14
- parser (3.2.1.1)
21
+ parser (3.3.12.0)
15
22
  ast (~> 2.4.1)
23
+ racc
24
+ prism (1.9.0)
25
+ racc (1.8.1)
16
26
  rainbow (3.1.1)
17
- rake (13.0.6)
18
- regexp_parser (2.7.0)
19
- rexml (3.2.5)
27
+ rake (13.4.2)
28
+ regexp_parser (2.13.1)
29
+ reline (0.3.3)
30
+ io-console (~> 0.5)
20
31
  rspec (3.12.0)
21
32
  rspec-core (~> 3.12.0)
22
33
  rspec-expectations (~> 3.12.0)
@@ -30,37 +41,51 @@ GEM
30
41
  diff-lcs (>= 1.2.0, < 2.0)
31
42
  rspec-support (~> 3.12.0)
32
43
  rspec-support (3.12.0)
33
- rubocop (1.48.1)
34
- json (~> 2.3)
35
- parallel (~> 1.10)
36
- parser (>= 3.2.0.0)
44
+ rubocop (1.91.0)
45
+ json (>= 2.3)
46
+ language_server-protocol (~> 3.17.0.2)
47
+ lint_roller (~> 1.1.0)
48
+ parallel (>= 1.10)
49
+ parser (>= 3.3.0.2)
37
50
  rainbow (>= 2.2.2, < 4.0)
38
- regexp_parser (>= 1.8, < 3.0)
39
- rexml (>= 3.2.5, < 4.0)
40
- rubocop-ast (>= 1.26.0, < 2.0)
51
+ regexp_parser (>= 2.9.3, < 3.0)
52
+ rubocop-ast (>= 1.49.0, < 2.0)
41
53
  ruby-progressbar (~> 1.7)
42
- unicode-display_width (>= 2.4.0, < 3.0)
43
- rubocop-ast (1.28.0)
44
- parser (>= 3.2.1.0)
45
- rubocop-performance (1.16.0)
46
- rubocop (>= 1.7.0, < 2.0)
47
- rubocop-ast (>= 0.4.0)
54
+ unicode-display_width (>= 2.4.0, < 4.0)
55
+ rubocop-ast (1.50.0)
56
+ parser (>= 3.3.7.2)
57
+ prism (~> 1.7)
58
+ rubocop-performance (1.26.1)
59
+ lint_roller (~> 1.1)
60
+ rubocop (>= 1.75.0, < 2.0)
61
+ rubocop-ast (>= 1.47.1, < 2.0)
48
62
  ruby-progressbar (1.13.0)
49
- standard (1.25.3)
63
+ standard (1.57.0)
50
64
  language_server-protocol (~> 3.17.0.2)
51
- rubocop (~> 1.48.1)
52
- rubocop-performance (~> 1.16.0)
65
+ lint_roller (~> 1.0)
66
+ rubocop (~> 1.91.0)
67
+ standard-custom (~> 1.0.0)
68
+ standard-performance (~> 1.8)
69
+ standard-custom (1.0.2)
70
+ lint_roller (~> 1.0)
71
+ rubocop (~> 1.50)
72
+ standard-performance (1.9.0)
73
+ lint_roller (~> 1.1)
74
+ rubocop-performance (~> 1.26.0)
53
75
  unicode-display_width (2.4.2)
54
76
 
55
77
  PLATFORMS
56
78
  arm64-darwin-22
79
+ arm64-darwin-25
80
+ arm64-darwin-27
57
81
  x86_64-linux
58
82
 
59
83
  DEPENDENCIES
84
+ debug (~> 1.7, >= 1.7.1)
60
85
  musica!
61
86
  rake (~> 13.0)
62
87
  rspec (~> 3.0)
63
88
  standard (~> 1.3)
64
89
 
65
90
  BUNDLED WITH
66
- 2.4.8
91
+ 2.7.0
data/README.md CHANGED
@@ -3,11 +3,6 @@
3
3
  This is a Ruby command-line tool that interacts with music players on macOS
4
4
  using AppleScript.
5
5
 
6
- Welcome to your new gem! In this directory, you'll find the files you need to be
7
- able to package up your Ruby library into a gem. Put your Ruby code in the file
8
- `lib/musica`. To experiment with that code, run `bin/console` for an interactive
9
- prompt.
10
-
11
6
  ## Installation
12
7
 
13
8
  Install the gem and add to the application's Gemfile by executing:
@@ -20,7 +15,69 @@ If bundler is not being used to manage dependencies, install the gem by executin
20
15
 
21
16
  ## Usage
22
17
 
23
- TODO: Write usage instructions here
18
+ Musica passes its arguments straight to your music player as an AppleScript
19
+ command, so `musica <command>` runs:
20
+
21
+ ```applescript
22
+ tell application "<player>" to <command>
23
+ ```
24
+
25
+ Any command in the player's AppleScript dictionary works (open it in Script
26
+ Editor with File > Open Dictionary). For example:
27
+
28
+ $ musica play
29
+ $ musica pause
30
+ $ musica playpause
31
+ $ musica next track
32
+ $ musica previous track
33
+ $ musica set sound volume to 50
34
+
35
+ After each command, Musica prints the current status: the artist and track
36
+ while playing, otherwise the player state:
37
+
38
+ $ musica play
39
+ 🎧 Daft Punk - Digital Love
40
+ $ musica pause
41
+ 🎧 paused
42
+
43
+ Run `musica` without arguments to print the status alone.
44
+
45
+ Commands can differ between players. For example, Music uses
46
+ `set shuffle enabled to true` and Spotify uses `set shuffling to true`. Wrap
47
+ commands that contain quotes in single quotes so the shell keeps them:
48
+
49
+ $ musica 'play playlist "Favorite Songs"'
50
+
51
+ ### Natural language
52
+
53
+ With `-n`, Musica uses Apple's on-device model, through the `fm` command, to
54
+ turn a plain request into a command. This needs macOS 26 with Apple
55
+ Intelligence.
56
+
57
+ $ musica -n skip this song
58
+ $ musica -n turn it down a bit
59
+ $ musica -n play Digital Love by Daft Punk
60
+ $ musica -n play something by Queen
61
+ $ musica -n play the album Adore
62
+
63
+ It understands play, pause, next and previous track, volume changes, and
64
+ playing a track or album from your library by name, artist or album. To play
65
+ an album in order, Musica fills a playlist named "Musica" with its tracks and
66
+ plays that playlist, replacing what was in it before. Playing from your library
67
+ only works with Music.
68
+
69
+ ## Configuration
70
+
71
+ Musica can be configured to work with different music players. Create a configuration file at:
72
+ - `$XDG_CONFIG_HOME/musica/config.yml` (if XDG_CONFIG_HOME is set)
73
+ - `~/.config/musica/config.yml` (default)
74
+
75
+ Example configuration:
76
+ ```yaml
77
+ player: "Spotify"
78
+ ```
79
+
80
+ If no configuration file exists, one will be created automatically with the default player set to "Music".
24
81
 
25
82
  ## Development
26
83
 
data/exe/musica CHANGED
@@ -3,4 +3,8 @@
3
3
  require "bundler/setup"
4
4
  require "musica"
5
5
 
6
- Musica::Cli.new(ARGV).run
6
+ begin
7
+ Musica::Cli.new(ARGV).run
8
+ rescue Musica::Error => e
9
+ abort e.message
10
+ end
data/lib/musica/cli.rb CHANGED
@@ -1,17 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Musica
2
4
  class Cli
3
- def initialize(argv)
4
- @prompt = argv.join(" ")
5
- @player = Musica::Player.new(application: "Music")
5
+ def initialize(argv, player: Player.new(application: Config.player), interpreter: Interpreter.new, out: $stdout)
6
+ @natural = argv.first == "-n"
7
+ @prompt = argv.drop(@natural ? 1 : 0).join(" ")
8
+ @player = player
9
+ @interpreter = interpreter
10
+ @out = out
6
11
  end
7
12
 
8
13
  def run
9
- player.tell prompt unless prompt.empty?
10
- $stdout << "\u{1F3A7} #{player.status}\n"
14
+ player.tell command unless prompt.empty?
15
+ out << "\u{1F3A7} #{player.status}\n"
11
16
  end
12
17
 
13
18
  private
14
19
 
15
- attr_reader :argv, :player, :prompt
20
+ attr_reader :interpreter, :natural, :out, :player, :prompt
21
+
22
+ def command
23
+ natural ? interpreter.call(prompt) : prompt
24
+ end
16
25
  end
17
26
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Musica
7
+ module Config
8
+ DEFAULTS = {"player" => "Music"}.freeze
9
+
10
+ def self.player(env: ENV)
11
+ file = path(env)
12
+
13
+ unless File.exist?(file)
14
+ FileUtils.mkdir_p(File.dirname(file))
15
+ File.write(file, YAML.dump(DEFAULTS))
16
+ end
17
+
18
+ (YAML.load_file(file) || {}).fetch("player", DEFAULTS["player"])
19
+ end
20
+
21
+ def self.path(env)
22
+ base = env["XDG_CONFIG_HOME"]
23
+ base = File.join(env.fetch("HOME"), ".config") if base.nil? || base.empty?
24
+
25
+ File.join(base, "musica", "config.yml")
26
+ end
27
+ private_class_method :path
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ {
2
+ "title": "Intent",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "required": [
6
+ "action"
7
+ ],
8
+ "properties": {
9
+ "action": {
10
+ "type": "string",
11
+ "enum": [
12
+ "play",
13
+ "pause",
14
+ "next_track",
15
+ "previous_track",
16
+ "change_volume",
17
+ "play_track",
18
+ "play_album",
19
+ "unknown"
20
+ ],
21
+ "description": "What to do"
22
+ },
23
+ "name": {
24
+ "type": "string",
25
+ "description": "Track name, for play_track"
26
+ },
27
+ "artist": {
28
+ "type": "string",
29
+ "description": "Artist name, for play_track or play_album"
30
+ },
31
+ "album": {
32
+ "type": "string",
33
+ "description": "Album name, for play_album"
34
+ },
35
+ "delta": {
36
+ "type": "integer",
37
+ "description": "Volume change from -100 to 100, for change_volume"
38
+ }
39
+ },
40
+ "x-order": [
41
+ "action",
42
+ "name",
43
+ "artist",
44
+ "album",
45
+ "delta"
46
+ ]
47
+ }
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Musica
7
+ class Interpreter
8
+ SCHEMA = File.expand_path("intent.json", __dir__)
9
+
10
+ INSTRUCTIONS = <<~TEXT
11
+ You turn a request to a music player into an action.
12
+ - play: resume playback
13
+ - pause: pause or stop playback
14
+ - next_track: skip to the next song ("next", "skip")
15
+ - previous_track: go back to the previous song
16
+ - change_volume: louder or quieter; delta is the change, 10 for a bit, 30 for a lot, negative for quieter
17
+ - play_track: play a specific song; name is the song title, artist only if the request names one
18
+ - play_album: play an album; album is the album title, artist only if the request names one
19
+ - unknown: anything else
20
+ TEXT
21
+
22
+ FM = lambda do |request|
23
+ output, status = Open3.capture2(
24
+ "fm", "respond", "--no-stream", "--greedy",
25
+ "--schema", SCHEMA, "--instructions", INSTRUCTIONS, request
26
+ )
27
+ raise Error, "fm failed to interpret \"#{request}\"" unless status.success?
28
+
29
+ output
30
+ rescue Errno::ENOENT
31
+ raise Error, "Natural language commands need fm (macOS 26 with Apple Intelligence)"
32
+ end
33
+
34
+ COMMANDS = {
35
+ "play" => "play",
36
+ "pause" => "pause",
37
+ "next_track" => "next track",
38
+ "previous_track" => "previous track"
39
+ }.freeze
40
+
41
+ # Music can't play an album or queue tracks, so refill a playlist with the
42
+ # album in disc and track order and play that instead
43
+ ALBUM = <<~APPLESCRIPT
44
+ set found to every track of library playlist 1 whose %<conditions>s
45
+ if found is {} then error "No album " & %<album>s & " in your library"
46
+ set positions to {}
47
+ repeat with t in found
48
+ set end of positions to (disc number of t) * 1000 + (track number of t)
49
+ end repeat
50
+ if not (exists user playlist "Musica") then make new user playlist with properties {name:"Musica"}
51
+ set musica to user playlist "Musica"
52
+ delete every track of musica
53
+ repeat (count of found) times
54
+ set best to 0
55
+ repeat with i from 1 to count of found
56
+ if item i of positions is not missing value then
57
+ if best is 0 or item i of positions < item best of positions then set best to i
58
+ end if
59
+ end repeat
60
+ duplicate item best of found to musica
61
+ set item best of positions to missing value
62
+ end repeat
63
+ play musica
64
+ APPLESCRIPT
65
+
66
+ def initialize(runner: FM)
67
+ @runner = runner
68
+ end
69
+
70
+ def call(request)
71
+ intent = JSON.parse(runner.call(request))
72
+ name = mentioned(intent["name"], request)
73
+ artist = mentioned(intent["artist"], request)
74
+ album = mentioned(intent["album"], request)
75
+
76
+ case intent["action"]
77
+ when "play", "play_track", "play_album"
78
+ if album && !name
79
+ play_album(album, artist)
80
+ elsif name || artist
81
+ play_track(name, artist, album)
82
+ else
83
+ "play"
84
+ end
85
+ when "change_volume"
86
+ volume(intent["delta"], request)
87
+ else
88
+ COMMANDS.fetch(intent["action"]) { not_understood(request) }
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :runner
95
+
96
+ def mentioned(value, request)
97
+ value if value.is_a?(String) && !value.empty? && request.downcase.include?(value.downcase)
98
+ end
99
+
100
+ def play_track(name, artist, album)
101
+ "play (some track of library playlist 1 whose #{conditions("name" => name, "artist" => artist, "album" => album)})"
102
+ end
103
+
104
+ def play_album(album, artist)
105
+ format(ALBUM, conditions: conditions("album" => album, "artist" => artist), album: quote(album))
106
+ end
107
+
108
+ def conditions(filters)
109
+ filters.compact.map { |key, value| "#{key} is #{quote(value)}" }.join(" and ")
110
+ end
111
+
112
+ def volume(delta, request)
113
+ not_understood(request) unless delta.is_a?(Integer)
114
+
115
+ "set sound volume to (sound volume + #{delta})"
116
+ end
117
+
118
+ def quote(value)
119
+ %("#{value.gsub(/[\\"]/) { |char| "\\#{char}" }}")
120
+ end
121
+
122
+ def not_understood(request)
123
+ raise Error, "Couldn't understand \"#{request}\""
124
+ end
125
+ end
126
+ end
data/lib/musica/player.rb CHANGED
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Musica
4
6
  class Player
5
- def initialize(application:)
7
+ OSASCRIPT = ->(script) { Open3.capture2("osascript", "-e", script).first.chomp }
8
+
9
+ def initialize(application:, runner: OSASCRIPT)
6
10
  @application = application
11
+ @runner = runner
7
12
  end
8
13
 
9
14
  def status
@@ -11,12 +16,12 @@ module Musica
11
16
  end
12
17
 
13
18
  def tell(command)
14
- `osascript -e 'tell application "#{application}" to #{command}'`.chomp
19
+ runner.call(%(tell application "#{application}"\n#{command}\nend tell))
15
20
  end
16
21
 
17
22
  private
18
23
 
19
- attr_reader :application
24
+ attr_reader :application, :runner
20
25
 
21
26
  def artist
22
27
  tell "return artist of the current track"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Musica
4
- VERSION = "0.0.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/musica.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "musica/cli"
4
+ require_relative "musica/config"
5
+ require_relative "musica/interpreter"
4
6
  require_relative "musica/player"
5
7
  require_relative "musica/version"
6
8
 
metadata CHANGED
@@ -1,16 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: musica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ariel Rzezak
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-03-27 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description:
14
12
  email:
15
13
  - arzezak@gmail.com
16
14
  executables:
@@ -30,6 +28,9 @@ files:
30
28
  - exe/musica
31
29
  - lib/musica.rb
32
30
  - lib/musica/cli.rb
31
+ - lib/musica/config.rb
32
+ - lib/musica/intent.json
33
+ - lib/musica/interpreter.rb
33
34
  - lib/musica/player.rb
34
35
  - lib/musica/version.rb
35
36
  - sig/musica.rbs
@@ -40,7 +41,6 @@ metadata:
40
41
  homepage_uri: https://github.com/arzezak/musica
41
42
  source_code_uri: https://github.com/arzezak/musica
42
43
  changelog_uri: https://github.com/arzezak/musica/blob/main/CHANGELOG.md
43
- post_install_message:
44
44
  rdoc_options: []
45
45
  require_paths:
46
46
  - lib
@@ -55,8 +55,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
55
55
  - !ruby/object:Gem::Version
56
56
  version: '0'
57
57
  requirements: []
58
- rubygems_version: 3.4.8
59
- signing_key:
58
+ rubygems_version: 4.0.17
60
59
  specification_version: 4
61
60
  summary: Control macOS music players from the command-line
62
61
  test_files: []