wavesync 1.0.0.alpha3 → 1.0.0.beta1

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,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ module Wavesync
6
+ class Libmtp
7
+ class Error < StandardError; end
8
+ class DeviceNotFound < Error; end
9
+
10
+ INSTALL_HINT = 'Install libmtp with `brew install libmtp`'
11
+
12
+ DeviceFile = Data.define(:id, :filename, :size, :parent_id, :storage_id)
13
+ DeviceFolder = Data.define(:folder_id, :name, :parent_id, :storage_id)
14
+
15
+ LIBRARY_PATHS = [
16
+ '/usr/local/opt/libmtp/lib/libmtp.dylib',
17
+ '/opt/homebrew/opt/libmtp/lib/libmtp.dylib',
18
+ 'libmtp.dylib',
19
+ 'libmtp.so.9',
20
+ 'libmtp.so'
21
+ ].freeze
22
+
23
+ module FFIBindings
24
+ extend FFI::Library
25
+
26
+ begin
27
+ ffi_lib LIBRARY_PATHS
28
+ rescue LoadError
29
+ # Library missing; instance methods raise via ensure_loaded! when used.
30
+ end
31
+
32
+ LIBMTP_STORAGE_SORTBY_NOTSORTED = 0
33
+
34
+ LIBMTP_FILETYPE_WAV = 1
35
+ LIBMTP_FILETYPE_MP3 = 2
36
+ LIBMTP_FILETYPE_OGG = 4
37
+ LIBMTP_FILETYPE_AAC = 30
38
+ LIBMTP_FILETYPE_FLAC = 32
39
+ LIBMTP_FILETYPE_M4A = 34
40
+ LIBMTP_FILETYPE_UNKNOWN = 44
41
+
42
+ class DeviceEntry < FFI::Struct
43
+ layout :vendor, :pointer,
44
+ :vendor_id, :uint16,
45
+ :product, :pointer,
46
+ :product_id, :uint16,
47
+ :device_flags, :uint32
48
+ end
49
+
50
+ class RawDevice < FFI::Struct
51
+ layout :device_entry, DeviceEntry,
52
+ :bus_location, :uint32,
53
+ :devnum, :uint8
54
+ end
55
+
56
+ class Folder < FFI::Struct
57
+ layout :folder_id, :uint32,
58
+ :parent_id, :uint32,
59
+ :storage_id, :uint32,
60
+ :name, :pointer,
61
+ :sibling, :pointer,
62
+ :child, :pointer
63
+ end
64
+
65
+ class File < FFI::Struct
66
+ layout :item_id, :uint32,
67
+ :parent_id, :uint32,
68
+ :storage_id, :uint32,
69
+ :filename, :pointer,
70
+ :filesize, :uint64,
71
+ :modificationdate, :long,
72
+ :filetype, :int,
73
+ :next, :pointer
74
+ end
75
+
76
+ class DeviceStorage < FFI::Struct
77
+ layout :id, :uint32,
78
+ :storage_type, :uint16,
79
+ :filesystem_type, :uint16,
80
+ :access_capability, :uint16,
81
+ :max_capacity, :uint64,
82
+ :free_space_in_bytes, :uint64,
83
+ :free_space_in_objects, :uint64,
84
+ :storage_description, :pointer,
85
+ :volume_identifier, :pointer,
86
+ :next, :pointer,
87
+ :prev, :pointer
88
+ end
89
+
90
+ class MtpDevice < FFI::Struct
91
+ layout :object_bitsize, :uint8,
92
+ :params, :pointer,
93
+ :usbinfo, :pointer,
94
+ :storage, :pointer
95
+ end
96
+
97
+ def self.bind!
98
+ return if @bound
99
+
100
+ attach_function :LIBMTP_Init, [], :void
101
+ attach_function :LIBMTP_Detect_Raw_Devices, %i[pointer pointer], :int
102
+ attach_function :LIBMTP_Open_Raw_Device, [:pointer], :pointer
103
+ attach_function :LIBMTP_Get_Storage, %i[pointer int], :int
104
+ attach_function :LIBMTP_Get_Folder_List_For_Storage, %i[pointer uint32], :pointer
105
+ attach_function :LIBMTP_Get_Filelisting_With_Callback, %i[pointer pointer pointer], :pointer
106
+ attach_function :LIBMTP_Send_File_From_File, %i[pointer string pointer pointer pointer], :int
107
+ attach_function :LIBMTP_Create_Folder, %i[pointer pointer uint32 uint32], :uint32
108
+ attach_function :LIBMTP_Delete_Object, %i[pointer uint32], :int
109
+ attach_function :LIBMTP_Get_File_To_File, %i[pointer uint32 string pointer pointer], :int
110
+ attach_function :LIBMTP_destroy_folder_t, [:pointer], :void
111
+ attach_function :LIBMTP_destroy_file_t, [:pointer], :void
112
+ attach_function :LIBMTP_Release_Device, [:pointer], :void
113
+ attach_function :LIBMTP_FreeMemory, [:pointer], :void
114
+ attach_function :LIBMTP_Dump_Errorstack, [:pointer], :void
115
+ attach_function :LIBMTP_Clear_Errorstack, [:pointer], :void
116
+ @bound = true
117
+ end
118
+ end
119
+
120
+ @library_initialized = false
121
+
122
+ class << self
123
+ #: () -> void
124
+ def init_library!
125
+ return if @library_initialized
126
+
127
+ FFIBindings.bind!
128
+ FFIBindings.LIBMTP_Init
129
+ @library_initialized = true
130
+ end
131
+ end
132
+
133
+ #: () -> void
134
+ def initialize
135
+ @device_handle = nil #: FFI::Pointer?
136
+ @raw_devices_pointer = nil #: FFI::Pointer?
137
+ end
138
+
139
+ #: () -> bool
140
+ def detected?
141
+ ensure_library_loaded!
142
+ raw_devices_pointer_pointer = FFI::MemoryPointer.new(:pointer)
143
+ count_pointer = FFI::MemoryPointer.new(:int)
144
+ result = FFIBindings.LIBMTP_Detect_Raw_Devices(raw_devices_pointer_pointer, count_pointer)
145
+ count = count_pointer.read_int
146
+ raw_devices_address = raw_devices_pointer_pointer.read_pointer
147
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_address) unless raw_devices_address.null?
148
+ result.zero? && count.positive?
149
+ rescue Error
150
+ false
151
+ end
152
+
153
+ #: () -> Array[DeviceFile]
154
+ def files
155
+ ensure_open!
156
+ list_head = FFIBindings.LIBMTP_Get_Filelisting_With_Callback(@device_handle, nil, nil)
157
+ result = walk_files(list_head)
158
+ FFIBindings.LIBMTP_destroy_file_t(list_head) unless list_head.null?
159
+ result
160
+ end
161
+
162
+ #: () -> Array[DeviceFolder]
163
+ def folders
164
+ ensure_open!
165
+ mtp_device = FFIBindings::MtpDevice.new(@device_handle)
166
+ result = [] #: Array[DeviceFolder]
167
+ storage_node = mtp_device[:storage]
168
+ until storage_node.null?
169
+ storage = FFIBindings::DeviceStorage.new(storage_node)
170
+ storage_id = storage[:id]
171
+ folder_root = FFIBindings.LIBMTP_Get_Folder_List_For_Storage(@device_handle, storage_id)
172
+ walk_folders(folder_root, storage_id, result) unless folder_root.null?
173
+ FFIBindings.LIBMTP_destroy_folder_t(folder_root) unless folder_root.null?
174
+ storage_node = storage[:next]
175
+ end
176
+ result
177
+ end
178
+
179
+ #: (local_path: String, remote_filename: String, parent_id: Integer, storage_id: Integer) -> void
180
+ def send_file(local_path:, remote_filename:, parent_id:, storage_id:)
181
+ ensure_open!
182
+ filesize = ::File.size(local_path)
183
+ filename_pointer = FFI::MemoryPointer.from_string(remote_filename)
184
+ file_metadata = FFIBindings::File.new
185
+ file_metadata[:item_id] = 0
186
+ file_metadata[:parent_id] = parent_id
187
+ file_metadata[:storage_id] = storage_id
188
+ file_metadata[:filename] = filename_pointer
189
+ file_metadata[:filesize] = filesize
190
+ file_metadata[:modificationdate] = 0
191
+ file_metadata[:filetype] = filetype_for(remote_filename)
192
+ result = FFIBindings.LIBMTP_Send_File_From_File(@device_handle, local_path, file_metadata.to_ptr, nil, nil)
193
+ raise_on_error!(result, "send_file failed for #{local_path}")
194
+ end
195
+
196
+ #: (name: String, parent_id: Integer, storage_id: Integer) -> Integer
197
+ def create_folder(name:, parent_id:, storage_id:)
198
+ ensure_open!
199
+ name_pointer = FFI::MemoryPointer.from_string(name)
200
+ new_id = FFIBindings.LIBMTP_Create_Folder(@device_handle, name_pointer, parent_id, storage_id)
201
+ raise Error, "create_folder failed for #{name.inspect}" if new_id.zero?
202
+
203
+ new_id
204
+ end
205
+
206
+ #: (id: Integer) -> void
207
+ def delete_file(id:)
208
+ ensure_open!
209
+ result = FFIBindings.LIBMTP_Delete_Object(@device_handle, id)
210
+ raise_on_error!(result, "delete_file failed for id #{id}")
211
+ end
212
+
213
+ #: (id: Integer, local_path: String) -> void
214
+ def get_file(id:, local_path:)
215
+ ensure_open!
216
+ result = FFIBindings.LIBMTP_Get_File_To_File(@device_handle, id, local_path, nil, nil)
217
+ raise_on_error!(result, "get_file failed for id #{id}")
218
+ end
219
+
220
+ #: () -> void
221
+ def close!
222
+ device_handle = @device_handle
223
+ raw_devices_pointer = @raw_devices_pointer
224
+ FFIBindings.LIBMTP_Release_Device(device_handle) if device_handle
225
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_pointer) if raw_devices_pointer && !raw_devices_pointer.null?
226
+ @device_handle = nil
227
+ @raw_devices_pointer = nil
228
+ end
229
+
230
+ private
231
+
232
+ #: () -> void
233
+ def ensure_library_loaded!
234
+ self.class.init_library!
235
+ rescue LoadError, FFI::NotFoundError => e
236
+ raise Error, "#{e.message}. #{INSTALL_HINT}"
237
+ end
238
+
239
+ #: () -> void
240
+ def ensure_open!
241
+ return if @device_handle
242
+
243
+ ensure_library_loaded!
244
+ raw_devices_pointer_pointer = FFI::MemoryPointer.new(:pointer)
245
+ count_pointer = FFI::MemoryPointer.new(:int)
246
+ result = FFIBindings.LIBMTP_Detect_Raw_Devices(raw_devices_pointer_pointer, count_pointer)
247
+ device_count = count_pointer.read_int
248
+ raise DeviceNotFound, 'No MTP device detected. Connect the device and unmount it from any other app.' unless result.zero? && device_count.positive?
249
+
250
+ raw_devices_pointer = raw_devices_pointer_pointer.read_pointer
251
+ first_raw_device = FFIBindings::RawDevice.new(raw_devices_pointer)
252
+ device_handle = FFIBindings.LIBMTP_Open_Raw_Device(first_raw_device.to_ptr)
253
+ if device_handle.null?
254
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_pointer)
255
+ raise Error, 'Could not open MTP device'
256
+ end
257
+
258
+ FFIBindings.LIBMTP_Clear_Errorstack(device_handle)
259
+ get_storage_result = FFIBindings.LIBMTP_Get_Storage(device_handle, FFIBindings::LIBMTP_STORAGE_SORTBY_NOTSORTED)
260
+ unless get_storage_result.zero?
261
+ FFIBindings.LIBMTP_Dump_Errorstack(device_handle)
262
+ FFIBindings.LIBMTP_Release_Device(device_handle)
263
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_pointer)
264
+ raise Error, 'LIBMTP_Get_Storage failed'
265
+ end
266
+
267
+ @device_handle = device_handle
268
+ @raw_devices_pointer = raw_devices_pointer
269
+ end
270
+
271
+ #: (FFI::Pointer list_head) -> Array[DeviceFile]
272
+ def walk_files(list_head)
273
+ result = [] #: Array[DeviceFile]
274
+ node = list_head
275
+ until node.null?
276
+ file = FFIBindings::File.new(node)
277
+ filename_pointer = file[:filename]
278
+ filename = filename_pointer.null? ? '' : filename_pointer.read_string.force_encoding(Encoding::UTF_8)
279
+ result << DeviceFile.new(
280
+ id: file[:item_id],
281
+ filename: filename,
282
+ size: file[:filesize],
283
+ parent_id: file[:parent_id],
284
+ storage_id: file[:storage_id]
285
+ )
286
+ node = file[:next]
287
+ end
288
+ result
289
+ end
290
+
291
+ #: (FFI::Pointer node, Integer storage_id, Array[DeviceFolder] accumulator) -> void
292
+ def walk_folders(node, storage_id, accumulator)
293
+ return if node.null?
294
+
295
+ folder = FFIBindings::Folder.new(node)
296
+ name_pointer = folder[:name]
297
+ name = name_pointer.null? ? '' : name_pointer.read_string.force_encoding(Encoding::UTF_8)
298
+ accumulator << DeviceFolder.new(
299
+ folder_id: folder[:folder_id],
300
+ name: name,
301
+ parent_id: folder[:parent_id],
302
+ storage_id: storage_id
303
+ )
304
+ walk_folders(folder[:child], storage_id, accumulator)
305
+ walk_folders(folder[:sibling], storage_id, accumulator)
306
+ end
307
+
308
+ #: (String filename) -> Integer
309
+ def filetype_for(filename)
310
+ case ::File.extname(filename).downcase
311
+ when '.wav' then FFIBindings::LIBMTP_FILETYPE_WAV
312
+ when '.mp3' then FFIBindings::LIBMTP_FILETYPE_MP3
313
+ when '.flac' then FFIBindings::LIBMTP_FILETYPE_FLAC
314
+ when '.m4a' then FFIBindings::LIBMTP_FILETYPE_M4A
315
+ when '.aac' then FFIBindings::LIBMTP_FILETYPE_AAC
316
+ when '.ogg' then FFIBindings::LIBMTP_FILETYPE_OGG
317
+ else FFIBindings::LIBMTP_FILETYPE_UNKNOWN
318
+ end
319
+ end
320
+
321
+ #: (Integer result, String context) -> void
322
+ def raise_on_error!(result, context)
323
+ return if result.zero?
324
+
325
+ device_handle = @device_handle
326
+ if device_handle
327
+ FFIBindings.LIBMTP_Dump_Errorstack(device_handle)
328
+ FFIBindings.LIBMTP_Clear_Errorstack(device_handle)
329
+ end
330
+ raise Error, context
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ module Logger
6
+ #: (String? library_path) -> void
7
+ def self.configure(library_path)
8
+ @log_path = library_path ? File.join(library_path, 'wavesync.log') : nil
9
+ @invocation_args = nil unless library_path
10
+ end
11
+
12
+ #: () -> String?
13
+ def self.log_path
14
+ @log_path
15
+ end
16
+
17
+ #: (Array[String] args) -> void
18
+ def self.capture_invocation(args)
19
+ @invocation_args = args
20
+ end
21
+
22
+ #: () -> void
23
+ def self.log_invocation
24
+ path = log_path
25
+ return unless path && @invocation_args
26
+
27
+ invocation = (['wavesync'] + @invocation_args).join(' ')
28
+ entry = "---\n[#{timestamp}] #{invocation}\n"
29
+ File.open(path, 'a') { |file| file.write(entry) }
30
+ @invocation_args = nil
31
+ end
32
+
33
+ #: (Exception error, call_site: String, arguments: Hash[Symbol, untyped]) -> void
34
+ def self.log_error(error, call_site:, arguments: {})
35
+ path = log_path
36
+ return unless path
37
+
38
+ args_str = arguments.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')
39
+ entry = "[#{timestamp}] #{call_site}(#{args_str}) raised #{error.class}: #{error.message}\n"
40
+ File.open(path, 'a') { |file| file.write(entry) }
41
+ end
42
+
43
+ #: (String message) -> void
44
+ def self.log_event(message)
45
+ path = log_path
46
+ return unless path
47
+
48
+ entry = "[#{timestamp}] #{message}\n"
49
+ File.open(path, 'a') { |file| file.write(entry) }
50
+ end
51
+
52
+ #: (Float seconds) -> void
53
+ def self.log_run_time(seconds)
54
+ path = log_path
55
+ return unless path
56
+
57
+ entry = "[#{timestamp}] Run time: #{format_duration(seconds)}\n"
58
+ File.open(path, 'a') { |file| file.write(entry) }
59
+ end
60
+
61
+ #: () -> String
62
+ def self.timestamp
63
+ Time.now.strftime('%Y-%m-%d %H:%M:%S')
64
+ end
65
+ private_class_method :timestamp
66
+
67
+ #: (Float seconds) -> String
68
+ def self.format_duration(seconds)
69
+ total_seconds = seconds.to_i
70
+ hours = total_seconds / 3600
71
+ minutes = (total_seconds % 3600) / 60
72
+ secs = total_seconds % 60
73
+
74
+ if hours.positive?
75
+ "#{hours}h #{minutes}m #{secs}s"
76
+ elsif minutes.positive?
77
+ "#{minutes}m #{secs}s"
78
+ else
79
+ "#{secs}s"
80
+ end
81
+ end
82
+ private_class_method :format_duration
83
+ end
84
+ end
@@ -24,7 +24,8 @@ module Wavesync
24
24
  bpm = audio.bpm
25
25
  target_path = add_bpm_to_filename(target_path, bpm) if @device.bpm_source == :filename && bpm
26
26
 
27
- target_path
27
+ target_path = strip_unsupported_characters(target_path)
28
+ uppercase_relative_path(target_path)
28
29
  end
29
30
 
30
31
  #: (Pathname target_path, Audio audio) -> Array[Pathname]
@@ -36,7 +37,7 @@ module Wavesync
36
37
 
37
38
  pattern = target_path.dirname.join("#{basename}{, * bpm}#{ext}")
38
39
  Dir.glob(pattern.to_s).map { |f| Pathname(f) }
39
- .reject { |path| path == target_path }
40
+ .reject { |path| File.identical?(path.to_s, target_path.to_s) }
40
41
  end
41
42
 
42
43
  private
@@ -52,6 +53,22 @@ module Wavesync
52
53
  path.dirname.join(new_basename)
53
54
  end
54
55
 
56
+ #: (Pathname path) -> Pathname
57
+ def strip_unsupported_characters(path)
58
+ return path if @device.unsupported_characters.empty?
59
+
60
+ Pathname(path.to_s.delete(@device.unsupported_characters.join))
61
+ end
62
+
63
+ #: (Pathname path) -> Pathname
64
+ def uppercase_relative_path(path)
65
+ return path unless @device.uppercase_paths
66
+
67
+ relative = path.relative_path_from(@target_library_path)
68
+ uppercased = relative.each_filename.map(&:upcase).join('/')
69
+ @target_library_path.join(uppercased)
70
+ end
71
+
55
72
  #: (Pathname path) -> Pathname
56
73
  def remove_bpm_from_filename(path)
57
74
  ext = path.extname
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ require_relative 'logger'
4
5
  require_relative 'python_venv'
5
6
 
6
7
  module Wavesync
@@ -22,7 +23,8 @@ module Wavesync
22
23
  output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
23
24
  bpm = output.strip.to_f
24
25
  bpm.positive? ? bpm.round : nil
25
- rescue StandardError
26
+ rescue StandardError => e
27
+ Logger.log_error(e, call_site: 'PercivalBpmDetector.detect', arguments: { file_path: })
26
28
  nil
27
29
  end
28
30
  end