radio5 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: edd4e5e61372538e86183d4e6171031695c51379482a619eb29c0b94378906e9
4
+ data.tar.gz: d25d7a72fdb56988bb4ae585a986671a395da01476fffa24f2ddb25d152fb85d
5
+ SHA512:
6
+ metadata.gz: 94aeac4d8c3d39c751c22f5104c4f08fffa41c48f74db24f5ac984ee4f10d1f372f2bed4e361230a59f0791884adada50fa690bb8a42b523384a8ab6801f9622
7
+ data.tar.gz: 21475735cdf9bfc455c8a8841cdead2ab67b2e6e25503daf04c7fdd71eb32ce62f9cb8161a2c3a2f214a1365149efc4ea0711f58488fc8cdb64f74ebb52aeb3b
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ 0.1.0
2
+ ----------
3
+
4
+ - Initial release!
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Dmytro Horoshko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # Radio5
2
+
3
+ [![Build](https://github.com/ocvit/radio5/workflows/Build/badge.svg)](https://github.com/ocvit/radio5/actions)
4
+ [![Coverage Status](https://coveralls.io/repos/github/ocvit/radio5/badge.svg?branch=main)](https://coveralls.io/github/ocvit/radio5?branch=main)
5
+
6
+ Adapter for [Radiooooo](https://radiooooo.com/) private API.
7
+
8
+ For music exploration purposes only 🧐
9
+
10
+ ## TL;DR
11
+
12
+ It turned out that 95% of all functionality doesn't even require an account (I'm not even talking about premium one), so here we go.
13
+
14
+ ## Installation
15
+
16
+ Install the gem and add to Gemfile:
17
+
18
+ ```sh
19
+ bundle add radio5
20
+ ```
21
+
22
+ Or install it manually:
23
+
24
+ ```sh
25
+ gem install radio5
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Create a client:
31
+
32
+ ```ruby
33
+ client = Radio5::Client.new
34
+ ```
35
+
36
+ You can pass additional HTTP configration if needed:
37
+
38
+ ```ruby
39
+ client = Radio5::Client.new(
40
+ open_timeout: 30, # default: 10
41
+ read_timeout: 30, # default: 10
42
+ write_timeout: 30, # default: 10
43
+ proxy_url: "http://user:pass@123.4.56.178:80", # default: nil
44
+ debug_output: $stdout # default: nil
45
+ )
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ To get random track:
51
+
52
+ ```ruby
53
+ client.random_track
54
+ # => {
55
+ # id: "655f7bb24b0d722a021a2cf2",
56
+ # uuid: <uuid>,
57
+ # artist: "Kaye Ballard",
58
+ # title: "In Other Words (Fly Me to the Moon)",
59
+ # album: "In Other Words / Lazy Afternoon",
60
+ # year: "1954",
61
+ # label: "Decca",
62
+ # songwriter: "Bart Howard",
63
+ # length: 133,
64
+ # info: "It is the original recording of Fly me to the moon !",
65
+ # cover_url: "https://asset.radiooooo.com/cover/USA/1950/large/<uuid>_1.jpg",
66
+ # audio: {
67
+ # mpeg: {
68
+ # url: "https://radiooooo-track.b-cdn.net/USA/1950/<uuid>.mp3?token=<token>&expires=1704717060",
69
+ # expires_at: 2024-01-08 12:31:00 UTC
70
+ # },
71
+ # ogg: {
72
+ # url: "https://radiooooo-track.b-cdn.net/USA/1950/<uuid>.ogg?token=<token>&expires=1704717060",
73
+ # expires_at: 2024-01-08 12:31:00 UTC
74
+ # }
75
+ # },
76
+ # decade: 1950,
77
+ # mood: :slow,
78
+ # country: "USA",
79
+ # like_count: 3,
80
+ # created_at: nil,
81
+ # created_by: "655ec7fce03fdc024c70f698"
82
+ # }
83
+ #
84
+ # NOTES:
85
+ # - `created_at` - always nil here (API limitations), available via `#track` if needed
86
+ # - `created_by` - `id` of user who uploaded this track
87
+ ```
88
+
89
+ To get random track using additional filters:
90
+
91
+ ```ruby
92
+ # with country
93
+ client.random_track(country: "FRA")
94
+
95
+ # with decade(s)
96
+ client.random_track(decades: [1960, 2000])
97
+
98
+ # with mood(s)
99
+ client.random_track(moods: [:slow, :weird])
100
+
101
+ # with everything together
102
+ client.random_track(country: "SWE", decades: [1940, 1980, 2010], moods: [:slow, :fast])
103
+
104
+ # in case no tracks match the filters
105
+ client.random_track(country: "KN1", decades: [1940], moods: [:weird])
106
+ # => nil
107
+ ```
108
+
109
+ To get information about specific track using its `id`:
110
+
111
+ ```ruby
112
+ client.track("655f7bb24b0d722a021a2cf2")
113
+ # => {
114
+ # ...
115
+ # created_at: 2023-11-23 16:20:02.283 UTC
116
+ # }
117
+ #
118
+ # output is exactly the same as from `#random_track`, but `created_at` is now filled
119
+ ```
120
+
121
+ OK, what input parameters are available?
122
+
123
+ ```ruby
124
+ # list of countries + additional info:
125
+ # - `exist` - "is it still around" flag
126
+ # - `rank` - subjective ranking provided by the website, only 10 countries have it
127
+ client.countries
128
+ # => {
129
+ # "AFG" => {name: "Afganistan", exist: true, rank: nil},
130
+ # "CBE" => {name: "Belgian Congo", exist: false, rank: nil},
131
+ # "FRA" => {name: "France", exist: true, rank: 2},
132
+ # ...
133
+ # }
134
+
135
+ # decades
136
+ client.decades
137
+ # => [1900, 1910, 1920, ..., 2010, 2020]
138
+
139
+ # moods
140
+ client.moods
141
+ # => [:fast, :slow, :weird]
142
+ #
143
+ # NOTE: by default all 3 moods are used in `#random_track` and `#island_track`
144
+ ```
145
+
146
+ It's also possible to get all valid `country`/`decade`/`moods` combinations in advance:
147
+
148
+ ```ruby
149
+ # grouped by country
150
+ client.countries_for_decade(1960)
151
+ # => {
152
+ # "THA" => [:fast, :slow, :weird],
153
+ # "TWN" => [:fast, :slow, :weird],
154
+ # "EST" => [:fast, :slow],
155
+ # "ALB" => [:fast, :slow],
156
+ # "NZL" => [:fast, :slow],
157
+ # ...
158
+ # }
159
+
160
+ # grouped by mood
161
+ client.countries_for_decade(1960, group_by: :mood)
162
+ # => {
163
+ # slow: ["FRA", "THA", "ALB", "GRC", "IRL", ...],
164
+ # fast: ["THA", "TWN", "EST", "ALB", "NZL", ...],
165
+ # weird: ["AZE", "POL", "IRN", "YUG", "DAH", ...]
166
+ # }
167
+ ```
168
+
169
+ How to work with the "islands" ("playlists" in the simple words):
170
+
171
+ ```ruby
172
+ # list all islands
173
+ client.islands
174
+ # => [{
175
+ # id: "5d330a3e06fb03d8872a3316",
176
+ # uuid: <uuid>,
177
+ # api_id: <api_id>,
178
+ # name: "Intimacy",
179
+ # info: "To get Laid. Slow to start. Fast to go further. Weird when nothing works.",
180
+ # category: "thematic",
181
+ # favourite_count: 1,
182
+ # play_count: 783339,
183
+ # rank: 17,
184
+ # icon_url: "https://asset.radiooooo.com/island/icon/<uuid>_2.svg",
185
+ # splash_url: "https://asset.radiooooo.com/island/splash/<uuid>_13.svg",
186
+ # marker_url: "https://asset.radiooooo.com/island/marker/<uuid>_9.svg",
187
+ # enabled: true,
188
+ # free: false,
189
+ # on_map: false,
190
+ # random: false,
191
+ # play_mode: "RANDOM",
192
+ # created_at: 2016-02-12 16:31:00 UTC,
193
+ # created_by: "5d3306de06fb03d8871fd119",
194
+ # updated_at: 2023-02-17 15:54:09.806 UTC,
195
+ # updated_by: "5d3306de06fb03d8871fd119"
196
+ # }, ...]
197
+ #
198
+ # NOTES:
199
+ # - `api_id` - have no idea where it is used
200
+ # - `rank` - 1..160, not unique, can be nil
201
+ # - `enabled` - is it searchable via web app, doesn't matter in our case ^^
202
+ # - `free` - do you need premium account to listen to it, doesn't matter ^^
203
+ # - `on_map` - is it displayed on a global map currently
204
+ # - `random` - there is only one playlist with `true` and it's called "Shuffle";
205
+ # basically the same as `#random_track` with no filters
206
+ # - `play_mode` - it is somehow used in a web app
207
+ # - `created_by` - `id` of user who created this island
208
+ # - `updated_by` - ...and who updated it last time
209
+
210
+ # to get random track from selected island
211
+ client.island_track(island_id: "5d330a3e06fb03d8872a3316")
212
+
213
+ # it's also possible to specify moods
214
+ client.island_track(island_id: "5d330a3e06fb03d8872a3316", moods: [:fast, :weird])
215
+ ```
216
+
217
+ User endpoints - WIP.
218
+
219
+ ## Auth?
220
+
221
+ There is just a couple of features that require login and/or premium account:
222
+
223
+ - history of "listened" tracks - track becomes "listened" when you got it via `#random_track` or `#island_track` (free)
224
+ - `followed` flag for `#user` - indicates whether or not you follow this user (free)
225
+ - `#user_liked_tracks` - list of tracks which user really rock'n'roll'ed to (free)
226
+ - ability to use multiple countries as a filter in `#random_track` (premium)
227
+
228
+ Currently auth is in a WIP state.
229
+
230
+ ## TODO
231
+
232
+ - [x] HTTP client (no external deps, net/http hardcore only)
233
+ - [x] Basic API client (no auth)
234
+ - [x] Countries support
235
+ - [x] Islands support
236
+ - [x] Tracks support
237
+ - [ ] Users support
238
+ - [ ] Auth + auth'ed endpoints
239
+
240
+ ## Development
241
+
242
+ ```sh
243
+ bin/setup // install deps
244
+ bin/console // interactive prompt to play around
245
+ rake spec // test!
246
+ rake rubocop // lint!
247
+ sudo rm -rf / // relax, just kidding ^^
248
+ ```
249
+
250
+ ## Contributing
251
+
252
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ocvit/radio5.
253
+
254
+ ## License
255
+
256
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/lib/radio5/api.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ class Api
5
+ class Error < StandardError; end
6
+ class TrackNotFound < Error; end
7
+ class MatchingTrackNotFound < Error; end
8
+ class UnexpectedResponse < StandardError; end
9
+
10
+ HOST = "radiooooo.com"
11
+ PORT = 443
12
+
13
+ attr_reader :client
14
+
15
+ def initialize(client:)
16
+ @client = client
17
+ end
18
+
19
+ def get(path, query_params: {}, headers: {})
20
+ request(Net::HTTP::Get, path, query_params, nil, headers)
21
+ end
22
+
23
+ def post(path, query_params: {}, body: nil, headers: {})
24
+ request(Net::HTTP::Post, path, query_params, body, headers)
25
+ end
26
+
27
+ private
28
+
29
+ def request(http_method_class, path, query_params, body, headers)
30
+ http = create_http
31
+ response = http.request(http_method_class, path, query_params, body, headers)
32
+
33
+ case response.code
34
+ when "200", "400"
35
+ json = Utils.parse_json(response.body)
36
+
37
+ case json
38
+ in error: "No track with this id"
39
+ raise TrackNotFound
40
+ in error: "No track for this selection"
41
+ raise MatchingTrackNotFound
42
+ in error: other_error
43
+ raise Error, other_error
44
+ else
45
+ [response, json]
46
+ end
47
+ else
48
+ raise UnexpectedResponse, "code: #{response.code.inspect}, body: #{response.body.inspect}"
49
+ end
50
+ end
51
+
52
+ # rubocop:disable Layout/HashAlignment
53
+ def create_http
54
+ Http.new(
55
+ host: HOST,
56
+ port: PORT,
57
+ open_timeout: client.open_timeout,
58
+ read_timeout: client.read_timeout,
59
+ write_timeout: client.write_timeout,
60
+ proxy_url: client.proxy_url,
61
+ debug_output: client.debug_output
62
+ )
63
+ end
64
+ # rubocop:enable Layout/HashAlignment
65
+ end
66
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ class Client
5
+ module Countries
6
+ def countries
7
+ _, json = api.get("/language/countries/en.json")
8
+
9
+ json.each_with_object({}) do |(iso_code, name, exist, rank), countries|
10
+ countries[iso_code] = {
11
+ name: name,
12
+ exist: exist,
13
+ rank: rank
14
+ }
15
+ end
16
+ end
17
+
18
+ def countries_for_decade(decade, group_by: :country)
19
+ validate_decade!(decade)
20
+
21
+ # optimization to avoid doing this inside `case` to save HTTP request
22
+ unless [:mood, :country].include?(group_by)
23
+ raise ArgumentError, "invalid `group_by` value: #{group_by.inspect}"
24
+ end
25
+
26
+ _, json = api.get("/country/mood", query_params: {decade: decade})
27
+
28
+ grouped_by_mood = json.transform_keys do |mood_upcased|
29
+ mood = mood_upcased.downcase
30
+
31
+ validate_mood!(mood)
32
+
33
+ mood
34
+ end
35
+
36
+ case group_by
37
+ when :mood
38
+ grouped_by_mood
39
+ when :country
40
+ grouped_by_country = Hash.new { |hash, country| hash[country] = [] }
41
+
42
+ MOODS.each_with_object(grouped_by_country) do |mood, grouped_by_country|
43
+ grouped_by_mood[mood].each do |country|
44
+ grouped_by_country[country] << mood
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ class Client
5
+ module Islands
6
+ include Utils
7
+
8
+ # rubocop:disable Layout/HashAlignment
9
+ def islands
10
+ _, json = api.get("/island/all")
11
+
12
+ json.map do |island|
13
+ rank_value = island[:sort]
14
+ rank = rank_value if rank_value.is_a?(Integer)
15
+
16
+ created_at = parse_time_string(island.fetch(:created).fetch(:date))
17
+ created_by = island.fetch(:created).fetch(:user_id)
18
+
19
+ updated_node = island[:modified]
20
+ updated_at = updated_node && parse_time_string(updated_node.fetch(:date))
21
+ updated_by = updated_node&.fetch(:user_id)
22
+
23
+ {
24
+ id: island.fetch(:_id),
25
+ uuid: island.fetch(:uuid),
26
+ api_id: island[:apiid],
27
+ name: normalize_string(island.fetch(:name)),
28
+ info: normalize_string(island[:info]),
29
+ category: normalize_string(island[:category]),
30
+ favourite_count: island[:favorites],
31
+ play_count: island.fetch(:plays),
32
+ rank: rank,
33
+ icon_url: parse_asset_url(island, :icon),
34
+ splash_url: parse_asset_url(island, :splash),
35
+ marker_url: parse_asset_url(island, :marker),
36
+ enabled: island.fetch(:enabled),
37
+ free: island[:free],
38
+ on_map: island.fetch(:onmap),
39
+ random: island.fetch(:random),
40
+ play_mode: island[:play],
41
+ created_at: created_at,
42
+ created_by: created_by,
43
+ updated_at: updated_at,
44
+ updated_by: updated_by
45
+ }
46
+ end
47
+ end
48
+ # rubocop:enable Layout/HashAlignment
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ class Client
5
+ module Tracks
6
+ def track(track_id)
7
+ validate_track_id!(track_id)
8
+
9
+ _, json = api.get("/track/play/#{track_id}")
10
+
11
+ Parser.track_info(json)
12
+ rescue Api::TrackNotFound
13
+ nil
14
+ end
15
+
16
+ # TODO: technically, API accepts an array of countries, but without premium
17
+ # account only the first one is used during filtering.
18
+ # `country` should be used for now
19
+ # `countries` might be added in a future after implementation of auth
20
+
21
+ # rubocop:disable Layout/HashAlignment
22
+ def random_track(country: nil, decades: [], moods: MOODS)
23
+ iso_codes = country ? [country] : []
24
+
25
+ validate_country_iso_codes!(iso_codes)
26
+ validate_decades!(decades)
27
+ validate_moods!(moods)
28
+
29
+ body = {
30
+ mode: "explore",
31
+ isocodes: iso_codes,
32
+ decades: decades.uniq,
33
+ moods: stringify_moods(moods).uniq
34
+ }.to_json
35
+
36
+ _, json = api.post("/play", body: body)
37
+
38
+ Parser.track_info(json)
39
+ rescue Api::MatchingTrackNotFound
40
+ nil
41
+ end
42
+ # rubocop:enable Layout/HashAlignment
43
+
44
+ # rubocop:disable Layout/HashAlignment
45
+ def island_track(island_id:, moods: MOODS)
46
+ validate_island_id!(island_id)
47
+ validate_moods!(moods)
48
+
49
+ body = {
50
+ mode: "islands",
51
+ island: island_id,
52
+ moods: stringify_moods(moods).uniq
53
+ }.to_json
54
+
55
+ _, json = api.post("/play", body: body)
56
+
57
+ Parser.track_info(json)
58
+ rescue Api::MatchingTrackNotFound
59
+ nil
60
+ end
61
+ # rubocop:enable Layout/HashAlignment
62
+
63
+ module Parser
64
+ extend Utils
65
+
66
+ # rubocop:disable Layout/HashAlignment
67
+ def self.track_info(json)
68
+ created_node = json[:created]
69
+ created_at = created_node && parse_time_string(created_node.fetch(:date))
70
+ created_by = created_node ? created_node.fetch(:user_id) : json.fetch(:profile_id)
71
+
72
+ audio = {
73
+ mpeg: track_audio(json, :mpeg),
74
+ ogg: track_audio(json, :ogg)
75
+ }
76
+
77
+ {
78
+ id: json.fetch(:_id),
79
+ uuid: json.fetch(:uuid),
80
+ artist: normalize_string(json.fetch(:artist)),
81
+ title: normalize_string(json.fetch(:title)),
82
+ album: normalize_string(json[:album]),
83
+ year: normalize_string(json.fetch(:year)),
84
+ label: normalize_string(json[:label]),
85
+ songwriter: normalize_string(json[:songwriter]),
86
+ length: json.fetch(:length),
87
+ info: normalize_string(json[:info]),
88
+ cover_url: parse_asset_url(json, :image, size: "large"),
89
+ audio: audio,
90
+ decade: json.fetch(:decade),
91
+ mood: symbolize_mood(json.fetch(:mood)),
92
+ country: json.fetch(:country),
93
+ like_count: json.fetch(:likes),
94
+ created_at: created_at,
95
+ created_by: created_by
96
+ }
97
+ end
98
+ # rubocop:enable Layout/HashAlignment
99
+
100
+ def self.track_audio(json, format)
101
+ url = json.fetch(:links).fetch(format)
102
+ url.gsub!(/#t=\d*,\d+/, "")
103
+
104
+ expires_at_unix = Integer(url[/(?<=expires=)\d+/])
105
+ expires_at = parse_unix_timestamp(expires_at_unix)
106
+
107
+ {
108
+ url: url,
109
+ expires_at: expires_at
110
+ }
111
+ end
112
+ end
113
+ private_constant :Parser
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ class Client
5
+ module Users
6
+ def user
7
+ # ...
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ class Client
5
+ include Utils
6
+ include Validator
7
+ include Users
8
+ include Countries
9
+ include Islands
10
+ include Tracks
11
+
12
+ attr_accessor :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output
13
+
14
+ def initialize(open_timeout: nil, read_timeout: nil, write_timeout: nil, proxy_url: nil, debug_output: nil)
15
+ @open_timeout = open_timeout
16
+ @read_timeout = read_timeout
17
+ @write_timeout = write_timeout
18
+ @proxy_url = proxy_url
19
+ @debug_output = debug_output
20
+ end
21
+
22
+ def api
23
+ @api ||= Api.new(client: self)
24
+ end
25
+
26
+ def decades
27
+ DECADES
28
+ end
29
+
30
+ def moods
31
+ MOODS
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Radio5
8
+ class Http
9
+ DEFAULT_OPEN_TIMEOUT = 10 # seconds
10
+ DEFAULT_READ_TIMEOUT = 10 # seconds
11
+ DEFAULT_WRITE_TIMEOUT = 10 # seconds
12
+ DEFAULT_DEBUG_OUTPUT = File.open(File::NULL, "w")
13
+ DEFAULT_MAX_RETRIES = 3
14
+ DEFAULT_HEADERS = {
15
+ "Content-Type" => "application/json; charset=utf-8",
16
+ "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
17
+ }
18
+ RETRIABLE_ERRORS = [
19
+ Errno::ECONNREFUSED,
20
+ Errno::ECONNRESET,
21
+ Errno::ETIMEDOUT,
22
+ Net::OpenTimeout,
23
+ Net::ReadTimeout,
24
+ Net::WriteTimeout,
25
+ OpenSSL::SSL::SSLError
26
+ ]
27
+
28
+ attr_reader :max_retries
29
+
30
+ # rubocop:disable Layout/ExtraSpacing
31
+ def initialize(
32
+ host:,
33
+ port:,
34
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
35
+ read_timeout: DEFAULT_READ_TIMEOUT,
36
+ write_timeout: DEFAULT_WRITE_TIMEOUT,
37
+ proxy_url: nil,
38
+ max_retries: DEFAULT_MAX_RETRIES,
39
+ debug_output: DEFAULT_DEBUG_OUTPUT
40
+ )
41
+ # @host = host
42
+ # @port = port
43
+ # @open_timeout = open_timeout
44
+ # @read_timeout = read_timeout
45
+ # @write_timeout = write_timeout
46
+ # @proxy_url = proxy_url
47
+ # @debug_output = debug_output
48
+
49
+ proxy_uri = parse_proxy_uri(proxy_url)
50
+ @max_retries = max_retries
51
+
52
+ @http = Net::HTTP.new(host, port, proxy_uri&.host, proxy_uri&.port, proxy_uri&.user, proxy_uri&.pass)
53
+
54
+ @http.tap do |c|
55
+ c.use_ssl = port == 443
56
+ c.open_timeout = open_timeout
57
+ c.read_timeout = read_timeout
58
+ c.write_timeout = write_timeout
59
+
60
+ c.set_debug_output(debug_output)
61
+ end
62
+ end
63
+ # rubocop:enable Layout/ExtraSpacing
64
+
65
+ def request(http_method_class, path, query_params, body, headers)
66
+ request = build_request(http_method_class, path, query_params, body, headers)
67
+ make_request(request)
68
+ end
69
+
70
+ private
71
+
72
+ def parse_proxy_uri(proxy_url)
73
+ return if proxy_url.nil?
74
+
75
+ proxy_uri = URI(proxy_url)
76
+
77
+ unless @proxy_uri.is_a?(URI::HTTP)
78
+ raise ArgumentError, "Invalid proxy URL: #{@proxy_uri}"
79
+ end
80
+
81
+ proxy_uri
82
+ end
83
+
84
+ def build_request(http_method_class, path, query_params, body, headers)
85
+ path = add_query_params(path, query_params)
86
+
87
+ request = http_method_class.new(path)
88
+ add_body(request, body)
89
+ add_headers(request, headers)
90
+
91
+ request
92
+ end
93
+
94
+ def add_query_params(path, query_params)
95
+ if query_params.empty?
96
+ path
97
+ else
98
+ "#{path}?#{URI.encode_www_form(query_params)}"
99
+ end
100
+ end
101
+
102
+ def add_body(request, body)
103
+ request.body = body
104
+ end
105
+
106
+ def add_headers(request, headers)
107
+ DEFAULT_HEADERS.merge(headers).each do |key, value|
108
+ request.delete(key)
109
+ request.add_field(key, value)
110
+ end
111
+ end
112
+
113
+ def make_request(request, retries: 0)
114
+ @http.request(request)
115
+ rescue *RETRIABLE_ERRORS => error
116
+ if retries < max_retries
117
+ make_request(request, retries: retries + 1)
118
+ else
119
+ raise error
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ module Regexps
5
+ # rubocop:disable Layout/ExtraSpacing
6
+
7
+ MONGO_ID = /^[a-f\d]{24}$/
8
+ UUID_GENERIC = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
9
+ UUID = /^#{UUID_GENERIC}$/
10
+
11
+ COUNTRY_ISO_CODE_GENERIC = /([A-Z]{3}|KN1)/
12
+ COUNTRY_ISO_CODE = /^#{COUNTRY_ISO_CODE_GENERIC}$/
13
+
14
+ ASSET_URL = lambda do |sub_path, exts|
15
+ asset_host = Regexp.escape(Utils::ASSET_HOST)
16
+ sub_path = sub_path.is_a?(Regexp) ? sub_path : Regexp.escape(sub_path)
17
+ exts = /(#{exts.join("|")})/
18
+
19
+ /#{asset_host}#{sub_path}\/#{UUID_GENERIC}(_\d+)?\.#{exts}/
20
+ end
21
+
22
+ ISLAND_ICON_URL = ASSET_URL.call("/island/icon", ["png", "svg"])
23
+ ISLAND_SPLASH_URL = ASSET_URL.call("/island/splash", ["png", "svg"])
24
+ ISLAND_MARKER_URL = ASSET_URL.call("/island/marker", ["png", "svg"])
25
+ TRACK_COVER_URL = ASSET_URL.call(/\/cover\/#{COUNTRY_ISO_CODE_GENERIC}\/\d{4}\/large/, ["jpg", "jpeg"])
26
+
27
+ AUDIO_URL = lambda do |exts|
28
+ exts = /(#{exts.join("|")})/
29
+
30
+ /.+\/#{UUID_GENERIC}\.#{exts}\?token=[^&]{22}&expires=\d{10}$/
31
+ end
32
+
33
+ MPEG_URL = AUDIO_URL.call(["mp3", "m4a"])
34
+ OGG_URL = AUDIO_URL.call(["ogg"])
35
+
36
+ # rubocop:enable Layout/ExtraSpacing
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Radio5
7
+ module Utils
8
+ module_function
9
+
10
+ ASSET_HOST = "https://asset.radiooooo.com"
11
+
12
+ def parse_json(json_raw)
13
+ JSON.parse(json_raw, symbolize_names: true)
14
+ end
15
+
16
+ def parse_asset_url(hash, key, size: nil)
17
+ node = hash[key]
18
+
19
+ if node
20
+ path, filename = node.fetch_values(:path, :filename)
21
+ path << "#{size}/" if size
22
+
23
+ create_asset_url(path, filename)
24
+ end
25
+ end
26
+
27
+ def create_asset_url(path, filename)
28
+ URI.join(ASSET_HOST, path, filename).to_s
29
+ end
30
+
31
+ def parse_time_string(time_string)
32
+ Time.parse(time_string).utc
33
+ end
34
+
35
+ def parse_unix_timestamp(ts)
36
+ Time.at(ts).utc
37
+ end
38
+
39
+ def normalize_string(string)
40
+ return if string.nil? || string.empty?
41
+
42
+ normalized = string.strip
43
+ normalized unless normalized.empty?
44
+ end
45
+
46
+ def stringify_moods(moods)
47
+ moods.map { |mood| MOODS_MAPPING.fetch(mood) }
48
+ end
49
+
50
+ def symbolize_mood(mood)
51
+ MOODS_MAPPING.key(mood)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ module Validator
5
+ module_function
6
+
7
+ def mongo_id?(object)
8
+ object.is_a?(String) && object.match?(Regexps::MONGO_ID)
9
+ end
10
+
11
+ def country_iso_code?(object)
12
+ object.is_a?(String) && object.match?(Regexps::COUNTRY_ISO_CODE)
13
+ end
14
+
15
+ def decade?(object)
16
+ object.is_a?(Integer) && DECADES.include?(object)
17
+ end
18
+
19
+ def mood?(object)
20
+ object.is_a?(Symbol) && MOODS_MAPPING.key?(object)
21
+ end
22
+
23
+ def validate_track_id!(object)
24
+ unless mongo_id?(object)
25
+ raise ArgumentError, "invalid track ID: #{object.inspect}"
26
+ end
27
+ end
28
+
29
+ def validate_island_id!(object)
30
+ unless mongo_id?(object)
31
+ raise ArgumentError, "invalid island ID: #{object.inspect}"
32
+ end
33
+ end
34
+
35
+ def validate_country_iso_codes!(iso_codes)
36
+ unless iso_codes.is_a?(Array)
37
+ raise ArgumentError, "country ISO codes should be an array"
38
+ end
39
+
40
+ iso_codes.each do |iso_code|
41
+ validate_country_iso_code!(iso_code)
42
+ end
43
+ end
44
+
45
+ def validate_country_iso_code!(object)
46
+ unless country_iso_code?(object)
47
+ raise ArgumentError, "invalid country ISO code: #{object.inspect}"
48
+ end
49
+ end
50
+
51
+ def validate_decades!(decades)
52
+ unless decades.is_a?(Array)
53
+ raise ArgumentError, "decades should be an array"
54
+ end
55
+
56
+ decades.each do |decade|
57
+ validate_decade!(decade)
58
+ end
59
+ end
60
+
61
+ def validate_decade!(object)
62
+ unless decade?(object)
63
+ raise ArgumentError, "invalid decade: #{object.inspect}"
64
+ end
65
+ end
66
+
67
+ def validate_moods!(moods)
68
+ unless moods.is_a?(Array)
69
+ raise ArgumentError, "moods should be an array"
70
+ end
71
+
72
+ moods.each do |mood|
73
+ validate_mood!(mood)
74
+ end
75
+ end
76
+
77
+ def validate_mood!(object)
78
+ unless mood?(object)
79
+ raise ArgumentError, "invalid mood: #{object.inspect}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radio5
4
+ VERSION = "0.1.0"
5
+ end
data/lib/radio5.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "radio5/version"
4
+ require_relative "radio5/utils"
5
+ require_relative "radio5/http"
6
+ require_relative "radio5/api"
7
+ require_relative "radio5/regexps"
8
+ require_relative "radio5/validator"
9
+ require_relative "radio5/client/users"
10
+ require_relative "radio5/client/countries"
11
+ require_relative "radio5/client/islands"
12
+ require_relative "radio5/client/tracks"
13
+ require_relative "radio5/client"
14
+
15
+ module Radio5
16
+ DECADES = (1900..2020).step(10).to_a.freeze
17
+
18
+ MOODS_MAPPING = {
19
+ fast: "FAST",
20
+ slow: "SLOW",
21
+ weird: "WEIRD"
22
+ }.freeze
23
+
24
+ MOODS = MOODS_MAPPING.keys.freeze
25
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: radio5
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dmytro Horoshko
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-01-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Adapter for Radiooooo.com private API.
14
+ email:
15
+ - electric.molfar@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/radio5.rb
24
+ - lib/radio5/api.rb
25
+ - lib/radio5/client.rb
26
+ - lib/radio5/client/countries.rb
27
+ - lib/radio5/client/islands.rb
28
+ - lib/radio5/client/tracks.rb
29
+ - lib/radio5/client/users.rb
30
+ - lib/radio5/http.rb
31
+ - lib/radio5/regexps.rb
32
+ - lib/radio5/utils.rb
33
+ - lib/radio5/validator.rb
34
+ - lib/radio5/version.rb
35
+ homepage: https://github.com/ocvit/radio5
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ bug_tracker_uri: https://github.com/ocvit/radio5/issues
40
+ changelog_uri: https://github.com/ocvit/radio5/blob/main/CHANGELOG.md
41
+ homepage_uri: https://github.com/ocvit/radio5
42
+ source_code_uri: https://github.com/ocvit/radio5
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '2.7'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 3.4.10
59
+ signing_key:
60
+ specification_version: 4
61
+ summary: Radiooooo.com unlocked!
62
+ test_files: []