watershed_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 +10 -0
- data/COMM-LICENSE +22 -0
- data/LICENSE +617 -0
- data/README.md +169 -0
- data/lib/watershed_uk/cinema.rb +107 -0
- data/lib/watershed_uk/internal/http.rb +41 -0
- data/lib/watershed_uk/internal/parser/feed.rb +66 -0
- data/lib/watershed_uk/internal/parser/screening.rb +100 -0
- data/lib/watershed_uk/internal/ticketsolve.rb +21 -0
- data/lib/watershed_uk/internal/title_sanitizer.rb +43 -0
- data/lib/watershed_uk/performance.rb +77 -0
- data/lib/watershed_uk/version.rb +6 -0
- data/lib/watershed_uk.rb +20 -0
- metadata +166 -0
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# WatershedUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull cinema and performance information for [Watershed](https://www.watershed.co.uk), the independent cinema in Bristol, and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'watershed_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install watershed_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 BBFC
|
|
24
|
+
certificates and the `S` and `PS` (partly subtitled) markers after them,
|
|
25
|
+
presenter, partner, preview and strand prefixes (`Bristol Bad Film Club
|
|
26
|
+
presents:`, `Spike Island x Watershed:`, `Preview:`, `Bristol Surf Cinema:`,
|
|
27
|
+
`Sonic Cinema:`), 35mm, director's cut and anniversary markers, years, and
|
|
28
|
+
Q&A and panel discussion add-ons, and naming National Theatre Live and Royal
|
|
29
|
+
Ballet and Opera screenings in full.
|
|
30
|
+
|
|
31
|
+
No token or setup is needed. Watershed sells tickets through Ticketsolve,
|
|
32
|
+
whose public XML feed lists every show on sale.
|
|
33
|
+
|
|
34
|
+
### Cinema
|
|
35
|
+
|
|
36
|
+
Watershed is one venue. The feed names it but gives no address, so the gem
|
|
37
|
+
holds it: the address from the website's footer and coordinates from
|
|
38
|
+
OpenStreetMap. Cinema methods make no requests.
|
|
39
|
+
|
|
40
|
+
``` ruby
|
|
41
|
+
WatershedUk::Cinema.all
|
|
42
|
+
#=> [<WatershedUk::Cinema ...>]
|
|
43
|
+
|
|
44
|
+
cinema = WatershedUk::Cinema.new('watershed')
|
|
45
|
+
#=> <WatershedUk::Cinema ...>
|
|
46
|
+
|
|
47
|
+
cinema.adr
|
|
48
|
+
#=> {
|
|
49
|
+
street_address: '1 Canons Road',
|
|
50
|
+
extended_address: 'Harbourside',
|
|
51
|
+
locality: 'Bristol',
|
|
52
|
+
region: nil,
|
|
53
|
+
postal_code: 'BS1 5TX',
|
|
54
|
+
country_name: 'United Kingdom'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cinema.brand
|
|
58
|
+
#=> 'Independent'
|
|
59
|
+
|
|
60
|
+
cinema.name
|
|
61
|
+
#=> 'Watershed'
|
|
62
|
+
|
|
63
|
+
cinema.full_name
|
|
64
|
+
#=> 'Independent Watershed'
|
|
65
|
+
|
|
66
|
+
cinema.id
|
|
67
|
+
#=> 'watershed'
|
|
68
|
+
|
|
69
|
+
cinema.latitude
|
|
70
|
+
#=> 51.4517753
|
|
71
|
+
|
|
72
|
+
cinema.longitude
|
|
73
|
+
#=> -2.598143
|
|
74
|
+
|
|
75
|
+
cinema.url
|
|
76
|
+
#=> 'https://www.watershed.co.uk'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Performances
|
|
80
|
+
|
|
81
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
82
|
+
defaulting to today in London and the following 13 days. The Ticketsolve feed
|
|
83
|
+
holds every advertised date, about six months ahead, so any range is one
|
|
84
|
+
request of about 320 KB.
|
|
85
|
+
|
|
86
|
+
``` ruby
|
|
87
|
+
WatershedUk::Performance.at('watershed', from: Date.today, to: Date.today + 2)
|
|
88
|
+
#=> [<WatershedUk::Performance ...>, <WatershedUk::Performance ...>, ...]
|
|
89
|
+
|
|
90
|
+
performance = WatershedUk::Performance.at('watershed').first
|
|
91
|
+
#=> <WatershedUk::Performance ...>
|
|
92
|
+
|
|
93
|
+
performance.film_name
|
|
94
|
+
#=> 'Daïchi Saïto' # from 'Sonic Cinema: Daïchi Saïto (35mm) (18 CTBA)'
|
|
95
|
+
|
|
96
|
+
performance.film_year
|
|
97
|
+
#=> nil # from the title, e.g. 1983 for 'The Store (1983) (18 CTBA)'
|
|
98
|
+
|
|
99
|
+
performance.dimension
|
|
100
|
+
#=> '2d'
|
|
101
|
+
|
|
102
|
+
performance.variant
|
|
103
|
+
#=> ['35mm', 'q&a']
|
|
104
|
+
|
|
105
|
+
performance.subtitled?
|
|
106
|
+
#=> false
|
|
107
|
+
|
|
108
|
+
performance.festival
|
|
109
|
+
#=> nil # e.g. #<data Cinebase::Festival name="Afrika Eye", year=2026>
|
|
110
|
+
|
|
111
|
+
performance.starting_at
|
|
112
|
+
#=> 2026-11-26 18:30:00 UTC
|
|
113
|
+
|
|
114
|
+
performance.booking_url
|
|
115
|
+
#=> 'https://watershed.ticketsolve.com/shows/1173695141/events/428916513'
|
|
116
|
+
|
|
117
|
+
performance.cinema_name
|
|
118
|
+
#=> 'Watershed'
|
|
119
|
+
|
|
120
|
+
performance.cinema_id
|
|
121
|
+
#=> 'watershed'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Booking links are each event's Ticketsolve page, which leads to ticket and
|
|
125
|
+
seat selection. Sold out performances have no booking link.
|
|
126
|
+
|
|
127
|
+
Only films at Watershed itself are returned. The feed also holds Watershed's
|
|
128
|
+
partner venues, its event spaces and the Pervasive Media Studio, which the gem
|
|
129
|
+
skips. Within the cinema it keeps the `Film` and `Event Cinema` categories,
|
|
130
|
+
and shows with no category that have a certificate in their name. Talks,
|
|
131
|
+
workshops, other events and seat sponsorships are dropped. Screenings with
|
|
132
|
+
an introduction, a Q&A or a panel are films.
|
|
133
|
+
|
|
134
|
+
Variants come from show tags, each event's comment and titles: `subtitled`
|
|
135
|
+
(descriptive subtitles or captions), `relaxed`, `baby` (parents and carers
|
|
136
|
+
of babies), `audio_described`, `35mm`, `arts` (National Theatre Live and
|
|
137
|
+
other event cinema) and `q&a`. Films subtitled from another language are
|
|
138
|
+
not a variant.
|
|
139
|
+
|
|
140
|
+
Festival tags name Afrika Eye and the Bristol Palestine Film Festival
|
|
141
|
+
editions.
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
bundle exec rake # unit tests against fixtures
|
|
147
|
+
bundle exec rake live # live Ticketsolve feed
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The fixture in `test/fixtures` is the Ticketsolve feed, trimmed to a few
|
|
151
|
+
shows at three of its venues, a few events each and no descriptions or
|
|
152
|
+
images. `test/support/api_shapes.rb` describes the parts the gem reads. The
|
|
153
|
+
unit suite checks the fixture against it and the live suite checks the real
|
|
154
|
+
feed, held only in memory. When the feed changes, update the shape first,
|
|
155
|
+
then the fixture.
|
|
156
|
+
|
|
157
|
+
## Releasing
|
|
158
|
+
|
|
159
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
160
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
161
|
+
RubyGems trusted publishing.
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
1. Fork it
|
|
166
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
167
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
168
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
169
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module WatershedUk
|
|
2
|
+
# The object representing the Watershed cinema in Bristol
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the cinema's id, 'watershed'
|
|
6
|
+
|
|
7
|
+
# Watershed is one venue, sold through Ticketsolve alongside its partner
|
|
8
|
+
# venues and event spaces. The address is from the website's footer, the
|
|
9
|
+
# coordinates from the cinema's OpenStreetMap node 12973154703.
|
|
10
|
+
VENUES = {
|
|
11
|
+
'watershed' => {
|
|
12
|
+
name: 'Watershed', ticketsolve_venue_id: '426565625',
|
|
13
|
+
street_address: '1 Canons Road', extended_address: 'Harbourside', locality: 'Bristol',
|
|
14
|
+
postal_code: 'BS1 5TX', latitude: 51.4517753, longitude: -2.598143
|
|
15
|
+
}
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
|
|
19
|
+
URL = 'https://www.watershed.co.uk'.freeze
|
|
20
|
+
|
|
21
|
+
# Constructor
|
|
22
|
+
# @param [String] id the cinema's id, 'watershed'
|
|
23
|
+
# @return [WatershedUk::Cinema]
|
|
24
|
+
def initialize(id)
|
|
25
|
+
@id = id.to_s.downcase
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Return basic cinema information for all cinemas
|
|
29
|
+
# @return [Array<WatershedUk::Cinema>]
|
|
30
|
+
# @example
|
|
31
|
+
# WatershedUk::Cinema.all
|
|
32
|
+
# #=> [<WatershedUk::Cinema>]
|
|
33
|
+
def self.all
|
|
34
|
+
VENUES.keys.map { |id| new(id) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @!method address
|
|
38
|
+
# Address of the cinema
|
|
39
|
+
# @return [Hash] of different address parts
|
|
40
|
+
# @see #adr
|
|
41
|
+
|
|
42
|
+
# Address of the cinema
|
|
43
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
44
|
+
# :locality, :region, :postal_code, :country_name
|
|
45
|
+
# @example
|
|
46
|
+
# cinema = WatershedUk::Cinema.new('watershed')
|
|
47
|
+
# cinema.adr
|
|
48
|
+
# #=> {
|
|
49
|
+
# street_address: '1 Canons Road',
|
|
50
|
+
# extended_address: 'Harbourside',
|
|
51
|
+
# locality: 'Bristol',
|
|
52
|
+
# region: nil,
|
|
53
|
+
# postal_code: 'BS1 5TX',
|
|
54
|
+
# country_name: 'United Kingdom'
|
|
55
|
+
# }
|
|
56
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
57
|
+
def adr
|
|
58
|
+
ADDRESS_PARTS.to_h { |part| [part, venue[part]] }.merge(country_name: 'United Kingdom')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Brand of the cinema
|
|
62
|
+
# @return [String] which will always be 'Independent'
|
|
63
|
+
def brand
|
|
64
|
+
'Independent'.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Float, nil]
|
|
68
|
+
def latitude
|
|
69
|
+
venue[:latitude]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Float, nil]
|
|
73
|
+
def longitude
|
|
74
|
+
venue[:longitude]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The name of the cinema
|
|
78
|
+
# @return [String]
|
|
79
|
+
# @example
|
|
80
|
+
# WatershedUk::Cinema.new('watershed').name
|
|
81
|
+
# #=> 'Watershed'
|
|
82
|
+
def name
|
|
83
|
+
venue[:name].to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The url of the cinema's website
|
|
87
|
+
# @return [String]
|
|
88
|
+
# @example
|
|
89
|
+
# WatershedUk::Cinema.new('watershed').url
|
|
90
|
+
# #=> 'https://www.watershed.co.uk'
|
|
91
|
+
def url
|
|
92
|
+
venue.empty? ? '' : URL
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @api private
|
|
96
|
+
# @return [String, nil] the venue's id in the Ticketsolve feed
|
|
97
|
+
def ticketsolve_venue_id
|
|
98
|
+
venue[:ticketsolve_venue_id]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def venue
|
|
104
|
+
VENUES.fetch(id, {})
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module WatershedUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Requests to Ticketsolve
|
|
10
|
+
module Http
|
|
11
|
+
TIMEOUT = 30
|
|
12
|
+
RETRIES = 2
|
|
13
|
+
USER_AGENT = 'Mozilla/5.0 (compatible; watershed_uk; +https://github.com/andycroll/watershed_uk)'
|
|
14
|
+
TRANSIENT_ERRORS = [
|
|
15
|
+
EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def get(url)
|
|
21
|
+
uri = URI(url)
|
|
22
|
+
attempts = 0
|
|
23
|
+
begin
|
|
24
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
25
|
+
http.request(Net::HTTP::Get.new(uri, 'User-Agent' => USER_AGENT))
|
|
26
|
+
end
|
|
27
|
+
rescue *TRANSIENT_ERRORS
|
|
28
|
+
attempts += 1
|
|
29
|
+
raise if attempts > RETRIES
|
|
30
|
+
|
|
31
|
+
sleep(attempts)
|
|
32
|
+
retry
|
|
33
|
+
end
|
|
34
|
+
# Omit response bodies from errors, and never follow redirects.
|
|
35
|
+
raise "Ticketsolve HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
+
|
|
37
|
+
String.new(response.body, encoding: Encoding::UTF_8)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WatershedUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Parses a Ticketsolve shows.xml feed: venues, each with its shows, each
|
|
8
|
+
# with its events. Nothing here is particular to Watershed.
|
|
9
|
+
class Feed
|
|
10
|
+
# A Ticketsolve venue, e.g. id '426565625', name 'Watershed Cinemas'
|
|
11
|
+
Venue = Data.define(:id, :name, :shows)
|
|
12
|
+
|
|
13
|
+
# A production on sale, e.g. a film, with the tags and category the
|
|
14
|
+
# box office gives it
|
|
15
|
+
Show = Data.define(:id, :name, :category, :tags, :url, :events)
|
|
16
|
+
|
|
17
|
+
# A single performance of a show, with its UTC start, its status such
|
|
18
|
+
# as 'available' or 'sold out', the box office's comment and its
|
|
19
|
+
# booking page
|
|
20
|
+
Event = Data.define(:id, :starting_at, :status, :comment, :url)
|
|
21
|
+
|
|
22
|
+
# @param [String] xml a shows.xml document
|
|
23
|
+
def initialize(xml)
|
|
24
|
+
@document = REXML::Document.new(xml)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<Venue>]
|
|
28
|
+
def venues
|
|
29
|
+
@document.get_elements('venues/venue').map do |venue|
|
|
30
|
+
Venue.new(id: venue.attributes['id'], name: text(venue, 'name'),
|
|
31
|
+
shows: venue.get_elements('shows/show').map { |show| show(show) })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def show(show)
|
|
38
|
+
Show.new(
|
|
39
|
+
id: show.attributes['id'],
|
|
40
|
+
name: text(show, 'name'),
|
|
41
|
+
category: text(show, 'event_category'),
|
|
42
|
+
tags: show.get_elements('tags/tag').map { |tag| tag.text.to_s.strip },
|
|
43
|
+
url: text(show, 'url'),
|
|
44
|
+
events: show.get_elements('events/event').map { |event| event(event) }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def event(event)
|
|
49
|
+
Event.new(
|
|
50
|
+
id: event.attributes['id'],
|
|
51
|
+
starting_at: Time.iso8601(text(event, 'date_time_iso')).utc,
|
|
52
|
+
status: text(event, 'status'),
|
|
53
|
+
comment: text(event, 'comment'),
|
|
54
|
+
url: text(event, 'url')
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Names and comments are CDATA sections between whitespace
|
|
59
|
+
def text(element, path)
|
|
60
|
+
node = element.elements[path]
|
|
61
|
+
node ? node.texts.map(&:value).join.strip : ""
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WatershedUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Reads one Ticketsolve event of a Watershed show as a film screening
|
|
8
|
+
class Screening
|
|
9
|
+
# Watershed's categories for screenings, including broadcasts such as
|
|
10
|
+
# National Theatre Live. Talks, workshops and other events have their
|
|
11
|
+
# own categories.
|
|
12
|
+
FILM_CATEGORIES = ['Film', 'Event Cinema'].freeze
|
|
13
|
+
|
|
14
|
+
SOLD_OUT = 'sold out'
|
|
15
|
+
|
|
16
|
+
CATEGORY_VARIANTS = { 'Event Cinema' => 'arts' }.freeze
|
|
17
|
+
TAG_VARIANTS = { '35mm' => '35mm', 'audio_description' => 'audio_described', 'ntl' => 'arts' }.freeze
|
|
18
|
+
|
|
19
|
+
# The box office notes each event's access and extras in its comment,
|
|
20
|
+
# e.g. 'This screening has descriptive subtitles'
|
|
21
|
+
COMMENT_VARIANTS = {
|
|
22
|
+
/descriptive subtitles|captioned/i => 'subtitled',
|
|
23
|
+
/\bbabies\b/i => 'baby',
|
|
24
|
+
/\brelaxed screening\b/i => 'relaxed',
|
|
25
|
+
/\bQ&A\b/ => 'q&a'
|
|
26
|
+
}.freeze
|
|
27
|
+
TITLE_VARIANTS = { /\(35mm\b/i => '35mm', /\bQ&A\b/ => 'q&a' }.freeze
|
|
28
|
+
|
|
29
|
+
# Festival tags carry a two digit year, e.g. 'afrika_eye_ae26'
|
|
30
|
+
FESTIVAL_TAGS = {
|
|
31
|
+
/\Aafrika_eye_ae(\d{2})\z/ => 'Afrika Eye',
|
|
32
|
+
/\Abpff(\d{2})_/ => 'Bristol Palestine Film Festival'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# National Theatre Live shows are tagged, and often untitled as such
|
|
36
|
+
NT_LIVE_TAG = 'ntl'
|
|
37
|
+
NT_LIVE = /\A(?:NT|National Theatre) Live:/
|
|
38
|
+
THREE_D = /\b3D\b/i
|
|
39
|
+
|
|
40
|
+
# @param [Feed::Show] show
|
|
41
|
+
# @return [Boolean] whether the show is a film, rather than a talk,
|
|
42
|
+
# workshop or other event. Some films have no category, but every
|
|
43
|
+
# film has a certificate in its name.
|
|
44
|
+
def self.film?(show)
|
|
45
|
+
if show.category.empty?
|
|
46
|
+
show.name.match?(TitleSanitizer::CERTIFICATE)
|
|
47
|
+
else
|
|
48
|
+
FILM_CATEGORIES.include?(show.category)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param [Feed::Show] show the film
|
|
53
|
+
# @param [Feed::Event] event one screening of it
|
|
54
|
+
def initialize(show, event)
|
|
55
|
+
@show = show
|
|
56
|
+
@event = event
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Hash] options for WatershedUk::Performance.new
|
|
60
|
+
def to_hash
|
|
61
|
+
{
|
|
62
|
+
booking_url: @event.status == SOLD_OUT ? nil : @event.url,
|
|
63
|
+
dimension: @show.name.match?(THREE_D) ? '3d' : '2d',
|
|
64
|
+
festival: festival,
|
|
65
|
+
film_name: TitleSanitizer.new(title).sanitized,
|
|
66
|
+
film_year: Cinebase::TitleSanitizer.film_year(title),
|
|
67
|
+
starting_at: @event.starting_at,
|
|
68
|
+
variant: variant
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def festival
|
|
75
|
+
FESTIVAL_TAGS.each do |pattern, name|
|
|
76
|
+
tag = @show.tags.grep(pattern).first
|
|
77
|
+
return Cinebase::Festival.new(name: name, year: 2000 + tag[pattern, 1].to_i) if tag
|
|
78
|
+
end
|
|
79
|
+
Cinebase::Festival.from_title(title, year: @event.starting_at.year)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def title
|
|
83
|
+
if @show.tags.include?(NT_LIVE_TAG) && !@show.name.match?(NT_LIVE)
|
|
84
|
+
"NT Live: #{@show.name}"
|
|
85
|
+
else
|
|
86
|
+
@show.name
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def variant
|
|
91
|
+
variants = @show.tags.filter_map { |tag| TAG_VARIANTS[tag] }
|
|
92
|
+
variants << CATEGORY_VARIANTS[@show.category]
|
|
93
|
+
variants += COMMENT_VARIANTS.filter_map { |pattern, variant| variant if @event.comment.match?(pattern) }
|
|
94
|
+
variants += TITLE_VARIANTS.filter_map { |pattern, variant| variant if @show.name.match?(pattern) }
|
|
95
|
+
variants.compact.uniq
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WatershedUk
|
|
4
|
+
# Internal utility classes: Do not use
|
|
5
|
+
# @api private
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads Watershed's Ticketsolve box office
|
|
8
|
+
class Ticketsolve
|
|
9
|
+
include Http
|
|
10
|
+
|
|
11
|
+
# Every show on sale at Watershed and its partner venues, with every
|
|
12
|
+
# advertised event, in one public document
|
|
13
|
+
FEED_URL = 'https://watershed.ticketsolve.com/shows.xml'
|
|
14
|
+
|
|
15
|
+
# @return [String] the shows feed, parsed by {Parser::Feed}
|
|
16
|
+
def feed
|
|
17
|
+
get(FEED_URL)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module WatershedUk
|
|
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 [WatershedUk::Internal::TitleSanitizer]
|
|
11
|
+
|
|
12
|
+
# @!method sanitized
|
|
13
|
+
# sanitized and standardized title
|
|
14
|
+
# @return [String] sanitised title
|
|
15
|
+
|
|
16
|
+
# BBFC certificate, or CTBA when it is yet to be announced, e.g. '(15)',
|
|
17
|
+
# '(18 CTBA)' or '(18) CTBA'
|
|
18
|
+
CERTIFICATE = /\((?:U|PG|12A?|15|18|CTBA)(?: CTBA)?\)(?:\s*CTBA)?/
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# strings and regex to be removed
|
|
23
|
+
def remove
|
|
24
|
+
[
|
|
25
|
+
/\s*#{CERTIFICATE}(?:\s+P?S)?\s*\z/, # certificate, then (partly) subtitled
|
|
26
|
+
/\A[^:]+ presents:\s*/i, # presenter
|
|
27
|
+
/\A[^:]+ x Watershed:\s*/i, # partner
|
|
28
|
+
/\A(?:Preview|Bristol Surf Cinema|Sonic Cinema):\s*/i, # strand
|
|
29
|
+
/\s*\(35mm [^)]*\)/i, # film print tour
|
|
30
|
+
/\s*\(Director[’']s Cut\)/i, # cut
|
|
31
|
+
/\s*\+\s*panel discussion\z/i # panel add-on
|
|
32
|
+
] + COMMON_REMOVE
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# regexes and their replacements
|
|
36
|
+
def replace
|
|
37
|
+
{
|
|
38
|
+
/\A(?:NT|National Theatre) Live:\s*(.*)/ => 'National Theatre Live: '
|
|
39
|
+
}.merge(COMMON_REPLACE)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module WatershedUk
|
|
2
|
+
# A single screening of a film at the Watershed cinema
|
|
3
|
+
class Performance < Cinebase::Performance
|
|
4
|
+
# @!attribute [r] booking_url
|
|
5
|
+
# @return [String, nil] the Ticketsolve 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 from the film's title
|
|
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. The Ticketsolve
|
|
29
|
+
# feed holds every advertised date, so any range is one request.
|
|
30
|
+
# @param [String] cinema_id the cinema's id, 'watershed'
|
|
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<WatershedUk::Performance>]
|
|
34
|
+
def self.at(
|
|
35
|
+
cinema_id,
|
|
36
|
+
from: TZInfo::Timezone.get(TIME_ZONE).now.to_date,
|
|
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
|
+
venues = Internal::Parser::Feed.new(Internal::Ticketsolve.new.feed).venues
|
|
46
|
+
shows = venues.select { |venue| venue.id == cinema.ticketsolve_venue_id }.flat_map(&:shows)
|
|
47
|
+
performances = screenings(shows, from..to).map do |screening|
|
|
48
|
+
new(screening.to_hash.merge(cinema_id: cinema.id, cinema_name: cinema.name))
|
|
49
|
+
end
|
|
50
|
+
performances.sort_by(&:starting_at)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @!method showing_on
|
|
55
|
+
# The date of the screening
|
|
56
|
+
# @return [Date]
|
|
57
|
+
|
|
58
|
+
# @!method starting_at
|
|
59
|
+
# UTC time of the screening
|
|
60
|
+
# @return [Time]
|
|
61
|
+
|
|
62
|
+
# @!method variant
|
|
63
|
+
# The kinds of screening (35mm, arts, audio_described, baby, q&a,
|
|
64
|
+
# relaxed, subtitled)
|
|
65
|
+
# @return <Array[String]>
|
|
66
|
+
|
|
67
|
+
def self.screenings(shows, dates)
|
|
68
|
+
london = TZInfo::Timezone.get(TIME_ZONE)
|
|
69
|
+
shows.select { |show| Internal::Parser::Screening.film?(show) }.flat_map do |show|
|
|
70
|
+
show.events
|
|
71
|
+
.select { |event| dates.cover?(london.to_local(event.starting_at).to_date) }
|
|
72
|
+
.map { |event| Internal::Parser::Screening.new(show, event) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
private_class_method :screenings
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/watershed_uk.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require 'rexml/document'
|
|
2
|
+
require 'time'
|
|
3
|
+
|
|
4
|
+
require 'cinebase'
|
|
5
|
+
|
|
6
|
+
require_relative './watershed_uk/version'
|
|
7
|
+
|
|
8
|
+
require_relative './watershed_uk/internal/http'
|
|
9
|
+
require_relative './watershed_uk/internal/ticketsolve'
|
|
10
|
+
require_relative './watershed_uk/internal/title_sanitizer'
|
|
11
|
+
|
|
12
|
+
require_relative './watershed_uk/internal/parser/feed'
|
|
13
|
+
require_relative './watershed_uk/internal/parser/screening'
|
|
14
|
+
|
|
15
|
+
require_relative './watershed_uk/cinema'
|
|
16
|
+
require_relative './watershed_uk/performance'
|
|
17
|
+
|
|
18
|
+
# watershed_uk gem
|
|
19
|
+
module WatershedUk
|
|
20
|
+
end
|