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.
data/lib/tmdb/page.rb ADDED
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # One page of a paginated TMDb response.
5
+ #
6
+ # Every paginated endpoint in this gem returns a Page, where the old gem
7
+ # returned a different envelope class per endpoint (`Tmdb::Result` for
8
+ # searches, `Tmdb::Discover` for discover, `Tmdb::Movie` for `Movie.popular`)
9
+ # and left walking the pages entirely to the caller.
10
+ #
11
+ # page = TMDb::Search.movie('blade runner')
12
+ # page.results # => [TMDb::Object, ...]
13
+ # page.total_pages # => 3
14
+ # page.map(&:title) # Enumerable over the results
15
+ #
16
+ # page.each_page { |p| ... } # lazily fetches page 2, 3, ...
17
+ # page.auto_paginate { |movie| } # lazily walks every result
18
+ #
19
+ # Envelope keys that are not part of the pagination contract (`dates` on
20
+ # `/movie/now_playing`, `id` on `/movie/{id}/changes`) stay readable through
21
+ # `meta`, and directly on the page itself.
22
+ class Page
23
+ include Enumerable
24
+
25
+ ENVELOPE_KEYS = %i[page total_pages total_results results].freeze
26
+
27
+ attr_reader :results, :page, :total_pages, :total_results, :meta
28
+
29
+ def initialize(page: nil, total_pages: nil, total_results: nil, results: [], meta: {}, &fetcher)
30
+ @results = results.freeze
31
+ @page = (page || 1).to_i
32
+ @total_pages = (total_pages || 1).to_i
33
+ @total_results = (total_results || @results.size).to_i
34
+ @meta = Object.new(meta)
35
+ @fetcher = fetcher
36
+ end
37
+
38
+ def each(&block)
39
+ return enum_for(:each) unless block
40
+
41
+ @results.each(&block)
42
+ self
43
+ end
44
+
45
+ def size
46
+ @results.size
47
+ end
48
+ alias length size
49
+
50
+ def empty?
51
+ @results.empty?
52
+ end
53
+
54
+ def first_page?
55
+ page <= 1
56
+ end
57
+
58
+ def last_page?
59
+ page >= total_pages
60
+ end
61
+
62
+ def next_page
63
+ last_page? ? nil : page + 1
64
+ end
65
+
66
+ def previous_page
67
+ first_page? ? nil : page - 1
68
+ end
69
+
70
+ # Fetches an arbitrary page of the same query. Raises rather than lying when
71
+ # this page was built by hand (a test double, say) and has no query behind it.
72
+ def fetch_page(number)
73
+ raise Error, 'This page was not built from a request, so it cannot fetch another' unless @fetcher
74
+
75
+ @fetcher.call(number)
76
+ end
77
+
78
+ def fetch_next_page
79
+ next_page && fetch_page(next_page)
80
+ end
81
+
82
+ # Yields this page, then each following one, fetching as it goes. TMDb caps
83
+ # pagination at 500 pages; walking to the end of a large result set is a lot
84
+ # of requests, so this stays explicit rather than happening behind `each`.
85
+ def each_page
86
+ return enum_for(:each_page) unless block_given?
87
+
88
+ current = self
89
+
90
+ loop do
91
+ yield current
92
+ break if current.last_page?
93
+
94
+ current = current.fetch_next_page
95
+ break if current.nil?
96
+ end
97
+
98
+ self
99
+ end
100
+
101
+ # Every result from this page on, across page boundaries.
102
+ def auto_paginate(&block)
103
+ return enum_for(:auto_paginate) unless block
104
+
105
+ each_page { |current| current.results.each(&block) }
106
+ end
107
+
108
+ def to_h
109
+ meta.to_h.merge(
110
+ page: page, total_pages: total_pages, total_results: total_results, results: results.map(&:to_h)
111
+ )
112
+ end
113
+
114
+ def ==(other)
115
+ other.is_a?(Page) && to_h == other.to_h
116
+ end
117
+ alias eql? ==
118
+
119
+ def hash
120
+ [self.class, to_h].hash
121
+ end
122
+
123
+ def inspect
124
+ "#<TMDb::Page page=#{page}/#{total_pages} total_results=#{total_results} results=#{size}>"
125
+ end
126
+
127
+ private
128
+
129
+ def method_missing(name, *args, &block)
130
+ return super unless args.empty? && block.nil?
131
+ return super unless meta.respond_to?(name)
132
+
133
+ meta.public_send(name)
134
+ end
135
+
136
+ def respond_to_missing?(name, include_private = false)
137
+ meta.respond_to?(name, include_private) || super
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Person < Resource
5
+ def detail(id, filters = {})
6
+ object("/person/#{id}", filters)
7
+ end
8
+
9
+ def movie_credits(id, filters = {})
10
+ object("/person/#{id}/movie_credits", filters)
11
+ end
12
+
13
+ def tv_credits(id, filters = {})
14
+ object("/person/#{id}/tv_credits", filters)
15
+ end
16
+
17
+ def combined_credits(id, filters = {})
18
+ object("/person/#{id}/combined_credits", filters)
19
+ end
20
+
21
+ def external_ids(id, filters = {})
22
+ object("/person/#{id}/external_ids", filters)
23
+ end
24
+
25
+ def images(id, filters = {})
26
+ object("/person/#{id}/images", filters)
27
+ end
28
+
29
+ def profiles(id, filters = {})
30
+ list("/person/#{id}/images", :profiles, filters)
31
+ end
32
+
33
+ def tagged_images(id, filters = {})
34
+ page("/person/#{id}/tagged_images", filters)
35
+ end
36
+
37
+ def translations(id, filters = {})
38
+ list("/person/#{id}/translations", :translations, filters)
39
+ end
40
+
41
+ def changes(id, filters = {})
42
+ list("/person/#{id}/changes", :changes, filters)
43
+ end
44
+
45
+ def popular(filters = {})
46
+ page('/person/popular', filters)
47
+ end
48
+
49
+ def latest(filters = {})
50
+ object('/person/latest', filters)
51
+ end
52
+
53
+ expose_class_methods
54
+ end
55
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ # Base for the endpoint classes.
5
+ #
6
+ # Each subclass is a thin map from a method to a TMDb path; the wrapping is
7
+ # uniform, so a subclass only ever says which path, which JSON key, and
8
+ # whether the answer is one object, a list, or a page.
9
+ #
10
+ # Every public instance method is also exposed as a class method backed by the
11
+ # default client, so both of these work:
12
+ #
13
+ # TMDb::Movie.detail(550) # default client
14
+ # TMDb::Movie.new(other_client).detail(550) # an explicit one
15
+ #
16
+ # Every method takes an optional trailing `filters` Hash, merged into the
17
+ # query string. That is how `language:`, `append_to_response:`, `page:` and
18
+ # the rest are passed, and a filter always beats the client's own default.
19
+ class Resource
20
+ attr_reader :client
21
+
22
+ def initialize(client = nil)
23
+ @client = client || TMDb.client
24
+ end
25
+
26
+ class << self
27
+ private
28
+
29
+ def expose_class_methods
30
+ public_instance_methods(false).each do |name|
31
+ singleton_class.define_method(name) do |*args, **kwargs, &block|
32
+ new.public_send(name, *args, **kwargs, &block)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def fetch(path, filters = {})
41
+ client.get(path, filters)
42
+ end
43
+
44
+ # One object.
45
+ def object(path, filters = {})
46
+ Object.new(fetch(path, filters))
47
+ end
48
+
49
+ # A bare Array living under `key` in the response body.
50
+ def list(path, key, filters = {})
51
+ objects(fetch(path, filters)[key])
52
+ end
53
+
54
+ def objects(entries)
55
+ case entries
56
+ when ::Array then entries.map { |entry| Object.new(entry) }
57
+ when nil then []
58
+ else [Object.new(entries)]
59
+ end
60
+ end
61
+
62
+ # A paginated envelope. The returned Page can fetch its own following pages,
63
+ # reusing these filters with the page number swapped out.
64
+ def page(path, filters = {}, key: :results)
65
+ filters = normalize(filters)
66
+ fetcher = nil
67
+ fetcher = lambda do |number|
68
+ body = fetch(path, number.nil? ? filters : filters.merge(page: number))
69
+ build_page(body, key, &fetcher)
70
+ end
71
+
72
+ fetcher.call(nil)
73
+ end
74
+
75
+ def build_page(body, key = :results, &)
76
+ Page.new(
77
+ page: body[:page],
78
+ total_pages: body[:total_pages],
79
+ total_results: body[:total_results],
80
+ results: objects(body[key]),
81
+ meta: body.except(*Page::ENVELOPE_KEYS, key),
82
+ &
83
+ )
84
+ end
85
+
86
+ def normalize(filters)
87
+ (filters || {}).to_h.transform_keys(&:to_sym)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Review < Resource
5
+ def detail(id, filters = {})
6
+ object("/review/#{id}", filters)
7
+ end
8
+
9
+ expose_class_methods
10
+ end
11
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class Search < Resource
5
+ def movie(query, filters = {})
6
+ search('/search/movie', query, filters)
7
+ end
8
+
9
+ def tv(query, filters = {})
10
+ search('/search/tv', query, filters)
11
+ end
12
+
13
+ def person(query, filters = {})
14
+ search('/search/person', query, filters)
15
+ end
16
+
17
+ def multi(query, filters = {})
18
+ search('/search/multi', query, filters)
19
+ end
20
+
21
+ def company(query, filters = {})
22
+ search('/search/company', query, filters)
23
+ end
24
+
25
+ def collection(query, filters = {})
26
+ search('/search/collection', query, filters)
27
+ end
28
+
29
+ def keyword(query, filters = {})
30
+ search('/search/keyword', query, filters)
31
+ end
32
+
33
+ expose_class_methods
34
+
35
+ private
36
+
37
+ # themoviedb-api merged the query into the caller's own filters hash with
38
+ # `merge!`, quietly mutating it. This does not.
39
+ def search(path, query, filters)
40
+ page(path, normalize(filters).merge(query: query))
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class TV
5
+ class Episode < Resource
6
+ def detail(id, season_number, episode_number, filters = {})
7
+ object(path(id, season_number, episode_number), filters)
8
+ end
9
+
10
+ def credits(id, season_number, episode_number, filters = {})
11
+ object("#{path(id, season_number, episode_number)}/credits", filters)
12
+ end
13
+
14
+ def cast(id, season_number, episode_number, filters = {})
15
+ list("#{path(id, season_number, episode_number)}/credits", :cast, filters)
16
+ end
17
+
18
+ def crew(id, season_number, episode_number, filters = {})
19
+ list("#{path(id, season_number, episode_number)}/credits", :crew, filters)
20
+ end
21
+
22
+ def guest_stars(id, season_number, episode_number, filters = {})
23
+ list("#{path(id, season_number, episode_number)}/credits", :guest_stars, filters)
24
+ end
25
+
26
+ def external_ids(id, season_number, episode_number, filters = {})
27
+ object("#{path(id, season_number, episode_number)}/external_ids", filters)
28
+ end
29
+
30
+ def images(id, season_number, episode_number, filters = {})
31
+ object("#{path(id, season_number, episode_number)}/images", filters)
32
+ end
33
+
34
+ # An episode's images are stills, so this is the one the API actually has.
35
+ def stills(id, season_number, episode_number, filters = {})
36
+ list("#{path(id, season_number, episode_number)}/images", :stills, filters)
37
+ end
38
+ alias posters stills
39
+
40
+ def videos(id, season_number, episode_number, filters = {})
41
+ list("#{path(id, season_number, episode_number)}/videos", :results, filters)
42
+ end
43
+
44
+ def translations(id, season_number, episode_number, filters = {})
45
+ list("#{path(id, season_number, episode_number)}/translations", :translations, filters)
46
+ end
47
+
48
+ # Addressed by the episode's own TMDb id.
49
+ def changes(episode_id, filters = {})
50
+ list("/tv/episode/#{episode_id}/changes", :changes, filters)
51
+ end
52
+
53
+ expose_class_methods
54
+
55
+ private
56
+
57
+ def path(id, season_number, episode_number)
58
+ "/tv/#{id}/season/#{season_number}/episode/#{episode_number}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class TV
5
+ class Season < Resource
6
+ def detail(id, season_number, filters = {})
7
+ object("/tv/#{id}/season/#{season_number}", filters)
8
+ end
9
+
10
+ def credits(id, season_number, filters = {})
11
+ object("/tv/#{id}/season/#{season_number}/credits", filters)
12
+ end
13
+
14
+ def cast(id, season_number, filters = {})
15
+ list("/tv/#{id}/season/#{season_number}/credits", :cast, filters)
16
+ end
17
+
18
+ def crew(id, season_number, filters = {})
19
+ list("/tv/#{id}/season/#{season_number}/credits", :crew, filters)
20
+ end
21
+
22
+ def aggregate_credits(id, season_number, filters = {})
23
+ object("/tv/#{id}/season/#{season_number}/aggregate_credits", filters)
24
+ end
25
+
26
+ def external_ids(id, season_number, filters = {})
27
+ object("/tv/#{id}/season/#{season_number}/external_ids", filters)
28
+ end
29
+
30
+ def images(id, season_number, filters = {})
31
+ object("/tv/#{id}/season/#{season_number}/images", filters)
32
+ end
33
+
34
+ def posters(id, season_number, filters = {})
35
+ list("/tv/#{id}/season/#{season_number}/images", :posters, filters)
36
+ end
37
+
38
+ def videos(id, season_number, filters = {})
39
+ list("/tv/#{id}/season/#{season_number}/videos", :results, filters)
40
+ end
41
+
42
+ def translations(id, season_number, filters = {})
43
+ list("/tv/#{id}/season/#{season_number}/translations", :translations, filters)
44
+ end
45
+
46
+ # Addressed by the season's own TMDb id, not by series + season number.
47
+ def changes(season_id, filters = {})
48
+ list("/tv/season/#{season_id}/changes", :changes, filters)
49
+ end
50
+
51
+ expose_class_methods
52
+ end
53
+ end
54
+ end
data/lib/tmdb/tv.rb ADDED
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ class TV < Resource
5
+ def detail(id, filters = {})
6
+ object("/tv/#{id}", filters)
7
+ end
8
+
9
+ def alternative_titles(id, filters = {})
10
+ list("/tv/#{id}/alternative_titles", :results, filters)
11
+ end
12
+
13
+ def credits(id, filters = {})
14
+ object("/tv/#{id}/credits", filters)
15
+ end
16
+
17
+ def cast(id, filters = {})
18
+ list("/tv/#{id}/credits", :cast, filters)
19
+ end
20
+
21
+ def crew(id, filters = {})
22
+ list("/tv/#{id}/credits", :crew, filters)
23
+ end
24
+
25
+ def content_ratings(id, filters = {})
26
+ list("/tv/#{id}/content_ratings", :results, filters)
27
+ end
28
+
29
+ def external_ids(id, filters = {})
30
+ object("/tv/#{id}/external_ids", filters)
31
+ end
32
+
33
+ def images(id, filters = {})
34
+ object("/tv/#{id}/images", filters)
35
+ end
36
+
37
+ def backdrops(id, filters = {})
38
+ list("/tv/#{id}/images", :backdrops, filters)
39
+ end
40
+
41
+ def posters(id, filters = {})
42
+ list("/tv/#{id}/images", :posters, filters)
43
+ end
44
+
45
+ def logos(id, filters = {})
46
+ list("/tv/#{id}/images", :logos, filters)
47
+ end
48
+
49
+ def keywords(id, filters = {})
50
+ list("/tv/#{id}/keywords", :results, filters)
51
+ end
52
+
53
+ def videos(id, filters = {})
54
+ list("/tv/#{id}/videos", :results, filters)
55
+ end
56
+
57
+ def translations(id, filters = {})
58
+ list("/tv/#{id}/translations", :translations, filters)
59
+ end
60
+
61
+ def watch_providers(id, filters = {})
62
+ object("/tv/#{id}/watch/providers", filters)
63
+ end
64
+
65
+ def changes(id, filters = {})
66
+ list("/tv/#{id}/changes", :changes, filters)
67
+ end
68
+
69
+ def similar(id, filters = {})
70
+ page("/tv/#{id}/similar", filters)
71
+ end
72
+
73
+ def recommendations(id, filters = {})
74
+ page("/tv/#{id}/recommendations", filters)
75
+ end
76
+
77
+ def reviews(id, filters = {})
78
+ page("/tv/#{id}/reviews", filters)
79
+ end
80
+
81
+ def latest(filters = {})
82
+ object('/tv/latest', filters)
83
+ end
84
+
85
+ def on_the_air(filters = {})
86
+ page('/tv/on_the_air', filters)
87
+ end
88
+
89
+ def airing_today(filters = {})
90
+ page('/tv/airing_today', filters)
91
+ end
92
+
93
+ def top_rated(filters = {})
94
+ page('/tv/top_rated', filters)
95
+ end
96
+
97
+ def popular(filters = {})
98
+ page('/tv/popular', filters)
99
+ end
100
+
101
+ expose_class_methods
102
+ end
103
+
104
+ # themoviedb-api nested the season and episode endpoints under a `Tv` module
105
+ # while the series lived on a `TV` class. Both spellings resolve here, so
106
+ # `TMDb::Tv::Season` and `TMDb::TV::Season` are the same class.
107
+ Tv = TV
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TMDb
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'faraday'
6
+ require 'faraday/retry'
7
+
8
+ require_relative 'tmdb/version'
9
+ require_relative 'tmdb/errors'
10
+ require_relative 'tmdb/object'
11
+ require_relative 'tmdb/page'
12
+ require_relative 'tmdb/config'
13
+ require_relative 'tmdb/client'
14
+ require_relative 'tmdb/resource'
15
+ require_relative 'tmdb/certification'
16
+ require_relative 'tmdb/change'
17
+ require_relative 'tmdb/collection'
18
+ require_relative 'tmdb/company'
19
+ require_relative 'tmdb/configuration'
20
+ require_relative 'tmdb/credit'
21
+ require_relative 'tmdb/discover'
22
+ require_relative 'tmdb/find'
23
+ require_relative 'tmdb/genre'
24
+ require_relative 'tmdb/job'
25
+ require_relative 'tmdb/keyword'
26
+ require_relative 'tmdb/movie'
27
+ require_relative 'tmdb/network'
28
+ require_relative 'tmdb/person'
29
+ require_relative 'tmdb/review'
30
+ require_relative 'tmdb/search'
31
+ require_relative 'tmdb/tv'
32
+ require_relative 'tmdb/tv/season'
33
+ require_relative 'tmdb/tv/episode'
34
+
35
+ # A Ruby client for The Movie Database (TMDb) v3 API.
36
+ #
37
+ # TMDb.configure do |config|
38
+ # config.api_key = ENV['TMDB_API_KEY']
39
+ # config.language = 'es'
40
+ # end
41
+ #
42
+ # TMDb::Movie.detail(550, append_to_response: 'credits')
43
+ module TMDb
44
+ class << self
45
+ # The settings the default client is built from.
46
+ def config
47
+ @config ||= Config.new
48
+ end
49
+
50
+ def configure
51
+ yield(config)
52
+ # The client caches its own copy of the config and its connection, so it
53
+ # has to be rebuilt when the settings behind it change.
54
+ @client = nil
55
+
56
+ config
57
+ end
58
+
59
+ # The shared client every `TMDb::Movie.detail`-style class method goes
60
+ # through. Assign your own to swap it wholesale.
61
+ def client
62
+ @client ||= Client.new(config)
63
+ end
64
+
65
+ attr_writer :client
66
+
67
+ def reset!
68
+ @config = nil
69
+ @client = nil
70
+ end
71
+ end
72
+ end