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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a992679eb8cc1ca24e7e22e99346898c04fc72eb8be46a6d33b9411c91a35f87
4
+ data.tar.gz: b352e9575e802db5fb9b96bd02c5fc1450b7c7294902c043da3b21bb8ec2f112
5
+ SHA512:
6
+ metadata.gz: c4defc01e39e05ce85d43fb11a148dbab02a3d6992ebcf8c8eed8d744bfabcd8da17c78278e3018571851f6d56b3fe7da12f0b902b5c4a92dd4d661d10513268
7
+ data.tar.gz: 0e87f12e4dffd91f038a8a924b0f1dec4458653f51e4ea4ac14c848c38aa81b3d1f3f82bc1f9d86f6d92d06514c3f07e2f44a8135c7d6478502357753b409a5e
data/CHANGELOG.md ADDED
@@ -0,0 +1,63 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0]
9
+
10
+ First release. A rewrite of [`themoviedb-api`](https://github.com/18Months/themoviedb-api)
11
+ 1.4.1, which has been unmaintained since 2023, under the `TMDb` namespace. Same
12
+ endpoints, modern stack.
13
+
14
+ ### Added
15
+
16
+ - Faraday 2 as the HTTP layer, replacing `rest-client`.
17
+ - `TMDb::Object`, an immutable hash-backed value object, replacing the
18
+ `OpenStruct` subclass the old gem returned. Reads by method, by string key and
19
+ by symbol key; nests recursively; answers `nil` for a key TMDb did not send.
20
+ - An error class per HTTP status — `NotFound`, `Unauthorized`, `RateLimited`,
21
+ `ServerError` and the rest — all under `TMDb::Error`, each carrying the status,
22
+ TMDb's own `status_code` and the response body. Distinguishing a missing id
23
+ from a bad key no longer means matching on the message text.
24
+ - Transport failures raised as `TMDb::ConnectionError` / `TMDb::TimeoutError`;
25
+ no Faraday or socket exception escapes the client.
26
+ - Retries with exponential backoff for 429, 5xx and dropped connections,
27
+ honouring `Retry-After`. Connect and read timeouts, defaulting to 5s and 10s.
28
+ - `TMDb::Page` for every paginated endpoint, `Enumerable` over its results, with
29
+ `fetch_next_page`, `each_page` and a lazy `auto_paginate`.
30
+ - Instantiable clients (`TMDb::Client.new(language: 'en')`) alongside the class
31
+ methods, so one process can run more than one configuration.
32
+ - v4 bearer token authentication alongside the v3 API key.
33
+ - Endpoints TMDb added since the old gem was written: `watch_providers`,
34
+ `release_dates`, `aggregate_credits`, `logos`, `alternative_names`,
35
+ `TV.recommendations`, `Movie.external_ids`, `Find.all`, and the
36
+ `/configuration` sub-resources.
37
+
38
+ ### Fixed
39
+
40
+ - `Find.tv_season` and `Find.tv_episode` read each other's result buckets.
41
+ - The base URL was plain HTTP, sending the API key in cleartext.
42
+ - `Search.*` mutated the filters hash it was passed.
43
+ - No timeouts were set, so a hung connection hung the caller indefinitely.
44
+
45
+ ### Changed
46
+
47
+ - **Breaking:** the namespace is `TMDb`, not `Tmdb`.
48
+ - **Breaking:** configuration is `TMDb.configure { |c| c.api_key = ... }` rather
49
+ than `Tmdb::Api.key(...)`.
50
+ - **Breaking:** paginated endpoints all return `TMDb::Page`, where the old gem
51
+ returned a different envelope class per endpoint.
52
+ - **Breaking:** the endpoint classes are no longer value objects, so
53
+ `Tmdb::Person.new(id: 1)` becomes `TMDb::Object.new(id: 1)`.
54
+ - `Tmdb::Find.tv_serie` is now `tv_series`; the old spelling remains as an alias.
55
+ - `Tmdb::Tv::Season` is now `TMDb::TV::Season`; `TMDb::Tv` remains as an alias.
56
+ - Ruby >= 3.2 is required.
57
+ - No core monkeypatching. The old gem patched `Hash` and `Object` when
58
+ ActiveSupport was absent.
59
+
60
+ ### Removed
61
+
62
+ - `Account`, `Authentication`, `List` and `Rating`: value structs with no
63
+ endpoints behind them, since the client is read-only.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cineol
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # tmdb-client
2
+
3
+ A Ruby client for [The Movie Database](https://developer.themoviedb.org/docs) v3 API.
4
+
5
+ Built on Faraday 2, with typed errors, retries, timeouts and no `OpenStruct`. It
6
+ replaces the unmaintained [`themoviedb-api`](https://github.com/18Months/themoviedb-api)
7
+ gem and covers the same endpoints — see [Coming from themoviedb-api](#coming-from-themoviedb-api).
8
+
9
+ Requires Ruby >= 3.2.
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ gem 'tmdb_client', '~> 1.0'
15
+ ```
16
+
17
+ ```sh
18
+ gem install tmdb_client
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ ```ruby
24
+ TMDb.configure do |config|
25
+ config.api_key = ENV['TMDB_API_KEY'] # a v3 API key
26
+ config.language = 'es'
27
+ end
28
+ ```
29
+
30
+ In Rails, `config/initializers/tmdb.rb` is the place for it.
31
+
32
+ A v4 read access token works too, and takes precedence when both are set:
33
+
34
+ ```ruby
35
+ TMDb.configure { |config| config.access_token = ENV['TMDB_ACCESS_TOKEN'] }
36
+ ```
37
+
38
+ | Setting | Default | What it does |
39
+ | --- | --- | --- |
40
+ | `api_key` | `nil` | v3 API key, sent as a query parameter |
41
+ | `access_token` | `nil` | v4 read access token, sent as a bearer header |
42
+ | `language` | `nil` | Default `language` parameter, e.g. `'es'` or `'es-ES'` |
43
+ | `region` | `nil` | Default `region` parameter, e.g. `'ES'` |
44
+ | `base_url` | `https://api.themoviedb.org/3` | API root |
45
+ | `timeout` | `10` | Read timeout, seconds |
46
+ | `open_timeout` | `5` | Connect timeout, seconds |
47
+ | `retries` | `2` | Retries for 429, 500, 502, 503, 504 and dropped connections |
48
+ | `retry_interval` | `0.5` | First backoff wait, seconds; doubles each retry |
49
+ | `max_retry_interval` | `10` | Longest `Retry-After` this will wait for; beyond it, give up |
50
+ | `adapter` | Faraday's default | Faraday adapter |
51
+ | `logger` | `nil` | A Logger; set it and requests are logged |
52
+ | `user_agent` | `tmdb_client/VERSION` | `User-Agent` header |
53
+
54
+ Missing credentials raise `TMDb::ConfigurationError` before any request is made.
55
+
56
+ ## Making requests
57
+
58
+ Every endpoint is a class method:
59
+
60
+ ```ruby
61
+ TMDb::Movie.detail(550)
62
+ TMDb::TV.detail(1396)
63
+ TMDb::TV::Season.detail(1396, 1)
64
+ TMDb::TV::Episode.detail(1396, 1, 1)
65
+ TMDb::Person.detail(287)
66
+ ```
67
+
68
+ Every one takes an optional trailing hash of filters, passed straight through as
69
+ query parameters. A filter always beats the configured default, which is how one
70
+ call can ask for English while the client stays configured for Spanish:
71
+
72
+ ```ruby
73
+ TMDb::Movie.detail(550, append_to_response: 'credits,external_ids', language: 'en')
74
+ TMDb::Discover.movie(with_genres: 18, 'primary_release_date.gte': '2026-01-01')
75
+ ```
76
+
77
+ ### Several clients at once
78
+
79
+ The class methods go through a shared client. For a second set of settings —
80
+ another language, another key — build a client and pass it in:
81
+
82
+ ```ruby
83
+ english = TMDb::Client.new(language: 'en')
84
+
85
+ TMDb::Movie.new(english).detail(550)
86
+ ```
87
+
88
+ ## Reading responses
89
+
90
+ Responses come back as `TMDb::Object`: immutable, hash-backed, and readable
91
+ whichever way suits the call site.
92
+
93
+ ```ruby
94
+ movie = TMDb::Movie.detail(550, append_to_response: 'credits')
95
+
96
+ movie.title # => "Fight Club"
97
+ movie[:title] # => "Fight Club"
98
+ movie['title'] # => "Fight Club"
99
+
100
+ movie.credits.cast.first.character # nested objects and arrays are wrapped
101
+ movie.genres.map { |genre| genre[:id] } # => [18, 53]
102
+ movie.to_h # a plain, deeply unwrapped Hash
103
+ ```
104
+
105
+ A key TMDb did not send reads as `nil` rather than raising — many TMDb fields are
106
+ conditional, and a credit has no `imdb_id`:
107
+
108
+ ```ruby
109
+ movie.credits.cast.first.imdb_id # => nil
110
+ movie.key?(:imdb_id) # => false, if you need to tell absent from null
111
+ ```
112
+
113
+ ## Pagination
114
+
115
+ Every paginated endpoint returns a `TMDb::Page`, which is `Enumerable` over its
116
+ results:
117
+
118
+ ```ruby
119
+ page = TMDb::Search.movie('blade runner')
120
+
121
+ page.results # => [TMDb::Object, ...]
122
+ page.page # => 1
123
+ page.total_pages # => 3
124
+ page.total_results # => 47
125
+ page.map(&:title)
126
+ ```
127
+
128
+ Following pages are fetched on demand, reusing the original filters:
129
+
130
+ ```ruby
131
+ page.fetch_next_page # the next Page, or nil on the last one
132
+ page.each_page { |p| ... } # this page, then each following one
133
+ page.auto_paginate.first(50) # results across page boundaries, lazily
134
+ ```
135
+
136
+ `TMDb::Find` is the exception: it returns a plain Array, because a lookup by
137
+ external id matches at most a handful of things.
138
+
139
+ ## Errors
140
+
141
+ Everything raised inherits from `TMDb::Error`, so one rescue catches the lot —
142
+ including transport failures. No Faraday or socket exception escapes.
143
+
144
+ ```ruby
145
+ begin
146
+ TMDb::Movie.detail(id)
147
+ rescue TMDb::NotFound
148
+ nil # a wrong id
149
+ rescue TMDb::RateLimited => e
150
+ retry_in(e.retry_after) # seconds, from the Retry-After header
151
+ rescue TMDb::Error => e
152
+ report(e)
153
+ end
154
+ ```
155
+
156
+ | Class | Raised for |
157
+ | --- | --- |
158
+ | `TMDb::BadRequest` | 400 |
159
+ | `TMDb::Unauthorized` | 401 |
160
+ | `TMDb::Forbidden` | 403 |
161
+ | `TMDb::NotFound` | 404 |
162
+ | `TMDb::MethodNotAllowed` | 405 |
163
+ | `TMDb::NotAcceptable` | 406 |
164
+ | `TMDb::UnprocessableEntity` | 422 |
165
+ | `TMDb::RateLimited` | 429 |
166
+ | `TMDb::ServerError` | 500, and any other 5xx |
167
+ | `TMDb::ServiceUnavailable` | 502, 503, 504 |
168
+ | `TMDb::ConnectionError` | DNS, refused connections, TLS failures |
169
+ | `TMDb::TimeoutError` | Timeouts (a `ConnectionError`) |
170
+ | `TMDb::ParsingError` | A 2xx body that is not JSON |
171
+ | `TMDb::ConfigurationError` | No credentials configured |
172
+
173
+ `ClientError` (4xx) and `ServerError` (5xx) sit between those and `TMDb::Error`,
174
+ so `rescue TMDb::ServerError` means "their side" and `rescue TMDb::ClientError`
175
+ means "ours". Every error carries what it knows:
176
+
177
+ ```ruby
178
+ rescue TMDb::Error => e
179
+ e.status # => 404 the HTTP status
180
+ e.code # => 34 TMDb's own status_code
181
+ e.message # => "The resource you requested could not be found."
182
+ e.body # the raw response body
183
+ ```
184
+
185
+ ## Retries
186
+
187
+ 429s, 5xx responses and dropped connections are retried twice by default, with
188
+ exponential backoff. `Retry-After` is honoured when TMDb sends it, up to
189
+ `max_retry_interval`; a longer wait than that gives up rather than blocking.
190
+ 4xx responses are never retried. Set `retries` to `0` to turn it off.
191
+
192
+ ## Endpoints
193
+
194
+ <details>
195
+ <summary><code>TMDb::Movie</code></summary>
196
+
197
+ `detail` · `alternative_titles` · `credits` · `cast` · `crew` · `director` ·
198
+ `external_ids` · `images` · `backdrops` · `posters` · `logos` · `videos` ·
199
+ `keywords` · `watch_providers` · `release_dates` · `releases` · `translations` ·
200
+ `changes` · `similar` · `recommendations` · `reviews` · `lists` · `latest` ·
201
+ `upcoming` · `now_playing` · `popular` · `top_rated`
202
+ </details>
203
+
204
+ <details>
205
+ <summary><code>TMDb::TV</code></summary>
206
+
207
+ `detail` · `alternative_titles` · `credits` · `cast` · `crew` ·
208
+ `content_ratings` · `external_ids` · `images` · `backdrops` · `posters` ·
209
+ `logos` · `keywords` · `videos` · `translations` · `watch_providers` ·
210
+ `changes` · `similar` · `recommendations` · `reviews` · `latest` ·
211
+ `on_the_air` · `airing_today` · `top_rated` · `popular`
212
+ </details>
213
+
214
+ <details>
215
+ <summary><code>TMDb::TV::Season</code> and <code>TMDb::TV::Episode</code></summary>
216
+
217
+ Season: `detail` · `credits` · `cast` · `crew` · `aggregate_credits` ·
218
+ `external_ids` · `images` · `posters` · `videos` · `translations` · `changes`
219
+
220
+ Episode: `detail` · `credits` · `cast` · `crew` · `guest_stars` ·
221
+ `external_ids` · `images` · `stills` · `videos` · `translations` · `changes`
222
+ </details>
223
+
224
+ <details>
225
+ <summary><code>TMDb::Person</code></summary>
226
+
227
+ `detail` · `movie_credits` · `tv_credits` · `combined_credits` · `external_ids` ·
228
+ `images` · `profiles` · `tagged_images` · `translations` · `changes` · `popular` ·
229
+ `latest`
230
+ </details>
231
+
232
+ <details>
233
+ <summary><code>TMDb::Search</code>, <code>TMDb::Find</code> and <code>TMDb::Discover</code></summary>
234
+
235
+ Search: `movie` · `tv` · `person` · `multi` · `company` · `collection` · `keyword`
236
+
237
+ Find (all take `external_source:`): `movie` · `people` · `tv_series` ·
238
+ `tv_season` · `tv_episode` · `all`
239
+
240
+ Discover: `movie` · `tv`
241
+ </details>
242
+
243
+ <details>
244
+ <summary>The rest</summary>
245
+
246
+ `TMDb::Collection` — `detail` · `images` · `backdrops` · `posters` · `translations`
247
+ `TMDb::Company` — `detail` · `alternative_names` · `images` · `movies`
248
+ `TMDb::Network` — `detail` · `alternative_names` · `images`
249
+ `TMDb::Keyword` — `detail` · `movies`
250
+ `TMDb::Genre` — `movie_list` · `tv_list` · `movies`
251
+ `TMDb::Credit` — `detail`
252
+ `TMDb::Review` — `detail`
253
+ `TMDb::Certification` — `movie_list` · `tv_list`, each a Hash keyed by country
254
+ `TMDb::Change` — `movie` · `tv` · `person`
255
+ `TMDb::Job` — `list`
256
+ `TMDb::Configuration` — `get` · `countries` · `languages` · `jobs` · `timezones` · `primary_translations`
257
+ </details>
258
+
259
+ ## Coming from themoviedb-api
260
+
261
+ The endpoints are the same. What changed:
262
+
263
+ | themoviedb-api | tmdb_client |
264
+ | --- | --- |
265
+ | `Tmdb::` | `TMDb::` |
266
+ | `Tmdb::Api.key(k)` / `.language(l)` | `TMDb.configure { \|c\| c.api_key = k; c.language = l }` |
267
+ | `Tmdb::Struct` (an `OpenStruct`) | `TMDb::Object`, frozen and hash-backed |
268
+ | `Tmdb::Result` / `Tmdb::Discover` / `Tmdb::Movie` envelopes | `TMDb::Page` everywhere |
269
+ | One `Tmdb::Error` for every failure | A class per status, all under `TMDb::Error` |
270
+ | `Tmdb::Tv::Season` | `TMDb::TV::Season` (`TMDb::Tv` still resolves) |
271
+ | `Tmdb::Find.tv_serie` | `TMDb::Find.tv_series` (`tv_serie` still resolves) |
272
+ | `Tmdb::Person.new(id: 1)` as a value | `TMDb::Object.new(id: 1)` |
273
+ | `Movie.similar` etc. returned mixed types | Every result is a `TMDb::Object` |
274
+
275
+ Reading a response is unchanged: `.title`, `['title']` and `[:title]` all work,
276
+ nesting is still recursive, and an absent key is still `nil`.
277
+
278
+ Fixed along the way:
279
+
280
+ - `Find.tv_season` and `Find.tv_episode` read each other's result buckets. They
281
+ now read the right ones.
282
+ - The base URL was plain `http://`, so the API key travelled in cleartext.
283
+ - Connection failures escaped as `NoMethodError` or `TypeError` instead of a
284
+ `Tmdb::Error`, so `rescue Tmdb::Error` did not actually catch an outage.
285
+ - `Search.*` mutated the filters hash it was given.
286
+ - No timeouts, so a hung connection hung the caller indefinitely.
287
+ - `Object` and `Hash` were monkeypatched when ActiveSupport was absent.
288
+
289
+ Dropped: `Account`, `Authentication`, `List` and `Rating`. They were value
290
+ structs with no endpoints behind them — this client, like the old one, is
291
+ read-only.
292
+
293
+ ## Development
294
+
295
+ The specs run in Docker, so no local Ruby is needed:
296
+
297
+ ```sh
298
+ make test # specs and RuboCop
299
+ make spec # specs only
300
+ make console # IRB with the gem loaded
301
+ ```
302
+
303
+ ## License
304
+
305
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Certification < Resource
5
+ # Returns a Hash keyed by country code -- `{ US: [TMDb::Object, ...] }`.
6
+ def movie_list(filters = {})
7
+ by_country('/certification/movie/list', filters)
8
+ end
9
+
10
+ def tv_list(filters = {})
11
+ by_country('/certification/tv/list', filters)
12
+ end
13
+
14
+ expose_class_methods
15
+
16
+ private
17
+
18
+ def by_country(path, filters)
19
+ fetch(path, filters)[:certifications].to_h do |country, certifications|
20
+ [country, objects(certifications)]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # Ids changed in a date window. Use the per-resource `changes` methods
5
+ # (TMDb::Movie.changes and friends) for what actually changed on one title.
6
+ class Change < Resource
7
+ def movie(filters = {})
8
+ page('/movie/changes', filters)
9
+ end
10
+
11
+ def person(filters = {})
12
+ page('/person/changes', filters)
13
+ end
14
+
15
+ def tv(filters = {})
16
+ page('/tv/changes', filters)
17
+ end
18
+
19
+ expose_class_methods
20
+ end
21
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # The one place this gem talks HTTP.
5
+ #
6
+ # Instances are safe to share across threads: the Faraday connection is built
7
+ # once behind a mutex and Faraday itself is thread-safe for concurrent
8
+ # requests. Nothing here mutates the config after construction.
9
+ class Client
10
+ attr_reader :config
11
+
12
+ # Accepts a Config, or keyword overrides on top of the global one:
13
+ #
14
+ # TMDb::Client.new # global config
15
+ # TMDb::Client.new(language: 'en') # global, but English
16
+ # TMDb::Client.new(config) # an explicit config
17
+ def initialize(config = nil, **overrides)
18
+ @config = build_config(config, overrides)
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def get(path, params = {})
23
+ raise ConfigurationError, credentials_message unless config.credentials?
24
+
25
+ handle(connection.get(path.delete_prefix('/'), query_for(params)))
26
+ rescue Faraday::Error => e
27
+ raise translate(e)
28
+ end
29
+
30
+ def connection
31
+ @connection || @mutex.synchronize { @connection ||= build_connection }
32
+ end
33
+
34
+ def inspect
35
+ "#<TMDb::Client base_url=#{config.base_url.inspect} language=#{config.language.inspect}>"
36
+ end
37
+
38
+ private
39
+
40
+ # No Faraday exception is allowed past here: a caller rescuing TMDb::Error
41
+ # should not have to know, or import, the transport this gem happens to use.
42
+ def translate(error)
43
+ case error
44
+ when Faraday::TimeoutError
45
+ TimeoutError.new("TMDb request timed out: #{error.message}")
46
+ when Faraday::ConnectionFailed, Faraday::SSLError
47
+ ConnectionError.new("Could not reach TMDb: #{error.message}")
48
+ when Faraday::ParsingError
49
+ ParsingError.new("TMDb returned a response that is not JSON: #{error.message}")
50
+ else
51
+ Error.new("TMDb request failed: #{error.message}")
52
+ end
53
+ end
54
+
55
+ def build_config(config, overrides)
56
+ base = (config || TMDb.config).dup
57
+ overrides.each { |key, value| base.public_send(:"#{key}=", value) }
58
+
59
+ base
60
+ end
61
+
62
+ def credentials_message
63
+ 'No TMDb credentials configured. Set an api_key (v3) or an access_token (v4) ' \
64
+ 'via TMDb.configure { |c| c.api_key = ... }.'
65
+ end
66
+
67
+ # Per-call filters win over the client's defaults, and nils drop out rather
68
+ # than being sent as empty parameters.
69
+ def query_for(params)
70
+ config.default_params.merge(normalize(params)).compact
71
+ end
72
+
73
+ def normalize(params)
74
+ return {} if params.nil? || params.empty?
75
+
76
+ params.to_h.transform_keys(&:to_sym)
77
+ end
78
+
79
+ def handle(response)
80
+ return parse(response.body) if response.success?
81
+
82
+ raise error_for(response)
83
+ end
84
+
85
+ # Parsed here rather than by Faraday's JSON middleware: that middleware
86
+ # passes its parser options positionally, which json 3.0 no longer accepts,
87
+ # and a client this small does not need a middleware to call JSON.parse.
88
+ def parse(body)
89
+ return {} if body.nil? || body.strip.empty?
90
+
91
+ ::JSON.parse(body, symbolize_names: true)
92
+ rescue ::JSON::ParserError => e
93
+ raise ParsingError, "TMDb returned a response that is not JSON: #{e.message}"
94
+ end
95
+
96
+ # A failing response may carry anything -- TMDb's JSON error shape, an HTML
97
+ # error page from a proxy, nothing at all. The status is what matters, so a
98
+ # body that will not parse must not mask it.
99
+ def parse_quietly(body)
100
+ parsed = parse(body)
101
+
102
+ parsed.is_a?(::Hash) ? parsed : {}
103
+ rescue ParsingError
104
+ {}
105
+ end
106
+
107
+ def error_for(response)
108
+ details = parse_quietly(response.body)
109
+
110
+ Error.class_for(response.status).new(
111
+ details[:status_message] || "TMDb responded with HTTP #{response.status}",
112
+ status: response.status,
113
+ code: details[:status_code],
114
+ body: response.body,
115
+ retry_after: retry_after(response)
116
+ )
117
+ end
118
+
119
+ def retry_after(response)
120
+ seconds = response.headers['retry-after'].to_i
121
+
122
+ seconds.positive? ? seconds : nil
123
+ end
124
+
125
+ def build_connection
126
+ Faraday.new(url: base_url, headers: config.headers) { |faraday| build_stack(faraday) }
127
+ end
128
+
129
+ # A trailing slash on the prefix, paired with a relative request path, is the
130
+ # only combination Faraday joins without dropping the "/3" version segment.
131
+ def base_url
132
+ "#{config.base_url.chomp('/')}/"
133
+ end
134
+
135
+ def build_stack(faraday)
136
+ faraday.request :retry, retry_options
137
+ faraday.response :logger, config.logger, headers: false, bodies: false if config.logger
138
+
139
+ faraday.options.timeout = config.timeout
140
+ faraday.options.open_timeout = config.open_timeout
141
+
142
+ faraday.adapter(*Array(config.adapter))
143
+ end
144
+
145
+ def retry_options
146
+ {
147
+ max: config.retries,
148
+ interval: config.retry_interval,
149
+ interval_randomness: 0.5,
150
+ backoff_factor: 2,
151
+ retry_statuses: Config::RETRY_STATUSES,
152
+ max_interval: config.max_retry_interval,
153
+ methods: %i[get],
154
+ # Faraday::RetriableResponse is what the middleware raises to itself for a
155
+ # retryable status; leaving it out of this list makes every retryable
156
+ # response escape as a bare Faraday error instead of being retried.
157
+ exceptions: [
158
+ Faraday::RetriableResponse, Faraday::ConnectionFailed, Faraday::TimeoutError,
159
+ Errno::ETIMEDOUT, Errno::ECONNRESET
160
+ ]
161
+ }
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Collection < Resource
5
+ def detail(id, filters = {})
6
+ object("/collection/#{id}", filters)
7
+ end
8
+
9
+ def images(id, filters = {})
10
+ object("/collection/#{id}/images", filters)
11
+ end
12
+
13
+ def backdrops(id, filters = {})
14
+ list("/collection/#{id}/images", :backdrops, filters)
15
+ end
16
+
17
+ def posters(id, filters = {})
18
+ list("/collection/#{id}/images", :posters, filters)
19
+ end
20
+
21
+ def translations(id, filters = {})
22
+ list("/collection/#{id}/translations", :translations, filters)
23
+ end
24
+
25
+ expose_class_methods
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Company < Resource
5
+ def detail(id, filters = {})
6
+ object("/company/#{id}", filters)
7
+ end
8
+
9
+ def alternative_names(id, filters = {})
10
+ list("/company/#{id}/alternative_names", :results, filters)
11
+ end
12
+
13
+ def images(id, filters = {})
14
+ object("/company/#{id}/images", filters)
15
+ end
16
+
17
+ def movies(id, filters = {})
18
+ page("/company/#{id}/movies", filters)
19
+ end
20
+
21
+ expose_class_methods
22
+ end
23
+ end