m3u8 1.8.1 → 1.9.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +89 -0
  4. data/lib/m3u8/bitrate_item.rb +2 -0
  5. data/lib/m3u8/byte_range.rb +2 -0
  6. data/lib/m3u8/cli/diff_command.rb +61 -0
  7. data/lib/m3u8/cli/inspect_command.rb +9 -1
  8. data/lib/m3u8/cli/validate_command.rb +16 -0
  9. data/lib/m3u8/cli.rb +45 -11
  10. data/lib/m3u8/content_steering_item.rb +1 -0
  11. data/lib/m3u8/date_range_item.rb +1 -0
  12. data/lib/m3u8/define_item.rb +1 -0
  13. data/lib/m3u8/discontinuity_item.rb +2 -0
  14. data/lib/m3u8/encryptable.rb +10 -0
  15. data/lib/m3u8/gap_item.rb +2 -0
  16. data/lib/m3u8/key_item.rb +1 -8
  17. data/lib/m3u8/map_item.rb +1 -0
  18. data/lib/m3u8/media_item.rb +1 -0
  19. data/lib/m3u8/part_inf_item.rb +1 -0
  20. data/lib/m3u8/part_item.rb +1 -0
  21. data/lib/m3u8/playback_start.rb +1 -0
  22. data/lib/m3u8/playlist.rb +236 -2
  23. data/lib/m3u8/playlist_item.rb +1 -0
  24. data/lib/m3u8/preload_hint_item.rb +1 -0
  25. data/lib/m3u8/reader.rb +15 -2
  26. data/lib/m3u8/rendition_report_item.rb +1 -0
  27. data/lib/m3u8/segment_item.rb +1 -0
  28. data/lib/m3u8/serializable.rb +43 -0
  29. data/lib/m3u8/server_control_item.rb +1 -0
  30. data/lib/m3u8/session_data_item.rb +1 -0
  31. data/lib/m3u8/session_key_item.rb +1 -8
  32. data/lib/m3u8/skip_item.rb +1 -0
  33. data/lib/m3u8/time_item.rb +1 -0
  34. data/lib/m3u8/variable_resolver.rb +83 -0
  35. data/lib/m3u8/version.rb +1 -1
  36. data/lib/m3u8.rb +2 -0
  37. data/spec/lib/m3u8/cli/diff_command_spec.rb +49 -0
  38. data/spec/lib/m3u8/cli/inspect_command_spec.rb +12 -0
  39. data/spec/lib/m3u8/cli/validate_command_spec.rb +17 -1
  40. data/spec/lib/m3u8/cli_spec.rb +47 -1
  41. data/spec/lib/m3u8/conformance_spec.rb +123 -0
  42. data/spec/lib/m3u8/reader_spec.rb +35 -0
  43. data/spec/lib/m3u8/round_trip_spec.rb +110 -0
  44. data/spec/lib/m3u8/serializable_spec.rb +133 -0
  45. data/spec/lib/m3u8/time_item_spec.rb +13 -0
  46. data/spec/lib/m3u8/variable_resolver_spec.rb +104 -0
  47. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00157be29743342bb6156539b9fca6a1e67ae9ae60e74cab04eebeda10a888ec
4
- data.tar.gz: f82b069b8fef0ce72694b11640431b25ea04caa7616ee69ab6c56e9f9029fdc3
3
+ metadata.gz: 6aa8d6835f0eba8dae6c9cd066c09c184e9a940cd4623dc6a8820b3ca3194c03
4
+ data.tar.gz: 372db49600b9957e662a061d3458a28a992c0050f13488db1ad621b6f2e9181e
5
5
  SHA512:
6
- metadata.gz: 659a5a703d0a00887b9dc724bb252aeb56fb44010756772f8667b62ebd77b34b6b3a4f647c0c6aaf46cdfbfafbdf5173b59db2f1a84efa34fd4c5b4aafbab732
7
- data.tar.gz: 07a539694c4231895fd0f7eebfb18f860c0dbbcbdd0f63e31b50ce7485937dc84fcac198ad443f9f6591bed7c17d32839bae7aba3904e452781e98520f21153c
6
+ metadata.gz: bc706ed5149e62a8dbdfc57fc351a8c2fcadbefc91731621446ec08d958a3f22e56490e48f5e2dc983d829d0c208fec38ca504f8f1d76334fd0387730b7be634
7
+ data.tar.gz: 1779b54ffacfe7ca0a25a4091ed837f3c7c3c40b6ff7dd8bd8e780614bf714e036434fa537db35dbc653f69e8b980e64a52055c90de61f66730ead28561d1321
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ **1.9.0**
2
+
3
+ * Added `to_h`, `as_json`, and `to_json` to `Playlist` and all item
4
+ classes, with `Playlist.from_h` to rebuild a playlist from a Hash or
5
+ parsed JSON. Each serialized item carries an `item_type` key.
6
+ * Added a strict parsing mode via `Playlist.read(input, strict: true)`,
7
+ plus a `Playlist#unknown_tags` accessor that collects unrecognized
8
+ `#EXT` tags instead of silently dropping them.
9
+ * Added `Playlist#resolve_variables` to apply `EXT-X-DEFINE` variable
10
+ substitution (`NAME`/`VALUE`, `IMPORT`, and `QUERYPARAM`).
11
+ * Added `Playlist#warnings` reporting non-fatal conformance advisories:
12
+ protocol-version compatibility and Low-Latency HLS recommendations.
13
+ The `validate` CLI command prints them under a `Warnings:` heading.
14
+ * Added a `--json` option to the `inspect` CLI command and a new `diff`
15
+ command for comparing two playlists.
16
+ * Fixed a missing `require 'time'` that raised `NoMethodError` when
17
+ parsing a playlist containing `EXT-X-PROGRAM-DATE-TIME` without the
18
+ `time` library already loaded.
19
+
20
+ ***
21
+
1
22
  **1.8.1**
2
23
 
3
24
  * Merged pull request #59 from [marocchino](https://github.com/marocchino).
data/README.md CHANGED
@@ -70,6 +70,13 @@ Reads from stdin when no file is given:
70
70
  $ cat playlist.m3u8 | m3u8 inspect
71
71
  ```
72
72
 
73
+ Pass `--json` to emit the playlist as JSON instead of the summary:
74
+
75
+ ```
76
+ $ m3u8 inspect --json master.m3u8
77
+ {"master":true,"version":null,...}
78
+ ```
79
+
73
80
  ### Validate
74
81
 
75
82
  Check playlist validity (exit 0 for valid, 1 for invalid):
@@ -83,6 +90,19 @@ Invalid
83
90
  - Playlist contains both master and media items
84
91
  ```
85
92
 
93
+ ### Diff
94
+
95
+ Show structural differences between two playlists (exit 0 when identical,
96
+ 1 when they differ):
97
+
98
+ ```
99
+ $ m3u8 diff old.m3u8 new.m3u8
100
+ version: 4 => 7
101
+ target: 12 => 6
102
+ items[2].duration: 5.0 => 6.0
103
+ items[5]: added SegmentItem
104
+ ```
105
+
86
106
  ## Usage (Builder DSL)
87
107
 
88
108
  `Playlist.build` provides a block-based DSL for concise playlist construction. It supports two forms:
@@ -487,6 +507,19 @@ The following validations are performed:
487
507
 
488
508
  `valid?` delegates to `errors.empty?` and both are recomputed on each call.
489
509
 
510
+ Separately, `warnings` returns non-fatal conformance advisories that do not
511
+ affect `valid?`: protocol-version compatibility (e.g. `EXT-X-MAP` requires
512
+ version 6) and Low-Latency HLS recommendations (`PART-HOLD-BACK` and
513
+ `CAN-SKIP-UNTIL` thresholds, required `EXT-X-PART-INF`):
514
+
515
+ ```ruby
516
+ playlist.warnings
517
+ # => ["EXT-X-MAP requires version 6 (version 5)"]
518
+ ```
519
+
520
+ The `m3u8 validate` command prints these under a `Warnings:` heading after
521
+ the validity result.
522
+
490
523
  ## Usage (parsing playlists)
491
524
 
492
525
  ```ruby
@@ -496,6 +529,34 @@ playlist.master?
496
529
  # => true
497
530
  ```
498
531
 
532
+ By default, unrecognized `#EXT` tags are ignored and collected in
533
+ `unknown_tags`. Pass `strict: true` to raise on the first one instead:
534
+
535
+ ```ruby
536
+ playlist = M3u8::Playlist.read(file)
537
+ playlist.unknown_tags
538
+ # => ["#EXT-X-FUTURE-TAG:1"]
539
+
540
+ M3u8::Playlist.read(file, strict: true)
541
+ # => raises M3u8::InvalidPlaylistError on an unsupported tag
542
+ ```
543
+
544
+ `EXT-X-DEFINE` variables are preserved verbatim on parse. Call
545
+ `resolve_variables` to expand `{$NAME}` references (in URIs and string
546
+ attribute values) into a new playlist:
547
+
548
+ ```ruby
549
+ resolved = playlist.resolve_variables
550
+ # IMPORT and QUERYPARAM definitions take their values from arguments:
551
+ resolved = playlist.resolve_variables(
552
+ imported: { 'host' => 'https://cdn.example.com' },
553
+ query: { 'token' => 'abc123' }
554
+ )
555
+ ```
556
+
557
+ Resolving raises `M3u8::InvalidPlaylistError` for an undefined reference
558
+ or a missing IMPORT/QUERYPARAM value.
559
+
499
560
  Query playlist properties:
500
561
 
501
562
  ```ruby
@@ -555,6 +616,34 @@ playlist.part_inf.part_target
555
616
 
556
617
  M3u8::Reader is the class that handles parsing if you want more control over the process.
557
618
 
619
+ ## Serialization (Hash and JSON)
620
+
621
+ Convert a playlist (and every item) to a plain Hash or JSON for interop with
622
+ other tooling, and rebuild a playlist from that Hash or JSON:
623
+
624
+ ```ruby
625
+ playlist = M3u8::Playlist.read(File.open('spec/fixtures/master.m3u8'))
626
+
627
+ hash = playlist.to_h
628
+ # => { master: true, version: nil, ..., items: [{ ..., item_type: "PlaylistItem" }] }
629
+
630
+ json = playlist.to_json
631
+ # => "{\"master\":true,...}"
632
+
633
+ # Each item also responds to to_h / as_json / to_json
634
+ playlist.playlists.first.to_h
635
+ # => { program_id: "1", codecs: "avc1.640028,mp4a.40.2", bandwidth: 5_042_000, ... }
636
+
637
+ # Rebuild from a Hash or from parsed JSON
638
+ M3u8::Playlist.from_h(hash)
639
+ M3u8::Playlist.from_h(JSON.parse(json))
640
+ ```
641
+
642
+ Each item Hash carries an `:item_type` key identifying its class, which
643
+ `from_h` uses to reconstruct the correct item. Defining `to_json`/`as_json`
644
+ overrides the generic implementations that the `json` library and
645
+ ActiveSupport otherwise provide for these objects.
646
+
558
647
  ## Codec string generation
559
648
 
560
649
  Generate the codec string based on audio and video codec options without dealing with a playlist instance:
@@ -4,6 +4,8 @@ module M3u8
4
4
  # BitrateItem represents an EXT-X-BITRATE tag that indicates the
5
5
  # approximate bitrate of the following media segments in kbps.
6
6
  class BitrateItem
7
+ include Serializable
8
+
7
9
  # @return [Integer, nil] approximate bitrate in kbps
8
10
  attr_accessor :bitrate
9
11
 
@@ -3,6 +3,8 @@
3
3
  module M3u8
4
4
  # ByteRange represents sub range of a resource
5
5
  class ByteRange
6
+ include Serializable
7
+
6
8
  # @return [Integer, nil] number of bytes
7
9
  # @return [Integer, nil] start offset in bytes
8
10
  attr_accessor :length, :start
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ class CLI
5
+ # DiffCommand reports structural differences between two playlists
6
+ # by comparing their normalized Hash representations.
7
+ class DiffCommand
8
+ def initialize(first, second, stdout)
9
+ @first = first.to_h
10
+ @second = second.to_h
11
+ @stdout = stdout
12
+ end
13
+
14
+ def run
15
+ if @first == @second
16
+ @stdout.puts 'Identical'
17
+ return 0
18
+ end
19
+
20
+ diff_hash('', without_items(@first), without_items(@second))
21
+ diff_items
22
+ 1
23
+ end
24
+
25
+ private
26
+
27
+ def without_items(hash)
28
+ hash.except(:items)
29
+ end
30
+
31
+ def diff_items
32
+ first = @first[:items]
33
+ second = @second[:items]
34
+ (0...[first.size, second.size].max).each do |index|
35
+ diff_item(index, first[index], second[index])
36
+ end
37
+ end
38
+
39
+ def diff_item(index, first, second)
40
+ return if first == second
41
+
42
+ if first.nil?
43
+ @stdout.puts " items[#{index}]: added #{second[:item_type]}"
44
+ elsif second.nil?
45
+ @stdout.puts " items[#{index}]: removed #{first[:item_type]}"
46
+ else
47
+ diff_hash("items[#{index}].", first, second)
48
+ end
49
+ end
50
+
51
+ def diff_hash(prefix, first, second)
52
+ (first.keys | second.keys).each do |key|
53
+ next if first[key] == second[key]
54
+
55
+ @stdout.puts " #{prefix}#{key}: " \
56
+ "#{first[key].inspect} => #{second[key].inspect}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -7,12 +7,15 @@ module M3u8
7
7
  MEDIA_WIDTH = 12
8
8
  MASTER_WIDTH = 23
9
9
 
10
- def initialize(playlist, stdout)
10
+ def initialize(playlist, stdout, json: false)
11
11
  @playlist = playlist
12
12
  @stdout = stdout
13
+ @json = json
13
14
  end
14
15
 
15
16
  def run
17
+ return print_json if @json
18
+
16
19
  if @playlist.master?
17
20
  print_master
18
21
  else
@@ -23,6 +26,11 @@ module M3u8
23
26
 
24
27
  private
25
28
 
29
+ def print_json
30
+ @stdout.puts @playlist.to_json
31
+ 0
32
+ end
33
+
26
34
  def print_media
27
35
  field 'Type', 'Media', MEDIA_WIDTH
28
36
  field 'Version', @playlist.version, MEDIA_WIDTH
@@ -10,6 +10,14 @@ module M3u8
10
10
  end
11
11
 
12
12
  def run
13
+ code = report_validity
14
+ report_warnings
15
+ code
16
+ end
17
+
18
+ private
19
+
20
+ def report_validity
13
21
  if @playlist.valid?
14
22
  @stdout.puts 'Valid'
15
23
  0
@@ -19,6 +27,14 @@ module M3u8
19
27
  1
20
28
  end
21
29
  end
30
+
31
+ def report_warnings
32
+ warnings = @playlist.warnings
33
+ return if warnings.empty?
34
+
35
+ @stdout.puts 'Warnings:'
36
+ warnings.each { |warning| @stdout.puts " - #{warning}" }
37
+ end
22
38
  end
23
39
  end
24
40
  end
data/lib/m3u8/cli.rb CHANGED
@@ -3,12 +3,13 @@
3
3
  require 'optparse'
4
4
  require_relative 'cli/inspect_command'
5
5
  require_relative 'cli/validate_command'
6
+ require_relative 'cli/diff_command'
6
7
 
7
8
  module M3u8
8
9
  # CLI provides a command-line interface for inspecting and validating
9
10
  # m3u8 playlists
10
11
  class CLI
11
- COMMANDS = %w[inspect validate].freeze
12
+ COMMANDS = %w[inspect validate diff].freeze
12
13
 
13
14
  def self.run(argv, stdin, stdout, stderr)
14
15
  new(argv, stdin, stdout, stderr).run
@@ -32,12 +33,21 @@ module M3u8
32
33
  private
33
34
 
34
35
  def parse_global_options
35
- @parser = OptionParser.new do |opts|
36
+ @parser = build_parser
37
+ @exit_code = catch(:exit) do
38
+ @parser.order!(@argv)
39
+ nil
40
+ end
41
+ end
42
+
43
+ def build_parser
44
+ OptionParser.new do |opts|
36
45
  opts.banner = 'Usage: m3u8 <command> [options] [file]'
37
46
  opts.separator ''
38
47
  opts.separator 'Commands:'
39
- opts.separator ' inspect Show playlist metadata'
48
+ opts.separator ' inspect Show playlist metadata (--json for JSON)'
40
49
  opts.separator ' validate Check playlist validity'
50
+ opts.separator ' diff Show differences between two playlists'
41
51
  opts.separator ''
42
52
  opts.on('-v', '--version', 'Show version') do
43
53
  @stdout.puts M3u8::VERSION
@@ -48,11 +58,6 @@ module M3u8
48
58
  throw :exit, 0
49
59
  end
50
60
  end
51
-
52
- @exit_code = catch(:exit) do
53
- @parser.order!(@argv)
54
- nil
55
- end
56
61
  end
57
62
 
58
63
  def dispatch
@@ -63,24 +68,53 @@ module M3u8
63
68
  return usage_error("unknown command: #{command}") \
64
69
  unless COMMANDS.include?(command)
65
70
 
71
+ return run_diff if command == 'diff'
72
+
73
+ run_single(command)
74
+ end
75
+
76
+ def run_single(command)
77
+ json = !@argv.delete('--json').nil?
78
+ if json && command != 'inspect'
79
+ return usage_error('--json is only supported for inspect')
80
+ end
81
+
66
82
  input = resolve_input
67
83
  return 2 unless input
68
84
 
69
85
  playlist = parse_playlist(input)
70
86
  return 2 unless playlist
71
87
 
72
- execute_command(command, playlist)
88
+ execute_command(command, playlist, json)
73
89
  end
74
90
 
75
- def execute_command(command, playlist)
91
+ def execute_command(command, playlist, json)
76
92
  case command
77
93
  when 'inspect'
78
- InspectCommand.new(playlist, @stdout).run
94
+ InspectCommand.new(playlist, @stdout, json: json).run
79
95
  when 'validate'
80
96
  ValidateCommand.new(playlist, @stdout).run
81
97
  end
82
98
  end
83
99
 
100
+ def run_diff
101
+ first = @argv.shift
102
+ second = @argv.shift
103
+ return usage_error('diff requires two files') if first.nil? || second.nil?
104
+
105
+ playlists = [parse_file(first), parse_file(second)]
106
+ return 2 if playlists.any?(&:nil?)
107
+
108
+ DiffCommand.new(playlists[0], playlists[1], @stdout).run
109
+ end
110
+
111
+ def parse_file(path)
112
+ input = read_file(path)
113
+ return if input.nil?
114
+
115
+ parse_playlist(input)
116
+ end
117
+
84
118
  def resolve_input
85
119
  file = @argv.shift
86
120
  if file
@@ -5,6 +5,7 @@ module M3u8
5
5
  # indicates a Content Steering Manifest for dynamic pathway selection.
6
6
  class ContentSteeringItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include AttributeFormatter
9
10
 
10
11
  # @return [String, nil] steering manifest server URI
@@ -4,6 +4,7 @@ module M3u8
4
4
  # DateRangeItem represents a #EXT-X-DATERANGE tag
5
5
  class DateRangeItem
6
6
  extend M3u8
7
+ include Serializable
7
8
  include AttributeFormatter
8
9
 
9
10
  # @return [String, nil] unique date range identifier
@@ -6,6 +6,7 @@ module M3u8
6
6
  # exclusive modes: NAME/VALUE, IMPORT, or QUERYPARAM.
7
7
  class DefineItem
8
8
  extend M3u8
9
+ include Serializable
9
10
 
10
11
  # @return [String, nil] variable name
11
12
  # @return [String, nil] variable value
@@ -4,6 +4,8 @@ module M3u8
4
4
  # DiscontinuityItem represents a EXT-X-DISCONTINUITY tag to indicate a
5
5
  # discontinuity between the SegmentItems that proceed and follow it.
6
6
  class DiscontinuityItem
7
+ include Serializable
8
+
7
9
  # Render as an m3u8 EXT-X-DISCONTINUITY tag.
8
10
  # @return [String]
9
11
  def to_s
@@ -25,6 +25,16 @@ module M3u8
25
25
  base.send :attr_accessor, :key_format_versions
26
26
  end
27
27
 
28
+ # Initialize from HLS attribute names or normalized symbol keys,
29
+ # storing only the normalized keys.
30
+ # @param params [Hash] attribute key-value pairs
31
+ def initialize(params = {})
32
+ convert_key_names(params).each do |key, value|
33
+ value = params[key] if value.nil?
34
+ instance_variable_set("@#{key}", value)
35
+ end
36
+ end
37
+
28
38
  # Render encryption attributes as a comma-separated string.
29
39
  # @return [String]
30
40
  def attributes_to_s
data/lib/m3u8/gap_item.rb CHANGED
@@ -5,6 +5,8 @@ module M3u8
5
5
  # to which it applies does not contain media data and should not be
6
6
  # loaded by clients.
7
7
  class GapItem
8
+ include Serializable
9
+
8
10
  # Render as an m3u8 EXT-X-GAP tag.
9
11
  # @return [String]
10
12
  def to_s
data/lib/m3u8/key_item.rb CHANGED
@@ -5,14 +5,7 @@ module M3u8
5
5
  class KeyItem
6
6
  include Encryptable
7
7
  extend M3u8
8
-
9
- # @param params [Hash] attribute key-value pairs
10
- def initialize(params = {})
11
- options = convert_key_names(params)
12
- options.merge(params).each do |key, value|
13
- instance_variable_set("@#{key}", value)
14
- end
15
- end
8
+ include Serializable
16
9
 
17
10
  # Parse an EXT-X-KEY tag.
18
11
  # @param text [String] raw tag line
data/lib/m3u8/map_item.rb CHANGED
@@ -5,6 +5,7 @@ module M3u8
5
5
  # Initialization Section
6
6
  class MapItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include M3u8
9
10
  include AttributeFormatter
10
11
 
@@ -4,6 +4,7 @@ module M3u8
4
4
  # MediaItem represents a set of EXT-X-MEDIA attributes
5
5
  class MediaItem
6
6
  extend M3u8
7
+ include Serializable
7
8
  include AttributeFormatter
8
9
 
9
10
  # @return [String, nil] media type (AUDIO, VIDEO, etc.)
@@ -5,6 +5,7 @@ module M3u8
5
5
  # information about partial segments in the playlist.
6
6
  class PartInfItem
7
7
  extend M3u8
8
+ include Serializable
8
9
 
9
10
  # @return [Float, nil] partial segment target duration
10
11
  attr_accessor :part_target
@@ -5,6 +5,7 @@ module M3u8
5
5
  # segments.
6
6
  class PartItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include M3u8
9
10
  include AttributeFormatter
10
11
 
@@ -4,6 +4,7 @@ module M3u8
4
4
  # PlaybackStart represents a #EXT-X-START tag and attributes
5
5
  class PlaybackStart
6
6
  extend M3u8
7
+ include Serializable
7
8
  include AttributeFormatter
8
9
 
9
10
  # @return [Float, nil] time offset in seconds