| | 1 | #!/usr/bin/python |
| | 2 | |
| | 3 | # vim:ts=4 sw=4 nowrap: |
| | 4 | |
| | 5 | # version: 0.1 |
| | 6 | # date: 2008-02-06 |
| | 7 | # by: trisooma |
| | 8 | # description: Part of mythtv python bindings. Provides the base class for |
| | 9 | # using mythtv from python |
| | 10 | |
| | 11 | # system imports |
| | 12 | import os |
| | 13 | import sys |
| | 14 | import socket |
| | 15 | import shlex |
| | 16 | import socket |
| | 17 | import code |
| | 18 | from datetime import datetime |
| | 19 | # package deps |
| | 20 | from log import * |
| | 21 | from db import * |
| | 22 | |
| | 23 | log = Log(INFO, '%(levelname)s - %(message)s', 'MythTV') |
| | 24 | |
| | 25 | RECSTATUS = { |
| | 26 | 'TunerBusy': -8, |
| | 27 | 'LowDiskSpace': -7, |
| | 28 | 'Cancelled': -6, |
| | 29 | 'Deleted': -5, |
| | 30 | 'Aborted': -4, |
| | 31 | 'Recorded': -3, |
| | 32 | 'Recording': -2, |
| | 33 | 'WillRecord': -1, |
| | 34 | 'Unknown': 0, |
| | 35 | 'DontRecord': 1, |
| | 36 | 'PreviousRecording': 2, |
| | 37 | 'CurrentRecording': 3, |
| | 38 | 'EarlierShowing': 4, |
| | 39 | 'TooManyRecordings': 5, |
| | 40 | 'NotListed': 6, |
| | 41 | 'Conflict': 7, |
| | 42 | 'LaterShowing': 8, |
| | 43 | 'Repeat': 9, |
| | 44 | 'Inactive': 10, |
| | 45 | 'NeverRecord': 11, |
| | 46 | } |
| | 47 | |
| | 48 | BACKEND_SEP = '[]:[]' |
| | 49 | PROTO_VERSION = 38 |
| | 50 | PROGRAM_FIELDS = 46 |
| | 51 | |
| | 52 | class Base: |
| | 53 | """ |
| | 54 | A connection to MythTV backend. |
| | 55 | """ |
| | 56 | def __init__(self, conn_type='Monitor'): |
| | 57 | self.db = DB(sys.argv[1:]) |
| | 58 | self.master_host = self.db.getSetting('MasterServerIP') |
| | 59 | self.master_port = int(self.db.getSetting('MasterServerPort')) |
| | 60 | |
| | 61 | if not self.master_host: |
| | 62 | log.Msg(CRITICAL, 'Unable to find MasterServerIP in database') |
| | 63 | sys.exit(1) |
| | 64 | if not self.master_port: |
| | 65 | log.Msg(CRITICAL, 'Unable to find MasterServerPort in database') |
| | 66 | sys.exit(1) |
| | 67 | |
| | 68 | try: |
| | 69 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| | 70 | self.socket.settimeout(10) |
| | 71 | self.socket.connect((self.master_host, self.master_port)) |
| | 72 | res = self.backendCommand('MYTH_PROTO_VERSION %s' % PROTO_VERSION).split(BACKEND_SEP) |
| | 73 | if res[0] == 'REJECT': |
| | 74 | log.Msg(CRITICAL, 'Backend has version %s and we speak version %s', res[1], PROTO_VERSION) |
| | 75 | sys.exit(1) |
| | 76 | res = self.backendCommand('ANN %s %s 0' % (conn_type, socket.gethostname())) |
| | 77 | if res != 'OK': |
| | 78 | log.Msg(CRITICAL, 'Unexpected answer to ANN command: %s', res) |
| | 79 | else: |
| | 80 | log.Msg(INFO, 'Successfully connected mythbackend at %s:%d', self.master_host, self.master_port) |
| | 81 | except socket.error, e: |
| | 82 | #log.Msg(CRITICAL, 'Couldn\'t connect to %s:%d (is the backend running)', self.master_host, self.master_port) |
| | 83 | raise Error('Couldn\'t connect to %s:%d (is the backend running)' % (self.master_host, self.master_port)) |
| | 84 | |
| | 85 | def backendCommand(self, data): |
| | 86 | """ |
| | 87 | Sends a formatted command via a socket to the mythbackend. |
| | 88 | |
| | 89 | Returns the result from the backend. |
| | 90 | """ |
| | 91 | def recv(): |
| | 92 | """ |
| | 93 | Reads the data returned from the backend. |
| | 94 | """ |
| | 95 | # The first 8 bytes of the response gives us the length |
| | 96 | data = self.socket.recv(8) |
| | 97 | try: |
| | 98 | length = int(data) |
| | 99 | except: |
| | 100 | return '' |
| | 101 | data = [] |
| | 102 | while length > 0: |
| | 103 | chunk = self.socket.recv(length) |
| | 104 | length = length - len(chunk) |
| | 105 | data.append(chunk) |
| | 106 | return ''.join(data) |
| | 107 | |
| | 108 | command = '%-8d%s' % (len(data), data) |
| | 109 | log.Msg(DEBUG, 'Sending command: %s', command) |
| | 110 | self.socket.send(command) |
| | 111 | return recv() |
| | 112 | |
| | 113 | def getPendingRecordings(self): |
| | 114 | """ |
| | 115 | Returns a list of Program objects which are scheduled to be recorded. |
| | 116 | """ |
| | 117 | programs = [] |
| | 118 | res = self.backendCommand('QUERY_GETALLPENDING').split(BACKEND_SEP) |
| | 119 | has_conflict = int(res.pop(0)) |
| | 120 | num_progs = int(res.pop(0)) |
| | 121 | log.Msg(DEBUG, '%s pending recordings', num_progs) |
| | 122 | for i in range(num_progs): |
| | 123 | programs.append( |
| | 124 | Program(res[i * PROGRAM_FIELDS:(i * PROGRAM_FIELDS) + PROGRAM_FIELDS])) |
| | 125 | return programs |
| | 126 | |
| | 127 | def getScheduledRecordings(self): |
| | 128 | """ |
| | 129 | Returns a list of Program objects which are scheduled to be recorded. |
| | 130 | """ |
| | 131 | programs = [] |
| | 132 | res = self.backendCommand('QUERY_GETALLSCHEDULED').split(BACKEND_SEP) |
| | 133 | num_progs = int(res.pop(0)) |
| | 134 | log.Msg(DEBUG, '%s scheduled recordings', num_progs) |
| | 135 | for i in range(num_progs): |
| | 136 | programs.append( |
| | 137 | Program(res[i * PROGRAM_FIELDS:(i * PROGRAM_FIELDS) + PROGRAM_FIELDS])) |
| | 138 | return programs |
| | 139 | |
| | 140 | def getUpcomingRecordings(self): |
| | 141 | """ |
| | 142 | Returns a list of Program objects which are scheduled to be recorded. |
| | 143 | |
| | 144 | Sorts the list by recording start time and only returns those with |
| | 145 | record status of WillRecord. |
| | 146 | """ |
| | 147 | def sort_programs_by_starttime(x, y): |
| | 148 | if x.starttime > y.starttime: |
| | 149 | return 1 |
| | 150 | elif x.starttime == y.starttime: |
| | 151 | return 0 |
| | 152 | else: |
| | 153 | return -1 |
| | 154 | programs = [] |
| | 155 | res = self.getPendingRecordings() |
| | 156 | for p in res: |
| | 157 | if p.recstatus == RECSTATUS['WillRecord']: |
| | 158 | programs.append(p) |
| | 159 | programs.sort(sort_programs_by_starttime) |
| | 160 | return programs |
| | 161 | |
| | 162 | def getRecorderList(self): |
| | 163 | """ |
| | 164 | Returns a list of recorders, or an empty list if none. |
| | 165 | """ |
| | 166 | recorders = [] |
| | 167 | c = self.db.cursor() |
| | 168 | c.execute('SELECT cardid FROM capturecard') |
| | 169 | row = c.fetchone() |
| | 170 | while row is not None: |
| | 171 | recorders.append(int(row[0])) |
| | 172 | row = c.fetchone() |
| | 173 | c.close() |
| | 174 | return recorders |
| | 175 | |
| | 176 | def getFreeRecorderList(self): |
| | 177 | """ |
| | 178 | Returns a list of free recorders, or an empty list if none. |
| | 179 | """ |
| | 180 | res = self.backendCommand('GET_FREE_RECORDER_LIST').split(BACKEND_SEP) |
| | 181 | recorders = [int(d) for d in res] |
| | 182 | return recorders |
| | 183 | |
| | 184 | def getRecorderDetails(self, recorder_id): |
| | 185 | """ |
| | 186 | Returns a Recorder object with details of the recorder. |
| | 187 | """ |
| | 188 | c = self.db.cursor() |
| | 189 | c.execute("""SELECT cardid, cardtype, videodevice, hostname |
| | 190 | FROM capturecard WHERE cardid = %s""", recorder_id) |
| | 191 | row = c.fetchone() |
| | 192 | if row: |
| | 193 | recorder = Recorder(row) |
| | 194 | return recorder |
| | 195 | else: |
| | 196 | return None |
| | 197 | |
| | 198 | def getCurrentRecording(self, recorder): |
| | 199 | """ |
| | 200 | Returns a Program object for the current recorders recording. |
| | 201 | """ |
| | 202 | res = self.backendCommand('QUERY_RECORDER %s[]:[]GET_CURRENT_RECORDING' % recorder) |
| | 203 | return Program(res.split(BACKEND_SEP)) |
| | 204 | |
| | 205 | def isRecording(self, recorder): |
| | 206 | """ |
| | 207 | Returns a boolean as to whether the given recorder is recording. |
| | 208 | """ |
| | 209 | res = self.backendCommand('QUERY_RECORDER %s[]:[]IS_RECORDING' % recorder) |
| | 210 | if res == '1': |
| | 211 | return True |
| | 212 | else: |
| | 213 | return False |
| | 214 | |
| | 215 | def isActiveBackend(self, hostname): |
| | 216 | """ |
| | 217 | Returns a boolean as to whether the given host is an active backend |
| | 218 | """ |
| | 219 | res = self.backendCommand('QUERY_IS_ACTIVE_BACKEND[]:[]%s' % hostname) |
| | 220 | if res == 'TRUE': |
| | 221 | return True |
| | 222 | else: |
| | 223 | return False |
| | 224 | |
| | 225 | class MythVideo: |
| | 226 | def __init__(self): |
| | 227 | self.db = MythDB() |
| | 228 | |
| | 229 | def pruneMetadata(self): |
| | 230 | """ |
| | 231 | Removes metadata from the database for files that no longer exist. |
| | 232 | """ |
| | 233 | c = self.db.cursor() |
| | 234 | c.execute(""" |
| | 235 | SELECT intid, filename |
| | 236 | FROM videometadata""") |
| | 237 | |
| | 238 | row = c.fetchone() |
| | 239 | while row is not None: |
| | 240 | intid = row[0] |
| | 241 | filename = row[1] |
| | 242 | if not os.path.exists(filename): |
| | 243 | log.Msg(INFO, '%s not exist, removing metadata...', filename) |
| | 244 | c2 = self.db.cursor() |
| | 245 | c2.execute("""DELETE FROM videometadata WHERE intid = %s""", (intid,)) |
| | 246 | c2.close() |
| | 247 | row = c.fetchone() |
| | 248 | c.close() |
| | 249 | |
| | 250 | def getGenreId(self, genre_name): |
| | 251 | """ |
| | 252 | Find the id of the given genre from MythDB. |
| | 253 | |
| | 254 | If the genre does not exist, insert it and return its id. |
| | 255 | """ |
| | 256 | c = self.db.cursor() |
| | 257 | c.execute("SELECT intid FROM videocategory WHERE lower(category) = %s", (genre_name,)) |
| | 258 | row = c.fetchone() |
| | 259 | c.close() |
| | 260 | |
| | 261 | if row is not None: |
| | 262 | return row[0] |
| | 263 | |
| | 264 | # Insert a new genre. |
| | 265 | c = self.db.cursor() |
| | 266 | c.execute("INSERT INTO videocategory(category) VALUES (%s)", (genre_name.capitalize(),)) |
| | 267 | newid = c.lastrowid |
| | 268 | c.close() |
| | 269 | |
| | 270 | return newid |
| | 271 | |
| | 272 | def getMetadataId(self, videopath): |
| | 273 | """ |
| | 274 | Finds the MythVideo metadata id for the given video path from the MythDB, if any. |
| | 275 | |
| | 276 | Returns None if no metadata was found. |
| | 277 | """ |
| | 278 | c = self.db.cursor() |
| | 279 | c.execute(""" |
| | 280 | SELECT intid |
| | 281 | FROM videometadata |
| | 282 | WHERE filename = %s""", (videopath,)) |
| | 283 | row = c.fetchone() |
| | 284 | c.close() |
| | 285 | |
| | 286 | if row is not None: |
| | 287 | return row[0] |
| | 288 | else: |
| | 289 | return None |
| | 290 | |
| | 291 | def hasMetadata(self, videopath): |
| | 292 | """ |
| | 293 | Determines if the given videopath has any metadata in the DB |
| | 294 | |
| | 295 | Returns False if no metadata was found. |
| | 296 | """ |
| | 297 | c = self.db.cursor() |
| | 298 | c.execute(""" |
| | 299 | SELECT category, year |
| | 300 | FROM videometadata |
| | 301 | WHERE filename = %s""", (videopath,)) |
| | 302 | row = c.fetchone() |
| | 303 | c.close() |
| | 304 | |
| | 305 | if row is not None: |
| | 306 | # If category is 0 and year is 1895, we can safely assume no metadata |
| | 307 | if (row[0] == 0) and (row[1] == 1895): |
| | 308 | return False |
| | 309 | else: |
| | 310 | return True |
| | 311 | else: |
| | 312 | return False |
| | 313 | |
| | 314 | def getMetadata(self, id): |
| | 315 | """ |
| | 316 | Finds the MythVideo metadata for the given id from the MythDB, if any. |
| | 317 | |
| | 318 | Returns None if no metadata was found. |
| | 319 | """ |
| | 320 | c = self.db.cursor() |
| | 321 | c.execute(""" |
| | 322 | SELECT * |
| | 323 | FROM videometadata |
| | 324 | WHERE intid = %s""", (id,)) |
| | 325 | row = c.fetchone() |
| | 326 | c.close() |
| | 327 | |
| | 328 | if row is not None: |
| | 329 | return row |
| | 330 | else: |
| | 331 | return None |
| | 332 | |
| | 333 | def setMetadata(self, data, id=None): |
| | 334 | """ |
| | 335 | Adds or updates the metadata in the database for a video item. |
| | 336 | """ |
| | 337 | c = self.db.cursor() |
| | 338 | if id is None: |
| | 339 | fields = ', '.join(data.keys()) |
| | 340 | format_string = ', '.join(['%s' for d in data.values()]) |
| | 341 | sql = "INSERT INTO videometadata(%s) VALUES(%s)" % (fields, format_string) |
| | 342 | c.execute(sql, data.values()) |
| | 343 | intid = c.lastrowid |
| | 344 | c.close() |
| | 345 | return intid |
| | 346 | else: |
| | 347 | log.Msg(DEBUG, 'Updating metadata for %s', id) |
| | 348 | format_string = ', '.join(['%s = %%s' % d for d in data]) |
| | 349 | sql = "UPDATE videometadata SET %s WHERE intid = %%s" % format_string |
| | 350 | sql_values = data.values() |
| | 351 | sql_values.append(id) |
| | 352 | c.execute(sql, sql_values) |
| | 353 | c.close() |
| | 354 | |
| | 355 | class Recorder: |
| | 356 | def __str__(self): |
| | 357 | return "Recorder %s (%s)" % (self.cardid, self.cardtype) |
| | 358 | |
| | 359 | def __repr__(self): |
| | 360 | return "Recorder %s (%s)" % (self.cardid, self.cardtype) |
| | 361 | |
| | 362 | def __init__(self, data): |
| | 363 | """ |
| | 364 | Load the list of data into the object. |
| | 365 | """ |
| | 366 | self.cardid = data[0] |
| | 367 | self.cardtype = data[1] |
| | 368 | self.videodevice = data[2] |
| | 369 | self.hostname = data[3] |
| | 370 | |
| | 371 | class Program: |
| | 372 | def __str__(self): |
| | 373 | return "%s (%s)" % (self.title, self.starttime.strftime('%Y-%m-%d %H:%M:%S')) |
| | 374 | |
| | 375 | def __repr__(self): |
| | 376 | return "%s (%s)" % (self.title, self.starttime.strftime('%Y-%m-%d %H:%M:%S')) |
| | 377 | |
| | 378 | def __init__(self, data): |
| | 379 | """ |
| | 380 | Load the list of data into the object. |
| | 381 | """ |
| | 382 | self.title = data[0] |
| | 383 | self.subtitle = data[1] |
| | 384 | self.description = data[2] |
| | 385 | self.category = data[3] |
| | 386 | try: |
| | 387 | self.chanid = int(data[4]) |
| | 388 | except ValueError: |
| | 389 | self.chanid = None |
| | 390 | self.channum = data[5] #chanstr |
| | 391 | self.callsign = data[6] #chansign |
| | 392 | self.channame = data[7] |
| | 393 | self.filename = data[8] #pathname |
| | 394 | self.fs_high = data[9] |
| | 395 | self.fs_low = data[10] |
| | 396 | self.starttime = datetime.fromtimestamp(int(data[11])) # startts |
| | 397 | self.endtime = datetime.fromtimestamp(int(data[12])) #endts |
| | 398 | self.duplicate = int(data[13]) |
| | 399 | self.shareable = int(data[14]) |
| | 400 | self.findid = int(data[15]) |
| | 401 | self.hostname = data[16] |
| | 402 | self.sourceid = int(data[17]) |
| | 403 | self.cardid = int(data[18]) |
| | 404 | self.inputid = int(data[19]) |
| | 405 | self.recpriority = int(data[20]) |
| | 406 | self.recstatus = int(data[21]) |
| | 407 | self.recordid = int(data[22]) |
| | 408 | self.rectype = data[23] |
| | 409 | self.dupin = data[24] |
| | 410 | self.dupmethod = data[25] |
| | 411 | self.recstartts = datetime.fromtimestamp(int(data[26])) |
| | 412 | self.recendts = datetime.fromtimestamp(int(data[27])) |
| | 413 | self.repeat = int(data[28]) |
| | 414 | self.programflags = data[29] |
| | 415 | self.recgroup = data[30] |
| | 416 | self.commfree = int(data[31]) |
| | 417 | self.outputfilters = data[32] |
| | 418 | self.seriesid = data[33] |
| | 419 | self.programid = data[34] |
| | 420 | self.lastmodified = data[35] |
| | 421 | self.stars = float(data[36]) |
| | 422 | self.airdate = data[37] |
| | 423 | self.hasairdate = int(data[38]) |
| | 424 | self.playgroup = data[39] |
| | 425 | self.recpriority2 = int(data[40]) |
| | 426 | self.parentid = data[41] |
| | 427 | self.storagegroup = data[42] |
| | 428 | self.audio_props = data[43] |
| | 429 | self.video_props = data[44] |
| | 430 | self.subtitle_type = data[45] |
| | 431 | |
| | 432 | if __name__ == '__main__': |
| | 433 | print 'Base is now part of the mythtv package' |