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
|
about
|
||||||
install
|
install
|
||||||
audiogenerators
|
audiogenerators
|
||||||
|
eventfilters
|
||||||
audiogenerators-write
|
audiogenerators-write
|
||||||
debug
|
debug
|
||||||
api/modules
|
api/modules
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
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
|
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
|
||||||
|
|
9
setup.py
9
setup.py
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue