vk_music 3.1.4 → 4.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +3 -0
  3. data/.github/workflows/ruby.yml +35 -0
  4. data/.gitignore +6 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +56 -0
  7. data/Gemfile +38 -10
  8. data/Gemfile.lock +76 -25
  9. data/LICENSE.txt +0 -0
  10. data/README.md +113 -94
  11. data/Rakefile +15 -22
  12. data/bin/console +18 -24
  13. data/lib/vk_music.rb +32 -18
  14. data/lib/vk_music/audio.rb +111 -187
  15. data/lib/vk_music/client.rb +187 -645
  16. data/lib/vk_music/playlist.rb +44 -97
  17. data/lib/vk_music/request.rb +13 -0
  18. data/lib/vk_music/request/audios.rb +29 -0
  19. data/lib/vk_music/request/base.rb +75 -0
  20. data/lib/vk_music/request/login.rb +35 -0
  21. data/lib/vk_music/request/my_page.rb +21 -0
  22. data/lib/vk_music/request/playlist.rb +31 -0
  23. data/lib/vk_music/request/playlist_section.rb +35 -0
  24. data/lib/vk_music/request/post.rb +22 -0
  25. data/lib/vk_music/request/profile.rb +24 -0
  26. data/lib/vk_music/request/search.rb +34 -0
  27. data/lib/vk_music/request/wall_section.rb +34 -0
  28. data/lib/vk_music/utility.rb +8 -78
  29. data/lib/vk_music/utility/audio_data_parser.rb +37 -0
  30. data/lib/vk_music/utility/audio_items_parser.rb +18 -0
  31. data/lib/vk_music/utility/audio_node_parser.rb +59 -0
  32. data/lib/vk_music/utility/audios_from_ids_loader.rb +21 -0
  33. data/lib/vk_music/utility/audios_ids_getter.rb +25 -0
  34. data/lib/vk_music/utility/audios_loader.rb +37 -0
  35. data/lib/vk_music/utility/data_type_guesser.rb +43 -0
  36. data/lib/vk_music/utility/duration_parser.rb +17 -0
  37. data/lib/vk_music/utility/last_profile_post_loader.rb +26 -0
  38. data/lib/vk_music/utility/link_decoder.rb +106 -0
  39. data/lib/vk_music/utility/node_text_children_reader.rb +14 -0
  40. data/lib/vk_music/utility/playlist_loader.rb +30 -0
  41. data/lib/vk_music/utility/playlist_node_parser.rb +21 -0
  42. data/lib/vk_music/utility/playlist_section_loader.rb +29 -0
  43. data/lib/vk_music/utility/playlist_url_parser.rb +32 -0
  44. data/lib/vk_music/utility/post_loader.rb +23 -0
  45. data/lib/vk_music/utility/post_url_parser.rb +24 -0
  46. data/lib/vk_music/utility/profile_id_resolver.rb +58 -0
  47. data/lib/vk_music/utility/wall_loader.rb +25 -0
  48. data/lib/vk_music/version.rb +7 -5
  49. data/lib/vk_music/web_parser.rb +9 -0
  50. data/lib/vk_music/web_parser/audios.rb +20 -0
  51. data/lib/vk_music/web_parser/base.rb +27 -0
  52. data/lib/vk_music/web_parser/login.rb +13 -0
  53. data/lib/vk_music/web_parser/my_page.rb +19 -0
  54. data/lib/vk_music/web_parser/playlist.rb +33 -0
  55. data/lib/vk_music/web_parser/playlist_section.rb +53 -0
  56. data/lib/vk_music/web_parser/post.rb +15 -0
  57. data/lib/vk_music/web_parser/profile.rb +33 -0
  58. data/lib/vk_music/web_parser/search.rb +56 -0
  59. data/lib/vk_music/web_parser/wall_section.rb +53 -0
  60. data/vk_music.gemspec +36 -40
  61. metadata +59 -77
  62. data/.travis.yml +0 -7
  63. data/bin/setup +0 -8
  64. data/lib/vk_music/constants.rb +0 -78
  65. data/lib/vk_music/exceptions.rb +0 -21
  66. data/lib/vk_music/link_decoder.rb +0 -102
  67. data/lib/vk_music/utility/log.rb +0 -51
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Request
5
+ # Wall in JSON sections request
6
+ class WallSection < Base
7
+ # Initialize new request
8
+ # @param owner_id [Integer]
9
+ # @param post_id [Integer]
10
+ # @param offset [Integer]
11
+ # @param client_id [Integer]
12
+ def initialize(owner_id, post_id, client_id)
13
+ @client_id = client_id
14
+ super(
15
+ "#{VK_ROOT}/audio",
16
+ {
17
+ act: 'load_section', type: 'wall', utf8: true,
18
+ owner_id: owner_id, post_id: post_id, wall_type: 'own'
19
+ },
20
+ 'GET',
21
+ {}
22
+ )
23
+ end
24
+
25
+ def_delegators :@parser, :audios, :title, :subtitle, :real_size, :more?
26
+
27
+ private
28
+
29
+ def after_call
30
+ @parser = WebParser::WallSection.new(@response, client_id: @client_id)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,78 +1,8 @@
1
- require_relative "utility/log"
2
-
3
- module VkMusic
4
- ##
5
- # Utility methods.
6
- module Utility
7
- ##
8
- # Turn amount of seconds into string.
9
- # @param s [Integer] amount of seconds.
10
- # @return [String] formatted string.
11
- def self.format_seconds(s)
12
- s = s.to_i # Require integer
13
- "#{(s / 60).to_s.rjust(2, "0")}:#{(s % 60).to_s.rjust(2, "0")}";
14
- end
15
-
16
- ##
17
- # Guess type of request by from string.
18
- #
19
- # Possible results:
20
- # * +:playlist+ - if string match playlist URL.
21
- # * +:post+ - if string match post URL.
22
- # * +:audios+ - if string match user or group URL.
23
- # * +:find+ - in rest of cases.
24
- # @param str [String] request from user for some audios.
25
- # @return [Symbol]
26
- def self.guess_request_type(str)
27
- case str
28
- when Constants::Regex::VK_PLAYLIST_URL_POSTFIX
29
- :playlist
30
- when Constants::Regex::VK_WALL_URL_POSTFIX, Constants::Regex::VK_POST_URL_POSTFIX
31
- :post
32
- when Constants::Regex::VK_BLOCK_URL
33
- :block
34
- when Constants::Regex::VK_URL
35
- :audios
36
- else
37
- :find
38
- end
39
- end
40
-
41
- ##
42
- # Turn hash into URL query string.
43
- # @param hash [Hash]
44
- # @return [String]
45
- def self.hash_to_params(hash = {})
46
- qs = ""
47
- hash.each_key do |key|
48
- qs << "&" unless qs.empty?
49
- case hash[key]
50
- when Array
51
- qs << CGI.escape(key.to_s) << "=" << hash[key].map { |value| CGI.escape(value.to_s) }.join(",")
52
- else
53
- qs << CGI.escape(key.to_s) << "=" << CGI.escape(hash[key].to_s)
54
- end
55
- end
56
- qs
57
- end
58
-
59
- ##
60
- # Get content of text children of provided Node.
61
- # @param node [Nokogiri::Xml::Node]
62
- # @return [String]
63
- def self.plain_text(node)
64
- node.children.select(&:text?).map(&:text).join ""
65
- end
66
-
67
- ##
68
- # Turn human readable track length to its size in seconds.
69
- # @param str [String] string in format "HH:MM:SS" or something alike (+/d++ Regex selector is used).
70
- # @return [Integer] amount of seconds.
71
- def self.parse_duration(str)
72
- str.scan(/\d+/)
73
- .map(&:to_i)
74
- .reverse
75
- .each_with_index.reduce(0) { |m, arr| m + arr[0] * 60**arr[1] }
76
- end
77
- end
78
- end
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ # Helpers
5
+ module Utility; end
6
+ end
7
+
8
+ Dir[File.join(__dir__, 'utility', '*.rb')].each { |file| require_relative file }
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Parse {Audio} from +Array+ of audio data
6
+ class AudioDataParser
7
+ class << self
8
+ # @param data [Array]
9
+ # @param client_id [Integer]
10
+ # @return [Audio]
11
+ def call(data, client_id)
12
+ url_encoded = get_url_encoded(data)
13
+ secrets = get_secrets(data)
14
+
15
+ Audio.new(id: data[0], owner_id: data[1],
16
+ secret1: secrets[2], secret2: secrets[5],
17
+ artist: CGI.unescapeHTML(data[4]), title: CGI.unescapeHTML(data[3]),
18
+ duration: data[5],
19
+ url_encoded: url_encoded, url: nil, client_id: client_id)
20
+ end
21
+
22
+ private
23
+
24
+ def get_url_encoded(data)
25
+ url_encoded = data[2].to_s
26
+ url_encoded = nil if url_encoded.empty?
27
+
28
+ url_encoded
29
+ end
30
+
31
+ def get_secrets(data)
32
+ data[13].to_s.split('/')
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Parse {Audio} from +Nokogiri::XML::Node+
6
+ class AudioItemsParser
7
+ # @param node [Nokogiri::XML::Node]
8
+ # @param client_id [Integer]
9
+ # @return [Array<Audio>]
10
+ def self.call(node, client_id)
11
+ node.css('.audio_item.ai_has_btn').map do |elem|
12
+ data = JSON.parse(elem.attribute('data-audio').value)
13
+ Utility::AudioDataParser.call(data, client_id)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Parse {Audio} from +Nokogiri::XML::Node+
6
+ class AudioNodeParser
7
+ class << self
8
+ # @param node [Nokogiri::XML::Node]
9
+ # @param client_id [Integer]
10
+ # @return [Audio]
11
+ def call(node, client_id)
12
+ input = node.at_css('input')
13
+ if input
14
+ parse_input(input, node, client_id)
15
+ else
16
+ parse_post(node, client_id)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def parse_input(input, node, client_id)
23
+ id_array = get_id_array(node)
24
+ artist, title, duration = get_main_data(node)
25
+
26
+ Audio.new(id: Integer(id_array[1], 10), owner_id: Integer(id_array[0], 10),
27
+ artist: artist, title: title, duration: duration,
28
+ url_encoded: get_encoded_url(input), url: nil, client_id: client_id)
29
+ end
30
+
31
+ def get_encoded_url(input)
32
+ url_encoded = input.attribute('value').to_s
33
+ url_encoded = nil if url_encoded.empty? || url_encoded == Constants::URL::VK[:audio_unavailable]
34
+
35
+ url_encoded
36
+ end
37
+
38
+ def get_id_array(node)
39
+ node.attribute('data-id').to_s.split('_')
40
+ end
41
+
42
+ def get_main_data(node)
43
+ [
44
+ node.at_css('.ai_artist').text.strip,
45
+ node.at_css('.ai_title').text.strip,
46
+ Integer(node.at_css('.ai_dur').attribute('data-dur').to_s, 10)
47
+ ]
48
+ end
49
+
50
+ def parse_post(node, client_id)
51
+ artist = node.at_css('.medias_music_author').text.strip
52
+ title = NodeTextChildrenReader.call(node.at_css('.medias_audio_title'))
53
+ duration = DurationParser.call(node.at_css('.medias_audio_dur').text)
54
+ Audio.new(artist: artist, title: title, duration: duration, client_id: client_id)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Load audios from ids
6
+ class AudiosFromIdsLoader
7
+ # @param agent [Mechanize]
8
+ # @param ids [Array<String>]
9
+ # @return [Array<Audio>]
10
+ def self.call(agent, ids, client_id)
11
+ audios = []
12
+ ids.each_slice(10) do |subarray|
13
+ page = Request::AudiosReload.new(subarray, client_id)
14
+ page.call(agent)
15
+ audios.concat(page.audios)
16
+ end
17
+ audios
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Load ids from array of data
6
+ class AudiosIdsGetter
7
+ # @param args [Array<Audio, (owner_id, audio_id, secret_1, secret_2),
8
+ # "#{owner_id}_#{id}_#{secret_1}_#{secret_2}">]
9
+ # @return [Array<String>] array of uniq full ids
10
+ def self.call(args)
11
+ ids = args.map do |item|
12
+ case item
13
+ when Audio then item.full_id
14
+ when Array then item.join('_')
15
+ when String then item
16
+ end
17
+ end
18
+ ids.compact!
19
+ ids.uniq!
20
+
21
+ ids
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Load user or group audios
6
+ class AudiosLoader
7
+ class << self
8
+ # @param agent [Mechanize]
9
+ # @param client_id [Integer]
10
+ # @param owner_id [Integer]
11
+ # @param up_to [Integer]
12
+ # @return [Playlist?]
13
+ def call(agent, client_id, owner_id, up_to)
14
+ page = Request::PlaylistSection.new(owner_id, -1, '', 0, client_id).call(agent)
15
+ audios = page.audios
16
+ return if audios.nil? || audios.empty?
17
+
18
+ up_to = page.real_size if up_to > page.real_size
19
+
20
+ load_till_up_to(audios, agent, client_id, owner_id, up_to)
21
+
22
+ Playlist.new(audios, id: -1, owner_id: owner_id, access_hash: '',
23
+ title: page.title, subtitle: page.subtitle, real_size: page.real_size)
24
+ end
25
+
26
+ private
27
+
28
+ def load_till_up_to(audios, agent, client_id, owner_id, up_to)
29
+ return audios.slice!(up_to..) if up_to <= audios.size
30
+
31
+ rest = PlaylistSectionLoader.call(agent, client_id, owner_id, -1, '', audios.size, up_to - audios.size)
32
+ audios.concat(rest)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'playlist_url_parser'
4
+ require_relative 'post_url_parser'
5
+
6
+ module VkMusic
7
+ module Utility
8
+ # Guess type of method to use based on string data
9
+ class DataTypeGuesser
10
+ # End of playlist URL
11
+ PLAYLIST_POSTFIX = PlaylistUrlParser::VK_PLAYLIST_URL_POSTFIX
12
+ public_constant :PLAYLIST_POSTFIX
13
+
14
+ # End of post URL
15
+ POST_POSTFIX = PostUrlParser::POST_POSTFIX
16
+ public_constant :POST_POSTFIX
17
+
18
+ # End of wall URL
19
+ WALL_POSTFIX = /.*wall(-?\d+)/.freeze
20
+ public_constant :WALL_POSTFIX
21
+
22
+ # End of profile audios URL
23
+ AUDIOS_POSTFIX = /.*audios(-?\d+)/.freeze
24
+ public_constant :AUDIOS_POSTFIX
25
+
26
+ # Full profile URL regex
27
+ PROFILE_URL = %r{(?:https?://)?(?:vk\.com/)([^/?&]+)}.freeze
28
+ public_constant :PROFILE_URL
29
+
30
+ # @param data [String]
31
+ # @return [Symbol]
32
+ def self.call(data)
33
+ case data
34
+ when PLAYLIST_POSTFIX then :playlist
35
+ when POST_POSTFIX then :post
36
+ when WALL_POSTFIX then :wall
37
+ when AUDIOS_POSTFIX, PROFILE_URL then :audios
38
+ else :find
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Turn human readable track length to its size in seconds
6
+ class DurationParser
7
+ # @param str [String] string in format "HH:MM:SS" or something alike (+/d++ Regex selector is used)
8
+ # @return [Integer] amount of seconds
9
+ def self.call(str)
10
+ str.scan(/\d+/)
11
+ .map(&:to_i)
12
+ .reverse
13
+ .each_with_index.reduce(0) { |acc, (count, position)| acc + count * 60**position }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'profile_id_resolver'
4
+
5
+ module VkMusic
6
+ module Utility
7
+ # Get user or group id from url
8
+ class LastProfilePostLoader
9
+ # vk.com url regex
10
+ VK_PATH = ProfileIdResolver::VK_PATH
11
+ private_constant :VK_PATH
12
+
13
+ # @param agent [Mechanize]
14
+ # @param url [String] URL to profile page
15
+ # @return [Array(owner_id?, post_id?)]
16
+ def self.call(agent, url: nil, owner_id: nil)
17
+ path = url&.match(VK_PATH)&.captures&.first
18
+ request = VkMusic::Request::Profile.new(profile_id: owner_id, profile_custom_path: path)
19
+ request.call(agent)
20
+ [request.id, request.last_post_id]
21
+ rescue Mechanize::ResponseCodeError
22
+ [nil, nil]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Link decoding utilities
6
+ class LinkDecoder
7
+ # JS code which creates function to unmask audio URL.
8
+ JS_CODE = <<~HEREDOC
9
+ function vk_unmask_link(link, vk_id) {
10
+
11
+ // Utility functions to unmask
12
+
13
+ var audioUnmaskSource = function (encrypted) {
14
+ if (encrypted.indexOf('audio_api_unavailable') != -1) {
15
+ var parts = encrypted.split('?extra=')[1].split('#');
16
+
17
+ var handled_anchor = '' === parts[1] ? '' : handler(parts[1]);
18
+
19
+ var handled_part = handler(parts[0]);
20
+
21
+ if (typeof handled_anchor != 'string' || !handled_part) {
22
+ // if (typeof handled_anchor != 'string') console.warn('Handled_anchor type: ' + typeof handled_anchor);
23
+ // if (!handled_part) console.warn('Handled_part: ' + handled_part);
24
+ return encrypted;
25
+ }
26
+
27
+ handled_anchor = handled_anchor ? handled_anchor.split(String.fromCharCode(9)) : [];
28
+
29
+ for (var func_key, splited_anchor, l = handled_anchor.length; l--;) {
30
+ splited_anchor = handled_anchor[l].split(String.fromCharCode(11));
31
+ func_key = splited_anchor.splice(0, 1, handled_part)[0];
32
+ if (!utility_object[func_key]) {
33
+ // console.warn('Was unable to find key: ' + func_key);
34
+ return encrypted;
35
+ }
36
+ handled_part = utility_object[func_key].apply(null, splited_anchor)
37
+ }
38
+
39
+ if (handled_part && 'http' === handled_part.substr(0, 4)) return handled_part;
40
+ // else console.warn('Failed unmasking: ' + handled_part);
41
+ } else {
42
+ // console.warn('Bad link: ' + encrypted);
43
+ }
44
+ return encrypted;
45
+ }
46
+
47
+ var handler = function (part) {
48
+ if (!part || part.length % 4 == 1) return !1;
49
+ for (var t, i, o = 0, s = 0, a = ''; i = part.charAt(s++);) {
50
+ i = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/='.indexOf(i)
51
+ ~i && (t = o % 4 ? 64 * t + i : i, o++ % 4) && (a += String.fromCharCode(255 & t >> (-2 * o & 6)));
52
+ }
53
+ return a;
54
+ }
55
+
56
+ var utility_object = {
57
+ i: function(e, t) {
58
+ return utility_object.s(e, t ^ vk_id);
59
+ },
60
+ s: function(e, t) {
61
+ var n = e.length;
62
+ if (n) {
63
+ var i = r_func(e, t),
64
+ o = 0;
65
+ for (e = e.split(''); ++o < n;)
66
+ e[o] = e.splice(i[n - 1 - o], 1, e[o])[0];
67
+ e = e.join('')
68
+ }
69
+ return e;
70
+ }
71
+ };
72
+
73
+ var r_func = function (e, t) {
74
+ var n = e.length,
75
+ i = [];
76
+ if (n) {
77
+ var o = n;
78
+ for (t = Math.abs(t); o--;)
79
+ t = (n * (o + 1) ^ t + o) % n,
80
+ i[o] = t;
81
+ }
82
+ return i;
83
+ }
84
+
85
+ return audioUnmaskSource(link);
86
+ }
87
+ HEREDOC
88
+ private_constant :JS_CODE
89
+
90
+ # JS context with unmasking link
91
+ @@js_context = ExecJS.compile(JS_CODE)
92
+
93
+ # Unmask audio download URL
94
+ # @param link [String] encoded link to audio. Usually looks like
95
+ # "https://m.vk.com/mp3/audio_api_unavailable.mp3?extra=...".
96
+ # @param client_id [Integer] ID of user which got this link. ID is required for decoding.
97
+ # @return [String?] audio download URL, which can be used only from current IP.
98
+ def self.call(link, client_id)
99
+ @@js_context.call('vk_unmask_link', link, client_id)
100
+ rescue StandardError
101
+ VkMusic.log.warn('LinkDecoder') { "Failed to decode link: #{link}" }
102
+ nil
103
+ end
104
+ end
105
+ end
106
+ end