musicbox 0.1
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/.gitignore +3 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/Rakefile +9 -0
- data/TODO.txt +145 -0
- data/bin/musicbox +118 -0
- data/lib/extensions/string.rb +35 -0
- data/lib/musicbox.rb +268 -0
- data/lib/musicbox/catalog.rb +205 -0
- data/lib/musicbox/catalog/album.rb +179 -0
- data/lib/musicbox/catalog/album_track.rb +66 -0
- data/lib/musicbox/catalog/albums.rb +19 -0
- data/lib/musicbox/catalog/artist.rb +52 -0
- data/lib/musicbox/catalog/artists.rb +15 -0
- data/lib/musicbox/catalog/collection.rb +15 -0
- data/lib/musicbox/catalog/collection_item.rb +35 -0
- data/lib/musicbox/catalog/format.rb +52 -0
- data/lib/musicbox/catalog/release.rb +243 -0
- data/lib/musicbox/catalog/release_artist.rb +59 -0
- data/lib/musicbox/catalog/releases.rb +15 -0
- data/lib/musicbox/catalog/tags.rb +116 -0
- data/lib/musicbox/catalog/track.rb +43 -0
- data/lib/musicbox/cover_maker.rb +49 -0
- data/lib/musicbox/discogs.rb +53 -0
- data/lib/musicbox/equalizer.rb +86 -0
- data/lib/musicbox/error.rb +5 -0
- data/lib/musicbox/exporter.rb +76 -0
- data/lib/musicbox/extractor.rb +35 -0
- data/lib/musicbox/group.rb +169 -0
- data/lib/musicbox/importer.rb +133 -0
- data/lib/musicbox/info_to_s.rb +18 -0
- data/lib/musicbox/label_maker.rb +61 -0
- data/lib/musicbox/player.rb +289 -0
- data/lib/musicbox/version.rb +5 -0
- data/musicbox.gemspec +38 -0
- metadata +303 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
class MusicBox
|
2
|
+
|
3
|
+
class Extractor
|
4
|
+
|
5
|
+
def initialize(catalog:)
|
6
|
+
@catalog = catalog
|
7
|
+
end
|
8
|
+
|
9
|
+
def extract_dir(source_dir)
|
10
|
+
puts "Extracting #{source_dir}"
|
11
|
+
files = @catalog.categorize_files(source_dir)
|
12
|
+
cue_file = files[:cue]&.shift or raise Error, "No cue file found"
|
13
|
+
bin_file = files[:audio]&.shift or raise Error, "No bin file found"
|
14
|
+
dest_dir = @catalog.import_dir / source_dir.basename
|
15
|
+
raise Error, "Extraction directory already exists: #{dest_dir}" if dest_dir.exist?
|
16
|
+
dest_dir.mkpath
|
17
|
+
xld_extract(cue_file: cue_file, bin_file: bin_file, dest_dir: dest_dir)
|
18
|
+
files.values.flatten.each { |p| p.cp_r(dest_dir) }
|
19
|
+
@catalog.extract_done_dir.mkpath unless @catalog.extract_done_dir.exist?
|
20
|
+
source_dir.rename(@catalog.extract_done_dir / source_dir.basename)
|
21
|
+
end
|
22
|
+
|
23
|
+
def xld_extract(cue_file:, bin_file:, dest_dir:)
|
24
|
+
dest_dir.chdir do
|
25
|
+
run_command('xld',
|
26
|
+
'-c', cue_file,
|
27
|
+
'-f', 'alac',
|
28
|
+
'--filename-format', '%D-%n - %t',
|
29
|
+
bin_file)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
class MusicBox
|
2
|
+
|
3
|
+
class Group
|
4
|
+
|
5
|
+
attr_accessor :root
|
6
|
+
|
7
|
+
InfoFileName = 'info.json'
|
8
|
+
|
9
|
+
def self.item_class
|
10
|
+
Item
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(root:)
|
14
|
+
@root = Path.new(root).expand_path
|
15
|
+
reset
|
16
|
+
load
|
17
|
+
end
|
18
|
+
|
19
|
+
def items
|
20
|
+
@items.values
|
21
|
+
end
|
22
|
+
|
23
|
+
def item_class
|
24
|
+
self.class.item_class
|
25
|
+
end
|
26
|
+
|
27
|
+
def reset
|
28
|
+
@items = {}
|
29
|
+
end
|
30
|
+
|
31
|
+
def load
|
32
|
+
reset
|
33
|
+
if @root.exist?
|
34
|
+
@root.glob("*/#{InfoFileName}").each do |info_file|
|
35
|
+
add_item(item_class.load(info_file.dirname))
|
36
|
+
end
|
37
|
+
;;warn "* loaded #{@items.length} items from #{@root}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def [](id)
|
42
|
+
@items[id]
|
43
|
+
end
|
44
|
+
|
45
|
+
def <<(item)
|
46
|
+
add_item(item)
|
47
|
+
end
|
48
|
+
|
49
|
+
def search(query:, fields:, limit: nil)
|
50
|
+
found = []
|
51
|
+
words = [query].flatten.join(' ').tokenize.sort.uniq - ['-']
|
52
|
+
words.each do |word|
|
53
|
+
regexp = Regexp.new(Regexp.quote(word), true)
|
54
|
+
found += @items.values.select do |item|
|
55
|
+
fields.find do |field|
|
56
|
+
case (value = item.send(field))
|
57
|
+
when Array
|
58
|
+
value.find { |v| v.to_s =~ regexp }
|
59
|
+
else
|
60
|
+
value.to_s =~ regexp
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
found = found.flatten.compact.uniq
|
66
|
+
found = found[0..limit - 1] if limit
|
67
|
+
found
|
68
|
+
end
|
69
|
+
|
70
|
+
def dir_for_id(id)
|
71
|
+
@root / id
|
72
|
+
end
|
73
|
+
|
74
|
+
def new_item(id, args={})
|
75
|
+
item = item_class.new(
|
76
|
+
{
|
77
|
+
id: id,
|
78
|
+
dir: dir_for_id(id),
|
79
|
+
}.merge(args)
|
80
|
+
)
|
81
|
+
add_item(item)
|
82
|
+
item
|
83
|
+
end
|
84
|
+
|
85
|
+
def add_item(item)
|
86
|
+
raise Error, "Item does not have ID" if item.id.nil?
|
87
|
+
raise Error, "Item already exists in #{@root}: #{item.id.inspect}" if @items[item.id]
|
88
|
+
@items[item.id] = item
|
89
|
+
end
|
90
|
+
|
91
|
+
def has_item?(id)
|
92
|
+
@items.has_key?(id)
|
93
|
+
end
|
94
|
+
|
95
|
+
def delete_item(item)
|
96
|
+
@items.delete(item.id)
|
97
|
+
end
|
98
|
+
|
99
|
+
def save_item(id:, item: nil, &block)
|
100
|
+
raise Error, "ID is nil" unless id
|
101
|
+
item = yield if block_given?
|
102
|
+
raise Error, "Item is nil" unless item
|
103
|
+
dir = dir_for_id(id)
|
104
|
+
info_file = dir / InfoFileName
|
105
|
+
dir.mkpath unless dir.exist?
|
106
|
+
;;warn "writing to #{info_file}"
|
107
|
+
info_file.write(JSON.pretty_generate(item))
|
108
|
+
end
|
109
|
+
|
110
|
+
def save_item_if_new(id:, item: nil, &block)
|
111
|
+
unless has_item?(id)
|
112
|
+
save_item(id: id, item: item, &block)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def destroy_item(item)
|
117
|
+
dir = dir_for_id(id)
|
118
|
+
dir.rmtree if dir.exist?
|
119
|
+
delete_item(item)
|
120
|
+
end
|
121
|
+
|
122
|
+
def destroy!
|
123
|
+
@root.rmtree if @root.exist?
|
124
|
+
end
|
125
|
+
|
126
|
+
class Item
|
127
|
+
|
128
|
+
attr_accessor :id
|
129
|
+
attr_accessor :dir
|
130
|
+
|
131
|
+
def self.load(dir, params={})
|
132
|
+
dir = Path.new(dir)
|
133
|
+
info_file = dir / Group::InfoFileName
|
134
|
+
raise Error, "Info file does not exist: #{info_file}" unless info_file.exist?
|
135
|
+
new(JSON.load(info_file.read).merge(dir: dir).merge(params))
|
136
|
+
end
|
137
|
+
|
138
|
+
def initialize(params={})
|
139
|
+
params.each { |k, v| send("#{k}=", v) }
|
140
|
+
end
|
141
|
+
|
142
|
+
def info_file
|
143
|
+
@dir / Group::InfoFileName
|
144
|
+
end
|
145
|
+
|
146
|
+
def save
|
147
|
+
;;warn "* saving item to #{@dir}"
|
148
|
+
raise Error, "dir not defined" unless @dir
|
149
|
+
@dir.mkpath unless @dir.exist?
|
150
|
+
info_file.write(JSON.pretty_generate(serialize))
|
151
|
+
end
|
152
|
+
|
153
|
+
def serialize(args={})
|
154
|
+
{ id: @id }.merge(args).compact
|
155
|
+
end
|
156
|
+
|
157
|
+
def fields(keys)
|
158
|
+
keys.map { |k| send(k) }
|
159
|
+
end
|
160
|
+
|
161
|
+
def <=>(other)
|
162
|
+
@id <=> other.id
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
168
|
+
|
169
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
class MusicBox
|
2
|
+
|
3
|
+
class Importer
|
4
|
+
|
5
|
+
def initialize(catalog:)
|
6
|
+
@catalog = catalog
|
7
|
+
@prompt = TTY::Prompt.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def import_dir(source_dir)
|
11
|
+
@source_dir = Path.new(source_dir).realpath
|
12
|
+
puts; puts "Importing from #{@source_dir}"
|
13
|
+
find_release
|
14
|
+
determine_disc
|
15
|
+
make_album
|
16
|
+
make_copy_plan
|
17
|
+
if @prompt.yes?('Add?')
|
18
|
+
import_album
|
19
|
+
make_label if @prompt.yes?('Make label?')
|
20
|
+
make_cover if @prompt.yes?('Make cover?')
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def determine_disc
|
25
|
+
@disc = nil
|
26
|
+
if @album
|
27
|
+
raise Error, "Album already exists" if @release.format_quantity.nil? || @release.format_quantity == 1
|
28
|
+
puts "Release has multiple discs."
|
29
|
+
n = @prompt.ask?('Which disc is this?', required: true, convert: :int)
|
30
|
+
raise Error, "Disc number out of range" unless n >= 1 && n <= @release.format_quantity
|
31
|
+
@disc = n
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def find_release
|
36
|
+
releases = @catalog.find(@source_dir.basename.to_s,
|
37
|
+
group: :releases,
|
38
|
+
prompt: true,
|
39
|
+
multiple: false)
|
40
|
+
@release = releases.first
|
41
|
+
@tracklist_flattened = @release.tracklist_flattened
|
42
|
+
print @release.details_to_s
|
43
|
+
end
|
44
|
+
|
45
|
+
def make_album
|
46
|
+
@album = Catalog::Album.new(
|
47
|
+
id: @release.id,
|
48
|
+
title: @release.title,
|
49
|
+
artist: @release.artist,
|
50
|
+
year: @release.original_release_year,
|
51
|
+
discs: @release.format_quantity,
|
52
|
+
dir: @catalog.albums.dir_for_id(@release.id))
|
53
|
+
@release.album = @album
|
54
|
+
end
|
55
|
+
|
56
|
+
def make_album_track(file)
|
57
|
+
tags = Catalog::Tags.load(file)
|
58
|
+
release_track = find_track_for_title(tags[:title])
|
59
|
+
name = '%s%02d - %s' % [
|
60
|
+
@disc ? ('%1d-' % @disc) : '',
|
61
|
+
tags[:track],
|
62
|
+
release_track.title.gsub(%r{[/:]}, '_'),
|
63
|
+
]
|
64
|
+
album_track = Catalog::AlbumTrack.new(
|
65
|
+
title: release_track.title,
|
66
|
+
artist: release_track.artist || @release.artist,
|
67
|
+
track: tags[:track],
|
68
|
+
disc: @disc || tags[:disc],
|
69
|
+
file: Path.new(name).add_extension(file.extname),
|
70
|
+
tags: tags,
|
71
|
+
album: @album)
|
72
|
+
puts "%-50s ==> %6s - %-50s ==> %-50s" % [
|
73
|
+
file.basename,
|
74
|
+
release_track.position,
|
75
|
+
release_track.title,
|
76
|
+
album_track.file,
|
77
|
+
]
|
78
|
+
album_track
|
79
|
+
end
|
80
|
+
|
81
|
+
def find_track_for_title(title)
|
82
|
+
release_track = @tracklist_flattened.find { |t| t.title.downcase == title.downcase }
|
83
|
+
unless release_track
|
84
|
+
puts "Can't find release track with title #{title.inspect}"
|
85
|
+
choices = @tracklist_flattened.map { |t| [t.title, t] }.to_h
|
86
|
+
release_track = @prompt.select('Track?', choices, per_page: 100)
|
87
|
+
end
|
88
|
+
release_track
|
89
|
+
end
|
90
|
+
|
91
|
+
def import_album
|
92
|
+
raise Error, "No tracks were added to album" if @album.tracks.empty?
|
93
|
+
@album.save
|
94
|
+
copy_files
|
95
|
+
@album.update_tags
|
96
|
+
extract_cover
|
97
|
+
end
|
98
|
+
|
99
|
+
def make_copy_plan
|
100
|
+
@copy_plan = @source_dir.children.select(&:file?).sort.map do |source_file|
|
101
|
+
dest_file = case source_file.extname.downcase
|
102
|
+
when '.m4a'
|
103
|
+
album_track = make_album_track(source_file) or next
|
104
|
+
@album.tracks << album_track
|
105
|
+
album_track.file
|
106
|
+
else
|
107
|
+
source_file.basename
|
108
|
+
end
|
109
|
+
[source_file, @album.dir / dest_file]
|
110
|
+
end.to_h
|
111
|
+
end
|
112
|
+
|
113
|
+
def copy_files
|
114
|
+
@copy_plan.each do |source_file, dest_file|
|
115
|
+
source_file.cp(dest_file)
|
116
|
+
end
|
117
|
+
@catalog.import_done_dir.mkpath unless @catalog.import_done_dir.exist?
|
118
|
+
@source_dir.rename(@catalog.import_done_dir / @source_dir.basename)
|
119
|
+
end
|
120
|
+
|
121
|
+
def make_label
|
122
|
+
output_file = '/tmp/labels.pdf'
|
123
|
+
LabelMaker.make_labels(@release.to_label, output_file: output_file)
|
124
|
+
run_command('open', output_file)
|
125
|
+
end
|
126
|
+
|
127
|
+
def make_cover
|
128
|
+
#FIXME
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class MusicBox
|
2
|
+
|
3
|
+
def self.info_to_s(info, indent: 0)
|
4
|
+
io = StringIO.new
|
5
|
+
width = info.map { |i| i.first.length }.max
|
6
|
+
info.each do |label, value, sub_info|
|
7
|
+
io.puts '%s%*s: %s' % [
|
8
|
+
' ' * indent,
|
9
|
+
width,
|
10
|
+
label,
|
11
|
+
value,
|
12
|
+
]
|
13
|
+
io.print info_to_s(sub_info, indent: indent + width + 2) if sub_info
|
14
|
+
end
|
15
|
+
io.string
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
class MusicBox
|
2
|
+
|
3
|
+
class LabelMaker
|
4
|
+
|
5
|
+
def self.make_labels(*labels, output_file:)
|
6
|
+
label_maker = new
|
7
|
+
label_maker.make_labels(labels)
|
8
|
+
label_maker.write(output_file)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@font_dir = Path.new('~/Fonts/D/DejaVu Sans')
|
13
|
+
@pdf = Prawn::Document.new(page_size: [3.5.in, 1.14.in], margin: 0)
|
14
|
+
@pdf.font_families.update(font_families)
|
15
|
+
@pdf.font('DejaVuSans')
|
16
|
+
@pdf.font_size(12)
|
17
|
+
end
|
18
|
+
|
19
|
+
def make_labels(labels)
|
20
|
+
labels.sort_by { |l| l.values_at(:key, :year) }.each_with_index do |label, i|
|
21
|
+
@pdf.start_new_page if i > 0
|
22
|
+
@pdf.bounding_box([0, 1.in], width: 2.5.in, height: 1.in) do
|
23
|
+
# ;;@pdf.transparent(0.5) { @pdf.stroke_bounds }
|
24
|
+
@pdf.text_box <<~END, inline_format: true
|
25
|
+
<b>#{label[:artist]}</b>
|
26
|
+
<i>#{label[:title]}</i>
|
27
|
+
END
|
28
|
+
end
|
29
|
+
@pdf.bounding_box([2.7.in, 1.in], width: 0.8.in, height: 1.in) do
|
30
|
+
# ;;@pdf.transparent(0.5) { @pdf.stroke_bounds }
|
31
|
+
@pdf.text_box <<~END, align: :right, inline_format: true
|
32
|
+
<b>#{label[:key]}
|
33
|
+
#{label[:year]}</b>
|
34
|
+
|
35
|
+
#{label[:format]}
|
36
|
+
#{label[:id]}
|
37
|
+
END
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def write(output_file)
|
43
|
+
@pdf.render_file(output_file.to_s)
|
44
|
+
end
|
45
|
+
|
46
|
+
def font_families
|
47
|
+
{
|
48
|
+
'DejaVuSans' => {
|
49
|
+
normal: 'DejaVuSans',
|
50
|
+
italic: 'DejaVuSans-Oblique',
|
51
|
+
bold: 'DejaVuSans-Bold',
|
52
|
+
bold_italic: 'DejaVuSans-BoldOblique',
|
53
|
+
}.map { |style, file|
|
54
|
+
[style, (@font_dir / file).add_extension('.ttf').realpath.to_s ]
|
55
|
+
}.to_h
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
class MusicBox
|
2
|
+
|
3
|
+
class Player
|
4
|
+
|
5
|
+
Keymap = {
|
6
|
+
'a' => :play_random_album,
|
7
|
+
't' => :play_random_tracks,
|
8
|
+
'r' => :play_album_for_current_track,
|
9
|
+
'n' => :play_next_track,
|
10
|
+
'p' => :play_previous_track,
|
11
|
+
' ' => :toggle_pause,
|
12
|
+
'<' => :skip_backward,
|
13
|
+
'>' => :skip_forward,
|
14
|
+
'^' => :skip_to_beginning,
|
15
|
+
'e' => :toggle_equalizer,
|
16
|
+
'E' => :next_equalizer,
|
17
|
+
'q' => :quit,
|
18
|
+
'.' => :show_playlist,
|
19
|
+
'?' => :show_keymap,
|
20
|
+
}
|
21
|
+
ObservedProperties = {
|
22
|
+
'playlist' => :playlist,
|
23
|
+
'playlist-pos' => :playlist_position,
|
24
|
+
'pause' => :pause_state,
|
25
|
+
'time-pos' => :time_position,
|
26
|
+
}
|
27
|
+
SeekSeconds = 30
|
28
|
+
|
29
|
+
attr_accessor :albums
|
30
|
+
attr_accessor :audio_device
|
31
|
+
attr_accessor :mpv_log_level
|
32
|
+
attr_accessor :equalizers
|
33
|
+
|
34
|
+
def initialize(**params)
|
35
|
+
{
|
36
|
+
mpv_log_level: 'error',
|
37
|
+
}.merge(params).each { |k, v| send("#{k}=", v) }
|
38
|
+
@playlist_file = Path.new('/tmp/mpv_playlist')
|
39
|
+
end
|
40
|
+
|
41
|
+
def play
|
42
|
+
raise Error, "No albums to play" if @albums.nil? || @albums.empty?
|
43
|
+
read_albums
|
44
|
+
@dispatcher = IO::Dispatcher.new
|
45
|
+
setup_interface
|
46
|
+
setup_mpv
|
47
|
+
puts "[ready]"
|
48
|
+
play_random_album
|
49
|
+
@dispatcher.run
|
50
|
+
end
|
51
|
+
|
52
|
+
def setup_mpv
|
53
|
+
@mpv = MPVClient.new(
|
54
|
+
'mpv-log-level' => @mpv_log_level,
|
55
|
+
'audio-device' => @audio_device,
|
56
|
+
'audio-display' => 'no',
|
57
|
+
'vo' => 'null',
|
58
|
+
'volume' => 100)
|
59
|
+
@mpv.register_event('log-message') do |event|
|
60
|
+
;;pp(log: event)
|
61
|
+
end
|
62
|
+
@mpv.command('request_log_messages', @mpv_log_level) if @mpv_log_level
|
63
|
+
@properties = HashStruct.new
|
64
|
+
ObservedProperties.each do |name, key|
|
65
|
+
@mpv.observe_property(name) { |n, v| property_changed(n, v) }
|
66
|
+
end
|
67
|
+
if @equalizers
|
68
|
+
@equalizer_enabled = true
|
69
|
+
next_equalizer
|
70
|
+
end
|
71
|
+
@dispatcher.add_io_handler(input: @mpv.socket) do |io|
|
72
|
+
@mpv.process_response
|
73
|
+
end
|
74
|
+
@dispatcher.add_io_handler(exception: @mpv.socket) do |io|
|
75
|
+
shutdown_mpv
|
76
|
+
end
|
77
|
+
at_exit { shutdown_mpv }
|
78
|
+
end
|
79
|
+
|
80
|
+
def shutdown_mpv
|
81
|
+
if @mpv
|
82
|
+
@mpv.command('quit')
|
83
|
+
@dispatcher.remove_io_handler(input: @mpv.socket, exception: @mpv.socket)
|
84
|
+
@mpv.stop
|
85
|
+
@mpv = nil
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def setup_interface
|
90
|
+
@stty_old_params = `stty -g`.chomp
|
91
|
+
at_exit { system('stty', @stty_old_params) }
|
92
|
+
system('stty', 'cbreak', '-echo')
|
93
|
+
@dispatcher.add_io_handler(input: STDIN) do |io|
|
94
|
+
key = io.sysread(1)
|
95
|
+
if (command = Keymap[key])
|
96
|
+
puts "[#{command_description(command)}]"
|
97
|
+
send(command)
|
98
|
+
else
|
99
|
+
puts "unknown key: %p" % key
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def read_albums
|
105
|
+
@album_for_track_path = {}
|
106
|
+
@albums.each do |album|
|
107
|
+
album.tracks.each do |track|
|
108
|
+
@album_for_track_path[track.path] = album
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def random_album
|
114
|
+
@albums.shuffle.first
|
115
|
+
end
|
116
|
+
|
117
|
+
def random_tracks(length:)
|
118
|
+
tracks = Set.new
|
119
|
+
while tracks.length < length
|
120
|
+
tracks << random_album.tracks.shuffle.first
|
121
|
+
end
|
122
|
+
tracks.to_a
|
123
|
+
end
|
124
|
+
|
125
|
+
def playlist_changed(value)
|
126
|
+
@current_track = @playlist = nil
|
127
|
+
if @properties.playlist
|
128
|
+
@playlist = @properties.playlist.map do |entry|
|
129
|
+
track_path = Path.new(entry.filename)
|
130
|
+
album = @album_for_track_path[track_path] \
|
131
|
+
or raise Error, "Can't determine album for track file: #{track_path}"
|
132
|
+
track = album.tracks.find { |t| t.path == track_path } \
|
133
|
+
or raise Error, "Can't determine track for track file: #{track_path}"
|
134
|
+
@current_track = track if entry.current
|
135
|
+
track
|
136
|
+
end
|
137
|
+
end
|
138
|
+
show_playlist
|
139
|
+
end
|
140
|
+
|
141
|
+
#
|
142
|
+
# commands called by interface
|
143
|
+
#
|
144
|
+
|
145
|
+
def quit
|
146
|
+
Kernel.exit(0)
|
147
|
+
end
|
148
|
+
|
149
|
+
def play_next_track
|
150
|
+
if @properties.playlist_position && @properties.playlist_position < @properties.playlist.count - 1
|
151
|
+
@mpv.command('playlist-next')
|
152
|
+
else
|
153
|
+
puts 'no next track'
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def play_previous_track
|
158
|
+
if @properties.playlist_position && @properties.playlist_position > 0
|
159
|
+
@mpv.command('playlist-prev')
|
160
|
+
else
|
161
|
+
puts 'no previous track'
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def play_random_album
|
166
|
+
play_tracks(random_album.tracks)
|
167
|
+
end
|
168
|
+
|
169
|
+
def play_random_tracks
|
170
|
+
play_tracks(random_tracks(length: 10))
|
171
|
+
end
|
172
|
+
|
173
|
+
def play_album_for_current_track
|
174
|
+
if @properties.playlist_position
|
175
|
+
entry = @properties.playlist[@properties.playlist_position]
|
176
|
+
track_path = Path.new(entry.filename)
|
177
|
+
album = @album_for_track_path[track_path] \
|
178
|
+
or raise Error, "Can't determine album for track file: #{track_path}"
|
179
|
+
play_tracks(album.tracks)
|
180
|
+
else
|
181
|
+
puts "no current track"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def toggle_pause
|
186
|
+
@mpv.set_property('pause', !@properties.pause_state)
|
187
|
+
end
|
188
|
+
|
189
|
+
def skip_backward
|
190
|
+
if @properties.time_position && @properties.time_position > 0
|
191
|
+
@mpv.command('seek', -SeekSeconds)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def skip_forward
|
196
|
+
if @properties.time_position
|
197
|
+
@mpv.command('seek', SeekSeconds)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def skip_to_beginning
|
202
|
+
if @properties.time_position && @properties.time_position > 0
|
203
|
+
@mpv.command('seek', 0, 'absolute-percent')
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def show_playlist
|
208
|
+
if @playlist
|
209
|
+
system('clear')
|
210
|
+
if @current_track
|
211
|
+
@current_track.album.show_cover(width: 'auto', height: 20, preserve_aspect_ratio: false)
|
212
|
+
puts
|
213
|
+
end
|
214
|
+
@playlist.each_with_index do |track, i|
|
215
|
+
puts '%1s %2d. %-40.40s | %-40.40s | %-40.40s' % [
|
216
|
+
track == @current_track ? '>' : '',
|
217
|
+
i + 1,
|
218
|
+
track.title,
|
219
|
+
track.album.title,
|
220
|
+
track.album.artist,
|
221
|
+
]
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def show_keymap
|
227
|
+
Keymap.each do |key, command|
|
228
|
+
puts "%-8s %s" % [key_description(key), command_description(command)]
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def play_tracks(tracks)
|
233
|
+
@playlist_file.dirname.mkpath
|
234
|
+
@playlist_file.write(tracks.map(&:path).join("\n"))
|
235
|
+
@mpv.command('loadlist', @playlist_file.to_s)
|
236
|
+
end
|
237
|
+
|
238
|
+
def toggle_equalizer
|
239
|
+
@equalizer_enabled = !@equalizer_enabled
|
240
|
+
set_current_equalizer
|
241
|
+
end
|
242
|
+
|
243
|
+
def next_equalizer
|
244
|
+
if @equalizers
|
245
|
+
@current_equalizer &&= @equalizers[@equalizers.index(@current_equalizer) + 1]
|
246
|
+
@current_equalizer ||= @equalizers.first
|
247
|
+
set_current_equalizer
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def set_current_equalizer
|
252
|
+
if @current_equalizer
|
253
|
+
puts "[equalizer: %s <%s>]" % [
|
254
|
+
@current_equalizer.name,
|
255
|
+
@equalizer_enabled ? 'enabled' : 'disabled',
|
256
|
+
]
|
257
|
+
@mpv.command('af', 'set', @current_equalizer.to_s(enabled: @equalizer_enabled))
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
#
|
262
|
+
# callbacks from MPV
|
263
|
+
#
|
264
|
+
|
265
|
+
def property_changed(name, value)
|
266
|
+
# ;;pp(name => value) unless name == 'time-pos'
|
267
|
+
key = ObservedProperties[name] or raise
|
268
|
+
@properties[key] = value
|
269
|
+
send("#{key}_changed", value) rescue NoMethodError
|
270
|
+
end
|
271
|
+
|
272
|
+
private
|
273
|
+
|
274
|
+
def key_description(key)
|
275
|
+
case key
|
276
|
+
when ' '
|
277
|
+
'space'
|
278
|
+
else
|
279
|
+
key
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
def command_description(command)
|
284
|
+
command.to_s.gsub('_', ' ')
|
285
|
+
end
|
286
|
+
|
287
|
+
end
|
288
|
+
|
289
|
+
end
|