iplayer-dl 0.1.16

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.
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