vk_music 3.1.5 → 4.0.2

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.
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 +71 -20
  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 +181 -647
  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 +107 -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
+ module 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
+ _add_hash, _edit_hash, action_hash, _delete_hash, _teplace_hash, url_hash = get_secrets(data)
14
+
15
+ Audio.new(id: data[0], owner_id: data[1],
16
+ secret1: action_hash, secret2: url_hash,
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
+ module 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,.audio_item.audio_item_disabled').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
+ module 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
+ module 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
+ module 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
+ module 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
+ module 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
+ module 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
+ module 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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VkMusic
4
+ module Utility
5
+ # Link decoding utilities
6
+ module 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
+ VkMusic.log.debug('LinkDecoder') { "Unmasking link `#{link}` with client id #{client_id}" }
100
+ @@js_context.call('vk_unmask_link', link, client_id)
101
+ rescue StandardError => e
102
+ VkMusic.log.warn('LinkDecoder') { "Failed to decode link `#{link}`: #{e.full_message}" }
103
+ nil
104
+ end
105
+ end
106
+ end
107
+ end