dacpclient 0.2.6 → 0.2.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,18 +1,25 @@
1
- require 'faraday'
2
1
  require 'digest'
3
- require 'net/http'
4
2
  require 'uri'
5
- require 'cgi'
6
3
  require 'plist'
4
+ require 'dmapparser'
5
+ require 'faraday'
6
+ require 'dacpclient/faraday/flatter_params_encoder'
7
7
  require 'dacpclient/pairingserver'
8
- require 'dacpclient/dmapparser'
9
- require 'dacpclient/dmapbuilder'
8
+ require 'dacpclient/browser'
10
9
  require 'dacpclient/version'
10
+ require 'dacpclient/model'
11
+ require 'dacpclient/models/status'
12
+ require 'dacpclient/models/pair_info'
13
+ require 'dacpclient/models/playlist'
14
+ require 'dacpclient/models/playlists'
15
+ require 'dacpclient/models/play_queue_item'
16
+ require 'dacpclient/models/play_queue'
11
17
 
12
18
  module DACPClient
13
19
  # The Client class handles communication with the server
14
20
  class Client
15
- attr_accessor :guid, :hsgid
21
+ attr_accessor :hsgid
22
+ attr_writer :guid
16
23
  attr_reader :name, :host, :port, :session_id
17
24
 
18
25
  HOME_SHARING_HOST = 'https://homesharing.itunes.apple.com'
@@ -26,25 +33,23 @@ module DACPClient
26
33
  }.freeze
27
34
 
28
35
  def initialize(name, host = 'localhost', port = 3689)
29
- @client = Net::HTTP.new(host, port)
30
36
  @name = name
31
37
  @host = host
32
38
  @port = port
33
39
 
34
40
  @session_id = nil
35
41
  @hsgid = nil
36
- @mediarevision = 1
37
- @uri = URI::HTTP.build(host: @host, port: @port)
38
- @client = Faraday.new(url: @uri.to_s)
42
+ @media_revision = 1
43
+ setup_connection
39
44
  end
40
45
 
41
- [:play, :playpause, :stop, :pause,
46
+ [:play, :playpause, :stop, :pause,
42
47
  :nextitem, :previtem, :getspeakers].each do |action_name|
43
- define_method action_name do
48
+ define_method action_name do
44
49
  do_action action_name
45
50
  end
46
51
  end
47
-
52
+
48
53
  alias_method :previous, :previtem
49
54
  alias_method :prev, :previtem
50
55
  alias_method :next, :nextitem
@@ -70,22 +75,22 @@ module DACPClient
70
75
  end
71
76
 
72
77
  def pair(pin)
73
- pairingserver = PairingServer.new(self, '0.0.0.0', 1024)
78
+ pairingserver = PairingServer.new(name, guid)
74
79
  pairingserver.pin = pin
75
80
  pairingserver.start
76
81
  end
77
82
 
78
83
  def serverinfo
79
- do_action('server-info', {}, true)
84
+ do_action('server-info', clean_url: true)
80
85
  end
81
86
 
82
87
  def login
83
88
  response = nil
84
89
  if @hsgid.nil?
85
90
  pairing_guid = '0x' + guid
86
- response = do_action(:login, 'pairing-guid' => pairing_guid)
91
+ response = do_action(:login, :'pairing-guid' => pairing_guid)
87
92
  else
88
- response = do_action(:login, 'hasFP' => '1')
93
+ response = do_action(:login, hasFP: 1)
89
94
  end
90
95
  @session_id = response[:mlid]
91
96
  response
@@ -93,16 +98,22 @@ module DACPClient
93
98
 
94
99
  def pair_and_login(pin = nil)
95
100
  login
96
- rescue DACPForbiddenError => e
101
+ rescue DACPForbiddenError, Faraday::ConnectionFailed => e
97
102
  pin = 4.times.map { Random.rand(10) } if pin.nil?
98
- warn "#{e.result.status} error: Cannot login, starting pairing process"
103
+ if e.instance_of? DACPForbiddenError
104
+ message = e.result.status
105
+ else
106
+ message = e
107
+ end
108
+ warn "#{message} error: Cannot login, starting pairing process"
99
109
  warn "Pincode: #{pin}"
100
- pair(pin)
110
+ @host = pair(pin).host
111
+ setup_connection
101
112
  retry
102
113
  end
103
114
 
104
115
  def content_codes
105
- do_action('content-codes', {}, true)
116
+ do_action('content-codes', clean_url: true)
106
117
  end
107
118
 
108
119
  def track_length
@@ -122,9 +133,10 @@ module DACPClient
122
133
  alias_method :position=, :seek
123
134
 
124
135
  def status(wait = false)
125
- revision = wait ? @mediarevision : 1
126
- result = do_action(:playstatusupdate, 'revision-number' => revision)
127
- @mediarevision = result[:cmsr]
136
+ revision = wait ? @media_revision : 1
137
+ result = do_action(:playstatusupdate, :'revision-number' => revision,
138
+ model: Status)
139
+ @media_revision = result.media_revision
128
140
  result
129
141
  rescue Faraday::Error::TimeoutError => e
130
142
  if wait
@@ -140,7 +152,7 @@ module DACPClient
140
152
  end
141
153
 
142
154
  def volume=(volume)
143
- do_action(:setproperty, 'dmcp.volume' => volume)
155
+ do_action(:setproperty, :'dmcp.volume' => volume)
144
156
  end
145
157
 
146
158
  def repeat
@@ -162,12 +174,12 @@ module DACPClient
162
174
  end
163
175
 
164
176
  def ctrl_int
165
- do_action('ctrl-int', {}, true)
177
+ do_action('ctrl-int', clean_url: true)
166
178
  end
167
179
 
168
180
  def logout
169
181
  do_action(:logout)
170
- @mediarevision = 1
182
+ @media_revision = 1
171
183
  @session_id = nil
172
184
  end
173
185
 
@@ -181,35 +193,37 @@ module DACPClient
181
193
  end
182
194
 
183
195
  def list_queue
184
- do_action('playqueue-contents')
196
+ do_action('playqueue-contents', model: PlayQueue)
185
197
  end
186
198
 
187
199
  def databases
188
- do_action('databases', {}, true)
200
+ do_action('databases', clean_url: true)
189
201
  end
190
202
 
191
- def playlists(db)
192
- do_action("databases/#{db}/containers", {}, true)
203
+ def playlists(db = default_db)
204
+ do_action("databases/#{db.miid}/containers", clean_url: true,
205
+ model: Playlists).items
193
206
  end
194
207
 
195
208
  def default_db
196
- databases[:mlcl].to_a.find { |item| item.mdbk == 1 }
209
+ databases.mlcl.to_a.find { |item| item.mdbk == 1 }
197
210
  end
198
211
 
199
- def default_playlist(db)
200
- @client.playlists(72).mlcl.to_a.find { |item| item.abpl }
212
+ def default_playlist(db = default_db)
213
+ playlists(db).find { |item| item.base_playlist? }
201
214
  end
202
215
 
203
216
  def artwork(database, id, width = 320, height = 320)
204
217
  url = "databases/#{database}/items/#{id}/extra_data/artwork"
205
- do_action(url, { mw: width, mh: height }, true)
218
+ do_action(url, { mw: width, mh: height }, clean_url: true)
206
219
  end
207
220
 
208
221
  def now_playing_artwork(width = 320, height = 320)
209
222
  do_action(:nowplayingartwork, mw: width, mh: height)
210
223
  end
211
224
 
212
- def search(db, container, search, type = nil)
225
+ def search(search, type = nil, db = default_db,
226
+ container = default_playlist(default_db))
213
227
  search = URI.escape(search)
214
228
  types = {
215
229
  title: 'dmap.itemname',
@@ -225,40 +239,54 @@ module DACPClient
225
239
  end
226
240
 
227
241
  q = queries.join(',')
228
- meta = %w(dmap.itemname dmap.itemid daap.songartist daap.songalbumartist
242
+ q = '(' + q + ')' if queries.length > 1
243
+ meta = %w(dmap.itemname dmap.itemid com.apple.itunes.has-chapter-data
229
244
  daap.songalbum com.apple.itunes.cloud-id dmap.containeritemid
230
245
  com.apple.itunes.has-video com.apple.itunes.itms-songid
231
246
  com.apple.itunes.extended-media-kind dmap.downloadstatus
232
- daap.songdisabled).join(',')
233
-
234
- url = "databases/#{db}/containers/#{container}/items"
235
- do_action(url, { type: 'music', sort: 'album', query: q, meta: meta },
236
- true)
247
+ daap.songdisabled daap.songhasbeenplayed daap.songbookmark
248
+ com.apple.itunes.is-hd-video daap.songlongcontentdescription
249
+ daap.songtime daap.songuserplaycount daap.songartist
250
+ com.apple.itunes.content-rating daap.songdatereleased
251
+ com.apple.itunes.movie-info-xml daap.songalbumartist
252
+ com.apple.itunes.extended-media-kind).join(',')
253
+ url = "databases/#{db.miid}/containers/#{container.miid}/items"
254
+ do_action(url, { query: q, type: 'music', sort: 'album', meta: meta,
255
+ :'include-sort-headers' => 1 }, clean_url: true)
237
256
  end
238
257
 
239
258
  private
240
259
 
241
- def do_action(action, params = {}, cleanurl = false)
260
+ def setup_connection
261
+ @uri = URI::HTTP.build(host: @host, port: @port)
262
+ Faraday::Utils.default_params_encoder = Faraday::FlatterParamsEncoder
263
+ @client = Faraday.new(@uri.to_s)
264
+ end
265
+
266
+ def do_action(action, clean_url: false, model: nil, **params)
242
267
  action = '/' + action.to_s
243
268
  unless @session_id.nil?
244
- params['session-id'] = @session_id
245
- action = '/ctrl-int/1' + action unless cleanurl
269
+ params['session-id'] = @session_id.to_s
270
+ action = '/ctrl-int/1' + action unless clean_url
246
271
  end
247
272
  params['hsgid'] = @hsgid unless @hsgid.nil?
273
+
248
274
  result = @client.get do |request|
275
+ request.options.params_encoder = Faraday::FlatterParamsEncoder
249
276
  request.url action
250
277
  request.params = params
251
278
  request.headers.merge!(DEFAULT_HEADERS)
252
279
  end
253
280
 
254
- parse_result result
281
+ parse_result result, model
255
282
  end
256
-
257
- def parse_result(result)
283
+
284
+ def parse_result(result, model)
258
285
  if !result.success?
259
286
  fail DACPForbiddenError, result
260
287
  elsif result.headers['Content-Type'] == 'application/x-dmap-tagged'
261
- DMAPParser.parse(result.body)
288
+ res = DMAPParser::Parser.parse(result.body)
289
+ model ? model.new(res) : res
262
290
  else
263
291
  result.body
264
292
  end
@@ -0,0 +1,77 @@
1
+ # rubocop:disable all
2
+ require 'cgi'
3
+ module Faraday
4
+ module FlatterParamsEncoder
5
+ def self.escape(s)
6
+ s.to_s.gsub(/[^a-zA-Z0-9 .~_\-,:\*'\+()]/) do
7
+ '%' + $&.unpack('H2' * $&.bytesize).join('%').upcase
8
+ end.tr(' ', '+')
9
+ end
10
+
11
+ def self.unescape(s)
12
+ CGI.unescape(s.to_s)
13
+ end
14
+
15
+ def self.encode(params)
16
+ return nil if params.nil?
17
+
18
+ unless params.is_a?(Array)
19
+ unless params.respond_to?(:to_hash)
20
+ fail TypeError,
21
+ "Can't convert #{params.class} into Hash."
22
+ end
23
+ params = params.to_hash
24
+ params = params.map do |key, value|
25
+ key = key.to_s if key.kind_of?(Symbol)
26
+ [key, value]
27
+ end
28
+ # Useful default for OAuth and caching.
29
+ # Only to be used for non-Array inputs. Arrays should preserve order.
30
+ params.sort!
31
+ end
32
+
33
+ # The params have form [['key1', 'value1'], ['key2', 'value2']].
34
+ buffer = ''
35
+ params.each do |key, value|
36
+ encoded_key = escape(key)
37
+ value = value.to_s if value == true || value == false
38
+ if value.nil?
39
+ buffer << "#{encoded_key}&"
40
+ elsif value.kind_of?(Array)
41
+ value.each do |sub_value|
42
+ encoded_value = escape(sub_value)
43
+ buffer << "#{encoded_key}=#{encoded_value}&"
44
+ end
45
+ else
46
+ encoded_value = escape(value)
47
+ buffer << "#{encoded_key}=#{encoded_value}&"
48
+ end
49
+ end
50
+ buffer.chop
51
+ end
52
+
53
+ def self.decode(query)
54
+ empty_accumulator = {}
55
+ return nil if query.nil?
56
+ split_query = (query.split('&').map do |pair|
57
+ pair.split('=', 2) if pair && !pair.empty?
58
+ end).compact
59
+ split_query.reduce(empty_accumulator.dup) do |accu, pair|
60
+ pair[0] = unescape(pair[0])
61
+ pair[1] = true if pair[1].nil?
62
+ if pair[1].respond_to?(:to_str)
63
+ pair[1] = unescape(pair[1].to_str.gsub(/\+/, ' '))
64
+ end
65
+ if accu[pair[0]].kind_of?(Array)
66
+ accu[pair[0]] << pair[1]
67
+ elsif accu[pair[0]]
68
+ accu[pair[0]] = [accu[pair[0]], pair[1]]
69
+ else
70
+ accu[pair[0]] = pair[1]
71
+ end
72
+ accu
73
+ end
74
+ end
75
+ end
76
+ end
77
+ # rubocop:enable all
@@ -0,0 +1,117 @@
1
+ module DACPClient
2
+ class Model
3
+ class DMAPAttribute < Struct.new(:tag, :item_class, :value)
4
+ def initialize(tag, item_class = nil)
5
+ super tag, item_class, nil
6
+ end
7
+ end
8
+
9
+ def initialize(params = {})
10
+ if params.is_a? DMAPParser::TagContainer
11
+ deserialize(params)
12
+ elsif params
13
+ params.each do |attr, value|
14
+ public_send("#{attr}=", value)
15
+ end
16
+ end
17
+ end
18
+
19
+ def inspect
20
+ puts self.class.name
21
+ dmap_attributes.each do |key, value|
22
+ puts " #{key}: #{value.value}"
23
+ end
24
+ end
25
+
26
+ def to_s
27
+ "#<#{self.class.name} " + dmap_attributes.map do |key, value|
28
+ "#{key}: #{value.value}"
29
+ end.join(', ') + '>'
30
+ end
31
+
32
+ def to_dmap
33
+ attributes = dmap_attributes
34
+ DMAPParser::Builder.send dmap_tag do
35
+ attributes.values.each do |value|
36
+ send(value.tag, value.value)
37
+ end
38
+ end.to_dmap
39
+ end
40
+
41
+ def method_missing(method, *args, &block)
42
+ if method.to_s =~ /(.*)\=$/ &&
43
+ dmap_attributes.key?(Regexp.last_match[1].to_sym)
44
+ dmap_attributes[Regexp.last_match[1].to_sym].value = args.first
45
+ elsif method.to_s =~ /(.*)\?$/ &&
46
+ dmap_attributes.key?(Regexp.last_match[1].to_sym)
47
+ dmap_attributes[Regexp.last_match[1].to_sym].value
48
+ elsif dmap_attributes.key? method
49
+ dmap_attributes[method].value
50
+ else
51
+ super
52
+ end
53
+ end
54
+
55
+ class << self
56
+ def dmap_attribute(method, key)
57
+ @dmap_attributes ||= {}
58
+ @dmap_attributes[method] = key
59
+ end
60
+
61
+ def dmap_container(method, key, item_class)
62
+ @dmap_attributes ||= {}
63
+ @dmap_attributes[method] = [key, item_class]
64
+ end
65
+
66
+ def dmap_tag(tag = nil)
67
+ if tag
68
+ @dmap_tag = tag
69
+ else
70
+ @dmap_tag
71
+ end
72
+ end
73
+
74
+ def build_dmap(params = {})
75
+ new(params).to_dmap
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def deserialize(data)
82
+ warn 'Invalid tag' if data.type.tag.to_sym != dmap_tag
83
+ dmap_attributes.values.each do |value|
84
+ value.value = get_value(data, value) if data.respond_to? value.tag
85
+ end
86
+ self
87
+ end
88
+
89
+ def get_value(data, value)
90
+ item_class = value.item_class
91
+ if item_class
92
+ data.send(value.tag).to_a.map do |item|
93
+ item_class.new(item) if item_class.dmap_tag == item.type.tag.to_sym
94
+ end.compact
95
+ else
96
+ data.send(value.tag)
97
+ end
98
+ end
99
+
100
+ def dmap_attributes
101
+ @dmap_attributes ||= initialize_attributes
102
+ end
103
+
104
+ def initialize_attributes
105
+ class_attributes = self.class.instance_variable_get(:@dmap_attributes)
106
+ attributes = {}
107
+ class_attributes.map do |key, value|
108
+ attributes[key] = DMAPAttribute.new(*value)
109
+ end
110
+ attributes
111
+ end
112
+
113
+ def dmap_tag
114
+ self.class.instance_variable_get(:@dmap_tag)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,15 @@
1
+ module DACPClient
2
+ class PairInfo < Model
3
+ dmap_tag :cmpa
4
+
5
+ dmap_attribute :pairing_code, :cmpg
6
+ dmap_attribute :name, :cmnm
7
+ dmap_attribute :type, :cmty
8
+
9
+ # DMAPParser::Builder.cmpa do
10
+ # cmpg pair
11
+ # cmnm name
12
+ # cmty device_type
13
+ # end.to_dmap
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module DACPClient
2
+ class PlayQueue < Model
3
+ dmap_tag :ceQR
4
+ dmap_attribute :status, :mstt
5
+ dmap_attribute :container_count, :mtco
6
+ dmap_attribute :shuffle_mode, :apsm
7
+ dmap_attribute :repeat_mode, :aprm
8
+ # ceQu (unknown (1): unknown): 0
9
+ dmap_container :items, :mlcl, DACPClient::PlayQueueItem
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ module DACPClient
2
+ class PlayQueueItem < Model
3
+ dmap_tag :mlit
4
+ dmap_attribute :track_id, :ceQs
5
+ dmap_attribute :title, :ceQn
6
+ dmap_attribute :artist, :ceQr
7
+ dmap_attribute :album, :ceQa
8
+ dmap_attribute :genre, :ceQg
9
+ dmap_attribute :album_id, :asai
10
+
11
+ dmap_attribute :media_kind, :cmmk
12
+ dmap_attribute :song_time, :astm
13
+
14
+ # aeGs (com.apple.itunes.can-be-genius-seed: bool): true
15
+ # ceGS (com.apple.itunes.genius-selectable: bool): true
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ module DACPClient
2
+ class Playlist < Model
3
+ dmap_tag :mlit
4
+ dmap_attribute :item_id, :miid
5
+ dmap_attribute :name, :minm
6
+ dmap_attribute :base_playlist, :abpl
7
+ dmap_attribute :count, :mimc
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module DACPClient
2
+ class Playlists < Model
3
+ dmap_tag :aply
4
+ dmap_attribute :status, :mstt
5
+ dmap_attribute :update_type, :myty
6
+ dmap_attribute :container_count, :mtco
7
+ dmap_attribute :returned_count, :mrco
8
+ dmap_container :items, :mlcl, DACPClient::Playlist
9
+ end
10
+ end
@@ -0,0 +1,44 @@
1
+ module DACPClient
2
+ class Status < Model
3
+ dmap_tag :cmst
4
+ dmap_attribute :media_revision, :cmsr
5
+ dmap_attribute :status_code, :mstt
6
+ dmap_attribute :play_status, :caps
7
+ dmap_attribute :shuffle_state, :cash
8
+ dmap_attribute :repeat_state, :carp
9
+ dmap_attribute :fullscreen, :cafs
10
+ dmap_attribute :visualizer, :cavs
11
+ dmap_attribute :volume_controllable, :cavc
12
+ dmap_attribute :album_shuffle, :caas
13
+ dmap_attribute :album_repeat, :caar
14
+ dmap_attribute :fullscreen_enabled, :cafe
15
+ dmap_attribute :visualizer_enabled, :cave
16
+ dmap_attribute :track_id, :canp
17
+ dmap_attribute :title, :cann
18
+ dmap_attribute :artist, :cana
19
+ dmap_attribute :album, :canl
20
+ dmap_attribute :album_id, :asai
21
+ dmap_attribute :media_kind, :cmmk
22
+ dmap_attribute :song_time, :astm
23
+ dmap_attribute :song_length, :cast
24
+ dmap_attribute :song_remaining_time, :cant
25
+
26
+ def song_position
27
+ return 0 unless song_length? && song_remaining_time?
28
+ song_length - song_remaining_time
29
+ end
30
+
31
+ def stopped?
32
+ play_status == 2
33
+ end
34
+
35
+ def playing?
36
+ play_status == 4
37
+ end
38
+
39
+ def paused?
40
+ !stopped? && !playing?
41
+ end
42
+ # casu (dacp.su: byte): 0
43
+ end
44
+ end
@@ -2,19 +2,22 @@ require 'socket'
2
2
  require 'dnssd'
3
3
  require 'digest'
4
4
  require 'gserver'
5
+ require 'dmapparser/builder'
5
6
  module DACPClient
6
7
  # The pairingserver handles pairing with iTunes
7
8
  class PairingServer < GServer
8
9
  attr_accessor :pin, :device_type
10
+ attr_reader :peer
9
11
 
10
12
  MDNS_TYPE = '_touch-remote._tcp'.freeze
11
13
 
12
- def initialize(client, host, port = 1024)
13
- @name = client.name
14
+ def initialize(name, guid, host = '0.0.0.0', port = 1024)
15
+ @name = name
14
16
  @port = port
15
17
  @host = host
16
- @pair = client.guid
18
+ @pair = guid
17
19
  @pin = [0, 0, 0, 0]
20
+ @peer = nil
18
21
  @device_type = 'iPod'
19
22
  super port, host
20
23
  end
@@ -23,24 +26,33 @@ module DACPClient
23
26
  @pairing_string = generate_pairing_string(@pair, @name, @device_type)
24
27
  @expected = PairingServer.generate_pin_challenge(@pair, @pin)
25
28
  @service = DNSSD.register!(@name, MDNS_TYPE, 'local', @port, text_record)
26
-
29
+ @pairing_string
30
+ PairInfo.new(DMAPParser::Parser.parse(@pairing_string))
27
31
  super
28
32
  join
29
33
 
30
34
  @service.stop
31
35
 
32
36
  sleep 0.5 # sleep so iTunes accepts our login
37
+ peer
33
38
  end
34
39
 
35
40
  def self.generate_pin_challenge(pair, pin)
36
41
  pin_string = pin.map { |i| "#{i}\x00" }.join
37
- Digest::MD5.hexdigest(pair.upcase + pin_string)
42
+ Digest::MD5.hexdigest(pair.upcase + pin_string).upcase
38
43
  end
39
44
 
40
45
  def serve(client)
41
- if client.gets =~ /pairingcode=#{@expected}/i
42
- client.print "HTTP/1.1 200 OK\r\n" +
43
- "Content-Length: #{@pairing_string.length}\r\n\r\n"
46
+ data = client.gets
47
+ peer_addr = client.peeraddr[2]
48
+ browser = DACPClient::Browser.new
49
+ browser.browse
50
+ @peer = browser.devices.find do |device|
51
+ device.host == peer_addr
52
+ end
53
+ if data =~ /pairingcode=#{@expected}/i && @peer
54
+ client.print "HTTP/1.1 200 OK\n" +
55
+ "Content-Length: #{@pairing_string.length}\n\n"
44
56
  client.print @pairing_string
45
57
  client.close
46
58
  stop
@@ -64,11 +76,7 @@ module DACPClient
64
76
  end
65
77
 
66
78
  def generate_pairing_string(pair, name, device_type)
67
- DMAPBuilder.cmpa do
68
- cmpg pair
69
- cmnm name
70
- cmty device_type
71
- end.to_dmap
79
+ PairInfo.build_dmap(pairing_code: pair, name: name, type: device_type)
72
80
  end
73
81
  end
74
82
  end
@@ -1,4 +1,4 @@
1
1
  # The DACPClient module
2
2
  module DACPClient
3
- VERSION = '0.2.6'
3
+ VERSION = '0.2.9'
4
4
  end