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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "mpv"
5
+ require_relative "paths"
6
+
7
+ module Segue
8
+ # Drives a pair of mpv instances so one can fade up while the other fades
9
+ # down - mpv has no crossfade of its own.
10
+ class Player
11
+ MAX_VOLUME = 100
12
+ FADE_STEPS = 10
13
+ FADE_STEP_SECONDS = 0.5
14
+ QUIT_TIMEOUT = 2
15
+
16
+ def initialize(executable: ENV.fetch("SEGUE_MPV", "mpv"))
17
+ @executable = executable
18
+ @processes = []
19
+ @current = spawn_mpv("a")
20
+ @other = spawn_mpv("b")
21
+ @stopped = nil
22
+ end
23
+
24
+ def playing?
25
+ loaded? && @current.get("pause") == false
26
+ end
27
+
28
+ def time
29
+ (@current.get("time-pos") || 0).round
30
+ end
31
+
32
+ def remaining
33
+ duration = @current.get("duration")
34
+ return 0 unless duration
35
+
36
+ [(duration - (@current.get("time-pos") || 0)).round, 0].max
37
+ end
38
+
39
+ def fadeout
40
+ fade([@current, MAX_VOLUME, 0])
41
+ end
42
+
43
+ def fadein
44
+ @current.set("volume", 0)
45
+ play
46
+ fade([@current, 0, MAX_VOLUME])
47
+ end
48
+
49
+ def crossfade(path)
50
+ @other.set("volume", 0)
51
+ @other.load(path)
52
+ fade([@current, MAX_VOLUME, 0], [@other, 0, MAX_VOLUME])
53
+ @current.command("stop")
54
+ @current, @other = @other, @current
55
+ @stopped = nil
56
+ end
57
+
58
+ def play(path = nil)
59
+ if path
60
+ @current.set("volume", MAX_VOLUME)
61
+ @current.load(path)
62
+ elsif @stopped
63
+ @current.load(@stopped[:path], start: @stopped[:time])
64
+ else
65
+ @current.set("pause", false)
66
+ end
67
+ @stopped = nil
68
+ end
69
+
70
+ def pause
71
+ @current.set("pause", true)
72
+ end
73
+
74
+ # mpv unloads the file on stop, so remember where we were to let play resume.
75
+ def stop
76
+ @stopped = { path: @current.get("path"), time: @current.get("time-pos") }
77
+ @current.command("stop")
78
+ end
79
+
80
+ def cleanup
81
+ [@current, @other].compact.each(&:close)
82
+ @current = @other = nil
83
+ @processes.each do |pid, socket_path|
84
+ terminate pid
85
+ FileUtils.rm_f socket_path
86
+ end
87
+ @processes = []
88
+ end
89
+
90
+ private
91
+
92
+ def loaded?
93
+ @current.get("idle-active") == false
94
+ end
95
+
96
+ def spawn_mpv(name)
97
+ socket_path = Segue::Paths.socket(name)
98
+ FileUtils.rm_f socket_path
99
+ pid = Process.spawn(
100
+ @executable,
101
+ "--idle=yes",
102
+ "--no-video",
103
+ "--no-terminal",
104
+ "--volume=#{MAX_VOLUME}",
105
+ "--input-ipc-server=#{socket_path}",
106
+ %i[out err] => File::NULL
107
+ )
108
+ @processes << [pid, socket_path]
109
+ Mpv.new(socket_path).connect
110
+ end
111
+
112
+ # Each ramp is [mpv, from, to] and they all step together, which is what
113
+ # makes a crossfade a crossfade rather than two sequential fades.
114
+ def fade(*ramps)
115
+ (0..FADE_STEPS).each do |step|
116
+ ramps.each { |mpv, from, to| mpv.set("volume", level(from, to, step)) }
117
+ sleep FADE_STEP_SECONDS
118
+ end
119
+ end
120
+
121
+ def level(from, to, step)
122
+ from + (((to - from) * step) / FADE_STEPS)
123
+ end
124
+
125
+ def terminate(pid)
126
+ deadline = Time.now + QUIT_TIMEOUT
127
+ while Time.now < deadline
128
+ return if Process.waitpid(pid, Process::WNOHANG)
129
+
130
+ sleep Mpv::POLL_INTERVAL
131
+ end
132
+ Process.kill "TERM", pid
133
+ Process.waitpid pid
134
+ rescue Errno::ECHILD, Errno::ESRCH
135
+ nil
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "player"
4
+ require_relative "preferences"
5
+ require_relative "queue"
6
+ require_relative "notifiers"
7
+
8
+ module Segue
9
+ class PlayerController
10
+ CROSSFADE_TRIGGER_SECONDS = 5
11
+
12
+ def initialize
13
+ @player = Segue::Player.new
14
+ @preferences = Segue::Preferences.new
15
+ @queue = Segue::Queue.new
16
+ @notifiers = Segue::Notifiers.new
17
+ @track = nil
18
+ @suspended = false
19
+ end
20
+
21
+ # An ordered chain of guard clauses - the priority between them is the
22
+ # point, so leave it flat rather than nesting it to please the metrics
23
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
24
+ def next
25
+ return on_play if @preferences.play? && @suspended
26
+ return on_pause if @preferences.pause? && !@suspended
27
+ return on_stop if @preferences.stop? && !@suspended
28
+ return build if @suspended
29
+ return when_playing if @track && @player.playing?
30
+ return when_auto if @preferences.continue?
31
+
32
+ when_manual
33
+ end
34
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
+
36
+ def cleanup
37
+ @notifiers.track_suspended
38
+ @player.fadeout
39
+ @player.cleanup
40
+ end
41
+
42
+ private
43
+
44
+ def on_pause
45
+ suspend("Now Paused", :pause)
46
+ end
47
+
48
+ def on_stop
49
+ suspend("Now Stopped", :stop)
50
+ end
51
+
52
+ def suspend(status, method)
53
+ @player.fadeout
54
+ @player.send(method)
55
+ @status = status
56
+ @suspended = true
57
+ @notifiers.track_suspended
58
+ build
59
+ end
60
+
61
+ def on_play
62
+ @player.fadein
63
+ @suspended = false
64
+ @notifiers.track_resumed(@track, @player.time)
65
+ when_playing_track(@player.remaining)
66
+ end
67
+
68
+ def when_playing
69
+ return on_skip if @preferences.skip?
70
+ return on_crossfade if @preferences.crossfade? && @player.remaining < CROSSFADE_TRIGGER_SECONDS
71
+
72
+ when_playing_track(@player.remaining)
73
+ end
74
+
75
+ def on_skip
76
+ @notifiers.track_suspended
77
+ advance
78
+ end
79
+
80
+ def on_crossfade
81
+ @notifiers.track_finished(@track)
82
+ advance
83
+ end
84
+
85
+ def advance
86
+ @track = @queue.next
87
+ unless @track
88
+ @player.fadeout
89
+ return when_empty
90
+ end
91
+
92
+ @notifiers.track_started(@track)
93
+ @player.crossfade(@track[:path])
94
+ when_playing_track(@player.remaining)
95
+ end
96
+
97
+ def when_auto
98
+ @notifiers.track_finished(@track)
99
+ @track = @queue.next
100
+ return when_empty unless @track
101
+
102
+ @notifiers.track_started(@track)
103
+ @player.play(@track[:path])
104
+ when_playing_track(@track[:length])
105
+ end
106
+
107
+ def when_playing_track(remaining)
108
+ @status = "Now Playing"
109
+ remaining_color = remaining < 30 ? 9 : 5
110
+ build(
111
+ [2, @track[:title]],
112
+ [0, "\n"],
113
+ [0, "by "],
114
+ [11, @track[:artist]],
115
+ [0, "\n"],
116
+ [0, "from "],
117
+ [6, @track[:album]],
118
+ [0, "\n"],
119
+ [remaining_color, duration(remaining)],
120
+ [0, " of "],
121
+ [0, duration(@track[:length])],
122
+ [0, " remaining\n"]
123
+ )
124
+ end
125
+
126
+ def when_empty
127
+ @status = "Now Waiting"
128
+ build
129
+ end
130
+
131
+ def when_manual
132
+ @status = "Now Waiting"
133
+ build
134
+ end
135
+
136
+ def display_time(time)
137
+ time.strftime("%I:%M:%S")
138
+ end
139
+
140
+ def duration(seconds)
141
+ seconds = seconds.to_i
142
+ seconds > 60 ? "#{seconds / 60}m and #{seconds % 60}s" : "#{seconds}s"
143
+ end
144
+
145
+ def build(*extra)
146
+ autoplay = @preferences[:autoplay] ? "+" : "-"
147
+ crossfade = @preferences[:crossfade] ? "+" : "-"
148
+ scrobble = @preferences[:scrobble] ? "+" : "-"
149
+ [
150
+ [0, display_time(Time.now)],
151
+ [0, "\n"],
152
+ [0, @status],
153
+ [0, "\n"],
154
+ [0, "#{Segue::Queue.length} queued tracks"],
155
+ [0, "\n"],
156
+ [8, "#{autoplay}autoplay #{crossfade}crossfade #{scrobble}scrobble"],
157
+ [0, "\n"]
158
+ ] + extra
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "paths"
5
+
6
+ module Segue
7
+ class Preferences
8
+ def initialize
9
+ @path = Segue::Paths.preferences
10
+ persist({ autoplay: true, crossfade: true }) unless File.exist?(@path)
11
+ end
12
+
13
+ def continue?
14
+ self[:autoplay]
15
+ end
16
+
17
+ def crossfade?
18
+ self[:autoplay] && self[:crossfade]
19
+ end
20
+
21
+ def scrobble?
22
+ self[:scrobble]
23
+ end
24
+
25
+ def pause?
26
+ reset(:pause)
27
+ end
28
+
29
+ def stop?
30
+ reset(:stop)
31
+ end
32
+
33
+ def play?
34
+ reset(:play)
35
+ end
36
+
37
+ def skip?
38
+ reset(:skip)
39
+ end
40
+
41
+ def [](key)
42
+ load_preferences[key]
43
+ end
44
+
45
+ def []=(key, value)
46
+ preferences = load_preferences
47
+ preferences[key] = value
48
+ persist(preferences)
49
+ end
50
+
51
+ def persist(preferences)
52
+ File.open(@path, "w") { |f| f.puts preferences.to_yaml }
53
+ end
54
+
55
+ private
56
+
57
+ def load_preferences
58
+ YAML.load_file(@path)
59
+ end
60
+
61
+ def reset(key)
62
+ result = self[key]
63
+ self[key] = false if result
64
+ result
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "ffmpeg"
6
+ require_relative "paths"
7
+
8
+ module Segue
9
+ # The queue is a directory of yaml files named after the time they were
10
+ # added, so ordering is just sorting by filename.
11
+ class Queue
12
+ EXTENSIONS = %w[.mp3 .m4a .flac .ogg .opus .wav .aac .wma].freeze
13
+
14
+ def initialize
15
+ @current_path = nil
16
+ end
17
+
18
+ def next
19
+ FileUtils.rm_f @current_path if @current_path
20
+ @current_path = Segue::Queue.entries.first
21
+ return unless @current_path
22
+
23
+ result = YAML.load_file(@current_path)
24
+ File.exist?(result[:path]) ? result : self.next
25
+ end
26
+
27
+ class << self
28
+ # Dir[] sorts its results, and the filenames are timestamps, so this is
29
+ # the queue in the order tracks were added
30
+ def entries
31
+ Dir[File.join(Segue::Paths.queue, "*.yml")]
32
+ end
33
+
34
+ def clear
35
+ FileUtils.rm_rf Segue::Paths.queue
36
+ end
37
+
38
+ def length
39
+ entries.length
40
+ end
41
+
42
+ def each
43
+ entries.each { |path| yield YAML.load_file(path) }
44
+ end
45
+
46
+ def remove(index)
47
+ path = entries[index.to_i]
48
+ if path
49
+ FileUtils.rm_f path
50
+ yield
51
+ else
52
+ puts "Could not find track at position #{index}"
53
+ end
54
+ end
55
+
56
+ def swap(index_a, index_b)
57
+ all = entries
58
+ path_a = all[index_a.to_i]
59
+ path_b = all[index_b.to_i]
60
+
61
+ if path_a && path_b
62
+ FileUtils.mv path_a, "#{path_a}.tmp"
63
+ FileUtils.mv path_b, path_a
64
+ FileUtils.mv "#{path_a}.tmp", path_b
65
+ yield
66
+ else
67
+ puts "Could not find tracks at positions #{index_a} and #{index_b}"
68
+ end
69
+ end
70
+
71
+ def add(path)
72
+ unless EXTENSIONS.include?(File.extname(path).downcase)
73
+ puts "skipping #{path}"
74
+ return
75
+ end
76
+
77
+ puts "adding #{path}"
78
+ tags = Segue::Ffmpeg.new(path)
79
+ enqueue(
80
+ title: tags.title,
81
+ artist: tags.artist,
82
+ album: tags.album,
83
+ length: tags.time,
84
+ path: File.expand_path(path)
85
+ )
86
+ end
87
+
88
+ def enqueue(track)
89
+ File.open(File.join(Segue::Paths.queue, next_name), "w") do |file|
90
+ file.puts track.to_yaml
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ # Millisecond timestamps collide when tracks are queued in quick
97
+ # succession, so add a zero padded sequence that keeps sorting stable.
98
+ def next_name
99
+ millis = (Time.now.to_f * 1000).to_i
100
+ sequence = 0
101
+ sequence += 1 while File.exist?(File.join(Segue::Paths.queue, name_for(millis, sequence)))
102
+ name_for(millis, sequence)
103
+ end
104
+
105
+ def name_for(millis, sequence)
106
+ format("%<millis>d-%<sequence>03d.yml", millis: millis, sequence: sequence)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "digest/md5"
5
+ require "uri"
6
+ require "cgi"
7
+ require "rexml/document"
8
+ require_relative "settings"
9
+
10
+ module Segue
11
+ class Scrobbler
12
+ SCROBBLER_URL = "http://ws.audioscrobbler.com/2.0/"
13
+
14
+ class SubmissionError < RuntimeError
15
+ end
16
+
17
+ class SessionError < RuntimeError
18
+ end
19
+
20
+ def self.ask(prompt)
21
+ puts prompt
22
+ gets.chomp.strip
23
+ end
24
+
25
+ REQUIRED = %i[api_key secret user].freeze
26
+
27
+ def self.blank?(attribute)
28
+ (attribute || "").empty?
29
+ end
30
+
31
+ def self.load
32
+ conf = Segue::Settings.new("~/.lastfm.yml")
33
+ prompt_for_credentials(conf)
34
+ return nil if REQUIRED.any? { |key| blank?(conf[key]) }
35
+
36
+ scrobbler = new(conf[:api_key], conf[:secret], conf[:user], conf[:session])
37
+ conf[:session] ||= authorise(scrobbler)
38
+ return nil if blank?(conf[:session])
39
+
40
+ scrobbler
41
+ end
42
+
43
+ def self.prompt_for_credentials(conf)
44
+ conf[:api_key] = ask("What is the api key? ") if blank?(conf[:api_key])
45
+ conf[:secret] = ask("What is the secret? ") if blank?(conf[:secret])
46
+ conf[:user] = ask("What is your lastfm username? ") if blank?(conf[:user])
47
+ end
48
+
49
+ def self.authorise(scrobbler)
50
+ scrobbler.fetch_session_key do |url|
51
+ puts "A browser will now launch to allow to authorise this application to access your lastfm account"
52
+ `open '#{url}'`
53
+ puts "Press enter when you have authorised the application"
54
+ gets
55
+ end
56
+ end
57
+
58
+ def initialize(api_key, secret, user, session_key = nil)
59
+ @api_key = api_key
60
+ @secret = secret
61
+ @user = user
62
+ @session_key = session_key
63
+ end
64
+
65
+ attr_reader :user, :api_key, :secret
66
+
67
+ def session_key
68
+ @session_key or raise SessionError, "The session key must be set or fetched"
69
+ end
70
+
71
+ def fetch_session_key
72
+ doc = lfm :get, "auth.gettoken"
73
+ request_token = doc.root.elements["token"].text
74
+ yield "http://www.last.fm/api/auth/?api_key=#{api_key}&token=#{request_token}"
75
+ doc = lfm :get, "auth.getsession", token: request_token
76
+ status = doc.root.attributes["status"]
77
+ raise SubmissionError, status unless status == "ok"
78
+
79
+ @session_key = doc.root.elements["session"].elements["key"].text
80
+ end
81
+
82
+ def with_profile_url
83
+ yield "http://www.last.fm/user/#{user}" if user
84
+ end
85
+
86
+ # http://www.last.fm/api/show?service=443
87
+ def scrobble(artist, title, params = {})
88
+ lfm_track "track.scrobble", artist, title, params
89
+ end
90
+
91
+ # See http://www.last.fm/api/show?service=454 for more details
92
+ def now_playing(artist, title, params = {})
93
+ lfm_track "track.updateNowPlaying", artist, title, params
94
+ end
95
+
96
+ # http://www.last.fm/api/show?service=260
97
+ def love(artist, title, params = {})
98
+ lfm_track "track.love", artist, title, params
99
+ end
100
+
101
+ private
102
+
103
+ def lfm_track(method, artist, title, params)
104
+ doc = lfm :post, method, params.merge(sk: session_key, artist: artist, track: title)
105
+ status = doc.root.attributes["status"]
106
+ raise SubmissionError, status unless status == "ok"
107
+ end
108
+
109
+ def lfm(get_or_post, method, parameters = {})
110
+ p = signed_parameters parameters.merge api_key: api_key, method: method
111
+ xml = send get_or_post, SCROBBLER_URL, p
112
+ REXML::Document.new xml
113
+ end
114
+
115
+ def get(url, parameters)
116
+ query_string = sort_parameters(parameters)
117
+ .map { |k, v| "#{k}=#{CGI.escape(v)}" }
118
+ .join("&")
119
+ Net::HTTP.get_response(URI.parse("#{url}?#{query_string}")).body
120
+ end
121
+
122
+ def post(url, parameters)
123
+ Net::HTTP.post_form(URI.parse(url), parameters).body
124
+ end
125
+
126
+ def signed_parameters(parameters)
127
+ sorted = sort_parameters parameters
128
+ signature = Digest::MD5.hexdigest(sorted.join + secret)
129
+ parameters.merge api_sig: signature
130
+ end
131
+
132
+ def sort_parameters(parameters)
133
+ parameters.map { |k, v| [k.to_s, v.to_s] }.sort
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Segue
7
+ class Settings
8
+ attr_reader :path, :preferences
9
+
10
+ def initialize(path)
11
+ @path = File.expand_path(path)
12
+ FileUtils.mkdir_p File.dirname(@path)
13
+ @preferences = File.exist?(@path) ? YAML.load_file(@path) : {}
14
+ end
15
+
16
+ def [](key)
17
+ preferences[key]
18
+ end
19
+
20
+ def []=(key, value)
21
+ preferences[key] = value
22
+ persist
23
+ end
24
+
25
+ def persist
26
+ File.open(path, "w") { |f| f.puts preferences.to_yaml }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Segue
4
+ VERSION = "0.1.0"
5
+ end