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.
- checksums.yaml +7 -0
- data/.editorconfig +10 -0
- data/.github/workflows/deploy.yml +33 -0
- data/.github/workflows/documentation.yml +29 -0
- data/.github/workflows/test.yml +19 -0
- data/.gitignore +13 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +134 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/Rakefile +13 -0
- data/artwork/icon.svg +186 -0
- data/artwork/icon128.png +0 -0
- data/bin/console +14 -0
- data/bin/drum +5 -0
- data/bin/drum.bat +2 -0
- data/bin/setup +8 -0
- data/drum.gemspec +36 -0
- data/lib/drum/model/album.rb +114 -0
- data/lib/drum/model/artist.rb +72 -0
- data/lib/drum/model/playlist.rb +222 -0
- data/lib/drum/model/raw_ref.rb +29 -0
- data/lib/drum/model/ref.rb +19 -0
- data/lib/drum/model/track.rb +157 -0
- data/lib/drum/model/user.rb +72 -0
- data/lib/drum/service/applemusic.rb +619 -0
- data/lib/drum/service/file.rb +89 -0
- data/lib/drum/service/mock.rb +50 -0
- data/lib/drum/service/service.rb +43 -0
- data/lib/drum/service/spotify.rb +615 -0
- data/lib/drum/service/stdio.rb +51 -0
- data/lib/drum/utils/ext.rb +88 -0
- data/lib/drum/utils/log.rb +93 -0
- data/lib/drum/utils/persist.rb +50 -0
- data/lib/drum/version.rb +3 -0
- data/lib/drum.rb +250 -0
- data/userdoc/playlist-format.md +96 -0
- metadata +207 -0
@@ -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
|
data/lib/drum/version.rb
ADDED
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
|
+
```
|