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.
@@ -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