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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +35 -0
- data/.tool-versions +1 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/Rakefile +12 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/exe/segue +59 -0
- data/lib/segue/ffmpeg.rb +75 -0
- data/lib/segue/mpv.rb +111 -0
- data/lib/segue/notifiers.rb +82 -0
- data/lib/segue/paths.rb +36 -0
- data/lib/segue/player.rb +138 -0
- data/lib/segue/player_controller.rb +161 -0
- data/lib/segue/preferences.rb +67 -0
- data/lib/segue/queue.rb +110 -0
- data/lib/segue/scrobbler.rb +136 -0
- data/lib/segue/settings.rb +29 -0
- data/lib/segue/version.rb +5 -0
- data/lib/segue.rb +135 -0
- metadata +105 -0
data/lib/segue/player.rb
ADDED
|
@@ -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
|
data/lib/segue/queue.rb
ADDED
|
@@ -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
|