spektrix_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.
data/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # SpektrixUk
2
+
3
+ A simple gem to pull cinemas and performances from UK independent cinemas that sell tickets through [Spektrix](https://www.spektrix.com) and spit out useful formatted info.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'spektrix_uk'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install spektrix_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 strands,
24
+ certificates, notes in square brackets, extras such as food or a Q&A, and
25
+ standardizing event cinema names.
26
+
27
+ No token or setup is needed. Each venue has its own Spektrix account, its
28
+ client, and the public Spektrix API v3 at
29
+ `https://system.spektrix.com/<client>/api/v3` lists its events and their
30
+ instances. Each account names its attributes differently, so the gem holds a
31
+ small configuration for each venue.
32
+
33
+ | id | Cinema | Client |
34
+ |------------|-------------------------|-------------------------|
35
+ | `broadway` | Broadway, Nottingham | `broadwaynottingham` |
36
+ | `home` | HOME, Manchester | `home` |
37
+ | `hpph` | Hyde Park Picture House | `leedsheritagetheatres` |
38
+ | `showroom` | Showroom Cinema | `showroomworkstation` |
39
+ | `tyneside` | Tyneside Cinema | `tynesidecinema` |
40
+
41
+ The ids belong to the gem and never change.
42
+
43
+ ### Cinema
44
+
45
+ Cinemas, addresses and coordinates are held in the gem, so they make no
46
+ requests. Every cinema's brand is `'Independent'`.
47
+
48
+ ``` ruby
49
+ SpektrixUk::Cinema.all
50
+ #=> [<SpektrixUk::Cinema ...>, <SpektrixUk::Cinema ...>, ...]
51
+
52
+ cinema = SpektrixUk::Cinema.new('tyneside')
53
+ #=> <SpektrixUk::Cinema ...>
54
+
55
+ cinema.adr
56
+ #=> {
57
+ street_address: '10 Pilgrim Street',
58
+ extended_address: nil,
59
+ locality: 'Newcastle upon Tyne',
60
+ region: nil,
61
+ postal_code: 'NE1 6QG',
62
+ country_name: 'United Kingdom'
63
+ }
64
+
65
+ cinema.brand
66
+ #=> 'Independent'
67
+
68
+ cinema.name
69
+ #=> 'Tyneside Cinema'
70
+
71
+ cinema.id
72
+ #=> 'tyneside'
73
+
74
+ cinema.latitude
75
+ #=> 54.973903
76
+
77
+ cinema.url
78
+ #=> 'https://tynesidecinema.co.uk'
79
+ ```
80
+
81
+ ### Performances
82
+
83
+ `Performance.at` returns performances for an inclusive range of showing dates,
84
+ defaulting to today in London and the following 13 days. Any range takes two
85
+ requests: the events with an instance in the range, and the instances. The API
86
+ sets no limit on the range. The Leeds Heritage Theatres account answers slowly,
87
+ in about half a minute for a year.
88
+
89
+ ``` ruby
90
+ SpektrixUk::Performance.at('tyneside', from: Date.today, to: Date.today + 2)
91
+ #=> [<SpektrixUk::Performance ...>, <SpektrixUk::Performance ...>, ...]
92
+
93
+ performance = SpektrixUk::Performance.at('tyneside').first
94
+ #=> <SpektrixUk::Performance ...>
95
+
96
+ performance.film_name
97
+ #=> 'The Grinch'
98
+
99
+ performance.film_year
100
+ #=> 2018 # from the venue's year attribute, or the title
101
+
102
+ performance.dimension
103
+ #=> '2d'
104
+
105
+ performance.variant
106
+ #=> ['audio_described', 'relaxed', 'subtitled']
107
+
108
+ performance.starting_at
109
+ #=> 2026-12-20 10:30:00 UTC
110
+
111
+ performance.booking_url
112
+ #=> 'https://tynesidecinema.co.uk/book-online/1808202'
113
+
114
+ performance.cinema_name
115
+ #=> 'Tyneside Cinema'
116
+
117
+ performance.cinema_id
118
+ #=> 'tyneside'
119
+ ```
120
+
121
+ Only films are listed, including event cinema and screenings with a Q&A. Each
122
+ venue marks films in its own way:
123
+
124
+ | id | Films are events where |
125
+ |------------|---------------------------------------------------------------------|
126
+ | `broadway` | `Film` or `EventCinema` is true |
127
+ | `home` | `ShowType` is `Film` |
128
+ | `hpph` | `LiveOrFilm` is `Film`, `Website` is `HPPH` and `Venue` is the Picture House or blank |
129
+ | `showroom` | `ShowroomNonFilmEvent` is not true |
130
+ | `tyneside` | `Medium` is not `Non-Film` |
131
+
132
+ Passes, bundles and lectures are left out by name. Cancelled instances are left
133
+ out. Instances at midnight are left out, as Hyde Park Picture House lists films
134
+ at midnight on the last day of the month before their times are set. Instances
135
+ not on sale are kept: most have started or are not yet on sale.
136
+
137
+ Booking links go to each venue's own booking page:
138
+
139
+ | id | Booking link |
140
+ |------------|---------------------------------------------------------------------------------|
141
+ | `broadway` | `https://www.broadway.org.uk/book/<instance>` |
142
+ | `home` | `https://tickets.homemcr.org/home/website/ChooseSeats.aspx?EventInstanceId=<instance>` |
143
+ | `hpph` | `https://tickets.hpph.co.uk/leedsheritagetheatres/website/ChooseSeats.aspx?EventInstanceId=<instance>` |
144
+ | `showroom` | `https://tickets.showroomcinema.org.uk/showroomworkstation/website/ChooseSeats.aspx?EventInstanceId=<instance>` |
145
+ | `tyneside` | `https://tynesidecinema.co.uk/book-online/<instance>` |
146
+
147
+ The instance is the number that starts a Spektrix instance id. Showroom and Hyde
148
+ Park Picture House open these pages in a frame on their own sites.
149
+
150
+ Variants are `35mm`, `arts`, `audio_described`, `baby`, `kids`, `q&a`,
151
+ `relaxed`, `senior` and `subtitled`, from each venue's event and instance
152
+ attributes and from titles. `subtitled` means captions or subtitles for
153
+ people who are deaf or hard of hearing, not a foreign language film's
154
+ subtitles.
155
+
156
+ ## Tests
157
+
158
+ ```sh
159
+ bundle exec rake # unit tests against fixtures
160
+ bundle exec rake live # live Spektrix API
161
+ ```
162
+
163
+ Fixtures in `test/fixtures` are API responses captured from each venue's
164
+ account and trimmed to a few events. `test/support/api_shapes.rb` describes the
165
+ fields and attributes the gem reads. The unit suite checks the fixtures against
166
+ it and the live suite checks real responses, held only in memory. When an
167
+ account changes, update the shape first, then the fixtures.
168
+
169
+ ## Releasing
170
+
171
+ Bump the version, merge, then run the **Release Gem** workflow in GitHub
172
+ Actions. It runs the tests, tags the version and publishes the gem through
173
+ RubyGems trusted publishing.
174
+
175
+ ## Contributing
176
+
177
+ 1. Fork it
178
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
179
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
180
+ 4. Push to the branch (`git push origin my-new-feature`)
181
+ 5. Create new Pull Request
@@ -0,0 +1,87 @@
1
+ module SpektrixUk
2
+ # An independent cinema that sells tickets through Spektrix
3
+ class Cinema < Cinebase::Cinema
4
+ # @!attribute [r] id
5
+ # @return [String] the gem's id of the cinema, e.g. 'tyneside'
6
+
7
+ # Constructor
8
+ # @param [String, Symbol] id the cinema id, e.g. 'tyneside'
9
+ # @return [SpektrixUk::Cinema]
10
+ def initialize(id)
11
+ @id = id.to_s
12
+ end
13
+
14
+ # Every supported cinema, held in the gem
15
+ # @return [Array<SpektrixUk::Cinema>]
16
+ # @example
17
+ # SpektrixUk::Cinema.all
18
+ # #=> [<SpektrixUk::Cinema>, <SpektrixUk::Cinema>, ...]
19
+ def self.all
20
+ SpektrixUk::Internal::Venue::ALL.keys.map { |id| new(id) }
21
+ end
22
+
23
+ # @!method address
24
+ # Address of the cinema
25
+ # @return [Hash] of different address parts
26
+ # @see #adr
27
+
28
+ # Address of the cinema
29
+ # @return [Hash] contains :street_address, :extended_address,
30
+ # :locality, :region, :postal_code, :country_name
31
+ # @example
32
+ # cinema = SpektrixUk::Cinema.new('tyneside')
33
+ # cinema.adr
34
+ # #=> {
35
+ # street_address: '10 Pilgrim Street',
36
+ # extended_address: nil,
37
+ # locality: 'Newcastle upon Tyne',
38
+ # region: nil,
39
+ # postal_code: 'NE1 6QG',
40
+ # country_name: 'United Kingdom'
41
+ # }
42
+ # @note Uses method naming as at http://microformats.org/wiki/adr
43
+ def adr
44
+ venue.address
45
+ end
46
+
47
+ # Brand of the cinema
48
+ # @return [String] which will always be 'Independent'
49
+ def brand
50
+ 'Independent'.freeze
51
+ end
52
+
53
+ # @return [Float] latitude of the cinema
54
+ def latitude
55
+ venue.latitude
56
+ end
57
+
58
+ # @return [Float] longitude of the cinema
59
+ def longitude
60
+ venue.longitude
61
+ end
62
+
63
+ # The name of the cinema
64
+ # @return [String]
65
+ # @example
66
+ # SpektrixUk::Cinema.new('tyneside').name
67
+ # #=> 'Tyneside Cinema'
68
+ def name
69
+ venue.name
70
+ end
71
+
72
+ # The cinema's own website
73
+ # @return [String]
74
+ # @example
75
+ # SpektrixUk::Cinema.new('tyneside').url
76
+ # #=> 'https://tynesidecinema.co.uk'
77
+ def url
78
+ venue.url
79
+ end
80
+
81
+ private
82
+
83
+ def venue
84
+ SpektrixUk::Internal::Venue.find(id)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SpektrixUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Reads a venue's events and instances from the public Spektrix API v3,
10
+ # which needs no token. Each venue's Spektrix account is its client.
11
+ class Api
12
+ include Http
13
+
14
+ BASE_URL = "https://system.spektrix.com"
15
+
16
+ # @param [String] client the Spektrix client name, e.g. 'tynesidecinema'
17
+ def initialize(client)
18
+ @client = client
19
+ end
20
+
21
+ # Events with an instance starting within the range
22
+ # @param [Date] from first day
23
+ # @param [Date] to the day after the last, as the API reads a date as
24
+ # its midnight
25
+ # @return [Array<Hash>]
26
+ def events(from, to)
27
+ get_json("events", instanceStart_from: from.iso8601, instanceStart_to: to.iso8601)
28
+ end
29
+
30
+ # Instances, or performances, starting within the range
31
+ # @param [Date] from first day
32
+ # @param [Date] to the day after the last
33
+ # @return [Array<Hash>]
34
+ def instances(from, to)
35
+ get_json("instances", startFrom: from.iso8601, startTo: to.iso8601)
36
+ end
37
+
38
+ private
39
+
40
+ def get_json(resource, query)
41
+ JSON.parse(get("#{BASE_URL}/#{@client}/api/v3/#{resource}?#{URI.encode_www_form(query)}"))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module SpektrixUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Requests to the Spektrix API
10
+ module Http
11
+ private
12
+
13
+ def get(url)
14
+ uri = URI(url)
15
+ request = Net::HTTP::Get.new(uri)
16
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
17
+ open_timeout: 10, read_timeout: 120) do |http|
18
+ http.request(request)
19
+ end
20
+ # Omit response bodies from errors, and never follow redirects.
21
+ raise "Spektrix HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
22
+
23
+ String.new(response.body, encoding: Encoding::UTF_8)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module SpektrixUk
6
+ # @api private
7
+ module Internal
8
+ module Parser
9
+ # Parses a Spektrix instance of a film into performance attributes
10
+ class Instance
11
+ # Variants a title names, at any venue
12
+ TITLE_VARIANTS = {
13
+ /\bQ&A\b/i => "q&a",
14
+ /\b35mm\b/i => "35mm",
15
+ /\A(?:Baby Kino|Baby & Carer\/)|Parent & Baby/i => "baby",
16
+ /\b(?:Toddler Kino|Family Time|Family Film)\b|\A(?:TK|FT):/ => "kids",
17
+ /\bRelaxed Screening\b/i => "relaxed"
18
+ }.freeze
19
+
20
+ THREE_D = /\b3D\b/
21
+ YEAR = /\A\d{4}\z/
22
+ TIME_ZONE = "Europe/London"
23
+
24
+ # @param [Hash] instance one Spektrix instance
25
+ # @param [Hash] event the event the instance belongs to
26
+ # @param [SpektrixUk::Internal::Venue] venue the venue that sells it
27
+ def initialize(instance, event:, venue:)
28
+ @instance = instance
29
+ @event = event
30
+ @venue = venue
31
+ end
32
+
33
+ # @return [Hash] options for SpektrixUk::Performance.new
34
+ def to_hash
35
+ {
36
+ booking_url: format(@venue.booking_url, instance: @instance.fetch("id")[/\A\d+/]),
37
+ dimension: three_d? ? "3d" : "2d",
38
+ festival: Cinebase::Festival.from_title(title, year: starting_at.year),
39
+ film_name: TitleSanitizer.new(title).sanitized,
40
+ film_year: film_year,
41
+ starting_at: starting_at,
42
+ variant: variant
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def film_year
49
+ year = @event[@venue.year].to_s.strip
50
+ if year.match?(YEAR)
51
+ year.to_i
52
+ else
53
+ Cinebase::TitleSanitizer.film_year(title.gsub(TitleSanitizer::NOTES, ""))
54
+ end
55
+ end
56
+
57
+ # startUtc is UTC though it carries no zone
58
+ def starting_at
59
+ @starting_at ||=
60
+ if @instance["startUtc"]
61
+ Time.iso8601(@instance["startUtc"].delete_suffix("Z") + "Z")
62
+ else
63
+ local = Time.iso8601("#{@instance.fetch('start')}Z")
64
+ TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
65
+ end
66
+ end
67
+
68
+ def three_d?
69
+ @instance[@venue.three_d] == true || title.match?(THREE_D)
70
+ end
71
+
72
+ def title
73
+ @title ||= @event.fetch("name").strip
74
+ end
75
+
76
+ def variant
77
+ attributes = [[@venue.event_variants, @event], [@venue.instance_variants, @instance]]
78
+ variants = attributes.flat_map do |map, record|
79
+ map.filter_map { |attribute, values| values[record[attribute]] }
80
+ end
81
+ variants += TITLE_VARIANTS.filter_map { |pattern, name| name if title.match?(pattern) }
82
+ variants.uniq
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,76 @@
1
+ module SpektrixUk
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
+ # Square-bracketed notes, e.g. '[35mm]', '[+ Filmmaker Q&A]' or
8
+ # Tyneside's programme year '[2026]'
9
+ NOTES = /\s*\[[^\]]*\]/
10
+
11
+ # Strands and seasons named before a colon, e.g. 'Classics: Dracula'
12
+ STRANDS = [
13
+ 'Baby Kino', 'BHM\d+', 'Classics', 'Cult', 'DocNights', 'Dog Friendly', 'Early Doors(?: - \w+)?',
14
+ 'Family Time', 'FF[FS]\d+(?: x [^:]+)?', 'Film Studies', 'FT', 'Healthy Holidays',
15
+ 'Into Film Festival', 'Japanese Film Club presents', 'My Movie', 'PBPB', 'Perspectives',
16
+ 'Philosophy', 'Popcorn & Prosecco', 'Presents', 'Preview', 'Queer East \d+',
17
+ 'Queer Up Duck! Festival', 'Reclaim the Frame presents', 'Spotlight',
18
+ 'TK', 'Toddler Kino', 'UK Jewish Film Festival', 'VHS Screening', 'WTF'
19
+ ].freeze
20
+
21
+ # Extras after a '+', e.g. 'The Holiday + Popcorn & Prosecco'
22
+ EXTRAS = [
23
+ 'Event', 'Family Yoga', 'Intro(?:duction)?', 'Live Score.*', 'Mince Pie & Prosecco',
24
+ 'Popcorn & Prosecco', 'Shorts?'
25
+ ].freeze
26
+
27
+ # Versions after a dash, e.g. 'Elf - Pay What You Feel'
28
+ VERSIONS = [
29
+ '\d+(?:st|nd|rd|th) Anniversary', '4K Restoration', 'Parent & Baby', 'Pay What You Feel',
30
+ 'Presented by .*', 'Quiet Coach.*', 'Santa Extravaganza', 'Sing Along'
31
+ ].freeze
32
+
33
+ # @!method initialize(title)
34
+ # Constructor
35
+ # @param [String] title a film title
36
+ # @return [SpektrixUk::Internal::TitleSanitizer]
37
+
38
+ # @!method sanitized
39
+ # sanitized and standardized title
40
+ # @return [String] sanitised title
41
+
42
+ private
43
+
44
+ # strings and regex to be removed
45
+ def remove
46
+ [
47
+ NOTES,
48
+ /\s*\((?:U|PG|12A?|15|18|R18|CTBA|TBC)\)/, # certificate
49
+ /\s*\((?:Preview|NEIFF|[^)]*Festival)\)/i, # preview or festival
50
+ /\AChristmas \d+\s*[-:]\s*/, # season
51
+ /\A(?!NT Live|Royal )[^\/]{1,30}\/\s+/, # strand, e.g. 'Cinema Social/ '
52
+ /\A(?:#{STRANDS.join('|')}):\s*/, # strand, e.g. 'Classics: '
53
+ /\A(?:FFS\d+|See It First –|Family Film Club -|Family Supper Club -)\s+/, # strand
54
+ /\A(?:Festive Afternoon Tea|Supper Club) \+\s*/, # food before the film
55
+ /\s+\+\s+(?:#{EXTRAS.join('|')})\z/i, # extras after the film
56
+ /\s*\+[^+]*\bQ&A\b.*\z/i, # Q&A, e.g. '+ Q&A with the director'
57
+ /\s+[-–]\s+(?:#{VERSIONS.join('|')})\z/i, # version
58
+ /:\s*\d+(?:st|nd|rd|th) Anniversary\z/i, # anniversary
59
+ /\s+Family Event\z/i, # family event
60
+ /\s*\(\d+(?:st|nd|rd|th) Anniversary [^)]*\)/i, # anniversary restoration
61
+ /\s*\(4K Restoration\)/i # restoration
62
+ ] + COMMON_REMOVE
63
+ end
64
+
65
+ # regexes and their replacements
66
+ def replace
67
+ {
68
+ /\A(?:EOS|Exhibition on Screen):\s*(.*)/i => 'Exhibition on Screen: ',
69
+ /\A(.*) - Met Opera \d+\/\d+\z/ => 'Met Opera: ',
70
+ /\A(?:NT Live|NTL|National Theatre Live)[:\/]\s*(.*)/ => 'National Theatre Live: ',
71
+ /\A(?:RBO|(?:The )?Royal (?:Ballet|Opera)(?: and Opera)?)[:\/]\s*(.*)/ => 'Royal Ballet and Opera: '
72
+ }.merge(COMMON_REPLACE)
73
+ end
74
+ end
75
+ end
76
+ end