pandoru 0.1.0

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,249 @@
1
+ require_relative '_base'
2
+
3
+ module Pandoru
4
+ module Models
5
+ # A single station seed — an artist, song, or genre the station was
6
+ # built from. Returned under the "music" key of a getStation response
7
+ # when includeExtendedAttributes is set.
8
+ class StationSeed < Base
9
+ field :seed_id, 'seedId'
10
+ field :music_token, 'musicToken'
11
+ field :pandora_id, 'pandoraId'
12
+ field :pandora_type, 'pandoraType'
13
+ field :genre_name, 'genreName'
14
+ field :song_name, 'songName'
15
+ field :artist_name, 'artistName'
16
+ field :art_url, 'artUrl'
17
+
18
+ def song?
19
+ !song_name.nil?
20
+ end
21
+
22
+ def artist?
23
+ song_name.nil? && !artist_name.nil?
24
+ end
25
+
26
+ def genre?
27
+ !genre_name.nil?
28
+ end
29
+
30
+ # Human-readable label, handy for clustering/debugging.
31
+ def label
32
+ return genre_name if genre?
33
+ [artist_name, song_name].compact.join(' - ')
34
+ end
35
+ end
36
+
37
+ # The seed sets for a station, grouped by kind.
38
+ class StationSeeds < Base
39
+ attr_accessor :genres, :songs, :artists
40
+
41
+ def self.from_json(api_client, data)
42
+ return nil unless data
43
+ instance = new(data, api_client)
44
+ instance.genres = StationSeed.from_json_list(api_client, data['genres'])
45
+ instance.songs = StationSeed.from_json_list(api_client, data['songs'])
46
+ instance.artists = StationSeed.from_json_list(api_client, data['artists'])
47
+ instance
48
+ end
49
+
50
+ # All seeds flattened into one array.
51
+ def all
52
+ Array(genres) + Array(artists) + Array(songs)
53
+ end
54
+ end
55
+
56
+ # A single thumbs-up/down on a song.
57
+ class SongFeedback < Base
58
+ field :feedback_id, 'feedbackId'
59
+ field :song_identity, 'songIdentity'
60
+ field :is_positive, 'isPositive', type: :boolean
61
+ field :pandora_id, 'pandoraId'
62
+ field :album_art_url, 'albumArtUrl'
63
+ field :music_token, 'musicToken'
64
+ field :song_name, 'songName'
65
+ field :artist_name, 'artistName'
66
+ field :pandora_type, 'pandoraType'
67
+ date_field :date_created, 'dateCreated'
68
+
69
+ def positive?
70
+ is_positive == true
71
+ end
72
+ end
73
+
74
+ # A station's feedback (thumbs) returned under the "feedback" key of an
75
+ # extended getStation response.
76
+ class StationFeedback < Base
77
+ attr_accessor :thumbs_up, :thumbs_down
78
+ field :total_thumbs_up, 'totalThumbsUp'
79
+ field :total_thumbs_down, 'totalThumbsDown'
80
+
81
+ def self.from_json(api_client, data)
82
+ return nil unless data
83
+ instance = new(data, api_client)
84
+ instance.populate_from_json(data)
85
+ instance.thumbs_up = SongFeedback.from_json_list(api_client, data['thumbsUp'])
86
+ instance.thumbs_down = SongFeedback.from_json_list(api_client, data['thumbsDown'])
87
+ instance
88
+ end
89
+ end
90
+
91
+ class Station < Base
92
+ field :station_id, 'stationId'
93
+ field :station_name, 'stationName'
94
+ field :station_token, 'stationToken'
95
+ field :art_url, 'artUrl'
96
+ field :detail_url, 'stationDetailUrl'
97
+ field :sharing_url, 'stationSharingUrl'
98
+
99
+ field :allow_add_music, 'allowAddMusic', type: :boolean
100
+ field :allow_delete, 'allowDelete', type: :boolean
101
+ field :allow_rename, 'allowRename', type: :boolean
102
+ field :allow_edit_description, 'allowEditDescription', type: :boolean
103
+
104
+ field :is_creator, 'isCreator', type: :boolean
105
+ field :is_shared, 'isShared', type: :boolean
106
+ field :is_quickmix, 'isQuickMix', type: :boolean
107
+ field :is_genre_station, 'isGenreStation', type: :boolean
108
+ field :is_thumbprint, 'isThumbprint', type: :boolean
109
+
110
+ field :thumb_count, 'thumbCount'
111
+ date_field :date_created, 'dateCreated'
112
+
113
+ # Populated only by getStation with includeExtendedAttributes; nil for
114
+ # stations returned in a getStationList response.
115
+ attr_accessor :seeds, :feedback
116
+
117
+ # Convenience aliases
118
+ alias_method :id, :station_id
119
+ alias_method :name, :station_name
120
+ alias_method :token, :station_token
121
+
122
+ def self.from_json(api_client, data)
123
+ station = super
124
+ return station unless station && data
125
+ station.seeds = StationSeeds.from_json(api_client, data['music'])
126
+ station.feedback = StationFeedback.from_json(api_client, data['feedback'])
127
+ station
128
+ end
129
+
130
+ # Seed accessors that are always safe to call (empty array when the
131
+ # station was loaded without extended attributes).
132
+ def seed_artists
133
+ seeds&.artists || []
134
+ end
135
+
136
+ def seed_songs
137
+ seeds&.songs || []
138
+ end
139
+
140
+ def seed_genres
141
+ seeds&.genres || []
142
+ end
143
+
144
+ def thumbs_up
145
+ feedback&.thumbs_up || []
146
+ end
147
+
148
+ def thumbs_down
149
+ feedback&.thumbs_down || []
150
+ end
151
+
152
+ def get_playlist
153
+ return nil unless @api_client
154
+ @api_client.get_playlist(token)
155
+ end
156
+
157
+ def rename(new_name)
158
+ return false unless allow_rename && @api_client
159
+ @api_client.rename_station(token, new_name)
160
+ @name = new_name
161
+ true
162
+ end
163
+
164
+ def delete
165
+ return false unless allow_delete && @api_client
166
+ @api_client.delete_station(token)
167
+ true
168
+ end
169
+
170
+ def add_seed(music_token)
171
+ return false unless allow_add_music && @api_client
172
+ @api_client.add_music(token, music_token)
173
+ true
174
+ end
175
+ end
176
+
177
+ class StationList < Collection
178
+ field :checksum, 'checksum'
179
+
180
+ def self.from_json(api_client, data)
181
+ instance = new(data, api_client)
182
+ instance.populate_from_json(data)
183
+
184
+ if data['stations']
185
+ stations = Station.from_json_list(api_client, data['stations'])
186
+ stations.each { |station| instance << station }
187
+ end
188
+
189
+ instance
190
+ end
191
+
192
+ def find_by_name(name)
193
+ find { |station| station.name == name }
194
+ end
195
+
196
+ def quickmix_stations
197
+ select(&:is_quickmix)
198
+ end
199
+
200
+ def user_stations
201
+ reject(&:is_quickmix)
202
+ end
203
+ end
204
+
205
+ class GenreStation < Base
206
+ field :id, 'stationId'
207
+ field :name, 'stationName'
208
+ field :token, 'stationToken'
209
+ field :category, 'categoryName'
210
+
211
+ def create_station
212
+ return nil unless @api_client
213
+ @api_client.create_station(search_token: token)
214
+ end
215
+ end
216
+
217
+ class GenreStationList < Collection
218
+ field :checksum, 'checksum'
219
+
220
+ def self.from_json(api_client, data)
221
+ instance = new(data, api_client)
222
+ instance.populate_from_json(data)
223
+
224
+ if data['categories']
225
+ data['categories'].each do |category|
226
+ category_name = category['categoryName']
227
+ next unless category['stations']
228
+
229
+ category['stations'].each do |station_data|
230
+ station_data['categoryName'] = category_name
231
+ station = GenreStation.from_json(api_client, station_data)
232
+ instance << station
233
+ end
234
+ end
235
+ end
236
+
237
+ instance
238
+ end
239
+
240
+ def categories
241
+ map(&:category).uniq
242
+ end
243
+
244
+ def stations_for_category(category)
245
+ select { |station| station.category == category }
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,41 @@
1
+ require_relative '_base'
2
+
3
+ module Pandoru
4
+ module Models
5
+ # A single Music-Genome-derived trait from track.explainTrack, e.g.
6
+ # "electric rock instrumentation" or "a subtle use of vocal harmony".
7
+ class FocusTrait < Base
8
+ field :focus_trait_id, 'focusTraitId'
9
+ field :focus_trait_name, 'focusTraitName'
10
+
11
+ def to_s
12
+ focus_trait_name.to_s
13
+ end
14
+ end
15
+
16
+ # The result of track.explainTrack: the human-readable traits the Music
17
+ # Genome Project used to justify playing a track. This is the closest the
18
+ # API gets to exposing genome data — discrete trait tags, not a vector.
19
+ class TrackExplanation < Base
20
+ attr_accessor :explanations
21
+
22
+ def self.from_json(api_client, data)
23
+ return nil unless data
24
+ instance = new(data, api_client)
25
+ instance.explanations =
26
+ FocusTrait.from_json_list(api_client, data['explanations'])
27
+ instance
28
+ end
29
+
30
+ # The genome-derived trait name strings, with the trailing filler entry
31
+ # removed. The API always appends a non-attribute entry as the last
32
+ # explanation ("...many other similarities identified in the Music
33
+ # Genome Project"); it carries no focusTraitId, so we drop it.
34
+ def focus_traits
35
+ traits = explanations || []
36
+ traits = traits[0...-1] if traits.last && traits.last.focus_trait_id.nil?
37
+ traits.map(&:focus_trait_name).compact
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ require_relative 'models/_base'
2
+ require_relative 'models/station'
3
+ require_relative 'models/playlist'
4
+ require_relative 'models/search'
5
+ require_relative 'models/bookmark'
6
+ require_relative 'models/track_explanation'
7
+
8
+ module Pandoru
9
+ module Models
10
+ # Convenience method to create models from API responses
11
+ def self.from_json(model_class, data, api_client = nil)
12
+ model_class.from_json(data, api_client)
13
+ end
14
+
15
+ def self.from_json_list(model_class, data_list, api_client = nil)
16
+ model_class.from_json_list(data_list, api_client)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,395 @@
1
+ # Pandora API Transport
2
+ #
3
+ # This module contains the very low level transport agent for the Pandora API.
4
+ # The transport is concerned with the details of a raw HTTP call to the Pandora
5
+ # API along with the request and response encryption by way of an Encryptor
6
+ # object. The result from a transport is a JSON object for the API or an
7
+ # exception.
8
+ #
9
+ # API consumers should use one of the API clients in the Pandoru::Client package.
10
+
11
+ require 'faraday'
12
+ require 'faraday/retry'
13
+ require 'json'
14
+ require 'uri'
15
+ require 'time'
16
+ require 'crypt/blowfish'
17
+ require 'base64'
18
+ require 'cgi'
19
+
20
+ module Pandoru
21
+ module Transport
22
+ DEFAULT_API_HOST = "tuner.pandora.com/services/json/"
23
+
24
+ # Function decorator implementing retrying logic for handling connection errors
25
+ module Retries
26
+ def self.retry_on_error(max_tries = 3, exceptions = [StandardError])
27
+ lambda do |method|
28
+ lambda do |*args, &block|
29
+ attempts = 0
30
+ begin
31
+ attempts += 1
32
+ method.call(*args, &block)
33
+ rescue *exceptions => e
34
+ if attempts < max_tries
35
+ sleep(delay_exponential(0.5, 2, attempts))
36
+ retry
37
+ else
38
+ raise e
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def self.delay_exponential(base, growth_factor, attempts)
46
+ if base == "rand"
47
+ base = rand
48
+ elsif base <= 0
49
+ raise ArgumentError, "Base must be greater than 0"
50
+ end
51
+
52
+ base * (growth_factor ** (attempts - 1))
53
+ end
54
+ end
55
+
56
+ # Blowfish cryptography for Pandora API encryption/decryption
57
+ class BlowfishCryptor
58
+ BLOCK_SIZE = 8
59
+
60
+ def initialize(key)
61
+ @cipher = Crypt::Blowfish.new(key)
62
+ end
63
+
64
+ def encrypt(data)
65
+ padded_data = add_padding(data)
66
+
67
+ # Encrypt block by block for consistency with decrypt
68
+ blocks = padded_data.scan(/.{#{BLOCK_SIZE}}/m)
69
+ encrypted = blocks.map { |block| @cipher.encrypt_block(block) }.join
70
+
71
+ encode_hex(encrypted)
72
+ end
73
+
74
+ def decrypt(data, strip_padding: true)
75
+ return '' if data.empty?
76
+ decoded_data = decode_hex(data)
77
+
78
+ # Decrypt block by block (decrypt_string only does one block!)
79
+ blocks = decoded_data.scan(/.{#{BLOCK_SIZE}}/m)
80
+ decrypted = blocks.map { |block| @cipher.decrypt_block(block) }.join
81
+
82
+ strip_padding ? self.class.strip_padding(decrypted) : decrypted
83
+ end
84
+
85
+ private
86
+
87
+ def add_padding(data)
88
+ pad_size = BLOCK_SIZE - (data.bytesize % BLOCK_SIZE)
89
+ padding = pad_size.chr * pad_size
90
+ data + padding
91
+ end
92
+
93
+ def self.strip_padding(data)
94
+ pad_size = data[-1].ord
95
+ computed_padding = pad_size.chr * pad_size
96
+
97
+ raise ArgumentError, "Invalid padding" unless data[-pad_size..-1] == computed_padding
98
+
99
+ data[0...-pad_size]
100
+ end
101
+
102
+ def decode_hex(data)
103
+ return '' if data.empty?
104
+ [data.upcase].pack('H*')
105
+ end
106
+
107
+ def encode_hex(data)
108
+ data.unpack1('H*').downcase.encode('utf-8')
109
+ end
110
+ end
111
+
112
+ # Pandora Blowfish Encryptor
113
+ class Encryptor
114
+ def initialize(decryption_key, encryption_key)
115
+ @bf_out = BlowfishCryptor.new(encryption_key)
116
+ @bf_in = BlowfishCryptor.new(decryption_key)
117
+ end
118
+
119
+ def encrypt(data)
120
+ @bf_out.encrypt(data)
121
+ end
122
+
123
+ def decrypt(data)
124
+ JSON.parse(@bf_in.decrypt(data))
125
+ end
126
+
127
+ def decrypt_sync_time(data)
128
+ decrypted = @bf_in.decrypt(data, strip_padding: false)
129
+ # Extract the sync time (skip first 4 bytes, take all but last 2)
130
+ # This matches Python's [4:-2] slice
131
+ time_str = decrypted[4...-2]
132
+ # The sync time is stored as an ASCII string of the Unix timestamp
133
+ time_str.to_i
134
+ end
135
+ end
136
+
137
+ # Pandora API Transport with retries
138
+ class APITransport
139
+ API_VERSION = "5"
140
+
141
+ REQUIRE_RESET = %w[auth.partnerLogin].freeze
142
+ NO_ENCRYPT = %w[auth.partnerLogin].freeze
143
+ REQUIRE_TLS = %w[
144
+ auth.partnerLogin
145
+ auth.userLogin
146
+ station.getPlaylist
147
+ user.createUser
148
+ ].freeze
149
+
150
+ attr_reader :cryptor, :api_host
151
+ attr_accessor :partner_auth_token, :user_auth_token, :partner_id, :user_id,
152
+ :start_time, :server_sync_time
153
+
154
+ def initialize(cryptor = nil, api_host: DEFAULT_API_HOST, proxy: nil)
155
+ @cryptor = cryptor
156
+ api_host ||= DEFAULT_API_HOST
157
+
158
+ # Parse host and path
159
+ if api_host.include?('/services/json')
160
+ # Extract host from full URL
161
+ if api_host.include?('://')
162
+ # Protocol://host format
163
+ uri_parts = api_host.split('/')
164
+ @api_host = uri_parts[2]
165
+ else
166
+ # host/path format
167
+ @api_host = api_host.split('/')[0]
168
+ end
169
+ @api_path = '/services/json/'
170
+ else
171
+ @api_host = api_host
172
+ @api_path = '/services/json/'
173
+ end
174
+
175
+ # Set port and TLS based on URL
176
+ if api_host.include?('https:') || api_host.include?(':443') || api_host == 'tuner.pandora.com'
177
+ @api_port = 443
178
+ @api_tls = true
179
+ else
180
+ @api_port = 80
181
+ @api_tls = false
182
+ end
183
+
184
+ # Strip protocol from host for compatibility
185
+ @api_host = @api_host.gsub(%r{^https?://}, '')
186
+ # Remove path if it got included
187
+ @api_host = @api_host.split('/')[0]
188
+
189
+ # Extract port from hostname if present
190
+ if @api_host.include?(':')
191
+ host_parts = @api_host.split(':')
192
+ @api_host = host_parts[0]
193
+ @api_port = host_parts[1].to_i
194
+ @api_tls = (@api_port == 443)
195
+ end
196
+
197
+ @encryption_padding = "\x00" * 16
198
+ @connection = build_connection(proxy)
199
+ reset
200
+ @start_time = Time.now.to_f # Set after reset so it doesn't get cleared
201
+ end
202
+
203
+ def reset
204
+ @partner_auth_token = nil
205
+ @user_auth_token = nil
206
+ @partner_id = nil
207
+ @user_id = nil
208
+ @start_time = nil
209
+ @server_sync_time = nil
210
+ end
211
+
212
+ def set_partner(data)
213
+ self.sync_time = data["syncTime"]
214
+ @partner_auth_token = data["partnerAuthToken"]
215
+ @partner_id = data["partnerId"]
216
+ end
217
+
218
+ def set_user(data)
219
+ @user_id = data["userId"]
220
+ @user_auth_token = data["userAuthToken"]
221
+ end
222
+
223
+ def auth_token
224
+ @auth_token || @user_auth_token || @partner_auth_token
225
+ end
226
+
227
+ def sync_time
228
+ return nil unless @server_sync_time
229
+ (@server_sync_time + (Time.now.to_f - @start_time)).to_i
230
+ end
231
+
232
+ def call(method, **data)
233
+ start_request(method)
234
+
235
+ params = build_params(method)
236
+ url = build_url(method, params)
237
+ request_info = build_data(method, data)
238
+
239
+ result = make_http_request(url, request_info[:data], params, request_info[:encrypted])
240
+ parse_response(result)
241
+ end
242
+
243
+ def test_connectivity
244
+ return false unless @connection
245
+
246
+ begin
247
+ test_url = "#{@api_tls ? 'https' : 'http'}://#{@api_host}:#{@api_tls ? 443 : 80}"
248
+ test_url(test_url)
249
+ rescue StandardError
250
+ false
251
+ end
252
+ end
253
+
254
+ def test_url(url)
255
+ response = @connection.head(url)
256
+ response.status == 200
257
+ rescue
258
+ false
259
+ end
260
+
261
+ private
262
+
263
+ def sync_time=(sync_time_encrypted)
264
+ @server_sync_time = @cryptor.decrypt_sync_time(sync_time_encrypted)
265
+ end
266
+
267
+ def build_connection(proxy)
268
+ options = {
269
+ headers: { 'User-Agent' => 'pianobar-2022.04.01' }
270
+ }
271
+ options[:proxy] = proxy if proxy
272
+
273
+ Faraday.new(options) do |f|
274
+ f.request :retry, max: 3, interval: 0.5, backoff_factor: 2
275
+ f.adapter Faraday.default_adapter
276
+ end
277
+ end
278
+
279
+ def start_request(method)
280
+ reset if REQUIRE_RESET.include?(method)
281
+ @start_time ||= Time.now.to_f
282
+ end
283
+
284
+ def encrypt(data, key = nil)
285
+ if key
286
+ # Use provided key to create temporary blowfish cryptor
287
+ temp_cryptor = BlowfishCryptor.new(key)
288
+ temp_cryptor.encrypt(data)
289
+ elsif @cryptor
290
+ # Use existing cryptor's underlying blowfish cryptor
291
+ @cryptor.instance_variable_get(:@bf_out).encrypt(data)
292
+ else
293
+ raise ArgumentError, "No encryption key or cryptor available"
294
+ end
295
+ end
296
+
297
+ def decrypt(data, key = nil)
298
+ if key
299
+ # Use provided key to create temporary blowfish cryptor
300
+ temp_cryptor = BlowfishCryptor.new(key)
301
+ temp_cryptor.decrypt(data)
302
+ elsif @cryptor
303
+ # Use existing cryptor's underlying blowfish cryptor
304
+ @cryptor.instance_variable_get(:@bf_in).decrypt(data)
305
+ else
306
+ raise ArgumentError, "No decryption key or cryptor available"
307
+ end
308
+ end
309
+
310
+ def build_url(method, params = {})
311
+ protocol = REQUIRE_TLS.include?(method) ? "https" : "http"
312
+ port = @api_tls ? 443 : 80
313
+ # Only include port if it's non-standard
314
+ port_string = (protocol == "https" && port == 443) || (protocol == "http" && port == 80) ? "" : ":#{port}"
315
+ base_url = "#{protocol}://#{@api_host}#{port_string}#{@api_path}"
316
+
317
+ # Build query string with parameters in specific order: method first, then others
318
+ query_parts = []
319
+ query_parts << "method=#{CGI.escape(method)}"
320
+ query_parts << "auth_token=#{CGI.escape(auth_token)}" if auth_token
321
+ query_parts << "user_auth_token=#{CGI.escape(@user_auth_token)}" if @user_auth_token
322
+ query_parts << "partner_id=#{CGI.escape(@partner_id.to_s)}" if @partner_id
323
+ query_parts << "user_id=#{CGI.escape(@user_id.to_s)}" if @user_id
324
+
325
+ query_string = query_parts.join('&')
326
+
327
+ "#{base_url}?#{query_string}"
328
+ end
329
+
330
+ def build_params(method)
331
+ remove_empty_values({
332
+ method: method,
333
+ auth_token: auth_token,
334
+ partner_id: @partner_id,
335
+ user_id: @user_id
336
+ })
337
+ end
338
+
339
+ def build_data(method, data)
340
+ data = data.dup
341
+ data[:userAuthToken] = @user_auth_token if @user_auth_token
342
+ data[:partnerAuthToken] = @partner_auth_token if @partner_auth_token && !@user_auth_token
343
+ data[:syncTime] = sync_time
344
+
345
+ json_data = JSON.generate(remove_empty_values(data))
346
+
347
+ if NO_ENCRYPT.include?(method) || !@cryptor
348
+ { data: json_data, encrypted: false }
349
+ else
350
+ { data: @cryptor.encrypt(json_data), encrypted: true }
351
+ end
352
+ end
353
+
354
+ def make_http_request(url, data, params, encrypted = false)
355
+ begin
356
+ response = @connection.post(url) do |req|
357
+ # Don't set req.params since URL already has query string
358
+ # Ensure body is properly encoded as UTF-8 string
359
+ req.body = data.to_s.force_encoding('UTF-8')
360
+ # Set Content-Type for JSON, but not for encrypted data
361
+ req.headers['Content-Type'] = 'application/json' unless encrypted
362
+ # Match pydora's headers
363
+ req.headers['Accept'] = '*/*'
364
+ req.headers['Connection'] = 'keep-alive'
365
+ end
366
+
367
+ response.raise_error unless response.success?
368
+ response.body
369
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
370
+ # Let the retry middleware handle transient errors first
371
+ # If we get here, retries have been exhausted
372
+ raise NetworkError, "Network error: #{e.message}"
373
+ end
374
+ end
375
+
376
+ def parse_response(result)
377
+ parsed = JSON.parse(result)
378
+
379
+ if parsed["stat"] == "ok"
380
+ parsed["result"]
381
+ else
382
+ error_code = parsed["code"]
383
+ error_message = parsed["message"]
384
+ raise Pandoru.create_api_error(error_message, error_code)
385
+ end
386
+ rescue JSON::ParserError => e
387
+ raise NetworkError, "Invalid JSON response: #{e.message}"
388
+ end
389
+
390
+ def remove_empty_values(hash)
391
+ hash.reject { |_, v| v.nil? }
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,3 @@
1
+ module Pandoru
2
+ VERSION = "0.1.0"
3
+ end