Compare commits
57 commits
Author | SHA1 | Date | |
---|---|---|---|
dfc59e94f9 | |||
3e609581cf | |||
46eb5c400b | |||
1a21495434 | |||
d06c72043f | |||
c330bdeff4 | |||
e9a37cf0f2 | |||
924615b282 | |||
e1729fed05 | |||
5976c3fe3d | |||
d724fa5c24 | |||
b97bf3271d | |||
19a1e3de1b | |||
0f565910f7 | |||
3a59fdd917 | |||
ae85eeb971 | |||
e8f061ec63 | |||
b8c7bd424e | |||
d4852d5df4 | |||
74a0f2c902 | |||
1a517e0f29 | |||
3d541993c7 | |||
1d30e7f984 | |||
cdf5733d59 | |||
255d97d42f | |||
b0c2d2195e | |||
d9123555ce | |||
32337c54cf | |||
9854445f18 | |||
4d175b9451 | |||
d69352076e | |||
ea04da9bd3 | |||
1adfa83d1d | |||
3619d2c7a6 | |||
c122d17af3 | |||
b2b9de1271 | |||
4145e53d8f | |||
344fd7ef85 | |||
6495c625b4 | |||
c3c837b7f4 | |||
ef38515559 | |||
400d56cfdd | |||
88ff77b968 | |||
e54ce8f90f | |||
1fbe659fc1 | |||
bf5eca28c3 | |||
9e3c2c5194 | |||
4e685f3425 | |||
76ffb69dbf | |||
db8b555233 | |||
51de003d95 | |||
3fe43cdaf7 | |||
964770f9b4 | |||
50c7a5ca61 | |||
4401a29f04 | |||
9769717e11 | |||
b95eb96f12 |
29 changed files with 1068 additions and 360 deletions
13
ISSUE_TEMPLATE.md
Normal file
13
ISSUE_TEMPLATE.md
Normal 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?
|
|
@ -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
|
||||||
|
|
||||||
|
|
22
doc/source/api/larigira.filters.rst
Normal file
22
doc/source/api/larigira.filters.rst
Normal 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:
|
|
@ -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
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
152
doc/source/quickstart.rst
Normal 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!
|
16
doc/source/troubleshooting.rst
Normal file
16
doc/source/troubleshooting.rst
Normal 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.
|
||||||
|
|
63
larigira/audioform_podcast.py
Normal file
63
larigira/audioform_podcast.py
Normal 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
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
216
larigira/audiogen_podcast.py
Normal file
216
larigira/audiogen_podcast.py
Normal 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))
|
|
@ -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():
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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: #}
|
||||||
|
|
|
@ -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: #}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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:
|
|
@ -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"))
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
/* global jQuery */
|
/* global jQuery */
|
||||||
|
|
||||||
jQuery(function ($) {
|
jQuery(function ($) {
|
||||||
$('.button').button({
|
$('.button').button({
|
||||||
icons: {
|
icons: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
185
setup.py
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in a new issue