tmdb_client 1.0.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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # Settings for a client. Held on a plain object rather than in a class
5
+ # variable, so a process can run more than one (a different language, a
6
+ # different key) without them treading on each other.
7
+ #
8
+ # Not to be confused with TMDb::Configuration, which is the `/configuration`
9
+ # endpoint.
10
+ class Config
11
+ DEFAULT_BASE_URL = 'https://api.themoviedb.org/3'
12
+ DEFAULT_TIMEOUT = 10
13
+ DEFAULT_OPEN_TIMEOUT = 5
14
+ DEFAULT_RETRIES = 2
15
+ DEFAULT_RETRY_INTERVAL = 0.5
16
+ # TMDb sends Retry-After on a 429, and the retry middleware honours it.
17
+ # Waiting on it is right; waiting on it for minutes inside a web request is
18
+ # not, so a wait longer than this gives up instead of sleeping.
19
+ DEFAULT_MAX_RETRY_INTERVAL = 10
20
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
21
+
22
+ # A v3 API key, sent as a query parameter.
23
+ attr_accessor :api_key
24
+ # A v4 read access token, sent as a bearer header. Takes precedence over
25
+ # api_key when both are set.
26
+ attr_accessor :access_token
27
+ attr_accessor :language, :region, :base_url, :timeout, :open_timeout,
28
+ :retries, :retry_interval, :max_retry_interval, :adapter, :logger, :user_agent
29
+
30
+ def initialize
31
+ @base_url = DEFAULT_BASE_URL
32
+ @timeout = DEFAULT_TIMEOUT
33
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
34
+ @retries = DEFAULT_RETRIES
35
+ @retry_interval = DEFAULT_RETRY_INTERVAL
36
+ @max_retry_interval = DEFAULT_MAX_RETRY_INTERVAL
37
+ @adapter = Faraday.default_adapter
38
+ @user_agent = "tmdb_client/#{TMDb::VERSION} (+https://github.com/cineol-gems/tmdb-client)"
39
+ end
40
+
41
+ def bearer_auth?
42
+ !access_token.nil? && !access_token.to_s.empty?
43
+ end
44
+
45
+ def credentials?
46
+ bearer_auth? || !api_key.to_s.empty?
47
+ end
48
+
49
+ # The query parameters every request starts from. A per-call filter of the
50
+ # same name overrides these, which is how `language: 'en'` works on one call
51
+ # while the client stays configured for Spanish.
52
+ def default_params
53
+ params = { language: language, region: region }
54
+ params[:api_key] = api_key unless bearer_auth?
55
+
56
+ params.compact
57
+ end
58
+
59
+ def headers
60
+ base = { 'Accept' => 'application/json', 'User-Agent' => user_agent }
61
+ base['Authorization'] = "Bearer #{access_token}" if bearer_auth?
62
+
63
+ base
64
+ end
65
+
66
+ def initialize_copy(other)
67
+ super
68
+ @adapter = other.adapter.dup if other.adapter.is_a?(::Array)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # The `/configuration` endpoints -- image base URLs, the country and language
5
+ # lists, the job list. Not to be confused with TMDb::Config, which is this
6
+ # gem's own settings.
7
+ class Configuration < Resource
8
+ def get(filters = {})
9
+ object('/configuration', filters)
10
+ end
11
+ alias detail get
12
+
13
+ def countries(filters = {})
14
+ objects(fetch('/configuration/countries', filters))
15
+ end
16
+
17
+ def languages(filters = {})
18
+ objects(fetch('/configuration/languages', filters))
19
+ end
20
+
21
+ def jobs(filters = {})
22
+ objects(fetch('/configuration/jobs', filters))
23
+ end
24
+
25
+ def timezones(filters = {})
26
+ objects(fetch('/configuration/timezones', filters))
27
+ end
28
+
29
+ # An Array of locale strings, not objects.
30
+ def primary_translations(filters = {})
31
+ fetch('/configuration/primary_translations', filters)
32
+ end
33
+
34
+ expose_class_methods
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Credit < Resource
5
+ def detail(id, filters = {})
6
+ object("/credit/#{id}", filters)
7
+ end
8
+
9
+ expose_class_methods
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Discover < Resource
5
+ def movie(filters = {})
6
+ page('/discover/movie', filters)
7
+ end
8
+
9
+ def tv(filters = {})
10
+ page('/discover/tv', filters)
11
+ end
12
+
13
+ expose_class_methods
14
+ end
15
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # Base class for everything this gem raises.
5
+ #
6
+ # `rescue TMDb::Error` catches every failure, transport ones included: no
7
+ # Faraday, socket or JSON exception is ever allowed to escape the client, so
8
+ # callers never have to know what the client is built on.
9
+ #
10
+ # Where the old `themoviedb-api` gem collapsed every failure into one class
11
+ # carrying only a message string, an error here knows its HTTP `status`,
12
+ # TMDb's own numeric `code`, and the parsed `body` -- so a missing id can be
13
+ # told from a bad key without matching on prose.
14
+ class Error < StandardError
15
+ attr_reader :status, :code, :body, :retry_after
16
+
17
+ def initialize(message = nil, status: nil, code: nil, body: nil, retry_after: nil)
18
+ @status = status
19
+ @code = code
20
+ @body = body
21
+ @retry_after = retry_after
22
+
23
+ super(message || 'TMDb request failed')
24
+ end
25
+
26
+ # Maps an HTTP status onto the class raised for it. Unknown 4xx and 5xx
27
+ # statuses still land under ClientError/ServerError rather than the bare
28
+ # base, so `rescue TMDb::ServerError` keeps meaning "their side".
29
+ def self.class_for(status)
30
+ STATUS_ERRORS.fetch(status) do
31
+ if status.to_i >= 500 then ServerError
32
+ elsif status.to_i >= 400 then ClientError
33
+ else Error
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ # The gem was asked to make a request it has no credentials for. Raised
40
+ # before any HTTP happens, so a forgotten `TMDb.configure` reads as a
41
+ # configuration mistake rather than as a 401 from TMDb.
42
+ class ConfigurationError < Error; end
43
+
44
+ # TMDb answered, but with a failing status.
45
+ class HTTPError < Error; end
46
+
47
+ # 4xx -- we asked wrongly.
48
+ class ClientError < HTTPError; end
49
+ class BadRequest < ClientError; end
50
+ class Unauthorized < ClientError; end
51
+ class Forbidden < ClientError; end
52
+ class NotFound < ClientError; end
53
+ class MethodNotAllowed < ClientError; end
54
+ class NotAcceptable < ClientError; end
55
+ class UnprocessableEntity < ClientError; end
56
+
57
+ # 429. `retry_after` carries the header TMDb sent, in seconds, when it sent one.
58
+ class RateLimited < ClientError; end
59
+
60
+ # 5xx -- their side.
61
+ class ServerError < HTTPError; end
62
+ class ServiceUnavailable < ServerError; end
63
+
64
+ # We never reached TMDb, or could not read what came back.
65
+ class ConnectionError < Error; end
66
+ class TimeoutError < ConnectionError; end
67
+ class ParsingError < Error; end
68
+
69
+ Error::STATUS_ERRORS = {
70
+ 400 => BadRequest,
71
+ 401 => Unauthorized,
72
+ 403 => Forbidden,
73
+ 404 => NotFound,
74
+ 405 => MethodNotAllowed,
75
+ 406 => NotAcceptable,
76
+ 422 => UnprocessableEntity,
77
+ 429 => RateLimited,
78
+ 500 => ServerError,
79
+ 502 => ServiceUnavailable,
80
+ 503 => ServiceUnavailable,
81
+ 504 => ServiceUnavailable
82
+ }.freeze
83
+ end
data/lib/tmdb/find.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # Looks a title up by an id from somewhere else -- IMDb, TVDB, Wikidata:
5
+ #
6
+ # TMDb::Find.movie('tt0137523', external_source: 'imdb_id')
7
+ #
8
+ # Each method returns a bare Array of the matches for that media type, which
9
+ # is nearly always empty or one long.
10
+ class Find < Resource
11
+ def movie(external_id, filters = {})
12
+ list("/find/#{external_id}", :movie_results, filters)
13
+ end
14
+
15
+ def people(external_id, filters = {})
16
+ list("/find/#{external_id}", :person_results, filters)
17
+ end
18
+ alias person people
19
+
20
+ def tv_series(external_id, filters = {})
21
+ list("/find/#{external_id}", :tv_results, filters)
22
+ end
23
+ # themoviedb-api spelled it `tv_serie`. Kept so a migration is a rename.
24
+ alias tv_serie tv_series
25
+
26
+ # themoviedb-api read `tv_episode_results` here and `tv_season_results` in
27
+ # tv_episode -- the two were swapped. These read the right keys.
28
+ def tv_season(external_id, filters = {})
29
+ list("/find/#{external_id}", :tv_season_results, filters)
30
+ end
31
+
32
+ def tv_episode(external_id, filters = {})
33
+ list("/find/#{external_id}", :tv_episode_results, filters)
34
+ end
35
+
36
+ # Every bucket from a single request, where asking for a film and then a
37
+ # series costs two.
38
+ def all(external_id, filters = {})
39
+ object("/find/#{external_id}", filters)
40
+ end
41
+
42
+ expose_class_methods
43
+ end
44
+ end
data/lib/tmdb/genre.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Genre < Resource
5
+ def movie_list(filters = {})
6
+ list('/genre/movie/list', :genres, filters)
7
+ end
8
+
9
+ def tv_list(filters = {})
10
+ list('/genre/tv/list', :genres, filters)
11
+ end
12
+
13
+ # Deprecated by TMDb; TMDb::Discover.movie(with_genres: id) is the
14
+ # supported way to ask this. Kept for parity with themoviedb-api.
15
+ def movies(genre_id, filters = {})
16
+ page("/genre/#{genre_id}/movies", filters)
17
+ end
18
+
19
+ expose_class_methods
20
+ end
21
+ end
data/lib/tmdb/job.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # Deprecated by TMDb in favour of TMDb::Configuration.jobs. Kept for parity
5
+ # with themoviedb-api.
6
+ class Job < Resource
7
+ def list(filters = {})
8
+ objects(fetch('/job/list', filters)[:jobs])
9
+ end
10
+
11
+ expose_class_methods
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Keyword < Resource
5
+ def detail(id, filters = {})
6
+ object("/keyword/#{id}", filters)
7
+ end
8
+
9
+ def movies(id, filters = {})
10
+ page("/keyword/#{id}/movies", filters)
11
+ end
12
+
13
+ expose_class_methods
14
+ end
15
+ end
data/lib/tmdb/movie.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Movie < Resource
5
+ def detail(id, filters = {})
6
+ object("/movie/#{id}", filters)
7
+ end
8
+
9
+ def alternative_titles(id, filters = {})
10
+ list("/movie/#{id}/alternative_titles", :titles, filters)
11
+ end
12
+
13
+ def credits(id, filters = {})
14
+ object("/movie/#{id}/credits", filters)
15
+ end
16
+
17
+ def cast(id, filters = {})
18
+ list("/movie/#{id}/credits", :cast, filters)
19
+ end
20
+
21
+ def crew(id, filters = {})
22
+ list("/movie/#{id}/credits", :crew, filters)
23
+ end
24
+
25
+ def director(id, filters = {})
26
+ crew(id, filters).select { |member| member[:job] == 'Director' }
27
+ end
28
+
29
+ def external_ids(id, filters = {})
30
+ object("/movie/#{id}/external_ids", filters)
31
+ end
32
+
33
+ def images(id, filters = {})
34
+ object("/movie/#{id}/images", filters)
35
+ end
36
+
37
+ def backdrops(id, filters = {})
38
+ list("/movie/#{id}/images", :backdrops, filters)
39
+ end
40
+
41
+ def posters(id, filters = {})
42
+ list("/movie/#{id}/images", :posters, filters)
43
+ end
44
+
45
+ def logos(id, filters = {})
46
+ list("/movie/#{id}/images", :logos, filters)
47
+ end
48
+
49
+ def videos(id, filters = {})
50
+ list("/movie/#{id}/videos", :results, filters)
51
+ end
52
+
53
+ def keywords(id, filters = {})
54
+ list("/movie/#{id}/keywords", :keywords, filters)
55
+ end
56
+
57
+ def watch_providers(id, filters = {})
58
+ object("/movie/#{id}/watch/providers", filters)
59
+ end
60
+
61
+ def release_dates(id, filters = {})
62
+ list("/movie/#{id}/release_dates", :results, filters)
63
+ end
64
+
65
+ # Deprecated by TMDb in favour of `release_dates`; kept for parity with
66
+ # themoviedb-api, which only had this one.
67
+ def releases(id, filters = {})
68
+ list("/movie/#{id}/releases", :countries, filters)
69
+ end
70
+
71
+ def translations(id, filters = {})
72
+ list("/movie/#{id}/translations", :translations, filters)
73
+ end
74
+
75
+ def changes(id, filters = {})
76
+ list("/movie/#{id}/changes", :changes, filters)
77
+ end
78
+
79
+ def similar(id, filters = {})
80
+ page("/movie/#{id}/similar", filters)
81
+ end
82
+
83
+ def recommendations(id, filters = {})
84
+ page("/movie/#{id}/recommendations", filters)
85
+ end
86
+
87
+ def reviews(id, filters = {})
88
+ page("/movie/#{id}/reviews", filters)
89
+ end
90
+
91
+ def lists(id, filters = {})
92
+ page("/movie/#{id}/lists", filters)
93
+ end
94
+
95
+ def latest(filters = {})
96
+ object('/movie/latest', filters)
97
+ end
98
+
99
+ def upcoming(filters = {})
100
+ page('/movie/upcoming', filters)
101
+ end
102
+
103
+ def now_playing(filters = {})
104
+ page('/movie/now_playing', filters)
105
+ end
106
+
107
+ def popular(filters = {})
108
+ page('/movie/popular', filters)
109
+ end
110
+
111
+ def top_rated(filters = {})
112
+ page('/movie/top_rated', filters)
113
+ end
114
+
115
+ expose_class_methods
116
+ end
117
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Network < Resource
5
+ def detail(id, filters = {})
6
+ object("/network/#{id}", filters)
7
+ end
8
+
9
+ def alternative_names(id, filters = {})
10
+ list("/network/#{id}/alternative_names", :results, filters)
11
+ end
12
+
13
+ def images(id, filters = {})
14
+ object("/network/#{id}/images", filters)
15
+ end
16
+
17
+ expose_class_methods
18
+ end
19
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # An immutable, hash-backed view over one TMDb JSON object.
5
+ #
6
+ # TMDb's payloads are wide, deeply nested, and change shape between endpoints
7
+ # and `append_to_response` combinations, so this gem does not try to declare a
8
+ # field list per resource -- it wraps whatever came back and lets you read it
9
+ # three ways, whichever suits the call site:
10
+ #
11
+ # movie.title # => "Fight Club"
12
+ # movie[:title] # => "Fight Club"
13
+ # movie['title'] # => "Fight Club"
14
+ #
15
+ # Nested objects and arrays are wrapped recursively, so `movie.credits.cast`
16
+ # is an Array of TMDb::Object. A key TMDb did not send reads as `nil` rather
17
+ # than raising: half of TMDb's fields are conditional (a credit has no
18
+ # `imdb_id`, a film in production has no `release_date`), and forcing every
19
+ # caller to guard each read would be worse than the typo it would catch.
20
+ #
21
+ # This replaces the old gem's OpenStruct subclass. Unlike OpenStruct it is
22
+ # frozen, allocates no singleton class per instance, and cannot be mutated
23
+ # into disagreeing with the response it came from.
24
+ class Object
25
+ # Ruby calls these implicitly to convert an object. Answering `nil` to them
26
+ # would make Ruby raise a confusing TypeError deep inside a splat or an
27
+ # interpolation, so they must miss loudly instead.
28
+ COERCION_METHODS = %i[to_ary to_a to_hash to_str to_int to_io to_proc coerce].freeze
29
+
30
+ attr_reader :attributes
31
+
32
+ def initialize(attributes = {})
33
+ @attributes = self.class.normalize(attributes)
34
+
35
+ freeze
36
+ end
37
+
38
+ class << self
39
+ # Symbolizes keys one level deep and wraps the values. JSON keys arrive as
40
+ # strings, hand-built test doubles as symbols; both end up identical.
41
+ def normalize(attributes)
42
+ case attributes
43
+ when Object then attributes.attributes
44
+ when nil then {}.freeze
45
+ when ::Hash
46
+ attributes.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = wrap(value) }.freeze
47
+ else
48
+ raise ArgumentError, "TMDb::Object expects a Hash, got #{attributes.class}"
49
+ end
50
+ end
51
+
52
+ # Anything already wrapped, and every scalar, falls through untouched.
53
+ def wrap(value)
54
+ case value
55
+ when ::Hash then new(value)
56
+ when ::Array then value.map { |element| wrap(element) }.freeze
57
+ else value
58
+ end
59
+ end
60
+ end
61
+
62
+ def [](key)
63
+ @attributes[key.to_sym]
64
+ end
65
+
66
+ def fetch(key, *default, &)
67
+ @attributes.fetch(key.to_sym, *default, &)
68
+ end
69
+
70
+ def dig(key, *rest)
71
+ value = self[key]
72
+
73
+ rest.empty? ? value : value&.dig(*rest)
74
+ end
75
+
76
+ def key?(key)
77
+ @attributes.key?(key.to_sym)
78
+ end
79
+ alias has_key? key?
80
+ alias member? key?
81
+
82
+ def keys
83
+ @attributes.keys
84
+ end
85
+
86
+ def each_pair(&block)
87
+ return enum_for(:each_pair) unless block
88
+
89
+ @attributes.each_pair(&block)
90
+ self
91
+ end
92
+
93
+ # A plain, deeply-unwrapped Hash with symbol keys -- the inverse of `new`.
94
+ def to_h
95
+ @attributes.transform_values { |value| self.class.unwrap(value) }
96
+ end
97
+
98
+ def self.unwrap(value)
99
+ case value
100
+ when Object then value.to_h
101
+ when ::Array then value.map { |element| unwrap(element) }
102
+ else value
103
+ end
104
+ end
105
+
106
+ def ==(other)
107
+ other.is_a?(Object) && attributes == other.attributes
108
+ end
109
+ alias eql? ==
110
+
111
+ def hash
112
+ [self.class, @attributes].hash
113
+ end
114
+
115
+ def inspect
116
+ "#<TMDb::Object #{@attributes.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
117
+ end
118
+ alias to_s inspect
119
+
120
+ private
121
+
122
+ def method_missing(name, *args)
123
+ return super unless args.empty?
124
+ return super if COERCION_METHODS.include?(name)
125
+ return super if name.end_with?('=', '?', '!')
126
+
127
+ @attributes[name]
128
+ end
129
+
130
+ # Deliberately narrower than `method_missing`: only keys actually present
131
+ # answer true. Ruby's implicit conversion checks (`to_ary` and friends) and
132
+ # any `respond_to?`-based duck typing therefore get an honest answer, while
133
+ # a plain read of an absent key still returns nil.
134
+ def respond_to_missing?(name, include_private = false)
135
+ @attributes.key?(name) || super
136
+ end
137
+ end
138
+ end