├── .gitignore ├── .rbenv-version ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── hlspider ├── hlspider.gemspec ├── lib ├── hlspider.rb └── hlspider │ ├── downloader.rb │ ├── playlist.rb │ ├── playlist_line.rb │ ├── response.rb │ ├── spider.rb │ └── version.rb └── spec ├── hlspider ├── playlist_line_spec.rb ├── playlist_spec.rb └── spider_spec.rb ├── hlspider_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | -------------------------------------------------------------------------------- /.rbenv-version: -------------------------------------------------------------------------------- 1 | 1.8.7-p352 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 1.9.2 5 | - 2.0.0 6 | - jruby-19mode 7 | - rbx-19mode 8 | - jruby-head 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in hlspider.gemspec 4 | gemspec 5 | 6 | gem 'rake' 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (C) 2011 by Brooke McKim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![BuildStatus](https://travis-ci.org/brookemckim/hlspider.png)](https://travis-ci.org/brookemckim/hlspider) 2 | [![CodeClimate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/brookemckim/hlspider) 3 | 4 | # HLSpider - the HTTP Live Streaming Spider 5 | Downloads .m3u8 playlists and reports back on whether or not the playlists are aligned in time. 6 | 7 | ## Purpose 8 | 9 | Apple's HTTP Live Streaming (HLS) is used to deliver content with varying bit rate streams so a 3G connected cellphone can watch a video without buffering while a laptop can watch that same content in full 1080p. HLS uses .m3u8 playlist files (each bit rate having its own) which contain links to download the next video segment.It is very important that these different playlists are all at the same point in time so switching between bit rates is a seamless experience. 10 | 11 | Point HLSpider at multiple playlists and it will report back on whether or not these playlist contain the same number segment at the end of their playlist. 12 | 13 | ## Usage 14 | 15 | ### Ruby 16 | 17 | ``` 18 | # Point the spider at multiple playlists 19 | playlists = ["http://host.com/video1/playlist1.m3u8", "http://host.com/video1/playlist2.m3u8", "http://host.com/video1/playlist3.m3u8"] 20 | spider = HLSpider.new(playlists) 21 | ``` 22 | 23 | OR 24 | 25 | ``` 26 | # The parent multi bit rate playlist 27 | parent_url = "http://host.com/video1/all_bitrates_playlist.m3u8" 28 | spider = HLSpider.new(parent_url) 29 | ``` 30 | 31 | ### Command line 32 | 33 | ``` 34 | hlspider --playlists=http://host.com/video1/playlist1.m3u8,http://host.com/video1/playlist2.m3u8,http://host.com/video1/playlist3.m3u8 35 | ``` 36 | 37 | OR 38 | 39 | ``` 40 | hlspider --playlists=http://host.com/video1/all_bitrates_playlist.m3u8 41 | ``` 42 | 43 | #### Options 44 | ``` 45 | --loop TIMES - How many times the spider should compare the playlists. 46 | --sleep SECONDS - How many seconds the spider should sleep between loops. 47 | ``` 48 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs.push 'lib' 6 | t.libs.push 'spec' 7 | t.test_files = FileList['spec/**/*_spec.rb'] 8 | t.verbose = true 9 | end 10 | 11 | task :default => [:test] 12 | task :spec => [:test] 13 | -------------------------------------------------------------------------------- /bin/hlspider: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | require 'hlspider' 4 | rescue LoadError 5 | require 'rubygems' 6 | require 'hlspider' 7 | end 8 | 9 | require 'optparse' 10 | 11 | options = {} 12 | 13 | opts_parser = OptionParser.new do |opts| 14 | opts.banner = 'Downloads m3u8 playlists and confirms their segments are aligned.' 15 | opts.banner += '' 16 | 17 | opts.on('-p', '--playlists PLAYLISTS', Array, 'URL(s) to playlist(s)') do |playlists| 18 | options[:playlists] = playlists 19 | end 20 | 21 | options[:loop] = 1 22 | opts.on('-l', '--loop TIMES', Integer) do |l| 23 | options[:loop] = l || 5 24 | end 25 | 26 | options[:sleep] = 3 27 | opts.on('-s', '--sleep SECONDS', Integer) do |s| 28 | options[:sleep] = s 29 | end 30 | 31 | opts.on( '-h', '--help', 'Display this screen' ) do 32 | puts opts 33 | exit 34 | end 35 | end 36 | opts_parser.parse! 37 | 38 | if options[:playlists] 39 | spider = HLSpider::Spider.new(options[:playlists]) 40 | else 41 | puts "No playlists were specified." 42 | exit(1) 43 | end 44 | 45 | if options[:loop] == 0 46 | x = -1 47 | else 48 | x = 1 49 | end 50 | 51 | while x <= options[:loop] do 52 | spider.crawl! 53 | 54 | if spider.aligned? 55 | puts "--- Aligned at segment : #{spider.last_segments[0]} ---" 56 | else 57 | puts "--- Unaligned with segments : #{spider.last_segments.join(', ')} ---" 58 | end 59 | 60 | x += 1 unless options[:loop] == 0 61 | 62 | sleep(options[:sleep]) 63 | end 64 | -------------------------------------------------------------------------------- /hlspider.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "hlspider/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "hlspider" 7 | s.version = HLSpider::VERSION 8 | s.authors = ["brookemckim"] 9 | s.email = ["brooke.mckim@gmail.com"] 10 | s.homepage = "http://www.github.com/brookemckim/hlspider" 11 | s.summary = %q{Download and parse .m3u8 playlists.} 12 | s.description = %q{Downloads .m3u8 playlists and reports back on whether or not the playlists are aligned in time.} 13 | 14 | s.rubyforge_project = "hlspider" 15 | 16 | s.files = `git ls-files`.split("\n") 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | s.require_paths = ["lib"] 20 | 21 | # specify any dependencies here 22 | s.add_development_dependency 'minitest', '~> 2.7.0' 23 | s.add_development_dependency 'webmock' 24 | end 25 | -------------------------------------------------------------------------------- /lib/hlspider.rb: -------------------------------------------------------------------------------- 1 | $:.push File.dirname(__FILE__) 2 | 3 | require 'hlspider/version' 4 | require 'hlspider/downloader' 5 | require 'hlspider/playlist_line' 6 | require 'hlspider/playlist' 7 | require 'hlspider/spider' 8 | 9 | module HLSpider 10 | def self.new(*args) 11 | HLSpider::Spider.new(*args) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/hlspider/downloader.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'hlspider/response' 3 | 4 | # Internal: Asynchronsoly downloads urls and returns Array of responses. 5 | module HLSpider 6 | module Downloader 7 | # Internal: Download given URLs. 8 | # 9 | # urls - An Array of strings or a single string of URL(s) 10 | # 11 | # Examples 12 | # 13 | # download(["http://www.google.com", "http://www.yahoo.com"]) 14 | # # => [] 15 | # 16 | # download("http://www.bing.com") 17 | # # => [] 18 | # 19 | # Returns the Array of responses. 20 | # Raises HLSpider::Downloader::DownloadError if there was a problem 21 | # downloading all urls. 22 | def download(urls) 23 | urls = Array(urls) 24 | 25 | responses = [] 26 | threads = [] 27 | 28 | urls.each do |url| 29 | threads << Thread.new { 30 | uri = URI.parse(url) 31 | body = Net::HTTP.get_response(uri).body 32 | 33 | responses << Response.new(url, body) 34 | } 35 | 36 | threads.each { |t| t.join } 37 | end 38 | 39 | responses 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/hlspider/playlist.rb: -------------------------------------------------------------------------------- 1 | # Internal: Parses out and exposes the parts of M3U8 playlist files. 2 | # 3 | # M3U8 References: 4 | # http://developer.apple.com/library/ios/#documentation/networkinginternet/conceptual/streamingmediaguide/HTTPStreamingArchitecture/HTTPStreamingArchitecture.html 5 | # 6 | # Examples 7 | # 8 | # p = Playlist.new(File.read("/path/to/playlist.m3u8"), "http://url.tld/where/playlist/was/downloaded/from") 9 | # # => 10 | # 21 | 22 | require 'uri' 23 | 24 | module HLSpider 25 | class Playlist 26 | # Public: Gets/Sets the raw M3U8 Playlist File. 27 | attr_accessor :file 28 | 29 | # Public: Gets/Sets Optional source of playlist file. Used only for reference. 30 | attr_accessor :source 31 | 32 | # Public: Gets the target duration if available. 33 | attr_reader :target_duration 34 | 35 | # Public: Gets the media sequence of the playlist. 36 | attr_reader :media_sequence 37 | 38 | # Internal: Initialize a Playlist. 39 | # 40 | # file - A String containing an .m3u8 playlist file. 41 | # source - A String source of where the playlist was downloaded from. (optional) 42 | def initialize(file, source = nil) 43 | @file = file 44 | @source = source 45 | @valid = false 46 | @domain = "" 47 | if @source 48 | uri = URI.parse(@source) 49 | if uri.is_a?(URI::HTTP) 50 | @domain = @source[0...@source.index(uri.request_uri)] 51 | end 52 | end 53 | 54 | @variable_playlist = false 55 | @segment_playlist = false 56 | 57 | @playlists = [] 58 | @segments = [] 59 | 60 | parse(@file) 61 | end 62 | 63 | # Internal: Set the m3u8 file. 64 | # 65 | # file - The String of the m3u8 file. 66 | # 67 | # Examples 68 | # 69 | # file( File.read('/path/to/playlist.m3u8') ) 70 | # # => '#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=713245\n 71 | # http://hls.telvue.com/brightstar/2-1/playlist.m3u8?wowzasessionid=268983957' 72 | # 73 | # Returns the file String. 74 | def file=(file) 75 | @file = file 76 | parse(@file) 77 | end 78 | 79 | # Public: Check whether the playlist is a variable playlist or not. 80 | # 81 | # 82 | # Examples 83 | # 84 | # variable_playlist? 85 | # # => true 86 | # 87 | # Returns Boolean variable_playlist. 88 | def variable_playlist? 89 | @variable_playlist 90 | end 91 | 92 | # Public: Check whether the playlist is a segment playlist or not. 93 | # 94 | # 95 | # Examples 96 | # 97 | # segment_playlist? 98 | # # => false 99 | # 100 | # Returns Boolean segment_playlist. 101 | def segment_playlist? 102 | @segment_playlist 103 | end 104 | 105 | # Public: Check whether the playlist is valid (either a segment or variable playlist). 106 | # 107 | # 108 | # Examples 109 | # 110 | # valid? 111 | # # => true 112 | # 113 | # Returns Boolean valid. 114 | def valid? 115 | @valid 116 | end 117 | 118 | # Public: Sub-Playlists of playlist file. Appends source if 119 | # playlists are not absolute urls. 120 | # 121 | # 122 | # 123 | # Examples 124 | # 125 | # playlists 126 | # # => ["http://site.tld/playlist_1.m3u8", "http://site.tld/playlist_2.m3u8"] 127 | # 128 | # Returns Array of Strings. 129 | def playlists 130 | @playlists.collect do |p| 131 | if absolute_url?(p) 132 | p 133 | elsif p.start_with?("/") 134 | @domain + p 135 | elsif @source 136 | @source.sub(/[^\/]*.m3u8/, p) 137 | end 138 | end 139 | end 140 | 141 | # Public: Segments contained in playlist file. Appends source if 142 | # segments are not absolute urls. 143 | # 144 | # 145 | # 146 | # Examples 147 | # 148 | # segments 149 | # # => ["http://site.tld/segments_1.ts", "http://site.tld/segments_2.ts"] 150 | # 151 | # Returns Array of Strings. 152 | def segments 153 | @segments.collect do |p| 154 | if absolute_url?(p) 155 | p 156 | elsif p.start_with?("/") 157 | @domain + p 158 | elsif @source 159 | @source.sub(/[^\/]*.m3u8/, p) 160 | end 161 | end 162 | end 163 | 164 | # Public: Prints contents of @file. 165 | # 166 | # 167 | # Examples 168 | # 169 | # to_s 170 | # #=> '#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=713245\n 171 | # http://hls.telvue.com/brightstar/2-1/playlist.m3u8?wowzasessionid=268983957' 172 | # 173 | # Returns String file. 174 | def inspect 175 | @file 176 | end 177 | alias_method :inspect, :to_s 178 | 179 | private 180 | include PlaylistLine 181 | 182 | # Internal: Parses @file and sets @variable_playlist, @segment_playlist, and @valid. 183 | # 184 | # 185 | # Examples 186 | # 187 | # parse(playlist_file) 188 | # 189 | # Returns nothing. 190 | def parse(file) 191 | @valid = true if /#EXTM3U/.match(@file) 192 | 193 | if has_playlist?(@file) && !has_segment?(@file) 194 | @variable_playlist = true 195 | 196 | @file.each_line do |line| 197 | @playlists << line[/([^ "]+.m3u8[^ "]*)/].strip if has_playlist?(line) 198 | end 199 | elsif has_segment?(@file) && !has_playlist?(@file) 200 | @segment_playlist = true 201 | 202 | @file.each_line do |line| 203 | if has_segment?(line) 204 | @segments << line[/([^ "]+.(ts|aac)[^ "]*)/].strip 205 | elsif duration_line?(line) 206 | @target_duration = parse_duration(line.strip) 207 | elsif media_sequence_line?(line) 208 | @media_sequence = parse_sequence(line.strip) 209 | end 210 | end 211 | else 212 | @valid = false 213 | end 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /lib/hlspider/playlist_line.rb: -------------------------------------------------------------------------------- 1 | # Internal: A set of methods for examining individual lines of m3u8 playlists. 2 | module HLSpider 3 | module PlaylistLine 4 | # Internal: Checks if String str contains a .ts file extension 5 | # 6 | # str - String to be checked 7 | # 8 | # Examples 9 | # 10 | # has_segment?("video_01.ts") 11 | # #=> true 12 | # 13 | # has_segment?("arandomstring") 14 | # #=> false 15 | # 16 | # Returns Boolean. 17 | def has_segment?(str) 18 | !!( str[/.*.(ts|aac)(\z|\?|$)/] ) 19 | end 20 | 21 | # Internal: Checks if String str contains links to .m3u8 file extensions. 22 | # 23 | # str - String to be checked 24 | # 25 | # Examples 26 | # 27 | # has_playlist?("playlist.m3u8") 28 | # #=> true 29 | # 30 | # has_playlist?("arandomstring") 31 | # #=> false 32 | # 33 | # Returns Boolean. 34 | def has_playlist?(str) 35 | !!( str[/.m3u8/] ) 36 | end 37 | 38 | # Internal: Checks if String str contains 'EXT-X-TARGETDURATION'. 39 | # 40 | # str - String to be checked 41 | # 42 | # Examples 43 | # 44 | # duration_line?("EXT-X-TARGETDURATION:10") 45 | # #=> true 46 | # 47 | # duration_line?("arandomstring") 48 | # #=> false 49 | # 50 | # Returns Boolean. 51 | def duration_line?(str) 52 | !!( str[/EXT-X-TARGETDURATION/] ) 53 | end 54 | 55 | # Internal: Parses Integer target duration out of String str 56 | # 57 | # str - String to be parsed 58 | # 59 | # Examples 60 | # 61 | # parse_duration("EXT-X-TARGETDURATION:10") 62 | # #=> 10 63 | # 64 | # parse_duration("arandomstring") 65 | # #=> nil 66 | # 67 | # Returns Integer or nil. 68 | def parse_duration(str) 69 | if dur = /EXT-X-TARGETDURATION:(\d*)\z/.match(str) 70 | dur[1].to_i 71 | else 72 | nil 73 | end 74 | end 75 | 76 | # Internal: Parses string and returns whether or not it is an absolute url. 77 | # 78 | # str - String to be parsed 79 | # 80 | # Examples 81 | # 82 | # absolute_url?("directory/file.m3u8") 83 | # #=> false 84 | # 85 | # absolute_url?("http://www.site.tld/file.m3u8") 86 | # #=> true 87 | # 88 | # Returns Boolean. 89 | def absolute_url?(str) 90 | !!( str[/\Ahtt(ps|p)\:\/\//] ) 91 | end 92 | 93 | # Internal: Parses string and returns whether or not it is a media sequence line. 94 | # 95 | # str - String to be parsed 96 | # 97 | # Examples 98 | # 99 | # media_sequence_line?("#EXT-X-MEDIA-SEQUENCE:1739") 100 | # #=> true 101 | # 102 | # media_sequence_line?("holla") 103 | # #=> false 104 | # 105 | # Returns Boolean. 106 | def media_sequence_line?(str) 107 | !!( str[/EXT-X-MEDIA-SEQUENCE/] ) 108 | end 109 | 110 | # Internal: Parses string and returns media sequence number. 111 | # 112 | # line - Line to be parsed 113 | # 114 | # Examples 115 | # 116 | # parse_sequence("#EXT-X-MEDIA-SEQUENCE:1739") 117 | # #=> 1739 118 | # 119 | # Returns Integer or nil. 120 | def parse_sequence(line) 121 | if sequence = /#EXT-X-MEDIA-SEQUENCE:\s*(\d*)/.match(line) 122 | sequence[1].to_i 123 | else 124 | nil 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/hlspider/response.rb: -------------------------------------------------------------------------------- 1 | # Internal: Reponse from the downloader which contains the response body 2 | # and the request url. 3 | module HLSpider 4 | class Response 5 | def initialize(url, body) 6 | @url = url 7 | @body = body 8 | end 9 | 10 | attr_reader :url, :body 11 | end 12 | end 13 | 14 | -------------------------------------------------------------------------------- /lib/hlspider/spider.rb: -------------------------------------------------------------------------------- 1 | # Public: Asynchronsoly downloads .m3u8 playlist files from specified URLs. 2 | # 3 | # 4 | # Examples 5 | # 6 | # Spider.new(["http://host.tld/video1/playlist_1.m3u8", "http://host.tld/video1/playlist_2.m3u8"]) 7 | # # => # 8 | # 9 | # Spider.new("http://host.tld/video1/parent_playlist.m3u8") 10 | # # => # 11 | module HLSpider 12 | class Spider 13 | class InvalidPlaylist < StandardError; end; 14 | 15 | # Public: Gets Array of urls. 16 | attr_reader :urls 17 | 18 | # Public: Gets Array of valid playlists. 19 | attr_reader :playlists 20 | 21 | # Public: Initialize a Playlist Spider. 22 | # 23 | # urls - An Array containing multiple String urls to playlist files. 24 | # Also accepts single String url that points to parent playlist. 25 | def initialize(urls) 26 | @urls = Array(urls) 27 | end 28 | 29 | # Public: Starts the download of Array urls 30 | # 31 | # 32 | # Examples 33 | # 34 | # crawl 35 | # # => [#, #] 36 | # 37 | # Returns Array of Playlists 38 | def crawl! 39 | @playlists = dive(@urls) 40 | end 41 | 42 | # Public: Checks if playlists' segments are aligned. 43 | # 44 | # 45 | # Examples 46 | # 47 | # aligned? 48 | # # => true 49 | # 50 | # Returns Boolean. 51 | def aligned? 52 | last_segments.uniq.size == 1 53 | end 54 | 55 | # Public: playlist getter. 56 | # 57 | # 58 | # Examples 59 | # 60 | # playlists 61 | # # => [#, #] 62 | # 63 | # Returns Array of Playlists 64 | def playlists 65 | @playlists ||= crawl! 66 | end 67 | 68 | # Public: Get Array of last segments across playlists. 69 | # 70 | # 71 | # Examples 72 | # 73 | # last_segments 74 | # # => ['video_05.ts', 'video_05.ts', 'video_05.ts'] 75 | # 76 | # Returns Array of Strings 77 | def last_segments 78 | playlists.collect { |p| p.media_sequence } 79 | end 80 | 81 | private 82 | 83 | include Downloader 84 | 85 | # Internal: Download playlists from Array urls. 86 | # 87 | # 88 | # Examples 89 | # 90 | # dive(["http://host.tld/video1/playlist_1.m3u8", "http://host.tld/video1/playlist_2.m3u8"]) 91 | # # => [#, #] 92 | # 93 | # Returns Array of Playlists. 94 | # Raises HLSpider::Spider::InvalidPlaylist if an invalid playlist is downloaded. 95 | def dive(urls = []) 96 | playlists = [] 97 | 98 | responses = download(urls) 99 | 100 | responses.each do |response| 101 | playlist = Playlist.new(response.body, response.url) 102 | 103 | if playlist.valid? 104 | if playlist.variable_playlist? 105 | playlists << dive(playlist.playlists) 106 | else 107 | playlists << playlist 108 | end 109 | else 110 | raise InvalidPlaylist, "#{playlist.source} was an invalid playlist." 111 | end 112 | end 113 | 114 | playlists.flatten 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/hlspider/version.rb: -------------------------------------------------------------------------------- 1 | module HLSpider 2 | VERSION = "0.6.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/hlspider/playlist_line_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe HLSpider::PlaylistLine do 4 | before do 5 | class PlaylistLine; extend HLSpider::PlaylistLine; end 6 | 7 | @segment_line = "http://host.tld/video1/video_123123023030.ts?session=12391239" 8 | @playlist_line = "http://host.told/video1/playlist_123213.m3u8" 9 | @duration_line = "EXT-X-TARGETDURATION:55" 10 | end 11 | 12 | describe "#has_segment?" do 13 | it "returns true on String with video segment" do 14 | PlaylistLine.has_segment?(@segment_line).must_equal(true) 15 | end 16 | 17 | it "returns false on String without video segment" do 18 | PlaylistLine.has_segment?(@playlist_line).must_equal(false) 19 | end 20 | end 21 | 22 | describe "#has_playlist?" do 23 | it "returns true on String with playlist" do 24 | PlaylistLine.has_playlist?(@playlist_line).must_equal(true) 25 | end 26 | 27 | it "returns false on String without playlist" do 28 | PlaylistLine.has_playlist?(@segment_line).must_equal(false) 29 | end 30 | end 31 | 32 | describe "#duration_line?" do 33 | it "returns true on String with playlist duration" do 34 | PlaylistLine.duration_line?(@duration_line).must_equal(true) 35 | end 36 | 37 | it "returns false on String without playlist duration" do 38 | PlaylistLine.duration_line?(@playlist_line).must_equal(false) 39 | end 40 | end 41 | 42 | describe "#parse_duration" do 43 | it "returns Integer duration on String with duration" do 44 | PlaylistLine.parse_duration(@duration_line).must_equal(55) 45 | end 46 | 47 | it "returns nil on String without duration" do 48 | PlaylistLine.parse_duration(@segment_line).must_equal(nil) 49 | end 50 | end 51 | 52 | describe "#absolute_url?" do 53 | it "returns true for full url" do 54 | PlaylistLine.absolute_url?("http://www.google.com/gmail/").must_equal(true) 55 | end 56 | 57 | it "returns false for relative path" do 58 | PlaylistLine.absolute_url?("holla/dolla.m3u8").must_equal(false) 59 | end 60 | end 61 | 62 | describe "#media_sequence_line?" do 63 | it "returns false when line is not a media sequence line" do 64 | PlaylistLine.media_sequence_line?("!!!!!!!hello").must_equal(false) 65 | end 66 | 67 | it "returns true when line is a media sequence line" do 68 | PlaylistLine.media_sequence_line?("#EXT-X-MEDIA-SEQUENCE:1739").must_equal(true) 69 | end 70 | end 71 | 72 | describe "#parse_sequence" do 73 | it "returns sequence number when the line has one" do 74 | PlaylistLine.parse_sequence("#EXT-X-MEDIA-SEQUENCE:1739").must_equal(1739) 75 | end 76 | 77 | it "returns nil when line does not a sequence number" do 78 | PlaylistLine.parse_sequence("~_~").must_equal(nil) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/hlspider/playlist_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe HLSpider::Playlist do 4 | before do 5 | @segments_playlist = %q{#EXTM3U 6 | #EXT-X-TARGETDURATION:10 7 | #EXT-X-VERSION:3 8 | #EXT-X-MEDIA-SEQUENCE:0 9 | #EXT-X-PLAYLIST-TYPE:VOD 10 | #EXTINF:7.12, 11 | segment00000.ts 12 | segment00001.ts 13 | /absolute/path/segment00002.ts 14 | #EXT-X-ENDLIST 15 | } 16 | 17 | @variable_playlist = %q{#EXTM3U 18 | #EXTM3U 19 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=673742 20 | rel/playlist.m3u8 21 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=204909 22 | http://anotherhost.com/playlist3.m3u8 23 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=173742 24 | /absolute/path/playlist.m3u8 25 | } 26 | 27 | @extra_media_playlist = %q{#EXTM3U 28 | #EXT-X-FAXS-CM:URI="iphone360.mp4.drmmeta" 29 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Portuguese",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="pt",URI="iphone.m3u8" 30 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="SAP",DEFAULT=NO,AUTOSELECT=YES,LANGUAGE="en",URI="sap.m3u8" 31 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=673742,CODECS="mp4a.40.2,avc1.4d401e",AUDIO="aac" 32 | web.m3u8 33 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=401437,CODECS="mp4a.40.2,avc1.4d401e",AUDIO="aac" 34 | iphone.m3u8 35 | } 36 | 37 | @querystring_playlist = %q{#EXTM3U 38 | #EXT-X-VERSION:3 39 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=500000 40 | chunklist-b500000.m3u8?wowzasessionid=2030032484 41 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000 42 | chunklist-b1000000.m3u8?wowzasessionid=2030032484 43 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1400000 44 | chunklist-b1400000.m3u8?wowzasessionid=2030032484 45 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=96000 46 | chunklist-b96000.m3u8?wowzasessionid=2030032484&wowzaaudioonly 47 | } 48 | 49 | @audioonly_playlist = %q{#EXTM3U 50 | #EXT-X-VERSION:3 51 | #EXT-X-ALLOW-CACHE:NO 52 | #EXT-X-TARGETDURATION:11 53 | #EXT-X-MEDIA-SEQUENCE:35 54 | #EXTINF:9.0, 55 | media-b96000_35.aac?wowzasessionid=1332469433&wowzaaudioonly 56 | #EXTINF:9.0, 57 | media-b96000_36.aac?wowzasessionid=1332469433&wowzaaudioonly 58 | #EXTINF:9.0, 59 | media-b96000_37.aac?wowzasessionid=1332469433&wowzaaudioonly 60 | #EXTINF:9.0, 61 | media-b96000_38.aac?wowzasessionid=1332469433&wowzaaudioonly 62 | } 63 | end 64 | 65 | it "should identify if it is a segments playlist" do 66 | playlist = HLSpider::Playlist.new(@segments_playlist) 67 | playlist.segment_playlist?.must_equal(true) 68 | playlist.variable_playlist?.must_equal(false) 69 | end 70 | 71 | it "should identify if it is a variable playlist" do 72 | playlist = HLSpider::Playlist.new(@variable_playlist) 73 | playlist.variable_playlist?.must_equal(true) 74 | playlist.segment_playlist?.must_equal(false) 75 | end 76 | 77 | it "should resolve relative playlists" do 78 | playlist = HLSpider::Playlist.new(@variable_playlist, "http://host.com/main/playlist.m3u8") 79 | playlist.playlists[0].must_equal("http://host.com/main/rel/playlist.m3u8") 80 | end 81 | 82 | it "should resolve absolute playlists" do 83 | playlist = HLSpider::Playlist.new(@variable_playlist, "http://host.com/main/playlist.m3u8") 84 | playlist.playlists[2].must_equal("http://host.com/absolute/path/playlist.m3u8") 85 | end 86 | 87 | it "should resolve absolute segments" do 88 | playlist = HLSpider::Playlist.new(@segments_playlist, "http://host.com/main/playlist.m3u8") 89 | playlist.segments[2].must_equal("http://host.com/absolute/path/segment00002.ts") 90 | end 91 | 92 | it "should accept playlist names with hifen" do 93 | playlist = HLSpider::Playlist.new(@variable_playlist, "http://host.com/main/playlist-with-hifen.m3u8") 94 | playlist.playlists[0].must_equal("http://host.com/main/rel/playlist.m3u8") 95 | end 96 | 97 | it "should accept absolute playlists" do 98 | playlist = HLSpider::Playlist.new(@variable_playlist, "http://host.com/main/playlist.m3u8") 99 | playlist.playlists[1].must_equal("http://anotherhost.com/playlist3.m3u8") 100 | end 101 | 102 | it "should same value to to_s and inspect" do 103 | playlist = HLSpider::Playlist.new(@segments_playlist) 104 | playlist.to_s.must_equal(playlist.inspect) 105 | end 106 | 107 | it "should accept extra media urls on playlists" do 108 | playlist = HLSpider::Playlist.new(@extra_media_playlist, "http://host.com/main/playlist.m3u8") 109 | playlist.playlists.must_equal([ 110 | "http://host.com/main/iphone.m3u8", 111 | "http://host.com/main/sap.m3u8", 112 | "http://host.com/main/web.m3u8", 113 | "http://host.com/main/iphone.m3u8" 114 | ]) 115 | end 116 | 117 | it "should accept urls with querystings on playlists" do 118 | playlist = HLSpider::Playlist.new(@querystring_playlist, "http://host.com/main/playlist.m3u8") 119 | playlist.playlists.must_equal([ 120 | "http://host.com/main/chunklist-b500000.m3u8?wowzasessionid=2030032484", 121 | "http://host.com/main/chunklist-b1000000.m3u8?wowzasessionid=2030032484", 122 | "http://host.com/main/chunklist-b1400000.m3u8?wowzasessionid=2030032484", 123 | "http://host.com/main/chunklist-b96000.m3u8?wowzasessionid=2030032484&wowzaaudioonly" 124 | ]) 125 | end 126 | 127 | it "should accept audio only segments" do 128 | playlist = HLSpider::Playlist.new(@audioonly_playlist, "http://host.com/main/playlist.m3u8") 129 | playlist.segments.must_equal([ 130 | "http://host.com/main/media-b96000_35.aac?wowzasessionid=1332469433&wowzaaudioonly", 131 | "http://host.com/main/media-b96000_36.aac?wowzasessionid=1332469433&wowzaaudioonly", 132 | "http://host.com/main/media-b96000_37.aac?wowzasessionid=1332469433&wowzaaudioonly", 133 | "http://host.com/main/media-b96000_38.aac?wowzasessionid=1332469433&wowzaaudioonly" 134 | ]) 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/hlspider/spider_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe HLSpider::Spider do 4 | include WebMock::API 5 | 6 | before do 7 | WebMock.reset! 8 | @playlist = "http://host.com/playlist.m3u8" 9 | @playlists = ["http://host.com/playlist.m3u8", "http://host.com/playlist2.m3u8"] 10 | stub_request(:get, "http://host.com/playlist.m3u8").to_return({:status => 200, :headers => {}, :body => %q{#EXTM3U 11 | #EXT-X-TARGETDURATION:10 12 | #EXT-X-VERSION:3 13 | #EXT-X-MEDIA-SEQUENCE:0 14 | #EXT-X-PLAYLIST-TYPE:VOD 15 | #EXTINF:7.12, 16 | segment00000.ts 17 | segment00001.ts 18 | #EXT-X-ENDLIST 19 | }}) 20 | stub_request(:get, "http://host.com/playlist2.m3u8").to_return({:status => 200, :headers => {}, :body => %q{#EXTM3U 21 | #EXTM3U 22 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=673742 23 | playlist.m3u8 24 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=204909 25 | playlist3.m3u8 26 | }}) 27 | 28 | stub_request(:get, "http://host.com/playlist3.m3u8").to_return({:status => 200, :headers => {}, :body => %q{#EXTM3U 29 | #EXT-X-TARGETDURATION:10 30 | #EXT-X-VERSION:3 31 | #EXT-X-MEDIA-SEQUENCE:0 32 | #EXT-X-PLAYLIST-TYPE:VOD 33 | #EXTINF:7.12, 34 | segment00000.ts 35 | segment00001.ts 36 | segment00002.ts 37 | #EXT-X-ENDLIST 38 | }}) 39 | 40 | stub_request(:get, "http://host.com/playlist4.m3u8").to_return({:status => 200, :headers => {}, :body => %q{#EXT-X-TARGETDURATION:10}}) 41 | end 42 | 43 | it "can be created with a String" do 44 | HLSpider::Spider.new(@playlist).must_be_instance_of(HLSpider::Spider) 45 | end 46 | 47 | it "can be created with an Array" do 48 | HLSpider::Spider.new(@playlists).must_be_instance_of(HLSpider::Spider) 49 | end 50 | 51 | it "should download the playlist content when parsing it" do 52 | spider = HLSpider::Spider.new(@playlist) 53 | spider.playlists.count.must_equal(1) 54 | spider.playlists[0].must_be_instance_of(HLSpider::Playlist) 55 | assert_requested(:get, @playlist) 56 | end 57 | 58 | it "should download recursively when the playlist if of playlists" do 59 | spider = HLSpider::Spider.new(@playlists) 60 | spider.playlists.count.must_equal(3) 61 | spider.playlists[0].must_be_instance_of(HLSpider::Playlist) 62 | spider.playlists[1].must_be_instance_of(HLSpider::Playlist) 63 | spider.playlists[2].must_be_instance_of(HLSpider::Playlist) 64 | assert_requested(:get, "http://host.com/playlist.m3u8", :times => 2) 65 | assert_requested(:get, "http://host.com/playlist2.m3u8") 66 | assert_requested(:get, "http://host.com/playlist3.m3u8") 67 | end 68 | 69 | it "should raise error when the playlist is invalid" do 70 | spider = HLSpider::Spider.new("http://host.com/playlist4.m3u8") 71 | assert_raises(HLSpider::Spider::InvalidPlaylist) { 72 | spider.playlists 73 | } 74 | end 75 | 76 | it "should return the last segment of each playlist" do 77 | spider = HLSpider::Spider.new(@playlists) 78 | spider.last_segments.must_equal([0, 0, 0]) 79 | end 80 | 81 | it "should check if the playlists are aligned" do 82 | spider = HLSpider::Spider.new(@playlists) 83 | spider.aligned?.must_equal(true) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/hlspider_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe HLSpider do 4 | before do 5 | @spider = HLSpider.new("http://host.com/playlist.m3u8") 6 | end 7 | 8 | it "should instansiate a Spider class" do 9 | @spider.must_be_kind_of(HLSpider::Spider) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'webmock/minitest' 3 | require 'hlspider' 4 | 5 | WebMock.disable_net_connect! 6 | 7 | --------------------------------------------------------------------------------