odeon_uk 5.0.0-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +43 -0
  3. data/.gitignore +18 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +132 -0
  6. data/COMM-LICENSE +22 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE +617 -0
  9. data/README.md +174 -0
  10. data/Rakefile +38 -0
  11. data/lib/odeon_uk/cinema.rb +181 -0
  12. data/lib/odeon_uk/internal/api_response.rb +36 -0
  13. data/lib/odeon_uk/internal/guest_token.rb +23 -0
  14. data/lib/odeon_uk/internal/parser/api/showtime.rb +80 -0
  15. data/lib/odeon_uk/internal/title_sanitizer.rb +42 -0
  16. data/lib/odeon_uk/performance.rb +81 -0
  17. data/lib/odeon_uk/version.rb +6 -0
  18. data/lib/odeon_uk.rb +30 -0
  19. data/odeon_uk.gemspec +31 -0
  20. data/test/fixtures/api/showtimes-824.json +66 -0
  21. data/test/fixtures/api/sites.json +32 -0
  22. data/test/lib/odeon_uk/cinema_test.rb +113 -0
  23. data/test/lib/odeon_uk/internal/api_response_test.rb +66 -0
  24. data/test/lib/odeon_uk/internal/fixture_shape_test.rb +14 -0
  25. data/test/lib/odeon_uk/internal/guest_token_test.rb +84 -0
  26. data/test/lib/odeon_uk/internal/parser/api/showtime_test.rb +68 -0
  27. data/test/lib/odeon_uk/internal/title_sanitizer_test.rb +141 -0
  28. data/test/lib/odeon_uk/performance_test.rb +150 -0
  29. data/test/lib/odeon_uk/version_test.rb +7 -0
  30. data/test/live/api_shape_test.rb +19 -0
  31. data/test/live/integration_test.rb +21 -0
  32. data/test/live/live_helper.rb +11 -0
  33. data/test/package_test.rb +56 -0
  34. data/test/support/api_fixtures_helper.rb +16 -0
  35. data/test/support/api_shapes.rb +46 -0
  36. data/test/support/fake_api_response.rb +20 -0
  37. data/test/test_helper.rb +20 -0
  38. data/vendor/curl-impersonate/SOURCE +5 -0
  39. data/vendor/curl-impersonate/bin/curl-impersonate +0 -0
  40. data/vendor/curl-impersonate/licenses/LICENSE +21 -0
  41. data/vendor/curl-impersonate/licenses/LICENSE_BORINGSSL +238 -0
  42. data/vendor/curl-impersonate/licenses/LICENSE_BROTLI +19 -0
  43. data/vendor/curl-impersonate/licenses/LICENSE_CARES +24 -0
  44. data/vendor/curl-impersonate/licenses/LICENSE_CURL +22 -0
  45. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_GPL2 +339 -0
  46. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_GPL3 +35 -0
  47. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_LGPL3 +165 -0
  48. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_UNICODE +91 -0
  49. data/vendor/curl-impersonate/licenses/LICENSE_NGHTTP2 +23 -0
  50. data/vendor/curl-impersonate/licenses/LICENSE_NGHTTP3 +22 -0
  51. data/vendor/curl-impersonate/licenses/LICENSE_NGTCP2 +22 -0
  52. data/vendor/curl-impersonate/licenses/LICENSE_ZLIB +22 -0
  53. data/vendor/curl-impersonate/licenses/LICENSE_ZSTD +30 -0
  54. metadata +223 -0
data/odeon_uk.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'odeon_uk/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'odeon_uk'
7
+ spec.version = OdeonUk::VERSION
8
+ spec.authors = ['Andy Croll']
9
+ spec.email = ['andy@goodscary.com']
10
+ spec.description = 'Pull movie & cinema information from the ODEON Vista API'
11
+ spec.summary = "It's a scraper, but a nice one"
12
+ spec.homepage = 'https://github.com/andycroll/odeon_uk'
13
+ spec.licenses = %w(AGPL MIT)
14
+
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.files = `git ls-files`.split("\n")
18
+ spec.executables = spec.files.grep(%r{^bin/}).map { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_development_dependency 'base64'
23
+ spec.add_development_dependency 'bundler', '>= 2.0'
24
+ spec.add_development_dependency 'minitest-mock'
25
+ spec.add_development_dependency 'minitest-reporters'
26
+ spec.add_development_dependency 'rake'
27
+ spec.add_development_dependency 'simplecov', '< 1'
28
+ spec.add_development_dependency 'webmock'
29
+
30
+ spec.add_runtime_dependency 'cinebase', '~> 5.2'
31
+ end
@@ -0,0 +1,66 @@
1
+ {
2
+ "businessDate": "2026-10-24",
3
+ "showtimes": [
4
+ {
5
+ "id": "824-1001",
6
+ "filmId": "FILM-1",
7
+ "schedule": { "startsAt": "2026-10-24T09:00:00+01:00" },
8
+ "attributeIds": ["A-AD"],
9
+ "requires3dGlasses": false
10
+ },
11
+ {
12
+ "id": "824-1002",
13
+ "filmId": "FILM-1",
14
+ "schedule": { "startsAt": "2026-10-24T14:30:00+01:00" },
15
+ "attributeIds": ["A-3D", "A-IMAX"],
16
+ "requires3dGlasses": true
17
+ },
18
+ {
19
+ "id": "824-1003",
20
+ "filmId": "FILM-2",
21
+ "schedule": { "startsAt": "2026-10-24T18:00:00+01:00" },
22
+ "attributeIds": ["A-KOREAN", "A-SUB", "A-EVENT"],
23
+ "requires3dGlasses": false
24
+ },
25
+ {
26
+ "id": "824-1004",
27
+ "filmId": "FILM-3",
28
+ "schedule": { "startsAt": "2026-10-24T20:15:00+01:00" },
29
+ "attributeIds": ["A-TAMIL", "A-SUB"],
30
+ "requires3dGlasses": false
31
+ },
32
+ {
33
+ "id": "824-1005",
34
+ "filmId": "FILM-4",
35
+ "schedule": { "startsAt": "2026-10-24T10:00:00+01:00" },
36
+ "attributeIds": ["A-KIDS", "A-CAPT"],
37
+ "requires3dGlasses": false
38
+ },
39
+ {
40
+ "id": "824-1006",
41
+ "filmId": "FILM-1",
42
+ "schedule": { "startsAt": "2026-10-25T00:15:00+01:00" },
43
+ "attributeIds": [],
44
+ "requires3dGlasses": false
45
+ }
46
+ ],
47
+ "relatedData": {
48
+ "films": [
49
+ { "id": "FILM-1", "title": { "text": "The Example Film 3D" } },
50
+ { "id": "FILM-2", "title": { "text": "Made Up Concert: Live" } },
51
+ { "id": "FILM-3", "title": { "text": "Imaginary Picture (Tamil)" } },
52
+ { "id": "FILM-4", "title": { "text": "Pretend Pups" } }
53
+ ],
54
+ "attributes": [
55
+ { "id": "A-AD", "shortName": { "text": "AudioDes" }, "name": { "text": "Audio Described" } },
56
+ { "id": "A-3D", "shortName": { "text": "3D" }, "name": { "text": "3D" } },
57
+ { "id": "A-IMAX", "shortName": { "text": "IMAX" }, "name": { "text": "IMAX" } },
58
+ { "id": "A-KOREAN", "shortName": { "text": "KoreanLang" }, "name": { "text": "Korean (Audio)" } },
59
+ { "id": "A-TAMIL", "shortName": { "text": "TamilA" }, "name": { "text": "Tamil (Audio)" } },
60
+ { "id": "A-SUB", "shortName": { "text": "EnglishSub" }, "name": { "text": "English (Sub)" } },
61
+ { "id": "A-EVENT", "shortName": { "text": "EventCinem" }, "name": { "text": "Event Cinema" } },
62
+ { "id": "A-KIDS", "shortName": { "text": "ODEON Kids" }, "name": { "text": "ODEON Kids" } },
63
+ { "id": "A-CAPT", "shortName": { "text": "OpenCapt" }, "name": { "text": "Open Captioned" } }
64
+ ]
65
+ }
66
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "sites": [
3
+ {
4
+ "id": "017",
5
+ "name": { "text": "Birmingham New Street" },
6
+ "contactDetails": {
7
+ "address": { "line1": "1 Example Arcade", "line2": "New Street", "city": "Birmingham" }
8
+ }
9
+ },
10
+ {
11
+ "id": "153",
12
+ "name": { "text": "London Leicester Square" },
13
+ "contactDetails": {
14
+ "address": { "line1": "2 Example Square", "line2": "", "city": "London" }
15
+ }
16
+ },
17
+ {
18
+ "id": "824",
19
+ "name": { "text": "Brighton" },
20
+ "contactDetails": {
21
+ "address": { "line1": "3 Example Parade", "line2": "Seafront", "city": "Brighton" }
22
+ }
23
+ },
24
+ {
25
+ "id": "955",
26
+ "name": { "text": "Trafford Centre" },
27
+ "contactDetails": {
28
+ "address": { "line1": "4 Example Dome", "line2": "", "city": "Manchester" }
29
+ }
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,113 @@
1
+ require_relative '../../test_helper'
2
+
3
+ class OdeonUk::CinemaTest < Minitest::Test
4
+ def setup
5
+ @api_response = FakeApiResponse.new
6
+ end
7
+
8
+ def test_new_returns_cinema_with_string_id
9
+ cinema = OdeonUk::Cinema.new(824)
10
+
11
+ assert_instance_of OdeonUk::Cinema, cinema
12
+ assert_equal '824', cinema.id
13
+ end
14
+
15
+ def test_new_zero_pads_id
16
+ assert_equal '017', OdeonUk::Cinema.new(17).id
17
+ end
18
+
19
+ def test_all_returns_cinemas
20
+ with_fake_api do
21
+ cinemas = OdeonUk::Cinema.all
22
+
23
+ assert(cinemas.all? { |cinema| cinema.instance_of?(OdeonUk::Cinema) })
24
+ assert_equal %w(017 153 824 955), cinemas.map(&:id)
25
+ end
26
+ end
27
+
28
+ def test_adr_has_no_postcode
29
+ expected = {
30
+ street_address: '3 Example Parade',
31
+ extended_address: 'Seafront',
32
+ locality: 'Brighton',
33
+ region: nil,
34
+ postal_code: nil,
35
+ country_name: 'United Kingdom'
36
+ }
37
+
38
+ with_fake_api do
39
+ assert_equal expected, OdeonUk::Cinema.new(824).adr
40
+ end
41
+ end
42
+
43
+ def test_address_for_london
44
+ expected = {
45
+ street_address: '2 Example Square',
46
+ extended_address: '',
47
+ locality: 'London',
48
+ region: nil,
49
+ postal_code: nil,
50
+ country_name: 'United Kingdom'
51
+ }
52
+
53
+ with_fake_api do
54
+ assert_equal expected, OdeonUk::Cinema.new(153).address
55
+ end
56
+ end
57
+
58
+ def test_brand
59
+ assert_equal 'Odeon', OdeonUk::Cinema.new(824).brand
60
+ end
61
+
62
+ def test_full_name
63
+ with_fake_api do
64
+ assert_equal 'Odeon Brighton', OdeonUk::Cinema.new(824).full_name
65
+ assert_equal 'Odeon Leicester Square', OdeonUk::Cinema.new(153).full_name
66
+ end
67
+ end
68
+
69
+ def test_name_removes_london_prefix
70
+ with_fake_api do
71
+ assert_equal 'Leicester Square', OdeonUk::Cinema.new(153).name
72
+ end
73
+ end
74
+
75
+ def test_postal_code_is_empty
76
+ with_fake_api do
77
+ assert_equal '', OdeonUk::Cinema.new(824).postal_code
78
+ end
79
+ end
80
+
81
+ def test_slug
82
+ with_fake_api do
83
+ assert_equal 'odeon-brighton', OdeonUk::Cinema.new(824).slug
84
+ end
85
+ end
86
+
87
+ def test_url
88
+ with_fake_api do
89
+ assert_equal 'https://www.odeon.co.uk/cinemas/brighton/',
90
+ OdeonUk::Cinema.new(824).url
91
+ end
92
+ end
93
+
94
+ def test_url_keeps_london_prefix
95
+ with_fake_api do
96
+ assert_equal 'https://www.odeon.co.uk/cinemas/london-leicester-square/',
97
+ OdeonUk::Cinema.new(153).url
98
+ end
99
+ end
100
+
101
+ def test_url_uses_slug_that_differs_from_name
102
+ with_fake_api do
103
+ assert_equal 'https://www.odeon.co.uk/cinemas/manchester-trafford-centre/',
104
+ OdeonUk::Cinema.new(955).url
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def with_fake_api(&block)
111
+ OdeonUk::Internal::ApiResponse.stub(:new, @api_response, &block)
112
+ end
113
+ end
@@ -0,0 +1,66 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class OdeonUk::Internal::ApiResponseTest < Minitest::Test
4
+ include ApiFixturesHelper
5
+
6
+ BASE_URL = 'https://vwc.odeon.co.uk/WSVistaWebClient/ocapi/v1'.freeze
7
+ HEADERS = {
8
+ 'Authorization' => 'Bearer guest-jwt', 'Connect-Region-Code' => 'UK'
9
+ }.freeze
10
+
11
+ def setup
12
+ OdeonUk.token = 'guest-jwt'
13
+ end
14
+
15
+ def teardown
16
+ OdeonUk.token = nil
17
+ super
18
+ end
19
+
20
+ def test_sites
21
+ stub_request(:get, "#{BASE_URL}/sites")
22
+ .with(headers: HEADERS)
23
+ .to_return(status: 200, body: sites_json)
24
+
25
+ sites = api.sites
26
+
27
+ assert_kind_of Array, sites
28
+ assert_includes sites.first.keys, 'id'
29
+ end
30
+
31
+ def test_showtimes
32
+ stub_request(:get, "#{BASE_URL}/showtimes/by-business-date/2026-10-24")
33
+ .with(query: { 'siteIds' => '824' }, headers: HEADERS)
34
+ .to_return(status: 200, body: showtimes_json('824'))
35
+
36
+ showtimes = api.showtimes('824', Date.new(2026, 10, 24))
37
+
38
+ assert_equal %w(businessDate relatedData showtimes), showtimes.keys.sort
39
+ end
40
+
41
+ def test_missing_token_raises_before_request
42
+ OdeonUk.token = nil
43
+
44
+ assert_raises(OdeonUk::AuthenticationError) { api.sites }
45
+ end
46
+
47
+ def test_rejected_token_raises
48
+ stub_request(:get, "#{BASE_URL}/sites").to_return(status: 401)
49
+
50
+ assert_raises(OdeonUk::AuthenticationError) { api.sites }
51
+ end
52
+
53
+ def test_server_error_omits_body
54
+ stub_request(:get, "#{BASE_URL}/sites").to_return(status: 500, body: 'secret')
55
+
56
+ error = assert_raises(RuntimeError) { api.sites }
57
+
58
+ assert_equal 'Vista HTTP error 500', error.message
59
+ end
60
+
61
+ private
62
+
63
+ def api
64
+ OdeonUk::Internal::ApiResponse.new
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class FixtureShapeTest < Minitest::Test
4
+ include ApiFixturesHelper
5
+ include ApiShapes
6
+
7
+ def test_sites_fixture_matches_shape
8
+ assert_sites_shape(JSON.parse(sites_json))
9
+ end
10
+
11
+ def test_showtimes_fixture_matches_shape
12
+ assert_showtimes_shape(JSON.parse(showtimes_json('824')))
13
+ end
14
+ end
@@ -0,0 +1,84 @@
1
+ require_relative '../../../test_helper'
2
+ require 'base64'
3
+ require 'tmpdir'
4
+
5
+ class OdeonUk::Internal::GuestTokenTest < Minitest::Test
6
+ def setup
7
+ @directory = Dir.mktmpdir('odeon-guest-token')
8
+ end
9
+
10
+ def teardown
11
+ FileUtils.remove_entry(@directory)
12
+ OdeonUk.token = nil
13
+ end
14
+
15
+ def test_fetch_returns_token_from_page_data
16
+ token = token_expiring_at(Time.now + 3600)
17
+ curl = fake_curl("#{page_with(token)}\n200")
18
+
19
+ assert_equal token, guest_token(curl).fetch
20
+ end
21
+
22
+ def test_fetch_rejects_expired_token
23
+ curl = fake_curl("#{page_with(token_expiring_at(Time.now - 60))}\n200")
24
+
25
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
26
+ end
27
+
28
+ def test_fetch_rejects_page_without_page_data
29
+ curl = fake_curl("<html></html>\n200")
30
+
31
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
32
+ end
33
+
34
+ def test_fetch_rejects_redirect_to_queue
35
+ curl = fake_curl("\n302")
36
+
37
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
38
+ end
39
+
40
+ def test_fetch_rejects_failed_command
41
+ curl = fake_curl('', exit_status: 7)
42
+
43
+ assert_raises(OdeonUk::AuthenticationError) { guest_token(curl).fetch }
44
+ end
45
+
46
+ def test_fetch_explains_missing_executable
47
+ error = assert_raises(OdeonUk::AuthenticationError) do
48
+ guest_token(File.join(@directory, 'missing')).fetch
49
+ end
50
+
51
+ assert_match(/curl-impersonate/, error.message)
52
+ end
53
+
54
+ def test_acquire_token_sets_token
55
+ token = token_expiring_at(Time.now + 3600)
56
+
57
+ OdeonUk.acquire_token!(curl: fake_curl("#{page_with(token)}\n200"))
58
+
59
+ assert_equal token, OdeonUk.token
60
+ end
61
+
62
+ private
63
+
64
+ def guest_token(curl)
65
+ OdeonUk::Internal::GuestToken.new(curl: curl)
66
+ end
67
+
68
+ def token_expiring_at(time)
69
+ payload = Base64.urlsafe_encode64(JSON.generate('exp' => time.to_i), padding: false)
70
+ "header.#{payload}.signature"
71
+ end
72
+
73
+ def page_with(token)
74
+ data = { 'api' => { 'apiUrl' => 'https://vwc.odeon.co.uk/WSVistaWebClient', 'authToken' => token } }
75
+ "<script>\n window.initialData = #{JSON.generate(data)};\n window.pageData = [];\n</script>"
76
+ end
77
+
78
+ def fake_curl(output, exit_status: 0)
79
+ path = File.join(@directory, 'curl-impersonate')
80
+ File.write(path, "#!#{RbConfig.ruby}\nSTDOUT.write(#{output.dump})\nexit #{exit_status}\n")
81
+ File.chmod(0o755, path)
82
+ path
83
+ end
84
+ end
@@ -0,0 +1,68 @@
1
+ require_relative '../../../../../test_helper'
2
+
3
+ class OdeonUk::Internal::Parser::Api::ShowtimeTest < Minitest::Test
4
+ FILMS = {
5
+ 'HO1' => { 'title' => { 'text' => 'Jailer 2 (Tamil)' } },
6
+ 'HO2' => { 'title' => { 'text' => 'Cine-Excess 2026: Scum Of The Earth' } }
7
+ }.freeze
8
+
9
+ ATTRIBUTES = {
10
+ '1' => { 'shortName' => { 'text' => 'TamilA' }, 'name' => { 'text' => 'Tamil (Audio)' } },
11
+ '2' => { 'shortName' => { 'text' => 'EnglishSub' }, 'name' => { 'text' => 'English (Sub)' } },
12
+ '3' => { 'shortName' => { 'text' => 'IMAX' }, 'name' => { 'text' => 'IMAX' } },
13
+ '4' => { 'shortName' => { 'text' => 'OpenCapt' }, 'name' => { 'text' => 'Open Captioned' } },
14
+ '5' => { 'shortName' => { 'text' => 'Wheelchair' }, 'name' => { 'text' => 'Wheelchair Accessible' } }
15
+ }.freeze
16
+
17
+ def test_to_hash
18
+ expected = {
19
+ booking_url: 'https://www.odeon.co.uk/ticketing/seat-picker/?showtimeId=824-1',
20
+ dimension: '2d',
21
+ festival: nil,
22
+ film_name: 'Jailer 2',
23
+ language: nil,
24
+ starting_at: Time.utc(2026, 10, 24, 17, 30),
25
+ variant: []
26
+ }
27
+
28
+ assert_equal expected, parse
29
+ end
30
+
31
+ def test_3d_glasses
32
+ assert_equal '3d', parse(requires3d: true)[:dimension]
33
+ end
34
+
35
+ def test_audio_language
36
+ assert_equal 'Tamil', parse(attribute_ids: %w(1 2 3 5))[:language]
37
+ end
38
+
39
+ def test_maps_known_attributes_to_variants
40
+ assert_equal %w(subtitled imax), parse(attribute_ids: %w(1 2 3 5))[:variant]
41
+ end
42
+
43
+ def test_festival_moves_from_title_to_attribute
44
+ showtime = parse(film_id: 'HO2')
45
+
46
+ assert_equal Cinebase::Festival.new(name: 'Cine-Excess', year: 2026), showtime[:festival]
47
+ assert_equal 'Scum Of The Earth', showtime[:film_name]
48
+ end
49
+
50
+ def test_open_captions_and_subtitles_are_one_variant
51
+ assert_equal %w(subtitled), parse(attribute_ids: %w(2 4))[:variant]
52
+ end
53
+
54
+ private
55
+
56
+ def parse(attribute_ids: [], requires3d: false, film_id: 'HO1')
57
+ data = {
58
+ 'id' => '824-1',
59
+ 'filmId' => film_id,
60
+ 'schedule' => { 'startsAt' => '2026-10-24T18:30:00+01:00' },
61
+ 'attributeIds' => attribute_ids,
62
+ 'requires3dGlasses' => requires3d
63
+ }
64
+ OdeonUk::Internal::Parser::Api::Showtime.new(
65
+ data, films: FILMS, attributes: ATTRIBUTES
66
+ ).to_hash
67
+ end
68
+ end
@@ -0,0 +1,141 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class OdeonUk::Internal::TitleSanitizerTest < Minitest::Test
4
+ def test_removes_2d
5
+ assert_sanitized 'Iron Man 3', 'Iron Man 3 2D'
6
+ end
7
+
8
+ def test_removes_3d
9
+ assert_sanitized 'Iron Man 3', 'Iron Man 3 3D'
10
+ end
11
+
12
+ def test_removes_autism_prefix
13
+ assert_sanitized 'Planes', 'Autism Friendly Screening - Planes'
14
+ end
15
+
16
+ def test_bolshoi
17
+ assert_sanitized 'Bolshoi: Spartacus', 'Bolshoi - Spartacus (Live)'
18
+ end
19
+
20
+ def test_removes_cinemagic_prefix
21
+ assert_sanitized 'Echo Planet', 'Cinemagic 2013 - Echo Planet'
22
+ end
23
+
24
+ def test_globe_on_screen
25
+ assert_sanitized 'Globe: Twelfth Night', 'Globe On Screen: Twelfth Night'
26
+ end
27
+
28
+ def test_met_opera
29
+ assert_sanitized 'Met Opera: Eugene Onegin 2013', 'Met Opera - Eugene Onegin 2013'
30
+ end
31
+
32
+ def test_met_opera_season
33
+ assert_sanitized 'Met Opera: La Fanciulla del West',
34
+ 'Met Opera 2026-27: La Fanciulla del West'
35
+ end
36
+
37
+ def test_nt_live
38
+ assert_sanitized 'National Theatre: War Horse', 'NT Live - War Horse'
39
+ end
40
+
41
+ def test_national_theatre_encore
42
+ assert_sanitized 'National Theatre: Frankenstein',
43
+ 'National Theatre Live - Frankenstein (Encore Screening)'
44
+ end
45
+
46
+ def test_roh
47
+ assert_sanitized 'Royal Opera House: Carmen', 'ROH - Carmen'
48
+ end
49
+
50
+ def test_rbo_season
51
+ assert_sanitized 'Royal Ballet and Opera: Swan Lake',
52
+ 'RBO Cinema Season 2026-27: Swan Lake'
53
+ end
54
+
55
+ def test_short_rbo_season
56
+ assert_sanitized "Royal Ballet and Opera: Alice's Adventures in Wonderland",
57
+ "RBO 2026-27: Alice's Adventures in Wonderland"
58
+ end
59
+
60
+ def test_rsc
61
+ assert_sanitized 'Royal Shakespeare Company: Richard II', 'Richard II - RSC Live 2013'
62
+ end
63
+
64
+ def test_removes_ukjff_prefix
65
+ assert_sanitized 'From Cable Street To Brick Lane',
66
+ 'UKJFF - From Cable Street To Brick Lane'
67
+ end
68
+
69
+ def test_removes_singalong
70
+ assert_sanitized 'Frozen', 'Frozen Sing-a-long'
71
+ end
72
+
73
+ def test_html_ampersands
74
+ assert_sanitized 'Fast & Furious 7', 'Fast &amp; Furious 7'
75
+ end
76
+
77
+ def test_removes_language_suffix
78
+ assert_sanitized 'Jailer 2', 'Jailer 2 (Telugu)'
79
+ end
80
+
81
+ def test_removes_encore_suffix
82
+ assert_sanitized 'Avengers Endgame', 'Avengers Endgame: Encore'
83
+ end
84
+
85
+ def test_keeps_sing_along_within_title
86
+ assert_sanitized 'Bebefinn Sing-Along Movie', 'Bebefinn Sing-Along Movie'
87
+ end
88
+
89
+ def test_removes_dubbed
90
+ assert_sanitized 'Arrietty', 'Arrietty (Dubbed)'
91
+ end
92
+
93
+ def test_removes_subbed
94
+ assert_sanitized 'Arrietty', 'Arrietty (Subbed)'
95
+ end
96
+
97
+ def test_removes_english_dub
98
+ assert_sanitized 'Kung Fu Soccer', 'Kung Fu Soccer (English Dub)'
99
+ end
100
+
101
+ def test_removes_subtitle_description
102
+ assert_sanitized 'Kung Fu Soccer', 'Kung Fu Soccer (Mandarin with EN/CH subtitles)'
103
+ end
104
+
105
+ def test_removes_q_and_a
106
+ assert_sanitized 'NAZA', 'NAZA + Q&A'
107
+ end
108
+
109
+ def test_removes_in_person_q_and_a
110
+ assert_sanitized "Charlie Shaw's Revenge", "Charlie Shaw's Revenge + In-Person Q&A"
111
+ end
112
+
113
+ def test_removes_festival_prefix
114
+ assert_sanitized 'Scum Of The Earth', 'Cine-Excess 2026: Scum Of The Earth'
115
+ end
116
+
117
+ def test_removes_anniversary
118
+ assert_sanitized 'SCREAM', 'SCREAM (30th Anniversary)'
119
+ end
120
+
121
+ def test_world_tour_is_not_capitalised
122
+ assert_sanitized "ENHYPEN World Tour 'BLOOD SAGA'", "ENHYPEN WORLD TOUR 'BLOOD SAGA'"
123
+ end
124
+
125
+ def test_keeps_titles_with_their_own_subtitles
126
+ [
127
+ 'Harry Potter and the Deathly Hallows: Part 1',
128
+ 'Spider-Man: Brand New Day',
129
+ 'Hadestown: The Musical',
130
+ 'Rammstein - Live in Mexico City',
131
+ 'Ozzy & Black Sabbath: Back to the Beginning',
132
+ "BTS 'ARIRANG' IN BUENOS AIRES: LIVE VIEWING"
133
+ ].each { |title| assert_sanitized title, title }
134
+ end
135
+
136
+ private
137
+
138
+ def assert_sanitized(expected, title)
139
+ assert_equal expected, OdeonUk::Internal::TitleSanitizer.new(title).sanitized
140
+ end
141
+ end