Compare commits

...

57 commits
docs ... master

Author SHA1 Message Date
dfc59e94f9 FIX exception reporting causing another exception 2024-05-30 10:06:26 +02:00
3e609581cf podcast: ignores broken items 2022-05-06 13:31:07 +02:00
46eb5c400b change sorting of actions in edit_event 2021-11-20 19:28:06 +01:00
1a21495434 edit event: available-actions are limited in size
editing should be easier now
2021-11-20 18:55:48 +01:00
d06c72043f edit event: two columns 2021-11-20 18:49:24 +01:00
c330bdeff4 fix mpdrandom for new python2-mpd library 2021-06-13 15:27:38 +02:00
e9a37cf0f2 podcast: fallback pubDate parsing method 2021-04-24 01:17:17 +02:00
924615b282 podcat: tolerate items without enclosures 2021-04-21 01:12:50 +02:00
e1729fed05 podcast: better support for mRSS
they were already supported, but the duration tag was missed.
Now we look inside media:group, if available.
2021-04-21 00:39:24 +02:00
5976c3fe3d new apt dependencies to compile more stuff 2021-03-29 00:15:09 +02:00
d724fa5c24 MPD_ENFORCE_ALWAYS_PLAYING doc
closes #16
2021-03-27 13:22:52 +01:00
b97bf3271d MPD_ENFORCE_ALWAYS_PLAYING option
refs #16
2021-03-27 13:20:02 +01:00
19a1e3de1b doc: syntax 2021-03-27 12:57:56 +01:00
0f565910f7 doc: explain audiogenerators/audiospec some more 2021-03-27 12:56:00 +01:00
3a59fdd917 clarify mpd prefix option 2021-03-27 12:49:01 +01:00
ae85eeb971 some more fix to the doc 2021-03-27 12:45:44 +01:00
e8f061ec63 audiogen gains --log-level 2021-03-27 12:29:20 +01:00
b8c7bd424e explain SECRET_KEY some more 2021-03-27 12:28:53 +01:00
d4852d5df4 Improve installation and quickstart docs 2021-03-27 12:26:19 +01:00
74a0f2c902 FIX: add files before watching by tmpcleaner 2021-03-25 22:25:54 +01:00
1a517e0f29 quickstart: update package lists 2021-03-09 14:39:29 +01:00
3d541993c7 issue template 2021-03-04 23:27:16 +01:00
1d30e7f984 fix doc syntax 2021-03-03 01:15:37 +01:00
cdf5733d59 fix logging msg 2021-03-03 01:10:48 +01:00
255d97d42f REMOVE_UNUSED_FILES option 2021-03-03 01:10:32 +01:00
b0c2d2195e really move useless debug messages to debugv 2021-03-03 00:58:39 +01:00
d9123555ce podcast: more resilient to invalid audio 2021-03-03 00:58:11 +01:00
32337c54cf clean the debug log, move bloat to DEBUGV
easiest way to enable DEBUGV: set LARIGIRA_DEBUG=1

but production-level debug log really shouldn't be bothered with those
messages
2021-03-03 00:12:51 +01:00
9854445f18 podcast: duration is lazy-loaded 2021-03-03 00:02:31 +01:00
4d175b9451 doc umask 2021-03-02 23:57:52 +01:00
d69352076e umask support 2021-03-02 23:56:04 +01:00
ea04da9bd3 move things around
having larigira.py inside larigira/ created lot of troubles! let's avoid
it altogether
2021-03-02 23:45:34 +01:00
1adfa83d1d avoid 500 when end=null in frequency timespec 2021-03-02 23:43:11 +01:00
3619d2c7a6 doc: quickstart and common errors 2021-03-01 12:38:10 +01:00
c122d17af3 dump conf at startup if DEBUG 2021-03-01 11:46:26 +01:00
b2b9de1271 mutagen filter: small fix 2021-03-01 11:46:26 +01:00
4145e53d8f docs: clarify: "podcast" downloads before playing 2021-02-02 00:28:22 +01:00
344fd7ef85 HTTP_ADDRESS: new config variable 2021-02-02 00:27:59 +01:00
6495c625b4 calendar: cleaner code and better hide 2020-08-04 10:36:54 +02:00
c3c837b7f4 reformatted: black 2020-08-04 10:16:24 +02:00
ef38515559 podcast: fix formatting 2020-06-21 13:04:09 +02:00
400d56cfdd doc: filters 2020-06-21 13:01:00 +02:00
88ff77b968 Merge branch 'podcast' 2020-06-21 13:00:49 +02:00
e54ce8f90f doc: podcast audiogen 2020-06-21 12:58:41 +02:00
1fbe659fc1 add podcast audiogen 2020-06-21 12:57:59 +02:00
bf5eca28c3 audiogen http: download factored in fsutils 2020-06-21 12:56:53 +02:00
9e3c2c5194 version bump 2020-04-23 23:12:31 +02:00
4e685f3425 clarify docs about python support 2020-04-23 23:11:56 +02:00
76ffb69dbf filter duration: workaround on mutagen bugs
sometimes mutagen cannot determine audio file length. This doesn't lead
to exceptions, but to eventuduration being estimated as 0. This is
now considered an error.
In that case, the audio file is played.
2020-04-23 23:04:17 +02:00
db8b555233 mpdrandom supports prefix
this is useful if your music library is bigger than what you want to
play.
2020-04-23 23:03:30 +02:00
51de003d95 FIX update audio
setting "end" to an empty field was resulting in the field not being
changed.
2020-04-23 23:00:04 +02:00
3fe43cdaf7 pin every dependency 2020-04-23 23:00:04 +02:00
964770f9b4 version bump 2020-04-23 16:11:02 +02:00
50c7a5ca61 fix debug vari 2020-04-23 15:41:46 +02:00
4401a29f04 FIX import issues (deprecated stuff) 2020-04-23 15:28:28 +02:00
9769717e11 black formatting 2020-04-23 15:27:17 +02:00
b95eb96f12 /db/calendar has a "show all" button
which brings to ?all=1. it will list all events, including highly
frequent ones
2020-02-08 01:09:09 +01:00
29 changed files with 1068 additions and 360 deletions

13
ISSUE_TEMPLATE.md Normal file
View file

@ -0,0 +1,13 @@
## Is it a bug or a feature request?
## Describe what happens
### what should happen, instead?
### describe all the steps we can do to reproduce the bug
### provide debug logs relevant to the bug
## Which version of larigira are you using? From git?
### Which OS do you use? which version?

View file

@ -40,6 +40,10 @@ action
For example, ``{ 'kind': 'randomdir', 'paths': ['/my/dir', '/other/path'] }`` For example, ``{ 'kind': 'randomdir', 'paths': ['/my/dir', '/other/path'] }``
will pick a random file from one of the two paths. will pick a random file from one of the two paths.
Its main atribute is ``kind``. The kind essentialy specifies the function that will be run among a
predefined set of :doc:`audiogenerators` . Every other attribute is an argument to the specified
audiogenerator.
event event
An event is an alarm plus a list of actions. At given times, do those things An event is an alarm plus a list of actions. At given times, do those things

View file

@ -0,0 +1,22 @@
larigira\.filters package
=========================
Submodules
----------
larigira\.filters\.basic module
-------------------------------
.. automodule:: larigira.filters.basic
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: larigira.filters
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,12 +1,15 @@
Audiogenerators Audiogenerators
=============== ===============
mpdrandom mpd
--------- ---------
picks ``howmany`` song randomly from your mpd library. It follows this picks ``howmany`` song randomly from your mpd library. It follows this
strategy: it picks ``howmany`` artists from your MPD library, then picks a random song for each one strategy: it picks ``howmany`` artists from your MPD library, then picks a random song for each one
if you specify a ``prefix``, then only files inside the ``prefix`` directory
will be picked.
randomdir randomdir
---------- ----------
@ -33,6 +36,48 @@ mostrecent
It is similar to randomdir, but instead of picking randomly, picks the most It is similar to randomdir, but instead of picking randomly, picks the most
recent file (according to the ctime). recent file (according to the ctime).
podcast
------------
This is probably the most powerful generator that comes included with
``larigira``. To use this generator, you would need to have a valid podcast
URL. Beware, here the world *podcast* refer to its very specific meaning of
an xml-based format which resembles a RSS feed but has more media-specific
entities. See `this specification
<https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS>`_ for
more technical details.
So, if you have a valid podcast URL, larigira can look at it, extract audios,
download and play the most recent one. Here are some typical usecases for this:
* You want to play replica based on what you host on your radio's website.
* You want to play some audio from some other radio (or other kind of podcast
source)
The podcast form has many many options, but I promise you that 90% of the cases
are easily solved using ONLY the first option: enter the URL of the podcast
and... it works!
So, what are all the other options for? Well, to cover some other use cases.
For example, let's say that at night you want to play a *random* show (not the
last one, which is the default) that happened on your radio. Then you can
change the "sort by" to be "random". Easy, right?
Another typical usecase is selecting an audio that has a duration which "fits"
with the schedule of your radio: not too long and not too short. You can do
that with the "min len" and "max len" fields. For example, setting a `min_len`
of `30min` and `max_len` of `1h15m` you can avoid picking flash news (too
short) and very long shows.
You can do many other things with its options, but I left those to your
immagination. Let's just clarify the workflow:
* the podcast URL is fetched and audio information is retrieved
* filter: audios are filtered by min/max length
* sort: audios are sorted according to `sort_by` and `reverse`
* select: the n-th episode is fetched, according to `start` field
script script
-------- --------

View file

@ -14,65 +14,66 @@
# serve to show the default. # serve to show the default.
from __future__ import print_function from __future__ import print_function
import sys
import os import os
import subprocess import subprocess
import sys
from sphinx.util.console import red from sphinx.util.console import red
# If extensions (or modules to document with autodoc) are in another directory, # If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.')) # sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0' # needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.coverage', "sphinx.ext.coverage",
'sphinx.ext.viewcode', "sphinx.ext.viewcode",
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# The suffix of source filenames. # The suffix of source filenames.
source_suffix = '.rst' source_suffix = ".rst"
# The encoding of source files. # The encoding of source files.
#source_encoding = 'utf-8-sig' # source_encoding = 'utf-8-sig'
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = "index"
# General information about the project. # General information about the project.
project = 'larigira' project = "larigira"
copyright = '2015-2017, boyska' copyright = "2015-2017, boyska"
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '1.3' version = "1.3"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '1.3.1' release = "1.3.3"
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
#language = None # language = None
# There are two options for replacing |today|: either, you set today to some # There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used: # non-false value, then it is used:
#today = '' # today = ''
# Else, today_fmt is used as the format for a strftime call. # Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y' # today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
@ -80,167 +81,161 @@ exclude_patterns = []
# The reST default role (used for this markup: `text`) to use for all # The reST default role (used for this markup: `text`) to use for all
# documents. # documents.
#default_role = None # default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text. # If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True # add_function_parentheses = True
# If true, the current module name will be prepended to all description # If true, the current module name will be prepended to all description
# unit titles (such as .. function::). # unit titles (such as .. function::).
#add_module_names = True # add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the # If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default. # output. They are ignored by default.
#show_authors = False # show_authors = False
# The name of the Pygments (syntax highlighting) style to use. # The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx' pygments_style = "sphinx"
# A list of ignored prefixes for module index sorting. # A list of ignored prefixes for module index sorting.
#modindex_common_prefix = [] # modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents. # If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False # keep_warnings = False
# -- Options for HTML output ---------------------------------------------- # -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
html_theme = 'default' html_theme = "default"
# Theme options are theme-specific and customize the look and feel of a theme # Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the # further. For a list of options available for each theme, see the
# documentation. # documentation.
#html_theme_options = {} # html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory. # Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = [] # html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to # The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation". # "<project> v<release> documentation".
#html_title = None # html_title = None
# A shorter title for the navigation bar. Default is the same as html_title. # A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None # html_short_title = None
# The name of an image file (relative to this directory) to place at the top # The name of an image file (relative to this directory) to place at the top
# of the sidebar. # of the sidebar.
#html_logo = None # html_logo = None
# The name of an image file (within the static path) to use as favicon of the # The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large. # pixels large.
#html_favicon = None # html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ["_static"]
# Add any extra paths that contain custom files (such as robots.txt or # Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied # .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation. # directly to the root of the documentation.
#html_extra_path = [] # html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format. # using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y' # html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to # If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities. # typographically correct entities.
#html_use_smartypants = True # html_use_smartypants = True
# Custom sidebar templates, maps document names to template names. # Custom sidebar templates, maps document names to template names.
#html_sidebars = {} # html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to # Additional templates that should be rendered to pages, maps page names to
# template names. # template names.
#html_additional_pages = {} # html_additional_pages = {}
# If false, no module index is generated. # If false, no module index is generated.
#html_domain_indices = True # html_domain_indices = True
# If false, no index is generated. # If false, no index is generated.
#html_use_index = True # html_use_index = True
# If true, the index is split into individual pages for each letter. # If true, the index is split into individual pages for each letter.
#html_split_index = False # html_split_index = False
# If true, links to the reST sources are added to the pages. # If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True # html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True # html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True # html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will # If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the # contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served. # base URL from which the finished HTML is served.
#html_use_opensearch = '' # html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml"). # This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None # html_file_suffix = None
# Output file base name for HTML help builder. # Output file base name for HTML help builder.
htmlhelp_basename = 'larigiradoc' htmlhelp_basename = "larigiradoc"
# -- Options for LaTeX output --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
latex_elements = { latex_elements = {
# The paper size ('letterpaper' or 'a4paper'). # The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper', #'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
# The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt',
#'pointsize': '10pt', # Additional stuff for the LaTeX preamble.
#'preamble': '',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
} }
# Grouping the document tree into LaTeX files. List of tuples # Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
('index', 'larigira.tex', 'larigira Documentation', ("index", "larigira.tex", "larigira Documentation", "boyska", "manual")
'boyska', 'manual'),
] ]
# The name of an image file (relative to this directory) to place at the top of # The name of an image file (relative to this directory) to place at the top of
# the title page. # the title page.
#latex_logo = None # latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts, # For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters. # not chapters.
#latex_use_parts = False # latex_use_parts = False
# If true, show page references after internal links. # If true, show page references after internal links.
#latex_show_pagerefs = False # latex_show_pagerefs = False
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
#latex_show_urls = False # latex_show_urls = False
# Documents to append as an appendix to all manuals. # Documents to append as an appendix to all manuals.
#latex_appendices = [] # latex_appendices = []
# If false, no module index is generated. # If false, no module index is generated.
#latex_domain_indices = True # latex_domain_indices = True
# -- Options for manual page output --------------------------------------- # -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [("index", "larigira", "larigira Documentation", ["boyska"], 1)]
('index', 'larigira', 'larigira Documentation',
['boyska'], 1)
]
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
#man_show_urls = False # man_show_urls = False
# -- Options for Texinfo output ------------------------------------------- # -- Options for Texinfo output -------------------------------------------
@ -249,52 +244,58 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
('index', 'larigira', 'larigira Documentation', (
'boyska', 'larigira', 'One line description of project.', "index",
'Miscellaneous'), "larigira",
"larigira Documentation",
"boyska",
"larigira",
"One line description of project.",
"Miscellaneous",
)
] ]
# Documents to append as an appendix to all manuals. # Documents to append as an appendix to all manuals.
#texinfo_appendices = [] # texinfo_appendices = []
# If false, no module index is generated. # If false, no module index is generated.
#texinfo_domain_indices = True # texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'. # How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote' # texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu. # If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False # texinfo_no_detailmenu = False
def run_apidoc(_): def run_apidoc(_):
cur_dir = os.path.abspath(os.path.dirname(__file__)) cur_dir = os.path.abspath(os.path.dirname(__file__))
proj_dir = os.path.abspath(os.path.join(cur_dir, '..', '..')) proj_dir = os.path.abspath(os.path.join(cur_dir, "..", ".."))
modules = ['larigira'] modules = ["larigira"]
exclude_files = [os.path.abspath(os.path.join(proj_dir, excl)) exclude_files = [
for excl in ('larigira/rpc.py', 'larigira/dbadmin/')] os.path.abspath(os.path.join(proj_dir, excl))
output_path = os.path.join(cur_dir, 'api') for excl in ("larigira/rpc.py", "larigira/dbadmin/")
cmd_path = 'sphinx-apidoc' ]
if hasattr(sys, 'real_prefix'): # Are we in a virtualenv? output_path = os.path.join(cur_dir, "api")
cmd_path = "sphinx-apidoc"
if hasattr(sys, "real_prefix"): # Are we in a virtualenv?
# assemble the path manually # assemble the path manually
cmd_path = os.path.abspath(os.path.join(sys.prefix, cmd_path = os.path.abspath(
'bin', os.path.join(sys.prefix, "bin", "sphinx-apidoc")
'sphinx-apidoc')) )
if not os.path.exists(cmd_path): if not os.path.exists(cmd_path):
print(red("No apidoc available!"), file=sys.stderr) print(red("No apidoc available!"), file=sys.stderr)
return return
for module in modules: for module in modules:
try: try:
subprocess.check_call([cmd_path, subprocess.check_call(
'--force', [cmd_path, "--force", "-o", output_path, module]
'-o', output_path, + exclude_files,
module cwd=proj_dir,
] + exclude_files, )
cwd=proj_dir
)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print(red("APIdoc failed for module {}".format(module))) print(red("APIdoc failed for module {}".format(module)))
def setup(app): def setup(app):
app.connect('builder-inited', run_apidoc) app.connect("builder-inited", run_apidoc)

View file

@ -12,11 +12,13 @@ Contents:
:maxdepth: 2 :maxdepth: 2
about about
quickstart
install install
timegenerators timegenerators
audiogenerators audiogenerators
eventfilters eventfilters
audiogenerators-write audiogenerators-write
troubleshooting
debug debug
api/modules api/modules

View file

@ -6,7 +6,7 @@ using ``pip install larigira``. Or you can ``git clone
https://git.lattuga.net/boyska/larigira.git`` and run ``python setup.py install``. https://git.lattuga.net/boyska/larigira.git`` and run ``python setup.py install``.
As always, the usage of a virtualenv is recommended. As always, the usage of a virtualenv is recommended.
The only supported python version is 3.4. Python greater or equal than 3.4 is supported.
Configuration Configuration
--------------- ---------------
@ -22,7 +22,7 @@ inside ``~/.mpdconf``, add the following line::
bind_to_address "~/.mpd/socket" bind_to_address "~/.mpd/socket"
For larigira, you need to set the ``MPD_HOST`` environment variable to For larigira, you need to set the ``MPD_HOST`` environment variable to
``$HOME/.mpd/socket``. If you don't do this, you'll find many:: ``$HOME/.mpd/socket``. If you don't do this, you'll find many lines like these in the logs::
15:37:10|ERROR[Player:93] Cannot insert song file:///tmp/larigira.1002/audiogen-randomdir-8eoklcee.mp3 15:37:10|ERROR[Player:93] Cannot insert song file:///tmp/larigira.1002/audiogen-randomdir-8eoklcee.mp3
Traceback (most recent call last): Traceback (most recent call last):
@ -68,12 +68,16 @@ MPD_PORT
If you are not using a socket, but a TCP address (which is *not* suggested), this is how you can specify the If you are not using a socket, but a TCP address (which is *not* suggested), this is how you can specify the
port. port.
DEBUG DEBUG
you can set it to ``true`` or ``false``. Defaults to ``false``. you can set it to ``true`` or ``false``. Defaults to ``false``. Will enable extremely verbose output.
TMPDIR TMPDIR
The base for larigira tmpdir. Please note that larigira will create its own directory inside this The base for larigira tmpdir. Please note that larigira will create its own directory inside this
temporary directory. This defaults to the system-wide ``$TMPDIR``, or to ``/tmp/`` if not ``TMPDIR`` is temporary directory. This defaults to the system-wide ``$TMPDIR``, or to ``/tmp/`` if not ``TMPDIR`` is
not set. Choose it wisely, keeping in mind that in this directory a lot of cache files will be stored, and not set. Choose it wisely, keeping in mind that in this directory a lot of cache files will be stored, and
could therefore require hundreds of MB. could therefore require hundreds of MB.
UMASK
Umask affects created files permissions. This is important if you want to pass files to a MPD instance
that is running with a different users. There is no default umask, so that you can apply umask via your
standard system tools.
Events Events
^^^^^^^^^ ^^^^^^^^^
@ -81,13 +85,25 @@ Events
CONTINOUS_AUDIOSPEC CONTINOUS_AUDIOSPEC
when the playlist is too short, larigira picks something new. How? this is controlled by this variable. when the playlist is too short, larigira picks something new. How? this is controlled by this variable.
This variable should be set to the JSON representation of an audiospec describing how to generate new This variable should be set to the JSON representation of an audiospec describing how to generate new
audios. The default is ``{"kind": "mpd", "howmany": 1}``. You could, for example, change it to audios. The default is ``{"kind": "mpd", "howmany": 1}``, which picks a random song from MPD library. You could, for example, change it to:
``{ "kind": "randomdir", "paths": ["/var/music"], "howmany": 10}``
- ``{ "kind": "randomdir", "paths": ["/var/music"], "howmany": 5}``
to pick files from a specified directory, ignoring MPD library completely. Here, ``howmany`` is set to 5 for performance sake
- ``{"kind": "mpd", "howmany": 10, "prefix": "background"}``
if you want to use the MPD library, but only use one of its subdirectories.
Since using ``mpd`` with a ``prefix`` can be slow, so this is setting ``howmany`` to 10 to
gain some performance
Yes, there's a typo in the name, but I'll keep it like this for compatibility
EVENT_FILTERS EVENT_FILTERS
See :doc:`eventfilters` See :doc:`eventfilters`
LOG_CONFIG LOG_CONFIG
Path to an INI-formatted file to configure logging. See `python logging documentation Path to an INI-formatted file to configure logging. See `python logging documentation
<https://docs.python.org/2/library/logging.config.html#logging-config-dictschema>`_ <https://docs.python.org/2/library/logging.config.html#logging-config-dictschema>`_
MPD_ENFORCE_ALWAYS_PLAYING
If this is set to 1, larigira will make sure that MPD is always playing. This means that you can't stop
mpd, not even manually running ``mpd stop``. That's probably useful for radios in which mpd is meant to be
run unattended. Default: 0
Internals Internals
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
@ -123,6 +139,10 @@ MPD_WAIT_START_RETRYSECS
Web interface Web interface
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
HTTP_ADDRESS
The address that the HTTP interface will listen to; defaults to `0.0.0.0`
HTTP_PORT
The port that the HTTP interface will listen to; defaults to `5000`
FILE_PATH_SUGGESTION FILE_PATH_SUGGESTION
A list of paths. Those paths will be scanned for suggestions in audiogenerator forms. A list of paths. Those paths will be scanned for suggestions in audiogenerator forms.
UI_CALENDAR_FREQUENCY_THRESHOLD UI_CALENDAR_FREQUENCY_THRESHOLD
@ -145,3 +165,9 @@ LARIGIRA_UI_CALENDAR_DATE_FMT
The format to show in ``/db/calendar`` page. The format is specified `here <http://babel.pocoo.org/en/latest/dates.html>`_. Default is ``medium``. The format to show in ``/db/calendar`` page. The format is specified `here <http://babel.pocoo.org/en/latest/dates.html>`_. Default is ``medium``.
As an example, ``eee dd LLL`` will show ``Sun 10 Mar`` for english, and ``dom 10 mar`` for italian. As an example, ``eee dd LLL`` will show ``Sun 10 Mar`` for english, and ``dom 10 mar`` for italian.
Debug and development
^^^^^^^^^^^^^^^^^^^^^^^
REMOVE_UNUSED_FILES
By default, larigira removes the file it generates, as soon as they are no longer in MPD playlist. There is no good reason to change this behavior, unless you need to debug what's going on. For example, if you want to inspect files. Set this to `false` keep everything.

152
doc/source/quickstart.rst Normal file
View file

@ -0,0 +1,152 @@
Quick start: install larigira on Debian buster
==============================================
This guides have this assumptions or conventions:
* you have a Debian buster installation
- actually 99% of this should work in any distro with recent-enough python3 and systemd
- if you don't like systemd, you are free to use any other service manager; larigira integrates nicely with systemd, but has no requirement at all on it.
* you have a non-root main user, which we'll call ``radio``
* all commands are meant to be run as root. Use ``sudo -i`` if you don't have root password
Install
-----------
Let's start!::
apt-get install python3 python3-dev build-essential virtualenv mpd mpc libxml2-dev libxslt1-dev zlib1g-dev
virtualenv -p /usr/bin/python3 /opt/larigira/
/opt/larigira/bin/pip3 install --no-binary :all: larigira
touch /etc/default/larigira
mkdir -p /home/radio/.mpd/ /etc/larigira/ /var/log/larigira/
chown radio. /home/radio/.mpd/
chown radio:adm /var/log/larigira/
touch /etc/systemd/system/larigira.service
Edit ``/etc/systemd/system/larigira.service`` and put this content::
[Unit]
Description=Radio Automation
After=mpd.service
[Service]
Type=notify
NotifyAccess=all
EnvironmentFile=/etc/default/larigira
User=radio
ExecStart=/opt/larigira/bin/larigira
Restart=always
[Install]
WantedBy=multi-user.target
Now let's edit ``/etc/mpd.conf``::
music_directory "/home/radio/Music/"
playlist_directory "/home/radio/.mpd/playlists"
db_file "/home/radio/.mpd/tag_cache"
log_file "syslog"
pid_file "/home/radio/.mpd/pid"
state_file "/home/radio/.mpd/state"
sticker_file "/home/radio/.mpd/sticker.sql"
user "radio"
bind_to_address "/home/radio/.mpd/socket"
bind_to_address "127.0.0.1"
port "6600"
log_level "default"
replaygain "track"
replaygain_limit "yes"
volume_normalization "yes"
max_connections "30"
Now let's edit larigira settings, editing the file ``/etc/default/larigira``::
MPD_HOST=/home/radio/.mpd/socket
LARIGIRA_DEBUG=false
LARIGIRA_LOG_CONFIG=/etc/larigira/logging.ini
LARIGIRA_EVENT_FILTERS='["percentwait"]'
LARIGIRA_EF_MAXWAIT_PERC=400
LARIGIRA_MPD_ENFORCE_ALWAYS_PLAYING=1
LARIGIRA_SECRET_KEY="changeme with a random, secret string of any length"
Let's include logging configuration, editing ``/etc/larigira/logging.ini``::
[loggers]
keys=root
[formatters]
keys=brief,ext,debug
[handlers]
keys=syslog,own,owndebug,ownerr
[logger_root]
handlers=syslog,own,owndebug,ownerr
level=DEBUG
[handler_syslog]
class=handlers.SysLogHandler
level=INFO
args=('/dev/log', handlers.SysLogHandler.LOG_USER)
formatter=brief
[handler_own]
class=handlers.WatchedFileHandler
level=INFO
args=('/var/log/larigira/larigira.log',)
formatter=ext
[handler_owndebug]
class=handlers.WatchedFileHandler
level=DEBUG
args=('/var/log/larigira/larigira.debug',)
formatter=debug
[handler_ownerr]
class=handlers.WatchedFileHandler
level=ERROR
args=('/var/log/larigira/larigira.err',)
formatter=ext
[formatter_ext]
format=%(asctime)s|%(levelname)s[%(name)s] %(message)s
[formatter_debug]
format=%(asctime)s|%(levelname)s[%(name)s:%(lineno)d] %(message)s
[formatter_brief]
format=%(levelname)s:%(message)s
For hygiene's sake, let's configure rotation for this log, editing ``/etc/logrotate.d/larigira``::
/var/log/larigira/*.err
/var/log/larigira/*.log {
daily
missingok
rotate 14
compress
notifempty
copytruncate
create 600
}
/var/log/larigira/*.debug {
daily
rotate 2
missingok
compress
notifempty
copytruncate
create 600
}
Restart everything::
systemctl daemon-reload
systemctl restart mpd
systemctl restart larigira
systemctl enable larigira
systemctl enable mpd
Everything should work now!

View file

@ -0,0 +1,16 @@
Common problems
====================
I got AccessDenied in the logs
------------------------------
You are not using the UNIX socket to access MPD. See the configuration variable MPD_HOST. This is required by
MPD.
I got ``mpd.base.CommandError: [50@0] {} No such song``
-------------------------------------------------------
There is permissions issues going on. Probably you are running larigira and mpd with two different users, and
you haven't set up a common group for them.

View file

@ -0,0 +1,63 @@
from flask_wtf import Form
from wtforms import (BooleanField, IntegerField, SelectField, StringField,
SubmitField, validators)
from wtforms.fields.html5 import URLField
class AudioForm(Form):
nick = StringField(
"Audio nick",
validators=[validators.required()],
description="A simple name to recognize this audio",
)
url = URLField(
"URL",
validators=[validators.required()],
description="URL of the podcast; it must be valid xml",
)
# TODO: group by filters/sort/select
min_len = StringField(
"Accetta solo audio lunghi almeno:",
description="Leaving this empty will disable this filter",
)
max_len = StringField(
"Accetta solo audio lunghi al massimo:",
description="Leaving this empty will disable this filter",
)
sort_by = SelectField(
"Sort episodes",
choices=[
("none", "Don't sort"),
("random", "Random"),
("duration", "Duration"),
("date", "date"),
],
)
start = IntegerField(
"Play from episode number",
description="Episodes count from 0; 0 is a sane default",
)
reverse = BooleanField("Reverse sort (descending)")
submit = SubmitField("Submit")
def populate_from_audiospec(self, audiospec):
for key in ("nick", "url", "sort_by", "reverse", "min_len", "max_len"):
if key in audiospec:
getattr(self, key).data = audiospec[key]
self.start.data = int(audiospec.get("start", 0))
def audio_receive(form):
d = {"kind": "podcast"}
for key in (
"nick",
"url",
"sort_by",
"reverse",
"min_len",
"max_len",
"start",
):
d[key] = getattr(form, key).data
return d

View file

@ -3,7 +3,7 @@ import sys
import argparse import argparse
from .entrypoints_utils import get_one_entrypoint from .entrypoints_utils import get_one_entrypoint
import json import json
from logging import getLogger from logging import getLogger, basicConfig
log = getLogger("audiogen") log = getLogger("audiogen")
@ -15,6 +15,7 @@ def get_audiogenerator(kind):
def get_parser(): def get_parser():
parser = argparse.ArgumentParser(description="Generate audio and output paths") parser = argparse.ArgumentParser(description="Generate audio and output paths")
parser.add_argument("--log-level", choices=['DEBUG', 'INFO', 'WARNING', 'DEBUG'], default='WARNING')
parser.add_argument( parser.add_argument(
"audiospec", "audiospec",
metavar="AUDIOSPEC", metavar="AUDIOSPEC",
@ -49,6 +50,7 @@ def audiogenerate(spec):
def main(): def main():
"""Main function for the "larigira-audiogen" executable""" """Main function for the "larigira-audiogen" executable"""
args = get_parser().parse_args() args = get_parser().parse_args()
basicConfig(level=args.log_level)
spec = read_spec(args.audiospec[0]) spec = read_spec(args.audiospec[0])
errors = tuple(check_spec(spec)) errors = tuple(check_spec(spec))
if errors: if errors:

View file

@ -1,31 +1,4 @@
import os from larigira.fsutils import download_http
import logging
import posixpath
from tempfile import mkstemp
import urllib.request
from urllib.parse import urlparse
log = logging.getLogger(__name__)
def put(url, destdir=None, copy=False):
if url.split(":")[0] not in ("http", "https"):
log.warning("Not a valid URL: %s", url)
return None
ext = url.split(".")[-1]
if ext.lower() not in ("mp3", "ogg", "oga", "wma", "m4a"):
log.warning('Invalid format (%s) for "%s"', ext, url)
return None
if not copy:
return url
fname = posixpath.basename(urlparse(url).path)
# sanitize
fname = "".join(c for c in fname if c.isalnum() or c in list("._-")).rstrip()
tmp = mkstemp(suffix="." + ext, prefix="http-%s-" % fname, dir=destdir)
os.close(tmp[0])
log.info("downloading %s -> %s", url, tmp[1])
fname, headers = urllib.request.urlretrieve(url, tmp[1])
return "file://%s" % os.path.realpath(tmp[1])
def generate(spec): def generate(spec):
@ -35,10 +8,10 @@ def generate(spec):
Recognized argument is "paths" (list of static paths) Recognized argument is "paths" (list of static paths)
""" """
if "urls" not in spec: if "urls" not in spec:
raise ValueError("Malformed audiospec: missing 'paths'") raise ValueError("Malformed audiospec: missing 'urls'")
for url in spec["urls"]: for url in spec["urls"]:
ret = put(url, copy=True) ret = download_http(url, copy=True, prefix="http")
if ret is None: if ret is None:
continue continue
yield ret yield ret

View file

@ -1,25 +1,43 @@
import logging import logging
log = logging.getLogger("mpdrandom")
import random import random
from mpd import MPDClient from mpd import MPDClient
from .config import get_conf from .config import get_conf
log = logging.getLogger(__name__)
def generate_by_artist(spec): def generate_by_artist(spec):
"""choose HOWMANY random artists, and for each one choose a random song""" """Choose HOWMANY random artists, and for each one choose a random song."""
spec.setdefault("howmany", 1) spec.setdefault("howmany", 1)
prefix = spec.get("prefix", "").rstrip("/")
log.info("generating") log.info("generating")
conf = get_conf() conf = get_conf()
c = MPDClient(use_unicode=True) c = MPDClient(use_unicode=True)
c.connect(conf["MPD_HOST"], conf["MPD_PORT"]) c.connect(conf["MPD_HOST"], conf["MPD_PORT"])
artists = c.list("artist") if prefix:
log.debug("got %d artists", len(artists)) # TODO: listallinfo is discouraged.
# how else could we achieve the same result?
artists = list(
{r["artist"] for r in c.listallinfo(prefix) if "artist" in r}
)
else:
artists = c.list("artist")
if not artists: if not artists:
raise ValueError("no artists in your mpd database") raise ValueError("no artists in your mpd database")
for _ in range(spec["howmany"]): for _ in range(spec["howmany"]):
artist = random.choice(artists) artist = random.choice(artists) # pick one artist
yield random.choice(c.find("artist", artist))["file"] if type(artist) is not str:
# different mpd library versions have different behavior
artist = artist['artist']
# pick one song from that artist
artist_songs = (res["file"] for res in c.find("artist", artist))
if prefix:
artist_songs = [
fname
for fname in artist_songs
if fname.startswith(prefix + "/")
]
yield random.choice(list(artist_songs))

View file

@ -0,0 +1,216 @@
import datetime
import logging
import os
import random
import sys
from subprocess import CalledProcessError, check_output
import dateutil.parser
import requests
from lxml import html
from pytimeparse.timeparse import timeparse
from larigira.fsutils import download_http
def delta_humanreadable(tdelta):
if tdelta is None:
return ""
days = tdelta.days
hours = (tdelta - datetime.timedelta(days=days)).seconds // 3600
if days:
return "{}d{}h".format(days, hours)
return "{}h".format(hours)
def get_duration(url):
try:
lineout = check_output(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-i",
url,
]
).split(b"\n")
except CalledProcessError as exc:
raise ValueError("error probing `%s`" % url) from exc
duration = next(l for l in lineout if l.startswith(b"duration="))
value = duration.split(b"=")[1]
return int(float(value))
class Audio(object):
def __init__(self, url, duration=None, date=None):
self.url = url
self._duration = duration
self.date = date
self.end_date = datetime.datetime(
9999, 12, 31, tzinfo=datetime.timezone.utc
)
def __str__(self):
return self.url
def __repr__(self):
return "<Audio {} ({} {})>".format(
self.url, self._duration, delta_humanreadable(self.age)
)
@property
def duration(self):
"""lazy-calculation"""
if self._duration is None:
try:
self._duration = get_duration(self.url.encode("utf-8"))
except:
logging.exception(
"Error while computing duration of %s; set it to 0",
self.url,
)
self._duration = 0
return self._duration
@property
def urls(self):
return [self.url]
@property
def age(self):
if self.date is None:
return None
now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
return now - self.date
@property
def valid(self):
return self.end_date >= datetime.datetime.utcnow().replace(
tzinfo=datetime.timezone.utc
)
def get_tree(feed_url):
if feed_url.startswith("http:") or feed_url.startswith("https:"):
tree = html.fromstring(requests.get(feed_url).content)
else:
if not os.path.exists(feed_url):
raise ValueError("file not found: {}".format(feed_url))
tree = html.parse(open(feed_url))
return tree
def get_item_date(el):
el_date = el.find("pubdate")
if el_date is None:
return None
for time_format in ("%Y-%m-%dT%H:%M:%S%z", "%a, %d %b %Y %H:%M:%S %z"):
try:
return datetime.datetime.strptime(el_date.text, time_format)
except:
continue
return dateutil.parser.parse(el_date.text)
def get_audio_from_item(item):
encl = item.find("enclosure")
if encl is not None:
url = encl.get("url")
else:
return None
audio_args = {}
if item.find("duration") is not None:
duration_parts = item.findtext("duration").split(":")
total_seconds = 0
for i, num in enumerate(reversed(duration_parts)):
total_seconds += int(float(num)) * (60 ** i)
if total_seconds:
audio_args["duration"] = total_seconds
else:
contents = item.xpath("group/content")
if not contents:
contents = item.xpath("content")
for child in contents:
if child.get("url") == url and child.get("duration") is not None:
audio_args["duration"] = int(float(child.get("duration")))
break
return Audio(url, **audio_args)
def get_urls(tree):
items = tree.xpath("//item")
for i, it in enumerate(items):
try:
audio = get_audio_from_item(it)
except Exception:
logging.error("Could not parse item #%d, skipping", i)
continue
if audio is None:
continue
if audio.date is None:
try:
audio.date = get_item_date(it)
except Exception:
logging.warn("Could not find date for item #%d", i)
yield audio
def parse_duration(arg):
if arg.isdecimal():
secs = int(arg)
else:
secs = timeparse(arg)
if secs is None:
raise ValueError("%r is not a valid duration" % arg)
return secs
def generate(spec):
if "url" not in spec:
raise ValueError("Malformed audiospec: missing 'url'")
audios = list(get_urls(get_tree(spec["url"])))
if spec.get("min_len", False):
audios = [
a for a in audios if a.duration >= parse_duration(spec["min_len"])
]
if spec.get("max_len", False):
audios = [
a for a in audios if a.duration <= parse_duration(spec["max_len"])
]
# sort
sort_by = spec.get("sort_by", "none")
if sort_by == "random":
random.shuffle(audios)
elif sort_by == "date":
audios.sort(key=lambda x: x.age)
elif sort_by == "duration":
audios.sort(key=lambda x: x.duration)
if spec.get("reverse", False):
audios.reverse()
# slice
audios = audios[int(spec.get("start", 0)) :]
audios = audios[: int(spec.get("howmany", 1))]
# copy local
local_audios = [
download_http(a.url, copy=spec.get("copy", True), prefix="podcast")
for a in audios
]
return local_audios
# TODO: testing
# TODO: lxml should maybe be optional?
# TODO: ui
if __name__ == "__main__":
# less than proper testing
logging.basicConfig(level=logging.DEBUG)
for u in get_urls(get_tree(sys.argv[1])):
print(" -", repr(u))

View file

@ -16,6 +16,7 @@ def get_conf(prefix="LARIGIRA_"):
conf["CONTINOUS_AUDIOSPEC"] = dict(kind="mpd", howmany=1) conf["CONTINOUS_AUDIOSPEC"] = dict(kind="mpd", howmany=1)
conf["MPD_HOST"] = os.getenv("MPD_HOST", "localhost") conf["MPD_HOST"] = os.getenv("MPD_HOST", "localhost")
conf["MPD_PORT"] = int(os.getenv("MPD_PORT", "6600")) conf["MPD_PORT"] = int(os.getenv("MPD_PORT", "6600"))
conf["UMASK"] = None
conf["CACHING_TIME"] = 10 conf["CACHING_TIME"] = 10
conf["DB_URI"] = os.path.join(conf_dir, "db.json") conf["DB_URI"] = os.path.join(conf_dir, "db.json")
conf["SCRIPTS_PATH"] = os.path.join(conf_dir, "scripts") conf["SCRIPTS_PATH"] = os.path.join(conf_dir, "scripts")
@ -26,15 +27,23 @@ def get_conf(prefix="LARIGIRA_"):
conf["SECRET_KEY"] = "Please replace me!" conf["SECRET_KEY"] = "Please replace me!"
conf["MPD_WAIT_START"] = True conf["MPD_WAIT_START"] = True
conf["MPD_WAIT_START_RETRYSECS"] = 5 conf["MPD_WAIT_START_RETRYSECS"] = 5
conf["MPD_ENFORCE_ALWAYS_PLAYING"] = False
conf["CHECK_SECS"] = 20 # period for checking playlist length conf["CHECK_SECS"] = 20 # period for checking playlist length
conf["EVENT_TICK_SECS"] = 30 # period for scheduling events conf["EVENT_TICK_SECS"] = 30 # period for scheduling events
conf["DEBUG"] = False conf["DEBUG"] = False
conf[
"REMOVE_UNUSED_FILES"
] = True # please keep it to True unless you are into deep debugging!
conf["LOG_CONFIG"] = False conf["LOG_CONFIG"] = False
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
# UI_CALENDAR_FREQUENCY_THRESHOLD"] has been removed
# use UI_CALENDAR_OCCURRENCIES_THRESHOLD instead
conf["UI_CALENDAR_FREQUENCY_THRESHOLD"] = 4 * 60 * 60 # 4 hours conf["UI_CALENDAR_FREQUENCY_THRESHOLD"] = 4 * 60 * 60 # 4 hours
conf["UI_CALENDAR_OCCURRENCIES_THRESHOLD"] = 40
conf["UI_CALENDAR_DATE_FMT"] = "medium" conf["UI_CALENDAR_DATE_FMT"] = "medium"
conf["EVENT_FILTERS"] = [] conf["EVENT_FILTERS"] = []
conf["HTTP_ADDRESS"] = "0.0.0.0"
conf["HTTP_PORT"] = 5000 conf["HTTP_PORT"] = 5000
conf["HOME_URL"] = "/db/calendar" conf["HOME_URL"] = "/db/calendar"
conf.update(from_envvars(prefix=prefix)) conf.update(from_envvars(prefix=prefix))
@ -66,7 +75,9 @@ def from_envvars(prefix=None, envvars=None, as_json=True):
if not envvars: if not envvars:
envvars = { envvars = {
k: k[len(prefix) :] for k in os.environ.keys() if k.startswith(prefix) k: k[len(prefix) :]
for k in os.environ.keys()
if k.startswith(prefix)
} }
for env_name, name in envvars.items(): for env_name, name in envvars.items():

View file

@ -4,30 +4,22 @@ This module contains a flask blueprint for db administration stuff
Templates are self-contained in this directory Templates are self-contained in this directory
""" """
from __future__ import print_function from __future__ import print_function
import os
from datetime import datetime, timedelta, time
from collections import defaultdict
import mimetypes import mimetypes
import os
from collections import defaultdict
from datetime import datetime, time, timedelta
from flask import ( from flask import (Blueprint, Response, abort, current_app, flash, jsonify,
current_app, redirect, render_template, request, url_for)
Blueprint,
Response,
render_template,
jsonify,
abort,
request,
redirect,
url_for,
flash,
)
from larigira.entrypoints_utils import get_avail_entrypoints
from larigira.audiogen import get_audiogenerator
from larigira.timegen_every import FrequencyAlarm
from larigira.timegen import get_timegenerator, timegenerate
from larigira import forms from larigira import forms
from larigira.audiogen import get_audiogenerator
from larigira.config import get_conf from larigira.config import get_conf
from larigira.entrypoints_utils import get_avail_entrypoints
from larigira.timegen import get_timegenerator, timegenerate
from larigira.timegen_every import FrequencyAlarm
from .suggestions import get_suggestions from .suggestions import get_suggestions
db = Blueprint( db = Blueprint(
@ -39,10 +31,13 @@ db = Blueprint(
def request_wants_json(): def request_wants_json():
best = request.accept_mimetypes.best_match(["application/json", "text/html"]) best = request.accept_mimetypes.best_match(
["application/json", "text/html"]
)
return ( return (
best == "application/json" best == "application/json"
and request.accept_mimetypes[best] > request.accept_mimetypes["text/html"] and request.accept_mimetypes[best]
> request.accept_mimetypes["text/html"]
) )
@ -67,26 +62,31 @@ def events_list():
def events_calendar(): def events_calendar():
model = current_app.larigira.controller.monitor.model model = current_app.larigira.controller.monitor.model
today = datetime.now().date() today = datetime.now().date()
maxdays = 30 max_days = 30
max_occurrences = get_conf()["UI_CALENDAR_OCCURRENCIES_THRESHOLD"]
# {date: {datetime: [(alarm1,actions1), (alarm2,actions2)]}} # {date: {datetime: [(alarm1,actions1), (alarm2,actions2)]}}
days = defaultdict(lambda: defaultdict(list)) days = defaultdict(lambda: defaultdict(list))
freq_threshold = get_conf()["UI_CALENDAR_FREQUENCY_THRESHOLD"] show_all = request.args.get("all", "0") == "1"
for alarm in model.get_all_alarms(): for alarm in model.get_all_alarms():
if (
freq_threshold
and alarm["kind"] == "frequency"
and FrequencyAlarm(alarm).interval < freq_threshold
):
continue
actions = tuple(model.get_actions_by_alarm(alarm)) actions = tuple(model.get_actions_by_alarm(alarm))
if not actions: if not actions:
continue continue
t = datetime.fromtimestamp(int(today.strftime("%s"))) today_dt = datetime.fromtimestamp(int(today.strftime("%s")))
for t in timegenerate(alarm, now=t, howmany=maxdays): max_dt = datetime.combine(today_dt + timedelta(days=max_days), time())
if t is None or t > datetime.combine( occurrences = []
today + timedelta(days=maxdays), time() for t in timegenerate(
): alarm, now=today_dt, howmany=max_occurrences + 1
):
if t is None:
break break
if t > max_dt:
break
occurrences.append(t)
if not occurrences:
continue
if not show_all and len(occurrences) > max_occurrences:
continue
for t in occurrences:
days[t.date()][t].append((alarm, actions)) days[t.date()][t].append((alarm, actions))
# { weeknum: [day1, day2, day3] } # { weeknum: [day1, day2, day3] }
@ -94,7 +94,9 @@ def events_calendar():
for d in sorted(days.keys()): for d in sorted(days.keys()):
weeks[d.isocalendar()[:2]].append(d) weeks[d.isocalendar()[:2]].append(d)
return render_template("calendar.html", days=days, weeks=weeks) return render_template(
"calendar.html", days=days, weeks=weeks, show_all=show_all
)
@db.route("/add/time") @db.route("/add/time")
@ -121,9 +123,15 @@ def edit_time(alarmid):
data = receiver(form) data = receiver(form)
model.update_alarm(alarmid, data) model.update_alarm(alarmid, data)
model.reload() model.reload()
return redirect(url_for("db.events_calendar", highlight="%d" % alarmid)) return redirect(
url_for("db.events_calendar", highlight="%d" % alarmid)
)
return render_template( return render_template(
"add_time_kind.html", form=form, kind=kind, mode="edit", alarmid=alarmid "add_time_kind.html",
form=form,
kind=kind,
mode="edit",
alarmid=alarmid,
) )
@ -150,7 +158,9 @@ def addtime_kind(kind):
resp.status_code = 400 resp.status_code = 400
return resp return resp
return render_template("add_time_kind.html", form=form, kind=kind, mode="add") return render_template(
"add_time_kind.html", form=form, kind=kind, mode="add"
)
@db.route("/add/audio") @db.route("/add/audio")
@ -180,7 +190,10 @@ def addaudio_kind(kind):
return resp return resp
return render_template( return render_template(
"add_audio_kind.html", form=form, kind=kind, suggestions=get_suggestions() "add_audio_kind.html",
form=form,
kind=kind,
suggestions=get_suggestions(),
) )
@ -214,6 +227,7 @@ def edit_event(alarmid):
if alarm is None: if alarm is None:
abort(404) abort(404)
allactions = model.get_all_actions() allactions = model.get_all_actions()
allactions.sort(key=lambda a: a.eid, reverse=True)
actions = tuple(model.get_actions_by_alarm(alarm)) actions = tuple(model.get_actions_by_alarm(alarm))
return render_template( return render_template(
"edit_event.html", "edit_event.html",

View file

@ -19,6 +19,19 @@ li.alarm .alarm-actions { display: none; }
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row">
{% if not show_all %}
<a href="{{url_for('db.events_calendar')}}?all=1"
class="btn btn-sm btn-default state-collapsed pull-right">
<span class="glyphicon glyphicon-resize-full"></span>
Mostra tutti gli eventi</a>
{% else %}
<a href="{{url_for('db.events_calendar')}}?all=0"
class="btn btn-sm btn-default state-collapsed pull-right">
<span class="glyphicon glyphicon-resize-small"></span>
Nascondi gli eventi troppo frequenti</a>
{% endif %}
</div>
{% for week, weekdays in weeks|dictsort %} {% for week, weekdays in weeks|dictsort %}
<div class="week row" id="week-{{week[0]}}-{{week[1]}}"> <div class="week row" id="week-{{week[0]}}-{{week[1]}}">
{% for day in weeks[week] %} {% for day in weeks[week] %}
@ -45,5 +58,6 @@ li.alarm .alarm-actions { display: none; }
<hr/> <hr/>
{%endfor %} {%endfor %}
</div><!-- container --> </div><!-- container -->
{% endblock content %} {% endblock content %}
{# vim: set ts=2 sw=2 noet: #} {# vim: set ts=2 sw=2 noet: #}

View file

@ -7,6 +7,10 @@
padding: 5px; padding: 5px;
border: 2px dashed #999; border: 2px dashed #999;
} }
#available-actions {
max-height: 50vw;
overflow-y: scroll;
}
</style> </style>
{%endblock styles%} {%endblock styles%}
{%block scripts %} {%block scripts %}
@ -49,32 +53,37 @@ $(function() {
You are currently editing: <code>{{alarm|tojson}}</code> You are currently editing: <code>{{alarm|tojson}}</code>
{% endif %} {% endif %}
<h2>Change actions</h2> <h2>Change actions</h2>
<div> <div class="row">
Available actions: <div id="available-actions" class="col-md-8">
<ul> Available actions:
{% for a in all_actions %} <ul>
<li title="{{a.kind}}" {% for a in all_actions %}
data-nick="{{a.nick}}" <li title="{{a.kind}}"
data-id="{{a.eid}}" data-nick="{{a.nick}}"
class="avail ui-state-highlight">{% if a.nick %} {{a.nick}} {% else %} {{a.eid}} {%endif%}</li> data-id="{{a.eid}}"
{% endfor %} class="avail ui-state-highlight">{% if a.nick %} {{a.nick}} {% else %} {{a.eid}} {%endif%}</li>
</ul> {% endfor %}
</div> </ul>
<div> </div>
Actions currently added: <div class="col-md-4">
<ul id="selected"> <div class="row">
{% for a in actions %} <div class="col-md-12">
<li title="{{a.kind}}" Actions currently added:
data-nick="{{a.nick}}" <ul id="selected">
data-id="{{a.eid}}" {% for a in actions %}
class="ui-state-default">{% if a.nick %} {{a.nick}} {% else %} {{a.eid}} {%endif%}</li> <li title="{{a.kind}}"
{% endfor %} data-nick="{{a.nick}}"
</ul> data-id="{{a.eid}}"
</div> class="ui-state-default">{% if a.nick %} {{a.nick}} {% else %} {{a.eid}} {%endif%}</li>
<div> {% endfor %}
<button>Save</button> </ul>
</div> </div><!-- .col-md-12 -->
</div> <div class="col-md-12">
<button>Save</button>
</div><!-- .col-md-12 -->
</div><!-- .row -->
</div><!-- .col-md-4 -->
</div><!-- .row -->
{% endblock content %} {% endblock content %}
{# vim: set ts=2 sw=2 noet: #} {# vim: set ts=2 sw=2 noet: #}

View file

@ -1,17 +1,19 @@
from __future__ import print_function from __future__ import print_function
from gevent import monkey
monkey.patch_all(subprocess=True)
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
import gevent import gevent
from gevent import monkey
from gevent.queue import Queue from gevent.queue import Queue
from .eventutils import ParentedLet, Timer
from .timegen import timegenerate
from .audiogen import audiogenerate from .audiogen import audiogenerate
from .db import EventModel from .db import EventModel
from .eventutils import ParentedLet, Timer
from .timegen import timegenerate
monkey.patch_all(subprocess=True)
logging.getLogger("mpd").setLevel(logging.WARNING) logging.getLogger("mpd").setLevel(logging.WARNING)
@ -51,6 +53,7 @@ class Monitor(ParentedLet):
logging.exception( logging.exception(
"Could not generate " "an alarm from timespec %s", timespec "Could not generate " "an alarm from timespec %s", timespec
) )
return None
if when is None: if when is None:
# expired # expired
return None return None
@ -75,9 +78,11 @@ class Monitor(ParentedLet):
# but it is "tricky"; any small delay would cause the event to be # but it is "tricky"; any small delay would cause the event to be
# missed # missed
if delta is None: if delta is None:
self.log.debug( # this is way too much logging! we need more levels!
"Skipping event %s: will never ring", alarm.get("nick", alarm.eid) # self.log.debug(
) # "Skipping event %s: will never ring", alarm.get("nick", alarm.eid)
# )
pass
elif delta <= 2 * self.conf["EVENT_TICK_SECS"]: elif delta <= 2 * self.conf["EVENT_TICK_SECS"]:
self.log.debug( self.log.debug(
"Scheduling event %s (%ds) => %s", "Scheduling event %s (%ds) => %s",
@ -87,7 +92,7 @@ class Monitor(ParentedLet):
) )
self.schedule(alarm, actions, delta) self.schedule(alarm, actions, delta)
else: else:
self.log.debug( self.log.debugv(
"Skipping event %s too far (%ds)", "Skipping event %s too far (%ds)",
alarm.get("nick", alarm.eid), alarm.get("nick", alarm.eid),
delta, delta,
@ -104,7 +109,9 @@ class Monitor(ParentedLet):
if delta is None: if delta is None:
delta = self._alarm_missing_time(timespec) delta = self._alarm_missing_time(timespec)
audiogen = gevent.spawn_later(delta, self.process_action, timespec, audiospecs) audiogen = gevent.spawn_later(
delta, self.process_action, timespec, audiospecs
)
audiogen.parent_greenlet = self audiogen.parent_greenlet = self
audiogen.doc = 'Will wait {} seconds, then generate audio "{}"'.format( audiogen.doc = 'Will wait {} seconds, then generate audio "{}"'.format(
delta, ",".join(aspec.get("nick", "") for aspec in audiospecs) delta, ",".join(aspec.get("nick", "") for aspec in audiospecs)

View file

@ -64,6 +64,12 @@ def percentwait(songs, context, conf, getdur=get_duration):
continue continue
eventduration += songduration eventduration += songduration
if eventduration == 0:
# must be an error! mutagen support is not always perfect
return (
True,
("mutagen could not calculate length of %s" % ",".join(songs["uris"])),
)
wait = eventduration * (percentwait / 100.0) wait = eventduration * (percentwait / 100.0)
if remaining > wait: if remaining > wait:
return False, "remaining %d max allowed %d" % (remaining, wait) return False, "remaining %d max allowed %d" % (remaining, wait)

View file

@ -1,6 +1,13 @@
import os
import fnmatch import fnmatch
import logging
import mimetypes import mimetypes
import os
import posixpath
import urllib.request
from tempfile import mkstemp
from urllib.parse import urlparse
log = logging.getLogger(__name__)
def scan_dir(dirname, extension=None): def scan_dir(dirname, extension=None):
@ -37,3 +44,27 @@ def shortname(path):
name = name.rsplit(".", 1)[0] # no extension name = name.rsplit(".", 1)[0] # no extension
name = "".join(c for c in name if c.isalnum()) # no strange chars name = "".join(c for c in name if c.isalnum()) # no strange chars
return name return name
def download_http(url, destdir=None, copy=False, prefix="httpdl"):
if url.split(":")[0] not in ("http", "https"):
log.warning("Not a valid URL: %s", url)
return None
ext = url.split(".")[-1]
if ext.lower() not in ("mp3", "ogg", "oga", "wma", "m4a"):
log.warning('Invalid format (%s) for "%s"', ext, url)
return None
if not copy:
return url
fname = posixpath.basename(urlparse(url).path)
# sanitize
fname = "".join(
c for c in fname if c.isalnum() or c in list("._-")
).rstrip()
tmp = mkstemp(
suffix="." + ext, prefix="%s-%s-" % (prefix, fname), dir=destdir
)
os.close(tmp[0])
log.info("downloading %s -> %s", url, tmp[1])
fname, headers = urllib.request.urlretrieve(url, tmp[1])
return "file://%s" % os.path.realpath(tmp[1])

View file

@ -2,25 +2,26 @@
This module is for the main application logic This module is for the main application logic
""" """
from __future__ import print_function from __future__ import print_function
from gevent import monkey
monkey.patch_all(subprocess=True) import json
import sys
import os
import tempfile
import signal
from time import sleep
import logging import logging
import logging.config import logging.config
import os
import signal
import subprocess import subprocess
import sys
import tempfile
from time import sleep
import gevent import gevent
from gevent import monkey
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
from .mpc import Controller, get_mpd_client from larigira.config import get_conf
from .config import get_conf from larigira.mpc import Controller, get_mpd_client
from .rpc import create_app from larigira.rpc import create_app
monkey.patch_all(subprocess=True)
def on_main_crash(*args, **kwargs): def on_main_crash(*args, **kwargs):
@ -30,12 +31,14 @@ def on_main_crash(*args, **kwargs):
class Larigira(object): class Larigira(object):
def __init__(self): def __init__(self):
self.log = logging.getLogger("larigira") self.log = logging.getLogger("larigira")
self.conf = get_conf() self.conf = get_conf()
self.controller = Controller(self.conf) self.controller = Controller(self.conf)
self.controller.link_exception(on_main_crash) self.controller.link_exception(on_main_crash)
self.http_server = WSGIServer( self.http_server = WSGIServer(
("", int(self.conf["HTTP_PORT"])), create_app(self.controller.q, self) (self.conf["HTTP_ADDRESS"], int(self.conf["HTTP_PORT"])),
create_app(self.controller.q, self),
) )
def start(self): def start(self):
@ -57,28 +60,57 @@ def sd_notify(ready=False, status=None):
def main(): def main():
tempfile.tempdir = os.environ["TMPDIR"] = os.path.join( logging.addLevelName(9, "DEBUGV")
os.getenv("TMPDIR", "/tmp"), "larigira.%d" % os.getuid()
)
if not os.path.isdir(os.environ["TMPDIR"]):
os.makedirs(os.environ["TMPDIR"])
if get_conf()["LOG_CONFIG"]: if get_conf()["LOG_CONFIG"]:
logging.config.fileConfig( logging.config.fileConfig(
get_conf()["LOG_CONFIG"], disable_existing_loggers=True get_conf()["LOG_CONFIG"], disable_existing_loggers=True
) )
else: else:
log_format = "%(asctime)s|%(levelname)s[%(name)s:%(lineno)d] %(message)s" log_format = (
"%(asctime)s|%(levelname)s[%(name)s:%(lineno)d] %(message)s"
)
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if get_conf()["DEBUG"] else logging.INFO, level="DEBUGV" if get_conf()["DEBUG"] else logging.INFO,
format=log_format, format=log_format,
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
def debugv(self, message, *args, **kws):
if self.isEnabledFor(9):
self._log(9, message, args, **kws)
logging.Logger.debugv = debugv
logging.debug(
"Starting larigira with this conf:\n%s",
json.dumps(get_conf(), indent=2),
)
if get_conf()["UMASK"]:
umask = int(get_conf()["UMASK"], base=8)
logging.debug(
"Setting umask %s (decimal: %d)", get_conf()["UMASK"], umask
)
os.umask(umask)
tempfile.tempdir = os.environ["TMPDIR"] = os.path.join(
os.getenv("TMPDIR", "/tmp"), "larigira.%d" % os.getuid()
)
if not os.path.isdir(os.environ["TMPDIR"]):
os.makedirs(os.environ["TMPDIR"])
if get_conf()["MPD_WAIT_START"]: if get_conf()["MPD_WAIT_START"]:
while True: while True:
try: try:
get_mpd_client(get_conf()) get_mpd_client(get_conf())
except Exception: except Exception as exc:
logging.debug("Could not connect to MPD, waiting") print("exc", exc, file=sys.stderr)
logging.debug(
"Could not connect to MPD at (%s,%s), waiting",
get_conf()["MPD_HOST"],
get_conf()["MPD_PORT"],
)
sd_notify(status="Waiting MPD connection") sd_notify(status="Waiting MPD connection")
sleep(int(get_conf()["MPD_WAIT_START_RETRYSECS"])) sleep(int(get_conf()["MPD_WAIT_START_RETRYSECS"]))
else: else:

View file

@ -1,17 +1,19 @@
from __future__ import print_function from __future__ import print_function
import logging import logging
import signal import signal
import mpd
from pkg_resources import iter_entry_points from pkg_resources import iter_entry_points
import gevent import gevent
from gevent.queue import Queue from gevent.queue import Queue
import mpd
from .audiogen import audiogenerate
from .entrypoints_utils import get_avail_entrypoints
from .event import Monitor from .event import Monitor
from .eventutils import ParentedLet, Timer from .eventutils import ParentedLet, Timer
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):
@ -51,7 +53,9 @@ class MPDWatcher(ParentedLet):
FileNotFoundError, FileNotFoundError,
) as exc: ) as exc:
self.log.warning( self.log.warning(
"Connection to MPD failed (%s: %s)", exc.__class__.__name__, exc "Connection to MPD failed (%s: %s)",
exc.__class__.__name__,
exc,
) )
self.client = None self.client = None
first_after_connection = True first_after_connection = True
@ -83,9 +87,15 @@ class Player:
mpd_client = mpd.MPDClient(use_unicode=True) mpd_client = mpd.MPDClient(use_unicode=True)
try: try:
mpd_client.connect(self.conf["MPD_HOST"], self.conf["MPD_PORT"]) mpd_client.connect(self.conf["MPD_HOST"], self.conf["MPD_PORT"])
except (mpd.ConnectionError, ConnectionRefusedError, FileNotFoundError) as exc: except (
mpd.ConnectionError,
ConnectionRefusedError,
FileNotFoundError,
) as exc:
self.log.warning( self.log.warning(
"Connection to MPD failed (%s: %s)", exc.__class__.__name__, exc "Connection to MPD failed (%s: %s)",
exc.__class__.__name__,
exc,
) )
raise gevent.GreenletExit() raise gevent.GreenletExit()
return mpd_client return mpd_client
@ -124,8 +134,8 @@ class Player:
uris = greenlet.value uris = greenlet.value
for uri in uris: for uri in uris:
assert type(uri) is str, type(uri) assert type(uri) is str, type(uri)
self.tmpcleaner.watch(uri.strip())
mpd_client.add(uri.strip()) mpd_client.add(uri.strip())
self.tmpcleaner.watch(uri.strip())
picker.link_value(add) picker.link_value(add)
picker.start() picker.start()
@ -146,7 +156,9 @@ class Player:
try: try:
ret = ef(songs=songs, context=ctx, conf=self.conf) ret = ef(songs=songs, context=ctx, conf=self.conf)
except ImportError as exc: except ImportError as exc:
self.log.warn("Filter %s skipped: %s" % (entrypoint.name, exc)) self.log.warn(
"Filter %s skipped: %s" % (entrypoint.name, exc)
)
continue continue
if ret is None: # bad behavior! if ret is None: # bad behavior!
continue continue
@ -157,6 +169,11 @@ class Player:
reason = "Filtered by %s (%s)" % (entrypoint.name, reason) reason = "Filtered by %s (%s)" % (entrypoint.name, reason)
if ret is False: if ret is False:
return ret, reason return ret, reason
else:
if reason:
self.log.debug(
"filter %s says ok: %s", entrypoint.name, reason
)
return True, "Passed through %s" % ",".join(availfilters) return True, "Passed through %s" % ",".join(availfilters)
def enqueue(self, songs): def enqueue(self, songs):
@ -197,6 +214,11 @@ class Player:
self.log.exception("Cannot insert song %s", uri) self.log.exception("Cannot insert song %s", uri)
self.tmpcleaner.watch(uri.strip()) self.tmpcleaner.watch(uri.strip())
def play(self):
"""make sure that MPD is playing"""
mpd_client = self._get_mpd()
mpd_client.play()
class Controller(gevent.Greenlet): class Controller(gevent.Greenlet):
def __init__(self, conf): def __init__(self, conf):
@ -224,7 +246,6 @@ class Controller(gevent.Greenlet):
gevent.Greenlet.spawn(self.player.check_playlist) gevent.Greenlet.spawn(self.player.check_playlist)
while True: while True:
value = self.q.get() value = self.q.get()
self.log.debug("<- %s", str(value))
# emitter = value['emitter'] # emitter = value['emitter']
kind = value["kind"] kind = value["kind"]
args = value["args"] args = value["args"]
@ -232,6 +253,8 @@ class Controller(gevent.Greenlet):
kind == "mpc" and args[0] in ("player", "playlist", "connect") kind == "mpc" and args[0] in ("player", "playlist", "connect")
): ):
gevent.Greenlet.spawn(self.player.check_playlist) gevent.Greenlet.spawn(self.player.check_playlist)
if self.conf["MPD_ENFORCE_ALWAYS_PLAYING"]:
gevent.Greenlet.spawn(self.player.play)
try: try:
self.player.tmpcleaner.check_playlist() self.player.tmpcleaner.check_playlist()
except: except:
@ -249,7 +272,9 @@ class Controller(gevent.Greenlet):
self.log.exception( self.log.exception(
"Error while adding to queue; " "bad audiogen output?" "Error while adding to queue; " "bad audiogen output?"
) )
elif (kind == "signal" and args[0] == signal.SIGALRM) or kind == "refresh": elif (
kind == "signal" and args[0] == signal.SIGALRM
) or kind == "refresh":
# it's a tick! # it's a tick!
self.log.debug("Reload") self.log.debug("Reload")
self.monitor.q.put(dict(kind="forcetick")) self.monitor.q.put(dict(kind="forcetick"))

View file

@ -1,24 +1,17 @@
import logging
import gc import gc
import logging
from copy import deepcopy from copy import deepcopy
from greenlet import greenlet from flask import (Blueprint, Flask, abort, current_app, jsonify, redirect,
from flask import ( render_template, request)
current_app,
Blueprint,
Flask,
jsonify,
render_template,
request,
abort,
redirect,
)
from flask_bootstrap import Bootstrap
from flask.ext.babel import Babel from flask.ext.babel import Babel
from werkzeug.contrib.cache import SimpleCache from flask_bootstrap import Bootstrap
from cachelib import SimpleCache
from greenlet import greenlet
from .dbadmin import db
from .config import get_conf from .config import get_conf
from .dbadmin import db
rpc = Blueprint("rpc", __name__, url_prefix=get_conf()["ROUTE_PREFIX"] + "/api") rpc = Blueprint("rpc", __name__, url_prefix=get_conf()["ROUTE_PREFIX"] + "/api")
viewui = Blueprint( viewui = Blueprint(

View file

@ -1,4 +1,5 @@
/* global jQuery */ /* global jQuery */
jQuery(function ($) { jQuery(function ($) {
$('.button').button({ $('.button').button({
icons: { icons: {

View file

@ -6,7 +6,7 @@ monkey.patch_all(subprocess=True)
import pytest import pytest
from larigira.rpc import create_app from larigira.rpc import create_app
from larigira.larigira import Larigira from larigira.main import Larigira
@pytest.fixture @pytest.fixture

View file

@ -1,18 +1,12 @@
import logging import logging
from datetime import datetime from datetime import datetime
from pytimeparse.timeparse import timeparse from pytimeparse.timeparse import timeparse
from flask_wtf import Form from flask_wtf import Form
from wtforms import (
StringField,
validators,
SubmitField,
SelectMultipleField,
ValidationError,
)
from larigira.formutils import EasyDateTimeField from larigira.formutils import EasyDateTimeField
from wtforms import (SelectMultipleField, StringField, SubmitField,
ValidationError, validators)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -90,9 +84,9 @@ class FrequencyAlarmForm(Form):
def populate_from_timespec(self, timespec): def populate_from_timespec(self, timespec):
if "nick" in timespec: if "nick" in timespec:
self.nick.data = timespec["nick"] self.nick.data = timespec["nick"]
if "start" in timespec: if timespec.get("start"):
self.start.data = datetime.fromtimestamp(timespec["start"]) self.start.data = datetime.fromtimestamp(timespec["start"])
if "end" in timespec: if timespec.get("end"):
self.end.data = datetime.fromtimestamp(timespec["end"]) self.end.data = datetime.fromtimestamp(timespec["end"])
if "weekdays" in timespec: if "weekdays" in timespec:
self.weekdays.data = timespec["weekdays"] self.weekdays.data = timespec["weekdays"]
@ -123,6 +117,6 @@ def frequencyalarm_receive(form):
obj["start"] = int(form.start.data.strftime("%s")) obj["start"] = int(form.start.data.strftime("%s"))
else: else:
obj["start"] = 0 obj["start"] = 0
if form.end.data:
obj["end"] = int(form.end.data.strftime("%s")) obj["end"] = int(form.end.data.strftime("%s")) if form.end.data else None
return obj return obj

View file

@ -4,9 +4,10 @@ This component will look for files to be removed. There are some assumptions:
own specific TMPDIR own specific TMPDIR
* MPD URIs are parsed, and only file:/// is supported * MPD URIs are parsed, and only file:/// is supported
""" """
import logging
import os import os
from os.path import normpath from os.path import normpath
import logging
import mpd import mpd
@ -64,11 +65,13 @@ class UnusedCleaner:
"""check playlist + internal watchlist to see what can be removed""" """check playlist + internal watchlist to see what can be removed"""
mpdc = self._get_mpd() mpdc = self._get_mpd()
files_in_playlist = { files_in_playlist = {
song["file"] for song in mpdc.playlistid() if song["file"].startswith("/") song["file"]
for song in mpdc.playlistid()
if song["file"].startswith("/")
} }
for fpath in self.waiting_removal_files - files_in_playlist: for fpath in self.waiting_removal_files - files_in_playlist:
# we can remove it! # we can remove it!
self.log.debug("removing unused: %s", fpath) self.log.debug("removing unused: %s", fpath)
self.waiting_removal_files.remove(fpath) self.waiting_removal_files.remove(fpath)
if os.path.exists(fpath): if os.path.exists(fpath) and self.conf["REMOVE_UNUSED_FILES"]:
os.unlink(fpath) os.unlink(fpath)

185
setup.py
View file

@ -1,5 +1,5 @@
import sys
import os import os
import sys
from setuptools import setup from setuptools import setup
from setuptools.command.test import test as TestCommand from setuptools.command.test import test as TestCommand
@ -11,7 +11,7 @@ def read(fname):
class PyTest(TestCommand): class PyTest(TestCommand):
user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] user_options = [("pytest-args=", "a", "Arguments to pass to py.test")]
def initialize_options(self): def initialize_options(self):
TestCommand.initialize_options(self) TestCommand.initialize_options(self)
@ -25,89 +25,104 @@ class PyTest(TestCommand):
def run_tests(self): def run_tests(self):
# import here, cause outside the eggs aren't loaded # import here, cause outside the eggs aren't loaded
import pytest import pytest
errno = pytest.main(self.pytest_args) errno = pytest.main(self.pytest_args)
sys.exit(errno) sys.exit(errno)
setup(name='larigira',
version='1.3.1', setup(
description='A radio automation based on MPD', name="larigira",
long_description=read('README.rst'), version="1.3.3",
long_description_content_type='text/x-rst', description="A radio automation based on MPD",
author='boyska', long_description=read("README.rst"),
author_email='piuttosto@logorroici.org', long_description_content_type="text/x-rst",
license='AGPL', author="boyska",
packages=['larigira', 'larigira.dbadmin', 'larigira.filters'], author_email="piuttosto@logorroici.org",
install_requires=[ license="AGPL",
'Babel==2.6.0', packages=["larigira", "larigira.dbadmin", "larigira.filters"],
'Flask-Babel==0.12.2', install_requires=[
'pyxdg', "Babel==2.6.0",
'gevent', "Flask-Babel==1.0.0",
'flask-bootstrap', "pyxdg==0.26",
'python-mpd2', "gevent==1.4.0",
'wtforms', "flask-bootstrap",
'Flask-WTF', "python-mpd2",
'flask==0.11', "wtforms==2.2.1",
'pytimeparse', "Flask-WTF==0.14.2",
'croniter==0.3.29', "flask==0.11",
'tinydb' "pytimeparse==1.1.8",
], "croniter==0.3.29",
tests_require=['pytest-timeout==1.0', 'py>=1.4.29', 'pytest==3.0', ], "werkzeug==0.14.1",
python_requires='>=3.5', "cachelib==0.1",
extras_require={ "tinydb==3.12.2",
'percentwait': ['mutagen'], "lxml==4.5.1",
}, "requests==2.23.0",
cmdclass={'test': PyTest}, ],
zip_safe=False, tests_require=["pytest-timeout==1.0", "py>=1.4.29", "pytest==3.0"],
include_package_data=True, python_requires=">=3.5",
entry_points={ extras_require={"percentwait": ["mutagen"]},
'console_scripts': ['larigira=larigira.larigira:main', cmdclass={"test": PyTest},
'larigira-timegen=larigira.timegen:main', zip_safe=False,
'larigira-audiogen=larigira.audiogen:main', include_package_data=True,
'larigira-dbmanage=larigira.event_manage:main'], entry_points={
'larigira.audiogenerators': [ "console_scripts": [
'mpd = larigira.audiogen_mpdrandom:generate_by_artist', "larigira=larigira.main:main",
'static = larigira.audiogen_static:generate', "larigira-timegen=larigira.timegen:main",
'http = larigira.audiogen_http:generate', "larigira-audiogen=larigira.audiogen:main",
'randomdir = larigira.audiogen_randomdir:generate', "larigira-dbmanage=larigira.event_manage:main",
'mostrecent = larigira.audiogen_mostrecent:generate', ],
'script = larigira.audiogen_script:generate', "larigira.audiogenerators": [
], "mpd = larigira.audiogen_mpdrandom:generate_by_artist",
'larigira.timegenerators': [ "static = larigira.audiogen_static:generate",
'frequency = larigira.timegen_every:FrequencyAlarm', "http = larigira.audiogen_http:generate",
'single = larigira.timegen_every:SingleAlarm', "podcast = larigira.audiogen_podcast:generate",
'cron = larigira.timegen_cron:CronAlarm', "randomdir = larigira.audiogen_randomdir:generate",
], "mostrecent = larigira.audiogen_mostrecent:generate",
'larigira.timeform_create': [ "script = larigira.audiogen_script:generate",
'single = larigira.timeform_base:SingleAlarmForm', ],
'frequency = larigira.timeform_base:FrequencyAlarmForm', "larigira.timegenerators": [
'cron = larigira.timeform_cron:CronAlarmForm', "frequency = larigira.timegen_every:FrequencyAlarm",
], "single = larigira.timegen_every:SingleAlarm",
'larigira.timeform_receive': [ "cron = larigira.timegen_cron:CronAlarm",
'single = larigira.timeform_base:singlealarm_receive', ],
'frequency = larigira.timeform_base:frequencyalarm_receive', "larigira.timeform_create": [
'cron = larigira.timeform_cron:cronalarm_receive', "single = larigira.timeform_base:SingleAlarmForm",
], "frequency = larigira.timeform_base:FrequencyAlarmForm",
'larigira.audioform_create': [ "cron = larigira.timeform_cron:CronAlarmForm",
'static = larigira.audioform_static:StaticAudioForm', ],
'http = larigira.audioform_http:AudioForm', "larigira.timeform_receive": [
'script = larigira.audioform_script:ScriptAudioForm', "single = larigira.timeform_base:singlealarm_receive",
'randomdir = larigira.audioform_randomdir:Form', "frequency = larigira.timeform_base:frequencyalarm_receive",
'mostrecent = larigira.audioform_mostrecent:AudioForm', "cron = larigira.timeform_cron:cronalarm_receive",
], ],
'larigira.audioform_receive': [ "larigira.audioform_create": [
'static = larigira.audioform_static:staticaudio_receive', "static = larigira.audioform_static:StaticAudioForm",
'http = larigira.audioform_http:audio_receive', "http = larigira.audioform_http:AudioForm",
'script = larigira.audioform_script:scriptaudio_receive', "podcast = larigira.audioform_podcast:AudioForm",
'randomdir = larigira.audioform_randomdir:receive', "script = larigira.audioform_script:ScriptAudioForm",
'mostrecent = larigira.audioform_mostrecent:audio_receive', "randomdir = larigira.audioform_randomdir:Form",
], "mostrecent = larigira.audioform_mostrecent:AudioForm",
'larigira.eventfilter': [ ],
'maxwait = larigira.filters:maxwait', "larigira.audioform_receive": [
'percentwait = larigira.filters:percentwait', "static = larigira.audioform_static:staticaudio_receive",
], "http = larigira.audioform_http:audio_receive",
}, "podcast = larigira.audioform_podcast:audio_receive",
classifiers=[ "script = larigira.audioform_script:scriptaudio_receive",
"License :: OSI Approved :: GNU Affero General Public License v3", "randomdir = larigira.audioform_randomdir:receive",
"Programming Language :: Python :: 3", "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",
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Topic :: Multimedia :: Sound/Audio",
],
)