1 | #!/usr/bin/python |
---|
2 | |
---|
3 | import logging |
---|
4 | |
---|
5 | log = logging.getLogger('mythtv') |
---|
6 | log.setLevel(logging.DEBUG) |
---|
7 | ch = logging.StreamHandler() |
---|
8 | ch.setFormatter(logging.Formatter('%(levelname)s - %(message)s')) |
---|
9 | log.addHandler(ch) |
---|
10 | |
---|
11 | import os |
---|
12 | import sys |
---|
13 | import socket |
---|
14 | import shlex |
---|
15 | import socket |
---|
16 | import code |
---|
17 | |
---|
18 | from datetime import datetime |
---|
19 | |
---|
20 | try: |
---|
21 | import MySQLdb |
---|
22 | except: |
---|
23 | log.critical("MySQLdb (python-mysqldb) is required but is not found.") |
---|
24 | sys.exit(1) |
---|
25 | |
---|
26 | RECSTATUS = { |
---|
27 | 'TunerBusy': -8, |
---|
28 | 'LowDiskSpace': -7, |
---|
29 | 'Cancelled': -6, |
---|
30 | 'Deleted': -5, |
---|
31 | 'Aborted': -4, |
---|
32 | 'Recorded': -3, |
---|
33 | 'Recording': -2, |
---|
34 | 'WillRecord': -1, |
---|
35 | 'Unknown': 0, |
---|
36 | 'DontRecord': 1, |
---|
37 | 'PreviousRecording': 2, |
---|
38 | 'CurrentRecording': 3, |
---|
39 | 'EarlierShowing': 4, |
---|
40 | 'TooManyRecordings': 5, |
---|
41 | 'NotListed': 6, |
---|
42 | 'Conflict': 7, |
---|
43 | 'LaterShowing': 8, |
---|
44 | 'Repeat': 9, |
---|
45 | 'Inactive': 10, |
---|
46 | 'NeverRecord': 11, |
---|
47 | } |
---|
48 | |
---|
49 | BACKEND_SEP = '[]:[]' |
---|
50 | PROTO_VERSION = 34 |
---|
51 | PROGRAM_FIELDS = 43 |
---|
52 | |
---|
53 | def get_database_connection(): |
---|
54 | """ |
---|
55 | A connection to the mythtv database |
---|
56 | """ |
---|
57 | config_files = [ |
---|
58 | '/usr/local/share/mythtv/mysql.txt', |
---|
59 | '/usr/share/mythtv/mysql.txt', |
---|
60 | '/usr/local/etc/mythtv/mysql.txt', |
---|
61 | '/etc/mythtv/mysql.txt', |
---|
62 | os.path.expanduser('~/.mythtv/mysql.txt'), |
---|
63 | ] |
---|
64 | if 'MYTHCONFDIR' in os.environ: |
---|
65 | config_locations.append('%s/mysql.txt' % os.environ['MYTHCONFDIR']) |
---|
66 | |
---|
67 | found_config = False |
---|
68 | for config_file in config_files: |
---|
69 | try: |
---|
70 | config = shlex.shlex(open(config_file)) |
---|
71 | except: |
---|
72 | continue |
---|
73 | |
---|
74 | token = config.get_token() |
---|
75 | db_host = db_user = db_password = None |
---|
76 | while token != config.eof and (db_host == None or db_user == None or db_password == None): |
---|
77 | if token == "DBHostName": |
---|
78 | if config.get_token() == "=": |
---|
79 | db_host = config.get_token() |
---|
80 | elif token == "DBUserName": |
---|
81 | if config.get_token() == "=": |
---|
82 | db_user = config.get_token() |
---|
83 | elif token == "DBPassword": |
---|
84 | if config.get_token() == "=": |
---|
85 | db_password = config.get_token() |
---|
86 | |
---|
87 | token = config.get_token() |
---|
88 | log.debug('Using config %s' % config_file) |
---|
89 | found_config = True |
---|
90 | break |
---|
91 | |
---|
92 | if not found_config: |
---|
93 | log.critical('Unable to find config') |
---|
94 | sys.exit(1) |
---|
95 | return MySQLdb.connect(user=db_user, host=db_host, passwd=db_password, db="mythconverg") |
---|
96 | |
---|
97 | class MythTV: |
---|
98 | """ |
---|
99 | A connection to MythTV |
---|
100 | """ |
---|
101 | def __init__(self, conn_type='Monitor'): |
---|
102 | self.db = get_database_connection() |
---|
103 | self.master_host = self.getSetting('MasterServerIP') |
---|
104 | self.master_port = int(self.getSetting('MasterServerPort')) |
---|
105 | |
---|
106 | if not self.master_host: |
---|
107 | log.critical('Unable to find MasterServerIP in database') |
---|
108 | sys.exit(1) |
---|
109 | if not self.master_port: |
---|
110 | log.critical('Unable to find MasterServerPort in database') |
---|
111 | sys.exit(1) |
---|
112 | |
---|
113 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
---|
114 | self.socket.settimeout(10) |
---|
115 | self.socket.connect((self.master_host, self.master_port)) |
---|
116 | res = self.backendCommand('MYTH_PROTO_VERSION %s' % PROTO_VERSION).split(BACKEND_SEP) |
---|
117 | if res[0] == 'REJECT': |
---|
118 | log.critical('Backend has version %s and we speak version %s', res[1], PROTO_VERSION) |
---|
119 | sys.exit(1) |
---|
120 | res = self.backendCommand('ANN %s %s 0' % (conn_type, socket.gethostname())) |
---|
121 | if res != 'OK': |
---|
122 | log.critical('Unexpected answer to ANN command: %s', res) |
---|
123 | |
---|
124 | def getSetting(self, value, hostname=None): |
---|
125 | """ |
---|
126 | Returns the value for the given MythTV setting. |
---|
127 | |
---|
128 | Returns None if the setting was not found. If multiple rows are |
---|
129 | found (multiple hostnames), returns the value of the first one. |
---|
130 | """ |
---|
131 | log.debug('Looking for setting %s for host %s', value, hostname) |
---|
132 | c = self.db.cursor() |
---|
133 | if hostname is None: |
---|
134 | c.execute(""" |
---|
135 | SELECT data |
---|
136 | FROM settings |
---|
137 | WHERE value LIKE(%s) AND hostname IS NULL LIMIT 1""", |
---|
138 | (value,)) |
---|
139 | else: |
---|
140 | c.execute(""" |
---|
141 | SELECT data |
---|
142 | FROM settings |
---|
143 | WHERE value LIKE(%s) AND hostname LIKE(%s) LIMIT 1""", |
---|
144 | (value, hostname)) |
---|
145 | row = c.fetchone() |
---|
146 | c.close() |
---|
147 | |
---|
148 | if row: |
---|
149 | return row[0] |
---|
150 | else: |
---|
151 | return None |
---|
152 | |
---|
153 | def backendCommand(self, data): |
---|
154 | """ |
---|
155 | Sends a command via a socket to the mythbackend in |
---|
156 | the format that it expects. Returns the result from |
---|
157 | the backend |
---|
158 | """ |
---|
159 | def recv(): |
---|
160 | """ |
---|
161 | Reads the data returned fomr the backend |
---|
162 | """ |
---|
163 | # The first 8 bytes of the response gives us the length |
---|
164 | data = self.socket.recv(8) |
---|
165 | try: |
---|
166 | length = int(data) |
---|
167 | except: |
---|
168 | return '' |
---|
169 | data = [] |
---|
170 | while length > 0: |
---|
171 | chunk = self.socket.recv(length) |
---|
172 | length = length - len(chunk) |
---|
173 | data.append(chunk) |
---|
174 | return ''.join(data) |
---|
175 | |
---|
176 | command = '%-8d%s' % (len(data), data) |
---|
177 | log.debug('Sending command: %s' % command) |
---|
178 | self.socket.send(command) |
---|
179 | return recv() |
---|
180 | |
---|
181 | def getPendingRecordings(self): |
---|
182 | """ |
---|
183 | Returns a list of Program objects which are scheduled to be |
---|
184 | recorded |
---|
185 | """ |
---|
186 | programs = [] |
---|
187 | res = self.backendCommand('QUERY_GETALLPENDING').split(BACKEND_SEP) |
---|
188 | has_conflict = int(res.pop(0)) |
---|
189 | num_progs = int(res.pop(0)) |
---|
190 | log.debug('%s pending recordings', num_progs) |
---|
191 | for i in range(num_progs): |
---|
192 | programs.append( |
---|
193 | Program(res[i * PROGRAM_FIELDS:(i * PROGRAM_FIELDS) + PROGRAM_FIELDS])) |
---|
194 | return programs |
---|
195 | |
---|
196 | def getScheduledRecordings(self): |
---|
197 | """ |
---|
198 | Returns a list of Program objects which are scheduled to be |
---|
199 | recorded |
---|
200 | """ |
---|
201 | programs = [] |
---|
202 | res = self.backendCommand('QUERY_GETALLSCHEDULED').split(BACKEND_SEP) |
---|
203 | num_progs = int(res.pop(0)) |
---|
204 | log.debug('%s scheduled recordings', num_progs) |
---|
205 | for i in range(num_progs): |
---|
206 | programs.append( |
---|
207 | Program(res[i * PROGRAM_FIELDS:(i * PROGRAM_FIELDS) + PROGRAM_FIELDS])) |
---|
208 | return programs |
---|
209 | |
---|
210 | def getUpcomingRecordings(self): |
---|
211 | """ |
---|
212 | Returns a list of Program objects for programs which are actually |
---|
213 | going to be recorded. |
---|
214 | """ |
---|
215 | def sort_programs_by_starttime(x, y): |
---|
216 | if x.starttime > y.starttime: |
---|
217 | return 1 |
---|
218 | elif x.starttime == y.starttime: |
---|
219 | return 0 |
---|
220 | else: |
---|
221 | return -1 |
---|
222 | programs = [] |
---|
223 | res = self.getPendingRecordings() |
---|
224 | for p in res: |
---|
225 | if p.recstatus == RECSTATUS['WillRecord']: |
---|
226 | programs.append(p) |
---|
227 | programs.sort(sort_programs_by_starttime) |
---|
228 | return programs |
---|
229 | |
---|
230 | def getFreeRecorderList(self): |
---|
231 | """ |
---|
232 | Returns a list of free recorders, or an empty list if none |
---|
233 | """ |
---|
234 | res = self.backendCommand('GET_FREE_RECORDER_LIST').split(BACKEND_SEP) |
---|
235 | recorders = [int(d) for d in res] |
---|
236 | if recorders[0]: |
---|
237 | return recorders |
---|
238 | else: |
---|
239 | return [] |
---|
240 | |
---|
241 | def getCurrentRecording(self, recorder): |
---|
242 | res = self.backendCommand('QUERY_RECORDER %s[]:[]GET_CURRENT_RECORDING' % recorder) |
---|
243 | return Program(res.split(BACKEND_SEP)) |
---|
244 | |
---|
245 | def isRecording(self, recorder): |
---|
246 | res = self.backendCommand('QUERY_RECORDER %s[]:[]IS_RECORDING' % recorder) |
---|
247 | if res == '1': |
---|
248 | return True |
---|
249 | else: |
---|
250 | return False |
---|
251 | |
---|
252 | class MythVideo(): |
---|
253 | def __init__(self): |
---|
254 | self.db = get_database_connection() |
---|
255 | |
---|
256 | def pruneMetadata(self): |
---|
257 | c = self.db.cursor() |
---|
258 | c.execute(""" |
---|
259 | SELECT intid, filename |
---|
260 | FROM videometadata""") |
---|
261 | |
---|
262 | row = c.fetchone() |
---|
263 | while row is not None: |
---|
264 | intid = row[0] |
---|
265 | filename = row[1] |
---|
266 | if not os.path.exists(filename): |
---|
267 | log.info("%s not exist, removing metadata..." % filename) |
---|
268 | c2 = self.db.cursor() |
---|
269 | c2.execute("""DELETE FROM videometadata WHERE intid = %s""", (intid,)) |
---|
270 | c2.close() |
---|
271 | row = c.fetchone() |
---|
272 | c.close() |
---|
273 | |
---|
274 | def getGenreId(self, genre_name): |
---|
275 | """ |
---|
276 | Find the id of the given genre from MythDB. |
---|
277 | |
---|
278 | If the genre does not exist, insert it and return its id. |
---|
279 | """ |
---|
280 | c = self.db.cursor() |
---|
281 | c.execute("SELECT intid FROM videocategory WHERE lower(category) = %s", (genre_name,)) |
---|
282 | row = c.fetchone() |
---|
283 | c.close() |
---|
284 | |
---|
285 | if row is not None: |
---|
286 | return row[0] |
---|
287 | |
---|
288 | # Insert a new genre. |
---|
289 | c = self.db.cursor() |
---|
290 | c.execute("INSERT INTO videocategory(category) VALUES (%s)", (genre_name.capitalize(),)) |
---|
291 | newid = c.lastrowid |
---|
292 | c.close() |
---|
293 | |
---|
294 | return newid |
---|
295 | |
---|
296 | def getMetadataId(self, videopath): |
---|
297 | """ |
---|
298 | Finds the MythVideo metadata id for the given video path from the MythDB, if any. |
---|
299 | |
---|
300 | Returns None if no metadata was found. |
---|
301 | """ |
---|
302 | c = self.db.cursor() |
---|
303 | c.execute(""" |
---|
304 | SELECT intid |
---|
305 | FROM videometadata |
---|
306 | WHERE filename = %s""", (videopath,)) |
---|
307 | row = c.fetchone() |
---|
308 | c.close() |
---|
309 | |
---|
310 | if row is not None: |
---|
311 | return row[0] |
---|
312 | else: |
---|
313 | return None |
---|
314 | |
---|
315 | def getMetadata(self, id): |
---|
316 | """ |
---|
317 | Finds the MythVideo metadata for the given id from the MythDB, if any. |
---|
318 | |
---|
319 | Returns None if no metadata was found. |
---|
320 | """ |
---|
321 | c = self.db.cursor() |
---|
322 | c.execute(""" |
---|
323 | SELECT * |
---|
324 | FROM videometadata |
---|
325 | WHERE intid = %s""", (id,)) |
---|
326 | row = c.fetchone() |
---|
327 | c.close() |
---|
328 | |
---|
329 | if row is not None: |
---|
330 | return row |
---|
331 | else: |
---|
332 | return None |
---|
333 | |
---|
334 | def setMetadata(self, data, id=None): |
---|
335 | c = self.db.cursor() |
---|
336 | if id is None: |
---|
337 | fields = ', '.join(data.keys()) |
---|
338 | format_string = ', '.join(['%s' for d in data.values()]) |
---|
339 | sql = "INSERT INTO videometadata(%s) VALUES(%s)" % (fields, format_string) |
---|
340 | c.execute(sql, data.values()) |
---|
341 | intid = c.lastrowid |
---|
342 | c.close() |
---|
343 | return intid |
---|
344 | else: |
---|
345 | log.debug('Updating metadata for %s' % id) |
---|
346 | format_string = ', '.join(['%s = %%s' % d for d in data]) |
---|
347 | sql = "UPDATE videometadata SET %s WHERE intid = %%s" % format_string |
---|
348 | sql_values = data.values() |
---|
349 | sql_values.append(id) |
---|
350 | c.execute(sql, sql_values) |
---|
351 | c.close() |
---|
352 | |
---|
353 | class Program: |
---|
354 | def __init__(self, data): |
---|
355 | """ |
---|
356 | Load the list of data into the object |
---|
357 | """ |
---|
358 | self.title = data[0] |
---|
359 | self.subtitle = data[1] |
---|
360 | self.description = data[2] |
---|
361 | self.category = data[3] |
---|
362 | try: |
---|
363 | self.chanid = int(data[4]) |
---|
364 | except ValueError: |
---|
365 | self.chanid = None |
---|
366 | self.channum = data[5] #chanstr |
---|
367 | self.callsign = data[6] #chansign |
---|
368 | self.channame = data[7] |
---|
369 | self.filename = data[8] #pathname |
---|
370 | self.fs_high = data[9] |
---|
371 | self.fs_low = data[10] |
---|
372 | self.starttime = datetime.fromtimestamp(int(data[11])) # startts |
---|
373 | self.endtime = datetime.fromtimestamp(int(data[12])) #endts |
---|
374 | self.duplicate = int(data[13]) |
---|
375 | self.shareable = int(data[14]) |
---|
376 | self.findid = int(data[15]) |
---|
377 | self.hostname = data[16] |
---|
378 | self.sourceid = int(data[17]) |
---|
379 | self.cardid = int(data[18]) |
---|
380 | self.inputid = int(data[19]) |
---|
381 | self.recpriority = int(data[20]) |
---|
382 | self.recstatus = int(data[21]) |
---|
383 | self.recordid = int(data[22]) |
---|
384 | self.rectype = data[23] |
---|
385 | self.dupin = data[24] |
---|
386 | self.dupmethod = data[25] |
---|
387 | self.recstartts = datetime.fromtimestamp(int(data[26])) |
---|
388 | self.recendts = datetime.fromtimestamp(int(data[27])) |
---|
389 | self.repeat = int(data[28]) |
---|
390 | self.programflags = data[29] |
---|
391 | self.recgroup = data[30] |
---|
392 | self.commfree = int(data[31]) |
---|
393 | self.outputfilters = data[32] |
---|
394 | self.seriesid = data[33] |
---|
395 | self.programid = data[34] |
---|
396 | self.lastmodified = data[35] |
---|
397 | self.stars = float(data[36]) |
---|
398 | self.airdate = data[37] |
---|
399 | self.hasairdate = int(data[38]) |
---|
400 | self.playgroup = data[39] |
---|
401 | self.recpriority2 = int(data[40]) |
---|
402 | self.parentid = data[41] |
---|
403 | self.storagegroup = data[42] |
---|
404 | |
---|
405 | if __name__ == '__main__': |
---|
406 | banner = "'m' is a MythTV instance." |
---|
407 | try: |
---|
408 | import readline, rlcompleter |
---|
409 | except: |
---|
410 | pass |
---|
411 | else: |
---|
412 | readline.parse_and_bind("tab: complete") |
---|
413 | banner = banner + " TAB completion is available." |
---|
414 | m = MythTV() |
---|
415 | namespace = globals().copy() |
---|
416 | namespace.update(locals()) |
---|
417 | code.InteractiveConsole(namespace).interact(banner) |
---|