gft_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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/COMM-LICENSE +22 -0
- data/LICENSE +617 -0
- data/README.md +166 -0
- data/lib/gft_uk/cinema.rb +100 -0
- data/lib/gft_uk/internal/graphql.rb +76 -0
- data/lib/gft_uk/internal/indy.rb +57 -0
- data/lib/gft_uk/internal/parser/showing.rb +167 -0
- data/lib/gft_uk/internal/title_sanitizer.rb +41 -0
- data/lib/gft_uk/performance.rb +77 -0
- data/lib/gft_uk/version.rb +6 -0
- data/lib/gft_uk.rb +19 -0
- metadata +151 -0
data/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# GftUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull the cinema and its performances from the [Glasgow Film Theatre website](https://www.glasgowfilm.org) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'gft_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install gft_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
|
+
No token or setup is needed. The website is a single page app on Indy
|
|
24
|
+
Systems, the Indy Cinema Group's platform. It reads its listings from a
|
|
25
|
+
GraphQL API at `/graphql`, which answers consumer queries that send the
|
|
26
|
+
headers `Client-Type: consumer` and `Site-Id: 103`. The gem asks the same
|
|
27
|
+
API.
|
|
28
|
+
|
|
29
|
+
### Cinema
|
|
30
|
+
|
|
31
|
+
Glasgow Film Theatre has one venue, keyed by its Indy site id, `103`. The
|
|
32
|
+
website gives no address or coordinates, so the gem holds them, from
|
|
33
|
+
OpenStreetMap. Cinema methods make no requests.
|
|
34
|
+
|
|
35
|
+
``` ruby
|
|
36
|
+
GftUk::Cinema.all
|
|
37
|
+
#=> [<GftUk::Cinema ...>]
|
|
38
|
+
|
|
39
|
+
cinema = GftUk::Cinema.new('103')
|
|
40
|
+
#=> <GftUk::Cinema ...>
|
|
41
|
+
|
|
42
|
+
cinema.adr
|
|
43
|
+
#=> {
|
|
44
|
+
street_address: '12 Rose Street',
|
|
45
|
+
extended_address: nil,
|
|
46
|
+
locality: 'Glasgow',
|
|
47
|
+
region: nil,
|
|
48
|
+
postal_code: 'G3 6RB',
|
|
49
|
+
country_name: 'United Kingdom'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
cinema.brand
|
|
53
|
+
#=> 'Independent'
|
|
54
|
+
|
|
55
|
+
cinema.name
|
|
56
|
+
#=> 'Glasgow Film Theatre'
|
|
57
|
+
|
|
58
|
+
cinema.id
|
|
59
|
+
#=> '103'
|
|
60
|
+
|
|
61
|
+
cinema.latitude
|
|
62
|
+
#=> 55.8657225
|
|
63
|
+
|
|
64
|
+
cinema.url
|
|
65
|
+
#=> 'https://www.glasgowfilm.org'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Performances
|
|
69
|
+
|
|
70
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
71
|
+
defaulting to today in London and the following 13 days. The API lists the
|
|
72
|
+
dates with showings in one request. The gem then asks for ten of those dates
|
|
73
|
+
at a time, as aliased `showingsForDate` queries in one request. A year ahead
|
|
74
|
+
is about six requests.
|
|
75
|
+
|
|
76
|
+
``` ruby
|
|
77
|
+
GftUk::Performance.at('103', from: Date.today, to: Date.today + 365)
|
|
78
|
+
#=> [<GftUk::Performance ...>, <GftUk::Performance ...>, ...]
|
|
79
|
+
|
|
80
|
+
performance = GftUk::Performance.at('103', from: Date.today, to: Date.today + 365).first
|
|
81
|
+
#=> <GftUk::Performance ...>
|
|
82
|
+
|
|
83
|
+
performance.film_name
|
|
84
|
+
#=> 'The Money Maker' # from 'FFF: The Money Maker + Q&A'
|
|
85
|
+
|
|
86
|
+
performance.film_year
|
|
87
|
+
#=> 2026
|
|
88
|
+
|
|
89
|
+
performance.dimension
|
|
90
|
+
#=> '2d'
|
|
91
|
+
|
|
92
|
+
performance.variant
|
|
93
|
+
#=> ['q&a', 'subtitled']
|
|
94
|
+
|
|
95
|
+
performance.subtitled?
|
|
96
|
+
#=> true
|
|
97
|
+
|
|
98
|
+
performance.festival
|
|
99
|
+
#=> #<data Cinebase::Festival name="French Film Festival", year=2026>
|
|
100
|
+
|
|
101
|
+
performance.starting_at
|
|
102
|
+
#=> 2026-11-21 17:00:00 UTC
|
|
103
|
+
|
|
104
|
+
performance.booking_url
|
|
105
|
+
#=> 'https://www.glasgowfilm.org/checkout/showing/fff-the-money-maker-qa/500092/'
|
|
106
|
+
|
|
107
|
+
performance.cinema_name
|
|
108
|
+
#=> 'Glasgow Film Theatre'
|
|
109
|
+
|
|
110
|
+
performance.cinema_id
|
|
111
|
+
#=> '103'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Booking links open the website's ticket step for the showing. Sold out
|
|
115
|
+
screenings, with no seats remaining, are listed with no booking link.
|
|
116
|
+
|
|
117
|
+
Only films are listed. Showings that are unpublished or private, festival
|
|
118
|
+
passes such as 'Scotland Loves Anime 2026 Weekend Pass', workshops, and title
|
|
119
|
+
classes for live events, talks, television and education are dropped. Event
|
|
120
|
+
cinema, such as National Theatre Live, is kept.
|
|
121
|
+
|
|
122
|
+
Variants come from each showing's badges and from titles: `audio_described`,
|
|
123
|
+
`subtitled` (subtitled, captioned and deaf friendly screenings), `relaxed`
|
|
124
|
+
(autism and dementia friendly), `kids` (the Take 2 children's strand), `q&a`,
|
|
125
|
+
`35mm`, `70mm` and `arts` (event cinema). 4K, introductions, previews and BSL
|
|
126
|
+
interpretation are not variants.
|
|
127
|
+
|
|
128
|
+
Festivals come from badges (French Film Festival, Glasgow Film Festival,
|
|
129
|
+
GYFF, SLA, SMHAF) and from the `FFF:` title prefix. Film years come from a
|
|
130
|
+
year in the title, or else the film's release date.
|
|
131
|
+
|
|
132
|
+
Titles are cleaned up:
|
|
133
|
+
|
|
134
|
+
- Festival and strand prefixes go: 'FFF:', 'Take 2:', 'Take 2 Access:',
|
|
135
|
+
'Access Film Club:', 'Movie Memories:', 'Youth Screening:' and 'Preview:'.
|
|
136
|
+
- Anniversaries, restorations and prints go: 'Marie Antoinette - 20th
|
|
137
|
+
Anniversary - 4K', 'Short Cuts - 35mm', 'A Silent Voice (10th Anniversary)'.
|
|
138
|
+
- Add-ons go: '+ Q&A', '+ discussion', '+ introduction'.
|
|
139
|
+
- 'NT Live:' becomes 'National Theatre Live:'.
|
|
140
|
+
|
|
141
|
+
## Tests
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
bundle exec rake # unit tests against fixtures
|
|
145
|
+
LIVE=1 bundle exec rake live # live glasgowfilm.org
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The fixtures in `test/fixtures` are GraphQL responses: every advertised date,
|
|
149
|
+
and a few films on some of those dates. `test/support/api_shapes.rb`
|
|
150
|
+
describes the fields the gem reads. The unit suite checks the fixtures against
|
|
151
|
+
it and the live suite checks real responses, held only in memory. When the API
|
|
152
|
+
changes, update the shape first, then the fixtures.
|
|
153
|
+
|
|
154
|
+
## Releasing
|
|
155
|
+
|
|
156
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
157
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
158
|
+
RubyGems trusted publishing.
|
|
159
|
+
|
|
160
|
+
## Contributing
|
|
161
|
+
|
|
162
|
+
1. Fork it
|
|
163
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
164
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
165
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
166
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module GftUk
|
|
2
|
+
# The object representing Glasgow Film Theatre
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the cinema's Indy Systems site id, '103'
|
|
6
|
+
|
|
7
|
+
# The cinema has one venue, keyed by the site id its website's GraphQL
|
|
8
|
+
# API takes. The website gives no address or coordinates, so the gem
|
|
9
|
+
# holds them.
|
|
10
|
+
VENUES = {
|
|
11
|
+
'103' => {
|
|
12
|
+
name: 'Glasgow Film Theatre', street_address: '12 Rose Street', locality: 'Glasgow',
|
|
13
|
+
postal_code: 'G3 6RB', latitude: 55.8657225, longitude: -4.2612728
|
|
14
|
+
}
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
|
|
18
|
+
URL = 'https://www.glasgowfilm.org'.freeze
|
|
19
|
+
|
|
20
|
+
# Constructor
|
|
21
|
+
# @param [Integer, String] id the cinema's Indy site id, 103 or '103'
|
|
22
|
+
# @return [GftUk::Cinema]
|
|
23
|
+
def initialize(id)
|
|
24
|
+
@id = id.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return basic cinema information for all cinemas
|
|
28
|
+
# @return [Array<GftUk::Cinema>] the one cinema
|
|
29
|
+
# @example
|
|
30
|
+
# GftUk::Cinema.all
|
|
31
|
+
# #=> [<GftUk::Cinema>]
|
|
32
|
+
def self.all
|
|
33
|
+
VENUES.keys.map { |id| new(id) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @!method address
|
|
37
|
+
# Address of the cinema
|
|
38
|
+
# @return [Hash] of different address parts
|
|
39
|
+
# @see #adr
|
|
40
|
+
|
|
41
|
+
# Address of the cinema
|
|
42
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
43
|
+
# :locality, :region, :postal_code, :country_name
|
|
44
|
+
# @example
|
|
45
|
+
# cinema = GftUk::Cinema.new('103')
|
|
46
|
+
# cinema.adr
|
|
47
|
+
# #=> {
|
|
48
|
+
# street_address: '12 Rose Street',
|
|
49
|
+
# extended_address: nil,
|
|
50
|
+
# locality: 'Glasgow',
|
|
51
|
+
# region: nil,
|
|
52
|
+
# postal_code: 'G3 6RB',
|
|
53
|
+
# country_name: 'United Kingdom'
|
|
54
|
+
# }
|
|
55
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
56
|
+
def adr
|
|
57
|
+
ADDRESS_PARTS.to_h { |part| [part, venue[part]] }.merge(country_name: 'United Kingdom')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Brand of the cinema
|
|
61
|
+
# @return [String] which will always be 'Independent'
|
|
62
|
+
def brand
|
|
63
|
+
'Independent'.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Float, nil]
|
|
67
|
+
def latitude
|
|
68
|
+
venue[:latitude]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Float, nil]
|
|
72
|
+
def longitude
|
|
73
|
+
venue[:longitude]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The name of the cinema
|
|
77
|
+
# @return [String]
|
|
78
|
+
# @example
|
|
79
|
+
# GftUk::Cinema.new('103').name
|
|
80
|
+
# #=> 'Glasgow Film Theatre'
|
|
81
|
+
def name
|
|
82
|
+
venue[:name].to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The url of the cinema's website
|
|
86
|
+
# @return [String]
|
|
87
|
+
# @example
|
|
88
|
+
# GftUk::Cinema.new('103').url
|
|
89
|
+
# #=> 'https://www.glasgowfilm.org'
|
|
90
|
+
def url
|
|
91
|
+
venue.empty? ? '' : URL
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def venue
|
|
97
|
+
VENUES.fetch(id, {})
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
|
|
6
|
+
module GftUk
|
|
7
|
+
# Internal utility classes: Do not use
|
|
8
|
+
# @api private
|
|
9
|
+
module Internal
|
|
10
|
+
# Posts queries to the consumer GraphQL API of a website built on Indy
|
|
11
|
+
# Systems, the Indy Cinema Group's platform
|
|
12
|
+
class Graphql
|
|
13
|
+
RETRIES = 2
|
|
14
|
+
TRANSIENT_ERRORS = [
|
|
15
|
+
EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# @param [String] url the website's GraphQL endpoint
|
|
19
|
+
# @param [String] site_id the venue's Indy site id
|
|
20
|
+
def initialize(url, site_id:)
|
|
21
|
+
@uri = URI(url)
|
|
22
|
+
@site_id = site_id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param [String] document a GraphQL query
|
|
26
|
+
# @param [Hash] variables the query's variables
|
|
27
|
+
# @return [Hash] the response's data
|
|
28
|
+
def query(document, variables = {})
|
|
29
|
+
body = JSON.parse(post(JSON.generate(query: document, variables: variables)))
|
|
30
|
+
# Indy reports a failed query in one 'error' object, not an 'errors' list
|
|
31
|
+
error = body['error'] || Array(body['errors']).first
|
|
32
|
+
if error
|
|
33
|
+
raise "Indy GraphQL error: #{error['message']}"
|
|
34
|
+
else
|
|
35
|
+
body.fetch('data')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Indy refuses consumer queries that name no site
|
|
42
|
+
def post(body)
|
|
43
|
+
request = Net::HTTP::Post.new(@uri)
|
|
44
|
+
request['Content-Type'] = 'application/json'
|
|
45
|
+
request['Client-Type'] = 'consumer'
|
|
46
|
+
request['Site-Id'] = @site_id
|
|
47
|
+
request.body = body
|
|
48
|
+
response = with_retries { connection.request(request) }
|
|
49
|
+
# Omit response bodies from errors, and never follow redirects.
|
|
50
|
+
raise "Indy HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
51
|
+
|
|
52
|
+
String.new(response.body, encoding: Encoding::UTF_8)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Net::HTTP never retries a POST itself
|
|
56
|
+
def with_retries
|
|
57
|
+
attempts = 0
|
|
58
|
+
begin
|
|
59
|
+
yield
|
|
60
|
+
rescue *TRANSIENT_ERRORS
|
|
61
|
+
attempts += 1
|
|
62
|
+
raise if attempts > RETRIES
|
|
63
|
+
|
|
64
|
+
@connection = nil
|
|
65
|
+
sleep(attempts)
|
|
66
|
+
retry
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# One connection for all of an import's requests
|
|
71
|
+
def connection
|
|
72
|
+
@connection ||= Net::HTTP.start(@uri.host, @uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GftUk
|
|
4
|
+
# Internal utility classes: Do not use
|
|
5
|
+
# @api private
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads a venue's listings from a website built on Indy Systems
|
|
8
|
+
class Indy
|
|
9
|
+
# Dates are aliased showingsForDate queries in one request. Ten keeps
|
|
10
|
+
# each response to a couple of seconds.
|
|
11
|
+
DATES_PER_REQUEST = 10
|
|
12
|
+
|
|
13
|
+
DATES_QUERY = <<~GRAPHQL
|
|
14
|
+
query ($siteIds: [ID]) { datesWithShowing(siteIds: $siteIds) { value } }
|
|
15
|
+
GRAPHQL
|
|
16
|
+
|
|
17
|
+
SHOWING_FIELDS = <<~GRAPHQL
|
|
18
|
+
data {
|
|
19
|
+
id time published private seatsRemaining
|
|
20
|
+
showingBadges { displayName }
|
|
21
|
+
movie { id name urlSlug releaseDate titleClass { name } }
|
|
22
|
+
}
|
|
23
|
+
GRAPHQL
|
|
24
|
+
|
|
25
|
+
# @param [String] url the website's home page
|
|
26
|
+
# @param [String] site_id the venue's Indy site id
|
|
27
|
+
def initialize(url, site_id:)
|
|
28
|
+
@graphql = Graphql.new("#{url}/graphql", site_id: site_id)
|
|
29
|
+
@site_id = site_id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array<Date>] every date with showings on sale
|
|
33
|
+
def dates
|
|
34
|
+
value = @graphql.query(DATES_QUERY, siteIds: [@site_id]).dig('datesWithShowing', 'value')
|
|
35
|
+
JSON.parse(value).map { |date| Date.iso8601(date) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param [Array<Date>] dates listings dates, in London
|
|
39
|
+
# @return [Array<Hash>] the showings on those dates, each with its movie
|
|
40
|
+
def showings(dates)
|
|
41
|
+
dates.each_slice(DATES_PER_REQUEST).flat_map do |slice|
|
|
42
|
+
variables = slice.each_with_index.to_h { |date, index| ["d#{index}", date.iso8601] }
|
|
43
|
+
data = @graphql.query(showings_query(variables.keys), variables.merge('siteIds' => [@site_id]))
|
|
44
|
+
variables.keys.flat_map { |key| data.dig(key, 'data') || [] }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def showings_query(keys)
|
|
51
|
+
params = keys.map { |key| "$#{key}: String" }.join(', ')
|
|
52
|
+
fields = keys.map { |key| "#{key}: showingsForDate(date: $#{key}, siteIds: $siteIds) { #{SHOWING_FIELDS} }" }
|
|
53
|
+
"query (#{params}, $siteIds: [ID]) { #{fields.join(' ')} }"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GftUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Parses a single Indy showing into performance attributes
|
|
8
|
+
class Showing
|
|
9
|
+
BOOKING_PATH = '/checkout/showing/'
|
|
10
|
+
|
|
11
|
+
# Showing badges and the variants they mean. 'Subtitled' marks
|
|
12
|
+
# foreign language films. Others, such as 'Accessible', '4K Screening',
|
|
13
|
+
# 'Introduction', 'Preview' and 'BSL Interpretation', are not variants.
|
|
14
|
+
BADGES = {
|
|
15
|
+
'35mm Screening' => %w(35mm),
|
|
16
|
+
'70mm Screening' => %w(70mm),
|
|
17
|
+
'Access Take 2' => %w(kids relaxed),
|
|
18
|
+
'As Live' => %w(arts),
|
|
19
|
+
'Audio Described' => %w(audio_described),
|
|
20
|
+
'Autism Friendly' => %w(relaxed),
|
|
21
|
+
'Captioned' => %w(subtitled),
|
|
22
|
+
'Deaf-friendly' => %w(subtitled),
|
|
23
|
+
'Dementia Friendly' => %w(relaxed),
|
|
24
|
+
'Event Cinema' => %w(arts),
|
|
25
|
+
'Event Cinema - Encore' => %w(arts),
|
|
26
|
+
'Q&A' => %w(q&a),
|
|
27
|
+
'Recorded Q&A' => %w(q&a),
|
|
28
|
+
'Subtitled' => %w(subtitled),
|
|
29
|
+
'Take 2' => %w(kids)
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Retired badges stay on old showings with this suffix, e.g.
|
|
33
|
+
# 'Take 2 - don't use'
|
|
34
|
+
RETIRED = /\s*-\s*don['’]t use\z/i
|
|
35
|
+
|
|
36
|
+
# Event cinema title classes, e.g. 'Event Cinema - Encore'
|
|
37
|
+
EVENT_CINEMA = /\AEvent Cinema\b/
|
|
38
|
+
|
|
39
|
+
# Badges naming a festival, with its edition when given
|
|
40
|
+
FESTIVALS = {
|
|
41
|
+
/\AFrench Film Festival\z/ => 'French Film Festival',
|
|
42
|
+
/\AGlasgow Film Festival\z/ => 'Glasgow Film Festival',
|
|
43
|
+
/\AGYFF\z/ => 'Glasgow Youth Film Festival',
|
|
44
|
+
/\ASLA (20\d{2})\z/ => 'Scotland Loves Anime',
|
|
45
|
+
/\ASMHAF\z/ => 'Scottish Mental Health Arts Festival'
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Title prefixes naming a festival
|
|
49
|
+
TITLE_FESTIVALS = {
|
|
50
|
+
/\AFFF: / => 'French Film Festival'
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
TITLE_VARIANTS = {
|
|
54
|
+
/\b35mm\b/i => '35mm',
|
|
55
|
+
/\b70mm\b/i => '70mm',
|
|
56
|
+
/\bQ&A\b/i => 'q&a'
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# Title classes for live events, talks and classes, e.g. 'GFT Live
|
|
60
|
+
# Event', 'GFF Events', 'Workshops' and 'Education'
|
|
61
|
+
NON_FILM_CLASSES = /\b(?:Live|Guest|Television) Event\b|\bEvents\b|\bWorkshops?\b|\bEducation\b/i
|
|
62
|
+
NON_FILM_BADGES = %w(Workshop).freeze
|
|
63
|
+
# Festival passes are sold as showings, e.g. 'Scotland Loves Anime
|
|
64
|
+
# 2026 Weekend Pass'
|
|
65
|
+
NON_FILM_TITLES = /\b(?:Pass|Gift Card|Voucher|Membership)\z/i
|
|
66
|
+
|
|
67
|
+
THREE_D = /\b3D\b/i
|
|
68
|
+
TIME_ZONE = 'Europe/London'
|
|
69
|
+
|
|
70
|
+
# @param [Hash] showing one showing, with its movie
|
|
71
|
+
# @param [String] url the website's home page
|
|
72
|
+
def initialize(showing, url:)
|
|
73
|
+
@showing = showing
|
|
74
|
+
@url = url
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Films only, on public sale
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def film?
|
|
80
|
+
public? && !title.match?(NON_FILM_TITLES) && !title_class.match?(NON_FILM_CLASSES) &&
|
|
81
|
+
!badges.intersect?(NON_FILM_BADGES)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Hash] options for GftUk::Performance.new
|
|
85
|
+
def to_hash
|
|
86
|
+
{
|
|
87
|
+
booking_url: booking_url,
|
|
88
|
+
dimension: title.match?(THREE_D) ? '3d' : '2d',
|
|
89
|
+
festival: festival,
|
|
90
|
+
film_name: TitleSanitizer.new(title).sanitized,
|
|
91
|
+
film_year: Cinebase::TitleSanitizer.film_year(title) || release_year,
|
|
92
|
+
starting_at: starting_at,
|
|
93
|
+
variant: variant
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def badges
|
|
100
|
+
@badges ||= @showing.fetch('showingBadges', []).map { |badge| badge.fetch('displayName').strip.sub(RETIRED, '') }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Sold out showings have no seats remaining
|
|
104
|
+
def booking_url
|
|
105
|
+
if @showing['seatsRemaining'].to_i.positive?
|
|
106
|
+
"#{@url}#{BOOKING_PATH}#{movie['urlSlug'] || movie['id']}/#{@showing.fetch('id')}/"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def festival
|
|
111
|
+
Cinebase::Festival.from_title(title, year: london_year) || badge_festival || title_festival
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def badge_festival
|
|
115
|
+
badges.each do |badge|
|
|
116
|
+
FESTIVALS.each do |pattern, name|
|
|
117
|
+
match = badge.match(pattern)
|
|
118
|
+
return Cinebase::Festival.new(name: name, year: Integer(match[1] || london_year)) if match
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def title_festival
|
|
125
|
+
name = TITLE_FESTIVALS.find { |pattern, _| title.match?(pattern) }&.last
|
|
126
|
+
Cinebase::Festival.new(name: name, year: london_year) if name
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def london_year
|
|
130
|
+
TZInfo::Timezone.get(TIME_ZONE).to_local(starting_at).year
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def movie
|
|
134
|
+
@showing.fetch('movie')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def public?
|
|
138
|
+
@showing['published'] != false && @showing['private'] != true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def release_year
|
|
142
|
+
movie['releaseDate']&.slice(0, 4)&.to_i
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Times are UTC, e.g. '2026-11-01T12:00:00Z'
|
|
146
|
+
def starting_at
|
|
147
|
+
@starting_at ||= Time.iso8601(@showing.fetch('time')).utc
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def title
|
|
151
|
+
@title ||= movie.fetch('name').strip
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def title_class
|
|
155
|
+
movie.dig('titleClass', 'name').to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def variant
|
|
159
|
+
variants = badges.flat_map { |badge| BADGES.fetch(badge, []) }
|
|
160
|
+
variants << 'arts' if title_class.match?(EVENT_CINEMA)
|
|
161
|
+
variants += TITLE_VARIANTS.filter_map { |pattern, name| name if title.match?(pattern) }
|
|
162
|
+
variants.uniq
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module GftUk
|
|
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 [GftUk::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
|
+
/\AFFF:\s*/, # French Film Festival
|
|
22
|
+
/\ATake 2(?: Access)?:\s*/i, # children's strand
|
|
23
|
+
/\AAccess Film Club:\s*/i, # autism friendly strand
|
|
24
|
+
/\AMovie Memories:\s*/i, # dementia friendly strand
|
|
25
|
+
/\AYouth Screening:\s*/i, # 15-25 strand
|
|
26
|
+
/\APreview:\s*/i, # preview
|
|
27
|
+
/\s+-\s+\d+(?:st|nd|rd|th) Anniversary\b/i, # anniversary re-release
|
|
28
|
+
/\s+-\s+(?:4K|35mm|70mm)\b/i, # restoration or print
|
|
29
|
+
/\s*\+\s*Introduction\z/i # introduction add-on
|
|
30
|
+
] + COMMON_REMOVE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# regexes and their replacements
|
|
34
|
+
def replace
|
|
35
|
+
{
|
|
36
|
+
/\A(?:NT|National Theatre) Live:\s*(.*)/ => 'National Theatre Live: '
|
|
37
|
+
}.merge(COMMON_REPLACE)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module GftUk
|
|
2
|
+
# A single screening of a film on the Glasgow Film Theatre website
|
|
3
|
+
class Performance < Cinebase::Performance
|
|
4
|
+
# @!attribute [r] booking_url
|
|
5
|
+
# @return [String, nil] the booking URL, nil when sold out
|
|
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 [Cinebase::Festival] :festival (nil) festival edition
|
|
22
|
+
# @option options [String] :film_name name of the film
|
|
23
|
+
# @option options [Integer] :film_year (nil) year of the film
|
|
24
|
+
# @option options [Time] :starting_at listed start time of the performance
|
|
25
|
+
|
|
26
|
+
TIME_ZONE = 'Europe/London'.freeze
|
|
27
|
+
|
|
28
|
+
# Performances within an inclusive range of showing dates: one request
|
|
29
|
+
# for the dates with showings, then one for every ten of those dates.
|
|
30
|
+
# @param [Integer, String] cinema_id the cinema's Indy site id, '103'
|
|
31
|
+
# @param [Date] from first date (defaults to today in London)
|
|
32
|
+
# @param [Date] to last date (defaults to 13 days after from)
|
|
33
|
+
# @return [Array<GftUk::Performance>]
|
|
34
|
+
def self.at(
|
|
35
|
+
cinema_id,
|
|
36
|
+
from: today,
|
|
37
|
+
to: from + 13
|
|
38
|
+
)
|
|
39
|
+
raise ArgumentError, 'to must not precede from' if to < from
|
|
40
|
+
|
|
41
|
+
cinema = Cinema.new(cinema_id)
|
|
42
|
+
if cinema.name.empty?
|
|
43
|
+
[]
|
|
44
|
+
else
|
|
45
|
+
performances = showings(cinema, from..to).map do |showing|
|
|
46
|
+
new(showing.to_hash.merge(cinema_id: cinema.id, cinema_name: cinema.name))
|
|
47
|
+
end
|
|
48
|
+
performances.sort_by(&:starting_at)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @!method showing_on
|
|
53
|
+
# The date of the screening
|
|
54
|
+
# @return [Date]
|
|
55
|
+
|
|
56
|
+
# @!method starting_at
|
|
57
|
+
# UTC time of the screening
|
|
58
|
+
# @return [Time]
|
|
59
|
+
|
|
60
|
+
# @!method variant
|
|
61
|
+
# The kinds of screening (arts, audio_described, kids, relaxed, subtitled ...)
|
|
62
|
+
# @return <Array[String]>
|
|
63
|
+
|
|
64
|
+
def self.today
|
|
65
|
+
TZInfo::Timezone.get(TIME_ZONE).now.to_date
|
|
66
|
+
end
|
|
67
|
+
private_class_method :today
|
|
68
|
+
|
|
69
|
+
def self.showings(cinema, dates)
|
|
70
|
+
indy = GftUk::Internal::Indy.new(cinema.url, site_id: cinema.id)
|
|
71
|
+
indy.showings(indy.dates.select { |date| dates.cover?(date) })
|
|
72
|
+
.map { |showing| GftUk::Internal::Parser::Showing.new(showing, url: cinema.url) }
|
|
73
|
+
.select(&:film?)
|
|
74
|
+
end
|
|
75
|
+
private_class_method :showings
|
|
76
|
+
end
|
|
77
|
+
end
|