maimai_net 0.0.1
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/.github/workflows/gem.yml +53 -0
- data/.gitignore +165 -0
- data/.rspec +5 -0
- data/Gemfile +4 -0
- data/LICENSE +32 -0
- data/README.md +47 -0
- data/Rakefile +6 -0
- data/bin/console +16 -0
- data/bin/setup +6 -0
- data/lib/maimai_net/client.rb +1104 -0
- data/lib/maimai_net/constants.rb +485 -0
- data/lib/maimai_net/core_ext.rb +12 -0
- data/lib/maimai_net/error.rb +55 -0
- data/lib/maimai_net/faraday_ext/cookie_jar.rb +38 -0
- data/lib/maimai_net/model-typing.rb +202 -0
- data/lib/maimai_net/model.rb +359 -0
- data/lib/maimai_net/module_ext.rb +437 -0
- data/lib/maimai_net/page-debug.rb +9 -0
- data/lib/maimai_net/page-html_helper.rb +131 -0
- data/lib/maimai_net/page-player_data_helper.rb +33 -0
- data/lib/maimai_net/page-track_result_helper.rb +90 -0
- data/lib/maimai_net/page.rb +606 -0
- data/lib/maimai_net/refines.rb +28 -0
- data/lib/maimai_net/region.rb +108 -0
- data/lib/maimai_net/user_option.rb +147 -0
- data/lib/maimai_net/util.rb +9 -0
- data/lib/maimai_net/version.rb +3 -0
- data/lib/maimai_net.rb +14 -0
- data/maimai-net.gemspec +44 -0
- metadata +172 -0
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'faraday/follow_redirects'
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
require 'maimai_net/page'
|
|
6
|
+
require 'maimai_net/faraday_ext/cookie_jar'
|
|
7
|
+
|
|
8
|
+
module MaimaiNet
|
|
9
|
+
module Client
|
|
10
|
+
using IncludeAutoConstant
|
|
11
|
+
|
|
12
|
+
KEY_MAP_CONSTANT = {
|
|
13
|
+
genre: MaimaiNet::Genre,
|
|
14
|
+
character: MaimaiNet::NameGroup,
|
|
15
|
+
word: MaimaiNet::NameGroup,
|
|
16
|
+
level: MaimaiNet::LevelGroup,
|
|
17
|
+
version: MaimaiNet::GameVersion,
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
PLURAL_KEY_MAP = {
|
|
21
|
+
genres: :genre,
|
|
22
|
+
characters: :character,
|
|
23
|
+
words: :word,
|
|
24
|
+
levels: :level,
|
|
25
|
+
versions: :version,
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
class Base
|
|
29
|
+
include ModuleExt
|
|
30
|
+
|
|
31
|
+
def initialize(username = nil, password = nil)
|
|
32
|
+
@username = username
|
|
33
|
+
@password = password
|
|
34
|
+
@cookies = HTTP::CookieJar.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :username, :password, :cookies
|
|
38
|
+
inspect_permit_variable_exclude :username, :password, :cookies
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << Base
|
|
42
|
+
def inherited(cls)
|
|
43
|
+
super
|
|
44
|
+
|
|
45
|
+
return unless self.singleton_class == method(__method__).owner
|
|
46
|
+
@_subclasses ||= []
|
|
47
|
+
@_subclasses << cls unless @_subclasses.include?(cls)
|
|
48
|
+
|
|
49
|
+
cls.singleton_class.undef_method :regions
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def regions
|
|
53
|
+
fail NoMethodError, "invalid call" unless self == method(__method__).owner
|
|
54
|
+
@_subclasses.dup
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def region_info
|
|
58
|
+
fail NotImplementedError, "this client is not associated with region information" if (@_properties.to_h rescue {}).empty?
|
|
59
|
+
@_properties.dup
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class Connection
|
|
64
|
+
# @param client [Base] client data
|
|
65
|
+
def initialize(client)
|
|
66
|
+
@client = client
|
|
67
|
+
@conn = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# automatically private hook methods
|
|
71
|
+
# @return [void]
|
|
72
|
+
def self.method_added(meth)
|
|
73
|
+
return super unless /^on_/.match? meth
|
|
74
|
+
private meth
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @!group Routes
|
|
78
|
+
public
|
|
79
|
+
# access home page
|
|
80
|
+
# @return [void]
|
|
81
|
+
def home
|
|
82
|
+
send_request('get', '/maimai-mobile/home', nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# logs out current session
|
|
86
|
+
# @return [void]
|
|
87
|
+
def logout
|
|
88
|
+
send_request('get', '/maimai-mobile/home/userOption/logout', nil)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# access player data
|
|
92
|
+
# @param diffs [Array<String, Symbol, Integer, MaimaiNet::Difficulty>] valid difficulty values
|
|
93
|
+
# @return [Model::PlayerData::Data] player's maimai deluxe difficulty statistics
|
|
94
|
+
# @raise [TypeError] invalid difficulty provided
|
|
95
|
+
# @raise [ArgumentError] no difficulty provided
|
|
96
|
+
def player_data(*diffs)
|
|
97
|
+
diffs.compact!
|
|
98
|
+
diffs.uniq!
|
|
99
|
+
fail ArgumentError, "expected at least 1, given #{diffs.size}" if diffs.empty?
|
|
100
|
+
|
|
101
|
+
diff_errors = []
|
|
102
|
+
diffs.reject do |diff|
|
|
103
|
+
case diff
|
|
104
|
+
when String, Symbol
|
|
105
|
+
MaimaiNet::Difficulty::DELUXE_WEBSITE.key?(diff.to_sym) ||
|
|
106
|
+
MaimaiNet::Difficulty::DELUXE_WEBSITE.key?(Difficulty::SHORTS.key(diff.to_sym))
|
|
107
|
+
when MaimaiNet::Difficulty # always true
|
|
108
|
+
true
|
|
109
|
+
when Integer
|
|
110
|
+
MaimaiNet::Difficulty::DELUXE_WEBSITE.value?(diff)
|
|
111
|
+
else
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
end.each do |diff|
|
|
115
|
+
case diff
|
|
116
|
+
when String, Symbol; diff_errors << [diff, KeyError]
|
|
117
|
+
when Integer; diff_errors << [diff, ArgumentError]
|
|
118
|
+
else; diff_errors << [diff, TypeError]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
unless diff_errors.empty?
|
|
123
|
+
fail TypeError, "at least one of difficulty provided are erroneous.\n%s" % [
|
|
124
|
+
diff_errors.map do |d, et| '(%s: %p)' % [et, d] end.join(', '),
|
|
125
|
+
]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
diffs.map! do |diff|
|
|
129
|
+
case diff
|
|
130
|
+
when String, Symbol; Difficulty(diff)
|
|
131
|
+
when MaimaiNet::Difficulty; diff
|
|
132
|
+
when Integer; Difficulty(deluxe_web_id: diff)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
diffs.sort_by! &:id
|
|
136
|
+
|
|
137
|
+
results = diffs.map do |diff|
|
|
138
|
+
send_request(
|
|
139
|
+
'get', '/maimai-mobile/playerData',
|
|
140
|
+
{diff: diff.deluxe_web_id},
|
|
141
|
+
response_page: Page::PlayerData,
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# aggregate results if necessary
|
|
146
|
+
if results.size > 1 then
|
|
147
|
+
user_diff_stat = {}
|
|
148
|
+
results.each do |result|
|
|
149
|
+
user_diff_stat.update result.statistics
|
|
150
|
+
end
|
|
151
|
+
results.first.class.new(
|
|
152
|
+
plate: results.first.plate,
|
|
153
|
+
statistics: user_diff_stat,
|
|
154
|
+
)
|
|
155
|
+
else
|
|
156
|
+
results.shift
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# access recently uploaded photo album page
|
|
161
|
+
# @return [Array<Model::PhotoUpload>] player's recently uploaded photos
|
|
162
|
+
def photo_album
|
|
163
|
+
send_request(
|
|
164
|
+
'get', '/maimai-mobile/playerData/photo', nil,
|
|
165
|
+
response_page: Page::PhotoUpload,
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# access recent session gameplay info
|
|
170
|
+
# @return [Array<Model::Result::TrackReference>]
|
|
171
|
+
def recent_plays
|
|
172
|
+
send_request(
|
|
173
|
+
'get', '/maimai-mobile/record', nil,
|
|
174
|
+
response_page: Page::RecentTrack,
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# access recent session gameplay info detail
|
|
179
|
+
# @return [Model::Result::Data]
|
|
180
|
+
def recent_play_info(ref)
|
|
181
|
+
id = case ref
|
|
182
|
+
when Model::Result::TrackReference
|
|
183
|
+
ref.ref_web_id.to_s
|
|
184
|
+
when Model::Result::ReferenceWebID
|
|
185
|
+
ref.to_s
|
|
186
|
+
when /^\d+,\d+$/
|
|
187
|
+
ref
|
|
188
|
+
else
|
|
189
|
+
fail TypeError, 'expected a valid index ID format'
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
send_request(
|
|
193
|
+
'get', '/maimai-mobile/record/playlogDetail', {idx: id},
|
|
194
|
+
response_page: Page::TrackResult,
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# access recent session gameplay detailed info
|
|
199
|
+
# @param [Integer, nil] amount of tracks to fetch
|
|
200
|
+
# @return [Array<Model::Result::Data>]
|
|
201
|
+
def recent_play_details(limit = nil)
|
|
202
|
+
commands = []
|
|
203
|
+
if Integer === limit then
|
|
204
|
+
if limit.positive? then
|
|
205
|
+
commands << ->(plays){plays.last(limit)}
|
|
206
|
+
else
|
|
207
|
+
fail ArgumentError, "expected positive size limit, given #{limit}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
plays = recent_plays.map(&:ref_web_id)
|
|
211
|
+
commands.each do |cmd| plays.replace cmd[plays] end
|
|
212
|
+
plays.map(&method(:recent_play_info))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# access given set best score
|
|
216
|
+
# @return [Model::Record::Data]
|
|
217
|
+
def music_record_info(ref)
|
|
218
|
+
id = case ref
|
|
219
|
+
when Model::WebID::DUMMY, Model::WebID::DUMMY_ID
|
|
220
|
+
fail ArgumentError, 'unable to use dummy ID for lookup'
|
|
221
|
+
when Model::WebID
|
|
222
|
+
ref.to_s
|
|
223
|
+
when String
|
|
224
|
+
ref
|
|
225
|
+
else
|
|
226
|
+
fail TypeError, 'expected a valid index ID format'
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
send_request(
|
|
230
|
+
'get', '/maimai-mobile/record/musicDetail', {idx: id},
|
|
231
|
+
response_page: Page::ChartsetRecord,
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# access finale archive page
|
|
236
|
+
# @return [Model::FinaleArchive::Data] player's archived maimai finale statistics
|
|
237
|
+
def finale_archive
|
|
238
|
+
send_request(
|
|
239
|
+
'get', '/maimai-mobile/home/congratulations', nil,
|
|
240
|
+
response_page: Page::FinaleArchive,
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @!endgroup
|
|
245
|
+
|
|
246
|
+
# @!group Hooks
|
|
247
|
+
|
|
248
|
+
# hook upon receiving a login page
|
|
249
|
+
# @return [void]
|
|
250
|
+
def on_login_request(url, body, **opts)
|
|
251
|
+
page = Nokogiri::HTML.parse(body)
|
|
252
|
+
form = page.at_css('form[action][method=post]:has(input[type=password])')
|
|
253
|
+
data = form.css('input').select do |elm|
|
|
254
|
+
%w(text password hidden).include? elm['type'].downcase
|
|
255
|
+
end.map do |elm|
|
|
256
|
+
[elm['name'], elm['value']]
|
|
257
|
+
end.inject({}) do |res, (name, value)|
|
|
258
|
+
res[name] = value
|
|
259
|
+
res
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
userkey = if data.key?('segaId') then 'segaId'
|
|
263
|
+
elsif data.key?('sid') then 'sid'
|
|
264
|
+
else fail NotImplementedError, 'user id compatible field not found'
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
data[userkey] = @client.username
|
|
268
|
+
data['password'] = @client.password
|
|
269
|
+
|
|
270
|
+
send_request(
|
|
271
|
+
form['method'],
|
|
272
|
+
url + form['action'],
|
|
273
|
+
data, **opts,
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# hook upon receiving login error page
|
|
278
|
+
# @return [void]
|
|
279
|
+
# @raise [Error::LoginError]
|
|
280
|
+
def on_login_error
|
|
281
|
+
fail Error::LoginError, 100101
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# hook upon receiving generic error page
|
|
285
|
+
# @return [void]
|
|
286
|
+
# @raise [Error::LoginError] error code describes an invalid login
|
|
287
|
+
# @raise [Error::SessionRefreshError] error code describes a vague request to visit homepage
|
|
288
|
+
# @raise [Error::SessionExpiredError] error code describes the session is fully expired and requires another login
|
|
289
|
+
# @raise [Error::GeneralError]
|
|
290
|
+
def on_error(body)
|
|
291
|
+
page = Nokogiri::HTML.parse(body)
|
|
292
|
+
error_elm = page.at_css('.container_red > div')
|
|
293
|
+
error_note = error_elm.text
|
|
294
|
+
error_code = error_note.match(/\d+/).to_s.to_i
|
|
295
|
+
|
|
296
|
+
case error_code
|
|
297
|
+
when 100101
|
|
298
|
+
fail Error::LoginError, error_code
|
|
299
|
+
when 200002
|
|
300
|
+
fail Error::SessionRefreshError, error_code
|
|
301
|
+
when 200004
|
|
302
|
+
fail Error::SessionExpiredError, error_code
|
|
303
|
+
else
|
|
304
|
+
fail Error::GeneralError, error_code
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @!endgroup
|
|
309
|
+
|
|
310
|
+
# @abstract sends request to given connection object
|
|
311
|
+
# @param method [Symbol, String] request method
|
|
312
|
+
# @param url [URI] request path
|
|
313
|
+
# @param data [String, Object] request body
|
|
314
|
+
# @param opts [Hash{Symbol => Object}]
|
|
315
|
+
# @option response_page [Class<Page::Base>] response parser class
|
|
316
|
+
# @option response [#call] response method, takes one argument, response body raw.
|
|
317
|
+
# @return [Model::Base::Struct] returns page data based from provided response_page field
|
|
318
|
+
# @return [void]
|
|
319
|
+
def send_request(method, url, data, **opts)
|
|
320
|
+
fail NotImplementedError, 'abstract method called' if Connection == method(__method__).owner
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# wraps form-based submission request
|
|
324
|
+
# @param endpoint [URI] request path
|
|
325
|
+
# @param query [String, Object] request query
|
|
326
|
+
# @parse response_page [Class<Page::Base>] fetch response parser class
|
|
327
|
+
# @yieldparam data [Hash{String => Object}] request data
|
|
328
|
+
# @yieldparam content parsed page content
|
|
329
|
+
# @yieldreturn [Boolean] any falsy-values will stop the processing
|
|
330
|
+
# @return [void]
|
|
331
|
+
def fetch_and_submit_form(endpoint, query, response_page:)
|
|
332
|
+
send_request(
|
|
333
|
+
'get', endpoint, query,
|
|
334
|
+
response: ->(body) {
|
|
335
|
+
page = response_page.parse(body)
|
|
336
|
+
root = page.instance_variable_get(:@root)
|
|
337
|
+
form = root.at_css('form[action][method=post]')
|
|
338
|
+
|
|
339
|
+
data = {}
|
|
340
|
+
|
|
341
|
+
form.css('input[type=hidden]').each do |hidden_input|
|
|
342
|
+
data.store hidden_input['name'], hidden_input['value']
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
result = yield data, page.data
|
|
346
|
+
return unless result
|
|
347
|
+
|
|
348
|
+
send_request(
|
|
349
|
+
form['method'], form['action'], data,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
true
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
# @!api private
|
|
359
|
+
# @param url [URI] response url
|
|
360
|
+
# @param body [String] response body
|
|
361
|
+
# @return [Model::Base::Struct, Array<Model::Base::Struct>] response page handled result
|
|
362
|
+
# @return [nil] no response page defined to handle the response
|
|
363
|
+
def process_response(url:, body:, request_options:)
|
|
364
|
+
info = @client.class.region_info
|
|
365
|
+
|
|
366
|
+
if info.key?(:login_error_proc) && info[:login_error_proc].call(url, body) then
|
|
367
|
+
return on_login_error
|
|
368
|
+
elsif url == URI.join(info[:website_base], info[:website_base].path + '/', 'error/') then
|
|
369
|
+
return on_error(body)
|
|
370
|
+
elsif info[:login_page_proc].call(url) then
|
|
371
|
+
return on_login_request(url, body, **request_options)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
if Class === request_options[:response_page] && request_options[:response_page] < Page::Base then
|
|
375
|
+
return request_options[:response_page].parse(body).data
|
|
376
|
+
elsif request_options[:response].respond_to?(:call) then
|
|
377
|
+
return request_options[:response].call(body)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Provides capability to handle the connection wrapping.
|
|
385
|
+
module ConnectionProvider
|
|
386
|
+
# @overload new(*args)
|
|
387
|
+
# First form, pass the remaining arguments into #initialize and wrap it into a connection.
|
|
388
|
+
# @return [Connection] default connection
|
|
389
|
+
# @overload new(key, *args)
|
|
390
|
+
# Second form, use mapped connection name to retrieve the connection,
|
|
391
|
+
# pass remaining arguments into #initialize and wrap it into a connection.
|
|
392
|
+
# @param key [Symbol] client connection name to use
|
|
393
|
+
# @return [Connection] provided connection from given key
|
|
394
|
+
# @overload new(cls, *args)
|
|
395
|
+
# Third form, use given class as connection,
|
|
396
|
+
# pass remaining arguments into #initialize and wrap it into a connection.
|
|
397
|
+
# @param cls [Class<Connection>] client connection class to use
|
|
398
|
+
# @return [Connection] provided connection
|
|
399
|
+
# @raise [ArgumentError] provided class is not a Connection.
|
|
400
|
+
# @raise [ArgumentError] invalid form.
|
|
401
|
+
def new(sym_or_cls = nil, *args, &block)
|
|
402
|
+
return method(__method__).call(nil, sym_or_cls, *args, &block) if String === sym_or_cls
|
|
403
|
+
sym_or_cls = self.default_connection if sym_or_cls.nil?
|
|
404
|
+
|
|
405
|
+
case sym_or_cls
|
|
406
|
+
when Symbol
|
|
407
|
+
cls = connections[sym_or_cls]
|
|
408
|
+
when Class
|
|
409
|
+
cls = sym_or_cls
|
|
410
|
+
fail ArgumentError, "expected Connection class, given #{cls}" unless cls < Connection
|
|
411
|
+
else
|
|
412
|
+
fail ArgumentError, "expected a connection name or a Connection-subclass, given #{sym_or_cls.class}"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
cls.new(super(*args, &block))
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# @param key [Symbol] connection name
|
|
419
|
+
# @param cls [Class<Connection>] connection class
|
|
420
|
+
# @return [void]
|
|
421
|
+
# @raise [ArgumentError] provided class is not a Connection.
|
|
422
|
+
def register_connection(key, cls)
|
|
423
|
+
fail ArgumentError, "expected Connection class, given #{cls}" unless cls < Connection
|
|
424
|
+
key = String(key).to_sym
|
|
425
|
+
connections.store(key, cls)
|
|
426
|
+
self.default_connection = key if connections.size.pred.zero?
|
|
427
|
+
nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# @return [Symbol, nil] currently assigned default connection for clients
|
|
431
|
+
def default_connection
|
|
432
|
+
class_variable_get(:@@default_connection)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# @param key [Symbol] connection name
|
|
436
|
+
# @return [void]
|
|
437
|
+
# @raise [KeyError] provided key is not a registered connection.
|
|
438
|
+
def default_connection=(key)
|
|
439
|
+
key = String(key).to_sym
|
|
440
|
+
fail KeyError, "'#{key}' is not registered" unless connections.key?(key)
|
|
441
|
+
class_variable_set(:@@default_connection, key)
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
private
|
|
446
|
+
# @return [Hash{Symbol => Class<Connection>}] set of key to connection class mappings
|
|
447
|
+
def connections
|
|
448
|
+
class_variable_get(:@@connections)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# initializes connection class variables
|
|
452
|
+
# @return [void]
|
|
453
|
+
def self.extended(cls)
|
|
454
|
+
super
|
|
455
|
+
|
|
456
|
+
cls.class_eval <<~EOT, __FILE__, __LINE__ + 1
|
|
457
|
+
@@default_connection = nil
|
|
458
|
+
@@connections = {}
|
|
459
|
+
EOT
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
module ConnectionProtocol
|
|
464
|
+
# wraps connection method definition with auto-retry capability
|
|
465
|
+
# @param opts [Hash]
|
|
466
|
+
# @option retry_count [Integer] set maximum allowed retries within this retry block
|
|
467
|
+
# @raise [Error::RetryExhausted] upon attempting to exceed the allowed amount of
|
|
468
|
+
# attempts for retrying the request.
|
|
469
|
+
def send_request(method, url, data, **opts)
|
|
470
|
+
fail NotImplementedError, 'connection is not defined' if @conn.nil?
|
|
471
|
+
|
|
472
|
+
# skip the wrapping if had been called recently
|
|
473
|
+
# * does not take account on "hooked" calls
|
|
474
|
+
prependers = self.class.ancestors
|
|
475
|
+
prependers.slice! (prependers.index(self.class)..)
|
|
476
|
+
|
|
477
|
+
stack = caller_locations(1).select do |trace| __method__.id2name == trace.label end.first(prependers.size)
|
|
478
|
+
if stack.size > 0 then
|
|
479
|
+
prev = stack[prependers.reverse.index(ConnectionProtocol)]
|
|
480
|
+
return super if __method__.id2name == prev.label # do not wrap further if it's a super call
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
max_count = retry_count = Integer === opts[:retry_count] ? opts[:retry_count] : 3
|
|
484
|
+
begin
|
|
485
|
+
super
|
|
486
|
+
rescue Error::RequestRetry
|
|
487
|
+
retry_count -= 1
|
|
488
|
+
retry unless retry_count.negative?
|
|
489
|
+
fail Error::RetryExhausted, "attempt exceeds #{max_count} retries"
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
module ConnectionMaintenanceSafety
|
|
495
|
+
# @return [Range(Time, Time)] JST's today maintenance schedule (in local time).
|
|
496
|
+
def maintenance_period
|
|
497
|
+
ctime = Time.now
|
|
498
|
+
atime = ctime.dup.localtime(32400)
|
|
499
|
+
start_mt = Time.new(
|
|
500
|
+
atime.year, atime.month, atime.day,
|
|
501
|
+
4, 0, 0, atime.utc_offset,
|
|
502
|
+
).localtime(ctime.utc_offset)
|
|
503
|
+
(start_mt)...(start_mt + 10_800)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# prevents connection during maintenance period.
|
|
507
|
+
# @raise [Error::RoutineMaintenance] raised if invoked during maintenance period.
|
|
508
|
+
def send_request(method, url, data, **opts)
|
|
509
|
+
ctime = Time.now
|
|
510
|
+
period = maintenance_period
|
|
511
|
+
fail Error::RoutineMaintenance, period if period.include?(ctime)
|
|
512
|
+
|
|
513
|
+
super
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
module ConnectionSupportSongList
|
|
518
|
+
using ObjectAsArray
|
|
519
|
+
|
|
520
|
+
# access user's best scores of all music on given sorting mode
|
|
521
|
+
# @param options [Hash] query parameter
|
|
522
|
+
# @option genre [Integer, Constants::Genre]
|
|
523
|
+
# @option character [Integer, Constants::NameGroup]
|
|
524
|
+
# @option level [Integer, Constants::LevelGroup]
|
|
525
|
+
# @option version [Integer, Symbol, Constants::GameVersion]
|
|
526
|
+
# @option diff [Integer, Constants::Difficulty]
|
|
527
|
+
# @return [Hash{Symbol => Array<Model::Record::InfoRating>}]
|
|
528
|
+
def song_list(category, **options)
|
|
529
|
+
fail ArgumentError, "#{category} is not a valid key" unless /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/.match?(category)
|
|
530
|
+
|
|
531
|
+
converted_options = options.map do |key, value|
|
|
532
|
+
next [key, value] unless Symbol === value
|
|
533
|
+
raw_value = KEY_MAP_CONSTANT[key].new(value)
|
|
534
|
+
[key, raw_value]
|
|
535
|
+
end.to_h
|
|
536
|
+
|
|
537
|
+
options.update(converted_options)
|
|
538
|
+
|
|
539
|
+
options.transform_keys! do |key|
|
|
540
|
+
if key == :character then
|
|
541
|
+
:word
|
|
542
|
+
else
|
|
543
|
+
key
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
options.transform_values! do |value|
|
|
548
|
+
case value
|
|
549
|
+
when MaimaiNet::Genre, MaimaiNet::NameGroup, MaimaiNet::LevelGroup, MaimaiNet::GameVersion, MaimaiNet::Difficulty
|
|
550
|
+
value.deluxe_web_id
|
|
551
|
+
else
|
|
552
|
+
value
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
send_request(
|
|
557
|
+
'get', "/maimai-mobile/record/music#{category}/search", options,
|
|
558
|
+
response_page: Page::MusicList,
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def song_list_by_genre(genres:, diffs:)
|
|
563
|
+
map_product_combine_result(genres, diffs) do |genre, diff|
|
|
564
|
+
assert_parameter :diff, diff, 0..4, 10
|
|
565
|
+
assert_parameter :genre, genre, 99, 101..106
|
|
566
|
+
|
|
567
|
+
song_list :Genre, genre: genre, diff: diff
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def song_list_by_title(characters:, diffs:)
|
|
572
|
+
map_product_combine_result(characters, diffs) do |character, diff|
|
|
573
|
+
assert_parameter :diff, diff, 0..4
|
|
574
|
+
assert_parameter :character, character, 0..15
|
|
575
|
+
|
|
576
|
+
song_list :Word, character: character, diff: diff
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def song_list_by_level(levels:)
|
|
581
|
+
levels.as_unique_array.map do |level|
|
|
582
|
+
assert_parameter :level, level, 1..6, 7..23
|
|
583
|
+
|
|
584
|
+
song_list :Level, level: level
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def song_list_by_version(versions:, diffs:)
|
|
589
|
+
map_product_combine_result(versions, diffs) do |version, diff|
|
|
590
|
+
assert_parameter :diff, diff, 0..4
|
|
591
|
+
assert_parameter :version, version, 0..23
|
|
592
|
+
|
|
593
|
+
song_list :Version, version: version, diff: diff
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# retrieves player's best score of given difficulty based on given filtering
|
|
598
|
+
# @param sort [Integer, Symbol, Constants::BestScoreSortType] preferred sorting
|
|
599
|
+
# @param diffs [Integer, Symbol, Constants::Difficulty, Array<Integer, Symbol, Constants::Difficulty] difficulties of preferred filter to fetch from
|
|
600
|
+
# @param played_only [Boolean] include difficulties without any score registered
|
|
601
|
+
# @param filters [Hash{Symbol => Object}] set of filters to apply for
|
|
602
|
+
# @option all [true] fetch all songs without any category grouping, takes no effect when specified as 2nd or later filter.
|
|
603
|
+
# @option genres [:all, Integer, Symbol, Constants::Genre, Array<Integer, Symbol, Constants::Genre>]
|
|
604
|
+
# @option characters [:all, Integer, Symbol, Constants::NameGroup, Array<Integer, Symbol, Constants::NameGroup>]
|
|
605
|
+
# @option levels [:all, Integer, Symbol, Constants::LevelGroup, Array<Integer, Symbol, Constants::LevelGroup>]
|
|
606
|
+
# @option versions [:all, Integer, Symbol, Constants::GameVersion, Array<Integer, Symbol, Constants::GameVersion>]
|
|
607
|
+
# @return [Array<MaimaiNet::Model::Record::InfoCategory>] list of best score of each songs on given difficulties without any category grouping applied (through all: true as first)
|
|
608
|
+
# @return [Hash{Symbol => Array<MaimaiNet::Model::Record::InfoCategory>}] list of best score of each songs on given difficulties grouped based on first category set
|
|
609
|
+
def song_list_by_custom(sort:, diffs:, played_only: true, **filters)
|
|
610
|
+
normalize_isc = ->(type, value) {
|
|
611
|
+
raw_value = value
|
|
612
|
+
|
|
613
|
+
case value
|
|
614
|
+
when Integer
|
|
615
|
+
return value
|
|
616
|
+
when Symbol
|
|
617
|
+
base_class = case type
|
|
618
|
+
when :sort; MaimaiNet::BestScoreSortType
|
|
619
|
+
when :diff; MaimaiNet::Difficulty
|
|
620
|
+
when *KEY_MAP_CONSTANT.keys;
|
|
621
|
+
KEY_MAP_CONSTANT[type]
|
|
622
|
+
end
|
|
623
|
+
fail ArgumentError, "expected key (#{type}) is compatible constant class" if base_class.nil?
|
|
624
|
+
|
|
625
|
+
raw_value = base_class.new(value)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
if MaimaiNet::Constant === raw_value then
|
|
629
|
+
fail ArgumentError, "Provided value #{value.inspect} is not a valid value for '#{type}'" if raw_value.deluxe_web_id.nil?
|
|
630
|
+
return raw_value.deluxe_web_id
|
|
631
|
+
end
|
|
632
|
+
fail ArgumentError, "expected Integer, Symbol or MaimaiNet::Constant classes. given #{raw_value.class}"
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
convert_values = ->(type, base_value) {
|
|
636
|
+
case type
|
|
637
|
+
when :all
|
|
638
|
+
fail ArgumentError, '"all" filter must not a false' unless base_value
|
|
639
|
+
return :A
|
|
640
|
+
when *PLURAL_KEY_MAP.keys
|
|
641
|
+
else fail ArgumentError, "invalid filter '#{type}'"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
prefix = case type
|
|
645
|
+
when :genres; :G
|
|
646
|
+
when :characters, :words; :W
|
|
647
|
+
when :levels; :L
|
|
648
|
+
when :versions; :V
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
return prefix if Symbol === base_value && base_value.downcase == :all
|
|
652
|
+
|
|
653
|
+
base_value.as_unique_array.yield_self do |values|
|
|
654
|
+
values.map do |raw_value|
|
|
655
|
+
value = raw_value
|
|
656
|
+
PLURAL_KEY_MAP[type].yield_self do |singular_type|
|
|
657
|
+
KEY_MAP_CONSTANT[singular_type]
|
|
658
|
+
end.yield_self do |cls|
|
|
659
|
+
fail TypeError, "given Symbol, expected key (#{type}) is compatible constant class" if cls.nil?
|
|
660
|
+
cls.new(value)
|
|
661
|
+
end if Symbol === value
|
|
662
|
+
|
|
663
|
+
case value
|
|
664
|
+
when Integer
|
|
665
|
+
"#{prefix}-#{value}"
|
|
666
|
+
when MaimaiNet::Genre, MaimaiNet::NameGroup, MaimaiNet::LevelGroup, MaimaiNet::GameVersion
|
|
667
|
+
"#{prefix}-#{value.deluxe_web_id}"
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
# filtering rules:
|
|
674
|
+
# - each filter represents an intersection relation
|
|
675
|
+
# - each variant in a filter represents a union relation
|
|
676
|
+
# - each variant in a difficulty parameter represents a union relation
|
|
677
|
+
#
|
|
678
|
+
# when first filter acts, it populates the song_list first
|
|
679
|
+
# afterwards, every following filter does:
|
|
680
|
+
# - all filters (all: true and <filter>: all) are skipped
|
|
681
|
+
# - set filtered_list flag to true
|
|
682
|
+
# - removes any song that doesn't intersect with the result
|
|
683
|
+
head_type, head_value = filters.shift
|
|
684
|
+
song_list = []
|
|
685
|
+
|
|
686
|
+
if head_type === :all then
|
|
687
|
+
head_values = convert_values.call(head_type, head_value)
|
|
688
|
+
else
|
|
689
|
+
head_values = convert_values.call(head_type, head_value)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
sort = normalize_isc.call(:sort, sort)
|
|
693
|
+
diffs = diffs.as_unique_array.map do |diff|
|
|
694
|
+
normalize_isc.call(:diff, diff)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
filters.reject! do |key, value| (key == :all && value) || value == :all end
|
|
698
|
+
processed_filters = filters.map &convert_values
|
|
699
|
+
|
|
700
|
+
quick_concat = ->(k, v1, v2) { v1.concat(v2) }
|
|
701
|
+
send = ->(search, diff) {
|
|
702
|
+
send_request(
|
|
703
|
+
'get', "/maimai-mobile/record/musicSort/search",
|
|
704
|
+
{
|
|
705
|
+
search: search,
|
|
706
|
+
sort: sort,
|
|
707
|
+
diff: diff,
|
|
708
|
+
playCheck: played_only ? 'on' : nil,
|
|
709
|
+
}.compact,
|
|
710
|
+
response_page: Page::MusicList,
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
# do not use web_id to compare
|
|
714
|
+
# web_id differs per source filter
|
|
715
|
+
get_id = ->(chart_info) {
|
|
716
|
+
[chart_info.info.type, chart_info.info.title].join(':')
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
# this is potentially adding unnecessary overhead for sorting everything first
|
|
720
|
+
create_sort_indices = ->(ary) {
|
|
721
|
+
# index 0 is always unique
|
|
722
|
+
# index 1 or higher is based on sort rank, nullity gives lowest value automatically
|
|
723
|
+
indices = Array.new(1 + MaimaiNet::BestScoreSortType::LIBRARY.size) do |j|
|
|
724
|
+
ary.map.each_with_index do |best_info, i| [best_info.object_id, j.positive? ? ary.size : i] end.to_h
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
assign_ranks = ->(high_index:, low_index:) {
|
|
728
|
+
->(sorted) {
|
|
729
|
+
sorted.each_with_index do |best_info, rank|
|
|
730
|
+
high_rank = sorted.size - (rank + 1)
|
|
731
|
+
low_rank = rank
|
|
732
|
+
|
|
733
|
+
indices[high_index][best_info.object_id] = high_rank
|
|
734
|
+
indices[low_index][best_info.object_id] = low_rank
|
|
735
|
+
end
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
ary.reject do |best_info| best_info.score.nil? end
|
|
740
|
+
.tap do |played_ary|
|
|
741
|
+
played_ary.sort_by do |best_info|
|
|
742
|
+
best_info.score.score
|
|
743
|
+
end.tap &assign_ranks.call(high_index: 1, low_index: 2)
|
|
744
|
+
|
|
745
|
+
played_ary.sort_by do |best_info|
|
|
746
|
+
dx = best_info.score.deluxe_score
|
|
747
|
+
dx.max.positive? ? Rational(dx.value, dx.max) : 0
|
|
748
|
+
end.tap &assign_ranks.call(high_index: 3, low_index: 4)
|
|
749
|
+
|
|
750
|
+
combo_grades = %i(AP+ AP FC+ FC)
|
|
751
|
+
played_ary.sort_by do |best_info|
|
|
752
|
+
flags = best_info.score.flags
|
|
753
|
+
combo_grades.find_index do |flag| flags.include?(flag) end
|
|
754
|
+
.yield_self do |rank| rank.nil? ? combo_grades.size : rank end
|
|
755
|
+
end.tap &assign_ranks.call(high_index: 6, low_index: 5)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
indices
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
head_values.as_unique_array.inject({}) do |result, search_value|
|
|
762
|
+
diffs.inject({}) do |diff_result, diff_value|
|
|
763
|
+
response = send.call(search_value, diff_value)
|
|
764
|
+
response = {} if response.empty?
|
|
765
|
+
|
|
766
|
+
diff_result.update(response, &quick_concat)
|
|
767
|
+
end.yield_self do |diff_result|
|
|
768
|
+
result.update(diff_result, &quick_concat)
|
|
769
|
+
end
|
|
770
|
+
end.yield_self do |result|
|
|
771
|
+
ids = result.values.inject([], :concat)
|
|
772
|
+
.map(&get_id)
|
|
773
|
+
|
|
774
|
+
processed_filters.inject(ids) do |filter_result, search_values|
|
|
775
|
+
break filter_result if filter_result.empty?
|
|
776
|
+
|
|
777
|
+
search_values.inject([]) do |search_result, search_value|
|
|
778
|
+
nil.yield_self do
|
|
779
|
+
search_value.start_with?('L') ?
|
|
780
|
+
diffs :
|
|
781
|
+
diffs.min
|
|
782
|
+
end.as_unique_array.inject([]) do |diff_result, diff_value|
|
|
783
|
+
response = send.call(search_value, diff_value)
|
|
784
|
+
response = response.values.inject([], :concat) if Hash === response
|
|
785
|
+
diff_result.concat response
|
|
786
|
+
end.yield_self do |diff_result|
|
|
787
|
+
diff_result.map(&get_id)
|
|
788
|
+
end.yield_self &search_result.method(:union)
|
|
789
|
+
end.yield_self &filter_result.method(:intersection)
|
|
790
|
+
end.yield_self do |filtered_ids|
|
|
791
|
+
result.transform_values! do |category_info_list|
|
|
792
|
+
category_info_list.select do |category_info|
|
|
793
|
+
filtered_ids.include?(get_id.call(category_info))
|
|
794
|
+
end
|
|
795
|
+
end.transform_values! do |category_info_list|
|
|
796
|
+
flat_indices = create_sort_indices.call(category_info_list)
|
|
797
|
+
.values_at(sort, 0).yield_self do |sort_indices|
|
|
798
|
+
head_indices = sort_indices.first.keys
|
|
799
|
+
head_indices.map do |k|
|
|
800
|
+
[k, sort_indices.map do |h| h[k] end]
|
|
801
|
+
end.to_h
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
category_info_list.sort_by do |category_info|
|
|
805
|
+
flat_indices[category_info.object_id]
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
private
|
|
813
|
+
def assert_parameter(key, value, *constraints)
|
|
814
|
+
raw_value = value
|
|
815
|
+
case value
|
|
816
|
+
when MaimaiNet::Difficulty, MaimaiNet::Genre,
|
|
817
|
+
MaimaiNet::NameGroup, MaimaiNet::LevelGroup,
|
|
818
|
+
MaimaiNet::GameVersion
|
|
819
|
+
raw_value = value.deluxe_web_id
|
|
820
|
+
when Symbol
|
|
821
|
+
raw_value = KEY_MAP_CONSTANT[key].yield_self do |cls|
|
|
822
|
+
fail TypeError, "given Symbol, expected key (#{key}) is compatible constant class" if cls.nil?
|
|
823
|
+
cls.new(value).deluxe_web_id
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
fail Error::ClientError, sprintf(
|
|
828
|
+
'%s type assertion fails, given %p (%p), expected %s',
|
|
829
|
+
key, value, raw_value,
|
|
830
|
+
constraints.join(', '),
|
|
831
|
+
) unless constraints.any? do |constraint|
|
|
832
|
+
if Class === constraint && !constraint.singleton_class? && constraint < MaimaiNet::Constant then
|
|
833
|
+
constraint.deluxe_web_id?(raw_value)
|
|
834
|
+
elsif constraint.respond_to?(:include?) then
|
|
835
|
+
constraint.include?(raw_value)
|
|
836
|
+
else
|
|
837
|
+
constraint === raw_value
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
nil
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def map_product_combine_result(*lists, &block)
|
|
844
|
+
fail ArgumentError, 'no lists given' if lists.empty?
|
|
845
|
+
head = lists.shift.as_unique_array
|
|
846
|
+
return head if lists.empty?
|
|
847
|
+
|
|
848
|
+
lists.map! &:as_unique_array
|
|
849
|
+
head.product(*lists).map(&block).inject({}) do |h, data|
|
|
850
|
+
h.update(data) do |k, v1, v2|
|
|
851
|
+
v1.concat(v2)
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
module ConnectionSupportUserOption
|
|
858
|
+
# obtain current user's gameplay settings
|
|
859
|
+
# @return [Hash<Symbol, UserOption::Option>]
|
|
860
|
+
def get_gameplay_settings
|
|
861
|
+
send_request(
|
|
862
|
+
'get', '/home/userOption/updateUserOption', nil,
|
|
863
|
+
response_page: Page::UserOption,
|
|
864
|
+
)
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# @!overload set_gameplay_settings(settings)
|
|
868
|
+
# @param changes [Hash{Symbol => UserOption::Option}]
|
|
869
|
+
# @!overload set_gameplay_settings(settings)
|
|
870
|
+
# @param changes [Hash{Symbol => UserOption::Choice, Symbol, Integer}]
|
|
871
|
+
# @return [void]
|
|
872
|
+
def set_gameplay_settings(changes)
|
|
873
|
+
fail TypeError, "expected Hash given #{changes.class}" unless Hash === changes
|
|
874
|
+
fail ArgumentError, "provided empty argument" if changes.empty?
|
|
875
|
+
changes.each_key do |k|
|
|
876
|
+
fail TypeError, "expected Symbol keys, given #{k.class}" unless Symbol === k
|
|
877
|
+
end
|
|
878
|
+
changes.values.tap do |options|
|
|
879
|
+
all_options = options.all? do |option| UserOption::Option === option end
|
|
880
|
+
all_raw = options.all? do |option|
|
|
881
|
+
[UserOption::Choice, Symbol, Integer].any? do |cls| cls === option end
|
|
882
|
+
end
|
|
883
|
+
option_classes = options.map(&:class).uniq
|
|
884
|
+
fail TypeError, "expected either all #{UserOption::Option} or any of #{UserOption::Choice}, Symbol, or Integer. given #{option_classes.join(', ')}" unless all_options || all_raw
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
fetch_and_submit_form(
|
|
888
|
+
'/home/userOption/updateUserOption', nil,
|
|
889
|
+
response_page: Page::UserOption,
|
|
890
|
+
) do |data, current|
|
|
891
|
+
# do not send update query if there's no change
|
|
892
|
+
return if changes == current
|
|
893
|
+
|
|
894
|
+
update = {}
|
|
895
|
+
update.update(current, changes) do |key, option_old, option_new|
|
|
896
|
+
case option_new
|
|
897
|
+
when UserOption::Option then option_new
|
|
898
|
+
when Integer, Symbol then
|
|
899
|
+
option_old.dup.tap do |option|
|
|
900
|
+
option.select(option_new)
|
|
901
|
+
end
|
|
902
|
+
when UserOption::Choice then
|
|
903
|
+
option_old.dup.tap do |option|
|
|
904
|
+
fail ArgumentError, "provided choice #{option_new.inspect} is not part of '#{option_old.name}' option." unless option_old.choices.include?(option_new)
|
|
905
|
+
option.selected = option_new
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
update.transform_values do |option|
|
|
911
|
+
option.selected_id
|
|
912
|
+
end.tap &data.method(:update)
|
|
913
|
+
|
|
914
|
+
true
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
module ConnectionSupportUserFavorite
|
|
920
|
+
# get current available songs
|
|
921
|
+
# @return [Array<Model::SongEntry>]
|
|
922
|
+
# @see #get_favorites
|
|
923
|
+
def get_songs
|
|
924
|
+
send_request(
|
|
925
|
+
'get', '/home/userOption/favorite/updateMusic', nil,
|
|
926
|
+
response_page: Page::UserFavorite,
|
|
927
|
+
).map(&:song)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# get current user favorite songs
|
|
931
|
+
# @return [Array<Model::SongEntry>]
|
|
932
|
+
# @see #get_songs
|
|
933
|
+
def get_favorites
|
|
934
|
+
send_request(
|
|
935
|
+
'get', '/home/userOption/favorite/updateMusic', nil,
|
|
936
|
+
response_page: Page::UserFavorite,
|
|
937
|
+
).select(&:flag).map(&:song)
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
# set current user favorite songs
|
|
941
|
+
# @param songs [Array<Model::SongFavoriteInfo, Model::SongEntry, Model::WebID>] list of songs to mark as favorite
|
|
942
|
+
# @return [void]
|
|
943
|
+
def set_favorites(songs)
|
|
944
|
+
fail TypeError, "expected Array, given #{songs.class}" unless Array === songs
|
|
945
|
+
song_classes = songs.map(&:class).uniq
|
|
946
|
+
fail TypeError, sprintf(
|
|
947
|
+
'expected Array of <%1$p, %2$p, %3$p>. given %4$p',
|
|
948
|
+
Model::SongFavoriteInfo, Model::SongEntry, Model::WebID,
|
|
949
|
+
song_classes,
|
|
950
|
+
) unless songs.all? do |song|
|
|
951
|
+
[Model::SongFavoriteInfo, Model::SongEntry, Model::WebID].any? do |cls| cls === song end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
songs.select! do |info|
|
|
955
|
+
Model::SongFavoriteInfo === info ? info.flag : true
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
fail ArgumentError, "expected array size is 30 or less, given #{songs.size}" if songs.size > 30
|
|
959
|
+
|
|
960
|
+
songs.map! do |info|
|
|
961
|
+
song = info
|
|
962
|
+
song = info.song if Model::SongFavoriteInfo === info
|
|
963
|
+
song = info.web_id if Model::SongEntry
|
|
964
|
+
song
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
fetch_and_submit_form(
|
|
968
|
+
'/home/userOption/favorite/updateMusic', nil,
|
|
969
|
+
response_page: Page::UserFavorite,
|
|
970
|
+
) do |data|
|
|
971
|
+
data[:music] = songs
|
|
972
|
+
true
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
class Base
|
|
978
|
+
extend ConnectionProvider
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
class Connection
|
|
982
|
+
include ConnectionSupportSongList
|
|
983
|
+
include ConnectionSupportUserOption
|
|
984
|
+
include ConnectionSupportUserFavorite
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
class << Connection
|
|
988
|
+
def inherited(cls)
|
|
989
|
+
cls.prepend ConnectionProtocol, ConnectionMaintenanceSafety
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
class FaradayConnection < Connection
|
|
994
|
+
# (see Connection#initialize)
|
|
995
|
+
def initialize(client)
|
|
996
|
+
super
|
|
997
|
+
info = client.class.region_info
|
|
998
|
+
|
|
999
|
+
@conn = Faraday.new(url: info[:base_host]) do |builder|
|
|
1000
|
+
builder.request :url_encoded
|
|
1001
|
+
builder.response :follow_redirects
|
|
1002
|
+
builder.use :cookie_jar, jar: client.cookies
|
|
1003
|
+
end
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# insert logging middleware into the connector, replaces if necessary
|
|
1007
|
+
# @return [void]
|
|
1008
|
+
def log!
|
|
1009
|
+
replace_connector do |builder|
|
|
1010
|
+
builder.response :logger, nil, headers: false, bodies: false, log_level: :info
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# (see Connection#logout)
|
|
1015
|
+
def logout
|
|
1016
|
+
exclude_middlewares :follow_redirects do
|
|
1017
|
+
super
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
# (see Connection#send_request)
|
|
1022
|
+
def send_request(method, url, data, **opts)
|
|
1023
|
+
body = Faraday::METHODS_WITH_BODY.include?(method) ? data : nil
|
|
1024
|
+
|
|
1025
|
+
resp = @conn.run_request(
|
|
1026
|
+
method.to_s.downcase.to_sym, url,
|
|
1027
|
+
body, nil,
|
|
1028
|
+
) do |req|
|
|
1029
|
+
req.params.update(data) if Faraday::METHODS_WITH_QUERY.include?(method) && Hash === data
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
process_response(
|
|
1033
|
+
url: resp.env.url,
|
|
1034
|
+
body: resp.body,
|
|
1035
|
+
request_options: opts,
|
|
1036
|
+
)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
private
|
|
1040
|
+
def replace_connector(&block)
|
|
1041
|
+
orig = @conn
|
|
1042
|
+
if @conn.builder.instance_variable_get(:@app) then
|
|
1043
|
+
@conn = @conn.dup
|
|
1044
|
+
@conn.builder.instance_variable_set(:@app, nil) # reset app state to refresh the handlers
|
|
1045
|
+
end
|
|
1046
|
+
@conn.builder.build(&block)
|
|
1047
|
+
nil
|
|
1048
|
+
rescue
|
|
1049
|
+
@conn = orig
|
|
1050
|
+
nil
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def exclude_middlewares(*middlewares)
|
|
1054
|
+
return if middlewares.empty?
|
|
1055
|
+
|
|
1056
|
+
orig = @conn
|
|
1057
|
+
if @conn.builder.instance_variable_get(:@app) then
|
|
1058
|
+
@conn = @conn.dup
|
|
1059
|
+
@conn.builder.instance_variable_set(:@app, nil) # reset app state to refresh the handlers
|
|
1060
|
+
end
|
|
1061
|
+
middlewares.each do |middleware|
|
|
1062
|
+
if Symbol === middleware then
|
|
1063
|
+
[Faraday::Request, Faraday::Response, Faraday::Middleware].map do |mod|
|
|
1064
|
+
mod.registered_middleware[middleware]
|
|
1065
|
+
end.compact.first.tap do |result|
|
|
1066
|
+
fail ArgumentError, "#{middleware} is not registered" if result.nil?
|
|
1067
|
+
middleware = result
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
@conn.builder.handlers.delete middleware
|
|
1071
|
+
fail Error::ClientError, "middleware #{middleware} is not removed yet from the stack" if @conn.builder.handlers.include? middleware
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
yield
|
|
1075
|
+
ensure
|
|
1076
|
+
@conn = orig
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
Base.register_connection :faraday, self
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# @!parse class JapanRegion < Base; end
|
|
1083
|
+
# @!parse class AsiaRegion < Base; end
|
|
1084
|
+
Region.infos.each do |k, data|
|
|
1085
|
+
Class.new Base do
|
|
1086
|
+
@_properties = data.dup.freeze
|
|
1087
|
+
end.tap do |cls|
|
|
1088
|
+
const_set :"#{k.capitalize}Region", cls
|
|
1089
|
+
define_singleton_method k do cls end
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
class << ConnectionProvider
|
|
1094
|
+
# make this module is exclusive to classes that already included them
|
|
1095
|
+
# and prevent further extension of it.
|
|
1096
|
+
|
|
1097
|
+
private
|
|
1098
|
+
def append_features(cls); end
|
|
1099
|
+
def prepend_features(cls); end
|
|
1100
|
+
|
|
1101
|
+
freeze
|
|
1102
|
+
end
|
|
1103
|
+
end
|
|
1104
|
+
end
|