musica 0.1.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: 58e294915c0927510c3ad251196cfa53a23dfe60f1abc87b46688794ae9a8112
4
- data.tar.gz: bd8603849d076e7e72d6c3bd47f96b1019d98fd1bc275125c8869993f7bda80c
3
+ metadata.gz: b7def5733a1fb7f5b9d36a812d3cbf6e615d88ec49f34bdf4eb6bbcf3127f613
4
+ data.tar.gz: 1d07b3cf2461f9e5e91fe29b72272c49b53c06ce2d3e6ec0c56ce2663566d713
5
5
  SHA512:
6
- metadata.gz: 3a7913af3724481bec22fc8327dfa475eb5fec30cb9dd18bde95be873033fe388286a9ae6413689a09e5f7549f43e58cfc025e9f6f19854103b1647ef0b369e8
7
- data.tar.gz: be741c3b07d5860b22905c6eb30fe4ce843ba93861c291ebfa137cee0b1b879b59a58daefba01b3e0287e1ef4e299ad115bcd8ae6494aeccf5db668a046ef023
6
+ metadata.gz: 1341f4bf9097a0aba074697a3f9335fe36e73b113ef2fb841920f2b3bcca0ed711084437451ea68ac0b9f20b78c2d764b4d3916bb0cef149bbcdf359d10c4b42
7
+ data.tar.gz: 2688cf16ee45c9f0f676eea0f938b0c232968e1178b45a2459e88633f564fd239036d35ea7d7befe60451f56a224921007f72b82507b91647fcd72f80feacb44
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
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
+
1
6
  ## [0.1.2] - 2025-09-01
2
7
 
3
8
  - Add YAML configuration support for player selection
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- musica (0.1.2)
4
+ musica (0.2.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -14,17 +14,20 @@ GEM
14
14
  io-console (0.6.0)
15
15
  irb (1.6.3)
16
16
  reline (>= 0.3.0)
17
- json (2.6.3)
17
+ json (3.1.0)
18
18
  language_server-protocol (3.17.0.3)
19
+ lint_roller (1.1.0)
19
20
  parallel (1.22.1)
20
- parser (3.2.1.1)
21
+ parser (3.3.12.0)
21
22
  ast (~> 2.4.1)
23
+ racc
24
+ prism (1.9.0)
25
+ racc (1.8.1)
22
26
  rainbow (3.1.1)
23
- rake (13.0.6)
24
- regexp_parser (2.7.0)
27
+ rake (13.4.2)
28
+ regexp_parser (2.13.1)
25
29
  reline (0.3.3)
26
30
  io-console (~> 0.5)
27
- rexml (3.2.5)
28
31
  rspec (3.12.0)
29
32
  rspec-core (~> 3.12.0)
30
33
  rspec-expectations (~> 3.12.0)
@@ -38,31 +41,43 @@ GEM
38
41
  diff-lcs (>= 1.2.0, < 2.0)
39
42
  rspec-support (~> 3.12.0)
40
43
  rspec-support (3.12.0)
41
- rubocop (1.48.1)
42
- json (~> 2.3)
43
- parallel (~> 1.10)
44
- 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)
45
50
  rainbow (>= 2.2.2, < 4.0)
46
- regexp_parser (>= 1.8, < 3.0)
47
- rexml (>= 3.2.5, < 4.0)
48
- rubocop-ast (>= 1.26.0, < 2.0)
51
+ regexp_parser (>= 2.9.3, < 3.0)
52
+ rubocop-ast (>= 1.49.0, < 2.0)
49
53
  ruby-progressbar (~> 1.7)
50
- unicode-display_width (>= 2.4.0, < 3.0)
51
- rubocop-ast (1.28.0)
52
- parser (>= 3.2.1.0)
53
- rubocop-performance (1.16.0)
54
- rubocop (>= 1.7.0, < 2.0)
55
- 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)
56
62
  ruby-progressbar (1.13.0)
57
- standard (1.25.3)
63
+ standard (1.57.0)
58
64
  language_server-protocol (~> 3.17.0.2)
59
- rubocop (~> 1.48.1)
60
- 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)
61
75
  unicode-display_width (2.4.2)
62
76
 
63
77
  PLATFORMS
64
78
  arm64-darwin-22
65
79
  arm64-darwin-25
80
+ arm64-darwin-27
66
81
  x86_64-linux
67
82
 
68
83
  DEPENDENCIES
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,11 +15,56 @@ If bundler is not being used to manage dependencies, install the gem by executin
20
15
 
21
16
  ## Usage
22
17
 
23
- Run commands to control your music player:
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:
24
27
 
25
28
  $ musica play
26
29
  $ musica pause
30
+ $ musica playpause
27
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.
28
68
 
29
69
  ## Configuration
30
70
 
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,41 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "fileutils"
5
-
6
3
  module Musica
7
4
  class Cli
8
- def initialize(argv)
9
- @prompt = argv.join(" ")
10
- @player = Musica::Player.new(application: load_config)
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
11
11
  end
12
12
 
13
13
  def run
14
- player.tell prompt unless prompt.empty?
15
- $stdout << "\u{1F3A7} #{player.status}\n"
14
+ player.tell command unless prompt.empty?
15
+ out << "\u{1F3A7} #{player.status}\n"
16
16
  end
17
17
 
18
18
  private
19
19
 
20
- attr_reader :argv, :player, :prompt
21
-
22
- def load_config
23
- config_dir = "#{ENV["XDG_CONFIG_HOME"]}/musica" || File.expand_path("~/.config/musica")
24
- config_file = File.join(config_dir, "config.yml")
25
-
26
- unless File.exist?(config_file)
27
- create_config(config_dir, config_file)
28
- end
29
-
30
- YAML.load_file(config_file).dig("player")
31
- end
32
-
33
- def create_config(config_dir, config_file)
34
- FileUtils.mkdir_p(config_dir)
35
-
36
- default_config = { "player" => "Music" }
20
+ attr_reader :interpreter, :natural, :out, :player, :prompt
37
21
 
38
- File.write(config_file, YAML.dump(default_config))
22
+ def command
23
+ natural ? interpreter.call(prompt) : prompt
39
24
  end
40
25
  end
41
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.1.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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: musica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ariel Rzezak
@@ -28,6 +28,9 @@ files:
28
28
  - exe/musica
29
29
  - lib/musica.rb
30
30
  - lib/musica/cli.rb
31
+ - lib/musica/config.rb
32
+ - lib/musica/intent.json
33
+ - lib/musica/interpreter.rb
31
34
  - lib/musica/player.rb
32
35
  - lib/musica/version.rb
33
36
  - sig/musica.rbs
@@ -52,7 +55,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
55
  - !ruby/object:Gem::Version
53
56
  version: '0'
54
57
  requirements: []
55
- rubygems_version: 3.7.1
58
+ rubygems_version: 4.0.17
56
59
  specification_version: 4
57
60
  summary: Control macOS music players from the command-line
58
61
  test_files: []