rill-node 0.0.0-stage → 2026.11.14

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 (82) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +64 -2
  4. package/binding.gyp +31 -0
  5. package/index.d.ts +45 -0
  6. package/index.js +112 -0
  7. package/native/darwin-arm64/librill.dylib +0 -0
  8. package/native/darwin-arm64/rill-media +0 -0
  9. package/native/darwin-arm64/rill_node.node +0 -0
  10. package/native/darwin-x64/librill.dylib +0 -0
  11. package/native/darwin-x64/rill-media +0 -0
  12. package/native/darwin-x64/rill_node.node +0 -0
  13. package/native/linux-arm64-gnu/librill.so +0 -0
  14. package/native/linux-arm64-gnu/rill-media +0 -0
  15. package/native/linux-arm64-gnu/rill_node.node +0 -0
  16. package/native/linux-x64-gnu/librill.so +0 -0
  17. package/native/linux-x64-gnu/rill-media +0 -0
  18. package/native/linux-x64-gnu/rill_node.node +0 -0
  19. package/native-src/include/rill.h +90 -0
  20. package/native-src/include/rill_media.h +22 -0
  21. package/native-src/shard.yml +9 -0
  22. package/native-src/src/c_api.cr +194 -0
  23. package/native-src/src/cli.cr +637 -0
  24. package/native-src/src/rill/c_api/job.cr +336 -0
  25. package/native-src/src/rill/c_api/memory.cr +32 -0
  26. package/native-src/src/rill/c_api/status.cr +25 -0
  27. package/native-src/src/rill/c_api/sync.cr +125 -0
  28. package/native-src/src/rill/c_api/worker_pool.cr +403 -0
  29. package/native-src/src/rill/cancellation.cr +29 -0
  30. package/native-src/src/rill/client.cr +52 -0
  31. package/native-src/src/rill/cookie_jar.cr +130 -0
  32. package/native-src/src/rill/dash/manifest.cr +279 -0
  33. package/native-src/src/rill/dash/parser.cr +227 -0
  34. package/native-src/src/rill/download_options.cr +96 -0
  35. package/native-src/src/rill/download_plan.cr +27 -0
  36. package/native-src/src/rill/download_progress.cr +16 -0
  37. package/native-src/src/rill/downloader.cr +662 -0
  38. package/native-src/src/rill/errors.cr +155 -0
  39. package/native-src/src/rill/extractor.cr +18 -0
  40. package/native-src/src/rill/extractor_registry.cr +98 -0
  41. package/native-src/src/rill/extractors/bandcamp.cr +265 -0
  42. package/native-src/src/rill/extractors/dash.cr +77 -0
  43. package/native-src/src/rill/extractors/direct_media.cr +73 -0
  44. package/native-src/src/rill/extractors/generic.cr +441 -0
  45. package/native-src/src/rill/extractors/hls.cr +202 -0
  46. package/native-src/src/rill/extractors/internet_archive.cr +180 -0
  47. package/native-src/src/rill/extractors/m3u.cr +81 -0
  48. package/native-src/src/rill/extractors/media_ccc_de.cr +219 -0
  49. package/native-src/src/rill/extractors/mixcloud.cr +495 -0
  50. package/native-src/src/rill/extractors/peer_tube.cr +226 -0
  51. package/native-src/src/rill/extractors/rumble.cr +325 -0
  52. package/native-src/src/rill/extractors/soundcloud.cr +553 -0
  53. package/native-src/src/rill/extractors/spotify.cr +184 -0
  54. package/native-src/src/rill/extractors/twitch_clip.cr +273 -0
  55. package/native-src/src/rill/extractors/youtube.cr +520 -0
  56. package/native-src/src/rill/format.cr +82 -0
  57. package/native-src/src/rill/format_selector.cr +143 -0
  58. package/native-src/src/rill/hls/parser.cr +257 -0
  59. package/native-src/src/rill/hls/playlist.cr +100 -0
  60. package/native-src/src/rill/http_client.cr +503 -0
  61. package/native-src/src/rill/http_url.cr +28 -0
  62. package/native-src/src/rill/json_value.cr +66 -0
  63. package/native-src/src/rill/media.cr +151 -0
  64. package/native-src/src/rill/media_muxer.cr +333 -0
  65. package/native-src/src/rill/mime_type.cr +58 -0
  66. package/native-src/src/rill/output_file_name.cr +91 -0
  67. package/native-src/src/rill/playlist.cr +109 -0
  68. package/native-src/src/rill/request_options.cr +179 -0
  69. package/native-src/src/rill/segment_assembly.cr +70 -0
  70. package/native-src/src/rill/timestamp.cr +34 -0
  71. package/native-src/src/rill/version.cr +3 -0
  72. package/native-src/src/rill.cr +47 -0
  73. package/package.json +48 -4
  74. package/scripts/build-native.js +46 -0
  75. package/scripts/install-gyp.js +10 -0
  76. package/scripts/install.js +19 -0
  77. package/scripts/lib.js +1 -0
  78. package/scripts/libdir.js +23 -0
  79. package/scripts/package-native.js +35 -0
  80. package/scripts/platform.js +41 -0
  81. package/scripts/prepack.js +47 -0
  82. package/src/addon.c +498 -0
@@ -0,0 +1,441 @@
1
+ require "json"
2
+ require "set"
3
+ require "uri"
4
+ require "xml"
5
+
6
+ require "../errors"
7
+ require "../extractor"
8
+ require "../format"
9
+ require "../http_url"
10
+ require "../json_value"
11
+ require "../media"
12
+ require "../mime_type"
13
+ require "../timestamp"
14
+ require "./direct_media"
15
+
16
+ module Rill::Extractors
17
+ class Generic < Extractor
18
+ def name : String
19
+ "generic"
20
+ end
21
+
22
+ def matches?(url : URI) : Bool
23
+ url.scheme == "http" || url.scheme == "https"
24
+ end
25
+
26
+ def extract(url : URI, client : HTTPClient) : Media
27
+ response = client.get(url)
28
+ content_type = response.headers["Content-Type"]?.try(&.split(';').first.strip) || ""
29
+
30
+ mime = MimeType.parse(content_type)
31
+ if mime.video? || mime.audio?
32
+ return direct_media(url, response.url, mime, response.body.bytesize)
33
+ end
34
+
35
+ metadata = page_metadata(response.body, response.url)
36
+ formats = metadata.formats
37
+ if formats.empty?
38
+ raise MediaUnavailableError.new(url.to_s, "no direct media links were found")
39
+ end
40
+
41
+ Media.new(
42
+ title: metadata.title || fallback_title(url),
43
+ description: metadata.description,
44
+ webpage_url: url,
45
+ uploader: metadata.uploader || metadata.site_name,
46
+ duration_seconds: metadata.duration_seconds,
47
+ thumbnail_url: metadata.thumbnail_url,
48
+ upload_date: metadata.upload_date,
49
+ extractor: name,
50
+ formats: formats,
51
+ )
52
+ end
53
+
54
+ private def direct_media(
55
+ original_url : URI,
56
+ response_url : URI,
57
+ mime : MimeType,
58
+ size : Int64,
59
+ ) : Media
60
+ format = Format.new(
61
+ format_id: "direct",
62
+ url: response_url,
63
+ kind: mime.audio? ? FormatKind::Audio : FormatKind::Combined,
64
+ container: mime.container,
65
+ filesize: size,
66
+ note: "direct response",
67
+ )
68
+
69
+ Media.new(
70
+ title: fallback_title(original_url),
71
+ webpage_url: original_url,
72
+ extractor: name,
73
+ formats: [format],
74
+ )
75
+ end
76
+
77
+ private def page_metadata(body : String, base_url : URI) : PageMetadata
78
+ document = XML.parse_html(body)
79
+ metadata = PageMetadata.new(base_url)
80
+ first_title = nil.as(String?)
81
+
82
+ each_element(document) do |node|
83
+ case node.name.downcase
84
+ when "title"
85
+ first_title ||= node.content.strip
86
+ when "meta"
87
+ metadata.add_meta(node)
88
+ when "video"
89
+ metadata.add_video(node)
90
+ when "audio"
91
+ metadata.add_audio(node)
92
+ when "source"
93
+ metadata.add_source(node)
94
+ when "script"
95
+ if node["type"]?.try(&.downcase) == "application/ld+json"
96
+ metadata.add_structured_data(node.content)
97
+ end
98
+ end
99
+ end
100
+
101
+ metadata.finish(first_title)
102
+ metadata
103
+ rescue XML::Error
104
+ raise MediaUnavailableError.new(base_url.to_s, "page is not valid HTML")
105
+ end
106
+
107
+ private def each_element(root : XML::Node, & : XML::Node ->) : Nil
108
+ pending = root.children.select(&.element?).to_a
109
+ index = 0
110
+
111
+ while index < pending.size
112
+ node = pending[index]
113
+ index += 1
114
+
115
+ yield node
116
+
117
+ node.children.each do |child|
118
+ pending << child if child.element?
119
+ end
120
+ end
121
+ end
122
+
123
+ private def fallback_title(url : URI) : String
124
+ basename = File.basename(url.path)
125
+ stem = File.basename(basename, File.extname(basename))
126
+ decoded = URI.decode(stem)
127
+ decoded.empty? ? url.host.to_s : decoded
128
+ end
129
+
130
+ private class PageMetadata
131
+ private struct MediaLink
132
+ getter uri : URI
133
+ getter kind : FormatKind
134
+ getter note : String
135
+ getter extension : String
136
+ getter declared_type : String?
137
+
138
+ def initialize(
139
+ @uri : URI,
140
+ @kind : FormatKind,
141
+ @note : String,
142
+ @extension : String,
143
+ @declared_type : String?,
144
+ )
145
+ end
146
+ end
147
+
148
+ STRUCTURED_TYPES = %w(
149
+ VideoObject AudioObject MediaObject Movie Clip TVEpisode Episode PodcastEpisode
150
+ )
151
+
152
+ getter title : String?
153
+ getter description : String?
154
+ getter site_name : String?
155
+ getter uploader : String?
156
+ getter upload_date : String?
157
+ getter thumbnail_url : URI?
158
+ getter duration_seconds : Float64?
159
+ getter formats = [] of Format
160
+
161
+ @width : Int32?
162
+ @height : Int32?
163
+ @thumbnail_candidate : String?
164
+ @video_type : String?
165
+ @audio_type : String?
166
+ @media = [] of MediaLink
167
+ @seen = Set(String).new
168
+ @structured_data = [] of String
169
+
170
+ def initialize(@base_url : URI)
171
+ end
172
+
173
+ def add_meta(node : XML::Node) : Nil
174
+ key = node["property"]? || node["name"]?
175
+ content = node["content"]?
176
+ return unless key && content
177
+
178
+ case key.downcase
179
+ when "og:title", "twitter:title"
180
+ @title ||= content
181
+ when "og:description", "description", "twitter:description"
182
+ @description ||= content
183
+ when "og:site_name"
184
+ @site_name ||= content
185
+ when "og:image", "twitter:image"
186
+ @thumbnail_candidate ||= content
187
+ when "og:video:width"
188
+ @width ||= positive_int(content)
189
+ when "og:video:height"
190
+ @height ||= positive_int(content)
191
+ when "og:video:duration"
192
+ @duration_seconds ||= positive_float(content)
193
+ when "og:video:type"
194
+ @video_type ||= content
195
+ when "og:audio:type"
196
+ @audio_type ||= content
197
+ when "og:video", "og:video:url", "og:video:secure_url"
198
+ add_media(content, FormatKind::Combined, "og:video")
199
+ when "og:audio", "og:audio:url", "og:audio:secure_url"
200
+ add_media(content, FormatKind::Audio, "og:audio")
201
+ when "twitter:player:stream"
202
+ add_media(content, FormatKind::Combined, "twitter:stream")
203
+ end
204
+ end
205
+
206
+ def add_video(node : XML::Node) : Nil
207
+ if poster = node["poster"]?
208
+ @thumbnail_candidate ||= poster
209
+ end
210
+ if source = node["src"]?
211
+ add_media(source, FormatKind::Combined, "video")
212
+ end
213
+ end
214
+
215
+ def add_audio(node : XML::Node) : Nil
216
+ if source = node["src"]?
217
+ add_media(source, FormatKind::Audio, "audio")
218
+ end
219
+ end
220
+
221
+ def add_source(node : XML::Node) : Nil
222
+ source = node["src"]?
223
+ return unless source
224
+
225
+ type = node["type"]?
226
+ kind = if type.try(&.downcase.starts_with?("audio/"))
227
+ FormatKind::Audio
228
+ else
229
+ FormatKind::Combined
230
+ end
231
+
232
+ add_media(source, kind, "source", type)
233
+ end
234
+
235
+ def add_structured_data(text : String) : Nil
236
+ @structured_data << text
237
+ end
238
+
239
+ def finish(fallback_title : String?) : Nil
240
+ @structured_data.each { |text| apply_structured_data(text) }
241
+ @title ||= fallback_title
242
+
243
+ if url = @thumbnail_candidate
244
+ @thumbnail_url = HttpUrl.resolve(@base_url, url)
245
+ end
246
+
247
+ @media.each do |entry|
248
+ resolved = resolve_format(entry)
249
+ next unless resolved
250
+
251
+ extension, kind = resolved
252
+ audio = kind == FormatKind::Audio
253
+ @formats << Format.new(
254
+ format_id: "media-#{@formats.size + 1}",
255
+ url: entry.uri,
256
+ kind: kind,
257
+ container: extension,
258
+ width: audio ? nil : @width,
259
+ height: audio ? nil : @height,
260
+ duration_seconds: @duration_seconds,
261
+ note: entry.note,
262
+ )
263
+ end
264
+ end
265
+
266
+ private def resolve_format(entry : MediaLink) : Tuple(String, FormatKind)?
267
+ extension = entry.extension
268
+ if DirectMedia::VIDEO_EXTENSIONS.includes?(extension)
269
+ return {extension, entry.kind}
270
+ end
271
+ if DirectMedia::AUDIO_EXTENSIONS.includes?(extension)
272
+ return {extension, FormatKind::Audio}
273
+ end
274
+
275
+ declared = entry.declared_type
276
+ declared ||= entry.kind == FormatKind::Audio ? @audio_type : @video_type
277
+ mime = MimeType.parse(declared || "")
278
+ return nil unless mime.video? || mime.audio?
279
+
280
+ container = mime.container
281
+ return nil unless container
282
+
283
+ kind = mime.audio? ? FormatKind::Audio : entry.kind
284
+ {container, kind}
285
+ end
286
+
287
+ private def apply_structured_data(text : String) : Nil
288
+ value = JSON.parse(text)
289
+ structured_objects(value).each { |object| apply_structured_object(object) }
290
+ rescue JSON::ParseException
291
+ end
292
+
293
+ private def structured_objects(value : JSON::Any) : Array(Hash(String, JSON::Any))
294
+ objects = [] of Hash(String, JSON::Any)
295
+ pending = [value]
296
+
297
+ until pending.empty?
298
+ current = pending.pop
299
+ if array = current.as_a?
300
+ pending.concat(array)
301
+ elsif object = current.as_h?
302
+ objects << object
303
+ if graph = object["@graph"]?
304
+ pending << graph
305
+ end
306
+ end
307
+ end
308
+
309
+ objects
310
+ end
311
+
312
+ private def apply_structured_object(object : Hash(String, JSON::Any)) : Nil
313
+ return unless structured_type?(object["@type"]?) || object["contentUrl"]?
314
+
315
+ @title ||= JsonValue.string(object["name"]?)
316
+ @description ||= JsonValue.string(object["description"]?)
317
+ @uploader ||= structured_author(object["author"]?)
318
+ @upload_date ||= date_prefix(JsonValue.string(object["uploadDate"]?))
319
+ @duration_seconds ||= structured_duration(object["duration"]?)
320
+ @width ||= structured_dimension(object["width"]?)
321
+ @height ||= structured_dimension(object["height"]?)
322
+ @thumbnail_candidate ||= structured_thumbnail(
323
+ object["thumbnailUrl"]? || object["image"]?,
324
+ )
325
+
326
+ content_url = JsonValue.string(object["contentUrl"]?)
327
+ return unless content_url
328
+
329
+ declared_type = JsonValue.string(object["encodingFormat"]?)
330
+ declared_type = nil unless declared_type.try(&.includes?('/'))
331
+ kind = MimeType.parse(declared_type || "").audio? ? FormatKind::Audio : FormatKind::Combined
332
+ add_media(content_url, kind, "json-ld", declared_type)
333
+ end
334
+
335
+ private def structured_type?(value : JSON::Any?) : Bool
336
+ if text = value.try(&.as_s?)
337
+ return STRUCTURED_TYPES.includes?(text)
338
+ end
339
+
340
+ array = value.try(&.as_a?)
341
+ return false unless array
342
+
343
+ array.any? do |item|
344
+ text = item.as_s?
345
+ text ? STRUCTURED_TYPES.includes?(text) : false
346
+ end
347
+ end
348
+
349
+ private def structured_author(value : JSON::Any?) : String?
350
+ return nil unless value
351
+
352
+ if array = value.as_a?
353
+ array.each do |item|
354
+ name = structured_author(item)
355
+ return name if name
356
+ end
357
+ return nil
358
+ end
359
+
360
+ if object = value.as_h?
361
+ JsonValue.string(object["name"]?)
362
+ else
363
+ JsonValue.string(value)
364
+ end
365
+ end
366
+
367
+ private def structured_duration(value : JSON::Any?) : Float64?
368
+ return nil unless value
369
+
370
+ if number = JsonValue.number(value)
371
+ number if number >= 0
372
+ else
373
+ Timestamp.iso8601_seconds(JsonValue.string(value))
374
+ end
375
+ end
376
+
377
+ private def structured_dimension(value : JSON::Any?) : Int32?
378
+ if object = value.try(&.as_h?)
379
+ JsonValue.dimension(object["value"]?)
380
+ else
381
+ JsonValue.dimension(value)
382
+ end
383
+ end
384
+
385
+ private def structured_thumbnail(value : JSON::Any?) : String?
386
+ return nil unless value
387
+
388
+ if array = value.as_a?
389
+ array.each do |item|
390
+ url = structured_thumbnail(item)
391
+ return url if url
392
+ end
393
+ return nil
394
+ end
395
+
396
+ if object = value.as_h?
397
+ JsonValue.string(object["url"]?)
398
+ else
399
+ JsonValue.string(value)
400
+ end
401
+ end
402
+
403
+ private def date_prefix(value : String?) : String?
404
+ return nil unless value
405
+
406
+ value.size >= 10 ? value[0, 10] : value
407
+ end
408
+
409
+ private def positive_int(value : String) : Int32?
410
+ parsed = value.to_i?
411
+ parsed if parsed && parsed > 0
412
+ end
413
+
414
+ private def positive_float(value : String) : Float64?
415
+ parsed = value.to_f?
416
+ parsed if parsed && parsed.finite? && parsed > 0
417
+ end
418
+
419
+ private def add_media(
420
+ value : String,
421
+ kind : FormatKind,
422
+ note : String,
423
+ declared_type : String? = nil,
424
+ ) : Nil
425
+ uri = HttpUrl.resolve(@base_url, value)
426
+ return unless uri
427
+ return if @seen.includes?(uri.to_s)
428
+
429
+ extension = File.extname(uri.path).lchop('.').downcase
430
+ @seen << uri.to_s
431
+ @media << MediaLink.new(
432
+ uri: uri,
433
+ kind: kind,
434
+ note: note,
435
+ extension: extension,
436
+ declared_type: declared_type,
437
+ )
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,202 @@
1
+ require "uri"
2
+
3
+ require "../errors"
4
+ require "../extractor"
5
+ require "../format"
6
+ require "../hls/parser"
7
+ require "../hls/playlist"
8
+ require "../media"
9
+
10
+ module Rill::Extractors
11
+ class Hls < Extractor
12
+ def name : String
13
+ "hls"
14
+ end
15
+
16
+ def matches?(url : URI) : Bool
17
+ return false unless url.scheme == "http" || url.scheme == "https"
18
+
19
+ File.extname(url.path).downcase == ".m3u8"
20
+ end
21
+
22
+ def extract(url : URI, client : HTTPClient) : Media
23
+ response = client.get(url)
24
+ playlist = Rill::Hls::Parser.parse(response.body, response.url)
25
+
26
+ case playlist
27
+ when Rill::Hls::MultivariantPlaylist
28
+ extract_master(playlist, url, client)
29
+ when Rill::Hls::MediaPlaylist
30
+ extract_media(playlist, url, response.url)
31
+ else
32
+ raise ManifestError.new("unsupported HLS playlist")
33
+ end
34
+ end
35
+
36
+ private def extract_master(
37
+ playlist : Rill::Hls::MultivariantPlaylist,
38
+ url : URI,
39
+ client : HTTPClient,
40
+ ) : Media
41
+ separate = playlist.variants.any? { |variant| separate_audio?(playlist, variant) }
42
+ container = separate ? detect_container(client, playlist.variants) : nil
43
+
44
+ if separate && container.nil?
45
+ raise UnsupportedManifestError.new("cannot determine HLS container for separate audio")
46
+ end
47
+
48
+ formats = playlist.variants.map_with_index do |variant, index|
49
+ kind = separate_audio?(playlist, variant) ? FormatKind::Video : FormatKind::Combined
50
+ format_for_variant(variant, index, kind, container)
51
+ end
52
+
53
+ warnings = [] of String
54
+ if separate
55
+ audio = audio_renditions(playlist)
56
+ available = playlist.renditions.count { |rendition| rendition.type == "AUDIO" && rendition.url }
57
+ if audio.size < available
58
+ warnings << "only default audio renditions are exposed"
59
+ end
60
+
61
+ audio.each_with_index do |rendition, index|
62
+ formats << format_for_audio(rendition, index, container)
63
+ end
64
+ end
65
+
66
+ Media.new(
67
+ title: title_for(url),
68
+ webpage_url: url,
69
+ extractor: name,
70
+ formats: formats,
71
+ warnings: warnings,
72
+ )
73
+ end
74
+
75
+ private def separate_audio?(
76
+ playlist : Rill::Hls::MultivariantPlaylist,
77
+ variant : Rill::Hls::Variant,
78
+ ) : Bool
79
+ group = variant.audio_group
80
+ return false unless group
81
+
82
+ playlist.renditions.any? do |rendition|
83
+ rendition.type == "AUDIO" && rendition.group_id == group && !rendition.url.nil?
84
+ end
85
+ end
86
+
87
+ private def audio_renditions(
88
+ playlist : Rill::Hls::MultivariantPlaylist,
89
+ ) : Array(Rill::Hls::Rendition)
90
+ referenced = playlist.variants.compact_map(&.audio_group)
91
+ groups = {} of String? => Array(Rill::Hls::Rendition)
92
+
93
+ playlist.renditions.each do |rendition|
94
+ next unless rendition.type == "AUDIO"
95
+ next unless rendition.url
96
+ next unless referenced.includes?(rendition.group_id)
97
+
98
+ list = groups[rendition.group_id]? || begin
99
+ created = [] of Rill::Hls::Rendition
100
+ groups[rendition.group_id] = created
101
+ created
102
+ end
103
+ list << rendition
104
+ end
105
+
106
+ groups.values.compact_map do |group|
107
+ group.find(&.default?) || group.find(&.autoselect?) || group.first?
108
+ end
109
+ end
110
+
111
+ private def detect_container(
112
+ client : HTTPClient,
113
+ variants : Array(Rill::Hls::Variant),
114
+ ) : String?
115
+ variants.each do |variant|
116
+ response = client.get(variant.url)
117
+ playlist = Rill::Hls::Parser.parse(response.body, response.url)
118
+ next unless playlist.is_a?(Rill::Hls::MediaPlaylist)
119
+
120
+ container = media_container(playlist)
121
+ return container if container
122
+ rescue Rill::Error
123
+ next
124
+ end
125
+
126
+ nil
127
+ end
128
+
129
+ private def extract_media(
130
+ playlist : Rill::Hls::MediaPlaylist,
131
+ url : URI,
132
+ playlist_url : URI,
133
+ ) : Media
134
+ format = Format.new(
135
+ format_id: "hls",
136
+ url: playlist_url,
137
+ kind: FormatKind::Combined,
138
+ protocol: FormatProtocol::Hls,
139
+ container: media_container(playlist),
140
+ duration_seconds: playlist.duration_seconds,
141
+ note: "HLS",
142
+ )
143
+
144
+ Media.new(
145
+ title: title_for(url),
146
+ webpage_url: url,
147
+ duration_seconds: playlist.duration_seconds,
148
+ extractor: name,
149
+ formats: [format],
150
+ )
151
+ end
152
+
153
+ private def format_for_variant(
154
+ variant : Rill::Hls::Variant,
155
+ index : Int32,
156
+ kind : FormatKind,
157
+ container : String?,
158
+ ) : Format
159
+ Format.new(
160
+ format_id: "hls-#{index + 1}",
161
+ url: variant.url,
162
+ kind: kind,
163
+ protocol: FormatProtocol::Hls,
164
+ container: kind == FormatKind::Video ? container : nil,
165
+ width: variant.width,
166
+ height: variant.height,
167
+ bitrate: variant.bandwidth,
168
+ note: variant.codecs,
169
+ )
170
+ end
171
+
172
+ private def format_for_audio(
173
+ rendition : Rill::Hls::Rendition,
174
+ index : Int32,
175
+ container : String?,
176
+ ) : Format
177
+ Format.new(
178
+ format_id: "audio-#{index + 1}",
179
+ url: rendition.url.not_nil!,
180
+ kind: FormatKind::Audio,
181
+ protocol: FormatProtocol::Hls,
182
+ container: container,
183
+ language: rendition.language,
184
+ note: rendition.name,
185
+ )
186
+ end
187
+
188
+ private def media_container(playlist : Rill::Hls::MediaPlaylist) : String?
189
+ return "mp4" if playlist.map_url
190
+
191
+ extension = File.extname(playlist.segments.first.url.path).lchop('.').downcase
192
+ extension.empty? ? nil : extension
193
+ end
194
+
195
+ private def title_for(url : URI) : String
196
+ basename = File.basename(url.path)
197
+ stem = File.basename(basename, File.extname(basename))
198
+ decoded = URI.decode(stem)
199
+ decoded.empty? ? "stream" : decoded
200
+ end
201
+ end
202
+ end