spotifyrb 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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +3 -0
  3. data/.github/FUNDING.yml +5 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +42 -0
  6. data/.gitignore +9 -0
  7. data/.rubocop.yml +8 -0
  8. data/Gemfile +11 -0
  9. data/Gemfile.lock +124 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +574 -0
  12. data/Rakefile +10 -0
  13. data/bin/console +19 -0
  14. data/bin/setup +8 -0
  15. data/lib/spotify/client.rb +56 -0
  16. data/lib/spotify/collection.rb +82 -0
  17. data/lib/spotify/error.rb +4 -0
  18. data/lib/spotify/error_generator.rb +128 -0
  19. data/lib/spotify/oauth.rb +61 -0
  20. data/lib/spotify/object.rb +19 -0
  21. data/lib/spotify/objects/album.rb +4 -0
  22. data/lib/spotify/objects/artist.rb +4 -0
  23. data/lib/spotify/objects/audiobook.rb +4 -0
  24. data/lib/spotify/objects/device.rb +4 -0
  25. data/lib/spotify/objects/episode.rb +4 -0
  26. data/lib/spotify/objects/player.rb +4 -0
  27. data/lib/spotify/objects/playlist.rb +4 -0
  28. data/lib/spotify/objects/show.rb +4 -0
  29. data/lib/spotify/objects/snapshot.rb +4 -0
  30. data/lib/spotify/objects/track.rb +4 -0
  31. data/lib/spotify/objects/user.rb +4 -0
  32. data/lib/spotify/resource.rb +64 -0
  33. data/lib/spotify/resources/albums.rb +16 -0
  34. data/lib/spotify/resources/artists.rb +20 -0
  35. data/lib/spotify/resources/me.rb +13 -0
  36. data/lib/spotify/resources/player.rb +46 -0
  37. data/lib/spotify/resources/playlists.rb +37 -0
  38. data/lib/spotify/resources/search.rb +10 -0
  39. data/lib/spotify/resources/users.rb +13 -0
  40. data/lib/spotify/version.rb +3 -0
  41. data/lib/spotify.rb +36 -0
  42. data/lib/spotifyrb.rb +1 -0
  43. data/spotifyrb.gemspec +29 -0
  44. metadata +111 -0
@@ -0,0 +1,82 @@
1
+ module Spotify
2
+ class Collection
3
+ attr_reader :data, :total
4
+
5
+ def self.from_response(response, type:, key: nil)
6
+ body = response.body
7
+
8
+ if key.is_a?(String)
9
+ data = body[key].map { |attrs| type.new(attrs) }
10
+ else
11
+ data = body.map { |attrs| type.new(attrs) }
12
+ end
13
+
14
+ new(
15
+ data: data,
16
+ total: data.count,
17
+ )
18
+ end
19
+
20
+ def self.from_search_response(response)
21
+ body = response.body
22
+
23
+ data = []
24
+
25
+ puts body["tracks"].count
26
+
27
+ if body["tracks"] && body["tracks"]["items"]
28
+ data.concat(body["tracks"]["items"].map { |attrs| Track.new(attrs) })
29
+ end
30
+
31
+ if body["artists"] && body["artists"]["items"]
32
+ data.concat(body["artists"]["items"].map { |attrs| Artist.new(attrs) })
33
+ end
34
+
35
+ if body["albums"] && body["albums"]["items"]
36
+ data.concat(body["albums"]["items"].map { |attrs| Album.new(attrs) })
37
+ end
38
+
39
+ if body["playlists"] && body["playlists"]["items"]
40
+ data.concat(body["playlists"]["items"].map { |attrs| Playlist.new(attrs) })
41
+ end
42
+
43
+ if body["shows"] && body["shows"]["items"]
44
+ data.concat(body["shows"]["items"].map { |attrs| Show.new(attrs) })
45
+ end
46
+
47
+ if body["episodes"] && body["episodes"]["items"]
48
+ data.concat(body["episodes"]["items"].map { |attrs| Episode.new(attrs) })
49
+ end
50
+
51
+ if body["audiobooks"] && body["audiobooks"]["items"]
52
+ data.concat(body["audiobooks"]["items"].map { |attrs| Audiobook.new(attrs) })
53
+ end
54
+
55
+ new(
56
+ data: data,
57
+ total: data.count,
58
+ )
59
+ end
60
+
61
+ def initialize(data:, total:)
62
+ @data = data
63
+ @total = total
64
+ end
65
+
66
+ def each(&block)
67
+ data.each(&block)
68
+ end
69
+
70
+ def first
71
+ data.first
72
+ end
73
+
74
+ def last
75
+ data.last
76
+ end
77
+
78
+ def count
79
+ data.count
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Error < StandardError
3
+ end
4
+ end
@@ -0,0 +1,128 @@
1
+ module Spotify
2
+ class ErrorGenerator < StandardError
3
+ attr_reader :http_status_code
4
+ attr_reader :spotify_error_code
5
+ attr_reader :spotify_error_message
6
+
7
+ def initialize(response_body, http_status_code)
8
+ @response_body = response_body
9
+ @http_status_code = http_status_code
10
+ set_spotify_error_values
11
+ super(build_message)
12
+ end
13
+
14
+ private
15
+
16
+ def set_spotify_error_values
17
+ @spotify_error_code = @response_body["error"]["status"]
18
+ @spotify_error_message = @response_body["error"]["message"]
19
+ end
20
+
21
+ def error_message
22
+ @spotify_error_message || @response_body["error"]["message"]
23
+ rescue NoMethodError
24
+ "An unknown error occurred."
25
+ end
26
+
27
+ def build_message
28
+ if spotify_error_code.nil?
29
+ return "Error #{@http_status_code}: #{error_message}"
30
+ end
31
+ "Error #{@http_status_code}: #{error_message} '#{spotify_error_message}'"
32
+ end
33
+ end
34
+
35
+ module Errors
36
+ class BadRequestError < ErrorGenerator
37
+ private
38
+
39
+ def error_message
40
+ "Your request was malformed."
41
+ end
42
+ end
43
+
44
+ class AuthenticationMissingError < ErrorGenerator
45
+ private
46
+
47
+ def error_message
48
+ "You did not supply valid authentication credentials."
49
+ end
50
+ end
51
+
52
+ class ForbiddenError < ErrorGenerator
53
+ private
54
+
55
+ def error_message
56
+ "You are not allowed to perform that action."
57
+ end
58
+ end
59
+
60
+ class EntityNotFoundError < ErrorGenerator
61
+ private
62
+
63
+ def error_message
64
+ "No results were found for your request."
65
+ end
66
+ end
67
+
68
+ class ConflictError < ErrorGenerator
69
+ private
70
+
71
+ def error_message
72
+ "Your request was a conflict."
73
+ end
74
+ end
75
+
76
+ class TooManyRequestsError < ErrorGenerator
77
+ private
78
+
79
+ def error_message
80
+ "Your request exceeded the API rate limit."
81
+ end
82
+ end
83
+
84
+ class InternalError < ErrorGenerator
85
+ private
86
+
87
+ def error_message
88
+ "We were unable to perform the request due to server-side problems."
89
+ end
90
+ end
91
+
92
+ class ServiceUnavailableError < ErrorGenerator
93
+ private
94
+
95
+ def error_message
96
+ "You have been rate limited for sending more than 20 requests per second."
97
+ end
98
+ end
99
+
100
+ class NotImplementedError < ErrorGenerator
101
+ private
102
+
103
+ def error_message
104
+ "This resource has not been implemented."
105
+ end
106
+ end
107
+ end
108
+
109
+ class ErrorFactory
110
+ HTTP_ERROR_MAP = {
111
+ 400 => Errors::BadRequestError,
112
+ 401 => Errors::AuthenticationMissingError,
113
+ 403 => Errors::ForbiddenError,
114
+ 404 => Errors::EntityNotFoundError,
115
+ 409 => Errors::ConflictError,
116
+ 429 => Errors::TooManyRequestsError,
117
+ 500 => Errors::InternalError,
118
+ 503 => Errors::ServiceUnavailableError,
119
+ 501 => Errors::NotImplementedError
120
+ }.freeze
121
+
122
+ def self.create(response_body, http_status_code)
123
+ status = http_status_code
124
+ error_class = HTTP_ERROR_MAP[status] || ErrorGenerator
125
+ error_class.new(response_body, http_status_code) if error_class
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,61 @@
1
+ module Spotify
2
+ class OAuth
3
+ attr_reader :client_id, :client_secret
4
+
5
+ def initialize(client_id:, client_secret:)
6
+ @client_id = client_id
7
+ @client_secret = client_secret
8
+ end
9
+
10
+ def create(grant_type:, scope: nil)
11
+ send_request(url: "https://id.spotify.tv/oauth2/token", body: {
12
+ client_id: client_id,
13
+ client_secret: client_secret,
14
+ grant_type: grant_type,
15
+ scope: scope
16
+ })
17
+ end
18
+
19
+ def refresh(refresh_token:)
20
+ send_request(url: "https://id.spotify.tv/oauth2/token", body: {
21
+ client_id: client_id,
22
+ client_secret: client_secret,
23
+ grant_type: "refresh_token",
24
+ refresh_token: refresh_token
25
+ })
26
+ end
27
+
28
+ def device(scopes:)
29
+ send_request(url: "https://id.spotify.tv/oauth2/device", body: { client_id: client_id, scope: scopes })
30
+ end
31
+
32
+ def validate(token:)
33
+ response = Faraday.get("https://id.spotify.tv/oauth2/validate", nil, { "Authorization" => "OAuth #{token}" })
34
+
35
+ return false if response.status != 200
36
+
37
+ JSON.parse(response.body, object_class: OpenStruct)
38
+ end
39
+
40
+ def revoke(token:)
41
+ response = Faraday.post("https://id.spotify.tv/oauth2/revoke", {
42
+ client_id: client_id,
43
+ token: token
44
+ })
45
+
46
+ JSON.parse(response.body, object_class: OpenStruct) if response.status != 200
47
+
48
+ true
49
+ end
50
+
51
+ private
52
+
53
+ def send_request(url:, body:)
54
+ response = Faraday.post(url, body)
55
+
56
+ return false if response.status != 200
57
+
58
+ JSON.parse(response.body, object_class: OpenStruct)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,19 @@
1
+ require "ostruct"
2
+
3
+ module Spotify
4
+ class Object < OpenStruct
5
+ def initialize(attributes)
6
+ super to_ostruct(attributes)
7
+ end
8
+
9
+ def to_ostruct(obj)
10
+ if obj.is_a?(Hash)
11
+ OpenStruct.new(obj.map { |key, val| [ key, to_ostruct(val) ] }.to_h)
12
+ elsif obj.is_a?(Array)
13
+ obj.map { |o| to_ostruct(o) }
14
+ else # Assumed to be a primitive value
15
+ obj
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Album < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Artist < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Audiobook < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Device < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Episode < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Player < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Playlist < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Show < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Snapshot < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class Track < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Spotify
2
+ class User < Object
3
+ end
4
+ end
@@ -0,0 +1,64 @@
1
+ module Spotify
2
+ class Resource
3
+ attr_reader :client
4
+
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ private
10
+
11
+ def get_request(url, params: {}, headers: {})
12
+ handle_response client.connection.get(url, params, headers)
13
+ end
14
+
15
+ def post_request(url, body:, headers: {})
16
+ handle_response client.connection.post(url, body, headers)
17
+ end
18
+
19
+ def patch_request(url, body:, headers: {})
20
+ handle_response client.connection.patch(url, body, headers)
21
+ end
22
+
23
+ def put_request(url, body:, headers: {})
24
+ handle_response client.connection.put(url, body, headers)
25
+ end
26
+
27
+ # def delete_request(url, params: {}, headers: {})
28
+ # handle_response client.connection.delete(url, params, headers)
29
+ # end
30
+
31
+ def delete_request(url, body: {}, headers: {})
32
+ response = client.connection.delete(url) do |req|
33
+ req.headers["Content-Type"] = "application/json"
34
+ req.body = body.to_json
35
+ end
36
+
37
+ handle_response response
38
+ end
39
+
40
+ def handle_response(response)
41
+ return true if response.status == 204 && (response.body.nil? || response.body.empty?)
42
+ return true if response.status == 200 && (response.body.nil? || response.body.empty?)
43
+ return response unless error?(response)
44
+
45
+ raise_error(response)
46
+ end
47
+
48
+ def error?(response)
49
+ [ 400, 401, 403, 404, 409, 429, 500, 501, 503 ].include?(response.status) ||
50
+ begin
51
+ body = response.body
52
+ body = JSON.parse(body) if body.is_a?(String) && body.strip.start_with?("{")
53
+ body.respond_to?(:key?) && body.key?("error")
54
+ rescue JSON::ParserError
55
+ false
56
+ end
57
+ end
58
+
59
+ def raise_error(response)
60
+ error = Spotify::ErrorFactory.create(response.body, response.status)
61
+ raise error if error
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ module Spotify
2
+ class AlbumsResource < Resource
3
+ def list(ids:, market: nil)
4
+ response = get_request("albums", params: { ids: ids, market: market })
5
+ Collection.from_response(response, type: Album, key: "albums")
6
+ end
7
+ def get(id:, market: nil)
8
+ response = get_request("albums/#{id}", params: { market: market })
9
+ Album.new response.body
10
+ end
11
+ def tracks(id:, **params)
12
+ response = get_request("albums/#{id}/tracks", params: params)
13
+ Collection.from_response(response, type: Track, key: "items")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ module Spotify
2
+ class ArtistsResource < Resource
3
+ def list(ids:)
4
+ response = get_request("artists", params: { ids: ids })
5
+ Collection.from_response(response, type: Artist, key: "artists")
6
+ end
7
+ def get(id:, market: nil)
8
+ response = get_request("artists/#{id}", params: { market: market })
9
+ Artist.new response.body
10
+ end
11
+ def albums(id:, **params)
12
+ response = get_request("artists/#{id}/albums", params: params)
13
+ Collection.from_response(response, type: Album, key: "items")
14
+ end
15
+ def top_tracks(id:, market: nil)
16
+ response = get_request("artists/#{id}/top-tracks", params: { market: market })
17
+ Collection.from_response(response, type: Track, key: "tracks")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ module Spotify
2
+ class MeResource < Resource
3
+ def me
4
+ response = get_request("me")
5
+ User.new response.body
6
+ end
7
+
8
+ def playlists(**params)
9
+ response = get_request("me/playlists", params: params)
10
+ Collection.from_response(response, type: Playlist, key: "items")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ module Spotify
2
+ class PlayerResource < Resource
3
+ def state(**params)
4
+ response = get_request("me/player", params: params)
5
+ if response == true
6
+ nil
7
+ else
8
+ Player.new response.body
9
+ end
10
+ end
11
+
12
+ def devices
13
+ response = get_request("me/player/devices")
14
+ Collection.from_response(response, type: Device, key: "devices")
15
+ end
16
+
17
+ def playing(**params)
18
+ response = get_request("me/player/currently-playing", params: params)
19
+ if response == true
20
+ nil
21
+ else
22
+ Player.new response.body
23
+ end
24
+ end
25
+
26
+ def play(device: nil)
27
+ response = put_request("me/player/play", body: { device_id: device }.compact)
28
+ response.success?
29
+ end
30
+
31
+ def pause(device: nil)
32
+ response = put_request("me/player/pause", body: { device_id: device }.compact)
33
+ response.success?
34
+ end
35
+
36
+ def next(device: nil)
37
+ response = post_request("me/player/next", body: { device_id: device }.compact)
38
+ response.success?
39
+ end
40
+
41
+ def previous(device: nil)
42
+ response = post_request("me/player/previous", body: { device_id: device }.compact)
43
+ response.success?
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,37 @@
1
+ module Spotify
2
+ class PlaylistsResource < Resource
3
+ def get(id:, **params)
4
+ response = get_request("playlists/#{id}", params: params)
5
+ Playlist.new response.body
6
+ end
7
+
8
+ def update(id:, **attrs)
9
+ put_request("playlists/#{id}", body: attrs)
10
+ end
11
+
12
+ def tracks(id:, **params)
13
+ response = get_request("playlists/#{id}/tracks", params: params)
14
+ Collection.from_response(response, type: Track, key: "items")
15
+ end
16
+
17
+ def add_tracks(id:, uris:, position: nil)
18
+ body = { uris: uris.split(",") }
19
+ body[:position] = position if position
20
+ response = post_request("playlists/#{id}/tracks", body: body)
21
+ Snapshot.new response.body
22
+ end
23
+
24
+ def remove_tracks(id:, uris:, snapshot_id: nil)
25
+ body = { tracks: uris.split(",").map { |uri| { uri: uri } } }
26
+ body[:snapshot_id] = snapshot_id if snapshot_id
27
+ response = delete_request("playlists/#{id}/tracks", body: body)
28
+ Snapshot.new response.body
29
+ end
30
+
31
+ def create(user:, name:, **params)
32
+ attrs = { name: name }
33
+ response = post_request("users/#{user}/playlists", body: attrs.merge(params))
34
+ Playlist.new response.body
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,10 @@
1
+ module Spotify
2
+ class SearchResource < Resource
3
+ def search(query:, type:, market: nil, limit: nil, offset: nil)
4
+ params = { q: query, type: type, market: market, limit: limit, offset: offset }
5
+ response = get_request("search", params: params)
6
+
7
+ Collection.from_search_response(response)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module Spotify
2
+ class UsersResource < Resource
3
+ def get(id:)
4
+ response = get_request("users/#{id}")
5
+ User.new response.body
6
+ end
7
+
8
+ def playlists(id:, **params)
9
+ response = get_request("users/#{id}/playlists", params: params)
10
+ Collection.from_response(response, type: Playlist, key: "items")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module Spotify
2
+ VERSION = "0.1.0"
3
+ end
data/lib/spotify.rb ADDED
@@ -0,0 +1,36 @@
1
+ require "faraday"
2
+ require "json"
3
+ require "ostruct"
4
+ require "spotify/version"
5
+
6
+ module Spotify
7
+ autoload :Error, "spotify/error"
8
+ autoload :Errors, "spotify/error_generator"
9
+ autoload :ErrorGenerator, "spotify/error_generator"
10
+ autoload :ErrorFactory, "spotify/error_generator"
11
+
12
+ autoload :Client, "spotify/client"
13
+ autoload :Collection, "spotify/collection"
14
+ autoload :Resource, "spotify/resource"
15
+ autoload :Object, "spotify/object"
16
+
17
+ autoload :MeResource, "spotify/resources/me"
18
+ autoload :SearchResource, "spotify/resources/search"
19
+ autoload :PlayerResource, "spotify/resources/player"
20
+ autoload :UsersResource, "spotify/resources/users"
21
+ autoload :AlbumsResource, "spotify/resources/albums"
22
+ autoload :ArtistsResource, "spotify/resources/artists"
23
+ autoload :PlaylistsResource, "spotify/resources/playlists"
24
+
25
+ autoload :User, "spotify/objects/user"
26
+ autoload :Album, "spotify/objects/album"
27
+ autoload :Track, "spotify/objects/track"
28
+ autoload :Artist, "spotify/objects/artist"
29
+ autoload :Audiobook, "spotify/objects/audiobook"
30
+ autoload :Episode, "spotify/objects/episode"
31
+ autoload :Playlist, "spotify/objects/playlist"
32
+ autoload :Show, "spotify/objects/show"
33
+ autoload :Snapshot, "spotify/objects/snapshot"
34
+ autoload :Player, "spotify/objects/player"
35
+ autoload :Device, "spotify/objects/device"
36
+ end
data/lib/spotifyrb.rb ADDED
@@ -0,0 +1 @@
1
+ require "spotify"
data/spotifyrb.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ require_relative 'lib/spotify/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "spotifyrb"
5
+ spec.version = Spotify::VERSION
6
+ spec.authors = [ "Dean Perry" ]
7
+ spec.email = [ "dean@deanpcmad.com" ]
8
+
9
+ spec.summary = "A Ruby library for interacting with the Spotify API"
10
+ spec.homepage = "https://deanpcmad.com"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = "https://github.com/deanpcmad/spotifyrb"
16
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.bindir = "exe"
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = [ "lib" ]
26
+
27
+ spec.add_dependency "faraday", "~> 2.11"
28
+ spec.add_dependency "ostruct", "~> 0.6.0"
29
+ end