glued 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6525daf00b7018118ea699e561d5da854e6b2c6b
4
+ data.tar.gz: 2f88d6c7f82b4e132623840697f985d0dd02ce91
5
+ SHA512:
6
+ metadata.gz: dfbbc52f1ca331675ec8e488cdd395876afc88bdc001d2623f24b48b7475d902a609cf999063663dac85051254913fffa7a0502b89fbf4a8bf3e5c27f80bed67
7
+ data.tar.gz: 71ac92849df89959ffe10b9191be2375845b32542e5be1de0405f08aa1abb5f6419b3d0c320caea76c22f4945230dd82181850f99d02ab18712c1a7941dbfc51
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,34 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ glued (0.1.0)
5
+ curb
6
+ nokogiri
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ curb (0.8.5)
12
+ diff-lcs (1.2.5)
13
+ mini_portile (0.5.2)
14
+ nokogiri (1.6.1)
15
+ mini_portile (~> 0.5.0)
16
+ observr (1.0.5)
17
+ rake (0.9.6)
18
+ rspec (2.14.1)
19
+ rspec-core (~> 2.14.0)
20
+ rspec-expectations (~> 2.14.0)
21
+ rspec-mocks (~> 2.14.0)
22
+ rspec-core (2.14.7)
23
+ rspec-expectations (2.14.5)
24
+ diff-lcs (>= 1.1.3, < 2.0)
25
+ rspec-mocks (2.14.5)
26
+
27
+ PLATFORMS
28
+ ruby
29
+
30
+ DEPENDENCIES
31
+ glued!
32
+ observr
33
+ rake
34
+ rspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) MMXIV The original author or authors
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Glued
2
+
3
+ [![Build Status](https://travis-ci.org/simongregory/glued.png?branch=master)](https://travis-ci.org/simongregory/glued)
4
+
5
+ A Ruby client to download [HDS][hds] *recorded* fragments and glue them together. If it's live content you want then you need to start coding.
6
+
7
+ The primary aim is to download video on demand content. Encrypted content will download, however it will not playback unless your player accomodates it and a license is available for the media.
8
+
9
+ [HDS][hds] is designed for live media that dynamically adapts to network conditions and avoids buffering. It uses the HTTP protocol to benefit from existing network infastructre (ie [CDN][cdn]s). It is similar to [MPEG-DASH][mpeg-dash] and [HLS][hls].
10
+
11
+ ## References
12
+
13
+ ### Specification Documents
14
+
15
+ * [Flash Media Manifest Specification][f4m-spec]
16
+ * [Flash Video Format Specification][f4v-spec]
17
+
18
+ ### Code
19
+
20
+ * [OSMF][] [httpstreaming][osmf-httpstreaming] (AS3)
21
+ * [OSMF][] [f4mClasses][osmf-f4mclasses] with [F4MLoader][osmf-f4mloader] as the entry point. (AS3)
22
+ * [HDS Union](https://github.com/AndyA/hds_union) (perl)
23
+ * [K-S-V Scripts](https://github.com/K-S-V/Scripts/blob/master/AdobeHDS.php) (php)
24
+
25
+ ### Etc
26
+
27
+ * [Getting started with HDS](http://www.thekuroko.com/http-dynamic-streaming-getting-started/)
28
+ * [Fragmented MP4](http://technology-pedia.blogspot.co.uk/2012/09/fragmented-mp4-format-fmp4-f4f-adobe.html)
29
+
30
+ * [flvtool2](https://github.com/unnu/flvtool2)
31
+ * [get-flash-videos](https://github.com/monsieurvideo/get-flash-videos)
32
+ * [rtmp-dump](http://rtmpdump.mplayerhq.hu)
33
+ * [get_iplayer](https://github.com/dinkypumpkin/get_iplayer/)
34
+
35
+ ### Questions / Answers / Guesses
36
+
37
+ A *box* is a convention within a stream of bytes. Each box starts with a header, which describes length and type, followed by the data in the box.
38
+
39
+ ### Abbreviations
40
+
41
+ HDS - HTTP Dynamic Streaming
42
+ F4M - Flash Media Manfiest
43
+ F4F - Flash Media File fragment
44
+ F4X - Flash Media Index file
45
+ F4V - H.264/AAC based content
46
+ FLV - Other flash supported codecs
47
+
48
+ ## How it works
49
+
50
+ Load the f4m manifest
51
+ - Bad url? go 💥
52
+ - Detect any alternate bitrate manifests? go 💥
53
+ - Detect a live stream? go 💥
54
+ - Automatically picks the higest bitrate stream
55
+ Builds a list of fragment urls
56
+ Download each fragment
57
+ - Fragment fails? go 💥
58
+ Glue it to the previous fragment
59
+ Repeat till done
60
+
61
+ ## License
62
+
63
+ [MIT][], see accomanying [LICENSE](LICENSE) document
64
+
65
+ [ruby]: https://www.ruby-lang.org
66
+ [hds]: http://www.adobe.com/uk/products/hds-dynamic-streaming.html "Adobe HTTP Dynamic Streaming"
67
+ [cdn]: http://en.wikipedia.org/wiki/Content_delivery_network
68
+ [hls]: http://en.wikipedia.org/wiki/HTTP_Live_Streaming
69
+ [mpeg-dash]: http://en.wikipedia.org/wiki/MPEG_DASH
70
+ [OSMF]: http://osmf.org/ "Open Source Media Framework"
71
+ [osmf-httpstreaming]: http://opensource.adobe.com/svn/opensource/osmf/trunk/framework/OSMF/org/osmf/net/httpstreaming/
72
+ [osmf-f4mclasses]: http://opensource.adobe.com/svn/opensource/osmf/trunk/framework/OSMF/org/osmf/elements/f4mClasses/
73
+ [osmf-f4mloader]: http://opensource.adobe.com/svn/opensource/osmf/trunk/framework/OSMF/org/osmf/elements/F4MLoader.as
74
+ [f4m-spec]: doc/adobe-media-manifest-specification.pdf
75
+ [f4v-spec]: doc/adobe-flash-video-file-format-spec.pdf
76
+ [MIT]: http://opensource.org/licenses/MIT
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'rspec'
2
+ require 'rspec/core/rake_task'
3
+
4
+ desc "Run all examples"
5
+ RSpec::Core::RakeTask.new(:spec) do |t|
6
+ t.rspec_opts = %w[--color]
7
+ end
8
+
9
+ task :t => [:spec]
10
+ task :default => [:spec]
data/bin/glued ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'glued'
4
+
5
+ puts %{Ruby HDS Glue #{Glued::VERSION}
6
+
7
+ Fetch it, glue it, watch it.
8
+
9
+ }
10
+
11
+ begin
12
+ Glue.new(ARGV[0])
13
+ rescue Exception => e
14
+ abort "Glue failed because: #{e.message}"
15
+ end
data/glued.watchr ADDED
@@ -0,0 +1,3 @@
1
+ watch( '.*\.rb' ) do
2
+ system 'rspec --color'
3
+ end
data/lib/glued.rb ADDED
@@ -0,0 +1,13 @@
1
+ # encoding: utf-8
2
+
3
+ require 'curb'
4
+ require 'base64'
5
+ require 'nokogiri'
6
+
7
+ require 'glued/bootstrap'
8
+ require 'glued/f4f'
9
+ require 'glued/f4m'
10
+ require 'glued/f4vio'
11
+ require 'glued/glue'
12
+ require 'glued/grabber'
13
+ require 'glued/version'
@@ -0,0 +1,196 @@
1
+ # encoding: utf-8
2
+
3
+ class Bootstrap
4
+ attr_reader :boxes
5
+
6
+ def initialize(data)
7
+ @reader = F4VIO.new(data)
8
+ @boxes = []
9
+ scan
10
+ end
11
+
12
+ #Top level
13
+ AFRA = 'afra' #Fragment random access for HTTP streaming
14
+ ABST = 'abst' #Bootstrap info for HTTP streaming
15
+ MOOV = 'moov' #Container for structural metadata
16
+ MOOF = 'moof' #Movie Fragment
17
+ MDAT = 'mdat' #Moovie data container
18
+
19
+ #Inside ABST
20
+ ASRT = 'asrt' #Segment run table box
21
+ AFRT = 'afrt' #Fragment runt table box
22
+
23
+ def segments
24
+ @boxes.first.segments
25
+ end
26
+
27
+ def fragments
28
+ @boxes.first.segment_run_tables.first.run_entry_table.first.fragments_per_segment
29
+ end
30
+
31
+ private
32
+
33
+ def scan
34
+ # Scan for 'boxes' in the stream see spec 1.3 F4V box format
35
+ until (@reader.eof?) do
36
+ box = get_box_info
37
+
38
+ case box.type
39
+ when ABST
40
+ @boxes << get_bootstrap_box(box)
41
+ when AFRA
42
+ @boxes << box
43
+ when MDAT
44
+ @boxes << box
45
+ else
46
+ break;
47
+ end
48
+ end
49
+
50
+ raise "Computer says no" if @boxes.empty?
51
+
52
+ @boxes
53
+ end
54
+
55
+ def get_box_info
56
+ pos = @reader.pos
57
+ size = @reader.int32
58
+ type = @reader.fourCC
59
+ size = @reader.int64 if size == 1 #For boxes over 4GB the size is moved here.
60
+
61
+ Header.new(pos, size, type)
62
+ end
63
+
64
+ def get_bootstrap_box(header)
65
+ # 2.11.2 Bootstrap Info box
66
+ b = BootstrapBox.new
67
+ b.header = header
68
+ b.version = @reader.byte
69
+ b.flags = @reader.int24
70
+ b.bootstrap_info_version = @reader.int32
71
+
72
+ plu = @reader.byte
73
+ b.profile = plu >> 6
74
+ b.live = (plu & 0x20) ? 1 : 0
75
+ b.update = (plu & 0x01) ? 1 : 0
76
+
77
+ b.time_scale = @reader.int32
78
+ b.current_media_time = @reader.int64
79
+ b.smpte_timecode_offset = @reader.int64
80
+ b.movie_identifier = @reader.string
81
+ b.servers = @reader.byte_ar
82
+ b.quality = @reader.byte_ar
83
+ b.drm_data = @reader.string
84
+ b.metadata = @reader.string
85
+ b.segments = @reader.byte
86
+ b.segment_run_tables = []
87
+ b.segments.times { b.segment_run_tables << get_asrt_box(get_box_info) }
88
+
89
+ raise "There should be at least one segment entry" if b.segment_run_tables.empty?
90
+
91
+ b.fragments = @reader.byte
92
+ b.fragment_run_tables = []
93
+ b.fragments.times { b.fragment_run_tables << get_afrt_box(get_box_info) }
94
+
95
+ raise "There should be at least one fragment entry" if b.fragment_run_tables.empty?
96
+
97
+ b
98
+ end
99
+
100
+ def get_asrt_box(header)
101
+ # 2.11.2.1 Segment Run Table box
102
+ raise "Unexpected segment run table box header '#{header.type}' instead of '#{ASRT}'" unless header.type == ASRT
103
+
104
+ b = RunTableBox.new
105
+ b.header = header
106
+ b.version = @reader.byte
107
+ b.flags = @reader.int24
108
+ b.quality_segment_url_modifiers = @reader.byte_ar
109
+
110
+ table = []
111
+ runs = @reader.int32
112
+
113
+ runs.times do
114
+ first_segment = @reader.int32
115
+ fragments_per_segment = @reader.int32
116
+
117
+ table << SegmentRunEntry.new(first_segment, fragments_per_segment)
118
+ end
119
+
120
+ b.run_entry_table = table
121
+ b
122
+ end
123
+
124
+ def get_afrt_box(header)
125
+ # 2.11.2.2 Fragment Run Table box
126
+ raise "Unexpected fragment run table box header '#{header.type}' instead of '#{AFRT}'" unless header.type == AFRT
127
+
128
+ b = RunTableBox.new
129
+ b.header = header
130
+ b.version = @reader.byte
131
+ b.flags = @reader.int24
132
+ b.time_scale = @reader.int32
133
+ b.quality_segment_url_modifiers = @reader.byte_ar
134
+
135
+ table = []
136
+ runs = @reader.int32
137
+
138
+ runs.times do
139
+ f = FragmentRunEntry.new
140
+ f.first_fragment = @reader.int32
141
+ f.first_fragment_timestamp = @reader.int64
142
+ f.fragment_duration = @reader.int32
143
+ f.discontinuity_indicator = @reader.byte if f.fragment_duration == 0
144
+
145
+ table << f
146
+ end
147
+
148
+ b.run_entry_table = table
149
+ b
150
+ end
151
+ end
152
+
153
+ class Header < Struct.new(:pos, :size, :type)
154
+ #pos, starting position within the byte stream
155
+ #size, number of bytes within the box
156
+ #type, descriptive type for the bytes stored in the box
157
+ end
158
+
159
+ class BootstrapBox < Struct.new(:header,
160
+ :version,
161
+ :flags,
162
+ :bootstrap_info_version,
163
+ :profile,
164
+ :live,
165
+ :update,
166
+ :time_scale,
167
+ :current_media_time,
168
+ :smpte_timecode_offset,
169
+ :movie_identifier,
170
+ :servers,
171
+ :quality,
172
+ :drm_data,
173
+ :metadata,
174
+ :segments,
175
+ :segment_run_tables,
176
+ :fragments,
177
+ :fragment_run_tables)
178
+ end
179
+
180
+ class SegmentRunEntry < Struct.new(:first_segment, :fragments_per_segment)
181
+ end
182
+
183
+ class FragmentRunEntry < Struct.new(:first_fragment,
184
+ :first_fragment_timestamp,
185
+ :fragment_duration,
186
+ :discontinuity_indicator)
187
+ end
188
+
189
+ #For Segment and Fragment boxes
190
+ class RunTableBox < Struct.new(:header,
191
+ :version,
192
+ :flags,
193
+ :time_scale,
194
+ :quality_segment_url_modifiers,
195
+ :run_entry_table)
196
+ end
data/lib/glued/f4f.rb ADDED
@@ -0,0 +1,44 @@
1
+ # encoding: utf-8
2
+
3
+ class F4F
4
+ attr_reader :boxes
5
+
6
+ def initialize(reader)
7
+ @reader = reader
8
+ @boxes = []
9
+
10
+ # Scan for boxes in the stream see spec 1.3 F4V box format
11
+ until (@reader.pos >= @reader.size) do
12
+ box = next_box
13
+ @boxes << box
14
+ @reader.pos = box.pos + box.size
15
+ end
16
+ end
17
+
18
+ def ok?
19
+ # WARNING: There are rumours that "Some moronic servers add wrong
20
+ # boxSize in header causing fragment verification to fail so we
21
+ # have to fix the boxSize before processing the fragment."
22
+
23
+ (@boxes[0].type == Bootstrap::AFRA && @boxes[1].type == Bootstrap::MOOF && @boxes[2].type == Bootstrap::MDAT)
24
+ end
25
+
26
+ def next_box
27
+ pos = @reader.pos
28
+ size = @reader.int32
29
+ type = @reader.fourCC
30
+ size = @reader.int64 if size == 1 #For boxes over 4GB the size is moved here.
31
+
32
+ header_size = @reader.pos-pos
33
+ content_size = size - header_size
34
+
35
+ F4FHeader.new(pos, size, type, @reader.pos, content_size)
36
+ end
37
+
38
+ end
39
+
40
+ class F4FHeader < Struct.new(:pos, :size, :type, :content_start, :content_size)
41
+ #pos, starting position within the byte stream
42
+ #size, number of bytes within the box
43
+ #type, descriptive type for the bytes stored in the box
44
+ end
data/lib/glued/f4m.rb ADDED
@@ -0,0 +1,44 @@
1
+ # encoding: utf-8
2
+
3
+ class F4M
4
+ NAMESPACE = 'http://ns.adobe.com/f4m/1.0'
5
+
6
+ attr_reader :duration,
7
+ :bootstrap_info,
8
+ :base_ref,
9
+ :media_filename,
10
+ :media
11
+
12
+ def initialize(url, data)
13
+ xml = Nokogiri::XML(data)
14
+
15
+ namespace = xml.root.namespace.href rescue 'not found'
16
+ raise "Invalid manifest namespace. It was #{namespace} but should have been #{NAMESPACE}" unless namespace == NAMESPACE
17
+
18
+ xml.remove_namespaces!
19
+
20
+ stream_type = xml.xpath("//streamType").first.text rescue 'unknown'
21
+ raise "Only recorded streams are supported." unless stream_type == 'recorded'
22
+
23
+ @duration = xml.xpath("//duration").first.text.to_i
24
+
25
+ b64_bootstrap_info = xml.xpath("//bootstrapInfo").first.text
26
+ @bootstrap_info = Base64.strict_decode64(b64_bootstrap_info)
27
+
28
+ media_nodes = xml.xpath("/manifest[1]/media")
29
+ @media = find_highest_bitrate(media_nodes)
30
+ @media_filename = @media.at_css('@url').value
31
+
32
+ @base_ref = url.split('/').slice(0...-1).join('/') #can be specified in xml
33
+ end
34
+
35
+ def find_highest_bitrate(list)
36
+ rates = []
37
+ list.each do |media|
38
+ br = media.at_css("@bitrate").value.to_i
39
+ rates[br] = media
40
+ end
41
+
42
+ rates.reject {|e| e.nil? }.pop
43
+ end
44
+ end
@@ -0,0 +1,47 @@
1
+ # encoding: utf-8
2
+
3
+ class F4VIO < StringIO
4
+
5
+ def byte
6
+ self.read(1).unpack('C').first
7
+ end
8
+
9
+ def int16
10
+ self.read(2).unpack('n').first
11
+ end
12
+
13
+ def int24
14
+ "\x00#{self.read(3)}".unpack('N').first
15
+ end
16
+
17
+ def int32
18
+ self.read(4).unpack('N').first
19
+ end
20
+
21
+ def int64
22
+ hi = int32
23
+ lo = int32
24
+ (hi * 4294967296) + lo
25
+ end
26
+
27
+ def fourCC
28
+ self.read(4).unpack('A4').first
29
+ end
30
+
31
+ def string
32
+ o, p = self.pos, 0
33
+ p += 1 while (self.read(1) != "\x00")
34
+
35
+ self.pos = o
36
+ str = self.read(p)
37
+ self.pos += 1
38
+
39
+ str
40
+ end
41
+
42
+ def byte_ar
43
+ ar = []
44
+ byte.times { ar << byte }
45
+ ar
46
+ end
47
+ end
data/lib/glued/glue.rb ADDED
@@ -0,0 +1,16 @@
1
+ # encoding: utf-8
2
+
3
+ class Glue
4
+
5
+ def initialize(url)
6
+ raise "Invalid manifest url '#{url}' (it should end with .f4m)" unless url.to_s =~ /\.f4m$/ #Only by convention
7
+
8
+ xml = Curl::Easy.perform(url).body
9
+ manifest = F4M.new(url, xml)
10
+ bootstrap = Bootstrap.new(manifest.bootstrap_info)
11
+ grabber = Grabber.new(manifest, bootstrap)
12
+
13
+ puts "\rComplete "
14
+ end
15
+
16
+ end
@@ -0,0 +1,91 @@
1
+ # encoding: utf-8
2
+
3
+ class Grabber
4
+ attr_reader :urls
5
+
6
+ def initialize(manifest, bootstrap, io=nil)
7
+ raise "Only one segment can be handled" if bootstrap.segments != 1 #As we've hardcoded 1 below
8
+ raise "Not enough fragments" if bootstrap.fragments < 1
9
+ raise "Too many fragments" if bootstrap.fragments > 10000 #not sure what this limit should be
10
+
11
+ @uri = "#{manifest.media_filename}.flv"
12
+ @url = "#{manifest.base_ref}/#{manifest.media_filename}Seg1-Frag"
13
+ @total_fragments = bootstrap.fragments
14
+ @urls = []
15
+ @downloaded_fragments = []
16
+ @fragments_downloaded = 0
17
+
18
+ #TODO: Track how much has already been downloaded and append from that point
19
+ raise "Aborting as the download target file '#{@uri}' already exists" if File.exist? @uri
20
+
21
+ @out = io ||= File.new(@uri, 'ab')
22
+ @out.write(flv_header(1,1))
23
+
24
+ build
25
+ end
26
+
27
+ private
28
+
29
+ #TODO: Inspect first fragment, test for audio and video, write header accordingly
30
+ def flv_header(audio, video)
31
+ #Audio and video need to be a 1 or 0
32
+ flv_header = ["464c5601050000000900000000"].pack("H*")
33
+
34
+ result = audio << 2 | video
35
+ flv_header[4] = [result].pack('C')
36
+
37
+ flv_header
38
+ end
39
+
40
+ def build
41
+ #TODO, correctly set the fragment start
42
+ fragment = 1
43
+ while fragment <= @total_fragments
44
+ @urls << "#{@url}#{fragment}"
45
+ fragment += 1
46
+ end
47
+
48
+ @urls.each { |url| download url }
49
+ end
50
+
51
+ def download url
52
+ file_name = url.split('/').pop
53
+
54
+ dl = fetch_and_report(url)
55
+
56
+ raise "Invalid content type '#{dl.content_type}' for fragment #{file_name}." unless dl.content_type == 'video/f4f'
57
+
58
+ reader = F4VIO.new(dl.body)
59
+ f4f = F4F.new(reader)
60
+
61
+ raise "Fragment did not verify" unless f4f.ok?
62
+
63
+ f4f.boxes.each do |box|
64
+ if box.type == 'mdat'
65
+ reader.pos = box.content_start
66
+ @out.write(reader.read(box.content_size))
67
+ end
68
+ end
69
+
70
+ @fragments_downloaded += 1
71
+ end
72
+
73
+ def fetch_and_report(url)
74
+ start_time = Time.now
75
+
76
+ dl = Curl::Easy.perform(url)
77
+
78
+ report(dl.body.length, Time.now-start_time)
79
+
80
+ dl
81
+ end
82
+
83
+ def report(number_of_bytes, in_seconds)
84
+ bps = (number_of_bytes*8) / in_seconds
85
+ kbps = bps/1000
86
+ mbps = bps/1000000
87
+
88
+ print "\rDownloading #{@fragments_downloaded+1}/#{@total_fragments} at #{mbps.round(2)} Mbps"
89
+ end
90
+ end
91
+
@@ -0,0 +1,7 @@
1
+ # encoding: utf-8
2
+
3
+ module Glued
4
+ VERSION = '0.1.0'
5
+ NAME = 'glued'
6
+ USER_AGENT = "#{NAME}-#{VERSION}"
7
+ end
@@ -0,0 +1,89 @@
1
+ # encoding: utf-8
2
+
3
+ require File.join(File.dirname(__FILE__), 'spec_helper')
4
+
5
+ describe Bootstrap, "parsing a bootstrap" do
6
+
7
+ def info
8
+ Base64.strict_decode64 'AAAAjWFic3QAAAAAAAACFAAAAAPoAAAAAAA+QYAAAAAAAAAAAEFXAAAAAAABAAAAGWFzcnQAAAAAAAAAAAEAAAABAAACFAEAAABGYWZydAAAAAAAAAPoAAAAAAMAAAABAAAAAAAAAAAAAB4AAAACFAAAAAAAPjoAAAAHgAAAAAAAAAAAAAAAAAAAAAAA'
9
+ end
10
+
11
+ it "decodes bootstrap info (abst) boxes" do
12
+ boot = Bootstrap.new(info)
13
+ box = boot.boxes.first
14
+
15
+ box.version.should eq(0)
16
+ box.flags.should eq(0)
17
+ box.bootstrap_info_version.should eq(532)
18
+ box.current_media_time.should eq(4080000)
19
+ box.smpte_timecode_offset.should eq(0)
20
+ box.movie_identifier.should eq('AW')
21
+ box.servers.should eq([])
22
+ box.quality.should eq([])
23
+ box.drm_data.should eq('')
24
+ box.metadata.should eq('')
25
+ end
26
+
27
+ it "errors when data is corrupt" do
28
+ expect { Bootstrap.new('Papa+LAZAR0U') }.to raise_error "Computer says no"
29
+ end
30
+
31
+ it "decodes segment run table (asrt) boxes" do
32
+ boot = Bootstrap.new(info)
33
+ box = boot.boxes.first
34
+
35
+ srt = box.segment_run_tables.first
36
+ sre = srt.run_entry_table.first
37
+
38
+ sre.first_segment.should eq(1)
39
+ sre.fragments_per_segment.should eq(532)
40
+ end
41
+
42
+ it "determines the number of segments" do
43
+ boot = Bootstrap.new(info)
44
+
45
+ boot.segments.should eq(1)
46
+ end
47
+
48
+ it "determines the number of fragments" do
49
+ boot = Bootstrap.new(info)
50
+
51
+ boot.fragments.should eq(532)
52
+ end
53
+
54
+
55
+ it "decodes fragment run table (afra) boxes" do
56
+ boot = Bootstrap.new(info)
57
+ box = boot.boxes.first
58
+
59
+ box.fragments.should eq(1)
60
+
61
+ box.fragment_run_tables.length.should eq(1)
62
+
63
+ frt = box.fragment_run_tables.first
64
+ fre = frt.run_entry_table.first
65
+
66
+ frt.run_entry_table.length.should eq(3)
67
+
68
+ fre.first_fragment.should eq(1)
69
+ end
70
+
71
+ it "errors when unexpected fragment boxes are found" do
72
+ bad_info = Base64.strict_decode64 "AAAAjWFic3QAAAAAAAACFAAAAAPoAAAAAAA+QYAAAAAAAAAAAEFXAAAAAAABAAAAGWFzcnQAAAAAAAAAAAEAAAABAAACFAEAAABGYWZ6dAAAAAAAAAPoAAAAAAMAAAABAAAAAAAAAAAAAB4AAAACFAAAAAAAPjoAAAAHgAAAAAAAAAAAAAAAAAAAAAAA"
73
+
74
+ expect {
75
+ boot = Bootstrap.new(bad_info)
76
+ }.to raise_error "Unexpected fragment run table box header 'afzt' instead of 'afrt'"
77
+ end
78
+
79
+ it "errors unless there are one or more segment table entries" do
80
+ end
81
+
82
+ it "errors when unexpected segment boxes are found" do
83
+ bad_info = Base64.strict_decode64 'AAAAjWFic3QAAAAAAAACFAAAAAPoAAAAAAA+QYAAAAAAAAAAAEFXAAAAAAABAAAAGWFzcHQAAAAAAAAAAAEAAAABAAACFAEAAABGYWZydAAAAAAAAAPoAAAAAAMAAAABAAAAAAAAAAAAAB4AAAACFAAAAAAAPjoAAAAHgAAAAAAAAAAAAAAAAAAAAAAA'
84
+
85
+ expect {
86
+ boot = Bootstrap.new(bad_info)
87
+ }.to raise_error "Unexpected segment run table box header 'aspt' instead of 'asrt'"
88
+ end
89
+ end
data/spec/f4f_spec.rb ADDED
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+
3
+ describe F4F, "Extracting Flash Media Fragments" do
4
+ end
data/spec/f4m_spec.rb ADDED
@@ -0,0 +1,58 @@
1
+ # encoding: utf-8
2
+
3
+ describe F4M, "Loading manifests" do
4
+
5
+ describe "a recorded manifest" do
6
+
7
+ before(:each) do
8
+ @manifest = F4M.new('http://cloud.org/only/fools/vod.f4m',
9
+ IO.read('spec/fixtures/vod.f4m'))
10
+ end
11
+
12
+ after(:each) do
13
+ @manifest = nil
14
+ end
15
+
16
+ it "creates a base url for fragments downloads" do
17
+ @manifest.base_ref.should eq('http://cloud.org/only/fools')
18
+ end
19
+
20
+ it "defines the total duration of the media in seconds" do
21
+ @manifest.duration.should eq(4080)
22
+ end
23
+
24
+ it "has bootstrap information" do
25
+ @manifest.bootstrap_info.should_not be_empty
26
+ end
27
+
28
+ it "defines the media filename" do
29
+ @manifest.media_filename.should eq('some-audio-video-')
30
+ end
31
+
32
+ end
33
+
34
+ describe "loading a live manifest" do
35
+ it "raises errors if a live manifest is used" do
36
+ expect {
37
+ F4M.new('http://cloud.org/and/horses/live.f4m',
38
+ IO.read('spec/fixtures/live.f4m'))
39
+ }.to raise_error "Only recorded streams are supported."
40
+ end
41
+ end
42
+
43
+ describe "loading manifests with unrecognised namespaces" do
44
+ it "raises errors with no namespaces" do
45
+ expect {
46
+ F4M.new('http://only.fools/and/horses/live.f4m',
47
+ IO.read('spec/fixtures/no_namespace.f4m'))
48
+ }.to raise_error "Invalid manifest namespace. It was not found but should have been http://ns.adobe.com/f4m/1.0"
49
+ end
50
+
51
+ it "raises errors for incompatible namespaces" do
52
+ expect {
53
+ F4M.new('http://only.fools/and/horses/live.f4m',
54
+ IO.read('spec/fixtures/bad_namespace.f4m'))
55
+ }.to raise_error "Invalid manifest namespace. It was http://ns.adobe.com/f4m/2.0 but should have been http://ns.adobe.com/f4m/1.0"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,45 @@
1
+ # encoding: utf-8
2
+
3
+ require File.join(File.dirname(__FILE__), 'spec_helper')
4
+
5
+ describe F4VIO, "Flash video binary decoding (big endian)" do
6
+
7
+ it "reads sequences of Unicode 8-bit characters (UTF-8), terminated with 0x00 (unless otherwise specified)" do
8
+ scanner = F4VIO.new "abc\x00xyz\x00"
9
+
10
+ scanner.string.should eq('abc')
11
+ scanner.string.should eq('xyz')
12
+ end
13
+
14
+ it "reads bytes" do
15
+ scanner = F4VIO.new 'hello'
16
+
17
+ scanner.byte.should eq(104)
18
+ scanner.byte.should eq(101)
19
+ scanner.byte.should eq(108)
20
+ scanner.byte.should eq(108)
21
+ scanner.byte.should eq(111)
22
+ end
23
+
24
+ it "reads 16 bit integers" do
25
+ # eg = {}
26
+ end
27
+
28
+ it "reads 24 bit integers" do
29
+ # eg = {}
30
+ end
31
+
32
+ it "reads 32 bit integers" do
33
+ # eg = {}
34
+ end
35
+
36
+ it "reads 64 bit integers" do
37
+ # eg = {}
38
+ end
39
+
40
+ it "reads 4CC, Four-character ASCII code, such as 'moov', encoded as UI32" do
41
+ scanner = F4VIO.new 'moov'
42
+ scanner.fourCC.should eq('moov')
43
+ end
44
+
45
+ end
@@ -0,0 +1,3 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns="http://ns.adobe.com/f4m/2.0">
3
+ </manifest>
@@ -0,0 +1,13 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns="http://ns.adobe.com/f4m/1.0" version="3.0">
3
+ <id>myvideo</id>
4
+ <duration>253</duration>
5
+ <mimeType>video/x-flv</mimeType>
6
+ <streamType>live</streamType>
7
+ <baseURL>http://comedy</baseURL>
8
+ <drmAdditionalHeader url="http://drm.com/mydrmadditionalheader"/>
9
+ <bootstrapInfo profile="named" url="/mybootstrapinfo" fragmentDuration="4"/>
10
+ <media url="/horses/low" bitrate="408" width="640" height="480"/>
11
+ <media url="/horses/med" bitrate="908" width="800" height="600"/>
12
+ <media url="/horses/hi" bitrate="1708" width="1920" height="1080"/>
13
+ </manifest>
@@ -0,0 +1,3 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest>
3
+ </manifest>
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest
3
+ xmlns="http://ns.adobe.com/f4m/1.0">
4
+ <id>jan2014_p40</id>
5
+ <startTime>2006-07-24T07:15:00+01:00</startTime>
6
+ <duration>4080</duration>
7
+ <mimeType>video/mp4</mimeType>
8
+ <streamType>recorded</streamType>
9
+ <deliveryType>streaming</deliveryType>
10
+ <!-- -audio_eng=128000-video=4947245 -->
11
+ <bootstrapInfo
12
+ id="boot1"
13
+ profile="named">AAAAjWFic3QAAAAAAAACFAAAAAPoAAAAAAA+QYAAAAAAAAAAAEFXAAAAAAABAAAAGWFzcnQAAAAAAAAAAAEAAAABAAACFAEAAABGYWZydAAAAAAAAAPoAAAAAAMAAAABAAAAAAAAAAAAAB4AAAACFAAAAAAAPjoAAAAHgAAAAAAAAAAAAAAAAAAAAAAA</bootstrapInfo>
14
+ <media
15
+ url="some-audio-video-"
16
+ bitrate="5075"
17
+ bootstrapInfoId="boot1"
18
+ width="1280"
19
+ height="720">
20
+ <metadata>AgAKb25NZXRhRGF0YQgAAAAAAA9tZXRhZGF0YWNyZWF0b3ICACVDb2RlU2hvcCdzIFVuaWZpZWQgU3RyZWFtaW5nIFBsYXRmb3JtAAhoYXNBdWRpbwEBAAhoYXNWaWRlbwEBAAhkdXJhdGlvbgBAr+AAAAAAAAAPYXVkaW9zYW1wbGVyYXRlAEDncAAAAAAAAA1hdWRpb2RhdGFyYXRlAEBgAAAAAAAAAAxhdWRpb2NvZGVjaWQCAARtcDRhAAZhYWNhb3QAQAAAAAAAAAAABXdpZHRoAECUAAAAAAAAAAZoZWlnaHQAQIaAAAAAAAAADXZpZGVvZGF0YXJhdGUAQLNTPrhR64UADHZpZGVvY29kZWNpZAIABEFWQzEACmF2Y3Byb2ZpbGUAQFkAAAAAAAAACGF2Y2xldmVsAEBAAAAAAAAAAAAJ</metadata>
21
+ </media>
22
+
23
+
24
+ <media url="/horses/low" bitrate="408" width="640" height="480"/>
25
+ <media url="/horses/med" bitrate="908" width="800" height="600"/>
26
+ <media url="/horses/hi" bitrate="1708" width="1920" height="1080"/>
27
+
28
+ </manifest>
data/spec/glue_spec.rb ADDED
@@ -0,0 +1,15 @@
1
+ # encoding: utf-8
2
+
3
+ require File.join(File.dirname(__FILE__), 'spec_helper')
4
+
5
+ describe "Glue" do
6
+ it "downloads hds fragments and glues them together" do
7
+ # one day it just might
8
+ end
9
+
10
+ it "raises errors when the manifest url looks doubtful" do
11
+ expect {
12
+ Glue.new('/steptoe/and/son.f5m')
13
+ }.to raise_error "Invalid manifest url '/steptoe/and/son.f5m' (it should end with .f4m)"
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ # encoding: utf-8
2
+
3
+ require File.join(File.dirname(__FILE__), 'spec_helper')
4
+
5
+ describe "grabbing fragments" do
6
+
7
+ before(:each) do
8
+ @bs = double(Bootstrap)
9
+ @f4m = double(F4M)
10
+ @io = StringIO.new
11
+ end
12
+
13
+ after(:each) do
14
+ @bs = nil
15
+ @f4m = nil
16
+ @io = nil
17
+ end
18
+
19
+ it "builds a list of url fragments" do
20
+ @f4m.stub(:base_ref).and_return('http://the.young.ones')
21
+ @f4m.stub(:media_filename).and_return('some-audio-video-')
22
+ @bs.stub(:segments).and_return(1)
23
+ @bs.stub(:fragments).and_return(20)
24
+
25
+ grabber = Grabber.new(@f4m, @bs, @io)
26
+
27
+ expect(grabber.urls.first).to eq('http://the.young.ones/some-audio-video-Seg1-Frag1')
28
+ expect(grabber.urls.last).to eq('http://the.young.ones/some-audio-video-Seg1-Frag20')
29
+ end
30
+
31
+ it "fails without the correct number of segments" do
32
+ @bs.stub(:segments).and_return(0)
33
+ expect { Grabber.new(@f4m, @bs) }.to raise_error "Only one segment can be handled"
34
+
35
+ @bs.stub(:segments).and_return(1)
36
+ @bs.stub(:fragments).and_return(0)
37
+
38
+ expect { Grabber.new(@f4m, @bs) }.to raise_error "Not enough fragments"
39
+ end
40
+
41
+ it "downloads the list of fragments" do
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+ # encoding: utf-8
2
+
3
+ $:.push File.join(File.dirname(__FILE__), "..", "lib")
4
+ $:.push File.dirname(__FILE__)
5
+
6
+ require 'glued'
7
+
8
+ require 'rspec'
9
+ require 'rspec/autorun'
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: glued
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Gregory
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-02-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: curb
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: observr
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: HDS Downloader
84
+ email: simon@helvector.org
85
+ executables:
86
+ - glued
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - bin/glued
91
+ - Gemfile
92
+ - Gemfile.lock
93
+ - glued.watchr
94
+ - lib/glued/bootstrap.rb
95
+ - lib/glued/f4f.rb
96
+ - lib/glued/f4m.rb
97
+ - lib/glued/f4vio.rb
98
+ - lib/glued/glue.rb
99
+ - lib/glued/grabber.rb
100
+ - lib/glued/version.rb
101
+ - lib/glued.rb
102
+ - LICENSE
103
+ - Rakefile
104
+ - README.md
105
+ - spec/bootstrap_spec.rb
106
+ - spec/f4f_spec.rb
107
+ - spec/f4m_spec.rb
108
+ - spec/f4vio_spec.rb
109
+ - spec/fixtures/bad_namespace.f4m
110
+ - spec/fixtures/live.f4m
111
+ - spec/fixtures/no_namespace.f4m
112
+ - spec/fixtures/vod.f4m
113
+ - spec/glue_spec.rb
114
+ - spec/grabber_spec.rb
115
+ - spec/spec_helper.rb
116
+ homepage: https://github.com/simongregory/glued
117
+ licenses:
118
+ - MIT
119
+ metadata: {}
120
+ post_install_message: |
121
+ Welcome to Glued
122
+ ================
123
+
124
+ To download HDS delivered media pass a f4m manifest to the
125
+ tool, ie:
126
+
127
+ glued http://royston.vasey.org/papa/lazarou.f4m
128
+
129
+ The flv file should then play in a media player such as VLC.
130
+ DRM is not removed. If the file has DRM it will only be
131
+ watchable with a Flash based player and a valid license.
132
+
133
+ Functionality is basic. The tool assumes you want the highest
134
+ quality media. Live content cannot be recorded. Multilevel
135
+ manifests are not supported, and bootstrap info has to be
136
+ embedded within the manifest.
137
+
138
+ If you try a manifest that doesn't work please open an issue
139
+ at https://github.com/simongregory/glue/issues and leave the
140
+ details.
141
+ rdoc_options: []
142
+ require_paths:
143
+ - lib
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - '>='
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - '>='
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubyforge_project:
157
+ rubygems_version: 2.0.14
158
+ signing_key:
159
+ specification_version: 4
160
+ summary: Downloads and joins HTTP Dynamic Stream fragments into a single media file
161
+ test_files: []