ruby-mythtv 0.1.0
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/History.txt +3 -0
- data/License.txt +22 -0
- data/README.txt +68 -0
- data/Rakefile +45 -0
- data/lib/mythtv/backend.rb +416 -0
- data/lib/mythtv/recording.rb +67 -0
- data/lib/ruby-mythtv.rb +31 -0
- data/test/test_backend.rb +58 -0
- data/test/test_helper.rb +2 -0
- metadata +65 -0
data/History.txt
ADDED
data/License.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2008 Nick Ludlam
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person
|
4
|
+
obtaining a copy of this software and associated documentation
|
5
|
+
files (the "Software"), to deal in the Software without
|
6
|
+
restriction, including without limitation the rights to use,
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
8
|
+
copies of the Software, and to permit persons to whom the
|
9
|
+
Software is furnished to do so, subject to the following
|
10
|
+
conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be
|
13
|
+
included in all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
17
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
19
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
20
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
21
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
data/README.txt
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
= Ruby-mythtv
|
2
|
+
|
3
|
+
== Description
|
4
|
+
|
5
|
+
A pure Ruby implementation of the MythTV Backend protocol to allow interaction with a MythTV server. Features include browsing and streaming of recordings, and thumbnail generation. See http://github.com/nickludlam/ruby-mythtv for more details.
|
6
|
+
|
7
|
+
== Requirements
|
8
|
+
|
9
|
+
This gem does not yet support multiple protocol versions, so it requires an up-to-date installation of MythTV v0.21, and specifically implements protocol version 40. For more information on the history of the MythTV protocol, see http://www.mythtv.org/wiki/index.php/Protocol
|
10
|
+
|
11
|
+
== Install
|
12
|
+
|
13
|
+
$ gem sources -a http://gems.github.com/ (only required once)
|
14
|
+
$ gem install nickludlam-ruby-mythtv
|
15
|
+
|
16
|
+
== Source
|
17
|
+
|
18
|
+
The ruby-mythtv source is available on GitHub at
|
19
|
+
|
20
|
+
http://github.com/nickludlam/ruby-mythtv
|
21
|
+
|
22
|
+
and can be cloned from
|
23
|
+
|
24
|
+
git://github.com/nickludlam/ruby-mythtv.git
|
25
|
+
|
26
|
+
== Basic usage
|
27
|
+
|
28
|
+
require 'ruby-mythtv'
|
29
|
+
|
30
|
+
# Connect to the server
|
31
|
+
backend = MythTV::Backend.new(:host => 'mythtv.localdomain')
|
32
|
+
|
33
|
+
# Get an array of recordings
|
34
|
+
recordings = backend.query_recordings
|
35
|
+
|
36
|
+
# Generate a thumbnail of the most recent recording, at 60 seconds in from the start
|
37
|
+
preview_thumbnail = @backend.preview_image(recordings[0], :secs_in => 60)
|
38
|
+
File.open('preview_thumbnail.png', 'w') { |f| f.write(preview_thumbnail) }
|
39
|
+
|
40
|
+
== Author
|
41
|
+
|
42
|
+
Written in 2008 by Nick Ludlam <nick@recoil.org>
|
43
|
+
|
44
|
+
== License
|
45
|
+
|
46
|
+
Copyright (c) 2008 Nick Ludlam
|
47
|
+
|
48
|
+
Permission is hereby granted, free of charge, to any person
|
49
|
+
obtaining a copy of this software and associated documentation
|
50
|
+
files (the "Software"), to deal in the Software without
|
51
|
+
restriction, including without limitation the rights to use,
|
52
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
53
|
+
copies of the Software, and to permit persons to whom the
|
54
|
+
Software is furnished to do so, subject to the following
|
55
|
+
conditions:
|
56
|
+
|
57
|
+
The above copyright notice and this permission notice shall be
|
58
|
+
included in all copies or substantial portions of the Software.
|
59
|
+
|
60
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
61
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
62
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
63
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
64
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
65
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
66
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
67
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
68
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
Gem::manage_gems
|
3
|
+
require 'rake/gempackagetask'
|
4
|
+
require 'rake/testtask'
|
5
|
+
|
6
|
+
spec = Gem::Specification.new do |s|
|
7
|
+
s.name = %q{ruby-mythtv}
|
8
|
+
s.version = "0.1.0"
|
9
|
+
|
10
|
+
s.specification_version = 2 if s.respond_to? :specification_version=
|
11
|
+
|
12
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
13
|
+
s.authors = ["Nick Ludlam"]
|
14
|
+
s.date = %q{2008-06-08}
|
15
|
+
s.description = %q{Ruby implementation of the MythTV communication protocol}
|
16
|
+
s.email = %q{nick@recoil.org}
|
17
|
+
s.extra_rdoc_files = ["History.txt", "License.txt", "README.txt"]
|
18
|
+
s.files = ["History.txt", "License.txt", "README.txt", "Rakefile", "lib/ruby-mythtv.rb", "lib/mythtv/backend.rb", "lib/mythtv/recording.rb", "test/test_backend.rb", "test/test_helper.rb"]
|
19
|
+
s.has_rdoc = true
|
20
|
+
s.homepage = %q{http://github.com/nickludlam/ruby-mythtv/}
|
21
|
+
s.rdoc_options = ["--main", "README.txt"]
|
22
|
+
s.require_paths = ["lib"]
|
23
|
+
s.rubyforge_project = %q{ruby-mythtv}
|
24
|
+
s.rubygems_version = %q{0.1.0}
|
25
|
+
s.summary = %q{Ruby implementation of the MythTV backend protocol}
|
26
|
+
s.test_files = ["test/test_backend.rb", "test/test_helper.rb"]
|
27
|
+
end
|
28
|
+
|
29
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
30
|
+
pkg.need_tar = true
|
31
|
+
end
|
32
|
+
|
33
|
+
task :build => "pkg/#{spec.name}-#{spec.version}.gem" do
|
34
|
+
puts "Generated latest version"
|
35
|
+
end
|
36
|
+
|
37
|
+
desc "Run basic unit tests"
|
38
|
+
Rake::TestTask.new("test_units") do |t|
|
39
|
+
t.pattern = 'test/test_*.rb'
|
40
|
+
t.verbose = true
|
41
|
+
t.warning = true
|
42
|
+
end
|
43
|
+
|
44
|
+
desc "Run unit tests as default"
|
45
|
+
task :default => :test_units
|
@@ -0,0 +1,416 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'uri'
|
3
|
+
require 'net/http'
|
4
|
+
|
5
|
+
module MythTV
|
6
|
+
|
7
|
+
# Raised when we get a response that isn't what we expect
|
8
|
+
class CommunicationError < RuntimeError
|
9
|
+
end
|
10
|
+
|
11
|
+
# Raised when we have a protocol version mismatch
|
12
|
+
class ProcolError < RuntimeError
|
13
|
+
end
|
14
|
+
|
15
|
+
# Raised when a method is passed incomplete initialisation information
|
16
|
+
class ArgumentError < RuntimeError
|
17
|
+
end
|
18
|
+
|
19
|
+
class Backend
|
20
|
+
include Socket::Constants
|
21
|
+
|
22
|
+
# Our current protocol implementation. TODO: Consider how we support
|
23
|
+
# multiple protocol versions within a single gem. In theory this is
|
24
|
+
# just a case of limiting the number of attr_accessors that are
|
25
|
+
# class_eval'd onto MythTV::Recording, and bumping the number below
|
26
|
+
MYTHTV_PROTO_VERSION = 40
|
27
|
+
|
28
|
+
# The currently defined field separator in responses
|
29
|
+
FIELD_SEPARATOR = "[]:[]"
|
30
|
+
|
31
|
+
# The payload size we request from the backend when performing a filetransfer
|
32
|
+
TRANSFER_BLOCKSIZE = 65535
|
33
|
+
|
34
|
+
attr_reader :host,
|
35
|
+
:port,
|
36
|
+
:status_port,
|
37
|
+
:connection_type,
|
38
|
+
:filetransfer_port,
|
39
|
+
:filetransfer_size,
|
40
|
+
:socket
|
41
|
+
|
42
|
+
# Open the socket, make a protocol check, and announce we'd like an interactive
|
43
|
+
# session with the backend server
|
44
|
+
def initialize(options = {})
|
45
|
+
default_options = { :port => 6543, :status_port => 6544, :connection_type => :playback }
|
46
|
+
options = default_options.merge(options)
|
47
|
+
|
48
|
+
raise ArgumentError, "You must specify a :host key and value to initialize()" unless options.has_key?(:host)
|
49
|
+
|
50
|
+
@host = options[:host]
|
51
|
+
@port = options[:port]
|
52
|
+
@status_port = options[:status_port]
|
53
|
+
|
54
|
+
@socket = TCPSocket.new(@host, @port)
|
55
|
+
|
56
|
+
check_proto
|
57
|
+
|
58
|
+
if options[:connection_type] == :playback
|
59
|
+
announce_playback()
|
60
|
+
elsif options[:connection_type] == :filetransfer
|
61
|
+
announce_filetransfer(options[:filename])
|
62
|
+
else
|
63
|
+
raise ArgumentError, "Unknown connection type '#{options[:connection_type]}'"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
############################################################################
|
68
|
+
# COMMAND WRAPPERS #########################################################
|
69
|
+
|
70
|
+
# Tell the backend we speak a specific version of the protocol. Raise
|
71
|
+
# an error if the backend does not accept that version.
|
72
|
+
def check_proto
|
73
|
+
send("MYTH_PROTO_VERSION #{MYTHTV_PROTO_VERSION}")
|
74
|
+
response = recv
|
75
|
+
unless response[0] == "ACCEPT" && response[1] == MYTHTV_PROTO_VERSION.to_s
|
76
|
+
close
|
77
|
+
raise ProcolError, response.join(": ")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Announce ourselves as a Playback connection.
|
82
|
+
# http://www.mythtv.org/wiki/index.php/Myth_Protocol_Command_ANN for details
|
83
|
+
def announce_playback
|
84
|
+
client_hostname = Socket.gethostname
|
85
|
+
|
86
|
+
# We don't want to receive broadcast events for this connection
|
87
|
+
want_events = "0"
|
88
|
+
|
89
|
+
send("ANN Playback #{client_hostname} #{want_events}")
|
90
|
+
response = recv
|
91
|
+
|
92
|
+
unless response[0] == "OK"
|
93
|
+
close
|
94
|
+
raise CommunicationError, response.join(": ")
|
95
|
+
else
|
96
|
+
@connection_type = :playback # Not currently used, but may be in later versions
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Announce ourselves as a FileTransfer connection.
|
101
|
+
# http://www.mythtv.org/wiki/index.php/Myth_Protocol_Command_ANN for details
|
102
|
+
def announce_filetransfer(filename = nil)
|
103
|
+
raise ArgumentError, "you must specify a filename" if filename.nil?
|
104
|
+
|
105
|
+
client_hostname = Socket.gethostname
|
106
|
+
|
107
|
+
filename = "/" + filename if filename[0] != "/" # Ensure leading slash
|
108
|
+
|
109
|
+
send("ANN FileTransfer #{client_hostname}#{FIELD_SEPARATOR}#{filename}")
|
110
|
+
response = recv
|
111
|
+
|
112
|
+
# Should get back something like:
|
113
|
+
# OK[]:[]<socket number>[]:[]<file size high 32 bits>[]:[]<file size low 32 bits>
|
114
|
+
unless response[0] == "OK"
|
115
|
+
close
|
116
|
+
raise CommunicationError, response.join(": ")
|
117
|
+
else
|
118
|
+
@filetransfer_port = response[1]
|
119
|
+
@filetransfer_size = [response[3].to_i, response[2].to_i].pack("ll").unpack("Q")[0]
|
120
|
+
@connection_type = :filetransfer # Not currently used, but may be in later versions
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Simple method to query the load of the backend server. Returns a hash with keys for
|
125
|
+
# :one_minute, :five_minute and :fifteen_minute
|
126
|
+
def query_load
|
127
|
+
send("QUERY_LOAD")
|
128
|
+
response = recv
|
129
|
+
{ :one_minute => response[0].to_f, :five_minute => response[1].to_f, :fifteen_minute => response[2].to_f }
|
130
|
+
end
|
131
|
+
|
132
|
+
# List all recordings stored on the backend. You can filter via the storagegroup property,
|
133
|
+
# and this defaults to /Default/, to list the recordings, rather than any which are from
|
134
|
+
# LiveTV sessions.
|
135
|
+
#
|
136
|
+
# Returns an array of MythTV::Recording objects
|
137
|
+
def query_recordings(options = {})
|
138
|
+
default_options = { :filter => { :storagegroup => /Default/ } }
|
139
|
+
options = default_options.merge(options)
|
140
|
+
|
141
|
+
send("QUERY_RECORDINGS Play")
|
142
|
+
response = recv
|
143
|
+
|
144
|
+
recording_count = response.shift.to_i
|
145
|
+
recordings = []
|
146
|
+
|
147
|
+
while recording_count > 0
|
148
|
+
recording_array = response.slice!(0, Recording::RECORDINGS_ELEMENTS.length)
|
149
|
+
|
150
|
+
recording = Recording.new(recording_array)
|
151
|
+
|
152
|
+
options[:filter].each_pair do |k, v|
|
153
|
+
recordings.push(recording) if recording.send(k) =~ v
|
154
|
+
end
|
155
|
+
|
156
|
+
recording_count -= 1
|
157
|
+
end
|
158
|
+
|
159
|
+
recordings = recordings.sort_by { |r| r.startts }
|
160
|
+
recordings.reverse!
|
161
|
+
end
|
162
|
+
|
163
|
+
# This method will return the next free recorder that the backend has available to it
|
164
|
+
# TODO: Fix up the checking of response. Does it return an IP or address in element 1?
|
165
|
+
def get_next_free_recorder
|
166
|
+
send("GET_NEXT_FREE_RECORDER#{FIELD_SEPARATOR}-1")
|
167
|
+
response = recv
|
168
|
+
|
169
|
+
# If we have a recorder free, return the recorder id, otherwise false
|
170
|
+
response[0] == "-1" ? false : response[0].to_i
|
171
|
+
end
|
172
|
+
|
173
|
+
# This will trigger the backend to start recording Live TV from a certain channel.
|
174
|
+
# TODO: This is currently buggy, so avoid until it's fixed in a later release
|
175
|
+
def spawn_live_tv(recorder_id, start_channel = 1)
|
176
|
+
client_hostname = Socket.gethostname
|
177
|
+
spawn_time = Time.now.strftime("%y-%m-%dT%H:%M:%S")
|
178
|
+
chain_id = "livetv-#{client_hostname}-#{spawn_time}"
|
179
|
+
|
180
|
+
query_recorder(recorder_id, "SPAWN_LIVETV", [chain_id, 0, "#{start_channel}"])
|
181
|
+
response = recv
|
182
|
+
|
183
|
+
# If we have an "OK" back, then return the chain_id, otherwise return false
|
184
|
+
response[0] == "OK" ? chain_id : false
|
185
|
+
end
|
186
|
+
|
187
|
+
# This method returns an array of recording objects which describe which programmes
|
188
|
+
# are to be recorded as far as the current EPG data extends
|
189
|
+
def query_scheduled
|
190
|
+
send("QUERY_GETALLSCHEDULED")
|
191
|
+
response = recv
|
192
|
+
|
193
|
+
recording_count = response.shift.to_i
|
194
|
+
recordings = []
|
195
|
+
|
196
|
+
while recording_count > 0
|
197
|
+
recording_array = response.slice!(0, Recording::RECORDINGS_ELEMENTS.length)
|
198
|
+
recordings << Recording.new(recording_array)
|
199
|
+
recording_count -= 1
|
200
|
+
end
|
201
|
+
|
202
|
+
recordings = recordings.sort_by { |r| r.startts }
|
203
|
+
recordings.reverse!
|
204
|
+
end
|
205
|
+
|
206
|
+
# Wrap the QUERY_MEMSTATS backend command. Returns a hash with keys for
|
207
|
+
# :used_memory, :free_memory, :total_swap and :free_swap
|
208
|
+
def query_memstats
|
209
|
+
send("QUERY_MEMSTATS")
|
210
|
+
response = recv
|
211
|
+
|
212
|
+
# We expect to get back 4 elements only for this method
|
213
|
+
raise CommunicationError, "Unexpected response from QUERY_MEMSTATS: #{response.join(":")}" if response.length != 4
|
214
|
+
|
215
|
+
{ :used_memory => response[0].to_i, :free_memory => response[1].to_i, :total_swap => response[2].to_i, :free_swap => response[3].to_i }
|
216
|
+
end
|
217
|
+
|
218
|
+
# Wrap the QUERY_UPTIME backend command. Return a single integer
|
219
|
+
def query_uptime
|
220
|
+
send("QUERY_UPTIME")
|
221
|
+
response = recv
|
222
|
+
|
223
|
+
# We expect to get back 1 element only for this method
|
224
|
+
raise CommunicationError, "Unexpected response from QUERY_UPTIME: #{response.join(":")}" if response.length != 1
|
225
|
+
|
226
|
+
response[0].to_i
|
227
|
+
end
|
228
|
+
|
229
|
+
# This is used when transfering files from the backend. It requests that the next block of data
|
230
|
+
# be sent to the socket, ready for us to recieve
|
231
|
+
def query_filetransfer_transfer_block(sock_num, size)
|
232
|
+
query = "QUERY_FILETRANSFER #{sock_num}#{FIELD_SEPARATOR}REQUEST_BLOCK#{FIELD_SEPARATOR}#{size}"
|
233
|
+
send(query)
|
234
|
+
end
|
235
|
+
|
236
|
+
# Tell the backend we've finished talking to it for the current session
|
237
|
+
def close
|
238
|
+
send("DONE")
|
239
|
+
@socket.close unless @socket.nil?
|
240
|
+
end
|
241
|
+
|
242
|
+
############################################################################
|
243
|
+
# STATUS PORT METHODS
|
244
|
+
|
245
|
+
# Returns a string which contains a PNG image of the this recording. The time offset
|
246
|
+
# into the file defaults to two minutes, and the default image width is 120 pixels.
|
247
|
+
# This uses the separate status port, rather than talking over the backend control port
|
248
|
+
def preview_image(recording, options = {})
|
249
|
+
default_options = { :height => 120, :secs_in => 120 }
|
250
|
+
options = default_options.merge(options)
|
251
|
+
|
252
|
+
# Generate our query string for the MythTV request
|
253
|
+
query_string = "ChanId=#{recording.chanid}&StartTime=#{recording.myth_delimited_recstart}"
|
254
|
+
|
255
|
+
# Add in the optional parameters if they were specified
|
256
|
+
query_string += "&SecsIn=#{options[:secs_in]}" if options[:secs_in]
|
257
|
+
query_string += "&Height=#{options[:height]}" if options[:height]
|
258
|
+
query_string += "&Width=#{options[:width]}" if options[:width]
|
259
|
+
|
260
|
+
url = URI::HTTP.build( { :host => @host,
|
261
|
+
:port => @status_port,
|
262
|
+
:path => "/Myth/GetPreviewImage",
|
263
|
+
:query => query_string } )
|
264
|
+
|
265
|
+
# Make a GET request, and store the image data returned
|
266
|
+
image_data = Net::HTTP.get(url)
|
267
|
+
|
268
|
+
image_data
|
269
|
+
end
|
270
|
+
|
271
|
+
############################################################################
|
272
|
+
# FILETRANSFER RELATED METHODS
|
273
|
+
|
274
|
+
# Yield into the given block with the data buffer of size TRANSFER_BLOCKSIZE
|
275
|
+
def stream(recording, options = {}, &block)
|
276
|
+
|
277
|
+
# Initialise a new connection of connection_type => :filetransfer
|
278
|
+
data_conn = Backend.new(:host => @host,
|
279
|
+
:port => @port,
|
280
|
+
:status_port => @status_port,
|
281
|
+
:connection_type => :filetransfer,
|
282
|
+
:filename => recording.path)
|
283
|
+
|
284
|
+
ft_port = data_conn.filetransfer_port
|
285
|
+
ft_size = data_conn.filetransfer_size
|
286
|
+
|
287
|
+
blocksize = options.has_key?(:transfer_blocksize) ? options[:transfer_blocksize] : TRANSFER_BLOCKSIZE
|
288
|
+
|
289
|
+
total_transfered = 0
|
290
|
+
|
291
|
+
begin
|
292
|
+
# While we still have data to fetch
|
293
|
+
while total_transfered < ft_size
|
294
|
+
# Make a request for the backend to send data
|
295
|
+
query_filetransfer_transfer_block(ft_port, blocksize)
|
296
|
+
|
297
|
+
# Collect the socket data in a string
|
298
|
+
buffer = ""
|
299
|
+
|
300
|
+
while buffer.length < blocksize
|
301
|
+
buffer += data_conn.socket.recv(blocksize)
|
302
|
+
# Special case for when the remainer to fetch is less than TRANSFER_BLOCKSIZE
|
303
|
+
break if total_transfered + buffer.length == ft_size
|
304
|
+
end
|
305
|
+
|
306
|
+
# Yield into the given block to allow the user to process as a stream
|
307
|
+
yield buffer
|
308
|
+
|
309
|
+
total_transfered += buffer.length
|
310
|
+
|
311
|
+
# If the user has only asked for a certain amount of data, stop when we hit this
|
312
|
+
break if options[:max_length] && total_transfered > options[:max_length]
|
313
|
+
end
|
314
|
+
ensure
|
315
|
+
# We need to close the data connection regardless of what is going on when we yield
|
316
|
+
data_conn.close
|
317
|
+
end
|
318
|
+
|
319
|
+
end
|
320
|
+
|
321
|
+
# Download the file to a given location, either with a default filename, or
|
322
|
+
# one specified by the caller
|
323
|
+
def download(recording, filename = nil)
|
324
|
+
|
325
|
+
# If no filename is given, we default to <title>_<recstartts>.<extension>
|
326
|
+
if filename.nil?
|
327
|
+
filename = recording.title + "_" +
|
328
|
+
recording.myth_nondelimited_recstart + File.extname(recording.filename)
|
329
|
+
end
|
330
|
+
|
331
|
+
File.open(filename, "wb") do |f|
|
332
|
+
stream(recording) { |data| f.write(data) }
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
# TODO: The LiveTV methods are still work-in-progress.
|
337
|
+
def start_livetv(channel = 1)
|
338
|
+
# If we have a free recorder...
|
339
|
+
if recorder_id = get_next_free_recorder
|
340
|
+
puts "Got a recorder ID of #{recorder_id}"
|
341
|
+
# If we can spawn live tv...
|
342
|
+
if chain_id = spawn_live_tv(recorder_id, channel)
|
343
|
+
puts "Got a chain ID of #{chain_id}"
|
344
|
+
# Send the two backend event messages
|
345
|
+
backend_message(["RECORDING_LIST_CHANGE", "empty"])
|
346
|
+
puts "Sent RECORDING_LIST_CHANGE"
|
347
|
+
backend_message(["LIVETV_CHAIN UPDATE #{chain_id}", "empty"])
|
348
|
+
puts "Sent LIVETV_CHAIN UPDATE"
|
349
|
+
|
350
|
+
# Find the filename from here...
|
351
|
+
query_recorder(recorder_id, "GET_CURRENT_RECORDING")
|
352
|
+
cur_rec = recv
|
353
|
+
puts "Current recording is:"
|
354
|
+
puts cur_rec.inspect
|
355
|
+
recording = Recording.new(cur_rec)
|
356
|
+
else
|
357
|
+
puts "spawn_live_tv returned with false or nil"
|
358
|
+
return false
|
359
|
+
end
|
360
|
+
else
|
361
|
+
puts "get_next_free_recorder returned with false or nil"
|
362
|
+
return false
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
# TODO: Finish this off. Check response?
|
367
|
+
def stop_livetv(recorder_id)
|
368
|
+
query_recorder(recorder_id, "STOP_LIVETV")
|
369
|
+
response = recv
|
370
|
+
end
|
371
|
+
|
372
|
+
private
|
373
|
+
|
374
|
+
# Private method for the generic QUERY_RECORDER command, which itself
|
375
|
+
# wraps a number of sub-commands.
|
376
|
+
def query_recorder(recorder_id, sub_command, options = [])
|
377
|
+
# place the recorder_id and sub_command strings on the front of options
|
378
|
+
# and join with the FIELD_SEPARATOR. This forms the QUERY_RECORDER command
|
379
|
+
cmd_string = options.unshift(recorder_id, sub_command)
|
380
|
+
send("QUERY_RECORDER #{options.join(FIELD_SEPARATOR)}")
|
381
|
+
end
|
382
|
+
|
383
|
+
# Wraps the BACKEND_MESSAGE command, which just sends events to the
|
384
|
+
# backend, with no responses provided. Only events expected are
|
385
|
+
# RECORDING_LIST_CHANGE, and LIVETV CHAIN_UPDATE
|
386
|
+
def backend_message(event_message = [])
|
387
|
+
event_message.unshift("BACKEND_MESSAGE")
|
388
|
+
send(message.join(FIELD_SEPARATOR))
|
389
|
+
end
|
390
|
+
|
391
|
+
# Send a message to the MythTV Backend
|
392
|
+
def send(message)
|
393
|
+
length = sprintf("%-8d", message.length)
|
394
|
+
@socket.write("#{length}#{message}")
|
395
|
+
end
|
396
|
+
|
397
|
+
# Fetch a reply from the MythTV Backend. Automatically splits around the
|
398
|
+
# FIELD_SEPARATOR
|
399
|
+
def recv
|
400
|
+
count = @socket.recv(8).to_i
|
401
|
+
|
402
|
+
# Where we accumulate the response
|
403
|
+
response = ""
|
404
|
+
|
405
|
+
# Keep fetching data until we have received the entire response
|
406
|
+
while (count > 0) do
|
407
|
+
buf = @socket.recv(TRANSFER_BLOCKSIZE)
|
408
|
+
response += buf
|
409
|
+
count -= buf.length
|
410
|
+
end
|
411
|
+
|
412
|
+
response.split(FIELD_SEPARATOR)
|
413
|
+
end
|
414
|
+
|
415
|
+
end # end Backend
|
416
|
+
end # end MythTV
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module MythTV
|
2
|
+
|
3
|
+
class Recording
|
4
|
+
# Represents a recording that is held on the MythTV Backend server we are communicating with.
|
5
|
+
#
|
6
|
+
# The keys included here, and the order in which they are specified seem to change between protocol version bumps
|
7
|
+
# on the MythTV backend, so this array affects both the initialize() and to_s() methods.
|
8
|
+
#
|
9
|
+
# Found inside mythtv/libs/libmythtv/programinfo.cpp in the MythTV subversion repository
|
10
|
+
RECORDINGS_ELEMENTS = [ :title, :subtitle, :description, :category, :chanid, :chanstr, :chansign, :channame,
|
11
|
+
:pathname, :filesize_hi, :filesize_lo, :startts, :endts, :duplicate, :shareable, :findid,
|
12
|
+
:hostname, :sourceid, :cardid, :inputid, :recpriority, :recstatus, :recordid, :rectype,
|
13
|
+
:dupin, :dupmethod, :recstartts, :recendts, :repeat, :programflags, :recgroup, :chancommfree,
|
14
|
+
:chanOutputFilters, :seriesid, :programid, :lastmodified, :stars, :originalAirDate,
|
15
|
+
:hasAirDate, :playgroup, :recpriority2, :parentid, :storagegroup, :audioproperties,
|
16
|
+
:videoproperties, :subtitleType ]
|
17
|
+
|
18
|
+
# Warning, metaprogramming ahead: Create attr_accessors for each symbol defined in MythTVRecording::RECORDINGS_ELEMENTS
|
19
|
+
def initialize(recording_array)
|
20
|
+
class << self;self;end.class_eval { RECORDINGS_ELEMENTS.each { |field| attr_accessor field } }
|
21
|
+
|
22
|
+
RECORDINGS_ELEMENTS.each_with_index do |field, i|
|
23
|
+
send(field.to_s + '=', recording_array[i])
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# A string representation of a Recording is used when we converse with the MythTV Backend about that recording
|
28
|
+
def to_s
|
29
|
+
RECORDINGS_ELEMENTS.collect { |field| self.send(field.to_s) }.join(MythTV::Backend::FIELD_SEPARATOR) + MythTV::Backend::FIELD_SEPARATOR
|
30
|
+
end
|
31
|
+
|
32
|
+
# Convenience methods to access the start and end times as Time objects, and duration as an Float
|
33
|
+
def start; Time.at(recstartts.to_i); end
|
34
|
+
def end; Time.at(recendts.to_i); end
|
35
|
+
def duration; self.end - self.start; end
|
36
|
+
|
37
|
+
# Cribbed from the Mythweb PHP code. Required for some method calls to the backend
|
38
|
+
def myth_delimited_recstart; myth_format_time(recstartts, :delimited); end
|
39
|
+
|
40
|
+
# Formats the start time for use in the copy process, as the latter half of the filename is a non-delimited time string
|
41
|
+
def myth_nondelimited_recstart; myth_format_time(recstartts, :nondelimited); end
|
42
|
+
|
43
|
+
# Convert the lo/hi long representation of the filesize into a string
|
44
|
+
def filesize
|
45
|
+
[filesize_lo.to_i, filesize_hi.to_i].pack("ll").unpack("Q").to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
# Fetch the path section of the pathname
|
49
|
+
def path; URI.parse(pathname).path; end
|
50
|
+
|
51
|
+
# Strip the filename out from the path returned by the server
|
52
|
+
def filename; File.basename(URI.parse(pathname).path); end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def myth_format_time(timestamp, format = :nondelimited)
|
57
|
+
timestamp = timestamp.to_i if timestamp.class != Bignum
|
58
|
+
case format
|
59
|
+
when :nondelimited
|
60
|
+
Time.at(timestamp).strftime("%Y%m%d%H%M%S")
|
61
|
+
when :delimited
|
62
|
+
Time.at(timestamp).strftime("%Y-%m-%dT%H:%M:%S")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end # end Recording
|
67
|
+
end # end MythTV
|
data/lib/ruby-mythtv.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# The MIT License
|
2
|
+
#
|
3
|
+
# Copyright (c) 2008 Nick Ludlam
|
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
|
+
|
23
|
+
module MythTV;
|
24
|
+
VERSION = '0.1.0'
|
25
|
+
end
|
26
|
+
|
27
|
+
$:.unshift(File.dirname(__FILE__))
|
28
|
+
|
29
|
+
require 'mythtv/backend.rb'
|
30
|
+
require 'mythtv/recording.rb'
|
31
|
+
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/test_helper.rb'
|
2
|
+
|
3
|
+
class TestBackend < Test::Unit::TestCase
|
4
|
+
def setup
|
5
|
+
abort("\nERROR: You must set the environment variable MYTHTV_BACKEND to the name of your MythTV backend server\n\n") unless ENV['MYTHTV_BACKEND']
|
6
|
+
host = ENV['MYTHTV_BACKEND']
|
7
|
+
@backend = MythTV::Backend.new(:host => host)
|
8
|
+
end
|
9
|
+
|
10
|
+
def teardown
|
11
|
+
@backend.close
|
12
|
+
end
|
13
|
+
|
14
|
+
# Assuming the system is up for more than 0 seconds!
|
15
|
+
def test_connection
|
16
|
+
uptime = @backend.query_uptime
|
17
|
+
assert uptime > 0
|
18
|
+
end
|
19
|
+
|
20
|
+
# Assuming there is at least one recording on the test server
|
21
|
+
def test_get_recordings
|
22
|
+
recordings = @backend.query_recordings
|
23
|
+
assert recordings.length > 0
|
24
|
+
assert_kind_of MythTV::Recording, recordings[0]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Assuming there is at least one scheduled recording on the test server
|
28
|
+
def test_get_scheduled
|
29
|
+
scheduled = @backend.query_scheduled
|
30
|
+
assert scheduled.length > 0
|
31
|
+
assert_kind_of MythTV::Recording, scheduled[0]
|
32
|
+
end
|
33
|
+
|
34
|
+
# Test the generation of a preview image
|
35
|
+
def test_make_preview_image
|
36
|
+
recordings = @backend.query_recordings
|
37
|
+
|
38
|
+
recording = recordings[0]
|
39
|
+
test_image = @backend.preview_image(recording, :secs_in => 1)
|
40
|
+
assert test_image.length > 0
|
41
|
+
|
42
|
+
# Define an array of the decimal values of the PNG magic number
|
43
|
+
png_sig = [137, 80, 78, 71, 13, 10, 26, 10]
|
44
|
+
test_image_sig = (0..7).collect { |i| test_image[i] }
|
45
|
+
|
46
|
+
assert_equal test_image_sig, png_sig
|
47
|
+
end
|
48
|
+
|
49
|
+
# Don't run this by default as it takes a while. Possibly limit to 100kB?
|
50
|
+
#def test_download
|
51
|
+
# recordings = @backend.query_recordings
|
52
|
+
#
|
53
|
+
# recording = recordings[-2]
|
54
|
+
# @backend.download(recording)
|
55
|
+
#end
|
56
|
+
|
57
|
+
|
58
|
+
end
|
data/test/test_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ruby-mythtv
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nick Ludlam
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-06-08 00:00:00 +01:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: Ruby implementation of the MythTV communication protocol
|
17
|
+
email: nick@recoil.org
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- History.txt
|
24
|
+
- License.txt
|
25
|
+
- README.txt
|
26
|
+
files:
|
27
|
+
- History.txt
|
28
|
+
- License.txt
|
29
|
+
- README.txt
|
30
|
+
- Rakefile
|
31
|
+
- lib/ruby-mythtv.rb
|
32
|
+
- lib/mythtv/backend.rb
|
33
|
+
- lib/mythtv/recording.rb
|
34
|
+
- test/test_backend.rb
|
35
|
+
- test/test_helper.rb
|
36
|
+
has_rdoc: true
|
37
|
+
homepage: http://github.com/nickludlam/ruby-mythtv/
|
38
|
+
post_install_message:
|
39
|
+
rdoc_options:
|
40
|
+
- --main
|
41
|
+
- README.txt
|
42
|
+
require_paths:
|
43
|
+
- lib
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: "0"
|
49
|
+
version:
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: "0"
|
55
|
+
version:
|
56
|
+
requirements: []
|
57
|
+
|
58
|
+
rubyforge_project: ruby-mythtv
|
59
|
+
rubygems_version: 1.1.1
|
60
|
+
signing_key:
|
61
|
+
specification_version: 2
|
62
|
+
summary: Ruby implementation of the MythTV backend protocol
|
63
|
+
test_files:
|
64
|
+
- test/test_backend.rb
|
65
|
+
- test/test_helper.rb
|