olly-iplayer-dl 0.15.2
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/.gitignore +1 -0
- data/COPYING +26 -0
- data/README +14 -0
- data/Rakefile +67 -0
- data/VERSION.yml +4 -0
- data/application.ico +0 -0
- data/bin/iplayer-dl +152 -0
- data/bin/iplayer-dl-gui +26 -0
- data/init.rb +12 -0
- data/iplayer-dl.gemspec +67 -0
- data/lib/iplayer.rb +5 -0
- data/lib/iplayer/browser.rb +69 -0
- data/lib/iplayer/downloader.rb +132 -0
- data/lib/iplayer/errors.rb +53 -0
- data/lib/iplayer/gui/app.rb +84 -0
- data/lib/iplayer/gui/main_frame.rb +123 -0
- data/lib/iplayer/metadata.rb +63 -0
- data/lib/iplayer/preferences.rb +70 -0
- data/lib/iplayer/subtitles.rb +33 -0
- data/lib/iplayer/version.rb +4 -0
- data/setup.rb +1596 -0
- data/share/pixmaps/iplayer-dl/icon128.png +0 -0
- data/share/pixmaps/iplayer-dl/icon16.png +0 -0
- data/share/pixmaps/iplayer-dl/icon32.png +0 -0
- data/share/pixmaps/iplayer-dl/icon48.png +0 -0
- data/share/pixmaps/iplayer-dl/icon64.png +0 -0
- data/test/test_metadata.rb +75 -0
- data/test/test_preferences.rb +175 -0
- data/test/test_subtitles.rb +57 -0
- metadata +84 -0
@@ -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/index.html'
|
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
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module IPlayer
|
2
|
+
module Errors
|
3
|
+
class RecognizedError < RuntimeError
|
4
|
+
end
|
5
|
+
|
6
|
+
class ParsingError < RecognizedError
|
7
|
+
def to_str
|
8
|
+
"Unable to parse the programme page. Perhaps the iPlayer has changed."
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class OutsideUK < RecognizedError
|
13
|
+
def to_str
|
14
|
+
"The BBC's geolocation has determined that you are outside the UK.\n"+
|
15
|
+
"You can try using a UK proxy."
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class FileUnavailable < RecognizedError
|
20
|
+
def to_str
|
21
|
+
"The programme file is not currently available.\n"+
|
22
|
+
"If it's new, try again later."
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class MP4Unavailable < RecognizedError
|
27
|
+
def to_str
|
28
|
+
"This programme is not currently available in an MP3 or MPEG4 version."
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class MetadataError < RecognizedError
|
33
|
+
def to_str
|
34
|
+
"Unable to parse the metadata for this programme.\n"+
|
35
|
+
"As a workaround, you can use the -f option to specify a filename manually."
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class ProgrammeDoesNotExist < RecognizedError
|
40
|
+
def to_str
|
41
|
+
"There is no page for this programme.\n"+
|
42
|
+
"This probably means that the programme does not exist."
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class NotAPid < RecognizedError
|
47
|
+
def to_str
|
48
|
+
"This does not look like a programme ID or a recognised programme URL: "+ message
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'wx'
|
2
|
+
require 'iplayer'
|
3
|
+
|
4
|
+
module IPlayer
|
5
|
+
module GUI
|
6
|
+
class App < Wx::App
|
7
|
+
include IPlayer
|
8
|
+
include IPlayer::Errors
|
9
|
+
|
10
|
+
def initialize(initial_frame_class, about, options)
|
11
|
+
@initial_frame_class = initial_frame_class
|
12
|
+
@about = about
|
13
|
+
@options = options
|
14
|
+
super()
|
15
|
+
if http_proxy = @options[:http_proxy]
|
16
|
+
http_proxy = 'http://' + http_proxy unless http_proxy =~ %r{^http://}
|
17
|
+
u = URI.parse(http_proxy)
|
18
|
+
http = Net::HTTP::Proxy(u.host, u.port)
|
19
|
+
else
|
20
|
+
http = Net::HTTP
|
21
|
+
end
|
22
|
+
@browser = Browser.new(http)
|
23
|
+
@flags = {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def on_init
|
27
|
+
@initial_frame_class.new(self).show
|
28
|
+
end
|
29
|
+
|
30
|
+
def download(pid, path)
|
31
|
+
downloader = Downloader.new(@browser, pid)
|
32
|
+
available_versions = downloader.available_versions
|
33
|
+
raise MP4Unavailable if available_versions.empty?
|
34
|
+
version = available_versions.sort_by{ |v|
|
35
|
+
@options[:type_preference].index(v.name) || 100
|
36
|
+
}.first
|
37
|
+
|
38
|
+
self.yield
|
39
|
+
|
40
|
+
downloader.download(version.pid, path) do |position, max|
|
41
|
+
return if check_flag(:stop_download)
|
42
|
+
yield position, max
|
43
|
+
self.yield
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def stop_download!
|
48
|
+
set_flag(:stop_download)
|
49
|
+
end
|
50
|
+
|
51
|
+
def get_default_filename(pid)
|
52
|
+
self.yield
|
53
|
+
begin
|
54
|
+
metadata = Metadata.new(pid, @browser)
|
55
|
+
title = metadata.full_title
|
56
|
+
filetype = metadata.filetype
|
57
|
+
rescue MetadataError
|
58
|
+
title = pid
|
59
|
+
filetype = 'mov'
|
60
|
+
end
|
61
|
+
"#{ title }.#{ filetype }".gsub(/[^a-z0-9 \-\.]+/i, '')
|
62
|
+
end
|
63
|
+
|
64
|
+
def name
|
65
|
+
@about[:name]
|
66
|
+
end
|
67
|
+
|
68
|
+
def show_about_box
|
69
|
+
Wx::about_box(@about)
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
def set_flag(name)
|
74
|
+
@flags[name] = true
|
75
|
+
end
|
76
|
+
|
77
|
+
def check_flag(name)
|
78
|
+
retval = !!@flags[name]
|
79
|
+
@flags.delete(name)
|
80
|
+
retval
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'wx'
|
2
|
+
require 'iplayer/errors'
|
3
|
+
|
4
|
+
module IPlayer
|
5
|
+
module GUI
|
6
|
+
class MainFrame < Wx::Frame
|
7
|
+
include Wx
|
8
|
+
include IPlayer::Errors
|
9
|
+
|
10
|
+
def initialize(app)
|
11
|
+
@app = app
|
12
|
+
|
13
|
+
super(nil, -1, @app.name, DEFAULT_POSITION, DEFAULT_SIZE, CAPTION|MINIMIZE_BOX|CLOSE_BOX|SYSTEM_MENU)
|
14
|
+
|
15
|
+
@pid_label = StaticText.new(self, -1, "Programme ID")
|
16
|
+
@pid_field = TextCtrl.new(self, -1, "", DEFAULT_POSITION, Size.new(300,-1))
|
17
|
+
@pid_field.set_tool_tip("Use either the short alphanumeric programme identifier or the URL of the viewing page on the iPlayer website.")
|
18
|
+
@download_progress = Gauge.new(self, -1, 1, DEFAULT_POSITION, DEFAULT_SIZE, GA_HORIZONTAL|GA_SMOOTH)
|
19
|
+
@stop_button = Button.new(self, -1, "Stop")
|
20
|
+
evt_button(@stop_button.get_id){ |e| stop_button_clicked(e) }
|
21
|
+
@stop_button.disable
|
22
|
+
@download_button = Button.new(self, -1, "Download...")
|
23
|
+
evt_button(@download_button.get_id){ |e| download_button_clicked(e) }
|
24
|
+
@about_button = Button.new(self, -1, "About...")
|
25
|
+
evt_button(@about_button.get_id){ |e| about_button_clicked(e) }
|
26
|
+
@status_bar = StatusBar.new(self, -1, 0)
|
27
|
+
@status_bar.set_fields_count(3)
|
28
|
+
@status_bar.set_status_widths([-1, 60, 60])
|
29
|
+
set_status_bar(@status_bar)
|
30
|
+
@status_bar.set_status_text("Waiting", 0)
|
31
|
+
|
32
|
+
set_properties
|
33
|
+
do_layout
|
34
|
+
end
|
35
|
+
|
36
|
+
def set_properties
|
37
|
+
set_background_colour(SystemSettings.get_colour(SYS_COLOUR_3DFACE))
|
38
|
+
relative_icon_path = File.join('share', 'pixmaps', 'iplayer-dl', 'icon32.png')
|
39
|
+
icon_path = [
|
40
|
+
File.join(File.dirname($0), '..', relative_icon_path),
|
41
|
+
File.join(File.dirname(__FILE__), '..', '..', '..', relative_icon_path)
|
42
|
+
].find{ |p| File.exist?(p) }
|
43
|
+
self.icon = Icon.new(icon_path, BITMAP_TYPE_PNG) if icon_path
|
44
|
+
end
|
45
|
+
|
46
|
+
def do_layout
|
47
|
+
sizer_main = BoxSizer.new(VERTICAL)
|
48
|
+
sizer_buttons = BoxSizer.new(HORIZONTAL)
|
49
|
+
sizer_input = BoxSizer.new(HORIZONTAL)
|
50
|
+
sizer_input.add(@pid_label, 0, ALL|ALIGN_CENTER_VERTICAL, 4)
|
51
|
+
sizer_input.add(@pid_field, 0, ALL|EXPAND|ALIGN_CENTER_VERTICAL, 4)
|
52
|
+
sizer_main.add(sizer_input, 0, EXPAND, 0)
|
53
|
+
sizer_main.add(@download_progress, 0, ALL|EXPAND, 4)
|
54
|
+
sizer_buttons.add(@about_button, 0, ALL, 4)
|
55
|
+
sizer_buttons.add(@stop_button, 0, ALL, 4)
|
56
|
+
sizer_buttons.add(@download_button, 0, ALL, 4)
|
57
|
+
sizer_main.add(sizer_buttons, 0, ALIGN_RIGHT|ALIGN_CENTER_HORIZONTAL, 0)
|
58
|
+
self.set_sizer(sizer_main)
|
59
|
+
sizer_main.fit(self)
|
60
|
+
layout
|
61
|
+
centre
|
62
|
+
end
|
63
|
+
|
64
|
+
def stop_button_clicked(event)
|
65
|
+
@app.stop_download!
|
66
|
+
@status_bar.set_status_text("Stopped", 0)
|
67
|
+
@download_button.enable
|
68
|
+
@stop_button.disable
|
69
|
+
end
|
70
|
+
|
71
|
+
def download_button_clicked(event)
|
72
|
+
pid = @pid_field.get_value
|
73
|
+
if pid.empty?
|
74
|
+
message_box('You must specify a programme ID before I can download it.')
|
75
|
+
return
|
76
|
+
else
|
77
|
+
begin
|
78
|
+
pid = Downloader.extract_pid(pid)
|
79
|
+
rescue NotAPid => error
|
80
|
+
message_box(error.to_str, :title => 'Error')
|
81
|
+
return
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
@download_button.disable
|
86
|
+
filename = @app.get_default_filename(pid)
|
87
|
+
|
88
|
+
fd = FileDialog.new(nil, 'Save as', '', filename, 'iPlayer Programmes|*.mov;*.mp3|', FD_SAVE)
|
89
|
+
|
90
|
+
if fd.show_modal == ID_OK
|
91
|
+
path = fd.get_path
|
92
|
+
@status_bar.set_status_text(File.basename(path), 0)
|
93
|
+
@download_button.disable
|
94
|
+
@stop_button.enable
|
95
|
+
begin
|
96
|
+
@app.download(pid, path) do |position, max|
|
97
|
+
@download_progress.set_range(max)
|
98
|
+
@download_progress.set_value(position)
|
99
|
+
percentage = "%.1f" % [((1000.0 * position) / max).round / 10.0]
|
100
|
+
@status_bar.set_status_text("#{(max.to_f / 2**20).round} MiB", 1)
|
101
|
+
@status_bar.set_status_text(percentage+"%", 2)
|
102
|
+
end
|
103
|
+
rescue RecognizedError => error
|
104
|
+
message_box(error.to_str, :title => 'Error')
|
105
|
+
rescue Exception => error
|
106
|
+
message_box("#{error.message} (#{error.class})\n#{error.backtrace.first}", :title => 'Error')
|
107
|
+
end
|
108
|
+
@stop_button.disable
|
109
|
+
end
|
110
|
+
@download_button.enable
|
111
|
+
end
|
112
|
+
|
113
|
+
def about_button_clicked(event)
|
114
|
+
@app.show_about_box
|
115
|
+
end
|
116
|
+
|
117
|
+
def message_box(message, options={})
|
118
|
+
options = {:title => @app.name, :buttons => OK}.merge(options)
|
119
|
+
MessageDialog.new(self, message, options[:title], options[:buttons]).show_modal
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'rexml/document'
|
2
|
+
require 'iplayer/errors'
|
3
|
+
|
4
|
+
module IPlayer
|
5
|
+
class Metadata
|
6
|
+
include IPlayer::Errors
|
7
|
+
|
8
|
+
METADATA_URL = 'http://www.bbc.co.uk/iplayer/playlist/%s'
|
9
|
+
|
10
|
+
def initialize(pid, browser)
|
11
|
+
@pid = pid
|
12
|
+
@browser = browser
|
13
|
+
end
|
14
|
+
|
15
|
+
def title
|
16
|
+
mixed_title.split(/:/).first
|
17
|
+
end
|
18
|
+
|
19
|
+
def full_title
|
20
|
+
mixed_title.gsub(/\s*:\s*/, ' - ')
|
21
|
+
end
|
22
|
+
|
23
|
+
def filetype
|
24
|
+
radio? ? 'mp3' : 'mov'
|
25
|
+
end
|
26
|
+
|
27
|
+
def versions
|
28
|
+
versions = {}
|
29
|
+
REXML::XPath.each(metadata, '//playlist/item') do |node|
|
30
|
+
version_pid = node.attributes['identifier'] or next
|
31
|
+
alternate_id = REXML::XPath.first(node, 'alternate').attributes['id'] rescue 'anonymous'
|
32
|
+
versions[alternate_id] = version_pid
|
33
|
+
end
|
34
|
+
versions
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def metadata
|
40
|
+
@metadata ||= REXML::Document.new( @browser.get(METADATA_URL % @pid).body )
|
41
|
+
rescue Exception => e
|
42
|
+
raise MetadataError, e.message
|
43
|
+
end
|
44
|
+
|
45
|
+
def mixed_title
|
46
|
+
REXML::XPath.first(metadata, '//playlist/title').text
|
47
|
+
end
|
48
|
+
|
49
|
+
def programme_type
|
50
|
+
# this could be done more easily if REXML actually worked properly
|
51
|
+
REXML::XPath.each(metadata, '//playlist/item') do |node|
|
52
|
+
kind = node.attributes['kind']
|
53
|
+
return kind if kind
|
54
|
+
end
|
55
|
+
return nil
|
56
|
+
end
|
57
|
+
|
58
|
+
def radio?
|
59
|
+
programme_type == 'radioProgramme'
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|