wavesync 1.0.0.alpha2 → 1.0.0.alpha4
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 +4 -4
- data/README.md +95 -59
- data/config/devices.yml +20 -0
- data/lib/wavesync/acid_chunk.rb +45 -4
- data/lib/wavesync/analyzer.rb +17 -4
- data/lib/wavesync/audio.rb +153 -90
- data/lib/wavesync/audio_format.rb +9 -2
- data/lib/wavesync/bpm_detector.rb +14 -7
- data/lib/wavesync/cli.rb +3 -0
- data/lib/wavesync/commands/analyze.rb +2 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +4 -1
- data/lib/wavesync/commands/help.rb +6 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +19 -26
- data/lib/wavesync/commands.rb +52 -12
- data/lib/wavesync/config.rb +43 -3
- data/lib/wavesync/cue_chunk.rb +203 -0
- data/lib/wavesync/device.rb +32 -7
- data/lib/wavesync/essentia_bpm_detector.rb +38 -0
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +7 -2
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +32 -6
- data/lib/wavesync/percival_bpm_detector.rb +31 -0
- data/lib/wavesync/python_venv.rb +25 -0
- data/lib/wavesync/scanner.rb +143 -27
- data/lib/wavesync/{set.rb → setlist.rb} +28 -12
- data/lib/wavesync/setlist_editor.rb +556 -0
- data/lib/wavesync/track_padding.rb +15 -4
- data/lib/wavesync/transport/filesystem.rb +36 -0
- data/lib/wavesync/transport/mtp.rb +285 -0
- data/lib/wavesync/transport.rb +21 -0
- data/lib/wavesync/ui.rb +67 -12
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +7 -2
- metadata +17 -32
- data/lib/wavesync/commands/set.rb +0 -63
- data/lib/wavesync/set_editor.rb +0 -245
data/lib/wavesync/audio.rb
CHANGED
|
@@ -1,52 +1,131 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
|
-
require 'streamio-ffmpeg'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require 'tmpdir'
|
|
6
6
|
require 'fileutils'
|
|
7
|
-
|
|
7
|
+
require_relative 'logger'
|
|
8
8
|
|
|
9
9
|
module Wavesync
|
|
10
10
|
class Audio
|
|
11
11
|
SUPPORTED_FORMATS = %w[.m4a .mp3 .wav .aif .aiff].freeze
|
|
12
12
|
|
|
13
|
+
#: (String library_path) -> Array[String]
|
|
13
14
|
def self.find_all(library_path)
|
|
14
15
|
Dir.glob(File.join(library_path, '**', '*'))
|
|
15
|
-
.select { |
|
|
16
|
+
.select { |file| SUPPORTED_FORMATS.include?(File.extname(file).downcase) }
|
|
17
|
+
.sort_by(&:downcase)
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
#: (String file_path) -> void
|
|
18
21
|
def initialize(file_path)
|
|
19
|
-
@file_path = file_path
|
|
20
|
-
@file_ext = File.extname(@file_path).downcase
|
|
21
|
-
@audio = FFMPEG::
|
|
22
|
+
@file_path = file_path #: String
|
|
23
|
+
@file_ext = File.extname(@file_path).downcase #: String
|
|
24
|
+
@audio = Wavesync::FFMPEG::Probe.new(file_path) #: Wavesync::FFMPEG::Probe
|
|
22
25
|
end
|
|
23
26
|
|
|
27
|
+
#: () -> Float
|
|
24
28
|
def duration
|
|
25
29
|
@audio.duration
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
#: () -> Integer?
|
|
28
33
|
def sample_rate
|
|
29
|
-
@sample_rate ||= @audio.
|
|
34
|
+
@sample_rate ||= @audio.sample_rate
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
#: () -> Integer?
|
|
32
38
|
def bit_depth
|
|
33
|
-
@bit_depth ||=
|
|
39
|
+
@bit_depth ||= @audio.bit_depth
|
|
34
40
|
end
|
|
35
41
|
|
|
42
|
+
#: () -> Integer?
|
|
43
|
+
def bitrate
|
|
44
|
+
@bitrate ||= @audio.bitrate
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: () -> (String | Integer)?
|
|
36
48
|
def bpm
|
|
37
49
|
return @bpm if defined?(@bpm)
|
|
38
50
|
|
|
39
51
|
@bpm = bpm_from_file
|
|
40
52
|
end
|
|
41
53
|
|
|
54
|
+
#: () -> AudioFormat
|
|
42
55
|
def format
|
|
43
56
|
AudioFormat.new(
|
|
44
57
|
file_type: @file_ext.delete_prefix('.'),
|
|
45
58
|
sample_rate: sample_rate,
|
|
46
|
-
bit_depth: bit_depth
|
|
59
|
+
bit_depth: bit_depth,
|
|
60
|
+
bitrate: bitrate
|
|
47
61
|
)
|
|
48
62
|
end
|
|
49
63
|
|
|
64
|
+
#: () -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
|
|
65
|
+
def cue_points
|
|
66
|
+
return [] unless @file_ext == '.wav'
|
|
67
|
+
|
|
68
|
+
CueChunk.read(@file_path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
|
|
72
|
+
def write_cue_points(cue_points)
|
|
73
|
+
temp_path = "#{@file_path}.tmp"
|
|
74
|
+
CueChunk.write(@file_path, temp_path, cue_points)
|
|
75
|
+
FileUtils.mv(temp_path, @file_path)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
ID3V2_FRAME_TITLE = 'TIT2'
|
|
79
|
+
ID3V2_FRAME_ARTIST = 'TPE1'
|
|
80
|
+
ID3V2_FRAME_ALBUM = 'TALB'
|
|
81
|
+
ID3V2_FRAME_ALBUM_ARTIST = 'TPE2'
|
|
82
|
+
ID3V2_FRAME_GENRE = 'TCON'
|
|
83
|
+
ID3V2_FRAME_COMPOSER = 'TCOM'
|
|
84
|
+
ID3V2_FRAME_ENCODED_BY = 'TENC'
|
|
85
|
+
ID3V2_FRAME_COMPILATION = 'TCMP'
|
|
86
|
+
|
|
87
|
+
FRAME_ID_TO_FFMPEG_KEY = {
|
|
88
|
+
ID3V2_FRAME_TITLE => 'title',
|
|
89
|
+
ID3V2_FRAME_ARTIST => 'artist',
|
|
90
|
+
ID3V2_FRAME_ALBUM => 'album',
|
|
91
|
+
ID3V2_FRAME_ALBUM_ARTIST => 'album_artist',
|
|
92
|
+
ID3V2_FRAME_GENRE => 'genre',
|
|
93
|
+
ID3V2_FRAME_COMPOSER => 'composer',
|
|
94
|
+
ID3V2_FRAME_ENCODED_BY => 'encoded_by',
|
|
95
|
+
ID3V2_FRAME_COMPILATION => 'compilation'
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
FFMPEG_KEY_TO_FRAME_ID = FRAME_ID_TO_FFMPEG_KEY.invert.freeze
|
|
99
|
+
|
|
100
|
+
COMBINING_MARKS = /\p{Mn}/
|
|
101
|
+
|
|
102
|
+
#: () -> Hash[String, String]
|
|
103
|
+
def transliterated_tag_changes
|
|
104
|
+
current_tags = @audio.tags
|
|
105
|
+
changes = {} #: Hash[String, String]
|
|
106
|
+
|
|
107
|
+
FRAME_ID_TO_FFMPEG_KEY.each_value do |ffmpeg_key|
|
|
108
|
+
current_value = find_in_tags(current_tags, ffmpeg_key)
|
|
109
|
+
next if current_value.nil?
|
|
110
|
+
|
|
111
|
+
transliterated = transliterate(current_value)
|
|
112
|
+
changes[ffmpeg_key] = transliterated if transliterated != current_value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
changes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#: () -> void
|
|
119
|
+
def transliterate_tags
|
|
120
|
+
return unless @file_ext == '.mp3'
|
|
121
|
+
|
|
122
|
+
changes = transliterated_tag_changes
|
|
123
|
+
return if changes.empty?
|
|
124
|
+
|
|
125
|
+
write_file_metadata(changes)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: (String | Integer | Float bpm) -> void
|
|
50
129
|
def write_bpm(bpm)
|
|
51
130
|
case @file_ext
|
|
52
131
|
when '.m4a'
|
|
@@ -61,20 +140,26 @@ module Wavesync
|
|
|
61
140
|
@bpm = bpm
|
|
62
141
|
end
|
|
63
142
|
|
|
64
|
-
|
|
65
|
-
|
|
143
|
+
#: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?, ?metadata: Hash[String, String], ?target_bitrate: Integer) ?{ (String) -> void } -> bool
|
|
144
|
+
def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil, metadata: {}, target_bitrate: 192)
|
|
66
145
|
ext = target_file_type || @file_ext.delete_prefix('.')
|
|
67
|
-
temp_path = File.join(
|
|
68
|
-
Dir.tmpdir,
|
|
69
|
-
"wavesync_transcode_#{SecureRandom.hex}.#{ext}"
|
|
70
|
-
)
|
|
146
|
+
temp_path = File.join(Dir.tmpdir, "wavesync_transcode_#{SecureRandom.hex}.#{ext}")
|
|
71
147
|
|
|
72
148
|
begin
|
|
73
|
-
@
|
|
149
|
+
command = Wavesync::FFMPEG.new.input(@file_path).audio_codec(transcode_codec(ext, target_bit_depth))
|
|
150
|
+
command.audio_bitrate("#{target_bitrate}k") if ext == 'mp3'
|
|
151
|
+
command.sample_rate(target_sample_rate) if target_sample_rate
|
|
152
|
+
if padding_seconds&.positive?
|
|
153
|
+
total_duration = @audio.duration + padding_seconds
|
|
154
|
+
command.audio_filter("apad=whole_dur=#{total_duration.round(6)}")
|
|
155
|
+
end
|
|
156
|
+
metadata.each { |key, value| command.metadata(key, value) }
|
|
157
|
+
command.run(temp_path)
|
|
158
|
+
yield temp_path if block_given?
|
|
74
159
|
FileUtils.install(temp_path, target_path)
|
|
75
160
|
true
|
|
76
|
-
rescue Errno::ENOENT
|
|
77
|
-
|
|
161
|
+
rescue Errno::ENOENT => e
|
|
162
|
+
Logger.log_error(e, call_site: 'Audio#transcode', arguments: { target_path:, target_sample_rate:, target_file_type:, target_bit_depth:, padding_seconds:, target_bitrate: })
|
|
78
163
|
false
|
|
79
164
|
ensure
|
|
80
165
|
FileUtils.rm_f(temp_path)
|
|
@@ -83,39 +168,14 @@ module Wavesync
|
|
|
83
168
|
|
|
84
169
|
private
|
|
85
170
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
audio_stream = data[:streams].find { |s| s[:codec_type] == 'audio' }
|
|
91
|
-
return nil unless audio_stream
|
|
92
|
-
|
|
93
|
-
bits_per_sample = audio_stream[:bits_per_sample]
|
|
94
|
-
return bits_per_sample if bits_per_sample&.positive?
|
|
95
|
-
|
|
96
|
-
nil
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
|
|
100
|
-
options = { custom: %w[-loglevel warning -nostats -hide_banner] }
|
|
101
|
-
|
|
102
|
-
if target_bit_depth == 24
|
|
103
|
-
options[:audio_codec] = 'pcm_s24le'
|
|
104
|
-
elsif target_bit_depth == 16
|
|
105
|
-
options[:audio_codec] = 'pcm_s16le'
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
options[:audio_codec] = 'pcm_s24le'
|
|
109
|
-
options[:audio_sample_rate] = target_sample_rate if target_sample_rate
|
|
171
|
+
#: (String target_file_type, Integer? target_bit_depth) -> String
|
|
172
|
+
def transcode_codec(target_file_type, target_bit_depth)
|
|
173
|
+
return 'libmp3lame' if target_file_type == 'mp3'
|
|
110
174
|
|
|
111
|
-
|
|
112
|
-
total_duration = @audio.duration + padding_seconds
|
|
113
|
-
options[:custom] += ['-af', "apad=whole_dur=#{total_duration.round(6)}"]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
options
|
|
175
|
+
target_bit_depth == 16 ? 'pcm_s16le' : 'pcm_s24le'
|
|
117
176
|
end
|
|
118
177
|
|
|
178
|
+
#: () -> (String | Integer)?
|
|
119
179
|
def bpm_from_file
|
|
120
180
|
case @file_ext
|
|
121
181
|
when '.m4a'
|
|
@@ -129,84 +189,87 @@ module Wavesync
|
|
|
129
189
|
end
|
|
130
190
|
end
|
|
131
191
|
|
|
192
|
+
#: () -> Integer?
|
|
132
193
|
def bpm_from_m4a
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
194
|
+
value = find_in_tags(@audio.tags, 'BPM')
|
|
195
|
+
return nil if value.nil?
|
|
196
|
+
|
|
197
|
+
int_value = value.to_i
|
|
198
|
+
int_value.zero? ? nil : int_value
|
|
137
199
|
end
|
|
138
200
|
|
|
201
|
+
#: () -> String?
|
|
139
202
|
def bpm_from_mp3
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return bpm_from_frame_list(tag) if tag
|
|
143
|
-
end
|
|
203
|
+
value = find_in_tags(@audio.tags, 'TBPM')
|
|
204
|
+
value&.then { |v| v.empty? ? nil : v }
|
|
144
205
|
end
|
|
145
206
|
|
|
207
|
+
#: () -> (String | Integer)?
|
|
146
208
|
def bpm_from_wav
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
bpm_from_frame_list = bpm_from_frame_list(tag) if tag
|
|
150
|
-
return bpm_from_frame_list if bpm_from_frame_list
|
|
151
|
-
end
|
|
209
|
+
value = find_in_tags(@audio.tags, 'TBPM')
|
|
210
|
+
return value if value && !value.empty?
|
|
152
211
|
|
|
153
212
|
bpm_from_acid_chunk
|
|
154
213
|
end
|
|
155
214
|
|
|
215
|
+
#: () -> String?
|
|
156
216
|
def bpm_from_aiff
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return bpm_from_frame_list(tag) if tag
|
|
160
|
-
end
|
|
217
|
+
value = find_in_tags(@audio.tags, 'TBPM')
|
|
218
|
+
value&.then { |v| v.empty? ? nil : v }
|
|
161
219
|
end
|
|
162
220
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def bpm_from_frame_list(tag)
|
|
169
|
-
tag.frame_list('TBPM').first&.to_s
|
|
221
|
+
#: (Hash[String, String] tags, String key) -> String?
|
|
222
|
+
def find_in_tags(tags, key)
|
|
223
|
+
tags.find { |k, _| k.casecmp(key).zero? }&.last
|
|
170
224
|
end
|
|
171
225
|
|
|
226
|
+
#: () -> Integer?
|
|
172
227
|
def bpm_from_acid_chunk
|
|
173
228
|
tmpo = Wavesync::AcidChunk.read_bpm(@file_path).to_i
|
|
174
229
|
tmpo&.zero? ? nil : tmpo
|
|
175
230
|
end
|
|
176
231
|
|
|
232
|
+
#: (String | Integer | Float bpm) -> void
|
|
177
233
|
def write_bpm_to_wav(bpm)
|
|
178
234
|
temp_path = "#{@file_path}.tmp"
|
|
179
235
|
AcidChunk.write_bpm(@file_path, temp_path, bpm)
|
|
180
236
|
FileUtils.mv(temp_path, @file_path)
|
|
181
237
|
end
|
|
182
238
|
|
|
239
|
+
#: (String | Integer | Float bpm) -> void
|
|
183
240
|
def write_bpm_to_mp3(bpm)
|
|
184
|
-
|
|
185
|
-
write_id3v2_bpm(file.id3v2_tag(true), bpm)
|
|
186
|
-
file.save
|
|
187
|
-
end
|
|
241
|
+
write_file_metadata('TBPM' => bpm.to_s)
|
|
188
242
|
end
|
|
189
243
|
|
|
244
|
+
#: (String | Integer | Float bpm) -> void
|
|
190
245
|
def write_bpm_to_m4a(bpm)
|
|
191
|
-
|
|
192
|
-
tag = file.tag
|
|
193
|
-
tag.item_map.insert('tmpo', TagLib::MP4::Item.from_int(bpm.to_i))
|
|
194
|
-
file.save
|
|
195
|
-
end
|
|
246
|
+
write_file_metadata('BPM' => bpm.to_i.to_s)
|
|
196
247
|
end
|
|
197
248
|
|
|
249
|
+
#: (String | Integer | Float bpm) -> void
|
|
198
250
|
def write_bpm_to_aiff(bpm)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
251
|
+
write_file_metadata('TBPM' => bpm.to_s)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
#: (Hash[String, String] metadata_hash) -> void
|
|
255
|
+
def write_file_metadata(metadata_hash)
|
|
256
|
+
ext = File.extname(@file_path)
|
|
257
|
+
temp_path = File.join(Dir.tmpdir, "wavesync_meta_#{SecureRandom.hex}#{ext}")
|
|
258
|
+
command = FFMPEG.new.input(@file_path).copy_streams.map_metadata(0)
|
|
259
|
+
command.movflags('+use_metadata_tags') if ext == '.m4a'
|
|
260
|
+
command.write_id3v2(1) if %w[.aif .aiff].include?(ext)
|
|
261
|
+
metadata_hash.each { |key, value| command.metadata(key, value) }
|
|
262
|
+
command.run(temp_path)
|
|
263
|
+
FileUtils.mv(temp_path, @file_path)
|
|
264
|
+
ensure
|
|
265
|
+
FileUtils.rm_f(temp_path)
|
|
203
266
|
end
|
|
204
267
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
268
|
+
#: (String string) -> String
|
|
269
|
+
def transliterate(string)
|
|
270
|
+
string
|
|
271
|
+
.unicode_normalize(:nfd)
|
|
272
|
+
.gsub(COMBINING_MARKS, '')
|
|
210
273
|
end
|
|
211
274
|
end
|
|
212
275
|
end
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Wavesync
|
|
4
|
-
AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth) do
|
|
4
|
+
AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth, :bitrate) do
|
|
5
|
+
#: (file_type: String?, sample_rate: Integer?, bit_depth: Integer?, ?bitrate: Integer?) -> void
|
|
6
|
+
def initialize(file_type:, sample_rate:, bit_depth:, bitrate: nil)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
#: (AudioFormat other) -> AudioFormat
|
|
5
11
|
def merge(other)
|
|
6
12
|
with(
|
|
7
13
|
file_type: other.file_type || file_type,
|
|
8
14
|
sample_rate: other.sample_rate || sample_rate,
|
|
9
|
-
bit_depth: other.bit_depth || bit_depth
|
|
15
|
+
bit_depth: other.bit_depth || bit_depth,
|
|
16
|
+
bitrate: other.bitrate || bitrate
|
|
10
17
|
)
|
|
11
18
|
end
|
|
12
19
|
end
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
require_relative 'essentia_bpm_detector'
|
|
5
|
+
require_relative 'percival_bpm_detector'
|
|
4
6
|
|
|
5
7
|
module Wavesync
|
|
6
8
|
class BpmDetector
|
|
9
|
+
CONFIDENCE_THRESHOLD = 2.0
|
|
10
|
+
|
|
11
|
+
#: () -> bool?
|
|
7
12
|
def self.available?
|
|
8
|
-
|
|
13
|
+
EssentiaBpmDetector.available? || PercivalBpmDetector.available?
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
#: (String file_path) -> Integer?
|
|
11
17
|
def self.detect(file_path)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
if EssentiaBpmDetector.available?
|
|
19
|
+
essentia_result = EssentiaBpmDetector.detect(file_path)
|
|
20
|
+
return essentia_result[:bpm] if essentia_result && essentia_result[:confidence] > CONFIDENCE_THRESHOLD
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
PercivalBpmDetector.detect(file_path) if PercivalBpmDetector.available?
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
end
|
data/lib/wavesync/cli.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require_relative 'commands'
|
|
4
5
|
|
|
5
6
|
module Wavesync
|
|
6
7
|
class CLI
|
|
8
|
+
#: () -> void
|
|
7
9
|
def self.start
|
|
10
|
+
Logger.capture_invocation(ARGV.dup)
|
|
8
11
|
command_name = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
|
|
9
12
|
command_class = Commands::ALL.find { |cmd| command_name == cmd.name }
|
|
10
13
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ module Wavesync
|
|
|
11
12
|
self.description = 'Detect and write BPM metadata to library tracks'
|
|
12
13
|
self.options = [FORCE_OPTION].freeze
|
|
13
14
|
|
|
15
|
+
#: () -> void
|
|
14
16
|
def run
|
|
15
17
|
options, config = parse_options(banner: 'Usage: wavesync analyze [options]') do |opts, opts_hash|
|
|
16
18
|
opts.on(*FORCE_OPTION.to_a) { opts_hash[:overwrite] = true }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'optparse'
|
|
6
|
+
require_relative '../transport'
|
|
7
|
+
|
|
8
|
+
module Wavesync
|
|
9
|
+
module Commands
|
|
10
|
+
class ClearCache < Command
|
|
11
|
+
DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Clear cache for a specific device only')
|
|
12
|
+
|
|
13
|
+
self.name = 'clear-cache'
|
|
14
|
+
self.description = 'Delete the on-disk staging cache used for MTP devices'
|
|
15
|
+
self.options = [DEVICE_OPTION].freeze
|
|
16
|
+
|
|
17
|
+
#: () -> void
|
|
18
|
+
def run
|
|
19
|
+
options, config = parse_options(banner: 'Usage: wavesync clear-cache [options]') do |opts, opts_hash|
|
|
20
|
+
opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
mtp_devices = config.device_configs.select { |device_config| device_config[:transport] == 'mtp' }
|
|
24
|
+
if options[:device]
|
|
25
|
+
mtp_devices = mtp_devices.select { |device_config| device_config[:name] == options[:device] }
|
|
26
|
+
if mtp_devices.empty?
|
|
27
|
+
puts "No MTP device named \"#{options[:device]}\" found in config."
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if mtp_devices.empty?
|
|
33
|
+
puts 'No MTP devices configured.'
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
mtp_devices.each { |device_config| clear_one(device_config[:name]) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
#: (String device_name) -> void
|
|
43
|
+
def clear_one(device_name)
|
|
44
|
+
path = Wavesync::Transport::Mtp.cache_path(device_name)
|
|
45
|
+
unless File.directory?(path)
|
|
46
|
+
puts "No cache for #{device_name} at #{path}"
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
size_bytes = directory_size(path)
|
|
51
|
+
FileUtils.rm_rf(path)
|
|
52
|
+
puts "Cleared #{path} (#{format_bytes(size_bytes)})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: (String path) -> Integer
|
|
56
|
+
def directory_size(path)
|
|
57
|
+
Dir.glob(File.join(path, '**', '*'))
|
|
58
|
+
.reject { |entry| File.directory?(entry) }
|
|
59
|
+
.sum { |entry| File.size(entry) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (Integer bytes) -> String
|
|
63
|
+
def format_bytes(bytes)
|
|
64
|
+
units = %w[B KB MB GB TB]
|
|
65
|
+
size = bytes.to_f
|
|
66
|
+
unit_index = 0
|
|
67
|
+
while size >= 1024 && unit_index < units.length - 1
|
|
68
|
+
size /= 1024
|
|
69
|
+
unit_index += 1
|
|
70
|
+
end
|
|
71
|
+
unit_index.zero? ? "#{bytes} #{units[unit_index]}" : format('%<size>.1f %<unit>s', size: size, unit: units[unit_index])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -13,8 +13,9 @@ module Wavesync
|
|
|
13
13
|
def subcommands = @subcommands || []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
#: (banner: String) ?{ (OptionParser, Hash[Symbol, untyped]) -> void } -> [Hash[Symbol, untyped], Config]
|
|
16
17
|
def parse_options(banner:)
|
|
17
|
-
options = {}
|
|
18
|
+
options = {} #: Hash[Symbol, untyped]
|
|
18
19
|
OptionParser.new do |opts|
|
|
19
20
|
opts.banner = banner
|
|
20
21
|
opts.on(*CONFIG_OPTION.to_a) { |value| options[:config] = value }
|
|
@@ -22,6 +23,8 @@ module Wavesync
|
|
|
22
23
|
end.parse!
|
|
23
24
|
config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
|
|
24
25
|
config = Commands.load_config(config_path)
|
|
26
|
+
Logger.configure(config.library)
|
|
27
|
+
Logger.log_invocation
|
|
25
28
|
[options, config]
|
|
26
29
|
end
|
|
27
30
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
require 'rainbow'
|
|
@@ -11,6 +12,7 @@ module Wavesync
|
|
|
11
12
|
|
|
12
13
|
DESCRIPTION_COLUMN = 23
|
|
13
14
|
|
|
15
|
+
#: () -> void
|
|
14
16
|
def run
|
|
15
17
|
subcommand_name = ARGV.shift
|
|
16
18
|
|
|
@@ -30,6 +32,7 @@ module Wavesync
|
|
|
30
32
|
|
|
31
33
|
private
|
|
32
34
|
|
|
35
|
+
#: () -> void
|
|
33
36
|
def show_general_help
|
|
34
37
|
puts 'Usage: wavesync [command] [options]'
|
|
35
38
|
puts ''
|
|
@@ -47,6 +50,7 @@ module Wavesync
|
|
|
47
50
|
GLOBAL_OPTIONS.each { |option| puts format_option_line(option, indent: 2) }
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
#: (untyped command) -> void
|
|
50
54
|
def show_command_help(command)
|
|
51
55
|
if command.subcommands.any?
|
|
52
56
|
puts "Usage: wavesync #{command.name} <subcommand> [options]"
|
|
@@ -68,10 +72,12 @@ module Wavesync
|
|
|
68
72
|
end
|
|
69
73
|
end
|
|
70
74
|
|
|
75
|
+
#: (String name, String description) -> String
|
|
71
76
|
def format_command_line(name, description)
|
|
72
77
|
" #{name.ljust(DESCRIPTION_COLUMN - 2)}#{description}"
|
|
73
78
|
end
|
|
74
79
|
|
|
80
|
+
#: (untyped option, indent: Integer) -> String
|
|
75
81
|
def format_option_line(option, indent:)
|
|
76
82
|
key = "#{option.short}, #{option.long}"
|
|
77
83
|
Rainbow("#{' ' * indent}#{key.ljust(DESCRIPTION_COLUMN - indent)}#{option.description}").darkgray
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative '../transport'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
module Commands
|
|
9
|
+
class Pull < Command
|
|
10
|
+
DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Name of device to pull from (as defined in config)')
|
|
11
|
+
|
|
12
|
+
self.name = 'pull'
|
|
13
|
+
self.description = 'Read cue points from device files and write them back into the source library'
|
|
14
|
+
self.options = [DEVICE_OPTION].freeze
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def run
|
|
18
|
+
options, config = parse_options(banner: 'Usage: wavesync pull [options]') do |opts, opts_hash|
|
|
19
|
+
opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
device_pairs = Commands.resolve_device_pairs(config, device_name: options[:device])
|
|
23
|
+
scanner = Wavesync::Scanner.new(config.library)
|
|
24
|
+
ui = Wavesync::UI.new
|
|
25
|
+
|
|
26
|
+
device_pairs.each do |pair|
|
|
27
|
+
device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
|
|
28
|
+
device = pair[1] #: Wavesync::Device
|
|
29
|
+
transport = Wavesync::Transport.for(device_config)
|
|
30
|
+
Commands.with_mtp_retry(transport, device_config[:name]) do
|
|
31
|
+
next unless transport.is_a?(Wavesync::Transport::Mtp)
|
|
32
|
+
|
|
33
|
+
transport.prepare! do |index, total, relative_path|
|
|
34
|
+
ui.pull_staging_progress(index, total, device)
|
|
35
|
+
ui.file_progress(relative_path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
scanner.pull_cue_points(transport.working_directory, device)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
module Wavesync
|
|
7
|
+
module Commands
|
|
8
|
+
class Setlist < Command
|
|
9
|
+
self.name = 'setlist'
|
|
10
|
+
self.subcommands = [
|
|
11
|
+
Subcommand.new(usage: 'setlist create NAME', description: 'Create a new setlist'),
|
|
12
|
+
Subcommand.new(usage: 'setlist edit NAME', description: 'Edit an existing setlist'),
|
|
13
|
+
Subcommand.new(usage: 'setlist list', description: 'List all setlists')
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def run
|
|
18
|
+
subcommand = ARGV.shift
|
|
19
|
+
|
|
20
|
+
_options, config = parse_options(banner: 'Usage: wavesync setlist <subcommand> [options]')
|
|
21
|
+
|
|
22
|
+
case subcommand
|
|
23
|
+
when 'create'
|
|
24
|
+
name = require_name('create')
|
|
25
|
+
if Wavesync::Setlist.exists?(config.library, name)
|
|
26
|
+
puts "Setlist '#{name}' already exists. Use 'wavesync setlist edit #{name}' to edit it."
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
setlist = Wavesync::Setlist.new(config.library, name)
|
|
30
|
+
Wavesync::SetlistEditor.new(setlist, config.library).run
|
|
31
|
+
when 'edit'
|
|
32
|
+
name = require_name('edit')
|
|
33
|
+
unless Wavesync::Setlist.exists?(config.library, name)
|
|
34
|
+
puts "Setlist '#{name}' not found. Use 'wavesync setlist create #{name}' to create it."
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
setlist = Wavesync::Setlist.load(config.library, name)
|
|
38
|
+
Wavesync::SetlistEditor.new(setlist, config.library).run
|
|
39
|
+
when 'list'
|
|
40
|
+
setlists = Wavesync::Setlist.all(config.library)
|
|
41
|
+
if setlists.empty?
|
|
42
|
+
puts 'No setlists found.'
|
|
43
|
+
else
|
|
44
|
+
setlists.each { |setlist| puts "#{setlist.name} (#{setlist.tracks.size} tracks)" }
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
puts "Unknown subcommand: #{subcommand || '(none)'}"
|
|
48
|
+
puts 'Available subcommands: create, edit, list'
|
|
49
|
+
exit 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
#: (String subcommand) -> String
|
|
56
|
+
def require_name(subcommand)
|
|
57
|
+
name = ARGV.shift
|
|
58
|
+
unless name
|
|
59
|
+
puts "Usage: wavesync setlist #{subcommand} <name>"
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|