musica 0.1.2 → 0.3.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: 68997c53cdcb8c578599e7ede93568d15db73adf953c7c6d674e382aed778ee5
4
+ data.tar.gz: f5591c92f6bae98c446a2a8bce0be35f6c18749475fe9f553a8fdd79d36c779a
5
5
  SHA512:
6
- metadata.gz: 3a7913af3724481bec22fc8327dfa475eb5fec30cb9dd18bde95be873033fe388286a9ae6413689a09e5f7549f43e58cfc025e9f6f19854103b1647ef0b369e8
7
- data.tar.gz: be741c3b07d5860b22905c6eb30fe4ce843ba93861c291ebfa137cee0b1b879b59a58daefba01b3e0287e1ef4e299ad115bcd8ae6494aeccf5db668a046ef023
6
+ metadata.gz: 7945760f5d031faf50a9dd0b0161a2b26e5c127214a78c0b15cefeb58d165980640bd7c52d91a02f98d7f71906748d65143e39fb094a2678d4c5c582bbd0b736
7
+ data.tar.gz: 4ba20cab7bb5ac547b172936eabf69b6d154b6a450faaa3da4f0f57fe7f6eed95f9e11d9bf849dea6eb95b8bd62913dd5b25e8533f0bb44355b0d7360b6c149c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [0.3.0] - 2026-10-19
2
+
3
+ - Turn plain requests into commands by default, without `-n`
4
+ - Send AppleScript commands as is with `-s`/`--script`, which was the default before
5
+ - Add `-h`/`--help` and `-v`/`--version`
6
+
7
+ ## [0.2.0] - 2026-10-19
8
+
9
+ - Add natural language commands with `-n`, using Apple's on-device model through `fm`
10
+ - Play tracks and albums from the Music library by name, artist or album
11
+
1
12
  ## [0.1.2] - 2025-09-01
2
13
 
3
14
  - 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.3.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,49 @@ 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
+ Tell Musica what you want in plain words:
19
+
20
+ $ musica skip this song
21
+ $ musica turn it down a bit
22
+ $ musica play Digital Love by Daft Punk
23
+ $ musica play something by Queen
24
+ $ musica play the album Adore
25
+
26
+ Musica uses Apple's on-device model, through the `fm` command, to turn the
27
+ request into a command for your music player. This needs macOS 26 with Apple
28
+ Intelligence.
29
+
30
+ It understands play, pause, next and previous track, volume changes, and
31
+ playing a track or album from your library by name, artist or album. To play
32
+ an album in order, Musica fills a playlist named "Musica" with its tracks and
33
+ plays that playlist, replacing what was in it before. Playing from your library
34
+ only works with Music.
35
+
36
+ After each request, Musica prints the current status: the artist and track
37
+ while playing, otherwise the player state:
24
38
 
25
39
  $ musica play
40
+ 🎧 Daft Punk - Digital Love
26
41
  $ musica pause
27
- $ musica next track
42
+ 🎧 paused
43
+
44
+ Run `musica` without arguments to print the status alone, `musica --help` for
45
+ a summary of the options, and `musica --version` for the version.
46
+
47
+ ### AppleScript
48
+
49
+ With `-s` (or `--script`), Musica sends its arguments to the player as an
50
+ AppleScript command, without `fm`. Any command in the player's AppleScript
51
+ dictionary works (open it in Script Editor with File > Open Dictionary):
52
+
53
+ $ musica -s playpause
54
+ $ musica -s set sound volume to 50
55
+
56
+ Commands can differ between players. For example, Music uses
57
+ `set shuffle enabled to true` and Spotify uses `set shuffling to true`. Wrap
58
+ commands that contain quotes in single quotes so the shell keeps them:
59
+
60
+ $ musica -s 'play playlist "Favorite Songs"'
28
61
 
29
62
  ## Configuration
30
63
 
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,62 @@
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
+ HELP = <<~TEXT
6
+ Usage: musica [request]
7
+ musica -s <applescript>
8
+
9
+ Without arguments, print what's playing. With a request, turn it into a
10
+ command using fm, send it to the music player, then print what's playing.
11
+
12
+ Options:
13
+ -s, --script Send an AppleScript command to the player as is
14
+ -h, --help Show this help
15
+ -v, --version Show the version
16
+
17
+ Examples:
18
+ musica skip this song
19
+ musica play the album Adore
20
+ musica -s set sound volume to 50
21
+
22
+ The player is set in musica/config.yml, under $XDG_CONFIG_HOME or ~/.config.
23
+ TEXT
24
+
25
+ def initialize(argv, player: nil, interpreter: nil, out: $stdout)
26
+ @argv = argv
27
+ @player = player
28
+ @interpreter = interpreter
29
+ @out = out
11
30
  end
12
31
 
13
32
  def run
14
- player.tell prompt unless prompt.empty?
15
- $stdout << "\u{1F3A7} #{player.status}\n"
33
+ case argv.first
34
+ when "-h", "--help"
35
+ out << HELP
36
+ when "-v", "--version"
37
+ out << "musica #{VERSION}\n"
38
+ when "-s", "--script"
39
+ play argv.drop(1).join(" ")
40
+ else
41
+ play(argv.empty? ? "" : interpreter.call(argv.join(" ")))
42
+ end
16
43
  end
17
44
 
18
45
  private
19
46
 
20
- attr_reader :argv, :player, :prompt
47
+ attr_reader :argv, :out
21
48
 
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")
49
+ def play(command)
50
+ player.tell command unless command.empty?
51
+ out << "\u{1F3A7} #{player.status}\n"
31
52
  end
32
53
 
33
- def create_config(config_dir, config_file)
34
- FileUtils.mkdir_p(config_dir)
35
-
36
- default_config = { "player" => "Music" }
54
+ def player
55
+ @player ||= Player.new(application: Config.player)
56
+ end
37
57
 
38
- File.write(config_file, YAML.dump(default_config))
58
+ def interpreter
59
+ @interpreter ||= Interpreter.new
39
60
  end
40
61
  end
41
62
  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, "Requests need fm (macOS 26 with Apple Intelligence); use -s to send AppleScript instead"
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.3.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.3.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.23
56
59
  specification_version: 4
57
60
  summary: Control macOS music players from the command-line
58
61
  test_files: []