hls 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +145 -0
- data/CLAUDE.md +448 -0
- data/README.md +283 -4
- data/examples/directory.rb +16 -42
- data/lib/generators/hls/install/install_generator.rb +42 -0
- data/lib/generators/hls/install/templates/application_video.rb +28 -0
- data/lib/generators/hls/install/templates/initializer.rb +14 -0
- data/lib/generators/hls/video/templates/video.rb +44 -0
- data/lib/generators/hls/video/video_generator.rb +37 -0
- data/lib/hls/application_video.rb +636 -0
- data/lib/hls/cache.rb +31 -0
- data/lib/hls/codecs.rb +94 -0
- data/lib/hls/directory.rb +38 -0
- data/lib/hls/encode_job.rb +44 -0
- data/lib/hls/input.rb +93 -0
- data/lib/hls/instrumentation.rb +40 -0
- data/lib/hls/lock.rb +50 -0
- data/lib/hls/manifest.rb +193 -0
- data/lib/hls/railtie.rb +40 -0
- data/lib/hls/state.rb +106 -0
- data/lib/hls/storage.rb +132 -0
- data/lib/hls/testing.rb +263 -0
- data/lib/hls/uploader.rb +211 -0
- data/lib/hls/version.rb +1 -1
- data/lib/hls.rb +29 -201
- data/plans/00-goal.md +112 -0
- data/plans/01-profile-dsl.md +105 -0
- data/plans/02-upload-pipeline.md +140 -0
- data/plans/03-reader-and-manifest.md +128 -0
- data/plans/04-codec-portability.md +90 -0
- data/plans/05-server-migration.md +126 -0
- data/plans/06-activestorage-adapter.md +255 -0
- metadata +36 -8
data/lib/hls/state.rb
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module HLS
|
|
8
|
+
# Sidecar state for an encoded bundle. Records the input digest,
|
|
9
|
+
# rendition list, and per-file upload status so a re-run can skip work
|
|
10
|
+
# that's already done and a crashed run can resume from the last
|
|
11
|
+
# successful upload.
|
|
12
|
+
#
|
|
13
|
+
# Lives at `<output>/.hls-state.json` next to the encoded bundle.
|
|
14
|
+
class State
|
|
15
|
+
FILENAME = ".hls-state.json"
|
|
16
|
+
|
|
17
|
+
class CorruptError < HLS::Error; end
|
|
18
|
+
|
|
19
|
+
def self.load(output_dir)
|
|
20
|
+
path = Pathname.new(output_dir).join(FILENAME)
|
|
21
|
+
new(path: path, data: read(path))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reads state JSON from disk. Returns `default_data` for a missing
|
|
25
|
+
# file (the common first-run case). Raises CorruptError when the
|
|
26
|
+
# file exists but isn't parseable — silently re-encoding on
|
|
27
|
+
# corruption would be expensive and surprising; explicit failure
|
|
28
|
+
# lets the caller decide whether to delete the sidecar and retry.
|
|
29
|
+
def self.read(path)
|
|
30
|
+
return default_data unless path.exist?
|
|
31
|
+
|
|
32
|
+
raw = JSON.parse(path.read, symbolize_names: true)
|
|
33
|
+
default_data.merge(raw)
|
|
34
|
+
rescue JSON::ParserError => e
|
|
35
|
+
raise CorruptError, "state file at #{path} is not valid JSON: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.default_data
|
|
39
|
+
{
|
|
40
|
+
input_digest: nil,
|
|
41
|
+
config_digest: nil,
|
|
42
|
+
profile: nil,
|
|
43
|
+
renditions: [],
|
|
44
|
+
encoded_at: nil,
|
|
45
|
+
uploads: {}
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_reader :path
|
|
50
|
+
|
|
51
|
+
def initialize(path:, data:)
|
|
52
|
+
@path = Pathname.new(path)
|
|
53
|
+
@data = data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def input_digest = @data[:input_digest]
|
|
57
|
+
def config_digest = @data[:config_digest]
|
|
58
|
+
def profile = @data[:profile]
|
|
59
|
+
def renditions = @data[:renditions]
|
|
60
|
+
def encoded_at = @data[:encoded_at]
|
|
61
|
+
def uploads = @data[:uploads]
|
|
62
|
+
|
|
63
|
+
# Has this output already been encoded for the given input AND
|
|
64
|
+
# profile config? A change to either invalidates the encode —
|
|
65
|
+
# bumping `audio_bitrate` or adding a rendition has to re-run
|
|
66
|
+
# ffmpeg even if the input file is byte-identical.
|
|
67
|
+
def encoded?(input_digest:, config_digest:)
|
|
68
|
+
!@data[:encoded_at].nil? &&
|
|
69
|
+
@data[:input_digest] == input_digest &&
|
|
70
|
+
@data[:config_digest] == config_digest
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Has the file at relative_key already been uploaded with the given digest?
|
|
74
|
+
def uploaded?(relative_key:, digest:)
|
|
75
|
+
upload = @data[:uploads][relative_key.to_sym]
|
|
76
|
+
!upload.nil? && upload[:digest] == digest
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def record_encode(input_digest:, config_digest:, profile:, renditions:)
|
|
80
|
+
@data[:input_digest] = input_digest
|
|
81
|
+
@data[:config_digest] = config_digest
|
|
82
|
+
@data[:profile] = profile
|
|
83
|
+
@data[:renditions] = renditions
|
|
84
|
+
@data[:encoded_at] = Time.now.utc.iso8601
|
|
85
|
+
# Content has changed; previous upload records are stale.
|
|
86
|
+
@data[:uploads] = {}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def record_upload(relative_key:, digest:, etag: nil)
|
|
90
|
+
@data[:uploads][relative_key.to_sym] = {
|
|
91
|
+
digest: digest,
|
|
92
|
+
etag: etag,
|
|
93
|
+
uploaded_at: Time.now.utc.iso8601
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def save
|
|
98
|
+
path.parent.mkpath
|
|
99
|
+
path.write(JSON.pretty_generate(@data))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_h
|
|
103
|
+
@data.dup
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/hls/storage.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module HLS
|
|
7
|
+
# The storage protocol the gem talks through to put/get objects and
|
|
8
|
+
# produce signed URLs. Profile classes attach an instance of one of
|
|
9
|
+
# these to themselves via `storage`.
|
|
10
|
+
#
|
|
11
|
+
# # Protocol
|
|
12
|
+
#
|
|
13
|
+
# storage.signing_ttl -> Integer (default TTL for presigned URLs)
|
|
14
|
+
# storage.object(key) -> Object (no I/O)
|
|
15
|
+
#
|
|
16
|
+
# object.get -> response with .body (IO-like)
|
|
17
|
+
# object.put(body:, content_type:, cache_control:) -> response with .etag
|
|
18
|
+
# object.presigned_url(:get, expires_in:) -> URL string
|
|
19
|
+
#
|
|
20
|
+
# # Built-in adapters
|
|
21
|
+
#
|
|
22
|
+
# - `HLS::Storage::S3` — default. Wraps an `Aws::S3::Bucket`.
|
|
23
|
+
# - `HLS::Storage::Memory` — in-process bucket for tests. Signed URLs
|
|
24
|
+
# are non-browser `memory://` strings but round-trip through a
|
|
25
|
+
# Manifest so you can assert on them.
|
|
26
|
+
module Storage
|
|
27
|
+
# Default storage backend: an Aws::S3::Bucket plus a signing TTL.
|
|
28
|
+
# Constructed with either a bucket name (resolved through
|
|
29
|
+
# HLS.s3_resource at first use) or a pre-built Aws::S3::Bucket
|
|
30
|
+
# (useful in tests or when the host already built one).
|
|
31
|
+
#
|
|
32
|
+
# HLS::Storage::S3.new(bucket_name: "videos", signing_ttl: 1.hour)
|
|
33
|
+
# HLS::Storage::S3.new(bucket: my_aws_bucket, signing_ttl: 60)
|
|
34
|
+
class S3
|
|
35
|
+
DEFAULT_SIGNING_TTL = 3600
|
|
36
|
+
|
|
37
|
+
attr_accessor :signing_ttl
|
|
38
|
+
attr_reader :bucket_name
|
|
39
|
+
|
|
40
|
+
def initialize(bucket_name: nil, bucket: nil, s3_resource: nil, signing_ttl: DEFAULT_SIGNING_TTL)
|
|
41
|
+
@bucket_name = bucket_name
|
|
42
|
+
@bucket = bucket
|
|
43
|
+
@s3_resource = s3_resource
|
|
44
|
+
@signing_ttl = signing_ttl
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def object(key)
|
|
48
|
+
bucket.object(key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The underlying Aws::S3::Bucket. Resolved lazily so a profile
|
|
52
|
+
# can declare `def self.storage = HLS::Storage::S3.new(bucket_name:
|
|
53
|
+
# ENV.fetch("..."))` without forcing an SDK lookup at class-load
|
|
54
|
+
# time.
|
|
55
|
+
def bucket
|
|
56
|
+
@bucket ||= begin
|
|
57
|
+
if bucket_name.to_s.empty?
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"HLS::Storage::S3 needs either a bucket_name or a pre-built bucket"
|
|
60
|
+
end
|
|
61
|
+
(@s3_resource || HLS.s3_resource).bucket(bucket_name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# In-memory bucket for tests. Backed by a hash protected by a
|
|
67
|
+
# single mutex — concurrent puts and gets across threads are safe.
|
|
68
|
+
class Memory
|
|
69
|
+
DEFAULT_SIGNING_TTL = 3600
|
|
70
|
+
|
|
71
|
+
def self.build(name: "memory", signing_ttl: DEFAULT_SIGNING_TTL, objects: {})
|
|
72
|
+
bucket = new(name: name, signing_ttl: signing_ttl)
|
|
73
|
+
objects.each { |key, body| bucket.object(key).put(body: body) }
|
|
74
|
+
bucket
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
attr_reader :name
|
|
78
|
+
attr_accessor :signing_ttl
|
|
79
|
+
|
|
80
|
+
def initialize(name:, signing_ttl: DEFAULT_SIGNING_TTL)
|
|
81
|
+
@name = name
|
|
82
|
+
@signing_ttl = signing_ttl
|
|
83
|
+
@store = {}
|
|
84
|
+
@mutex = Mutex.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def object(key)
|
|
88
|
+
Object.new(store: @store, mutex: @mutex, key: key, signing_ttl: @signing_ttl)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def keys
|
|
92
|
+
@mutex.synchronize { @store.keys }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class Object
|
|
96
|
+
attr_reader :key
|
|
97
|
+
|
|
98
|
+
def initialize(store:, mutex:, key:, signing_ttl:)
|
|
99
|
+
@store = store
|
|
100
|
+
@mutex = mutex
|
|
101
|
+
@key = key
|
|
102
|
+
@signing_ttl = signing_ttl
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def get
|
|
106
|
+
entry = @mutex.synchronize { @store[@key] }
|
|
107
|
+
raise KeyError, "no object at #{@key}" if entry.nil?
|
|
108
|
+
Response.new(body: StringIO.new(entry[:body].to_s), content_type: entry[:content_type])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def put(body:, content_type: "application/octet-stream", cache_control: nil)
|
|
112
|
+
body_str = body.respond_to?(:read) ? body.read : body.to_s
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@store[@key] = {
|
|
115
|
+
body: body_str,
|
|
116
|
+
content_type: content_type,
|
|
117
|
+
cache_control: cache_control
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
PutResponse.new(etag: %("#{Digest::MD5.hexdigest(body_str)}"))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def presigned_url(_verb = :get, expires_in: @signing_ttl, **_)
|
|
124
|
+
"memory://#{@key}?expires_in=#{expires_in}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Response = Struct.new(:body, :content_type, keyword_init: true)
|
|
129
|
+
PutResponse = Struct.new(:etag, keyword_init: true)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/hls/testing.rb
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "m3u8"
|
|
9
|
+
|
|
10
|
+
module HLS
|
|
11
|
+
# Public test helpers for verifying that an HLS profile actually
|
|
12
|
+
# produces a correct bundle when run against ffmpeg. Users of the gem
|
|
13
|
+
# can include this module in their own spec suites to write integration
|
|
14
|
+
# tests against their `app/videos/*.rb` profile classes:
|
|
15
|
+
#
|
|
16
|
+
# require "hls/testing"
|
|
17
|
+
#
|
|
18
|
+
# RSpec.describe CourseVideo do
|
|
19
|
+
# include HLS::Testing
|
|
20
|
+
#
|
|
21
|
+
# it "encodes a valid HLS bundle" do
|
|
22
|
+
# video = generate_test_video(duration: 12)
|
|
23
|
+
# output = Pathname.new(Dir.mktmpdir)
|
|
24
|
+
#
|
|
25
|
+
# profile = CourseVideo.new(input: HLS::Input.new(video), output: output)
|
|
26
|
+
# silence_ffmpeg do
|
|
27
|
+
# profile.encode!
|
|
28
|
+
# profile.poster!
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# expect(output).to be_a_valid_hls_bundle.with_variants(3)
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# All helpers shell out to ffmpeg/ffprobe (already a hard dep of the
|
|
36
|
+
# gem), so any environment that can run the gem can run these helpers.
|
|
37
|
+
module Testing
|
|
38
|
+
# Generates a deterministic test video at `path` (or a tmpdir-backed
|
|
39
|
+
# path if not given). Returns the path. The video uses ffmpeg's
|
|
40
|
+
# `testsrc` filter for video and `sine` for audio — fully
|
|
41
|
+
# self-contained, no external assets.
|
|
42
|
+
def generate_test_video(path: nil, duration: 12, width: 640, height: 360, framerate: 30, frequency: 440)
|
|
43
|
+
path = Pathname.new(path || Dir::Tmpname.create(["hls-fixture", ".mp4"]) {})
|
|
44
|
+
|
|
45
|
+
cmd = [
|
|
46
|
+
"ffmpeg", "-y", "-loglevel", "error",
|
|
47
|
+
"-f", "lavfi", "-i", "testsrc=duration=#{duration}:size=#{width}x#{height}:rate=#{framerate}",
|
|
48
|
+
"-f", "lavfi", "-i", "sine=frequency=#{frequency}:duration=#{duration}",
|
|
49
|
+
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
|
|
50
|
+
"-c:a", "aac", "-b:a", "64k",
|
|
51
|
+
"-shortest",
|
|
52
|
+
path.to_s
|
|
53
|
+
]
|
|
54
|
+
unless system(*cmd, out: File::NULL, err: File::NULL)
|
|
55
|
+
raise HLS::Error, "ffmpeg failed to generate test fixture at #{path}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Probes a media file with ffprobe and returns its parsed metadata.
|
|
62
|
+
def probe(path)
|
|
63
|
+
stdout, _stderr, status = Open3.capture3(
|
|
64
|
+
"ffprobe", "-v", "error",
|
|
65
|
+
"-select_streams", "v:0",
|
|
66
|
+
"-show_entries", "stream=width,height,codec_name",
|
|
67
|
+
"-of", "json",
|
|
68
|
+
path.to_s
|
|
69
|
+
)
|
|
70
|
+
raise HLS::Error, "ffprobe failed for #{path}" unless status.success?
|
|
71
|
+
JSON.parse(stdout)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns [width, height] for an image or video file.
|
|
75
|
+
def probe_dimensions(path)
|
|
76
|
+
stream = probe(path).fetch("streams").first or
|
|
77
|
+
raise HLS::Error, "no video stream in #{path}"
|
|
78
|
+
[stream["width"], stream["height"]]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parses an m3u8 file and returns the M3u8::Playlist.
|
|
82
|
+
def parse_playlist(path)
|
|
83
|
+
M3u8::Reader.new.read(File.read(path))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Silences stdout/stderr inside the block. Useful for hiding ffmpeg's
|
|
87
|
+
# noisy progress output during test runs. Restores streams even on
|
|
88
|
+
# exception.
|
|
89
|
+
def silence_ffmpeg
|
|
90
|
+
original_stdout = $stdout.dup
|
|
91
|
+
original_stderr = $stderr.dup
|
|
92
|
+
$stdout.reopen(File::NULL, "w")
|
|
93
|
+
$stderr.reopen(File::NULL, "w")
|
|
94
|
+
yield
|
|
95
|
+
ensure
|
|
96
|
+
$stdout.reopen(original_stdout) if original_stdout
|
|
97
|
+
$stderr.reopen(original_stderr) if original_stderr
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# RSpec matchers. Loaded automatically when RSpec is defined.
|
|
101
|
+
if defined?(RSpec::Matchers)
|
|
102
|
+
RSpec::Matchers.define :be_a_valid_hls_bundle do
|
|
103
|
+
match do |dir|
|
|
104
|
+
@dir = Pathname.new(dir)
|
|
105
|
+
@failures = []
|
|
106
|
+
|
|
107
|
+
unless @dir.directory?
|
|
108
|
+
@failures << "#{@dir} is not a directory"
|
|
109
|
+
next false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
master_path = @dir.join("index.m3u8")
|
|
113
|
+
unless master_path.exist?
|
|
114
|
+
@failures << "missing master playlist at #{master_path}"
|
|
115
|
+
next false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@master = M3u8::Reader.new.read(master_path.read)
|
|
119
|
+
if @master.items.empty?
|
|
120
|
+
@failures << "master playlist has no streams"
|
|
121
|
+
next false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Expected variant count, if specified.
|
|
125
|
+
if @expected_variant_count && @master.items.size != @expected_variant_count
|
|
126
|
+
@failures << "expected #{@expected_variant_count} variants, found #{@master.items.size}"
|
|
127
|
+
next false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Each variant playlist must exist on disk.
|
|
131
|
+
@master.items.each do |item|
|
|
132
|
+
variant_path = @dir.join(item.uri)
|
|
133
|
+
unless variant_path.exist?
|
|
134
|
+
@failures << "variant playlist missing on disk: #{item.uri}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Each variant must have segment files.
|
|
139
|
+
@master.items.each do |item|
|
|
140
|
+
variant_path = @dir.join(item.uri)
|
|
141
|
+
next unless variant_path.exist?
|
|
142
|
+
|
|
143
|
+
playlist = M3u8::Reader.new.read(variant_path.read)
|
|
144
|
+
playlist.items.each do |segment|
|
|
145
|
+
segment_path = variant_path.dirname.join(segment.segment)
|
|
146
|
+
unless segment_path.exist? && segment_path.size > 0
|
|
147
|
+
@failures << "segment missing or empty: #{item.uri} → #{segment.segment}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Posters, if specified.
|
|
153
|
+
(@expected_posters || []).each do |name|
|
|
154
|
+
poster_path = @dir.join("#{name}.jpg")
|
|
155
|
+
unless poster_path.exist? && poster_path.size > 0
|
|
156
|
+
@failures << "expected poster missing or empty: #{name}.jpg"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@failures.empty?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
chain :with_variants do |count|
|
|
164
|
+
@expected_variant_count = count
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
chain :with_posters do |*names|
|
|
168
|
+
@expected_posters = names
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
failure_message do |dir|
|
|
172
|
+
"expected #{dir} to be a valid HLS bundle, but:\n - " + @failures.join("\n - ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
failure_message_when_negated do |dir|
|
|
176
|
+
"expected #{dir} not to be a valid HLS bundle, but it was"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Asserts that the variant URIs in a master playlist resolve to
|
|
181
|
+
# clean URLs when a player fetches the master at the given URL.
|
|
182
|
+
# Catches the path-prefix-included-twice bug class:
|
|
183
|
+
#
|
|
184
|
+
# it "rewrites variant URIs correctly" do
|
|
185
|
+
# manifest = CourseVideo.manifest("phlex/forms/overview")
|
|
186
|
+
# expect(manifest.master_playlist).to resolve_variants_under(
|
|
187
|
+
# "https://app.example.com/videos/phlex/forms/overview.m3u8"
|
|
188
|
+
# )
|
|
189
|
+
# end
|
|
190
|
+
#
|
|
191
|
+
# The rule: a *relative* variant URI must resolve to a URL whose
|
|
192
|
+
# path extends the master URL's path-without-extension. If a
|
|
193
|
+
# variant URI accidentally includes the master path as a prefix,
|
|
194
|
+
# resolution against the master's parent directory loses that
|
|
195
|
+
# prefix and the resolved URL no longer starts with the master
|
|
196
|
+
# path — that's the doubling bug.
|
|
197
|
+
#
|
|
198
|
+
# Absolute variant URIs (different scheme/host) and path-absolute
|
|
199
|
+
# URIs (starting with /) are skipped — those are intentional
|
|
200
|
+
# routing decisions, not doubling.
|
|
201
|
+
#
|
|
202
|
+
# Pass `.matching(<regexp>)` to additionally assert the resolved
|
|
203
|
+
# URLs match a specific shape:
|
|
204
|
+
#
|
|
205
|
+
# .resolve_variants_under(url).matching(%r{/videos/.+/\d+\.m3u8\z})
|
|
206
|
+
RSpec::Matchers.define :resolve_variants_under do |master_url|
|
|
207
|
+
match do |playlist|
|
|
208
|
+
@master_uri = URI(master_url)
|
|
209
|
+
@master_path_without_ext = @master_uri.path.sub(/\.m3u8\z/, "")
|
|
210
|
+
@failures = []
|
|
211
|
+
|
|
212
|
+
unless playlist.respond_to?(:items)
|
|
213
|
+
@failures << "expected an M3u8::Playlist, got #{playlist.class}"
|
|
214
|
+
next false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if playlist.items.empty?
|
|
218
|
+
@failures << "master playlist has no variant streams"
|
|
219
|
+
next false
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
playlist.items.each_with_index do |item, i|
|
|
223
|
+
variant_uri = URI(item.uri)
|
|
224
|
+
resolved = (@master_uri + item.uri)
|
|
225
|
+
|
|
226
|
+
# Skip checks for absolute URIs (different host or scheme)
|
|
227
|
+
# and path-absolute URIs (starting with /). Those are
|
|
228
|
+
# explicit routing choices, not bugs.
|
|
229
|
+
relative = !variant_uri.absolute? && !item.uri.start_with?("/")
|
|
230
|
+
|
|
231
|
+
if relative && !resolved.path.start_with?(@master_path_without_ext)
|
|
232
|
+
@failures << "variant ##{i} URI #{item.uri.inspect} resolved to " \
|
|
233
|
+
"#{resolved} — its path #{resolved.path.inspect} should " \
|
|
234
|
+
"extend the master path #{@master_path_without_ext.inspect} " \
|
|
235
|
+
"but does not. This usually means the variant URI " \
|
|
236
|
+
"incorrectly includes a path prefix."
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if @expected_pattern && !resolved.to_s.match?(@expected_pattern)
|
|
240
|
+
@failures << "variant ##{i} resolved URL #{resolved.to_s.inspect} " \
|
|
241
|
+
"did not match #{@expected_pattern.inspect}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
@failures.empty?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
chain :matching do |pattern|
|
|
249
|
+
@expected_pattern = pattern
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
failure_message do
|
|
253
|
+
"expected variant URIs to resolve cleanly under #{@master_uri}, but:\n - " +
|
|
254
|
+
@failures.join("\n - ")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
failure_message_when_negated do
|
|
258
|
+
"expected variant URIs NOT to resolve cleanly under #{@master_uri}, but they did"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|