Selaa lähdekoodia

EventFilters: address the stacking of jingles

boyska 6 vuotta sitten
vanhempi
commit
165c4aeced

+ 31 - 0
doc/source/eventfilters.rst

@@ -0,0 +1,31 @@
+EventFilters
+================
+
+What is an event filter? It is a mechanism to filter songs before adding them to the playlist.
+Why is it any useful? To implement something better than a priority system!
+
+Real world example: you have many events. Some are very long ("shows"), some are short ("jingle"). If you
+have a long show of 2hours, and a jingle every 15minutes, then at the end of your jingle you'll find 8 jingle
+stacked. That's bad! How to fix it? There are many solutions, none of them is very general or suits every
+need. EventFilter is a mechanism to have multiple filters running serially to find a reason to exclude an
+event.
+
+Using an event filter
+-----------------------
+
+larigira provides some basic filter. A simple example can be "maxwait". The principle is just "don't
+add new events if the current playing song still needs more than X seconds to finish". Setting this to, for
+example, 15 minutes, would have found only one jingle in the previous example. Great job!
+
+However, it wouldn't be very kind of long shows. If you have two long shows, each 2h long, scheduled let's say
+at 8:00 and 9:30. Now the second show won't start, because there is 30min remaining, and the maxwait is set
+to 15min. If you have this solution, maybe *percentwait* will better suit your needs. Set this to 200%, and
+a jingle can wait up to twice its own duration. This is like setting maxtime=4hours for the long shows, but
+maxtime=1minute for jingles. Nice!
+
+Write your own
+----------------
+
+You probably have some very strange usecase here. Things to decide based on your custom naming convention,
+time of the day, moon phase, whatever. So you can and should write your own eventfilter. It often boils down
+to very simple python functions and configuration of an entrypoint for the `larigira.eventfilter` entrypoint.

+ 1 - 0
doc/source/index.rst

@@ -14,6 +14,7 @@ Contents:
    about
    install
    audiogenerators
+   eventfilters
    audiogenerators-write
    debug
    api/modules

+ 1 - 0
larigira/config.py

@@ -30,6 +30,7 @@ def get_conf(prefix='LARIGIRA_'):
     conf['TMPDIR'] = os.getenv('TMPDIR', '/tmp/')
     conf['FILE_PATH_SUGGESTION'] = ()  # tuple of paths
     conf['UI_CALENDAR_FREQUENCY_THRESHOLD'] = 4*60*60  # 4 hours
+    conf['EVENT_FILTERS'] = []
     conf.update(from_envvars(prefix=prefix))
     return conf
 

+ 1 - 0
larigira/filters/__init__.py

@@ -0,0 +1 @@
+from .basic import maxwait, percentwait

+ 70 - 0
larigira/filters/basic.py

@@ -0,0 +1,70 @@
+import wave
+
+
+def maxwait(songs, context, conf):
+    wait = int(conf.get('EF_MAXWAIT_SEC', 0))
+    if wait == 0:
+        return True
+    if 'time' not in context['status']:
+        return True, 'no song playing?'
+    curpos, duration = map(int, context['status']['time'].split(':'))
+    remaining = duration - curpos
+    if remaining > wait:
+        return False, 'remaining %d max allowed %d' % (remaining, wait)
+    return True
+
+
+def get_duration(path):
+    '''get track duration in seconds'''
+    if path.lower().endswith('.wav'):
+        with wave.open(path, 'r') as f:
+            frames = f.getnframes()
+            rate = f.getframerate()
+            duration = frames / rate
+            return int(duration)
+    try:
+        import mutagen
+    except ModuleNotFoundError:
+        raise ImportError("mutagen not installed")
+
+    audio = mutagen.File(path)
+    if audio is None:
+        return None
+    return int(audio.info.length)
+
+
+def percentwait(songs, context, conf, getdur=get_duration):
+    '''
+    Similar to maxwait, but the maximum waiting time is proportional to the
+    duration of the audio we're going to add.
+
+    This filter observe the EF_MAXWAIT_PERC variable.
+    The variable must be an integer representing the percentual.
+    If the variable is 0 or unset, the filter will not run.
+
+    For example, if the currently playing track still has 1 minute to go and we
+    are adding a jingle of 40seconds, then if EF_MAXWAIT_PERC==200 the audio
+    will be added (40s*200% = 1m20s) while if EF_MAXWAIT_PERC==100 it will be
+    filtered out.
+    '''
+    percentwait = int(conf.get('EF_MAXWAIT_PERC', 0))
+    if percentwait == 0:
+        return True
+    if 'time' not in context['status']:
+        return True, 'no song playing?'
+    curpos, duration = map(int, context['status']['time'].split(':'))
+    remaining = duration - curpos
+    eventduration = 0
+    for uri in songs['uris']:
+        if not uri.startswith('file://'):
+            return True, '%s is not a file' % uri
+        path = uri[len('file://'):]  # strips file://
+        songduration = getdur(path)
+        if songduration is None:
+            continue
+        eventduration += songduration
+
+    wait = eventduration * (percentwait/100.)
+    if remaining > wait:
+        return False, 'remaining %d max allowed %d' % (remaining, wait)
+    return True

+ 98 - 0
larigira/filters/tests/test_basic.py

@@ -0,0 +1,98 @@
+from larigira.filters.basic import maxwait, percentwait
+
+
+def matchval(d):
+    def mocked(input_):
+        if input_ in d:
+            return d[input_]
+        for k in d:
+            if k in input_:  # string matching
+                return d[k]
+        raise Exception("This test case is bugged! No value for %s" % input_)
+    return mocked
+
+
+durations = dict(one=60, two=120, three=180, four=240, ten=600, twenty=1200,
+                 thirty=1800, nonexist=None)
+dur = matchval(durations)
+
+
+def normalize_ret(ret):
+    if type(ret) is bool:
+        return ret, ''
+    return ret
+
+
+def mw(*args, **kwargs):
+    return normalize_ret(maxwait(*args, **kwargs))
+
+
+def pw(*args, **kwargs):
+    kwargs['getdur'] = dur
+    return normalize_ret(percentwait(*args, **kwargs))
+
+
+def test_maxwait_nonpresent_disabled():
+    ret = mw([], {}, {})
+    assert ret[0] is True
+
+
+def test_maxwait_explicitly_disabled():
+    ret = mw([], {}, {'EF_MAXWAIT_SEC': 0})
+    assert ret[0] is True
+
+
+def test_maxwait_ok():
+    ret = mw([], {'status': {'time': '250:300'}}, {'EF_MAXWAIT_SEC': 100})
+    assert ret[0] is True
+
+
+def test_maxwait_exceeded():
+    ret = mw([], {'status': {'time': '100:300'}}, {'EF_MAXWAIT_SEC': 100})
+    assert ret[0] is False
+
+
+def test_maxwait_limit():
+    ret = mw([], {'status': {'time': '199:300'}}, {'EF_MAXWAIT_SEC': 100})
+    assert ret[0] is False
+    ret = mw([], {'status': {'time': '200:300'}}, {'EF_MAXWAIT_SEC': 100})
+    assert ret[0] is True
+    ret = mw([], {'status': {'time': '201:300'}}, {'EF_MAXWAIT_SEC': 100})
+    assert ret[0] is True
+
+
+def test_percentwait_nonpresent_disabled():
+    ret = pw([], {}, {})
+    assert ret[0] is True
+
+
+def test_percentwait_explicitly_disabled():
+    ret = pw([], {}, {'EF_MAXWAIT_PERC': 0})
+    assert ret[0] is True
+
+
+def test_percentwait_ok():
+    # less than one minute missing
+    ret = pw(dict(uris=['file:///oneminute.ogg']),
+             {'status': {'time': '250:300'}},
+             {'EF_MAXWAIT_PERC': 100})
+    assert ret[0] is True
+
+    # more than one minute missing
+    ret = pw(dict(uris=['file:///oneminute.ogg']),
+             {'status': {'time': '220:300'}},
+             {'EF_MAXWAIT_PERC': 100})
+    assert ret[0] is False
+
+
+def test_percentwait_morethan100():
+    # requiring 5*10 = 50mins = 3000sec
+    ret = pw(dict(uris=['file:///tenminute.ogg']),
+             {'status': {'time': '4800:6000'}},
+             {'EF_MAXWAIT_PERC': 500})
+    assert ret[0] is True
+
+    ret = pw(dict(uris=['file:///oneminute.ogg']),
+             {'status': {'time': '2000:6000'}},
+             {'EF_MAXWAIT_PERC': 500})
+    assert ret[0] is False

+ 43 - 2
larigira/mpc.py

@@ -1,6 +1,7 @@
 from __future__ import print_function
 import logging
 import signal
+from pkg_resources import iter_entry_points
 
 import gevent
 from gevent.queue import Queue
@@ -10,6 +11,7 @@ from .event import Monitor
 from .eventutils import ParentedLet, Timer
 from .audiogen import audiogenerate
 from .unused import UnusedCleaner
+from .entrypoints_utils import get_avail_entrypoints
 
 
 def get_mpd_client(conf):
@@ -111,15 +113,54 @@ class Player:
         picker.link_value(add)
         picker.start()
 
+    def enqueue_filter(self, songs):
+        eventfilters = self.conf['EVENT_FILTERS']
+        if not eventfilters:
+            return True, ''
+        availfilters = get_avail_entrypoints('larigira.eventfilter')
+        if len([ef for ef in eventfilters if ef in availfilters]) == 0:
+            return True, ''
+        mpdc = self._get_mpd()
+        status = mpdc.status()
+        ctx = {
+            'playlist': mpdc.playlist(),
+            'status': status,
+            'durations': []
+        }
+        for entrypoint in iter_entry_points('larigira.eventfilter'):
+            if entrypoint.name in eventfilters:
+                ef = entrypoint.load()
+                try:
+                    ret = ef(songs=songs, context=ctx, conf=self.conf)
+                except ImportError as exc:
+                    self.log.warn("Filter %s skipped: %s" % (entrypoint.name,
+                                                             exc))
+                    continue
+                if ret is None:  # bad behavior!
+                    continue
+                if type(ret) is bool:
+                    reason = ''
+                else:
+                    ret, reason = ret
+                reason = 'Filtered by %s (%s)' % (entrypoint.name, reason)
+                if ret is False:
+                    return ret, reason
+        return True, 'Passed through %s' % ','.join(availfilters)
+
     def enqueue(self, songs):
         assert type(songs) is dict
         assert 'uris' in songs
         spec = [aspec.get('nick', aspec.eid) for aspec in songs['audiospecs']]
+        nicks = ','.join((aspec.get('nick', aspec.eid)
+                          for aspec in songs['audiospecs']))
         if not self.events_enabled:
-            self.log.debug('Ignoring <%s> (events disabled)',
-                           ','.join(spec)
+            self.log.debug('Ignoring <%s> (events disabled)', nicks
                            )
             return
+        filterok, reason = self.enqueue_filter(songs)
+        if not filterok:
+            self.log.debug('Ignoring <%s>, filtered: %s', nicks, reason)
+            return
         mpd_client = self._get_mpd()
         for uri in reversed(songs['uris']):
             assert type(uri) is str

+ 8 - 1
setup.py

@@ -35,7 +35,7 @@ setup(name='larigira',
       author='boyska',
       author_email='piuttosto@logorroici.org',
       license='AGPL',
-      packages=['larigira', 'larigira.dbadmin'],
+      packages=['larigira', 'larigira.dbadmin', 'larigira.filters'],
       install_requires=[
           'pyxdg',
           'gevent',
@@ -49,6 +49,9 @@ setup(name='larigira',
       ],
       tests_require=['pytest-timeout==1.0', 'py>=1.4.29', 'pytest==3.0', ],
       python_requires='>=3.5',
+      extras_require={
+          'percentwait': ['mutagen'],
+      },
       cmdclass={'test': PyTest},
       zip_safe=False,
       include_package_data=True,
@@ -88,6 +91,10 @@ setup(name='larigira',
               'randomdir = larigira.audioform_randomdir:receive',
               'mostrecent = larigira.audioform_mostrecent:audio_receive',
           ],
+          'larigira.eventfilter': [
+              'maxwait = larigira.filters:maxwait',
+              'percentwait = larigira.filters:percentwait',
+          ],
       },
       classifiers=[
           "License :: OSI Approved :: GNU Affero General Public License v3",