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