segue 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 654226acfd8c43958144ae5f8bd900a574d9535063718086869a1b7a561fc65e
4
+ data.tar.gz: d575d92843c8dcfc7063e0fe75a753b4f9d342ac32861319d07a9a7841b11658
5
+ SHA512:
6
+ metadata.gz: e0adbfd5b037b62276113137e8fbaa06f5bd904e7fcca18d69e9978123f8cdd3f335e95a8e3be52abbbb5348293710ffefced2dee955f365c186fce29f6a19cc
7
+ data.tar.gz: ef84a246e55156861643f8a2ca644a70a19473f6627b261fa85099b9718adeaeca096a515db94cf31076a495aa4caa091ecb890c987a0c1f41822e2c14b6b9e6
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,35 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Style/StringLiterals:
7
+ Enabled: true
8
+ EnforcedStyle: double_quotes
9
+
10
+ Style/StringLiteralsInInterpolation:
11
+ Enabled: true
12
+ EnforcedStyle: double_quotes
13
+
14
+ # Classes here are small enough to explain themselves
15
+ Style/Documentation:
16
+ Enabled: false
17
+
18
+ Layout/LineLength:
19
+ Max: 120
20
+
21
+ Metrics/MethodLength:
22
+ Max: 25
23
+
24
+ Metrics/AbcSize:
25
+ Max: 25
26
+
27
+ # Player and PlayerController are cohesive - splitting them would only scatter
28
+ # the state they share
29
+ Metrics/ClassLength:
30
+ Max: 130
31
+
32
+ # describe blocks are long by nature
33
+ Metrics/BlockLength:
34
+ Exclude:
35
+ - "spec/**/*"
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 4.0.6
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in segue.gemspec
6
+ gemspec
7
+
8
+ gem "rake"
9
+ gem "rspec"
10
+ gem "rubocop"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mark Ryall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Segue
2
+
3
+ A crossfading queueing daemon for [mpv](https://mpv.io) - kind of like `mpd` but nowhere near as flexible or
4
+ useful.
5
+
6
+ A *segue* is the radio and DJ term for a transition where one track fades into the next, which is the whole
7
+ point of this thing.
8
+
9
+ The player daemon starts two instances of mpv and uses those to play any tracks placed in the queue.
10
+
11
+ Why two instances? mpv doesn't support crossfading between tracks, so this crossfades by starting the other
12
+ mpv instance playing the next track and adjusting the volume of both.
13
+
14
+ This is a rewrite of [vlcraptor](https://github.com/markryall/vlcraptor), which did the same thing with two
15
+ copies of the VLC desktop app. mpv is a better fit: it's a headless binary rather than a `.app` bundle, it
16
+ speaks JSON over a unix socket instead of a line oriented telnet interface, and it runs anywhere rather than
17
+ only where VLC happens to be installed in `/Applications`.
18
+
19
+ ## Installation
20
+
21
+ `brew install mpv` (or your platform's equivalent) - segue looks for `mpv` on your `PATH`.
22
+
23
+ `brew install ffmpeg` is required by the `queue` command for extracting tags to place in the queue.
24
+
25
+ `brew install terminal-notifier` is optional depending on whether you want notifications when new tracks
26
+ start.
27
+
28
+ This gem can be installed with `gem install segue`.
29
+
30
+ ## Usage
31
+
32
+ Running `segue` without any parameters will list the available subcommands.
33
+
34
+ ### Player
35
+
36
+ Run `segue player` in a shell session.
37
+
38
+ This is the player daemon that controls two instances of mpv. It is an ncurses application which will take
39
+ over the display in that terminal.
40
+
41
+ You can quit with 'q', pause with ' ', stop with 's', play (resume) with 'p' and skip with 'n'.
42
+
43
+ ### Adding tracks to the queue
44
+
45
+ `segue queue folder_containing_audio_files audio_file.mp3` will place any number of audio files in the queue
46
+ and the player should immediately start playing the first track.
47
+
48
+ ### Viewing queue contents
49
+
50
+ `segue list` will list currently queued tracks with an estimated start time if the player is currently running
51
+ and playing a track.
52
+
53
+ ### Managing queue
54
+
55
+ `segue clear` will clear all queued tracks.
56
+
57
+ `segue remove 2` will remove the queued track at index position 2.
58
+
59
+ `segue swap 2 4` will swap the tracks at index positions 2 and 4.
60
+
61
+ ### Media controls
62
+
63
+ `segue pause` will pause, `segue stop` will stop and `segue play` will resume.
64
+
65
+ `segue skip` will fade out the current track and start the next one (unless the queue is empty).
66
+
67
+ ### Optional features
68
+
69
+ A number of features can be turned on/off while the player is running that will determine certain behaviour:
70
+
71
+ `segue autoplay off` will cause the player to stop and politely wait after the current track has finished.
72
+ `segue autoplay on` and tracks will start playing again.
73
+
74
+ `segue crossfade off` will turn off crossfading so new tracks will start once the previous one is completely
75
+ finished.
76
+
77
+ `segue scrobble on` will turn on last.fm scrobbling - you will require your own application api key and secret
78
+ to enable this.
79
+
80
+ ## Configuration
81
+
82
+ | Variable | Default | Purpose |
83
+ | --- | --- | --- |
84
+ | `SEGUE_HOME` | `~/.segue` | Where preferences, history and the queue are kept |
85
+ | `SEGUE_MPV` | `mpv` | The mpv executable to launch |
86
+
87
+ Keeping all state under one directory means segue can be run alongside vlcraptor (or a second copy of itself,
88
+ with a different `SEGUE_HOME`) without the two fighting over the same queue.
89
+
90
+ ## Development
91
+
92
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
93
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
94
+
95
+ The mpv IPC client is covered by specs that talk to a fake mpv over a unix socket, so the test suite does not
96
+ require mpv to be installed.
97
+
98
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update
99
+ the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for
100
+ the version, push git commits and the created tag, and push the `.gem` file to
101
+ [rubygems.org](https://rubygems.org).
102
+
103
+ ## Contributing
104
+
105
+ Bug reports and pull requests are welcome on GitHub at https://github.com/markryall/segue.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "segue"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/exe/segue ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), "..", "lib")
6
+
7
+ require "segue"
8
+
9
+ command = ARGV.shift
10
+
11
+ case command
12
+ when "autoplay"
13
+ Segue.autoplay(ARGV.shift)
14
+ when "clear"
15
+ Segue.clear
16
+ when "crossfade"
17
+ Segue.crossfade(ARGV.shift)
18
+ when "history"
19
+ Segue.history
20
+ when "list"
21
+ Segue.list
22
+ when "pause"
23
+ Segue.pause
24
+ when "play"
25
+ Segue.play
26
+ when "player"
27
+ Segue.player
28
+ when "remove"
29
+ Segue.remove(ARGV.shift)
30
+ when "queue"
31
+ Segue.queue(ARGV)
32
+ when "scrobble"
33
+ Segue.scrobble(ARGV.shift)
34
+ when "skip"
35
+ Segue.skip
36
+ when "stop"
37
+ Segue.stop
38
+ when "swap"
39
+ Segue.swap(ARGV)
40
+ when "version"
41
+ puts Segue::VERSION
42
+ else
43
+ puts "Unknown command \"#{command}\":"
44
+ puts " autoplay on/off: continue playing tracks or stop at the end of current track"
45
+ puts " clear: clear queue"
46
+ puts " crossfade on/off: 5 second crossfade when changing tracks"
47
+ puts " history: display play history"
48
+ puts " list: list current queue"
49
+ puts " pause: pause current track (resume with play)"
50
+ puts " play: resume after pause/stop"
51
+ puts " player: start the player"
52
+ puts " queue paths: queue folders or files containing music tracks"
53
+ puts " remove a: remove track from queue at index position a"
54
+ puts " scrobble on/off: send track information to last.fm (requires an api key)"
55
+ puts " skip: skip the current track"
56
+ puts " stop: stop the player (resume with play)"
57
+ puts " swap a b: swap tracks in queue at index positions a and b"
58
+ puts " version: print the segue version"
59
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Segue
6
+ # Pulls tags out of a file by parsing what ffmpeg prints about it.
7
+ class Ffmpeg
8
+ attr_reader(
9
+ :title,
10
+ :album,
11
+ :artist,
12
+ :albumartist,
13
+ :time,
14
+ :date,
15
+ :track,
16
+ :puid,
17
+ :mbartistid,
18
+ :mbalbumid,
19
+ :mbalbumartistid,
20
+ :asin
21
+ )
22
+
23
+ def initialize(path)
24
+ @path = path
25
+ parse
26
+
27
+ @title = tag :title, :tit2
28
+ @album = tag :album, :talb
29
+ @artist = tag :artist, :tpe1, :tpe2
30
+ @albumartist = tag :album_artist, :tso2
31
+ @time = to_duration tag :duration
32
+ @date = tag :date, :tdrc, :tyer
33
+ @track = tag :track, :trck
34
+ @puid = tag :"musicip puid"
35
+ @mbartistid = tag :musicbrainz_artistid, :"musicbrainz artist id"
36
+ @mbalbumid = tag :musicbrainz_albumid, :"musicbrainz album id"
37
+ @mbalbumartistid = tag :musicbrainz_albumartistid, :"musicbrainz album artist id"
38
+ @asin = tag :asin
39
+ end
40
+
41
+ def tag(*names)
42
+ names.each { |name| return @meta[name] if @meta[name] }
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def parse
49
+ @meta = {}
50
+ collecting = false
51
+ `ffmpeg -i #{Shellwords.escape(@path)} 2>&1`.each_line do |line|
52
+ if line.chomp == " Metadata:"
53
+ collecting = true
54
+ next
55
+ end
56
+ next unless collecting
57
+
58
+ match = / *: */.match line.chomp
59
+ add_meta match.pre_match.strip.downcase.to_sym, match.post_match.strip if match
60
+ end
61
+ end
62
+
63
+ def add_meta(key, value)
64
+ @meta[key] ||= value
65
+ end
66
+
67
+ def to_duration(string)
68
+ return nil unless string
69
+
70
+ first, = string.split ","
71
+ hours, minutes, seconds = first.split ":"
72
+ seconds.to_i + (minutes.to_i * 60) + (hours.to_i * 60 * 60)
73
+ end
74
+ end
75
+ end
data/lib/segue/mpv.rb ADDED
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Segue
7
+ # Client for mpv's JSON IPC protocol.
8
+ #
9
+ # mpv speaks newline delimited JSON over a unix socket. Requests carry a
10
+ # request_id and replies echo it back, which is what lets us ignore the
11
+ # asynchronous event messages mpv interleaves with responses.
12
+ class Mpv
13
+ class Error < RuntimeError
14
+ end
15
+
16
+ CONNECT_TIMEOUT = 5
17
+ LOAD_TIMEOUT = 5
18
+ POLL_INTERVAL = 0.05
19
+
20
+ attr_reader :socket_path
21
+
22
+ def initialize(socket_path)
23
+ @socket_path = socket_path
24
+ @request_id = 0
25
+ end
26
+
27
+ # mpv creates the socket a moment after it starts, so retry until it shows up.
28
+ def connect(timeout: CONNECT_TIMEOUT)
29
+ deadline = Time.now + timeout
30
+ begin
31
+ @socket = UNIXSocket.new(socket_path)
32
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
33
+ raise Error, "timed out waiting for mpv socket at #{socket_path}" if Time.now > deadline
34
+
35
+ sleep POLL_INTERVAL
36
+ retry
37
+ end
38
+ self
39
+ end
40
+
41
+ def connected?
42
+ !@socket.nil?
43
+ end
44
+
45
+ def command(*args)
46
+ reply = request(args)
47
+ raise Error, "#{args.first}: #{reply["error"]}" unless reply["error"] == "success"
48
+
49
+ reply["data"]
50
+ end
51
+
52
+ # Returns nil when mpv considers a property unavailable - time-pos and
53
+ # duration are both unavailable whenever nothing is loaded.
54
+ def get(property)
55
+ request(["get_property", property])["data"]
56
+ end
57
+
58
+ def set(property, value)
59
+ command("set_property", property, value)
60
+ end
61
+
62
+ def load(path, start: nil)
63
+ command("loadfile", path, "replace")
64
+ wait_for_load
65
+ command("seek", start, "absolute") if start&.positive?
66
+ end
67
+
68
+ def close
69
+ return unless @socket
70
+
71
+ begin
72
+ request(["quit"])
73
+ rescue Error, IOError, SystemCallError
74
+ # mpv may have gone already - nothing left to ask politely
75
+ end
76
+ @socket.close unless @socket.closed?
77
+ @socket = nil
78
+ end
79
+
80
+ private
81
+
82
+ def wait_for_load(timeout: LOAD_TIMEOUT)
83
+ deadline = Time.now + timeout
84
+ sleep POLL_INTERVAL while get("time-pos").nil? && Time.now < deadline
85
+ end
86
+
87
+ def request(args)
88
+ raise Error, "not connected to #{socket_path}" unless @socket
89
+
90
+ @request_id += 1
91
+ @socket.puts JSON.generate(command: args, request_id: @request_id)
92
+ read_reply(@request_id)
93
+ end
94
+
95
+ def read_reply(id)
96
+ loop do
97
+ line = @socket.gets
98
+ raise Error, "mpv closed the connection" if line.nil?
99
+
100
+ message = parse(line)
101
+ return message if message && message["request_id"] == id
102
+ end
103
+ end
104
+
105
+ def parse(line)
106
+ JSON.parse(line)
107
+ rescue JSON::ParserError
108
+ nil
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rainbow"
4
+ require "shellwords"
5
+ require_relative "paths"
6
+ require_relative "preferences"
7
+ require_relative "scrobbler"
8
+
9
+ module Segue
10
+ class Notifiers
11
+ def initialize
12
+ @preferences = Segue::Preferences.new
13
+ @history = Segue::Paths.history
14
+ end
15
+
16
+ def track_suspended
17
+ @preferences[:started] = nil
18
+ end
19
+
20
+ def track_resumed(track, elapsed)
21
+ return unless track
22
+
23
+ track[:start_time] = Time.now - elapsed
24
+ @preferences[:started] = track[:start_time].to_i
25
+ end
26
+
27
+ def track_started(track)
28
+ return unless track
29
+
30
+ track[:start_time] = Time.now
31
+ @preferences[:started] = track[:start_time].to_i
32
+ scrobbler&.now_playing(track[:artist], track[:title])
33
+ terminal_notify(
34
+ message: "#{track[:title]} by #{track[:artist]}",
35
+ title: "Now Playing"
36
+ )
37
+
38
+ File.open(@history, "a") { |file| file.puts history_line(track) }
39
+ end
40
+
41
+ def track_finished(track)
42
+ @preferences[:started] = nil
43
+
44
+ return unless track
45
+
46
+ scrobbler&.scrobble(track[:artist], track[:title], timestamp: track[:start_time].to_i)
47
+ end
48
+
49
+ private
50
+
51
+ def history_line(track)
52
+ [
53
+ display_time(track[:start_time]),
54
+ Rainbow(track[:title]).green,
55
+ "by",
56
+ Rainbow(track[:artist]).yellow,
57
+ "from",
58
+ Rainbow(track[:album]).cyan,
59
+ "(#{duration(track[:length])})"
60
+ ].join(" ")
61
+ end
62
+
63
+ def duration(seconds)
64
+ seconds = seconds.to_i
65
+ seconds > 60 ? "#{seconds / 60}m and #{seconds % 60}s" : "#{seconds}s"
66
+ end
67
+
68
+ def display_time(time)
69
+ time.strftime("%I:%M:%S")
70
+ end
71
+
72
+ def terminal_notify(message:, title:)
73
+ return if `which terminal-notifier`.empty?
74
+
75
+ `terminal-notifier -group segue -message #{Shellwords.escape(message)} -title #{Shellwords.escape(title)}`
76
+ end
77
+
78
+ def scrobbler
79
+ Segue::Scrobbler.load if @preferences.scrobble?
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Segue
7
+ # Everything segue writes to disk lives under a single directory so that it
8
+ # can run alongside other players without fighting over state.
9
+ module Paths
10
+ module_function
11
+
12
+ def home
13
+ File.expand_path(ENV.fetch("SEGUE_HOME", "~/.segue")).tap do |path|
14
+ FileUtils.mkdir_p path
15
+ end
16
+ end
17
+
18
+ def preferences
19
+ File.join(home, "preferences.yml")
20
+ end
21
+
22
+ def history
23
+ File.join(home, "history")
24
+ end
25
+
26
+ def queue
27
+ File.join(home, "queue").tap { |path| FileUtils.mkdir_p path }
28
+ end
29
+
30
+ # Sockets are scoped to the running player so a crashed one can't leave a
31
+ # stale path behind that the next player would try to talk to.
32
+ def socket(name)
33
+ File.join(Dir.tmpdir, "segue-#{name}-#{Process.pid}.sock")
34
+ end
35
+ end
36
+ end