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.
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
@@ -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
@@ -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