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