ruby-mythtv 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|