Mythfs.py

From MythTV Official Wiki
Jump to: navigation, search

Important.png Note: The correct title of this article is mythfs.py. It appears incorrectly here due to technical restrictions.


Author Raymond Wagner
Description This script is intended as an alternative to mythlink.pl, providing a simulated filesystem mount populated by MythTV recordings.
Supports Version24.png  


FuseFS access to storage group content.

Provides filesystem access to recordings through a myth URI. Recordings will work with the same format string as used by mythlink.pl.

#./mythfs.py Recordings,'%T/(%oY%-%om%-%od) %S' /mnt/mythtv/rename/by-title -o ro,allow_other
#./mythfs.py --storeformat by-date '%pY%-%pm%-%pd/(%pH%pi%-%peH%pei) %T %- %S'
#./mythfs.py --listformats
    Label        Format
    -----        ------
    by-date      %pY%-%pm%-%pd/(%pH%pi%-%peH%pei) %T %- %S
    by-title     %T/(%oY%-%om%-%od) %S
#./mythfs.py Recordings,by-date /srv/mounts/by-date -o ro,allow_other


PythonIcon.png mythfs.py

#!/usr/bin/env python

try:
    import _find_fuse_parts
except ImportError:
    pass
import re
import errno
import stat
import os
import sys
from time import mktime, sleep
from datetime import date
from traceback import format_exc
from weakref import proxy

try:
    import fuse
except:
    print 'Warning! FUSE Python bindings could not be found'
    sys.exit(1)
if not hasattr(fuse, '__version__'):
    print 'Warning! Installed FUSE Python bindings are too old.'
    sys.exit(1)
from fuse import Fuse

try:
    import MythTV
except:
    print 'Warning! MythTV Python bindings could not be found'
    sys.exit(1)
if MythTV.__version__ < (0,24,0,0):
    print 'Warning! Installed MythTV Python bindings are tool old. Please update'
    print '    to 0.23.0.18 or later.'
    sys.exit(1)
from MythTV import MythDB, MythVideo, ftopen, MythBE,\
                   Video, Recorded, MythLog, static

fuse.fuse_python_api = (0, 2)
LOG = MythLog(lstr='none')
MythLog._setfile('/dev/null')
BACKEND = None

def doNothing(*args, **kwargs):
    pass

def increment():
    res = 1
    while True:
        yield res
        res += 1

class Attr(fuse.Stat):
    def __init__(self):
        self.st_mode = 0
        self.st_ino = 0
        self.st_dev = 0
        self.st_blksize = 0
        self.st_nlink = 1
        self.st_uid = os.getuid()
        self.st_gid = os.getgid()
        self.st_blocks = 1
        self.st_rdev = 0
        self.st_size = 0
        self.st_atime = 0
        self.st_mtime = 0
        self.st_ctime = 0

class Directory(object):
    def __str__(self):
        return "<Directory '%s' at %s>" % (self.path, hex(id(self)))
    def __repr__(self):
        return "<Directory '%s' at %s>" % (self.path, hex(id(self)))
    def __init__(self, path):
        self.path = path
        self.attr = Attr()
        self.attr.st_mode = stat.S_IFDIR | 0555
        self.children = []

    def addChild(self, name, attr):
        self.children.append(name)
        LOG(LOG.FILE, "adding child to '%s'" % self.path, name)
        self.attr.st_size += 1
        if (self.attr.st_ctime > attr.st_ctime) or (self.attr.st_size == 1):
            self.attr.st_ctime = attr.st_ctime
        if (self.attr.st_mtime < attr.st_mtime) or (self.attr.st_size == 1):
            self.attr.st_mtime = attr.st_mtime
        if (self.attr.st_atime < attr.st_atime) or (self.attr.st_size == 1):
            self.attr.st_atime = attr.st_atime

class Handler( object ):
    def getAll(self):
        # provides an iterable of all initial objects
        # if inode needs to be known, a generator can be used
        #   and the inode pulled from the object
        return iter([])

    def setFormat(self, fmt):
        # called with a string of additional data passed on the
        #   mount call to control the behavior of the mount
        pass

    def _openHandler(self, inode):
        # called with the object when one is opened
        pass

    def _closeHandler(self, inode):
        # called with the object when one is closed
        pass

    def _deleteHandler(self, inode):
        # called with the object when one is deleted
        # raising a NotImplementedError will cause deletions
        #   to be disallowed
        raise NotImplementedError

class Single( Handler ):
    class FileObj( object ):
        def __init__(self, path, db=None):
            self.path = path
            self.db = MythDB(db=db)
        def open(self, mode):
            return ftopen(path, mode, db=self.db)

    def __init__(self):
        self.db = MythDB()
        self.be = MythBE(db=self.db)

    def getAll(self):
        reuri = re.compile('myth://((?P<group>.*)@)?(?P<host>[a-zA-Z0-9_\.]*)(:[0-9]*)?/(?P<file>.*)')
        match = reuri.match(self.uri)
        group,host,filename = match.groups()
        t,s = be.getSGFile(host, group, filename)

        obj = self.FileObj(self.file, db)
        obj.attr = Attr()
        obj.attr.st_mode = stat.S_IFREG | 0444
        obj.attr.st_size = int(s)
        self._addCallback(obj)

    def setFormat(self, fmt):
        self.uri = fmt

class Videos( Handler ):
    def __init__(self):
        self.db = MythDB()
        self.be = MythBE(db=db)
        self.vids = {}
        self._addCallback = doNothing
        self._events = [self.handleUpdate]
        self.be.registerevent(self.handleUpdate)

    def add(self, vid):
        if not vid.browse:
            return
        if vid.intid in self.vids:
            return

        vid.path = vid.filename
        vid.attr = Attr()
        try:
            ctime = vid.insertdate.timestamp()
        except:
            ctime = 0
        vid.attr.st_ctime = ctime
        vid.attr.st_atime = atime
        vid.attr.st_mtime = ctime
        vid.attr.st_mode = stat.S_IFREG | 0444
        t,s = self.be.getSGFile(vid.host, 'Videos', vid.filename)
        vid.attr.st_size = int(s)

        self._addCallback(vid)
        self.vids[vid.intid] = vid.attr.st_ino

    def getAll(self):
        for vid in Video.getAllEntries(db=self.db):
            self.add(vid)

    def handleUpdate(self, event=None):
        if event is None:
            self._reUp = re.compile(
                    re.escape(static.BACKEND_SEP).\
                        join(['BACKEND_MESSAGE',
                              'VIDEO_LIST_CHANGE',
                              'empty']))
            return self._reUp
        with self.db as cursor:
            cursor.execute("""SELECT intid FROM videometadata""")
            newids = [id[0] for id in cursor.fetchall()]

        oldids = self.vids.keys()
        for id in list(oldids):
            if id in newids:
                oldids.remove(id)
                newids.remove(id)

        for id in oldids:
            self._deleteCallback(self.vids[id])
        for id in newids:
            self.add(Video(id, db=self.db))

class Recordings( Handler ):
    def __init__(self):
        self.be = MythBE()
        self.recs = {}
        self._events = [self.handleAdd, self.handleDelete, self.handleUpdate]
        for e in self._events:
            self.be.registerevent(e)

    def add(self, rec):
        # check for duplicates
        match = (str(rec.chanid),rec.recstartts.isoformat())
        if match in self.recs:
            return

        # add attributes
        rec.attr = Attr()
        ctime = rec.lastmodified.timestamp()
        rec.attr.st_ctime = ctime
        rec.attr.st_mtime = ctime
        rec.attr.st_atime = ctime
        rec.attr.st_size = rec.filesize
        rec.attr.st_mode = stat.S_IFREG | 0444

        # process name
        rec.path = rec.formatPath(self.fmt, ' ')

        # add file
        self._addCallback(rec)
        self.recs[match] = rec.attr.st_ino

    def genAttr(self, rec):
        attr = Attr()
        ctime = rec.lastmodified.timestamp()
        attr.st_ctime = ctime
        attr.st_mtime = ctime
        attr.st_atime = ctime
        attr.st_size = rec.filesize
        attr.st_mode = stat.S_IFREG | 0444
        return attr

    def getAll(self):
        for rec in self.be.getRecordings():
            self.add(rec)

    def handleAdd(self, event=None):
        if event is None:
            self._reAdd = re.compile(
                    re.escape(static.BACKEND_SEP).\
                        join(['BACKEND_MESSAGE',
                              'RECORDING_LIST_CHANGE ADD '
                                  '(?P<chanid>[0-9]*) '
                                  '(?P<starttime>[0-9-]*T[0-9:]*)',
                              'empty']))
            return self._reAdd
        LOG(LOG.FILE, 'add event received', event)

        match = self._reAdd.match(event).groups()
        if match in self.recs:
            return

        rec = self.be.getRecording(match[0], match[1])
        self.add(rec)

    def handleDelete(self, event=None):
        if event is None:
            self._reDel = re.compile(
                    re.escape(static.BACKEND_SEP).\
                        join(['BACKEND_MESSAGE',
                              'RECORDING_LIST_CHANGE DELETE '
                                  '(?P<chanid>[0-9]*) '
                                  '(?P<starttime>[0-9-]*T[0-9:]*)',
                              'empty']))
            return self._reDel
        LOG(LOG.FILE, 'delete event received', event)

        match = self._reDel.match(event).groups()
        if match not in self.recs:
            return

        self._deleteCallback(self.recs[match])
        del self.recs[match]

    def handleUpdate(self, event=None):
        if event is None:
            self._reUp = re.compile(
                    re.escape(static.BACKEND_SEP).\
                        join(['BACKEND_MESSAGE',
                              'UPDATE_FILE_SIZE '
                                  '(?P<chanid>[0-9]*) '
                                  '(?P<starttime>[0-9-]*T[0-9:]*) '
                                  '(?P<size>[0-9]*)',
                              'empty']))
            return self._reUp
        LOG(LOG.FILE, 'update event received', event)

        match = self._reUp.match(event)
        size = match.group(3)
        match = match.group(1,2)
        if match not in self.recs:
            return

        inode = self.recs[match]
        rec = self._inodeCallback(inode)
        rec.filesize = int(size)
        rec.attr.st_size = int(size)

    def setFormat(self, fmt):
        if '%' not in fmt:
            LOG(LOG.FILE, 'pulling format from database', 'mythfs.format.%s' % fmt)
            fmt = self.be.db.settings.NULL['mythfs.format.%s' % fmt]
        LOG(LOG.FILE, 'using format', fmt)
        self.fmt = fmt

class MythFS( Fuse ):
    _nextInode = increment()

    def __init__(self, *args, **kw):
        Fuse.__init__(self, *args, **kw)
        self._inode = {}
        self._paths = {}
        self._openFiles = {}

    def fsinit(self):
        fmt = self.parser.largs[0].split(',',1)
        LOG(LOG.FILE, 'starting mythfs', str(fmt))
        self._add(Directory(''))
        try:
            LOG(LOG.FILE, 'running', '%s()' % fmt[0])
            self._handler = eval(fmt[0])()
        except:
            LOG(LOG.FILE, 'no file handler for',fmt[0])
            raise Exception('No file handler for given mount.')
        if len(fmt) == 2:
            self._handler.setFormat(fmt[1])
        self._handler._addCallback = self._add
        self._handler._deleteCallback = self._delete
        self._handler._inodeCallback = self._getObjIno
        self._handler.getAll()

    def _getObjIno(self, inode):
        return self._inode[inode]

    def _getObjPth(self, path):
        return self._inode[self._paths[path.strip('/')]]

    def _add(self, newfile):
        LOG(LOG.FILE, 'adding file', str(newfile))
        # add entries for new file
        path = newfile.path.strip('/')
        inode = self._nextInode.next()
        newfile.attr.st_ino = inode

        if path in self._paths:
            LOG(LOG.FILE, 'filename already in use', path)
            p = path.rsplit('.',1)
            i = 1
            while path in self._paths:
                path = '.'.join((p[0], str(i), p[1]))
                LOG(LOG.FILE, '    trying replacement', path)
                i += 1
            LOG(LOG.FILE, '    replacement found', path)
            newfile.path = path
        self._paths[path] = inode
        self._inode[inode] = newfile
        newfile.attr.st_ino = inode

        # increment directory size, or add new
        if path == '':
            return
        if '/' not in path:
            parent,child = '',path
        else:
            parent,child = path.rsplit('/',1)
        LOG(LOG.FILE, 'adding child (%s) to parent (%s)' % (child, parent))
        if parent in self._paths:
            parent = self._getObjPth(parent)
            parent.addChild(child, newfile.attr)
        else:
            LOG(LOG.FILE, 'parent not found, adding new', "'%s'" % parent)
            parent = Directory(parent)
            parent.addChild(child, newfile.attr)
            self._add(parent)

    def _delete(self, inode):
        path = self._getObjIno(inode).path
        # do not delete the root
        if path == '':
            return

        # delete references to entry
        del self._paths[path]
        del self._inode[inode]

        # update parents
        if '/' in path:
            parent,child = path.rsplit('/',1)
        else:
            parent,child = '',path
        parent = self._getObjPth(parent)
        parent.children.remove(child)
        parent.attr.st_size -= 1
        if parent.attr.st_size == 0:
            self._delete(self._paths[parent.path])

    def readdir(self, path, offset):
        LOG(LOG.FILE, 'requesting directory listing', path)
        d = self._getObjPth(path)
        LOG(LOG.FILE, '   listing...', str(d.children))
        r = tuple([fuse.Direntry(e) for e in d.children])
        LOG(LOG.FILE, '   listing...', str(r))
        return tuple([fuse.Direntry(str(e)) for e in d.children])

    def getattr(self, path):
        LOG(LOG.FILE, 'requesting attributes', path)
        a = self._getObjPth(path).attr
        LOG(LOG.FILE, '    ', str(a.__dict__.items()))
        return self._getObjPth(path).attr

    def open(self, path, flags):
        LOG(LOG.FILE, 'requesting file open', path)
        accmode = os.O_RDONLY | os.O_WRONLY | os.O_RDWR
        if (flags & accmode) != os.O_RDONLY:
            return -errno.EACCES

        if path not in self._openFiles:
            f = self._getObjPth(path)
            self._openFiles[path] = [1, f.open()]
            self._handler._openCallback(f)
        else:
            self._openFiles[path][0] += 1
        LOG(LOG.FILE, 'open files', str(self._openFiles))

    def read(self, path, length, offset, fh=None):
        LOG(LOG.FILE, 'requesting file read', '%s, %d, %d' % (path,length,offset))
        if path not in self._openFiles:
            return -errno.ENOENT
        f = self._openFiles[path][1]
        if f.tell() != offset:
            f.seek(offset)
        return f.read(length)

    def release(self, path, fh=None):
        LOG(LOG.FILE, 'requesting file close', path)
        if path in self._openFiles:
            self._openFiles[path][0] -= 1
            if self._openFiles[path][0] == 0:
                self._openFiles[path][1].close()
                del self._openFiles[path]
                self._handler._deleteCallback(self._getObjPth(path))
        else:
            return -errno.ENOENT

    def unlink(self, path):
        self._handler._deleteCallback(self._getObjPth(path))

class DebugFS( MythFS ):
    class Parser( object ):
        def __init__(self):
            self.largs = sys.argv[1:]

    def __init__(self, *args, **kwargs):
        self._inode = {}
        self._paths = {}
        self._openFiles = {}
        self.parser = self.Parser()

def store_format():
    i = iter(sys.argv)
    while i.next() != '--storeformat':
        pass
    tag = i.next()
    fmt = i.next()
    db = MythDB()
    db.settings.NULL['mythfs.format.%s' % tag] = fmt
    sys.exit()

def print_formats():
    db = MythDB()
    print '    Label        Format '
    print '    -----        ------ '
    with db as cursor:
        cursor.execute("""SELECT value,data FROM settings WHERE value like 'mythfs.format.%'""")
        for lbl,fmt in cursor.fetchall():
            lbl = lbl[14:]
            print '%s %s' % (lbl.center(16), fmt)
    sys.exit()

def print_help():
    print """usage: mythfs.py mode[#format] /mount/point [-o some,options]
  allowed modes are:
      Recordings,format - outputs all recordings
                          also accepts stored format names
      Videos            - outputs all MythVideo content
      Single,myth://... - outputs a single file
  other options:'
      --help            - print this
      --helpformat      - print a description of allowed format tags
      --listformats     - list all named formats stored in the database
      --storeformat <name> <format>
                        - store a new format to the database
"""
    sys.exit()

def print_format_help():
    print 'need to put stuff here'
    sys.exit()

def run_debug():
    MythLog._setfile('/var/log/mythtv/mythfs.log')
    MythLog._setlevel('important,general,file')
    fs = DebugFS()
    fs.fsinit()
    banner = 'MythTV Python interactive shell.'
    import code
    try:
        import readline, rlcompleter
    except:
        pass
    else:
        readline.parse_and_bind("tab: complete")
        banner += ' TAB completion available.'
    namespace = globals().copy()
    namespace.update(locals())
    code.InteractiveConsole(namespace).interact(banner)
    sys.exit()


def main():
    fs = MythFS(version='MythFS 0.24.0', usage='', dash_s_do='setsingle')
    fs.parse(errex=1)
    fs.flags = 0
    fs.multithreaded = True
    fs.main()

LOG(LOG.FILE, str(sys.argv))
if __name__ == '__main__':
    for arg in sys.argv:
        if arg == '--storeformat':
            store_format()
        if arg == '--listformats':
            print_formats()
        if arg == '--helpformat':
            print_format_help()
        if arg == '--help':
            print_help()
        if arg == '--debug':
            run_debug()
    main()