EventFilters: address the stacking of jingles

This commit is contained in:
boyska 2017-08-08 01:17:39 +02:00
parent 6eae204b4b
commit 165c4aeced
No known key found for this signature in database
GPG key ID: 7395DCAE58289CA9
8 changed files with 253 additions and 3 deletions

View file

@ -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.

View file

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

View file

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

View file

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

70
larigira/filters/basic.py Normal file
View file

@ -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

View file

@ -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

View file

@ -1,6 +1,7 @@
from __future__ import print_function from __future__ import print_function
import logging import logging
import signal import signal
from pkg_resources import iter_entry_points
import gevent import gevent
from gevent.queue import Queue from gevent.queue import Queue
@ -10,6 +11,7 @@ from .event import Monitor
from .eventutils import ParentedLet, Timer from .eventutils import ParentedLet, Timer
from .audiogen import audiogenerate from .audiogen import audiogenerate
from .unused import UnusedCleaner from .unused import UnusedCleaner
from .entrypoints_utils import get_avail_entrypoints
def get_mpd_client(conf): def get_mpd_client(conf):
@ -111,15 +113,54 @@ class Player:
picker.link_value(add) picker.link_value(add)
picker.start() 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): def enqueue(self, songs):
assert type(songs) is dict assert type(songs) is dict
assert 'uris' in songs assert 'uris' in songs
spec = [aspec.get('nick', aspec.eid) for aspec in songs['audiospecs']] 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: if not self.events_enabled:
self.log.debug('Ignoring <%s> (events disabled)', self.log.debug('Ignoring <%s> (events disabled)', nicks
','.join(spec)
) )
return 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() mpd_client = self._get_mpd()
for uri in reversed(songs['uris']): for uri in reversed(songs['uris']):
assert type(uri) is str assert type(uri) is str

View file

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