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 +7 -0
- data/CHANGELOG.md +63 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/lib/tmdb/certification.rb +24 -0
- data/lib/tmdb/change.rb +21 -0
- data/lib/tmdb/client.rb +164 -0
- data/lib/tmdb/collection.rb +27 -0
- data/lib/tmdb/company.rb +23 -0
- data/lib/tmdb/config.rb +71 -0
- data/lib/tmdb/configuration.rb +36 -0
- data/lib/tmdb/credit.rb +11 -0
- data/lib/tmdb/discover.rb +15 -0
- data/lib/tmdb/errors.rb +83 -0
- data/lib/tmdb/find.rb +44 -0
- data/lib/tmdb/genre.rb +21 -0
- data/lib/tmdb/job.rb +13 -0
- data/lib/tmdb/keyword.rb +15 -0
- data/lib/tmdb/movie.rb +117 -0
- data/lib/tmdb/network.rb +19 -0
- data/lib/tmdb/object.rb +138 -0
- data/lib/tmdb/page.rb +140 -0
- data/lib/tmdb/person.rb +55 -0
- data/lib/tmdb/resource.rb +90 -0
- data/lib/tmdb/review.rb +11 -0
- data/lib/tmdb/search.rb +43 -0
- data/lib/tmdb/tv/episode.rb +62 -0
- data/lib/tmdb/tv/season.rb +54 -0
- data/lib/tmdb/tv.rb +108 -0
- data/lib/tmdb/version.rb +5 -0
- data/lib/tmdb_client.rb +72 -0
- metadata +105 -0
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
|
data/lib/tmdb/change.rb
ADDED
|
@@ -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
|
data/lib/tmdb/client.rb
ADDED
|
@@ -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
|
data/lib/tmdb/company.rb
ADDED
|
@@ -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
|