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.
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
@@ -0,0 +1,6 @@
1
+ # Ruby interface for https://www.glasgowfilm.org
2
+ # @version 1.0.0
3
+ module GftUk
4
+ # Gem version
5
+ VERSION = '1.0.0'.freeze
6
+ end