curzon_uk 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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +47 -0
  3. data/.github/workflows/release.yml +73 -0
  4. data/.gitignore +19 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +11 -0
  7. data/COMM-LICENSE +22 -0
  8. data/Gemfile +3 -0
  9. data/LICENSE +617 -0
  10. data/README.md +164 -0
  11. data/Rakefile +38 -0
  12. data/curzon_uk.gemspec +31 -0
  13. data/lib/curzon_uk/cinema.rb +109 -0
  14. data/lib/curzon_uk/internal/api_response.rb +35 -0
  15. data/lib/curzon_uk/internal/guest_token.rb +27 -0
  16. data/lib/curzon_uk/internal/parser/api/showtime.rb +62 -0
  17. data/lib/curzon_uk/internal/title_sanitizer.rb +32 -0
  18. data/lib/curzon_uk/performance.rb +82 -0
  19. data/lib/curzon_uk/version.rb +6 -0
  20. data/lib/curzon_uk.rb +30 -0
  21. data/test/fixtures/api/showtimes-SOH1.json +58 -0
  22. data/test/fixtures/api/sites.json +40 -0
  23. data/test/lib/curzon_uk/cinema_test.rb +84 -0
  24. data/test/lib/curzon_uk/internal/api_response_test.rb +52 -0
  25. data/test/lib/curzon_uk/internal/fixture_shape_test.rb +14 -0
  26. data/test/lib/curzon_uk/internal/guest_token_test.rb +91 -0
  27. data/test/lib/curzon_uk/internal/parser/api/showtime_test.rb +59 -0
  28. data/test/lib/curzon_uk/internal/title_sanitizer_test.rb +31 -0
  29. data/test/lib/curzon_uk/performance_test.rb +106 -0
  30. data/test/lib/curzon_uk/version_test.rb +7 -0
  31. data/test/live/api_shape_test.rb +19 -0
  32. data/test/live/integration_test.rb +27 -0
  33. data/test/live/live_helper.rb +11 -0
  34. data/test/package_test.rb +56 -0
  35. data/test/support/api_fixtures_helper.rb +17 -0
  36. data/test/support/api_shapes.rb +48 -0
  37. data/test/support/fake_api_response.rb +20 -0
  38. data/test/test_helper.rb +20 -0
  39. metadata +208 -0
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # CurzonUk
2
+
3
+ A simple gem to pull cinemas and performances from the Vista API behind the [Curzon website](https://www.curzon.com) and spit out useful formatted info.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'curzon_uk'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install curzon_uk
18
+
19
+ ## Usage
20
+
21
+ The gem conforms to the API set down in the `cinebase` gem, [andycroll/cinebase](https://github.com/andycroll/cinebase), which provides a lot of useful base vocabulary and repetitive code for this series of cinema focussed gems.
22
+
23
+ Performance titles are sanitized as much as possible, removing years, Curzon's DocHouse and Film Club strand prefixes, festival prefixes and other markers.
24
+
25
+ ### Guest token
26
+
27
+ Every API call needs the public guest token the Curzon website gives each
28
+ visitor. Fetch one before anything else:
29
+
30
+ ``` ruby
31
+ CurzonUk.acquire_token!
32
+ CurzonUk::Cinema.all
33
+ ```
34
+
35
+ This shells out to **`curl-impersonate`**, not ordinary system `curl`, using its
36
+ Chrome 150 TLS/HTTP profile, and reads `window.initialData.api.authToken` from
37
+ `https://www.curzon.com/`. No browser or JavaScript execution is needed.
38
+
39
+ Platform-specific gem packages include the pinned `curl-impersonate` 2.2.3
40
+ executable for glibc Linux (x86-64, ARM64) and macOS (Intel, Apple Silicon).
41
+ On other platforms, or when installing from Git, install a
42
+ [curl-impersonate release](https://github.com/lexiforest/curl-impersonate/releases/tag/v2.2.3)
43
+ yourself and put it on your `PATH` or pass its path:
44
+
45
+ ``` ruby
46
+ CurzonUk.acquire_token!(curl: '/opt/curl-impersonate/curl-impersonate')
47
+ ```
48
+
49
+ Or supply your own current guest JWT, without the `Bearer ` prefix:
50
+
51
+ ``` ruby
52
+ CurzonUk.token = ENV.fetch('CURZON_TOKEN')
53
+ ```
54
+
55
+ Tokens currently last 12 hours. The gem checks the declared expiry but does not
56
+ verify the signature. Missing, expired or rejected tokens raise
57
+ `CurzonUk::AuthenticationError`; call `CurzonUk.acquire_token!` again. Normal
58
+ API calls never acquire or renew a token automatically.
59
+
60
+ ### Cinema
61
+
62
+ Cinema ids are Vista site ids, such as `'SOH1'` for Soho.
63
+
64
+ ``` ruby
65
+ CurzonUk::Cinema.all
66
+ #=> [<CurzonUk::Cinema ...>, <CurzonUk::Cinema ...>, ...]
67
+
68
+ cinema = CurzonUk::Cinema.new('SOH1')
69
+ #=> <CurzonUk::Cinema ...>
70
+
71
+ cinema.adr
72
+ #=> {
73
+ street_address: '99 Shaftesbury Avenue',
74
+ extended_address: nil,
75
+ locality: 'London',
76
+ region: nil,
77
+ postal_code: 'W1D 5DY',
78
+ country_name: 'United Kingdom'
79
+ }
80
+
81
+ cinema.brand
82
+ #=> 'Curzon'
83
+
84
+ cinema.full_name
85
+ #=> 'Curzon Soho'
86
+
87
+ cinema.url
88
+ #=> 'https://www.curzon.com/venues/soho/'
89
+ ```
90
+
91
+ ### Performances
92
+
93
+ `Performance.at` returns performances for an inclusive range of business dates,
94
+ defaulting to today in London and the following 13 days. It makes one request
95
+ per day.
96
+
97
+ ``` ruby
98
+ CurzonUk::Performance.at('SOH1', from: Date.today, to: Date.today + 2)
99
+ #=> [<CurzonUk::Performance ...>, <CurzonUk::Performance ...>, ...]
100
+
101
+ performance = CurzonUk::Performance.at('SOH1').first
102
+ #=> <CurzonUk::Performance ...>
103
+
104
+ performance.film_name
105
+ #=> 'Fatherland'
106
+
107
+ performance.film_year
108
+ #=> nil # from the title, e.g. 2026 for 'Street Fighter (2026)'
109
+
110
+ performance.dimension
111
+ #=> '2d'
112
+
113
+ performance.variant
114
+ #=> ['q&a']
115
+
116
+ performance.subtitled?
117
+ #=> false # true for open captioned screenings
118
+
119
+ performance.festival
120
+ #=> nil # e.g. #<data Cinebase::Festival name="UKJFF", year=2026>
121
+
122
+ performance.starting_at
123
+ #=> 2026-10-23 10:00:00 UTC
124
+
125
+ performance.booking_url
126
+ #=> 'https://www.curzon.com/ticketing/seats/?showtimeId=SOH1-57865'
127
+
128
+ performance.cinema_name
129
+ #=> 'Soho'
130
+
131
+ performance.cinema_id
132
+ #=> 'SOH1'
133
+ ```
134
+
135
+ Variants are `baby`, `q&a` and `subtitled`. Curzon doesn't tag audio
136
+ languages, so `language` is always `nil`.
137
+
138
+ ## Tests
139
+
140
+ ```sh
141
+ bundle exec rake # unit tests against fixtures
142
+ bundle exec rake live # live API, needs curl-impersonate or CURZON_TOKEN
143
+ ```
144
+
145
+ Fixtures in `test/fixtures/api` are hand-written: invented films and addresses
146
+ in the shape of Vista responses, never recorded API output.
147
+ `test/support/api_shapes.rb` describes the fields the gem reads. The unit suite
148
+ checks the fixtures against it and the live suite checks real responses, held
149
+ only in memory. When the API changes, update the shape first, then the fixtures.
150
+
151
+ ## Releasing
152
+
153
+ Bump the version, merge, then run the **Release Gem** workflow in GitHub
154
+ Actions. It builds the source and four platform gems, publishes them through
155
+ RubyGems trusted publishing (no MFA code needed) and tags the release. Versions
156
+ already on RubyGems are skipped, so a failed run can be rerun.
157
+
158
+ ## Contributing
159
+
160
+ 1. Fork it
161
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
162
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
163
+ 4. Push to the branch (`git push origin my-new-feature`)
164
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env rake
2
+ require 'bundler/gem_tasks'
3
+
4
+ require 'rake/testtask'
5
+ require 'cinebase/vista/package_task'
6
+
7
+ Cinebase::Vista::PackageTask.new('curzon_uk.gemspec', root: __dir__)
8
+
9
+ Rake::TestTask.new do |t|
10
+ t.libs << 'lib/curzon_uk'
11
+ t.test_files = FileList[
12
+ 'test/lib/curzon_uk/*_test.rb',
13
+ 'test/lib/curzon_uk/internal/*_test.rb',
14
+ 'test/lib/curzon_uk/internal/parser/api/*_test.rb'
15
+ ]
16
+ t.verbose = true
17
+ end
18
+
19
+ Rake::TestTask.new do |t|
20
+ t.libs << 'lib/curzon_uk'
21
+ t.name = :live
22
+ t.test_files = FileList[
23
+ 'test/live/*_test.rb'
24
+ ]
25
+ t.verbose = true
26
+ end
27
+
28
+ # http://erniemiller.org/2014/02/05/7-lines-every-gems-rakefile-should-have/
29
+ desc 'run gem console'
30
+ task :console do
31
+ require 'irb'
32
+ require 'irb/completion'
33
+ require 'curzon_uk'
34
+ ARGV.clear
35
+ IRB.start
36
+ end
37
+
38
+ task default: :test
data/curzon_uk.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'curzon_uk/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'curzon_uk'
7
+ spec.version = CurzonUk::VERSION
8
+ spec.authors = ['Andy Croll']
9
+ spec.email = ['andy@goodscary.com']
10
+ spec.description = 'Pull movie & cinema information from the Curzon Vista API'
11
+ spec.summary = "It's a scraper, but a nice one"
12
+ spec.homepage = 'https://github.com/andycroll/curzon_uk'
13
+ spec.licenses = %w(AGPL MIT)
14
+
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.files = `git ls-files`.split("\n")
18
+ spec.executables = spec.files.grep(%r{^bin/}).map { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_development_dependency 'base64'
23
+ spec.add_development_dependency 'bundler', '>= 2.0'
24
+ spec.add_development_dependency 'minitest-mock'
25
+ spec.add_development_dependency 'minitest-reporters'
26
+ spec.add_development_dependency 'rake'
27
+ spec.add_development_dependency 'simplecov', '< 1'
28
+ spec.add_development_dependency 'webmock'
29
+
30
+ spec.add_runtime_dependency 'cinebase', '~> 5.4'
31
+ end
@@ -0,0 +1,109 @@
1
+ module CurzonUk
2
+ # The object representing a cinema on the Curzon website
3
+ class Cinema < Cinebase::Cinema
4
+ # @!attribute [r] id
5
+ # @return [String] the Vista site id of the cinema, e.g. 'ALD1'
6
+
7
+ # UK postcode
8
+ POSTCODE = /[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}/i.freeze
9
+
10
+ # Constructor
11
+ # @param [String] id the cinema id, e.g. 'ALD1' or 'ald1'
12
+ # @return [CurzonUk::Cinema]
13
+ def initialize(id)
14
+ @id = id.to_s.upcase
15
+ end
16
+
17
+ # Return basic cinema information for all cinemas
18
+ # @return [Array<CurzonUk::Cinema>]
19
+ # @example
20
+ # CurzonUk::Cinema.all
21
+ # #=> [<CurzonUk::Cinema>, <CurzonUk::Cinema>, ...]
22
+ def self.all
23
+ sites_hash.keys.map { |id| new(id) }
24
+ end
25
+
26
+ # @!method address
27
+ # Address of the cinema
28
+ # @return [Hash] of different address parts
29
+ # @see #adr
30
+
31
+ # Address of the cinema
32
+ # @return [Hash] contains :street_address, :extended_address,
33
+ # :locality, :region, :postal_code, :country_name
34
+ # @example
35
+ # cinema = CurzonUk::Cinema.new('SOH1')
36
+ # cinema.adr
37
+ # #=> {
38
+ # street_address: '99 Shaftesbury Avenue',
39
+ # extended_address: nil,
40
+ # locality: 'London',
41
+ # region: nil,
42
+ # postal_code: 'W1D 5DY',
43
+ # country_name: 'United Kingdom'
44
+ # }
45
+ # @note Uses method naming as at http://microformats.org/wiki/adr
46
+ def adr
47
+ lines = address_lines
48
+ if (match = lines.last&.match(/\A(.*?),?\s*(#{POSTCODE.source})\z/i))
49
+ lines.pop
50
+ lines << match[1] unless match[1].empty?
51
+ postal_code = match[2]
52
+ end
53
+ street_address = lines.shift
54
+ locality = lines.pop
55
+ {
56
+ street_address: street_address,
57
+ extended_address: lines.empty? ? nil : lines.join(', '),
58
+ locality: locality,
59
+ region: nil,
60
+ postal_code: postal_code,
61
+ country_name: 'United Kingdom'.freeze
62
+ }
63
+ end
64
+
65
+ # Brand of the cinema
66
+ # @return [String] which will always be 'Curzon'
67
+ def brand
68
+ 'Curzon'.freeze
69
+ end
70
+
71
+ # The name of the cinema
72
+ # @return [String]
73
+ # @example
74
+ # CurzonUk::Cinema.new('SOH1').name
75
+ # #=> 'Soho'
76
+ def name
77
+ site_hash.dig('name', 'text').to_s
78
+ end
79
+
80
+ # The url of the cinema on the Curzon website
81
+ # @return [String]
82
+ def url
83
+ "https://www.curzon.com/venues/#{name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')}/"
84
+ end
85
+
86
+ # @api private
87
+ def self.sites_hash
88
+ @sites_hash ||= CurzonUk::Internal::ApiResponse.new.sites.to_h do |site|
89
+ [site.fetch('id'), site]
90
+ end
91
+ end
92
+ private_class_method :sites_hash
93
+
94
+ private
95
+
96
+ # Curzon ends each line with a comma
97
+ def address_lines
98
+ address = site_hash.dig('contactDetails', 'addressV2') || {}
99
+ %w(line1 line2 line3 line4).filter_map do |key|
100
+ text = address.dig(key, 'text').to_s.strip.delete_suffix(',').strip
101
+ text unless text.empty?
102
+ end
103
+ end
104
+
105
+ def site_hash
106
+ @site_hash ||= self.class.send(:sites_hash).fetch(id, {})
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module CurzonUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Fetches cinema and showtime data from the Vista API behind curzon.com
10
+ class ApiResponse
11
+ BASE_URL = "https://digital-api.curzon.com/ocapi/v1/"
12
+
13
+ # @return [Array<Hash>] cinemas
14
+ def sites
15
+ client.get("sites").fetch("sites")
16
+ end
17
+
18
+ # @param [String] cinema_id site id, e.g. 'ALD1'
19
+ # @param [Date] date business date
20
+ # @return [Hash] showtimes plus their related films and attributes
21
+ def showtimes(cinema_id, date)
22
+ client.get("showtimes/by-business-date/#{date.iso8601}",
23
+ "siteIds" => cinema_id)
24
+ end
25
+
26
+ private
27
+
28
+ # Curzon's API needs no Connect-Region-Code.
29
+ def client
30
+ Cinebase::Vista::Client.new(base_url: BASE_URL, region_code: nil,
31
+ token: CurzonUk.token)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module CurzonUk
6
+ module Internal
7
+ # Reads the public guest token from the Curzon website's page data
8
+ class GuestToken < Cinebase::Vista::GuestToken
9
+ PAGE_URL = "https://www.curzon.com/"
10
+
11
+ private
12
+
13
+ # Missing pages carry the same page data.
14
+ def accepted_statuses
15
+ %w[200 404]
16
+ end
17
+
18
+ def bundled_curl
19
+ File.expand_path("../../../vendor/curl-impersonate/bin/curl-impersonate", __dir__)
20
+ end
21
+
22
+ def extract_token(html)
23
+ JSON.parse(html[/window\.initialData\s*=\s*(.+?);\s*$/, 1].to_s).dig("api", "authToken")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module CurzonUk
6
+ # @api private
7
+ module Internal
8
+ module Parser
9
+ module Api
10
+ # Parses a single Vista showtime into performance attributes
11
+ class Showtime
12
+ BOOKING_URL = "https://www.curzon.com/ticketing/seats/?showtimeId="
13
+
14
+ # Vista attribute names
15
+ VARIANTS = {
16
+ "Baby Club" => "baby",
17
+ "Open Captioned" => "subtitled",
18
+ "Screening followed by a Q&A" => "q&a"
19
+ }.freeze
20
+
21
+ # @param [Hash] data one showtime
22
+ # @param [Hash{String => Hash}] films related films keyed by id
23
+ # @param [Hash{String => Hash}] attributes related attributes keyed by id
24
+ def initialize(data, films:, attributes:)
25
+ @data = data
26
+ @films = films
27
+ @attributes = attributes
28
+ end
29
+
30
+ # @return [Hash] options for CurzonUk::Performance.new
31
+ def to_hash
32
+ {
33
+ booking_url: BOOKING_URL + @data.fetch("id"),
34
+ dimension: @data["requires3dGlasses"] ? "3d" : "2d",
35
+ festival: Cinebase::Festival.from_title(title, year: starting_at.year),
36
+ film_name: TitleSanitizer.new(title).sanitized,
37
+ film_year: Cinebase::TitleSanitizer.film_year(title),
38
+ starting_at: starting_at,
39
+ variant: variant
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def starting_at
46
+ Time.iso8601(@data.dig("schedule", "startsAt")).utc
47
+ end
48
+
49
+ def title
50
+ @films.fetch(@data.fetch("filmId")).dig("title", "text")
51
+ end
52
+
53
+ def variant
54
+ @data.fetch("attributeIds", []).filter_map do |id|
55
+ VARIANTS[@attributes.dig(id, "name", "text").to_s.strip]
56
+ end.uniq
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ module CurzonUk
2
+ # Internal utility classes: Do not use
3
+ # @api private
4
+ module Internal
5
+ # Sanitize and standardize film titles
6
+ class TitleSanitizer < Cinebase::TitleSanitizer
7
+ # @!method initialize(title)
8
+ # Constructor
9
+ # @param [String] title a film title
10
+ # @return [CurzonUk::Internal::TitleSanitizer]
11
+
12
+ # @!method sanitized
13
+ # sanitized and standardized title
14
+ # @return [String] sanitised title
15
+
16
+ private
17
+
18
+ # strings and regex to be removed
19
+ def remove
20
+ [
21
+ /\ADocHouse: /i, # DocHouse strand
22
+ /\AFilm Club: /i # Film Club strand
23
+ ] + COMMON_REMOVE
24
+ end
25
+
26
+ # regexes and their replacements
27
+ def replace
28
+ COMMON_REPLACE
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,82 @@
1
+ module CurzonUk
2
+ # The object representing a single screening of a film on the Curzon website
3
+ class Performance < Cinebase::Performance
4
+ # @!attribute [r] booking_url
5
+ # @return [String] the booking URL on the cinema website
6
+ # @!attribute [r] cinema_name
7
+ # @return [String] the cinema name
8
+ # @!attribute [r] cinema_id
9
+ # @return [String] the cinema id
10
+ # @!attribute [r] dimension
11
+ # @return [String] 2d or 3d
12
+ # @!attribute [r] film_name
13
+ # @return [String] the film name
14
+
15
+ # @!method initialize(options)
16
+ # @param [Hash] options options hash
17
+ # @option options [String] :booking_url (nil) buying url for the screening
18
+ # @option options [String] :cinema_name name of the cinema
19
+ # @option options [String] :cinema_id website id of the cinema
20
+ # @option options [String] :dimension ('2d') dimension of the screening
21
+ # @option options [String] :film_name name of the film
22
+ # @option options [Time] :starting_at listed start time of the performance
23
+ # @option options [Cinebase::Festival] :festival (nil) festival edition
24
+ # @option options [Integer] :film_year (nil) year from the film's title
25
+ # @option options [String] :language (nil) audio language of the screening
26
+
27
+ # Performances within an inclusive range of cinema business dates.
28
+ # @param [String] cinema_id id of the cinema, e.g. 'ALD1'
29
+ # @param [Date] from first date (defaults to today in London)
30
+ # @param [Date] to last date (defaults to 13 days after from)
31
+ # @return [Array<CurzonUk::Performance>]
32
+ def self.at(
33
+ cinema_id,
34
+ from: TZInfo::Timezone.get('Europe/London').now.to_date,
35
+ to: from + 13
36
+ )
37
+ raise ArgumentError, 'to must not precede from' if to < from
38
+
39
+ api = CurzonUk::Internal::ApiResponse.new
40
+ cinema = Cinema.new(cinema_id)
41
+ (from..to).flat_map do |date|
42
+ performances_on(api, cinema, date)
43
+ end
44
+ end
45
+
46
+ # @!method showing_on
47
+ # The date of the screening
48
+ # @return [Date]
49
+
50
+ # @!method starting_at
51
+ # UTC time of the screening
52
+ # @return [Time]
53
+
54
+ # @!method variant
55
+ # The kinds of screening (IMAX, kids, baby, senior)
56
+ # @return <Array[String]>
57
+
58
+
59
+ # private
60
+
61
+ def self.performances_on(api, cinema, date)
62
+ response = api.showtimes(cinema.id, date)
63
+ related = response.fetch('relatedData')
64
+ films = by_id(related.fetch('films'))
65
+ attributes = by_id(related.fetch('attributes'))
66
+
67
+ response.fetch('showtimes').map do |data|
68
+ showtime = CurzonUk::Internal::Parser::Api::Showtime.new(
69
+ data, films: films, attributes: attributes
70
+ )
71
+ new(showtime.to_hash.merge(cinema_id: cinema.id,
72
+ cinema_name: cinema.name))
73
+ end
74
+ end
75
+ private_class_method :performances_on
76
+
77
+ def self.by_id(items)
78
+ items.to_h { |item| [item.fetch('id'), item] }
79
+ end
80
+ private_class_method :by_id
81
+ end
82
+ end
@@ -0,0 +1,6 @@
1
+ # Ruby interface for https://www.curzon.com
2
+ # @version 1.0.0
3
+ module CurzonUk
4
+ # Gem version
5
+ VERSION = '1.0.0'.freeze
6
+ end
data/lib/curzon_uk.rb ADDED
@@ -0,0 +1,30 @@
1
+ require 'json'
2
+
3
+ require 'cinebase'
4
+ require 'cinebase/vista'
5
+
6
+ require_relative './curzon_uk/version'
7
+
8
+ require_relative './curzon_uk/internal/api_response'
9
+ require_relative './curzon_uk/internal/guest_token'
10
+ require_relative './curzon_uk/internal/title_sanitizer'
11
+
12
+ require_relative './curzon_uk/internal/parser/api/showtime'
13
+
14
+ require_relative './curzon_uk/cinema'
15
+ require_relative './curzon_uk/performance'
16
+
17
+ # curzon_uk gem
18
+ module CurzonUk
19
+ AuthenticationError = Cinebase::Vista::AuthenticationError
20
+
21
+ # A raw guest JWT, without the "Bearer " prefix. Never persisted by the gem.
22
+ class << self
23
+ attr_accessor :token
24
+
25
+ # Explicit acquisition/renewal; ordinary API calls never launch a subprocess.
26
+ def acquire_token!(curl: nil)
27
+ self.token = Internal::GuestToken.new(curl: curl).fetch
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,58 @@
1
+ {
2
+ "businessDate": "2026-10-24",
3
+ "showtimes": [
4
+ {
5
+ "id": "SOH1-1001",
6
+ "filmId": "FILM-1",
7
+ "schedule": { "startsAt": "2026-10-24T13:00:00+01:00" },
8
+ "attributeIds": [],
9
+ "requires3dGlasses": false
10
+ },
11
+ {
12
+ "id": "SOH1-1002",
13
+ "filmId": "FILM-1",
14
+ "schedule": { "startsAt": "2026-10-24T16:00:00+01:00" },
15
+ "attributeIds": ["A-CAPT"],
16
+ "requires3dGlasses": true
17
+ },
18
+ {
19
+ "id": "SOH1-1003",
20
+ "filmId": "FILM-2",
21
+ "schedule": { "startsAt": "2026-10-24T18:30:00+01:00" },
22
+ "attributeIds": ["A-DOC", "A-QA"],
23
+ "requires3dGlasses": false
24
+ },
25
+ {
26
+ "id": "SOH1-1004",
27
+ "filmId": "FILM-3",
28
+ "schedule": { "startsAt": "2026-10-24T10:30:00+01:00" },
29
+ "attributeIds": ["A-BABY", "A-CLUB"],
30
+ "requires3dGlasses": false
31
+ },
32
+ {
33
+ "id": "SOH1-1005",
34
+ "filmId": "FILM-4",
35
+ "schedule": { "startsAt": "2026-10-25T00:15:00+01:00" },
36
+ "attributeIds": [],
37
+ "requires3dGlasses": false
38
+ }
39
+ ],
40
+ "relatedData": {
41
+ "films": [
42
+ { "id": "FILM-1", "title": { "text": "The Example Film" } },
43
+ { "id": "FILM-2", "title": { "text": "DocHouse: A Made Up Documentary (2025)" } },
44
+ { "id": "FILM-3", "title": { "text": "Film Club: Pretend Classic" } },
45
+ { "id": "FILM-4", "title": { "text": "UKJFF - Imaginary Picture" } }
46
+ ],
47
+ "attributes": [
48
+ { "id": "A-CAPT", "shortName": { "text": "Captioned" }, "name": { "text": "Open Captioned" } },
49
+ { "id": "A-DOC", "shortName": { "text": "Dochouse" }, "name": { "text": "Programmed by Example Dochouse" } },
50
+ { "id": "A-QA", "shortName": { "text": "Q&A" }, "name": { "text": "Screening followed by a Q&A" } },
51
+ { "id": "A-BABY", "shortName": { "text": "Baby Club" }, "name": { "text": "Baby Club" } },
52
+ { "id": "A-CLUB", "shortName": { "text": "Film Club" }, "name": { "text": "Film Club" } }
53
+ ],
54
+ "sites": [
55
+ { "id": "SOH1", "name": { "text": "Soho" } }
56
+ ]
57
+ }
58
+ }