EventFilters: address the stacking of jingles
This commit is contained in:
parent
6eae204b4b
commit
165c4aeced
8 changed files with 253 additions and 3 deletions
31
doc/source/eventfilters.rst
Normal file
31
doc/source/eventfilters.rst
Normal 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.
|
|
@ -14,6 +14,7 @@ Contents:
|
|||
about
|
||||
install
|
||||
audiogenerators
|
||||
eventfilters
|
||||
audiogenerators-write
|
||||
debug
|
||||
api/modules
|
||||
|
|
|
@ -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
larigira/filters/__init__.py
Normal file
1
larigira/filters/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .basic import maxwait, percentwait
|
70
larigira/filters/basic.py
Normal file
70
larigira/filters/basic.py
Normal 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
|
98
larigira/filters/tests/test_basic.py
Normal file
98
larigira/filters/tests/test_basic.py
Normal 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
|
|
@ -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
|
||||
|
|
9
setup.py
9
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",
|
||||
|
|
Loading…
Reference in a new issue