#!/usr/bin/env python # # Copyright (C) 2009 James Bellenger # # Licensed under GPL version 2. For details of what this means, write to # Free Software Foundation, Inc. # 51 Franklin St, Fifth Floor, # Boston, MA 02110-1301, USA # # CHANGELOG # 0.2.2 # - Changed API params to make it compatible with 0.12.1 # - Added --rar option to resolve rar files to its content using unrar binary # # 0.2.1 by James Bellenger VERSION = '0.2.2' USAGE = """ This program turns interesting inotify events into HTTP requests that can trigger a mediatomb server to add or remove media. Required Parameters: -h, --host Hostname of mediatomb daemon. Mediatomb should be running with webui enabled and authorization disabled (the default configuration). This parameter is required. -w, --watch A directory to watch. Try to avoid specifying overlapping directories This parameter is required and can be specified multiple times. Optional Parameters: -p, --port Port to look for mediatomb web service on. Defaults to 49152. -t, --translate A simple regex for translating paths on the file server to a path on the mediatomb machine. If this machine sees a file at /export/video/movie.mp4, and mediatomb sees the same file at /mnt/video/movie.mp4, you'll want to specify a value of 'export,mnt'. Can be specified multiple times. -r, --rar Resolve rar filenames to content filenames (Deleting won't work) -l, --log Log output to a given file. -v, --verbose Print extra debugging information. -V, --version Print version number and exit. --help Print this message and exit. """.strip() import getopt import os import pyinotify import re import sys import urllib import logging import subprocess as sub from xml.dom import minidom class Logger: log = None formatter = logging.Formatter('[%(asctime)s %(levelname)s] %(message)s') handler = None _instance = None def __init__(self): self.log = logging.getLogger(sys.argv[0]) self.handler = logging.StreamHandler() self.handler.setFormatter(self.formatter) self.log.addHandler(self.handler) self.log.setLevel(logging.INFO) for level in 'debug', 'info', 'warn', 'error', 'exception', 'fatal', 'setLevel': setattr(self, level, getattr(self.log, level)) def logtofile(self, fname): self.log.removeHandler(self.handler) self.handler = logging.FileHandler(fname) self.handler.setFormatter(self.formatter) self.log.addHandler(self.handler) def _getinstance(cls): if not cls._instance: cls._instance = Logger() return cls._instance getinstance = classmethod(_getinstance) log = Logger.getinstance() class Mediatomb: SID_PATTERN = re.compile(r'sid="(.*?)"', re.MULTILINE) REQUEST_FORMAT = "http://%s:%d/content/interface?req_type=%s&return_type=xml" host = None port = None _pcdir = None _sid = None def __init__(self, host, port): self.host = host self.port = port self._initsession() def _initsession(self): opener = urllib.URLopener() response = self._request('auth', sid='null', action='get_sid') if response: match = re.search(Mediatomb.SID_PATTERN, response) if match: self._sid = match.group(1) log.info('Created session ' + self._sid) return self._sid else: raise Exception('Could not initialize mediatomb session') def _tohex(self, string): return ''.join(["%02X" % ord(x) for x in string]).strip().lower() def _request(self, req_type, retries=1, **kwargs): if 'sid' not in kwargs: if not self._sid: self._initsession() kwargs['sid'] = self._sid opener = urllib.URLopener() url = Mediatomb.REQUEST_FORMAT % (self.host, self.port, req_type) extras = '&'.join([ '%s=%s' % i for i in kwargs.items() ]) if extras: url = '&'.join((url, extras)) log.debug('opening url: ' + url) try: response = opener.open(url) if response: body = response.read() log.debug('Response: ' + body) if '/' in body and retries > 0: log.info('Expiring stale sid') kwargs.pop('sid') self._sid = None return self._request(req_type, retries-1, **kwargs) else: return body else: log.error('No response received') except Exception, e: log.exception('Could not connect to mediatomb at %s:%d. Are you sure it\'s running?' % (self.host, self.port), e) def _initpcdir(self): containers = self.containers(0) if containers: for id, name in containers.items(): if name == 'PC Directory': log.debug('loaded PC Directory id %s' % id) self._pcdir = id return def containers(self, parent): response = self._request('containers', parent_id=parent) if response: dom = minidom.parseString(response) containers = dict() for container in dom.getElementsByTagName('container'): name = container.firstChild.data id = container.getAttribute('id') if name and id: containers[id] = name return containers def items(self, parent): response = self._request('items', parent_id=parent, start=0, count=0) if response: dom = minidom.parseString(response) items = dict() for item in dom.getElementsByTagName('item'): id = item.getAttribute('id') titles = item.getElementsByTagName('title') if titles: items[id] = titles[-1].firstChild.data return items def getmtid(self, path): if self._pcdir is None: self._initpcdir() if self._pcdir is None: log.error('Is PC Directory disabled in mediatomb?') return path = path.split(os.path.sep) if path[0] == '': path.pop(0) fname = path.pop() parent = self._pcdir for p in path: for id, name in self.containers(parent).items(): if name == p: parent = id break if parent != self._pcdir: for id, name in self.items(parent).items(): if name == fname: return id def removefile(self, path): mtid = self.getmtid(path) if mtid: response = self._request('remove', object_id = mtid) if response: if '' in response: log.info('removed ' + path) else: log.error('Could not understand server response when removing %s. Response:\n%s' % (path, response)) def addfile(self, path): id = self._tohex(path) response = self._request('add', object_id = self._tohex(path)) if response: if '' in response or 'Adding:' in response: log.info('Added file %s' % path) else: log.error('Could not understand server response when adding %s. Response:\n%s' % (path, response)) class Monitor: EVENT_MASK = pyinotify.IN_CREATE |\ pyinotify.IN_MOVED_TO |\ pyinotify.IN_MOVED_FROM |\ pyinotify.IN_DELETE _notify = None _mediatomb = None _watchmgr = None _notifycheck = None _notifystop = None _eventcheck = None translations = None def __init__(self, mediatomb, translations): # there are different apis between pyinotify 2.5 and 2.4 if 'WatchManager' in dir(pyinotify): # 2.5 self._watchmgr = pyinotify.WatchManager() self._notify = pyinotify.Notifier(self._watchmgr) self._notifycheck = self._notify.check_events self._notifystop = self._notify.stop else: # 2.4 self._notify = pyinotify.SimpleINotify() self._watchmgr = self._notify self._notifycheck = self._notify.event_check self._notifystop = self._notify.close self._mediatomb = mediatomb self.translations = translations self.resolverar = resolverar def add_watch(self, path): log.info('Adding watch for %s (this can take a while)... ' % path) self._watchmgr.add_watch(path, Monitor.EVENT_MASK, self._onevent, True) log.info('Watch for %s added.' % path) def process(self): self._notify.process_events() if self._notifycheck(1): self._notify.read_events() def close(self): self._notifystop() def _onevent(self, event): # IN_IGNORED generated when removing a directory if not event.mask & pyinotify.IN_IGNORED: if event.path: fname = event.path; if event.name: if self.resolverar: fname = os.path.join(fname, event.name) p = sub.Popen(['unrar', 'lb', fname], stdout=sub.PIPE,stderr=sub.PIPE) fname = p.stdout.readline().rstrip() if fname: fname = os.path.join(event.path, fname) else: fname = os.path.join(event.path, event.name) else: fname = os.path.join(fname, event.name) if event.name.startswith('.'): log.debug('Ignoring hidden file ' + fname) return for translation in self.translations: fname = re.sub(translation[0], translation[1], fname) # if we're processing a directory, just add it it to our watch list if event.mask & pyinotify.IN_ISDIR: if event.mask & pyinotify.IN_CREATE: self._watchmgr.add_watch(fname, Monitor.EVENT_MASK, self._onevent, True) log.info('Added new watch directory ' + fname) elif event.mask & (pyinotify.IN_DELETE | pyinotify.IN_MOVED_FROM): self._mediatomb.removefile(fname) else: self._mediatomb.addfile(fname) if __name__ == '__main__': watches = list() host = None port = 49152 translations = list() resolverar = 0 if len(sys.argv) > 1: opts, args = getopt.gnu_getopt(sys.argv[1:], 'l:t:r:h:p:e:w:vV', ('log=', 'translate=', 'rar', 'host=', 'port=', 'help', 'watch=', 'verbose', 'version')) for opt, arg in opts: if opt in ('-l', '--log'): log.logtofile(arg) if opt == '-t' or opt == '--translate': targs = arg.split(',') if len(targs) != 2: print 'Cannot understand translation argument %s' % arg sys.exit(1) else: translations.append(targs) if opt in ('-r', '--rar'): resolverar = 1 if opt in ('-h', '--host'): host = arg if opt in ('-p', '--port'): port = int(arg) if opt in ('-w', '--watch'): watches.append(arg) if opt in ('-v', '--verbose'): log.setLevel(logging.DEBUG) if opt in ('-V', '--version'): print 'mtproxy version %s' % VERSION sys.exit(0) if opt == '--help': print USAGE sys.exit(0) else: print USAGE sys.exit(1) if not host: print 'Mediatomb host not specified. Exiting' sys.exit(1) if not port: print 'Mediatomb port not specified. Exiting' sys.exit(1) if not watches: print 'No watches specified. Exiting' sys.exit(1) log.info('Starting mtproxy') mediatomb = Mediatomb(host, port) monitor = Monitor(mediatomb, translations) for watch in watches: monitor.add_watch(watch) while True: try: monitor.process() except KeyboardInterrupt: log.info('Closing') monitor.close() break except Exception, err: print err