iplayer-dl 0.1.16

Sign up to get free protection for your applications and to get access to all the features.
data/COPYING ADDED
@@ -0,0 +1,26 @@
1
+ Copyright (c) 2008 Paul Battley <pbattley@gmail.com>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
20
+
21
+ If you have received this program as a self-executing bundle, the following
22
+ licences apply to individual components:
23
+
24
+ ruby: GNU General Public License (GPL), version 2; Ruby License
25
+ rubyscript2exe: GNU General Public License (GPL), version 2
26
+ wxruby: wxWindows Licence
data/README ADDED
@@ -0,0 +1,14 @@
1
+ I've refactored iplayer-dl in order to make it easier to extend and wrap. As a
2
+ direct result, it's no longer just one script. As a result, you'll need to
3
+ install the libraries and command-line script in order to use it.
4
+
5
+ The good news is that this is as easy as typing:
6
+
7
+ ruby setup.rb config
8
+ sudo ruby setup.rb install
9
+
10
+ Windows users should omit 'sudo' in the second line.
11
+
12
+ You can then run the downloader just by typing:
13
+
14
+ iplayer-dl
@@ -0,0 +1,95 @@
1
+ require 'rake/testtask'
2
+ require 'rake/packagetask'
3
+ require 'rake/rdoctask'
4
+ require 'rake'
5
+ require 'find'
6
+ require 'lib/iplayer/version'
7
+
8
+ # Globals
9
+
10
+ PKG_NAME = 'iplayer-dl'
11
+
12
+ PKG_FILES = %w[ COPYING README setup.rb Rakefile ]
13
+ Find.find('lib/', 'test/', 'bin/', 'share/') do |f|
14
+ if FileTest.directory?(f) and File.basename(f) =~ /^\./
15
+ Find.prune
16
+ else
17
+ PKG_FILES << f
18
+ end
19
+ end
20
+
21
+ EXE_FILES = PKG_FILES + %w[ application.ico init.rb ]
22
+
23
+ # Tasks
24
+
25
+ task :default => :test
26
+
27
+ Rake::TestTask.new do |t|
28
+ t.libs << "test"
29
+ t.test_files = FileList['test/test_*.rb']
30
+ end
31
+
32
+ Rake::PackageTask.new(PKG_NAME, IPlayer::VERSION) do |p|
33
+ p.need_tar_gz = true
34
+ p.package_files = PKG_FILES
35
+ end
36
+
37
+ desc "Build a Windows executable. Needs rubyscript2exe.rb in the current path and the wx gem installed."
38
+ task :exe do |t|
39
+ mkdir_p 'tmp'
40
+ build_dir = File.join('tmp', "ipdl-#{IPlayer::GUI_VERSION}")
41
+ rm_rf build_dir
42
+ mkdir build_dir
43
+ EXE_FILES.each do |file|
44
+ next if File.directory?(file)
45
+ loc = File.join(build_dir, File.dirname(file))
46
+ mkdir_p loc
47
+ cp_r file, loc
48
+ end
49
+ sh "ruby rubyscript2exe.rb #{build_dir} --rubyscript2exe-verbose --rubyscript2exe-rubyw"
50
+ rm_rf build_dir
51
+ mkdir_p 'pkg'
52
+ mv "ipdl-#{IPlayer::GUI_VERSION}.exe", "pkg"
53
+ end
54
+
55
+ begin
56
+ require "rake/gempackagetask"
57
+
58
+ spec = Gem::Specification.new do |s|
59
+ # Change these as appropriate
60
+ s.name = "iplayer-dl"
61
+ s.version = IPlayer::VERSION
62
+ s.summary = "Download iPlayer content"
63
+ s.author = "Paul Battley"
64
+ s.email = "pbattley@gmail.com"
65
+ s.homepage = "http://po-ru.com/projects/iplayer-downloader"
66
+
67
+ s.has_rdoc = false
68
+
69
+ # Add any extra files to include in the gem
70
+ s.files = PKG_FILES
71
+ s.executables = ["iplayer-dl"]
72
+
73
+ s.require_paths = ["lib"]
74
+
75
+ # If you want to depend on other gems, add them here, along with any
76
+ # relevant versions
77
+ # s.add_dependency("some_other_gem", "~> 0.1.0")
78
+
79
+ # If your tests use any gems, include them here
80
+ s.add_development_dependency("mocha")
81
+ end
82
+
83
+ # This task actually builds the gem. We also regenerate a static
84
+ # .gemspec file, which is useful if something (i.e. GitHub) will
85
+ # be automatically building a gem for this project. If you're not
86
+ # using GitHub, edit as appropriate.
87
+ Rake::GemPackageTask.new(spec) do |pkg|
88
+ pkg.gem_spec = spec
89
+
90
+ # Generate the gemspec file for github.
91
+ file = File.dirname(__FILE__) + "/#{spec.name}.gemspec"
92
+ File.open(file, "w") {|f| f << spec.to_ruby }
93
+ end
94
+ rescue LoadError
95
+ end
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Download iPlayer programmes by spoofing an iPhone
4
+ # Paul Battley - http://po-ru.com/
5
+ #
6
+ # Get the latest version via subversion:
7
+ # svn co http://paulbattley.googlecode.com/svn/iplayer-dl
8
+
9
+ require 'iplayer'
10
+ require 'optparse'
11
+ require 'fileutils'
12
+ require 'iplayer/version'
13
+ require 'iplayer/preferences'
14
+
15
+ include IPlayer
16
+ include IPlayer::Errors
17
+
18
+ preferences = IPlayer::Preferences.new
19
+ filenames = []
20
+ pid_list = nil
21
+ dry_run = false
22
+
23
+ opts = ARGV.options{ |o|
24
+ o.banner << ' IDENTIFIER [IDENTIFIER [...]]'
25
+ o.define_head 'Download DRM-free videos from the BBC iPlayer, courtesy of their iPhone interface.'
26
+ o.separator 'IDENTIFIER is the iPlayer viewing page URL or the PID of the programme.'
27
+ o.separator ''
28
+ o.on(
29
+ '-t', '--type-preference=VERSION', String,
30
+ 'Video types in order of preference.',
31
+ "Default is '#{preferences.type_preference.join(',')}'."
32
+ ) { |s| preferences.type_preference = s.split(/,\s*/) }
33
+ o.on(
34
+ '-d', '--download-path=PATH', String,
35
+ 'Location into which downloaded files will be saved.',
36
+ 'Default is current working directory.'
37
+ ) { |d| preferences.download_path = d }
38
+ o.on(
39
+ '-s', '--title-subdir',
40
+ 'Place downloaded files in a sub-directory named after the title of the programme.'
41
+ ) { preferences.subdirs = true }
42
+ o.on(
43
+ '-u', '--subtitles',
44
+ 'Also download subtitles.'
45
+ ) { preferences.subtitles = true }
46
+ o.on(
47
+ '-f', '--filename=FILENAME', String,
48
+ 'Manually specify a name for the downloaded file.',
49
+ 'The default is constructed from the programme metadata.',
50
+ 'You can specify this multiple times for each download in order.'
51
+ ) { |f| filenames << f }
52
+ o.on(
53
+ '-p', '--http-proxy=HOST:PORT', String,
54
+ 'Specify an HTTP proxy.',
55
+ 'Default is taken from the http_proxy environment variable.'
56
+ ) { |p| preferences.http_proxy = p }
57
+ o.on(
58
+ '-l', '--pid-list=FILENAME', String,
59
+ 'List PIDs to be downloaded in a file, one per line.'
60
+ ) { |l| pid_list = l }
61
+ o.on(
62
+ '-n', '--dry-run', String,
63
+ 'Parse the iPlayer page and output the filename but don\'t actually download (useful for scripting).'
64
+ ) { |n| dry_run = true }
65
+ o.on(
66
+ '-v', '--version',
67
+ 'Show the software version.'
68
+ ) { puts IPlayer::VERSION; exit }
69
+ o.on_tail(
70
+ '-h', '--help',
71
+ 'Show this help message.'
72
+ ) { puts o; exit }
73
+ }
74
+
75
+ begin
76
+ opts.parse!
77
+ pids = ARGV
78
+ if pid_list
79
+ pids += File.read(pid_list).strip.split(/\s*\r?\n\s*/)
80
+ end
81
+ raise 'no programme identifier specified' if pids.empty?
82
+ rescue => exception
83
+ $stderr.puts 'Error: '+exception, ''
84
+ puts opts
85
+ exit 1
86
+ end
87
+
88
+ if http_proxy = preferences.http_proxy
89
+ http_proxy = 'http://' + http_proxy unless http_proxy =~ %r{^http://}
90
+ u = URI.parse(http_proxy)
91
+ $stderr.puts "Using proxy #{u.host}:#{u.port}"
92
+ http = Net::HTTP::Proxy(u.host, u.port)
93
+ else
94
+ http = Net::HTTP
95
+ end
96
+
97
+ pids.each_with_index do |pid, i|
98
+ browser = Browser.new(http)
99
+
100
+ begin
101
+ pid = Downloader.extract_pid(pid)
102
+ downloader = Downloader.new(browser, pid)
103
+
104
+ available_versions = downloader.available_versions
105
+ raise MP4Unavailable if available_versions.empty?
106
+ version = available_versions.sort_by{ |v|
107
+ preferences.type_preference.index(v.name) || 100
108
+ }.first
109
+
110
+ filename = filenames[i]
111
+ if filename.nil? || preferences.subdirs
112
+ metadata = downloader.metadata
113
+ filename ||= "#{ metadata.full_title }.#{ metadata.filetype }".gsub(/[^a-z0-9 \-\.]+/i, '')
114
+ end
115
+ if preferences.subdirs
116
+ subdir = metadata.title.gsub(/[^a-z0-9 \-\.]+/i, '')
117
+ path = File.expand_path( File.join( preferences.download_path, subdir ))
118
+ FileUtils.makedirs(path)
119
+ path = File.join( path, filename )
120
+ else
121
+ path = File.expand_path( File.join( preferences.download_path, filename ))
122
+ end
123
+
124
+ if dry_run
125
+ $stdout.puts filename
126
+ else
127
+ old_percentage = nil
128
+ first_chunk = true
129
+
130
+ $stderr.puts "#{ filename } (version: #{ version.name })"
131
+ download_options = {:subtitles => preferences.subtitles}
132
+ downloader.download(version.pid, path, download_options) do |position, max|
133
+ if first_chunk
134
+ $stderr.puts "Resuming download at #{position} bytes." if position > 0
135
+ first_chunk = false
136
+ end
137
+
138
+ percentage = "%.1f" % [((1000 * position) / max) / 10.0]
139
+ if percentage != old_percentage
140
+ old_percentage = percentage
141
+ $stderr.print "\r#{ percentage }%"
142
+ $stderr.flush
143
+ end
144
+ end
145
+ $stderr.puts
146
+ end
147
+
148
+ rescue RecognizedError => error
149
+ $stderr.puts(error.to_str)
150
+ next
151
+ end
152
+ end
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Download iPlayer programmes by spoofing an iPhone
4
+ # WxWidgets GUI interface
5
+ # Paul Battley - http://po-ru.com/
6
+ #
7
+ # Get the latest version via subversion:
8
+ # svn co http://paulbattley.googlecode.com/svn/iplayer-dl
9
+
10
+ require 'iplayer'
11
+ require 'iplayer/gui/app'
12
+ require 'iplayer/gui/main_frame'
13
+ require 'iplayer/version'
14
+
15
+ options = {
16
+ :type_preference => %w[original signed],
17
+ :http_proxy => ENV['http_proxy']
18
+ }
19
+ about = {
20
+ :name => 'iPlayer Downloader',
21
+ :version => "#{IPlayer::GUI_VERSION} (library #{IPlayer::VERSION})",
22
+ :developers => ['Paul Battley'],
23
+ :description => "Download programmes from the BBC iPlayer.\nVisit http://po-ru.com/projects/iplayer-downloader/\nfor more information."
24
+ }
25
+ app = IPlayer::GUI::App.new(IPlayer::GUI::MainFrame, about, options)
26
+ app.main_loop
@@ -0,0 +1,5 @@
1
+ require 'iplayer/errors'
2
+ require 'iplayer/browser'
3
+ require 'iplayer/metadata'
4
+ require 'iplayer/subtitles'
5
+ require 'iplayer/downloader'
@@ -0,0 +1,69 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+
4
+ class Net::HTTPResponse # Monkey-patch in some 21st-century functionality
5
+ include Enumerable
6
+
7
+ def cookies
8
+ inject([]){ |acc, (key, value)|
9
+ key == 'set-cookie' ? acc << value.split(/;/).first : acc
10
+ }
11
+ end
12
+
13
+ def to_hash
14
+ @to_hash ||= inject({}){ |hash, (key, value)|
15
+ hash[key] = value
16
+ hash
17
+ }
18
+ end
19
+ end
20
+
21
+ module IPlayer
22
+ class Browser
23
+
24
+ # Used by Safari Mobile
25
+ IPHONE_UA = 'Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ '+
26
+ '(KHTML, like Gecko) Version/3.0 Mobile/1A543a Safari/419.3'
27
+
28
+ # Used by Quicktime
29
+ QT_UA = 'Apple iPhone v1.1.4 CoreMedia v1.0.0.4A102'
30
+
31
+ # Safari, for no good reason
32
+ DESKTOP_UA = 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_4; en-gb) '+
33
+ 'AppleWebKit/525.18 (KHTML, like Gecko) Version/3.1.2 Safari/525.20.1'
34
+
35
+ DEFAULT_HEADERS = {
36
+ 'Accept' => '*/*',
37
+ 'Accept-Language' => 'en',
38
+ 'Connection' => 'keep-alive',
39
+ 'Pragma' => 'no-cache'
40
+ }
41
+
42
+ def initialize(http_class = Net::HTTP)
43
+ @http_class = http_class
44
+ end
45
+
46
+ def get(location, headers={}, &blk)
47
+ url = URI.parse(location)
48
+ http = @http_class.new(url.host, url.port)
49
+ path = url.path
50
+ if url.query
51
+ path << '?' << url.query
52
+ end
53
+ if defined? DEBUG
54
+ puts path
55
+ DEFAULT_HEADERS.merge(headers).each do |k,v|
56
+ puts " -> #{k}: #{v}"
57
+ end
58
+ end
59
+ response = http.request_get(path, DEFAULT_HEADERS.merge(headers), &blk)
60
+ if defined? DEBUG
61
+ response.each do |k,v|
62
+ puts "<- #{k}: #{v}"
63
+ end
64
+ end
65
+ response
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,132 @@
1
+ require 'tempfile'
2
+
3
+ module IPlayer
4
+ class Downloader
5
+
6
+ IPHONE_URL = 'http://www.bbc.co.uk/mobile/iplayer/'
7
+ SELECTOR_URL = 'http://www.bbc.co.uk/mediaselector/3/auth/iplayer_streaming_http_mp4/%s?%s'
8
+ BUG_URL = 'http://www.bbc.co.uk/iplayer/framework/img/o.gif?%d'
9
+ MAX_SEGMENT = 4 * 1024 * 1024
10
+ COPY_BUFFER = 4 * 1024 * 1024
11
+
12
+ include IPlayer::Errors
13
+
14
+ Version = Struct.new(:name, :pid)
15
+
16
+ attr_reader :browser, :pid
17
+ attr_accessor :cookies
18
+
19
+ def self.extract_pid(pid_or_url)
20
+ case pid_or_url
21
+ when %r!/(?:item|episode|programmes)/([a-z0-9]{8})!
22
+ $1
23
+ when %r!^[a-z0-9]{8}$!
24
+ pid_or_url
25
+ when %r!(b0[a-z0-9]{6})!
26
+ $1
27
+ else
28
+ raise NotAPid, pid_or_url
29
+ end
30
+ end
31
+
32
+ def initialize(browser, pid)
33
+ @browser = browser
34
+ @pid = pid
35
+ end
36
+
37
+ def metadata
38
+ @metadata = Metadata.new(@pid, @browser)
39
+ end
40
+
41
+ def get(url, user_agent, options={}, &blk)
42
+ options['User-Agent'] = user_agent
43
+ options['Cookie'] = cookies if cookies
44
+ browser.get(url, options, &blk)
45
+ end
46
+
47
+ def available_versions
48
+ metadata.versions.map{ |name, vpid| Version.new(name, vpid) }
49
+ end
50
+
51
+ def download(version_pid, path, options={}, &blk)
52
+ if options[:subtitles]
53
+ download_subtitles(version_pid, path)
54
+ end
55
+
56
+ if File.exist?(path)
57
+ offset = File.size(path)
58
+ else
59
+ offset = 0
60
+ end
61
+
62
+ File.open(path, 'a+b') do |io|
63
+ location = real_stream_location(version_pid)
64
+ content_length = content_length_from_initial_request(location)
65
+ yield(offset, content_length) if block_given?
66
+
67
+ offset.step(content_length - 1, MAX_SEGMENT) do |first_byte|
68
+ last_byte = [first_byte + MAX_SEGMENT - 1, content_length - 1].min
69
+ get(location, Browser::QT_UA, 'Range'=>"bytes=#{first_byte}-#{last_byte}") do |response|
70
+ response.read_body do |data|
71
+ offset += data.length
72
+ io << data
73
+ yield(offset, content_length) if block_given?
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def request_iphone_page
83
+ response = get(IPHONE_URL, Browser::IPHONE_UA)
84
+ raise ProgrammeDoesNotExist unless response.is_a?(Net::HTTPSuccess)
85
+ self.cookies = response.cookies.join('; ')
86
+ end
87
+
88
+ def request_image_bugs
89
+ get(BUG_URL % [(rand * 100000).floor], Browser::IPHONE_UA)
90
+ end
91
+
92
+ def real_stream_location(version_pid)
93
+ request_iphone_page
94
+ request_image_bugs
95
+
96
+ # Get the auth URL
97
+ r = (rand * 10000000).floor
98
+ selector = SELECTOR_URL % [version_pid, r]
99
+ response = get(selector, Browser::QT_UA, 'Range'=>'bytes=0-1')
100
+
101
+ # It redirects us to the real stream location
102
+ location = response.to_hash['location']
103
+ if location =~ /error\.shtml/
104
+ raise FileUnavailable
105
+ end
106
+ return location
107
+ end
108
+
109
+ def content_length_from_initial_request(location)
110
+ # The first request of CoreMedia is always for the first byte
111
+ response = get(location, Browser::QT_UA, 'Range'=>'bytes=0-1')
112
+
113
+ # We now know the full length of the content
114
+ content_range = response.to_hash['content-range']
115
+ unless content_range
116
+ raise FileUnavailable
117
+ end
118
+ return content_range[/\d+$/].to_i
119
+ end
120
+
121
+ def download_subtitles(version_pid, media_path)
122
+ subtitles = Subtitles.new(version_pid, browser)
123
+ xml = subtitles.w3c_timed_text
124
+ return if xml.nil?
125
+ subtitles_path = media_path.sub(/\.[^\.]+$/, '.xml')
126
+ return if File.exist?(subtitles_path)
127
+ File.open(subtitles_path, 'w') do |f|
128
+ f << xml
129
+ end
130
+ end
131
+ end
132
+ end