diff --git a/doc/source/eventfilters.rst b/doc/source/eventfilters.rst new file mode 100644 index 0000000..de1e88e --- /dev/null +++ b/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. diff --git a/doc/source/index.rst b/doc/source/index.rst index 825876c..b4cf129 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -14,6 +14,7 @@ Contents: about install audiogenerators + eventfilters audiogenerators-write debug api/modules diff --git a/larigira/config.py b/larigira/config.py index de00d11..8927808 100644 --- a/larigira/config.py +++ b/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 diff --git a/larigira/filters/__init__.py b/larigira/filters/__init__.py new file mode 100644 index 0000000..ff4759b --- /dev/null +++ b/larigira/filters/__init__.py @@ -0,0 +1 @@ +from .basic import maxwait, percentwait diff --git a/larigira/filters/basic.py b/larigira/filters/basic.py new file mode 100644 index 0000000..a2b9bd5 --- /dev/null +++ b/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 diff --git a/larigira/filters/tests/test_basic.py b/larigira/filters/tests/test_basic.py new file mode 100644 index 0000000..12c86d5 --- /dev/null +++ b/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 diff --git a/larigira/mpc.py b/larigira/mpc.py index a91a43b..9cef93a 100644 --- a/larigira/mpc.py +++ b/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 diff --git a/setup.py b/setup.py index e4fe2ac..009a50b 100644 --- a/setup.py +++ b/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",