drum 0.1.12

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,51 @@
1
+ require 'drum/model/playlist'
2
+ require 'drum/model/ref'
3
+ require 'drum/service/service'
4
+ require 'drum/utils/log'
5
+ require 'yaml'
6
+
7
+ module Drum
8
+ # A service that reads from stdin and writes to stdout.
9
+ class StdioService < Service
10
+ include Log
11
+
12
+ def name
13
+ 'stdio'
14
+ end
15
+
16
+ def parse_ref(raw_ref)
17
+ if raw_ref.is_token
18
+ location = case raw_ref.text
19
+ when 'stdout' then :stdout
20
+ when 'stdin' then :stdin
21
+ else return nil
22
+ end
23
+ Ref.new(self.name, :any, [location])
24
+ elsif raw_ref.text == '-'
25
+ Ref.new(self.name, :any, [:stdin, :stdout])
26
+ else
27
+ nil
28
+ end
29
+ end
30
+
31
+ def download(playlist_ref)
32
+ if playlist_ref.resource_location.include?(:stdin)
33
+ # TODO: Support multiple, --- delimited playlists?
34
+ [Playlist.deserialize(YAML.load(STDIN.read))]
35
+ else
36
+ []
37
+ end
38
+ end
39
+
40
+ def upload(playlist_ref, playlists)
41
+ if playlist_ref.resource_location.include?(:stdout)
42
+ playlists.each do |playlist|
43
+ log.all playlist.serialize.to_yaml
44
+ end
45
+ nil
46
+ else
47
+ raise 'Cannot upload to somewhere other than stdout!'
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,88 @@
1
+ module Drum
2
+ module Try
3
+ # A lightweight variant of Rails' try that only supports
4
+ # blocks (the other variants are already handled more
5
+ # elegantly using &.).
6
+ #
7
+ # @yield [value] The block to run if not nil
8
+ # @yieldparam [Object] value The non-nil value
9
+ # @return [Object, nil] Either the mapped self or nil
10
+ def try
11
+ if self.nil?
12
+ nil
13
+ else
14
+ yield self
15
+ end
16
+ end
17
+ end
18
+
19
+ module Casings
20
+ # Converts a string to kebab-case.
21
+ #
22
+ # @return [String] The kebabcased version of the string
23
+ def kebabcase
24
+ self.gsub(/([A-Z]+)([A-Z][a-z])/,'\1-\2')
25
+ .gsub(/([a-z\d])([A-Z])/,'\1-\2')
26
+ .gsub(/[\s_\/\-_:\.]+/, '-')
27
+ .downcase
28
+ end
29
+
30
+ # Converts a string to ['array', 'case']
31
+ #
32
+ # @return [Array<String>] The arraycased version of the string
33
+ def arraycase
34
+ self.kebabcase
35
+ .split('-')
36
+ end
37
+
38
+ # Converts a string to Start Case.
39
+ #
40
+ # @return [String] The startcased version of the string
41
+ def startcase
42
+ self.arraycase
43
+ .map { |s| s.capitalize }
44
+ .join(' ')
45
+ end
46
+
47
+ # Converts a string to camelCase.
48
+ #
49
+ # @return [String] The camelcased version of the string
50
+ def camelcase
51
+ self.arraycase
52
+ .each_with_index
53
+ .map { |s, i| if i == 0 then s else s.capitalize end }
54
+ .join
55
+ end
56
+
57
+ # Converts a string to PascalCase.
58
+ #
59
+ # @return [String] The pascalcased version of the string
60
+ def pascalcase
61
+ self.arraycase
62
+ .map { |s| s.capitalize }
63
+ .join
64
+ end
65
+ end
66
+
67
+ module ToHashById
68
+ # Initializes a hash from the array grouping by ids as keys
69
+ # (which are assumed to be unique).
70
+ #
71
+ # @return [Hash<Object, Object>] The resulting hash
72
+ def to_h_by_id
73
+ self.map { |v| [v.id, v] }.to_h
74
+ end
75
+ end
76
+ end
77
+
78
+ class Object
79
+ include Drum::Try
80
+ end
81
+
82
+ class String
83
+ include Drum::Casings
84
+ end
85
+
86
+ class Array
87
+ include Drum::ToHashById
88
+ end
@@ -0,0 +1,93 @@
1
+ require 'logger'
2
+
3
+ module Drum
4
+ # A simple logging facility.
5
+ #
6
+ # @!attribute level
7
+ # @return [Logger::Level] The minimum level of messages to be logged
8
+ # @!attribute output
9
+ # @return [Proc] A function taking a string for outputting a message
10
+ class Logger
11
+ module Level
12
+ ALL = 100
13
+ ERROR = 2
14
+ WARN = 1
15
+ INFO = 0
16
+ DEBUG = -1
17
+ TRACE = -2
18
+ end
19
+
20
+ attr_accessor :level
21
+ attr_accessor :output
22
+
23
+ # Creates a new logger with the given level.
24
+ #
25
+ # @param [Logger::Level] level The minimum level of messages to be logged.
26
+ # @param [Proc] output A function taking a string for outputting a message.
27
+ def initialize(level: Level::INFO, output: method(:puts))
28
+ self.level = level
29
+ self.output = output
30
+ end
31
+
32
+ # Logs a message at the given level.
33
+ #
34
+ # @param [Logger::Level] level The level to log the message at.
35
+ # @param [String] msg The message to log.
36
+ def log(level, msg)
37
+ if level >= self.level
38
+ self.output.(msg)
39
+ end
40
+ end
41
+
42
+ # Logs a message at the ALL level.
43
+ #
44
+ # @param [String] msg The message to log.
45
+ def all(msg)
46
+ self.log(Level::ALL, msg)
47
+ end
48
+
49
+ # Logs a message at the ERROR level.
50
+ #
51
+ # @param [String] msg The message to log.
52
+ def error(msg)
53
+ self.log(Level::ERROR, msg)
54
+ end
55
+
56
+ # Logs a message at the WARN level.
57
+ #
58
+ # @param [String] msg The message to log.
59
+ def warn(msg)
60
+ self.log(Level::WARN, msg)
61
+ end
62
+
63
+ # Logs a message at the INFO level.
64
+ #
65
+ # @param [String] msg The message to log.
66
+ def info(msg)
67
+ self.log(Level::INFO, msg)
68
+ end
69
+
70
+ # Logs a message at the DEBUG level.
71
+ #
72
+ # @param [String] msg The message to log.
73
+ def debug(msg)
74
+ self.log(Level::DEBUG, msg)
75
+ end
76
+
77
+ # Logs a message at the TRACE level.
78
+ #
79
+ # @param [String] msg The message to log.
80
+ def trace(msg)
81
+ self.log(Level::TRACE, msg)
82
+ end
83
+ end
84
+
85
+ module Log
86
+ # Fetches the global, lazily initialized logger.
87
+ #
88
+ # @return [Logger] The logger
89
+ def log
90
+ @@log ||= Logger.new
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,50 @@
1
+ require 'yaml'
2
+
3
+ # A wrapper around a hash that stores values persistently in a YAML.
4
+ #
5
+ # @!attribute value
6
+ # @return [Hash] The wrapped hash
7
+ class Drum::PersistentHash
8
+ attr_reader :value
9
+
10
+ # Creates a new persistent hash.
11
+ #
12
+ # @param [String] file_path The path to the stored YAML file (may be non-existent).
13
+ # @param [Hash] value The initial default value, if the file doesn't exist yet or is malformed
14
+ def initialize(file_path, value={})
15
+ @file_path = file_path
16
+ begin
17
+ self.load
18
+ rescue StandardError => e
19
+ @value = value
20
+ self.store
21
+ end
22
+ end
23
+
24
+ # Loads the hash from the file.
25
+ def load
26
+ @value = YAML.load(File.read(@file_path))
27
+ end
28
+
29
+ # Saves the hash to the file.
30
+ def store
31
+ File.write(@file_path, @value.to_yaml)
32
+ end
33
+
34
+ # Writes a mapping to the hash and stores it on disk.
35
+ #
36
+ # @param [Object] key The key to use.
37
+ # @param [Object] value The value to map the key to.
38
+ def []=(key, value)
39
+ @value[key] = value
40
+ store
41
+ end
42
+
43
+ # Reads a mapping from the hash.
44
+ #
45
+ # @param [Object] key The key to use.
46
+ # @return [Object] The value the key is mapped to.
47
+ def [](key)
48
+ @value[key]
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module Drum
2
+ VERSION = '0.1.12'
3
+ end
data/lib/drum.rb ADDED
@@ -0,0 +1,250 @@
1
+ require 'drum/model/raw_ref'
2
+ require 'drum/service/applemusic'
3
+ require 'drum/service/file'
4
+ require 'drum/service/mock'
5
+ require 'drum/service/service'
6
+ require 'drum/service/spotify'
7
+ require 'drum/service/stdio'
8
+ require 'drum/utils/log'
9
+ require 'drum/version'
10
+ require 'highline'
11
+ require 'progress_bar'
12
+ require 'thor'
13
+ require 'yaml'
14
+
15
+ module Drum
16
+ class Error < StandardError; end
17
+
18
+ # The command line interface for drum.
19
+ class CLI < Thor
20
+ include Log
21
+
22
+ # Sets up the CLI by registering the services.
23
+ def initialize(*args)
24
+ super
25
+
26
+ @hl = HighLine.new
27
+
28
+ # Set up .drum directory
29
+ @dot_dir = Pathname.new(Dir.home) / '.drum'
30
+ @dot_dir.mkdir unless @dot_dir.directory?
31
+
32
+ @cache_dir = @dot_dir / 'cache'
33
+ @cache_dir.mkdir unless @cache_dir.directory?
34
+
35
+ # Declare services in descending order of parse priority
36
+ @services = [
37
+ MockService.new,
38
+ StdioService.new,
39
+ AppleMusicService.new(@cache_dir),
40
+ SpotifyService.new(@cache_dir),
41
+ # The file service should be last since it may
42
+ # successfully parse refs that overlap with other
43
+ # services.
44
+ FileService.new
45
+ ].map { |s| [s.name, s] }.to_h
46
+ end
47
+
48
+ def self.exit_on_failure?
49
+ true
50
+ end
51
+
52
+ no_commands do
53
+ # Performs a block with the given service, if registered.
54
+ #
55
+ # @yield [name, service] The block to run
56
+ # @yieldparam [String] name The name of the service
57
+ # @yieldparam [Service] service The service
58
+ # @param [String] raw_name The name of the service
59
+ def with_service(raw_name)
60
+ name = raw_name.downcase
61
+ service = @services[name]
62
+ if service.nil?
63
+ raise "Sorry, #{name} is not a valid service! Try one of these: #{@services.keys}"
64
+ end
65
+ yield(name, service)
66
+ end
67
+
68
+ # Parses a ref using the registered services.
69
+ #
70
+ # @param [String] raw The raw ref to parse
71
+ # @return [optional, Ref] The ref, if parsed successfully with any of the services
72
+ def parse_ref(raw)
73
+ raw_ref = RawRef.parse(raw)
74
+ @services.each_value do |service|
75
+ ref = service.parse_ref(raw_ref)
76
+ unless ref.nil?
77
+ return ref
78
+ end
79
+ end
80
+ return nil
81
+ end
82
+
83
+ # Prompts the user for confirmation.
84
+ #
85
+ # @param [String] prompt The message to be displayed
86
+ def confirm(prompt)
87
+ answer = @hl.ask "#{prompt} [y/n]"
88
+ unless answer == 'y'
89
+ log.info 'Okay, exiting.'
90
+ exit
91
+ end
92
+ end
93
+ end
94
+
95
+ desc 'show [REF]', 'Preview a playlist in a simplified format'
96
+
97
+ # Previews a playlist in a simplified format.
98
+ #
99
+ # @param [String] raw_ref The (raw) playlist ref.
100
+ def show(raw_ref)
101
+ ref = self.parse_ref(raw_ref)
102
+
103
+ if ref.nil?
104
+ raise "Could not parse ref: #{raw_ref}"
105
+ end
106
+
107
+ self.with_service(ref.service_name) do |name, service|
108
+ playlists = service.download(ref)
109
+
110
+ playlists.each do |playlist|
111
+ log.all({
112
+ 'name' => playlist.name,
113
+ 'description' => playlist&.description,
114
+ 'tracks' => playlist.tracks.each_with_index.map do |track, i|
115
+ artists = (track.artist_ids&.filter_map { |id| playlist.artists[id]&.name } || []).join(', ')
116
+ "#{i + 1}. #{artists} - #{track.name}"
117
+ end
118
+ }.compact.to_yaml)
119
+ end
120
+ end
121
+ end
122
+
123
+ desc 'cp [SOURCE] [DEST]', 'Copy a playlist from the source to the given destination'
124
+
125
+ method_option :group_by_author,
126
+ type: :boolean,
127
+ default: false,
128
+ desc: "Prepend the author name to each playlist's path"
129
+
130
+ method_option :recase_paths,
131
+ type: :string,
132
+ enum: ['kebabcase', 'startcase', 'camelcase', 'pascalcase'],
133
+ default: false,
134
+ desc: "Convert each playlist's path segments to a specific case"
135
+
136
+ method_option :note_date,
137
+ type: :boolean,
138
+ default: false,
139
+ desc: 'Add a small note with the upload date to each description.'
140
+
141
+ # Copies a playlist from the source to the given destination.
142
+ #
143
+ # @param [String] raw_src_ref The source playlist ref.
144
+ # @param [String] raw_dest_ref The destination playlist ref.
145
+ # @return [void]
146
+ def cp(raw_src_ref, raw_dest_ref)
147
+ src_ref = self.parse_ref(raw_src_ref)
148
+ dest_ref = self.parse_ref(raw_dest_ref)
149
+
150
+ if src_ref.nil?
151
+ raise "Could not parse src ref: #{raw_src_ref}"
152
+ end
153
+ if dest_ref.nil?
154
+ raise "Could not parse dest ref: #{raw_dest_ref}"
155
+ end
156
+
157
+ self.with_service(src_ref.service_name) do |src_name, src_service|
158
+ self.with_service(dest_ref.service_name) do |dest_name, dest_service|
159
+ log.info "Copying from #{src_name} to #{dest_name}..."
160
+
161
+ # TODO: Should we handle merging at all or just copy (as currently done)?
162
+
163
+ playlists = src_service.download(src_ref).lazy
164
+
165
+ unless playlists.size.nil?
166
+ bar = ProgressBar.new(playlists.size)
167
+
168
+ # Redirect log output so the bar stays at the bottom
169
+ log.output = bar.method(:puts)
170
+ end
171
+
172
+ # Apply transformations to the downloaded playlists.
173
+ # Note that we use 'map' despite mutating the playlists
174
+ # in-place to preserve laziness in the iteration.
175
+
176
+ playlists = playlists.map do |playlist|
177
+ bar&.increment!
178
+
179
+ if options[:group_by_author]
180
+ author_name = playlist.author_id.try { |id| playlist.users[id] }&.display_name || 'Other'
181
+ playlist.path.unshift(author_name)
182
+ end
183
+
184
+ if options[:recase_paths]
185
+ casing = options[:recase_paths]
186
+ playlist.path.map! do |n|
187
+ case casing
188
+ when 'kebabcase' then n.kebabcase
189
+ when 'startcase' then n.startcase
190
+ when 'camelcase' then n.camelcase
191
+ when 'pascalcase' then n.pascalcase
192
+ else raise "Casing '#{casing}' is not implemented (yet)!"
193
+ end
194
+ end
195
+ end
196
+
197
+ if options[:note_date]
198
+ unless playlist.description.end_with?("\n") || playlist.description.empty?
199
+ playlist.description += "\n"
200
+ end
201
+ playlist.description += Time.now.strftime("Pushed with Drum on %Y-%m-%d")
202
+ end
203
+
204
+ playlist
205
+ end
206
+
207
+ dest_service.upload(dest_ref, playlists)
208
+ end
209
+ end
210
+ end
211
+
212
+ desc 'rm [REF]', 'Remove a playlist from the corresponding service'
213
+
214
+ # Removes a playlist from the corresponding service.
215
+ #
216
+ # @param [String] raw_ref The playlist ref.
217
+ # @return [void]
218
+ def rm(raw_ref)
219
+ ref = self.parse_ref(raw_ref)
220
+
221
+ if ref.nil?
222
+ raise "Could not parse ref: #{raw_ref}"
223
+ end
224
+
225
+ self.with_service(ref.service_name) do |name, service|
226
+ log.info "Removing from #{name}..."
227
+ service.remove(ref)
228
+ end
229
+ end
230
+
231
+ desc 'services', 'List available services'
232
+
233
+ # Lists available services.
234
+ #
235
+ # @return [void]
236
+ def services
237
+ log.info @services.each_key.to_a.join("\n")
238
+ end
239
+
240
+ map %w[--version -v] => :__print_version
241
+ desc '--version, -v', 'Print the version', hide: true
242
+
243
+ # Prints the version.
244
+ #
245
+ # @return [void]
246
+ def __print_version
247
+ log.all VERSION
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,96 @@
1
+ # Drum Playlist Format
2
+
3
+ Drum uses a YAML-based format to represent playlists. This makes it easy to back them up, check them into version control and use them in other tools, among others. There are two basic flavors of playlists, though both share the same rough structure:
4
+
5
+ * **Regular playlists**, which are a simple list of songs
6
+ * **Smart playlists**, which additionally include a list of filtering rules
7
+
8
+ A playlist is 'smart' if and only if it contains `filter`s.
9
+
10
+ > Note that by default, Drum will only allow copying smart playlists to destinations that support smart playlists (currently just local files). With the `-x`/`--execute` flag, however, the smart playlist will be converted into a regular playlist during the copying, thereby allowing all destinations that support regular playlists.
11
+
12
+ <!-- TODO: Actually implement smart playlists -->
13
+
14
+ ## Specification
15
+
16
+ ### Playlist
17
+
18
+ The top-level object in a playlist file.
19
+
20
+ ```yaml
21
+ id: string
22
+ name: string
23
+ description: string?
24
+ author_id: string? (references User.id)
25
+ path: string[]?
26
+ users: User[]?
27
+ artists: Artist[]?
28
+ albums: Album[]?
29
+ tracks: Track[]?
30
+ spotify:
31
+ id: string
32
+ public: boolean?
33
+ collaborative: boolean?
34
+ image_url: string?
35
+ applemusic:
36
+ library_id: string?
37
+ global_id: string?
38
+ public: boolean?
39
+ editable: boolean?
40
+ image_url: string?
41
+ ```
42
+
43
+ ### User
44
+
45
+ ```yaml
46
+ id: string
47
+ display_name: string?
48
+ spotify:
49
+ id: string
50
+ image_url: string?
51
+ ```
52
+
53
+ ### Artist
54
+
55
+ ```yaml
56
+ id: string
57
+ name: string
58
+ spotify:
59
+ id: string
60
+ image_url: string?
61
+ ```
62
+
63
+ ### Album
64
+
65
+ ```yaml
66
+ id: string
67
+ name: string
68
+ artist_ids: string[] (references Artist.id)
69
+ spotify:
70
+ id: string
71
+ image_url: string?
72
+ applemusic:
73
+ image_url: string?
74
+ ```
75
+
76
+ ### Track
77
+
78
+ ```yaml
79
+ name: string
80
+ artist_ids: string[] (references Artist.id)
81
+ composer_ids: string[]? (references Artist.id)
82
+ genres: string[]?
83
+ album_id: string? (references Album.id)
84
+ duration_ms: number?
85
+ explicit: boolean?
86
+ released_at: string? (ISO8601 date)
87
+ added_at: string? (ISO8601 date)
88
+ added_by: string? (references User.id)
89
+ isrc: string?
90
+ spotify:
91
+ id: string
92
+ applemusic:
93
+ library_id: string?
94
+ catalog_id: string?
95
+ preview_url: string?
96
+ ```