migrate to Rust
This commit is contained in:
parent
c2ee7965c5
commit
54692435d0
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -21,7 +21,7 @@ Steps to reproduce the behavior:
|
|||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Desktop/Server/Software (please complete the following information):**
|
**Desktop/Server/Software (please complete the following information):**
|
||||||
- OS: [e.g. debian 10]
|
- OS: [e.g. debian 11]
|
||||||
- python version
|
- python version
|
||||||
- ffmpeg version
|
- ffmpeg version
|
||||||
- are you using the current master of ffplayout?
|
- are you using the current master of ffplayout?
|
||||||
|
67
.github/workflows/codeql-analysis.yml
vendored
67
.github/workflows/codeql-analysis.yml
vendored
@ -1,67 +0,0 @@
|
|||||||
# For most projects, this workflow file will not need changing; you simply need
|
|
||||||
# to commit it to your repository.
|
|
||||||
#
|
|
||||||
# You may wish to alter this file to override the set of languages analyzed,
|
|
||||||
# or to provide custom queries or build logic.
|
|
||||||
#
|
|
||||||
# ******** NOTE ********
|
|
||||||
# We have attempted to detect the languages in your repository. Please check
|
|
||||||
# the `language` matrix defined below to confirm you have the correct set of
|
|
||||||
# supported CodeQL languages.
|
|
||||||
#
|
|
||||||
name: "CodeQL"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: [ master ]
|
|
||||||
schedule:
|
|
||||||
- cron: '38 5 * * 3'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [ 'python' ]
|
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
|
||||||
# Learn more:
|
|
||||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v1
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
|
||||||
# By default, queries listed here will override any specified in a config file.
|
|
||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v1
|
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
|
||||||
# 📚 https://git.io/JvXDl
|
|
||||||
|
|
||||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
|
||||||
# and modify them (or add more) to build your code if your project
|
|
||||||
# uses a compiled language
|
|
||||||
|
|
||||||
#- run: |
|
|
||||||
# make bootstrap
|
|
||||||
# make release
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v1
|
|
32
.github/workflows/pythonapp.yml
vendored
32
.github/workflows/pythonapp.yml
vendored
@ -1,32 +0,0 @@
|
|||||||
name: Python application
|
|
||||||
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v1
|
|
||||||
- name: Set up Python 3.9
|
|
||||||
uses: actions/setup-python@v1
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install ffmpeg
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-base.txt
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
- name: Lint with flake8
|
|
||||||
run: |
|
|
||||||
pip install flake8
|
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
||||||
- name: Test with pytest
|
|
||||||
run: |
|
|
||||||
pytest -vv
|
|
606
.pylintrc
606
.pylintrc
@ -1,606 +0,0 @@
|
|||||||
[MASTER]
|
|
||||||
|
|
||||||
# A comma-separated list of package or module names from where C extensions may
|
|
||||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
|
||||||
# run arbitrary code.
|
|
||||||
extension-pkg-whitelist=
|
|
||||||
|
|
||||||
# Specify a score threshold to be exceeded before program exits with error.
|
|
||||||
fail-under=10.0
|
|
||||||
|
|
||||||
# Add files or directories to the blacklist. They should be base names, not
|
|
||||||
# paths.
|
|
||||||
ignore=CVS
|
|
||||||
|
|
||||||
# Add files or directories matching the regex patterns to the blacklist. The
|
|
||||||
# regex matches against base names, not paths.
|
|
||||||
ignore-patterns=
|
|
||||||
|
|
||||||
# Python code to execute, usually for sys.path manipulation such as
|
|
||||||
# pygtk.require().
|
|
||||||
#init-hook=
|
|
||||||
|
|
||||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
|
||||||
# number of processors available to use.
|
|
||||||
jobs=1
|
|
||||||
|
|
||||||
# Control the amount of potential inferred values when inferring a single
|
|
||||||
# object. This can help the performance when dealing with large functions or
|
|
||||||
# complex, nested conditions.
|
|
||||||
limit-inference-results=100
|
|
||||||
|
|
||||||
# List of plugins (as comma separated values of python module names) to load,
|
|
||||||
# usually to register additional checkers.
|
|
||||||
load-plugins=
|
|
||||||
|
|
||||||
# Pickle collected data for later comparisons.
|
|
||||||
persistent=yes
|
|
||||||
|
|
||||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
|
||||||
# user-friendly hints instead of false-positive error messages.
|
|
||||||
suggestion-mode=yes
|
|
||||||
|
|
||||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
|
||||||
# active Python interpreter and may run arbitrary code.
|
|
||||||
unsafe-load-any-extension=no
|
|
||||||
|
|
||||||
|
|
||||||
[MESSAGES CONTROL]
|
|
||||||
|
|
||||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
|
||||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
|
|
||||||
confidence=
|
|
||||||
|
|
||||||
# Disable the message, report, category or checker with the given id(s). You
|
|
||||||
# can either give multiple identifiers separated by comma (,) or put this
|
|
||||||
# option multiple times (only on the command line, not in the configuration
|
|
||||||
# file where it should appear only once). You can also use "--disable=all" to
|
|
||||||
# disable everything first and then reenable specific checks. For example, if
|
|
||||||
# you want to run only the similarities checker, you can use "--disable=all
|
|
||||||
# --enable=similarities". If you want to run only the classes checker, but have
|
|
||||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
|
||||||
# --disable=W".
|
|
||||||
disable=print-statement,
|
|
||||||
parameter-unpacking,
|
|
||||||
unpacking-in-except,
|
|
||||||
old-raise-syntax,
|
|
||||||
backtick,
|
|
||||||
long-suffix,
|
|
||||||
old-ne-operator,
|
|
||||||
old-octal-literal,
|
|
||||||
import-star-module-level,
|
|
||||||
non-ascii-bytes-literal,
|
|
||||||
raw-checker-failed,
|
|
||||||
bad-inline-option,
|
|
||||||
locally-disabled,
|
|
||||||
file-ignored,
|
|
||||||
suppressed-message,
|
|
||||||
useless-suppression,
|
|
||||||
deprecated-pragma,
|
|
||||||
use-symbolic-message-instead,
|
|
||||||
apply-builtin,
|
|
||||||
basestring-builtin,
|
|
||||||
buffer-builtin,
|
|
||||||
cmp-builtin,
|
|
||||||
coerce-builtin,
|
|
||||||
execfile-builtin,
|
|
||||||
file-builtin,
|
|
||||||
long-builtin,
|
|
||||||
raw_input-builtin,
|
|
||||||
reduce-builtin,
|
|
||||||
standarderror-builtin,
|
|
||||||
unicode-builtin,
|
|
||||||
xrange-builtin,
|
|
||||||
coerce-method,
|
|
||||||
delslice-method,
|
|
||||||
getslice-method,
|
|
||||||
setslice-method,
|
|
||||||
no-absolute-import,
|
|
||||||
old-division,
|
|
||||||
dict-iter-method,
|
|
||||||
dict-view-method,
|
|
||||||
next-method-called,
|
|
||||||
metaclass-assignment,
|
|
||||||
indexing-exception,
|
|
||||||
raising-string,
|
|
||||||
reload-builtin,
|
|
||||||
oct-method,
|
|
||||||
hex-method,
|
|
||||||
nonzero-method,
|
|
||||||
cmp-method,
|
|
||||||
input-builtin,
|
|
||||||
round-builtin,
|
|
||||||
intern-builtin,
|
|
||||||
unichr-builtin,
|
|
||||||
map-builtin-not-iterating,
|
|
||||||
zip-builtin-not-iterating,
|
|
||||||
range-builtin-not-iterating,
|
|
||||||
filter-builtin-not-iterating,
|
|
||||||
using-cmp-argument,
|
|
||||||
eq-without-hash,
|
|
||||||
div-method,
|
|
||||||
idiv-method,
|
|
||||||
rdiv-method,
|
|
||||||
exception-message-attribute,
|
|
||||||
invalid-str-codec,
|
|
||||||
sys-max-int,
|
|
||||||
bad-python3-import,
|
|
||||||
deprecated-string-function,
|
|
||||||
deprecated-str-translate-call,
|
|
||||||
deprecated-itertools-function,
|
|
||||||
deprecated-types-field,
|
|
||||||
next-method-defined,
|
|
||||||
dict-items-not-iterating,
|
|
||||||
dict-keys-not-iterating,
|
|
||||||
dict-values-not-iterating,
|
|
||||||
deprecated-operator-function,
|
|
||||||
deprecated-urllib-function,
|
|
||||||
xreadlines-attribute,
|
|
||||||
deprecated-sys-function,
|
|
||||||
exception-escape,
|
|
||||||
comprehension-escape,
|
|
||||||
too-few-public-methods,
|
|
||||||
logging-fstring-interpolation,
|
|
||||||
too-many-instance-attributes,
|
|
||||||
missing-function-docstring,
|
|
||||||
import-error,
|
|
||||||
consider-using-with
|
|
||||||
|
|
||||||
# Enable the message, report, category or checker with the given id(s). You can
|
|
||||||
# either give multiple identifier separated by comma (,) or put this option
|
|
||||||
# multiple time (only on the command line, not in the configuration file where
|
|
||||||
# it should appear only once). See also the "--disable" option for examples.
|
|
||||||
enable=c-extension-no-member
|
|
||||||
|
|
||||||
|
|
||||||
[REPORTS]
|
|
||||||
|
|
||||||
# Python expression which should return a score less than or equal to 10. You
|
|
||||||
# have access to the variables 'error', 'warning', 'refactor', and 'convention'
|
|
||||||
# which contain the number of messages in each category, as well as 'statement'
|
|
||||||
# which is the total number of statements analyzed. This score is used by the
|
|
||||||
# global evaluation report (RP0004).
|
|
||||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
|
||||||
|
|
||||||
# Template used to display messages. This is a python new-style format string
|
|
||||||
# used to format the message information. See doc for all details.
|
|
||||||
#msg-template=
|
|
||||||
|
|
||||||
# Set the output format. Available formats are text, parseable, colorized, json
|
|
||||||
# and msvs (visual studio). You can also give a reporter class, e.g.
|
|
||||||
# mypackage.mymodule.MyReporterClass.
|
|
||||||
output-format=text
|
|
||||||
|
|
||||||
# Tells whether to display a full report or only the messages.
|
|
||||||
reports=no
|
|
||||||
|
|
||||||
# Activate the evaluation score.
|
|
||||||
score=yes
|
|
||||||
|
|
||||||
|
|
||||||
[REFACTORING]
|
|
||||||
|
|
||||||
# Maximum number of nested blocks for function / method body
|
|
||||||
max-nested-blocks=5
|
|
||||||
|
|
||||||
# Complete name of functions that never returns. When checking for
|
|
||||||
# inconsistent-return-statements if a never returning function is called then
|
|
||||||
# it will be considered as an explicit return statement and no message will be
|
|
||||||
# printed.
|
|
||||||
never-returning-functions=sys.exit
|
|
||||||
|
|
||||||
|
|
||||||
[LOGGING]
|
|
||||||
|
|
||||||
# The type of string formatting that logging methods do. `old` means using %
|
|
||||||
# formatting, `new` is for `{}` formatting.
|
|
||||||
logging-format-style=new
|
|
||||||
|
|
||||||
# Logging modules to check that the string format arguments are in logging
|
|
||||||
# function parameter format.
|
|
||||||
logging-modules=logging
|
|
||||||
|
|
||||||
|
|
||||||
[BASIC]
|
|
||||||
|
|
||||||
# Naming style matching correct argument names.
|
|
||||||
argument-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct argument names. Overrides argument-
|
|
||||||
# naming-style.
|
|
||||||
#argument-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct attribute names.
|
|
||||||
attr-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
|
||||||
# style.
|
|
||||||
#attr-rgx=
|
|
||||||
|
|
||||||
# Bad variable names which should always be refused, separated by a comma.
|
|
||||||
bad-names=foo,
|
|
||||||
bar,
|
|
||||||
baz,
|
|
||||||
toto,
|
|
||||||
tutu,
|
|
||||||
tata
|
|
||||||
|
|
||||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
|
||||||
# they will always be refused
|
|
||||||
bad-names-rgxs=
|
|
||||||
|
|
||||||
# Naming style matching correct class attribute names.
|
|
||||||
class-attribute-naming-style=any
|
|
||||||
|
|
||||||
# Regular expression matching correct class attribute names. Overrides class-
|
|
||||||
# attribute-naming-style.
|
|
||||||
#class-attribute-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct class names.
|
|
||||||
class-naming-style=PascalCase
|
|
||||||
|
|
||||||
# Regular expression matching correct class names. Overrides class-naming-
|
|
||||||
# style.
|
|
||||||
#class-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct constant names.
|
|
||||||
const-naming-style=UPPER_CASE
|
|
||||||
|
|
||||||
# Regular expression matching correct constant names. Overrides const-naming-
|
|
||||||
# style.
|
|
||||||
#const-rgx=
|
|
||||||
|
|
||||||
# Minimum line length for functions/classes that require docstrings, shorter
|
|
||||||
# ones are exempt.
|
|
||||||
docstring-min-length=-1
|
|
||||||
|
|
||||||
# Naming style matching correct function names.
|
|
||||||
function-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct function names. Overrides function-
|
|
||||||
# naming-style.
|
|
||||||
#function-rgx=
|
|
||||||
|
|
||||||
# Good variable names which should always be accepted, separated by a comma.
|
|
||||||
good-names=i,
|
|
||||||
j,
|
|
||||||
k,
|
|
||||||
ex,
|
|
||||||
Run,
|
|
||||||
_
|
|
||||||
|
|
||||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
|
||||||
# they will always be accepted
|
|
||||||
good-names-rgxs=
|
|
||||||
|
|
||||||
# Include a hint for the correct naming format with invalid-name.
|
|
||||||
include-naming-hint=no
|
|
||||||
|
|
||||||
# Naming style matching correct inline iteration names.
|
|
||||||
inlinevar-naming-style=any
|
|
||||||
|
|
||||||
# Regular expression matching correct inline iteration names. Overrides
|
|
||||||
# inlinevar-naming-style.
|
|
||||||
#inlinevar-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct method names.
|
|
||||||
method-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct method names. Overrides method-naming-
|
|
||||||
# style.
|
|
||||||
#method-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct module names.
|
|
||||||
module-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct module names. Overrides module-naming-
|
|
||||||
# style.
|
|
||||||
#module-rgx=
|
|
||||||
|
|
||||||
# Colon-delimited sets of names that determine each other's naming style when
|
|
||||||
# the name regexes allow several styles.
|
|
||||||
name-group=
|
|
||||||
|
|
||||||
# Regular expression which should only match function or class names that do
|
|
||||||
# not require a docstring.
|
|
||||||
no-docstring-rgx=^_
|
|
||||||
|
|
||||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
|
||||||
# to this list to register other decorators that produce valid properties.
|
|
||||||
# These decorators are taken in consideration only for invalid-name.
|
|
||||||
property-classes=abc.abstractproperty
|
|
||||||
|
|
||||||
# Naming style matching correct variable names.
|
|
||||||
variable-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct variable names. Overrides variable-
|
|
||||||
# naming-style.
|
|
||||||
#variable-rgx=
|
|
||||||
|
|
||||||
|
|
||||||
[SIMILARITIES]
|
|
||||||
|
|
||||||
# Ignore comments when computing similarities.
|
|
||||||
ignore-comments=yes
|
|
||||||
|
|
||||||
# Ignore docstrings when computing similarities.
|
|
||||||
ignore-docstrings=yes
|
|
||||||
|
|
||||||
# Ignore imports when computing similarities.
|
|
||||||
ignore-imports=no
|
|
||||||
|
|
||||||
# Minimum lines number of a similarity.
|
|
||||||
min-similarity-lines=4
|
|
||||||
|
|
||||||
|
|
||||||
[STRING]
|
|
||||||
|
|
||||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
|
||||||
# character used as a quote delimiter is used inconsistently within a module.
|
|
||||||
check-quote-consistency=no
|
|
||||||
|
|
||||||
# This flag controls whether the implicit-str-concat should generate a warning
|
|
||||||
# on implicit string concatenation in sequences defined over several lines.
|
|
||||||
check-str-concat-over-line-jumps=no
|
|
||||||
|
|
||||||
|
|
||||||
[VARIABLES]
|
|
||||||
|
|
||||||
# List of additional names supposed to be defined in builtins. Remember that
|
|
||||||
# you should avoid defining new builtins when possible.
|
|
||||||
additional-builtins=
|
|
||||||
|
|
||||||
# Tells whether unused global variables should be treated as a violation.
|
|
||||||
allow-global-unused-variables=yes
|
|
||||||
|
|
||||||
# List of strings which can identify a callback function by name. A callback
|
|
||||||
# name must start or end with one of those strings.
|
|
||||||
callbacks=cb_,
|
|
||||||
_cb
|
|
||||||
|
|
||||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
|
||||||
# not be used).
|
|
||||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
|
||||||
|
|
||||||
# Argument names that match this expression will be ignored. Default to name
|
|
||||||
# with leading underscore.
|
|
||||||
ignored-argument-names=_.*|^ignored_|^unused_
|
|
||||||
|
|
||||||
# Tells whether we should check for unused import in __init__ files.
|
|
||||||
init-import=no
|
|
||||||
|
|
||||||
# List of qualified module names which can have objects that can redefine
|
|
||||||
# builtins.
|
|
||||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
|
||||||
|
|
||||||
|
|
||||||
[FORMAT]
|
|
||||||
|
|
||||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
|
||||||
expected-line-ending-format=
|
|
||||||
|
|
||||||
# Regexp for a line that is allowed to be longer than the limit.
|
|
||||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
|
||||||
|
|
||||||
# Number of spaces of indent required inside a hanging or continued line.
|
|
||||||
indent-after-paren=4
|
|
||||||
|
|
||||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
|
||||||
# tab).
|
|
||||||
indent-string=' '
|
|
||||||
|
|
||||||
# Maximum number of characters on a single line.
|
|
||||||
max-line-length=100
|
|
||||||
|
|
||||||
# Maximum number of lines in a module.
|
|
||||||
max-module-lines=1000
|
|
||||||
|
|
||||||
# Allow the body of a class to be on the same line as the declaration if body
|
|
||||||
# contains single statement.
|
|
||||||
single-line-class-stmt=no
|
|
||||||
|
|
||||||
# Allow the body of an if to be on the same line as the test if there is no
|
|
||||||
# else.
|
|
||||||
single-line-if-stmt=no
|
|
||||||
|
|
||||||
|
|
||||||
[MISCELLANEOUS]
|
|
||||||
|
|
||||||
# List of note tags to take in consideration, separated by a comma.
|
|
||||||
notes=FIXME,
|
|
||||||
XXX,
|
|
||||||
TODO
|
|
||||||
|
|
||||||
# Regular expression of note tags to take in consideration.
|
|
||||||
#notes-rgx=
|
|
||||||
|
|
||||||
|
|
||||||
[TYPECHECK]
|
|
||||||
|
|
||||||
# List of decorators that produce context managers, such as
|
|
||||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
|
||||||
# produce valid context managers.
|
|
||||||
contextmanager-decorators=contextlib.contextmanager
|
|
||||||
|
|
||||||
# List of members which are set dynamically and missed by pylint inference
|
|
||||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
|
||||||
# expressions are accepted.
|
|
||||||
generated-members=LINGER,REQ,ROUTER,NOBLOCK
|
|
||||||
|
|
||||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
|
||||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
|
||||||
ignore-mixin-members=yes
|
|
||||||
|
|
||||||
# Tells whether to warn about missing members when the owner of the attribute
|
|
||||||
# is inferred to be None.
|
|
||||||
ignore-none=yes
|
|
||||||
|
|
||||||
# This flag controls whether pylint should warn about no-member and similar
|
|
||||||
# checks whenever an opaque object is returned when inferring. The inference
|
|
||||||
# can return multiple potential results while evaluating a Python object, but
|
|
||||||
# some branches might not be evaluated, which results in partial inference. In
|
|
||||||
# that case, it might be useful to still emit no-member and other checks for
|
|
||||||
# the rest of the inferred objects.
|
|
||||||
ignore-on-opaque-inference=yes
|
|
||||||
|
|
||||||
# List of class names for which member attributes should not be checked (useful
|
|
||||||
# for classes with dynamically set attributes). This supports the use of
|
|
||||||
# qualified names.
|
|
||||||
ignored-classes=optparse.Values,thread._local,_thread._local
|
|
||||||
|
|
||||||
# List of module names for which member attributes should not be checked
|
|
||||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
|
||||||
# and thus existing member attributes cannot be deduced by static analysis). It
|
|
||||||
# supports qualified module names, as well as Unix pattern matching.
|
|
||||||
ignored-modules=
|
|
||||||
|
|
||||||
# Show a hint with possible names when a member name was not found. The aspect
|
|
||||||
# of finding the hint is based on edit distance.
|
|
||||||
missing-member-hint=yes
|
|
||||||
|
|
||||||
# The minimum edit distance a name should have in order to be considered a
|
|
||||||
# similar match for a missing member name.
|
|
||||||
missing-member-hint-distance=1
|
|
||||||
|
|
||||||
# The total number of similar names that should be taken in consideration when
|
|
||||||
# showing a hint for a missing member.
|
|
||||||
missing-member-max-choices=1
|
|
||||||
|
|
||||||
# List of decorators that change the signature of a decorated function.
|
|
||||||
signature-mutators=
|
|
||||||
|
|
||||||
|
|
||||||
[SPELLING]
|
|
||||||
|
|
||||||
# Limits count of emitted suggestions for spelling mistakes.
|
|
||||||
max-spelling-suggestions=4
|
|
||||||
|
|
||||||
# Spelling dictionary name. Available dictionaries: de_AT (hunspell), de_BE
|
|
||||||
# (hunspell), de_CH (hunspell), de_DE (hunspell), de_LI (hunspell), de_LU
|
|
||||||
# (hunspell), en_AG (hunspell), en_AU (hunspell), en_BS (hunspell), en_BW
|
|
||||||
# (hunspell), en_BZ (hunspell), en_CA (hunspell), en_DK (hunspell), en_GB
|
|
||||||
# (hunspell), en_GH (hunspell), en_HK (hunspell), en_IE (hunspell), en_IN
|
|
||||||
# (hunspell), en_JM (hunspell), en_MW (hunspell), en_NA (hunspell), en_NG
|
|
||||||
# (hunspell), en_NZ (hunspell), en_PH (hunspell), en_SG (hunspell), en_TT
|
|
||||||
# (hunspell), en_US (hunspell), en_ZA (hunspell), en_ZM (hunspell), en_ZW
|
|
||||||
# (hunspell).
|
|
||||||
spelling-dict=en_US
|
|
||||||
|
|
||||||
# List of comma separated words that should not be checked.
|
|
||||||
spelling-ignore-words=rtmp,srs,supervisord,xmlrpclib,systemd,zmq,ffmpeg,CSS,JWT,
|
|
||||||
auth,Django,django,ffplayout,startproject,playlist,playlists,
|
|
||||||
http,www,init,json,cmd,config,configs,loudnorm,stderr,stdout,
|
|
||||||
ctrl,ffprobe,yaml,HH,MM,SS,libs,mediainfo,formatter,hls,HLS,
|
|
||||||
realtime,cutted,pillarbox,deinterlacing,drawtext,pre,rtp,svt,
|
|
||||||
ffplay,codecs
|
|
||||||
|
|
||||||
# A path to a file that contains the private dictionary; one word per line.
|
|
||||||
spelling-private-dict-file=
|
|
||||||
|
|
||||||
# Tells whether to store unknown words to the private dictionary (see the
|
|
||||||
# --spelling-private-dict-file option) instead of raising a message.
|
|
||||||
spelling-store-unknown-words=no
|
|
||||||
|
|
||||||
|
|
||||||
[DESIGN]
|
|
||||||
|
|
||||||
# Maximum number of arguments for function / method.
|
|
||||||
max-args=5
|
|
||||||
|
|
||||||
# Maximum number of attributes for a class (see R0902).
|
|
||||||
max-attributes=7
|
|
||||||
|
|
||||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
|
||||||
max-bool-expr=5
|
|
||||||
|
|
||||||
# Maximum number of branch for function / method body.
|
|
||||||
max-branches=15
|
|
||||||
|
|
||||||
# Maximum number of locals for function / method body.
|
|
||||||
max-locals=25
|
|
||||||
|
|
||||||
# Maximum number of parents for a class (see R0901).
|
|
||||||
max-parents=7
|
|
||||||
|
|
||||||
# Maximum number of public methods for a class (see R0904).
|
|
||||||
max-public-methods=20
|
|
||||||
|
|
||||||
# Maximum number of return / yield for function / method body.
|
|
||||||
max-returns=6
|
|
||||||
|
|
||||||
# Maximum number of statements in function / method body.
|
|
||||||
max-statements=60
|
|
||||||
|
|
||||||
# Minimum number of public methods for a class (see R0903).
|
|
||||||
min-public-methods=2
|
|
||||||
|
|
||||||
|
|
||||||
[CLASSES]
|
|
||||||
|
|
||||||
# List of method names used to declare (i.e. assign) instance attributes.
|
|
||||||
defining-attr-methods=__init__,
|
|
||||||
__new__,
|
|
||||||
setUp,
|
|
||||||
__post_init__
|
|
||||||
|
|
||||||
# List of member names, which should be excluded from the protected access
|
|
||||||
# warning.
|
|
||||||
exclude-protected=_asdict,
|
|
||||||
_fields,
|
|
||||||
_replace,
|
|
||||||
_source,
|
|
||||||
_make
|
|
||||||
|
|
||||||
# List of valid names for the first argument in a class method.
|
|
||||||
valid-classmethod-first-arg=cls
|
|
||||||
|
|
||||||
# List of valid names for the first argument in a metaclass class method.
|
|
||||||
valid-metaclass-classmethod-first-arg=cls
|
|
||||||
|
|
||||||
|
|
||||||
[IMPORTS]
|
|
||||||
|
|
||||||
# List of modules that can be imported at any level, not just the top level
|
|
||||||
# one.
|
|
||||||
allow-any-import-level=
|
|
||||||
|
|
||||||
# Allow wildcard imports from modules that define __all__.
|
|
||||||
allow-wildcard-with-all=no
|
|
||||||
|
|
||||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
|
||||||
# 3 compatible code, which means that the block might have code that exists
|
|
||||||
# only in one or another interpreter, leading to false positives when analysed.
|
|
||||||
analyse-fallback-blocks=no
|
|
||||||
|
|
||||||
# Deprecated modules which should not be used, separated by a comma.
|
|
||||||
deprecated-modules=optparse,tkinter.tix
|
|
||||||
|
|
||||||
# Create a graph of external dependencies in the given file (report RP0402 must
|
|
||||||
# not be disabled).
|
|
||||||
ext-import-graph=
|
|
||||||
|
|
||||||
# Create a graph of every (i.e. internal and external) dependencies in the
|
|
||||||
# given file (report RP0402 must not be disabled).
|
|
||||||
import-graph=
|
|
||||||
|
|
||||||
# Create a graph of internal dependencies in the given file (report RP0402 must
|
|
||||||
# not be disabled).
|
|
||||||
int-import-graph=
|
|
||||||
|
|
||||||
# Force import order to recognize a module as part of the standard
|
|
||||||
# compatibility libraries.
|
|
||||||
known-standard-library=
|
|
||||||
|
|
||||||
# Force import order to recognize a module as part of a third party library.
|
|
||||||
known-third-party=enchant
|
|
||||||
|
|
||||||
# Couples of modules and preferred modules, separated by a comma.
|
|
||||||
preferred-modules=
|
|
||||||
|
|
||||||
|
|
||||||
[EXCEPTIONS]
|
|
||||||
|
|
||||||
# Exceptions that will emit a warning when being caught. Defaults to
|
|
||||||
# "BaseException, Exception".
|
|
||||||
overgeneral-exceptions=BaseException,
|
|
||||||
Exception
|
|
@ -1,26 +0,0 @@
|
|||||||
How to contribute to ffplayout engine
|
|
||||||
-----
|
|
||||||
|
|
||||||
#### Did you need general help?
|
|
||||||
|
|
||||||
- Search in all issues if your question was already ask.
|
|
||||||
- Please give a detailed explanation of your problem and your goal.
|
|
||||||
- Give as much information as you can, like:
|
|
||||||
1. which system you are using
|
|
||||||
2. python version
|
|
||||||
3. ffmpeg version and libs
|
|
||||||
4. your ffplayout.yml config file
|
|
||||||
5. your log files
|
|
||||||
- Ask your question in a way, that we don't need to ask for more details and background.
|
|
||||||
|
|
||||||
#### Did you found a bug?
|
|
||||||
|
|
||||||
Try first the main branch, if this bug still exists there use the **Bug Report** issue template and fill up everything.
|
|
||||||
|
|
||||||
#### You have a feature request?
|
|
||||||
|
|
||||||
Please use the **Feature Request** issue template and fill up everything.
|
|
||||||
|
|
||||||
#### You want to make a pull request?
|
|
||||||
That is wonderful! But please use the same code style. This project tries to be PEP8 conform.
|
|
||||||
If you add new functions, create also a [test](https://github.com/ffplayout/ffplayout_engine/tree/master/tests) for it.
|
|
674
LICENSE
674
LICENSE
@ -1,674 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
|
||||||
or can get the source code. And you must show them these terms so they
|
|
||||||
know their rights.
|
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
|
||||||
that there is no warranty for this free software. For both users' and
|
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
|
||||||
changed, so that their problems will not be attributed erroneously to
|
|
||||||
authors of previous versions.
|
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the special requirements of the GNU Affero General Public License,
|
|
||||||
section 13, concerning interaction through a network will apply to the
|
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU General Public License from time to time. Such new versions will
|
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
|
||||||
notice like this when it starts in an interactive mode:
|
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
211
docs/CONFIG.md
211
docs/CONFIG.md
@ -1,211 +0,0 @@
|
|||||||
The configuration file **ffplayout.yml** has this sections:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
general:
|
|
||||||
stop_threshold: 11
|
|
||||||
```
|
|
||||||
Sometimes it can happen, that a file is corrupt but still playable,
|
|
||||||
this can produce an streaming error over all following files. The only way
|
|
||||||
in this case is, to stop ffplayout and start it again. Here we only say when
|
|
||||||
it stops, the starting process is in your hand. Best way is a **systemd service**
|
|
||||||
on linux. `stop_threshold` stop ffplayout, if it is async in time above this
|
|
||||||
value. A number below 3 can cause unexpected errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
mail:
|
|
||||||
subject: "Playout Error"
|
|
||||||
smpt_server: "mail.example.org"
|
|
||||||
smpt_port: 587
|
|
||||||
sender_addr: "ffplayout@example.org"
|
|
||||||
sender_pass: "12345"
|
|
||||||
recipient:
|
|
||||||
mail_level: "ERROR"
|
|
||||||
```
|
|
||||||
Send error messages to email address, like:
|
|
||||||
- missing playlist
|
|
||||||
- invalid json format
|
|
||||||
- missing clip path
|
|
||||||
|
|
||||||
leave recipient blank, if you don't need this.
|
|
||||||
`mail_level` can be: **WARNING, ERROR**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
logging:
|
|
||||||
log_to_file: True
|
|
||||||
backup_count: 7
|
|
||||||
log_path: "/var/log/ffplayout/"
|
|
||||||
log_level: "DEBUG"
|
|
||||||
ffmpeg_level: "ERROR"
|
|
||||||
```
|
|
||||||
|
|
||||||
Logging to file, if `log_to_file = False` > log to console.
|
|
||||||
`backup_count` says how long log files will be saved in days.
|
|
||||||
Path to **/var/log/** only if you run this program as *deamon*.
|
|
||||||
`log_level` can be: **DEBUG, INFO, WARNING, ERROR**
|
|
||||||
`ffmpeg_level` can be: **INFO, WARNING, ERROR**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
processing:
|
|
||||||
width: 1024
|
|
||||||
height: 576
|
|
||||||
aspect: 1.778
|
|
||||||
fps: 25
|
|
||||||
add_logo: True
|
|
||||||
logo: "docs/logo.png"
|
|
||||||
logo_scale: "100:-1"
|
|
||||||
logo_opacity: 0.7
|
|
||||||
logo_filter: "overlay=W-w-12:12"
|
|
||||||
add_loudnorm: False
|
|
||||||
loud_I: -18
|
|
||||||
loud_TP: -1.5
|
|
||||||
loud_LRA: 11
|
|
||||||
output_count: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
ffmpeg pre-compression settings, all clips get prepared in that way,
|
|
||||||
so the input for the final compression is unique.
|
|
||||||
- `aspect` mus be a float number.
|
|
||||||
- with `logo_scale = 100:-1` logo can be scaled
|
|
||||||
- with `logo_opacity` logo can make transparent
|
|
||||||
- with `logo_filter = overlay=W-w-12:12` you can modify the logo position
|
|
||||||
- with `use_loudnorm` you can activate single pass EBU R128 loudness normalization
|
|
||||||
- `loud_*` can adjust the loudnorm filter
|
|
||||||
- `output_count` sets the outputs for the filtering, > 1 gives the option to use the same filters for multiple outputs. This outputs can be taken in 'stream_param', names will be vout2, vout3;
|
|
||||||
aout2, aout2 etc.
|
|
||||||
|
|
||||||
**INFO:** output is progressive!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
ingest:
|
|
||||||
stream_input: >-
|
|
||||||
-f live_flv
|
|
||||||
-listen 1
|
|
||||||
-i rtmp://localhost:1936/live/stream
|
|
||||||
```
|
|
||||||
**ingest** works only in combination with output -> mode = **live_switch**!
|
|
||||||
It run a server for a ingest stream. This stream will override the normal streaming
|
|
||||||
until is done.
|
|
||||||
There is no authentication, this is up to you. The recommend way is to set address to localhost, stream to a local server with authentication and from there stream to this app.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
play:
|
|
||||||
mode: playlist
|
|
||||||
```
|
|
||||||
Set playing mode, like **playlist**; **folder**, or your own custom one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
playlist:
|
|
||||||
path: "/playlists"
|
|
||||||
day_start: "5:59:25"
|
|
||||||
length: "24:00:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
Put only the root path here, for example: **"/playlists"**.
|
|
||||||
Subfolders is read by the script and needs this structur:
|
|
||||||
- **"/playlists/2018/01"** (/playlists/year/month)
|
|
||||||
|
|
||||||
`day_start` means at which time the playlist should start. Leave `day_start` blank when playlist should always start at the begin.
|
|
||||||
`length` represent the target length from playlist, when is blank real length will not consider.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
storage:
|
|
||||||
path: "/mediaStorage"
|
|
||||||
filler_clip: "/mediaStorage/filler/filler.mp4"
|
|
||||||
extensions:
|
|
||||||
- ".mp4"
|
|
||||||
- ".mkv"
|
|
||||||
shuffle: True
|
|
||||||
```
|
|
||||||
Play ordered or ramdomly files from path, `filler_clip` is for fill the end
|
|
||||||
to reach 24 hours, it will loop when is necessary. `extensions:` search only files
|
|
||||||
with this extension, add as many as you want. Set `shuffle` to **True** to pick files randomly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
text:
|
|
||||||
add_text: True
|
|
||||||
over_pre: False
|
|
||||||
bind_address: "tcp://127.0.0.1:5555"
|
|
||||||
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
|
||||||
text_from_filename: False
|
|
||||||
style: "x=(w-tw)/2:y=(h-line_h)*0.9:fontsize=24:fontcolor=#ffffff:box=1:boxcolor=#000000:boxborderw=4"
|
|
||||||
regex: "^(.*)_"
|
|
||||||
```
|
|
||||||
Overlay text in combination with [messenger](https://github.com/ffplayout/messenger) or the web [frontend](https://github.com/ffplayout/ffplayout-frontend).
|
|
||||||
On windows `fontfile` path need to be like this: **C\:/WINDOWS/fonts/DejaVuSans.ttf**.
|
|
||||||
In a standard environment the filter drawtext node is: **Parsed_drawtext_2**.
|
|
||||||
`over_pre` if True text will be overlay in pre processing. Continue same text
|
|
||||||
over multiple files is in that mode not possible.
|
|
||||||
`text_from_filename` activate the extraction from text of a filename. With `style` you can define the drawtext parameters like position, color, etc. Post Text over API will override this.
|
|
||||||
With `regex` you can format file names, to get a title from it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
out:
|
|
||||||
mode: 'stream'
|
|
||||||
preview: False
|
|
||||||
preview_param: >-
|
|
||||||
-s 512+288
|
|
||||||
-c:v libx264
|
|
||||||
-crf 24
|
|
||||||
-x264-params keyint=50:min-keyint=25:scenecut=-1
|
|
||||||
-maxrate 800k
|
|
||||||
-bufsize 1600k
|
|
||||||
-preset ultrafast
|
|
||||||
-profile:v Main
|
|
||||||
-level 3.1
|
|
||||||
-c:a aac
|
|
||||||
-ar 44100
|
|
||||||
-b:a 128k
|
|
||||||
-flags +global_header
|
|
||||||
-f flv rtmp://preview.local/live/stream
|
|
||||||
stream_param: >-
|
|
||||||
-c:v libx264
|
|
||||||
-crf 23
|
|
||||||
-x264-params keyint=50:min-keyint=25:scenecut=-1
|
|
||||||
-maxrate 1300k
|
|
||||||
-bufsize 2600k
|
|
||||||
-preset medium
|
|
||||||
-profile:v Main
|
|
||||||
-level 3.1
|
|
||||||
-c:a aac
|
|
||||||
-ar 44100
|
|
||||||
-b:a 128k
|
|
||||||
-flags +global_header
|
|
||||||
-f flv rtmp://localhost/live/stream
|
|
||||||
```
|
|
||||||
|
|
||||||
The final ffmpeg post compression, Set the settings to your needs!
|
|
||||||
`mode` has the standard options **desktop**, **hls**, **live_switch**, **stream**. Self made outputs
|
|
||||||
can be define, by adding script in output folder with an **output()** function inside.
|
|
||||||
'preview' works only in streaming output and creates a separate preview stream.
|
|
||||||
|
|
||||||
For output mode hls, output can look like:
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
[...]
|
|
||||||
-flags +cgop
|
|
||||||
-f hls
|
|
||||||
-hls_time 6
|
|
||||||
-hls_list_size 600
|
|
||||||
-hls_flags append_list+delete_segments+omit_endlist+program_date_time
|
|
||||||
-hls_segment_filename /var/www/srs/live/stream-%09d.ts /var/www/srs/live/stream.m3u8
|
|
||||||
```
|
|
@ -1,55 +0,0 @@
|
|||||||
**ffplayout_engine Installation**
|
|
||||||
================
|
|
||||||
|
|
||||||
Here are a description on how to install *ffplayout engine* on a standard Linux server.
|
|
||||||
|
|
||||||
Requirements
|
|
||||||
-----
|
|
||||||
|
|
||||||
- python version 3.7+
|
|
||||||
- **ffmpeg v4.2+** and **ffprobe**
|
|
||||||
|
|
||||||
Installation
|
|
||||||
-----
|
|
||||||
|
|
||||||
- install **ffmpeg**, **ffprobe** (and **ffplay** if you need the preview mode)
|
|
||||||
- clone repo to **/opt/**: `git clone https://github.com/ffplayout/ffplayout_engine.git`
|
|
||||||
- `cd /opt/ffplayout_engine`
|
|
||||||
- create virtual environment: `virtualenv -p python3 venv`
|
|
||||||
- run `source ./venv/bin/activate`
|
|
||||||
- install dependencies: `pip3 install -r requirements.txt`
|
|
||||||
- create logging folder: **/var/log/ffplayout**
|
|
||||||
- create playlists folder, in that format: **/playlists/year/month**
|
|
||||||
- create folder for media storage: **/tv-media**
|
|
||||||
- set variables in config file to your needs
|
|
||||||
|
|
||||||
Single Channel Setup
|
|
||||||
-----
|
|
||||||
|
|
||||||
**systemd** is required
|
|
||||||
|
|
||||||
- copy **docs/ffplayout_engine.service** to **/etc/systemd/system/**
|
|
||||||
- copy **ffplayout.yml** to **/etc/ffplayout/**
|
|
||||||
- change user and group in service file (for example to **www-data**)
|
|
||||||
- activate service: `sudo systemctl enable ffplayout_engine`
|
|
||||||
- edit **/etc/ffplayout/ffplayout.yml**
|
|
||||||
- when playlists are exists, run service: `sudo systemctl start ffplayout_engine`
|
|
||||||
|
|
||||||
Multi Channel Setup
|
|
||||||
-----
|
|
||||||
|
|
||||||
- copy **docs/ffplayout_engine-multichannel.service** to **/etc/systemd/system/**
|
|
||||||
- change user and group in service file (for example to **www-data**)
|
|
||||||
- copy **ffplayout.yml** to **/etc/ffplayout/ffplayout-001.yml**
|
|
||||||
- copy **docs/supervisor** folder to **/etc/ffplayout/**
|
|
||||||
- every channel needs its own engine config **ffplayout-002.yml**, **ffplayout-003.yml**, etc.
|
|
||||||
- every channel needs also its own service file under **/etc/ffplayout/supervisor/config.d**
|
|
||||||
- create for every channel a subfolder for logging: **/var/log/ffplayout/channel-001**, **/var/log/ffplayout/channel-002**, etc.
|
|
||||||
- edit **/etc/ffplayout/ffplayout-00*.yml**
|
|
||||||
- when you want to use the web frontend, create only the first channel and the other ones in the frontend
|
|
||||||
- activate service: `sudo systemctl enable ffplayout_engine-multichannel`
|
|
||||||
- when playlists are exists, run service: `sudo systemctl start ffplayout_engine-multichannel`
|
|
||||||
|
|
||||||
Using it Without Installation
|
|
||||||
-----
|
|
||||||
Of course you can just run it too. Install only the dependencies from **requirements.txt** and run it with **python ffplayout.py [parameters]**.
|
|
@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Supervisor process control system for UNIX
|
|
||||||
Documentation=http://supervisord.org
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/opt/ffplayout_engine/venv/bin/supervisord -n -c /etc/ffplayout/supervisor/supervisord.conf
|
|
||||||
ExecStop=/opt/ffplayout_engine/venv/bin/supervisorctl $OPTIONS shutdown
|
|
||||||
ExecReload=/opt/ffplayout_engine/venv/bin/supervisorctl $OPTIONS reload
|
|
||||||
KillMode=process
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5s
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
@ -1,14 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=python and ffmpeg based playout
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/opt/ffplayout_engine/venv/bin/python /opt/ffplayout_engine/ffplayout.py
|
|
||||||
ExecReload=/bin/kill -1 $MAINPID
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
BIN
docs/logo.png
BIN
docs/logo.png
Binary file not shown.
Before Width: | Height: | Size: 6.6 KiB |
@ -1,11 +0,0 @@
|
|||||||
SupervisorD
|
|
||||||
-----
|
|
||||||
|
|
||||||
The supervisor config is only needed when you want to run multiple channels.
|
|
||||||
|
|
||||||
Every channel has his own config in [conf.d](/supervisor/conf.d/) folder. In the configuration you have to change this line:
|
|
||||||
|
|
||||||
```
|
|
||||||
command=./venv/bin/python3 ffplayout.py -c /etc/ffplayout/ffplayout-001.yml
|
|
||||||
```
|
|
||||||
to the correct ffpalyout YAML config file.
|
|
@ -1,11 +0,0 @@
|
|||||||
[program:engine-001]
|
|
||||||
directory=/opt/ffplayout_engine
|
|
||||||
command=/opt/ffplayout_engine/venv/bin/python ffplayout.py -c /etc/ffplayout/ffplayout-001.yml
|
|
||||||
redirect_stderr=true
|
|
||||||
stdout_logfile=/var/log/ffplayout/engine-001.log
|
|
||||||
killasgroup=true
|
|
||||||
stopasgroup=true
|
|
||||||
autorestart=true
|
|
||||||
autostart=true
|
|
||||||
startsecs=2
|
|
||||||
startretries=10
|
|
@ -1,19 +0,0 @@
|
|||||||
[supervisord]
|
|
||||||
pidfile=/tmp/supervisord.pid
|
|
||||||
nodaemon=true
|
|
||||||
logfile=/dev/null
|
|
||||||
logfile_maxbytes=0
|
|
||||||
|
|
||||||
[include]
|
|
||||||
files = conf.d/*.conf
|
|
||||||
|
|
||||||
[inet_http_server]
|
|
||||||
port=127.0.0.1:9001
|
|
||||||
username = ffplayout
|
|
||||||
password = hsF0wQkl5zopEy1mBlT3g
|
|
||||||
|
|
||||||
[supervisorctl]
|
|
||||||
serverurl=http://127.0.0.1:9001
|
|
||||||
|
|
||||||
[rpcinterface:supervisor]
|
|
||||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
|
67
ffplayout.py
67
ffplayout.py
@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module is the starting program for running ffplayout engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from importlib import import_module
|
|
||||||
from pathlib import Path
|
|
||||||
from platform import system
|
|
||||||
|
|
||||||
from ffplayout.utils import messenger, playout, validate_ffmpeg_libs
|
|
||||||
|
|
||||||
try:
|
|
||||||
if system() == 'Windows':
|
|
||||||
import colorama
|
|
||||||
colorama.init()
|
|
||||||
except ImportError:
|
|
||||||
print('colorama import failed, no colored console output on windows...')
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# main functions
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""
|
|
||||||
play out depending on output mode
|
|
||||||
"""
|
|
||||||
|
|
||||||
script_dir = Path(__file__).parent.absolute()
|
|
||||||
output_dir = script_dir.joinpath('ffplayout', 'output')
|
|
||||||
mode_exists = False
|
|
||||||
|
|
||||||
for output in output_dir.glob('*.py'):
|
|
||||||
if output != '__init__.py':
|
|
||||||
mode = Path(output).stem
|
|
||||||
|
|
||||||
if mode == playout.mode:
|
|
||||||
mode_exists = True
|
|
||||||
output = import_module(f'ffplayout.output.{mode}').output
|
|
||||||
output()
|
|
||||||
|
|
||||||
if not mode_exists:
|
|
||||||
messenger.error('Output mode not exist!')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# check if ffmpeg contains all codecs and filters
|
|
||||||
validate_ffmpeg_libs()
|
|
||||||
main()
|
|
149
ffplayout.yml
149
ffplayout.yml
@ -1,149 +0,0 @@
|
|||||||
general:
|
|
||||||
helptext: Sometimes it can happen, that a file is corrupt but still playable,
|
|
||||||
this can produce an streaming error over all following files. The only way
|
|
||||||
in this case is, to stop ffplayout and start it again. Here we only say when
|
|
||||||
it stops, the starting process is in your hand. Best way is a systemd service
|
|
||||||
on linux. 'stop_threshold' stop ffplayout, if it is async in time above this
|
|
||||||
value. A number below 3 can cause unexpected errors.
|
|
||||||
stop_threshold: 11
|
|
||||||
|
|
||||||
mail:
|
|
||||||
helptext: Send error messages to email address, like missing playlist; invalid
|
|
||||||
json format; missing clip path. Leave recipient blank, if you don't need this.
|
|
||||||
'mail_level' can be WARNING or ERROR.
|
|
||||||
subject: "Playout Error"
|
|
||||||
smtp_server: "mail.example.org"
|
|
||||||
smtp_port: 587
|
|
||||||
sender_addr: "ffplayout@example.org"
|
|
||||||
sender_pass: "abc123"
|
|
||||||
recipient:
|
|
||||||
mail_level: "ERROR"
|
|
||||||
|
|
||||||
logging:
|
|
||||||
helptext: Logging to file, if 'log_to_file' false log to console. 'backup_count'
|
|
||||||
says how long log files will be saved in days. Path to /var/log/ only if you
|
|
||||||
run this program as daemon. 'log_level' can be DEBUG, INFO, WARNING,
|
|
||||||
ERROR. 'ffmpeg_level' can be info, warning, error.
|
|
||||||
log_to_file: true
|
|
||||||
backup_count: 7
|
|
||||||
log_path: "/var/log/ffplayout/"
|
|
||||||
log_level: "DEBUG"
|
|
||||||
ffmpeg_level: "error"
|
|
||||||
|
|
||||||
processing:
|
|
||||||
helptext: Set playing mode, like playlist; folder, or you own custom one.
|
|
||||||
Default processing, for all clips that they get prepared in that way,
|
|
||||||
so the output is unique. 'aspect' must be a float number. 'logo' is only used
|
|
||||||
if the path exist. 'logo_scale' scale the logo to target size, leave it blank
|
|
||||||
when no scaling is needed, format is 'number:number', for example '100:-1'
|
|
||||||
for proportional scaling. With 'logo_opacity' logo can become transparent.
|
|
||||||
With 'logo_filter' 'overlay=W-w-12:12' you can modify the logo position.
|
|
||||||
With 'use_loudnorm' you can activate single pass EBU R128 loudness normalization.
|
|
||||||
'loud_*' can adjust the loudnorm filter. 'output_count' sets the outputs for
|
|
||||||
the filtering, > 1 gives the option to use the same filters for multiple outputs.
|
|
||||||
This outputs can be taken in 'stream_param', names will be vout2, vout3;
|
|
||||||
aout2, aout2 etc.
|
|
||||||
mode: playlist
|
|
||||||
width: 1024
|
|
||||||
height: 576
|
|
||||||
aspect: 1.778
|
|
||||||
fps: 25
|
|
||||||
add_logo: true
|
|
||||||
logo: "docs/logo.png"
|
|
||||||
logo_scale:
|
|
||||||
logo_opacity: 0.7
|
|
||||||
logo_filter: "overlay=W-w-12:12"
|
|
||||||
add_loudnorm: false
|
|
||||||
loud_i: -18
|
|
||||||
loud_tp: -1.5
|
|
||||||
loud_lra: 11
|
|
||||||
output_count: 1
|
|
||||||
|
|
||||||
ingest:
|
|
||||||
helptext: Works not with direct hls output, it always needs full processing! Run a server
|
|
||||||
for a ingest stream. This stream will override the normal streaming until is done.
|
|
||||||
There is no authentication, this is up to you. The recommend way is to set address
|
|
||||||
to localhost, stream to a local server with authentication and from there stream to this app.
|
|
||||||
enable: false
|
|
||||||
input_param: -f live_flv -listen 1 -i rtmp://localhost:1936/live/stream
|
|
||||||
|
|
||||||
playlist:
|
|
||||||
helptext: >
|
|
||||||
'path' can be a path to a single file, or a directory. For directory put
|
|
||||||
only the root folder, for example '/playlists', subdirectories are read by the
|
|
||||||
script. Subdirectories needs this structure '/playlists/2018/01'. 'day_start'
|
|
||||||
means at which time the playlist should start, leave day_start blank when playlist
|
|
||||||
should always start at the begin. 'length' represent the target length from
|
|
||||||
playlist, when is blank real length will not consider. 'infinit true' works with
|
|
||||||
single playlist file and loops it infinitely.
|
|
||||||
path: "/playlists"
|
|
||||||
day_start: "5:59:25"
|
|
||||||
length: "24:00:00"
|
|
||||||
infinit: false
|
|
||||||
|
|
||||||
storage:
|
|
||||||
helptext: Play ordered or randomly files from path. 'filler_clip' is for fill
|
|
||||||
the end to reach 24 hours, it will loop when is necessary. 'extensions' search
|
|
||||||
only files with this extension. Set 'shuffle' to 'true' to pick files randomly.
|
|
||||||
path: "/mediaStorage"
|
|
||||||
filler_clip: "/mediaStorage/filler/filler.mp4"
|
|
||||||
extensions:
|
|
||||||
- ".mp4"
|
|
||||||
- ".mkv"
|
|
||||||
shuffle: true
|
|
||||||
|
|
||||||
text:
|
|
||||||
helptext: Overlay text in combination with libzmq for remote text manipulation.
|
|
||||||
On windows fontfile path need to be like this 'C\:/WINDOWS/fonts/DejaVuSans.ttf'.
|
|
||||||
In a standard environment the filter drawtext node is Parsed_drawtext_2.
|
|
||||||
'over_pre' if true text will be overlay in pre processing. Continue same text
|
|
||||||
over multiple files is in that mode not possible. 'text_from_filename' activate the
|
|
||||||
extraction from text of a filename. With 'style' you can define the drawtext
|
|
||||||
parameters like position, color, etc. Post Text over API will override this.
|
|
||||||
With 'regex' you can format file names, to get a title from it.
|
|
||||||
add_text: false
|
|
||||||
over_pre: false
|
|
||||||
bind_address: "127.0.0.1:5555"
|
|
||||||
fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
|
||||||
text_from_filename: false
|
|
||||||
style: "x=(w-tw)/2:y=(h-line_h)*0.9:fontsize=24:fontcolor=#ffffff:box=1:boxcolor=#000000:boxborderw=4"
|
|
||||||
regex: "^(.*)_"
|
|
||||||
|
|
||||||
out:
|
|
||||||
helptext: The final playout compression. Set the settings to your needs.
|
|
||||||
'mode' has the standard options 'desktop', 'hls', 'stream'. Self made
|
|
||||||
outputs can be define, by adding script in output folder with an 'output' function
|
|
||||||
inside. 'preview' works only in streaming output and creates a separate preview stream.
|
|
||||||
mode: 'stream'
|
|
||||||
preview: false
|
|
||||||
preview_param: >-
|
|
||||||
-s 512x288
|
|
||||||
-c:v libx264
|
|
||||||
-crf 24
|
|
||||||
-x264-params keyint=50:min-keyint=25:scenecut=-1
|
|
||||||
-maxrate 800k
|
|
||||||
-bufsize 1600k
|
|
||||||
-preset ultrafast
|
|
||||||
-tune zerolatency
|
|
||||||
-profile:v Main
|
|
||||||
-level 3.1
|
|
||||||
-c:a aac
|
|
||||||
-ar 44100
|
|
||||||
-b:a 128k
|
|
||||||
-flags +global_header
|
|
||||||
-f flv rtmp://preview.local/live/stream
|
|
||||||
output_param: >-
|
|
||||||
-c:v libx264
|
|
||||||
-crf 23
|
|
||||||
-x264-params keyint=50:min-keyint=25:scenecut=-1
|
|
||||||
-maxrate 1300k
|
|
||||||
-bufsize 2600k
|
|
||||||
-preset faster
|
|
||||||
-tune zerolatency
|
|
||||||
-profile:v Main
|
|
||||||
-level 3.1
|
|
||||||
-c:a aac
|
|
||||||
-ar 44100
|
|
||||||
-b:a 128k
|
|
||||||
-flags +global_header
|
|
||||||
-f flv rtmp://localhost/live/stream
|
|
@ -1,15 +0,0 @@
|
|||||||
# Custom Configuration
|
|
||||||
|
|
||||||
Extend your arguments for using them in your custom extensions.
|
|
||||||
|
|
||||||
The file name must have the **argparse_** prefix. The content should look like:
|
|
||||||
|
|
||||||
```YAML
|
|
||||||
short: -v
|
|
||||||
long: --volume
|
|
||||||
help: set audio volume
|
|
||||||
```
|
|
||||||
|
|
||||||
At least **short** or **long** have to exist, all other parameters are optional. You can also extend the config, with keys which are exist in **ArgumentParser.add_argument()**.
|
|
||||||
|
|
||||||
**Every argument must have its own yaml file!**
|
|
@ -1,3 +0,0 @@
|
|||||||
short: -v
|
|
||||||
long: --volume
|
|
||||||
help: set audio volume
|
|
@ -1,25 +0,0 @@
|
|||||||
# Custom Filters
|
|
||||||
|
|
||||||
Add your one filters here. They must have the correct file naming:
|
|
||||||
|
|
||||||
- for audio filter: a_[filter name].py
|
|
||||||
- for video filter: v_[filter name].py
|
|
||||||
|
|
||||||
The file itself should contain only one filter in a function named `def filter_link(prope):`
|
|
||||||
|
|
||||||
Check **v_addtext.py** for example.
|
|
||||||
|
|
||||||
In your filter you can also read custom properties from the current program node. That you can use for any usecase you wish, like reading a subtitle file, or a different logo for every clip and so on.
|
|
||||||
|
|
||||||
The normal program node looks like:
|
|
||||||
|
|
||||||
```JSON
|
|
||||||
{
|
|
||||||
"in": 0,
|
|
||||||
"out": 3600.162,
|
|
||||||
"duration": 3600.162,
|
|
||||||
"source": "/dir/input.mp4"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This you can extend to your needs, and apply this values to your filters.
|
|
@ -1,17 +0,0 @@
|
|||||||
"""
|
|
||||||
custom audio filter, which get loaded automatically
|
|
||||||
"""
|
|
||||||
|
|
||||||
from ..utils import get_float, stdin_args
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_link(node):
|
|
||||||
"""
|
|
||||||
set audio volume
|
|
||||||
"""
|
|
||||||
|
|
||||||
if stdin_args.volume and get_float(stdin_args.volume, False):
|
|
||||||
return f'volume={stdin_args.volume}'
|
|
||||||
|
|
||||||
return None
|
|
@ -1,342 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module prepare all ffmpeg filters.
|
|
||||||
This is mainly for unify clips to have a unique output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from importlib import import_module
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..utils import (get_float, is_advertisement, lower_third, messenger, pre,
|
|
||||||
sync_op)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# building filters,
|
|
||||||
# when is needed add individual filters to match output format
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def text_filter():
|
|
||||||
"""
|
|
||||||
add drawtext filter for lower thirds messages
|
|
||||||
"""
|
|
||||||
filter_chain = []
|
|
||||||
font = ''
|
|
||||||
|
|
||||||
if lower_third.add_text and lower_third.over_pre:
|
|
||||||
if lower_third.fontfile and Path(lower_third.fontfile).is_file():
|
|
||||||
font = f":fontfile='{lower_third.fontfile}'"
|
|
||||||
filter_chain = [
|
|
||||||
"null,zmq=b=tcp\\\\://'{}',drawtext=text=''{}".format(
|
|
||||||
lower_third.address, font)]
|
|
||||||
|
|
||||||
return filter_chain
|
|
||||||
|
|
||||||
|
|
||||||
def deinterlace_filter(probe):
|
|
||||||
"""
|
|
||||||
when material is interlaced,
|
|
||||||
set deinterlacing filter
|
|
||||||
"""
|
|
||||||
filter_chain = []
|
|
||||||
|
|
||||||
if 'field_order' in probe.video[0] and \
|
|
||||||
probe.video[0]['field_order'] != 'progressive':
|
|
||||||
filter_chain.append('yadif=0:-1:0')
|
|
||||||
|
|
||||||
return filter_chain
|
|
||||||
|
|
||||||
|
|
||||||
def pad_filter(probe):
|
|
||||||
"""
|
|
||||||
if source and target aspect is different,
|
|
||||||
fix it with pillarbox or letterbox
|
|
||||||
"""
|
|
||||||
filter_chain = []
|
|
||||||
|
|
||||||
if not math.isclose(probe.video[0]['aspect'],
|
|
||||||
pre.aspect, abs_tol=0.03):
|
|
||||||
if probe.video[0]['aspect'] < pre.aspect:
|
|
||||||
filter_chain.append(
|
|
||||||
f'pad=ih*{pre.w}/{pre.h}/sar:ih:(ow-iw)/2:(oh-ih)/2')
|
|
||||||
elif probe.video[0]['aspect'] > pre.aspect:
|
|
||||||
filter_chain.append(
|
|
||||||
f'pad=iw:iw*{pre.h}/{pre.w}/sar:(ow-iw)/2:(oh-ih)/2')
|
|
||||||
|
|
||||||
return filter_chain
|
|
||||||
|
|
||||||
|
|
||||||
def fps_filter(probe):
|
|
||||||
"""
|
|
||||||
changing frame rate
|
|
||||||
"""
|
|
||||||
filter_chain = []
|
|
||||||
|
|
||||||
if probe.video[0]['fps'] != pre.fps:
|
|
||||||
filter_chain.append(f'fps={pre.fps}')
|
|
||||||
|
|
||||||
return filter_chain
|
|
||||||
|
|
||||||
|
|
||||||
def scale_filter(probe):
|
|
||||||
"""
|
|
||||||
if target resolution is different to source add scale filter,
|
|
||||||
apply also an aspect filter, when is different
|
|
||||||
"""
|
|
||||||
filter_chain = []
|
|
||||||
|
|
||||||
if int(probe.video[0]['width']) != pre.w or \
|
|
||||||
int(probe.video[0]['height']) != pre.h:
|
|
||||||
filter_chain.append(f'scale={pre.w}:{pre.h}')
|
|
||||||
|
|
||||||
if not math.isclose(probe.video[0]['aspect'],
|
|
||||||
pre.aspect, abs_tol=0.03):
|
|
||||||
filter_chain.append(f'setdar=dar={pre.aspect}')
|
|
||||||
|
|
||||||
return filter_chain
|
|
||||||
|
|
||||||
|
|
||||||
def fade_filter(duration, seek, out, track=''):
|
|
||||||
"""
|
|
||||||
fade in/out video, when is cutted at the begin or end
|
|
||||||
"""
|
|
||||||
filter_chain = []
|
|
||||||
|
|
||||||
if seek > 0.0:
|
|
||||||
filter_chain.append(f'{track}fade=in:st=0:d=0.5')
|
|
||||||
|
|
||||||
if out != duration and out - seek - 1.0 > 0:
|
|
||||||
filter_chain.append(f'{track}fade=out:st={out - seek - 1.0}:d=1.0')
|
|
||||||
|
|
||||||
return filter_chain
|
|
||||||
|
|
||||||
|
|
||||||
def overlay_filter(duration, advertisement, ad_last, ad_next):
|
|
||||||
"""
|
|
||||||
overlay logo: when is an ad don't overlay,
|
|
||||||
when ad is coming next fade logo out,
|
|
||||||
when clip before was an ad fade logo in
|
|
||||||
"""
|
|
||||||
logo_filter = '[v]null'
|
|
||||||
scale = ''
|
|
||||||
|
|
||||||
if pre.add_logo and pre.logo and Path(pre.logo).is_file() \
|
|
||||||
and not advertisement:
|
|
||||||
logo_chain = []
|
|
||||||
if pre.logo_scale and \
|
|
||||||
re.match(r'\d+:-?\d+', pre.logo_scale):
|
|
||||||
scale = f'scale={pre.logo_scale},'
|
|
||||||
logo_extras = (f'format=rgba,{scale}'
|
|
||||||
f'colorchannelmixer=aa={pre.logo_opacity}')
|
|
||||||
loop = 'loop=loop=-1:size=1:start=0'
|
|
||||||
logo_chain.append(f'movie={pre.logo},{loop},{logo_extras}')
|
|
||||||
if ad_last:
|
|
||||||
logo_chain.append('fade=in:st=0:d=1.0:alpha=1')
|
|
||||||
if ad_next:
|
|
||||||
logo_chain.append(f'fade=out:st={duration - 1}:d=1.0:alpha=1')
|
|
||||||
|
|
||||||
logo_filter = (f'{",".join(logo_chain)}[l];[v][l]'
|
|
||||||
f'{pre.logo_filter}:shortest=1')
|
|
||||||
|
|
||||||
return logo_filter
|
|
||||||
|
|
||||||
|
|
||||||
def add_audio(probe, duration):
|
|
||||||
"""
|
|
||||||
when clip has no audio we generate an audio line
|
|
||||||
"""
|
|
||||||
line = []
|
|
||||||
|
|
||||||
if not probe.audio:
|
|
||||||
messenger.warning(f'Clip "{probe.src}" has no audio!')
|
|
||||||
line = [(f'aevalsrc=0:channel_layout=stereo:duration={duration}:'
|
|
||||||
f'sample_rate=48000')]
|
|
||||||
|
|
||||||
return line
|
|
||||||
|
|
||||||
|
|
||||||
def add_loudnorm(probe):
|
|
||||||
"""
|
|
||||||
add single pass loudnorm filter to audio line
|
|
||||||
"""
|
|
||||||
loud_filter = []
|
|
||||||
|
|
||||||
if probe.audio and pre.add_loudnorm:
|
|
||||||
loud_filter = [
|
|
||||||
f'loudnorm=I={pre.loud_i}:TP={pre.loud_tp}:LRA={pre.loud_lra}']
|
|
||||||
|
|
||||||
return loud_filter
|
|
||||||
|
|
||||||
|
|
||||||
def extend_audio(probe, out, seek):
|
|
||||||
"""
|
|
||||||
check audio duration, is it shorter then clip duration - pad it
|
|
||||||
"""
|
|
||||||
pad = []
|
|
||||||
aud_dur = get_float(probe.audio[0].get('duration'), None)
|
|
||||||
|
|
||||||
if aud_dur and out - seek > aud_dur - seek + 0.1:
|
|
||||||
pad.append(f'apad=whole_dur={out - seek}')
|
|
||||||
|
|
||||||
return pad
|
|
||||||
|
|
||||||
|
|
||||||
def extend_video(probe, out, seek):
|
|
||||||
"""
|
|
||||||
check video duration, is it shorter then clip duration - pad it
|
|
||||||
"""
|
|
||||||
pad = []
|
|
||||||
vid_dur = probe.video[0].get('duration')
|
|
||||||
|
|
||||||
if vid_dur and out - seek > float(vid_dur) - seek + 0.1:
|
|
||||||
pad.append(
|
|
||||||
'tpad=stop_mode=add:'
|
|
||||||
f'stop_duration={(out - seek) - (float(vid_dur) - seek)}')
|
|
||||||
|
|
||||||
return pad
|
|
||||||
|
|
||||||
|
|
||||||
def realtime_filter(duration, track=''):
|
|
||||||
"""
|
|
||||||
this realtime filter is important for HLS output to stay in sync
|
|
||||||
"""
|
|
||||||
speed_filter = ''
|
|
||||||
|
|
||||||
if sync_op.realtime:
|
|
||||||
speed_filter = f',{track}realtime=speed=1'
|
|
||||||
|
|
||||||
if sync_op.time_delta < 0:
|
|
||||||
speed = duration / (duration + sync_op.time_delta)
|
|
||||||
|
|
||||||
if speed < 1.1:
|
|
||||||
speed_filter = f',{track}realtime=speed={speed}'
|
|
||||||
|
|
||||||
return speed_filter
|
|
||||||
|
|
||||||
|
|
||||||
def split_filter(filter_type):
|
|
||||||
"""
|
|
||||||
this filter splits the media input in multiple outputs,
|
|
||||||
to be able to have different streaming/HLS outputs
|
|
||||||
"""
|
|
||||||
map_node = []
|
|
||||||
filter_ = ''
|
|
||||||
|
|
||||||
prefix = 'a' if filter_type == 'a' else ''
|
|
||||||
|
|
||||||
if pre.output_count > 1:
|
|
||||||
for num in range(pre.output_count):
|
|
||||||
map_node.append(f'[{filter_type}out{num + 1}]')
|
|
||||||
|
|
||||||
filter_ = f',{prefix}split={pre.output_count}{"".join(map_node)}'
|
|
||||||
|
|
||||||
else:
|
|
||||||
filter_ = f'[{filter_type}out1]'
|
|
||||||
|
|
||||||
return filter_
|
|
||||||
|
|
||||||
|
|
||||||
def custom_filter(filter_type, node):
|
|
||||||
"""
|
|
||||||
read custom filters from filters folder
|
|
||||||
"""
|
|
||||||
filter_dir = Path(__file__).parent.absolute()
|
|
||||||
filters = []
|
|
||||||
|
|
||||||
for filter_file in filter_dir.glob(f'{filter_type}_*'):
|
|
||||||
filter_ = Path(filter_file).stem
|
|
||||||
filter_function = import_module(
|
|
||||||
f'ffplayout.filters.{filter_}').filter_link
|
|
||||||
link = filter_function(node)
|
|
||||||
|
|
||||||
if link is not None:
|
|
||||||
filters.append(link)
|
|
||||||
|
|
||||||
return filters
|
|
||||||
|
|
||||||
|
|
||||||
def build_filtergraph(node, node_last, node_next):
|
|
||||||
"""
|
|
||||||
build final filter graph, with video and audio chain
|
|
||||||
"""
|
|
||||||
|
|
||||||
advertisement = is_advertisement(node)
|
|
||||||
ad_last = is_advertisement(node_last)
|
|
||||||
ad_next = is_advertisement(node_next)
|
|
||||||
|
|
||||||
duration = node['duration']
|
|
||||||
seek = node['seek']
|
|
||||||
out = node['out']
|
|
||||||
probe = node['probe']
|
|
||||||
|
|
||||||
video_chain = []
|
|
||||||
audio_chain = []
|
|
||||||
|
|
||||||
if out > duration:
|
|
||||||
seek = 0
|
|
||||||
|
|
||||||
if probe and probe.video[0]:
|
|
||||||
custom_v_filter = custom_filter('v', node)
|
|
||||||
video_chain += text_filter() \
|
|
||||||
+ deinterlace_filter(probe) \
|
|
||||||
+ pad_filter(probe) \
|
|
||||||
+ fps_filter(probe) \
|
|
||||||
+ scale_filter(probe) \
|
|
||||||
+ extend_video(probe, out, seek)
|
|
||||||
if custom_v_filter:
|
|
||||||
video_chain += custom_v_filter
|
|
||||||
video_chain += fade_filter(duration, seek, out)
|
|
||||||
|
|
||||||
audio_chain += add_audio(probe, out - seek)
|
|
||||||
|
|
||||||
if not audio_chain:
|
|
||||||
custom_a_filter = custom_filter('a', node)
|
|
||||||
|
|
||||||
audio_chain += ['[0:a]anull'] \
|
|
||||||
+ add_loudnorm(probe) \
|
|
||||||
+ extend_audio(probe, out, seek)
|
|
||||||
if custom_a_filter:
|
|
||||||
audio_chain += custom_a_filter
|
|
||||||
audio_chain += fade_filter(duration, seek, out, 'a')
|
|
||||||
|
|
||||||
if video_chain:
|
|
||||||
video_filter = f'{",".join(video_chain)}[v]'
|
|
||||||
else:
|
|
||||||
video_filter = 'null[v]'
|
|
||||||
|
|
||||||
logo_filter = overlay_filter(out - seek, advertisement, ad_last, ad_next)
|
|
||||||
v_speed = realtime_filter(out - seek)
|
|
||||||
v_split = split_filter('v')
|
|
||||||
video_map = ['-map', '[vout1]']
|
|
||||||
video_filter = [
|
|
||||||
'-filter_complex',
|
|
||||||
f'[0:v]{video_filter};{logo_filter}{v_speed}{v_split}']
|
|
||||||
|
|
||||||
a_speed = realtime_filter(out - seek, 'a')
|
|
||||||
a_split = split_filter('a')
|
|
||||||
audio_map = ['-map', '[aout1]']
|
|
||||||
audio_filter = [
|
|
||||||
'-filter_complex', f'{",".join(audio_chain)}{a_speed}{a_split}']
|
|
||||||
|
|
||||||
if probe and probe.video[0]:
|
|
||||||
return video_filter + audio_filter + video_map + audio_map
|
|
||||||
|
|
||||||
return video_filter + video_map + ['-map', '1:a']
|
|
@ -1,27 +0,0 @@
|
|||||||
"""
|
|
||||||
custom video filter, which get loaded automatically
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..utils import lower_third
|
|
||||||
|
|
||||||
|
|
||||||
def filter_link(node):
|
|
||||||
"""
|
|
||||||
extract title from file name and overlay it
|
|
||||||
"""
|
|
||||||
font = ''
|
|
||||||
source = str(Path(node.get('source')).name)
|
|
||||||
match = re.match(lower_third.regex, source)
|
|
||||||
title = match[1] if match else source
|
|
||||||
|
|
||||||
if lower_third.fontfile and Path(lower_third.fontfile).is_file():
|
|
||||||
font = f":fontfile='{lower_third.fontfile}'"
|
|
||||||
|
|
||||||
if lower_third.text_from_filename:
|
|
||||||
escape = title.replace("'", "'\\\\\\''").replace("%", "\\\\\\%")
|
|
||||||
return f"drawtext=text='{escape}':{lower_third.style}{font}"
|
|
||||||
|
|
||||||
return None
|
|
@ -1,74 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
Start a streaming server and forword it to the playout.
|
|
||||||
This stream will have the first priority and
|
|
||||||
play instead of the normal stream (playlist/folder).
|
|
||||||
"""
|
|
||||||
from queue import Queue
|
|
||||||
from subprocess import PIPE, Popen
|
|
||||||
from threading import Thread
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
from .filters.default import overlay_filter
|
|
||||||
from .utils import ff_proc, ffmpeg_stderr_reader, ingest, messenger, pre
|
|
||||||
|
|
||||||
|
|
||||||
def listener(que):
|
|
||||||
filter_ = (f'[0:v]fps={str(pre.fps)},scale={pre.w}:{pre.h},'
|
|
||||||
+ f'setdar=dar={pre.aspect}[v];')
|
|
||||||
filter_ += overlay_filter(0, False, False, False)
|
|
||||||
|
|
||||||
server_cmd = [
|
|
||||||
'ffmpeg', '-hide_banner', '-nostats', '-v', 'level+error'
|
|
||||||
] + ingest.input_param + [
|
|
||||||
'-filter_complex', f'{filter_}[vout1]',
|
|
||||||
'-map', '[vout1]', '-map', '0:a'
|
|
||||||
] + pre.settings
|
|
||||||
|
|
||||||
messenger.warning(
|
|
||||||
'Ingest stream is experimental, use it at your own risk!')
|
|
||||||
messenger.debug(f'Server CMD: "{" ".join(server_cmd)}"')
|
|
||||||
|
|
||||||
while True:
|
|
||||||
with Popen(server_cmd, stderr=PIPE, stdout=PIPE) as ff_proc.server:
|
|
||||||
err_thread = Thread(name='stderr_server',
|
|
||||||
target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.server.stderr, '[Server]'))
|
|
||||||
err_thread.daemon = True
|
|
||||||
err_thread.start()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
buffer = ff_proc.server.stdout.read(pre.buffer_size)
|
|
||||||
if not buffer:
|
|
||||||
break
|
|
||||||
|
|
||||||
que.put(buffer)
|
|
||||||
|
|
||||||
sleep(.33)
|
|
||||||
|
|
||||||
|
|
||||||
def ingest_stream():
|
|
||||||
streaming_queue = Queue(maxsize=0)
|
|
||||||
|
|
||||||
rtmp_server_thread = Thread(name='ffmpeg_server',target=listener,
|
|
||||||
args=(streaming_queue,))
|
|
||||||
rtmp_server_thread.daemon = True
|
|
||||||
rtmp_server_thread.start()
|
|
||||||
|
|
||||||
return streaming_queue
|
|
@ -1,5 +0,0 @@
|
|||||||
## Outputs
|
|
||||||
ffplayout has a modularized output system, which mean you can write your own output function. Just create a python file in this folder with an **output()** function in it. In this function you ca do what ever you want. Use the other output files as references.
|
|
||||||
|
|
||||||
#### Activating Output
|
|
||||||
To use one of the outputs you need to edit the **ffplayout.yml** config, here under **out** set your **mode** to the file name, without extension. if you need it feel free to extend the config to your needs.
|
|
@ -1,142 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module plays the compressed output directly on the desktop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from importlib import import_module
|
|
||||||
from subprocess import PIPE, Popen
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from ..ingest_server import ingest_stream
|
|
||||||
from ..utils import (ff_proc, ffmpeg_stderr_reader, ingest,
|
|
||||||
log, lower_third, messenger, pre, terminate_processes)
|
|
||||||
|
|
||||||
|
|
||||||
def output():
|
|
||||||
"""
|
|
||||||
this output is for playing on desktop with ffplay
|
|
||||||
"""
|
|
||||||
overlay = []
|
|
||||||
live_on = False
|
|
||||||
stream_queue = None
|
|
||||||
|
|
||||||
if ingest.enable:
|
|
||||||
stream_queue = ingest_stream()
|
|
||||||
|
|
||||||
if lower_third.add_text and not lower_third.over_pre:
|
|
||||||
messenger.info(
|
|
||||||
f'Using drawtext node, listening on address: {lower_third.address}'
|
|
||||||
)
|
|
||||||
overlay = [
|
|
||||||
'-vf',
|
|
||||||
"null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format(
|
|
||||||
lower_third.address, lower_third.fontfile)
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
enc_cmd = [
|
|
||||||
'ffplay', '-hide_banner', '-nostats',
|
|
||||||
'-v', f'level+{log.ff_level}', '-i', 'pipe:0'
|
|
||||||
] + overlay
|
|
||||||
|
|
||||||
messenger.debug(f'Encoder CMD: "{" ".join(enc_cmd)}"')
|
|
||||||
|
|
||||||
ff_proc.encoder = Popen(enc_cmd, stderr=PIPE, stdin=PIPE, stdout=None)
|
|
||||||
|
|
||||||
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.encoder.stderr, '[Encoder]'))
|
|
||||||
enc_err_thread.daemon = True
|
|
||||||
enc_err_thread.start()
|
|
||||||
|
|
||||||
Iter = import_module(f'ffplayout.player.{pre.mode}').GetSourceIter
|
|
||||||
get_source = Iter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for node in get_source.next():
|
|
||||||
messenger.info(
|
|
||||||
f'Play for {node["out"] - node["seek"]:.2f} '
|
|
||||||
f'seconds: {node.get("source")}')
|
|
||||||
|
|
||||||
dec_cmd = [
|
|
||||||
'ffmpeg', '-v', f'level+{log.ff_level}',
|
|
||||||
'-hide_banner', '-nostats'
|
|
||||||
] + node['src_cmd'] + node['filter'] + pre.settings
|
|
||||||
|
|
||||||
messenger.debug(f'Decoder CMD: "{" ".join(dec_cmd)}"')
|
|
||||||
|
|
||||||
kill_dec = True
|
|
||||||
|
|
||||||
with Popen(
|
|
||||||
dec_cmd, stdout=PIPE, stderr=PIPE) as ff_proc.decoder:
|
|
||||||
dec_err_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.decoder.stderr,
|
|
||||||
'[Decoder]'))
|
|
||||||
dec_err_thread.daemon = True
|
|
||||||
dec_err_thread.start()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if stream_queue and not stream_queue.empty():
|
|
||||||
if kill_dec:
|
|
||||||
kill_dec = False
|
|
||||||
live_on = True
|
|
||||||
get_source.first = True
|
|
||||||
|
|
||||||
messenger.info(
|
|
||||||
"Switch from offline source to live ingest")
|
|
||||||
|
|
||||||
if ff_proc.decoder.poll() is None:
|
|
||||||
ff_proc.decoder.kill()
|
|
||||||
ff_proc.decoder.wait()
|
|
||||||
|
|
||||||
buf_live = stream_queue.get()
|
|
||||||
ff_proc.encoder.stdin.write(buf_live)
|
|
||||||
else:
|
|
||||||
if live_on:
|
|
||||||
messenger.info(
|
|
||||||
"Switch from live ingest to offline source")
|
|
||||||
kill_dec = True
|
|
||||||
live_on = False
|
|
||||||
|
|
||||||
buf_dec = ff_proc.decoder.stdout.read(
|
|
||||||
pre.buffer_size)
|
|
||||||
if buf_dec:
|
|
||||||
ff_proc.encoder.stdin.write(buf_dec)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
except BrokenPipeError:
|
|
||||||
messenger.error('Broken Pipe!')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except SystemExit:
|
|
||||||
messenger.info('Got close command')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
messenger.warning('Program terminated')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
# close encoder when nothing is to do anymore
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.terminate()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.terminate()
|
|
||||||
ff_proc.encoder.wait()
|
|
@ -1,126 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module write the files compression directly to a hls (m3u8) playlist,
|
|
||||||
without pre- and post-processing.
|
|
||||||
|
|
||||||
Example config:
|
|
||||||
|
|
||||||
out:
|
|
||||||
stream_output: >-
|
|
||||||
-flags +cgop
|
|
||||||
-f hls
|
|
||||||
-hls_time 6
|
|
||||||
-hls_list_size 600
|
|
||||||
-hls_flags append_list+delete_segments+omit_endlist+program_date_time
|
|
||||||
-hls_segment_filename /var/www/srs/live/stream-%09d.ts /var/www/srs/live/stream.m3u8
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from importlib import import_module
|
|
||||||
from pathlib import Path
|
|
||||||
from subprocess import PIPE, Popen
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from ..utils import (ff_proc, ffmpeg_stderr_reader, log, messenger, playout,
|
|
||||||
pre, sync_op, terminate_processes)
|
|
||||||
|
|
||||||
|
|
||||||
def clean_ts():
|
|
||||||
"""
|
|
||||||
this function get all *.m3u8 playlists from config,
|
|
||||||
read lines from them until it founds first *.ts file,
|
|
||||||
then it checks if files on hard drive are older then this first *.ts
|
|
||||||
and if so delete them
|
|
||||||
"""
|
|
||||||
m3u8_files = [p for p in playout.stream_output if 'm3u8' in p]
|
|
||||||
|
|
||||||
for m3u8_file in m3u8_files:
|
|
||||||
messenger.debug(f'cleanup *.ts files from: "{m3u8_file}"')
|
|
||||||
test_num = 0
|
|
||||||
hls_path = Path(m3u8_file).parent
|
|
||||||
|
|
||||||
if Path(m3u8_file).is_file():
|
|
||||||
with open(m3u8_file, 'r') as m3u8:
|
|
||||||
for line in m3u8:
|
|
||||||
if '.ts' in line:
|
|
||||||
test_num = int(re.findall(r'(\d+).ts', line)[0])
|
|
||||||
break
|
|
||||||
|
|
||||||
for ts_file in hls_path.rglob('*.ts'):
|
|
||||||
ts_num = int(re.findall(r'(\d+).ts', str(ts_file))[0])
|
|
||||||
|
|
||||||
if test_num > ts_num:
|
|
||||||
ts_file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def output():
|
|
||||||
"""
|
|
||||||
this output is hls output, no pre-process is needed.
|
|
||||||
"""
|
|
||||||
sync_op.realtime = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
Iter = import_module(f'ffplayout.player.{pre.mode}').GetSourceIter
|
|
||||||
get_source = Iter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for node in get_source.next():
|
|
||||||
messenger.info(f'Play: {node.get("source")}')
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
'ffmpeg', '-v', f'level+{log.ff_level}',
|
|
||||||
'-hide_banner', '-nostats'
|
|
||||||
] + node['src_cmd'] + node['filter'] + playout.output_param
|
|
||||||
|
|
||||||
messenger.debug(f'Encoder CMD: "{" ".join(cmd)}"')
|
|
||||||
|
|
||||||
ff_proc.encoder = Popen(cmd, stdin=PIPE, stderr=PIPE)
|
|
||||||
|
|
||||||
stderr_reader_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.encoder.stderr,
|
|
||||||
'[Encoder]'))
|
|
||||||
stderr_reader_thread.daemon = True
|
|
||||||
stderr_reader_thread.start()
|
|
||||||
stderr_reader_thread.join()
|
|
||||||
|
|
||||||
ts_cleaning_thread = Thread(target=clean_ts)
|
|
||||||
ts_cleaning_thread.daemon = True
|
|
||||||
ts_cleaning_thread.start()
|
|
||||||
|
|
||||||
except BrokenPipeError:
|
|
||||||
messenger.error('Broken Pipe!')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except SystemExit:
|
|
||||||
messenger.info('Got close command')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
messenger.warning('Program terminated')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
# close encoder when nothing is to do anymore
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.terminate()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.terminate()
|
|
||||||
ff_proc.encoder.wait()
|
|
@ -1,134 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module streams to -f null, so it is only for debugging.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from importlib import import_module
|
|
||||||
from subprocess import PIPE, Popen
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from ..ingest_server import ingest_stream
|
|
||||||
from ..utils import (ff_proc, ffmpeg_stderr_reader, ingest,
|
|
||||||
log, messenger, playout, pre, terminate_processes)
|
|
||||||
|
|
||||||
|
|
||||||
def output():
|
|
||||||
"""
|
|
||||||
this output is for streaming to a target address,
|
|
||||||
like rtmp, rtp, svt, etc.
|
|
||||||
"""
|
|
||||||
live_on = False
|
|
||||||
stream_queue = None
|
|
||||||
|
|
||||||
if ingest.enable:
|
|
||||||
stream_queue = ingest_stream()
|
|
||||||
|
|
||||||
messenger.info(f'Stream to null output, only usefull for debugging...')
|
|
||||||
|
|
||||||
try:
|
|
||||||
enc_cmd = [
|
|
||||||
'ffmpeg', '-v', f'level+{log.ff_level}', '-hide_banner',
|
|
||||||
'-nostats', '-re', '-thread_queue_size', '160', '-i', 'pipe:0'
|
|
||||||
] + playout.output_param[:-3] + ['-f', 'null', '-']
|
|
||||||
|
|
||||||
messenger.debug(f'Encoder CMD: "{" ".join(enc_cmd)}"')
|
|
||||||
|
|
||||||
ff_proc.encoder = Popen(enc_cmd, stdin=PIPE, stderr=PIPE)
|
|
||||||
|
|
||||||
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.encoder.stderr, '[Encoder]'))
|
|
||||||
enc_err_thread.daemon = True
|
|
||||||
enc_err_thread.start()
|
|
||||||
|
|
||||||
Iter = import_module(f'ffplayout.player.{pre.mode}').GetSourceIter
|
|
||||||
get_source = Iter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for node in get_source.next():
|
|
||||||
messenger.info(
|
|
||||||
f'Play for {node["out"] - node["seek"]:.2f} '
|
|
||||||
f'seconds: {node.get("source")}')
|
|
||||||
|
|
||||||
dec_cmd = [
|
|
||||||
'ffmpeg', '-v', f'level+{log.ff_level}',
|
|
||||||
'-hide_banner', '-nostats'
|
|
||||||
] + node['src_cmd'] + node['filter'] + pre.settings
|
|
||||||
|
|
||||||
messenger.debug(f'Decoder CMD: "{" ".join(dec_cmd)}"')
|
|
||||||
|
|
||||||
kill_dec = True
|
|
||||||
|
|
||||||
with Popen(
|
|
||||||
dec_cmd, stdout=PIPE, stderr=PIPE) as ff_proc.decoder:
|
|
||||||
dec_err_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.decoder.stderr,
|
|
||||||
'[Decoder]'))
|
|
||||||
dec_err_thread.daemon = True
|
|
||||||
dec_err_thread.start()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if stream_queue and not stream_queue.empty():
|
|
||||||
if kill_dec:
|
|
||||||
kill_dec = False
|
|
||||||
live_on = True
|
|
||||||
get_source.first = True
|
|
||||||
|
|
||||||
messenger.info(
|
|
||||||
"Switch from offline source to live ingest")
|
|
||||||
|
|
||||||
if ff_proc.decoder.poll() is None:
|
|
||||||
ff_proc.decoder.kill()
|
|
||||||
ff_proc.decoder.wait()
|
|
||||||
|
|
||||||
buf_live = stream_queue.get()
|
|
||||||
ff_proc.encoder.stdin.write(buf_live)
|
|
||||||
else:
|
|
||||||
if live_on:
|
|
||||||
messenger.info(
|
|
||||||
"Switch from live ingest to offline source")
|
|
||||||
kill_dec = True
|
|
||||||
live_on = False
|
|
||||||
|
|
||||||
buf_dec = ff_proc.decoder.stdout.read(
|
|
||||||
pre.buffer_size)
|
|
||||||
if buf_dec:
|
|
||||||
ff_proc.encoder.stdin.write(buf_dec)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
except BrokenPipeError:
|
|
||||||
messenger.error('Broken Pipe!')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except SystemExit:
|
|
||||||
messenger.info('Got close command')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
messenger.warning('Program terminated')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
# close encoder when nothing is to do anymore
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.kill()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.kill()
|
|
||||||
ff_proc.encoder.wait()
|
|
@ -1,153 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module streams the files out to a remote target.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from importlib import import_module
|
|
||||||
from subprocess import PIPE, Popen
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from ..ingest_server import ingest_stream
|
|
||||||
from ..utils import (ff_proc, ffmpeg_stderr_reader, ingest,
|
|
||||||
log, lower_third, messenger, playout, pre,
|
|
||||||
terminate_processes)
|
|
||||||
|
|
||||||
|
|
||||||
def output():
|
|
||||||
"""
|
|
||||||
this output is for streaming to a target address,
|
|
||||||
like rtmp, rtp, svt, etc.
|
|
||||||
"""
|
|
||||||
filtering = []
|
|
||||||
node = None
|
|
||||||
dec_cmd = []
|
|
||||||
preview = []
|
|
||||||
live_on = False
|
|
||||||
stream_queue = None
|
|
||||||
|
|
||||||
if ingest.enable:
|
|
||||||
stream_queue = ingest_stream()
|
|
||||||
|
|
||||||
if lower_third.add_text and not lower_third.over_pre:
|
|
||||||
messenger.info(
|
|
||||||
f'Using drawtext node, listening on address: {lower_third.address}'
|
|
||||||
)
|
|
||||||
filtering = [
|
|
||||||
'-filter_complex',
|
|
||||||
f"[0:v]null,zmq=b=tcp\\\\://'{lower_third.address}',"
|
|
||||||
+ f"drawtext=text='':fontfile='{lower_third.fontfile}'"
|
|
||||||
]
|
|
||||||
|
|
||||||
if playout.preview:
|
|
||||||
filtering[-1] += ',split=2[v_out1][v_out2]'
|
|
||||||
preview = ['-map', '[v_out1]', '-map', '0:a'
|
|
||||||
] + playout.preview_param + ['-map', '[v_out2]', '-map', '0:a']
|
|
||||||
|
|
||||||
elif playout.preview:
|
|
||||||
preview = playout.preview_param
|
|
||||||
|
|
||||||
try:
|
|
||||||
enc_cmd = [
|
|
||||||
'ffmpeg', '-v', f'level+{log.ff_level}', '-hide_banner',
|
|
||||||
'-nostats', '-re', '-thread_queue_size', '160', '-i', 'pipe:0'
|
|
||||||
] + filtering + preview + playout.output_param
|
|
||||||
|
|
||||||
messenger.debug(f'Encoder CMD: "{" ".join(enc_cmd)}"')
|
|
||||||
|
|
||||||
ff_proc.encoder = Popen(enc_cmd, stdin=PIPE, stderr=PIPE)
|
|
||||||
|
|
||||||
enc_err_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.encoder.stderr, '[Encoder]'))
|
|
||||||
enc_err_thread.daemon = True
|
|
||||||
enc_err_thread.start()
|
|
||||||
|
|
||||||
Iter = import_module(f'ffplayout.player.{pre.mode}').GetSourceIter
|
|
||||||
get_source = Iter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for node in get_source.next():
|
|
||||||
messenger.info(f'Play: {node.get("source")}')
|
|
||||||
|
|
||||||
dec_cmd = [
|
|
||||||
'ffmpeg', '-v', f'level+{log.ff_level}',
|
|
||||||
'-hide_banner', '-nostats'
|
|
||||||
] + node['src_cmd'] + node['filter'] + pre.settings
|
|
||||||
|
|
||||||
messenger.debug(f'Decoder CMD: "{" ".join(dec_cmd)}"')
|
|
||||||
|
|
||||||
kill_dec = True
|
|
||||||
|
|
||||||
with Popen(
|
|
||||||
dec_cmd, stdout=PIPE, stderr=PIPE) as ff_proc.decoder:
|
|
||||||
dec_err_thread = Thread(target=ffmpeg_stderr_reader,
|
|
||||||
args=(ff_proc.decoder.stderr,
|
|
||||||
'[Decoder]'))
|
|
||||||
dec_err_thread.daemon = True
|
|
||||||
dec_err_thread.start()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if stream_queue and not stream_queue.empty():
|
|
||||||
if kill_dec:
|
|
||||||
kill_dec = False
|
|
||||||
live_on = True
|
|
||||||
get_source.first = True
|
|
||||||
|
|
||||||
messenger.info(
|
|
||||||
"Switch from offline source to live ingest")
|
|
||||||
|
|
||||||
if ff_proc.decoder.poll() is None:
|
|
||||||
ff_proc.decoder.kill()
|
|
||||||
ff_proc.decoder.wait()
|
|
||||||
|
|
||||||
buf_live = stream_queue.get()
|
|
||||||
ff_proc.encoder.stdin.write(buf_live)
|
|
||||||
else:
|
|
||||||
if live_on:
|
|
||||||
messenger.info(
|
|
||||||
"Switch from live ingest to offline source")
|
|
||||||
kill_dec = True
|
|
||||||
live_on = False
|
|
||||||
|
|
||||||
buf_dec = ff_proc.decoder.stdout.read(
|
|
||||||
pre.buffer_size)
|
|
||||||
if buf_dec:
|
|
||||||
ff_proc.encoder.stdin.write(buf_dec)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
except BrokenPipeError:
|
|
||||||
messenger.error('Broken Pipe!')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except SystemExit:
|
|
||||||
messenger.info('Got close command')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
messenger.warning('Program terminated')
|
|
||||||
terminate_processes(getattr(get_source, 'stop', None))
|
|
||||||
|
|
||||||
# close encoder when nothing is to do anymore
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.kill()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.kill()
|
|
||||||
ff_proc.encoder.wait()
|
|
@ -1,7 +0,0 @@
|
|||||||
Here you have the possibility to add you own player module. Defaults are: playing a playlist, or the content of a folder.
|
|
||||||
|
|
||||||
If you need your own module, create a python file with the desire name. Inside it need a generator class with the name: **GetSourceIter**.
|
|
||||||
|
|
||||||
Check **folder.py** and **playlist.py** to get an idea how it needs to work.
|
|
||||||
|
|
||||||
After creating the custom module, set in config **play: -> mode:** the file name of your module without extension.
|
|
@ -1,231 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module handles folder reading. It monitor file adding, deleting or moving
|
|
||||||
"""
|
|
||||||
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from copy import deepcopy
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from watchdog.events import PatternMatchingEventHandler
|
|
||||||
from watchdog.observers import Observer
|
|
||||||
|
|
||||||
from ..filters.default import build_filtergraph
|
|
||||||
from ..utils import (MediaProbe, ff_proc, get_float, messenger, stdin_args,
|
|
||||||
storage)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# folder watcher
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class MediaStore:
|
|
||||||
"""
|
|
||||||
fill media list for playing
|
|
||||||
MediaWatch will interact with add and remove
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.store = []
|
|
||||||
|
|
||||||
if stdin_args.folder:
|
|
||||||
self.folder = stdin_args.folder
|
|
||||||
else:
|
|
||||||
self.folder = storage.path
|
|
||||||
|
|
||||||
self.fill()
|
|
||||||
|
|
||||||
def fill(self):
|
|
||||||
"""
|
|
||||||
fill media list
|
|
||||||
"""
|
|
||||||
for ext in storage.extensions:
|
|
||||||
self.store.extend(
|
|
||||||
[str(f) for f in Path(self.folder).rglob(f'*{ext}')])
|
|
||||||
|
|
||||||
def sort_or_radomize(self):
|
|
||||||
"""
|
|
||||||
sort or randomize file list
|
|
||||||
"""
|
|
||||||
if storage.shuffle:
|
|
||||||
self.rand()
|
|
||||||
else:
|
|
||||||
self.sort()
|
|
||||||
|
|
||||||
def add(self, file):
|
|
||||||
"""
|
|
||||||
add new file to media list
|
|
||||||
"""
|
|
||||||
self.store.append(file)
|
|
||||||
self.sort_or_radomize()
|
|
||||||
|
|
||||||
def remove(self, file):
|
|
||||||
"""
|
|
||||||
remove file from media list
|
|
||||||
"""
|
|
||||||
self.store.remove(file)
|
|
||||||
self.sort_or_radomize()
|
|
||||||
|
|
||||||
def sort(self):
|
|
||||||
"""
|
|
||||||
sort list for sorted playing
|
|
||||||
"""
|
|
||||||
self.store = sorted(self.store)
|
|
||||||
|
|
||||||
def rand(self):
|
|
||||||
"""
|
|
||||||
randomize list for playing
|
|
||||||
"""
|
|
||||||
random.shuffle(self.store)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaWatcher:
|
|
||||||
"""
|
|
||||||
watch given folder for file changes and update media list
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, media):
|
|
||||||
self._media = media
|
|
||||||
self.extensions = [f'*{ext}' for ext in storage.extensions]
|
|
||||||
self.current_clip = None
|
|
||||||
|
|
||||||
self.event_handler = PatternMatchingEventHandler(
|
|
||||||
patterns=self.extensions)
|
|
||||||
self.event_handler.on_created = self.on_created
|
|
||||||
self.event_handler.on_moved = self.on_moved
|
|
||||||
self.event_handler.on_deleted = self.on_deleted
|
|
||||||
|
|
||||||
self.observer = Observer()
|
|
||||||
self.observer.schedule(self.event_handler, self._media.folder,
|
|
||||||
recursive=True)
|
|
||||||
|
|
||||||
self.observer.start()
|
|
||||||
|
|
||||||
def on_created(self, event):
|
|
||||||
"""
|
|
||||||
add file to media list only if it is completely copied
|
|
||||||
"""
|
|
||||||
file_size = -1
|
|
||||||
while file_size != Path(event.src_path).stat().st_size:
|
|
||||||
file_size = Path(event.src_path).stat().st_size
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
self._media.add(event.src_path)
|
|
||||||
|
|
||||||
messenger.info(f'Add file to media list: "{event.src_path}"')
|
|
||||||
|
|
||||||
def on_moved(self, event):
|
|
||||||
"""
|
|
||||||
operation when file on storage are moved
|
|
||||||
"""
|
|
||||||
self._media.remove(event.src_path)
|
|
||||||
self._media.add(event.dest_path)
|
|
||||||
|
|
||||||
messenger.info(
|
|
||||||
f'Move file from "{event.src_path}" to "{event.dest_path}"')
|
|
||||||
|
|
||||||
if self.current_clip == event.src_path:
|
|
||||||
ff_proc.decoder.terminate()
|
|
||||||
|
|
||||||
def on_deleted(self, event):
|
|
||||||
"""
|
|
||||||
operation when file on storage are deleted
|
|
||||||
"""
|
|
||||||
self._media.remove(event.src_path)
|
|
||||||
|
|
||||||
messenger.info(f'Remove file from media list: "{event.src_path}"')
|
|
||||||
|
|
||||||
if self.current_clip == event.src_path:
|
|
||||||
ff_proc.decoder.terminate()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""
|
|
||||||
stop monitoring storage
|
|
||||||
"""
|
|
||||||
self.observer.stop()
|
|
||||||
self.observer.join()
|
|
||||||
|
|
||||||
|
|
||||||
class GetSourceIter:
|
|
||||||
"""
|
|
||||||
give next clip, depending on shuffle mode
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.media = MediaStore()
|
|
||||||
self.watcher = MediaWatcher(self.media)
|
|
||||||
|
|
||||||
self.last_played = []
|
|
||||||
self.index = 0
|
|
||||||
self.probe = MediaProbe()
|
|
||||||
self.next_probe = MediaProbe()
|
|
||||||
self.node = None
|
|
||||||
self.node_last = None
|
|
||||||
self.node_next = None
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.watcher.stop()
|
|
||||||
|
|
||||||
def next(self):
|
|
||||||
"""
|
|
||||||
generator for getting always a new file
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
while self.index < len(self.media.store):
|
|
||||||
if self.node_next:
|
|
||||||
self.node = deepcopy(self.node_next)
|
|
||||||
self.probe = deepcopy(self.next_probe)
|
|
||||||
else:
|
|
||||||
self.probe.load(self.media.store[self.index])
|
|
||||||
duration = get_float(self.probe.format.get('duration'), 0)
|
|
||||||
self.node = {
|
|
||||||
'in': 0,
|
|
||||||
'seek': 0,
|
|
||||||
'out': duration,
|
|
||||||
'duration': duration,
|
|
||||||
'source': self.media.store[self.index],
|
|
||||||
'probe': self.probe
|
|
||||||
}
|
|
||||||
if self.index < len(self.media.store) - 1:
|
|
||||||
self.next_probe.load(self.media.store[self.index + 1])
|
|
||||||
next_duration = get_float(
|
|
||||||
self.next_probe.format.get('duration'), 0)
|
|
||||||
self.node_next = {
|
|
||||||
'in': 0,
|
|
||||||
'seek': 0,
|
|
||||||
'out': next_duration,
|
|
||||||
'duration': next_duration,
|
|
||||||
'source': self.media.store[self.index + 1],
|
|
||||||
'probe': self.next_probe
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
self.media.rand()
|
|
||||||
self.node_next = None
|
|
||||||
|
|
||||||
self.node['src_cmd'] = ['-i', self.media.store[self.index]]
|
|
||||||
self.node['filter'] = build_filtergraph(
|
|
||||||
self.node, self.node_last, self.node_next)
|
|
||||||
|
|
||||||
self.watcher.current_clip = self.node.get('source')
|
|
||||||
yield self.node
|
|
||||||
self.index += 1
|
|
||||||
self.node_last = deepcopy(self.node)
|
|
||||||
|
|
||||||
self.index = 0
|
|
@ -1,497 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module handles playlists, it can be aware of time syncing.
|
|
||||||
Empty, missing or any other playlist related failure should be compensate.
|
|
||||||
Missing clips will be replaced by a dummy clip.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
from copy import deepcopy
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from ..filters.default import build_filtergraph
|
|
||||||
from ..utils import (MediaProbe, check_sync, get_date, get_delta, get_float,
|
|
||||||
get_time, messenger, playlist, sec_to_time, src_or_dummy,
|
|
||||||
storage, sync_op, valid_json)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_thread(clip_nodes, list_date):
|
|
||||||
"""
|
|
||||||
validate json values in new thread
|
|
||||||
and test if source paths exist
|
|
||||||
"""
|
|
||||||
def check_json(clip_nodes, list_date):
|
|
||||||
error = ''
|
|
||||||
counter = 0
|
|
||||||
probe = MediaProbe()
|
|
||||||
|
|
||||||
# check if all values are valid
|
|
||||||
for node in clip_nodes['program']:
|
|
||||||
source = node.get('source')
|
|
||||||
probe.load(source)
|
|
||||||
missing = []
|
|
||||||
_in = get_float(node.get('in'), 0)
|
|
||||||
_out = get_float(node.get('out'), 0)
|
|
||||||
duration = get_float(node.get('duration'), 0)
|
|
||||||
|
|
||||||
if probe.is_remote:
|
|
||||||
if not probe.video[0]:
|
|
||||||
missing.append(f'Remote file not exist: "{source}"')
|
|
||||||
elif source is None or not Path(source).is_file():
|
|
||||||
missing.append(f'File not exist: "{source}", '
|
|
||||||
f'at "{sec_to_time(counter + playlist.start)}"')
|
|
||||||
|
|
||||||
if not type(node.get('in')) in [int, float]:
|
|
||||||
missing.append(f'No in Value in: "{node}"')
|
|
||||||
|
|
||||||
if _out == 0:
|
|
||||||
missing.append(f'No out Value in: "{node}"')
|
|
||||||
|
|
||||||
if duration == 0:
|
|
||||||
missing.append(f'No duration Value in: "{node}"')
|
|
||||||
|
|
||||||
counter += _out - _in
|
|
||||||
|
|
||||||
line = '\n'.join(missing)
|
|
||||||
if line:
|
|
||||||
error += line + f'\nIn line: {node}\n\n'
|
|
||||||
|
|
||||||
if error:
|
|
||||||
messenger.error(
|
|
||||||
'Validation error, check JSON playlist, '
|
|
||||||
f'values are missing:\n{error}'
|
|
||||||
)
|
|
||||||
|
|
||||||
check_length(counter, list_date)
|
|
||||||
|
|
||||||
if clip_nodes and clip_nodes.get('program') and \
|
|
||||||
len(clip_nodes.get('program')) > 0:
|
|
||||||
validate = Thread(name='check_json', target=check_json,
|
|
||||||
args=(clip_nodes, list_date))
|
|
||||||
validate.daemon = True
|
|
||||||
validate.start()
|
|
||||||
else:
|
|
||||||
messenger.error('Validation error: playlist are empty')
|
|
||||||
|
|
||||||
|
|
||||||
def handle_list_init(node):
|
|
||||||
"""
|
|
||||||
handle init clip, but this clip can be the last one in playlist,
|
|
||||||
this we have to figure out and calculate the right length
|
|
||||||
"""
|
|
||||||
messenger.debug('List init')
|
|
||||||
|
|
||||||
delta, total_delta = get_delta(node['begin'])
|
|
||||||
seek = abs(delta) + node['seek'] if abs(delta) + node['seek'] >= 1 else 0
|
|
||||||
seek = round(seek, 3)
|
|
||||||
|
|
||||||
if node['out'] - seek > total_delta:
|
|
||||||
out = total_delta + seek
|
|
||||||
else:
|
|
||||||
out = node['out']
|
|
||||||
|
|
||||||
if out - seek > 1:
|
|
||||||
node['out'] = out
|
|
||||||
node['seek'] = seek
|
|
||||||
return src_or_dummy(node)
|
|
||||||
|
|
||||||
messenger.warning(f'Clip less then a second, skip:\n{node["source"]}')
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def handle_list_end(duration, node):
|
|
||||||
"""
|
|
||||||
when we come to last clip in playlist,
|
|
||||||
or when we reached total playtime,
|
|
||||||
we end up here
|
|
||||||
"""
|
|
||||||
messenger.debug('List end')
|
|
||||||
|
|
||||||
out = node['seek'] + duration if node['seek'] > 0 else duration
|
|
||||||
|
|
||||||
# prevent looping
|
|
||||||
if out > node['duration']:
|
|
||||||
out = node['duration']
|
|
||||||
else:
|
|
||||||
messenger.warning(
|
|
||||||
f'Clip length is not in time, new duration is: {duration:.2f}')
|
|
||||||
|
|
||||||
if node['duration'] > duration > 1 and \
|
|
||||||
node['duration'] - node['seek'] >= duration:
|
|
||||||
node['out'] = out
|
|
||||||
node = src_or_dummy(node)
|
|
||||||
elif node['duration'] > duration < 1.0:
|
|
||||||
messenger.warning(
|
|
||||||
f'Last clip less then 1 second long, skip:\n{node["source"]}')
|
|
||||||
node = None
|
|
||||||
else:
|
|
||||||
_, total_delta = get_delta(node['begin'])
|
|
||||||
messenger.error(
|
|
||||||
f'Playlist is not long enough:\n{total_delta:.2f} seconds needed')
|
|
||||||
node = src_or_dummy(node)
|
|
||||||
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def timed_source(node, last):
|
|
||||||
"""
|
|
||||||
prepare input clip
|
|
||||||
check begin and length from clip
|
|
||||||
return clip only if we are in 24 hours time range
|
|
||||||
"""
|
|
||||||
delta, total_delta = get_delta(node['begin'])
|
|
||||||
node_ = None
|
|
||||||
|
|
||||||
if playlist.start and playlist.length:
|
|
||||||
messenger.debug(f'delta: {delta:f}')
|
|
||||||
messenger.debug(f'total_delta: {total_delta:f}')
|
|
||||||
check_sync(delta, node)
|
|
||||||
|
|
||||||
if (total_delta > node['out'] - node['seek'] and not last) \
|
|
||||||
or not playlist.length:
|
|
||||||
# when we are in the 24 hour range, get the clip
|
|
||||||
node_ = src_or_dummy(node)
|
|
||||||
|
|
||||||
elif total_delta <= 0:
|
|
||||||
messenger.info(f'Begin is over play time, skip:\n{node["source"]}')
|
|
||||||
|
|
||||||
elif total_delta < node['duration'] - node['seek'] or last:
|
|
||||||
node_ = handle_list_end(total_delta, node)
|
|
||||||
|
|
||||||
return node_
|
|
||||||
|
|
||||||
|
|
||||||
def check_length(total_play_time, list_date):
|
|
||||||
"""
|
|
||||||
check if playlist is long enough
|
|
||||||
"""
|
|
||||||
if playlist.length and total_play_time < playlist.length - 5 \
|
|
||||||
and not playlist.loop:
|
|
||||||
messenger.error(
|
|
||||||
f'Playlist from {list_date} is not long enough!\n'
|
|
||||||
f'Total play time is: {sec_to_time(total_play_time)}, '
|
|
||||||
f'target length is: {sec_to_time(playlist.length)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistReader:
|
|
||||||
"""
|
|
||||||
Class which read playlists, it checks if playlist got modified,
|
|
||||||
when yes it reads the file new, when not it used the cached one
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, list_date, last_mod_time):
|
|
||||||
self.list_date = list_date
|
|
||||||
self.last_mod_time = last_mod_time
|
|
||||||
self.nodes = None
|
|
||||||
self.error = False
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
"""
|
|
||||||
read and process playlist
|
|
||||||
"""
|
|
||||||
self.nodes = {'program': []}
|
|
||||||
self.error = False
|
|
||||||
|
|
||||||
if '://' in playlist.path:
|
|
||||||
json_file = playlist.path.replace('\\', '/')
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = requests.get(json_file, timeout=1, verify=False)
|
|
||||||
b_time = result.headers['last-modified']
|
|
||||||
temp_time = time.strptime(b_time, "%a, %d %b %Y %H:%M:%S %Z")
|
|
||||||
mod_time = time.mktime(temp_time)
|
|
||||||
|
|
||||||
if mod_time > self.last_mod_time:
|
|
||||||
if isinstance(result.json(), dict):
|
|
||||||
self.nodes = result.json()
|
|
||||||
self.last_mod_time = mod_time
|
|
||||||
messenger.info('Open: ' + json_file)
|
|
||||||
validate_thread(deepcopy(self.nodes), self.list_date)
|
|
||||||
except (requests.exceptions.ConnectionError, socket.timeout):
|
|
||||||
messenger.error(f'No valid playlist from url: {json_file}')
|
|
||||||
self.error = True
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
json_file = Path(playlist.path)
|
|
||||||
|
|
||||||
if json_file.is_dir():
|
|
||||||
year, month, _ = self.list_date.split('-')
|
|
||||||
json_file = json_file.joinpath(
|
|
||||||
year, month, f'{self.list_date}.json')
|
|
||||||
|
|
||||||
if json_file.is_file():
|
|
||||||
# check last modification time from playlist
|
|
||||||
mod_time = json_file.stat().st_mtime
|
|
||||||
if mod_time > self.last_mod_time:
|
|
||||||
with open(json_file, 'r', encoding='utf-8') as playlist_file:
|
|
||||||
self.nodes = valid_json(playlist_file)
|
|
||||||
|
|
||||||
self.last_mod_time = mod_time
|
|
||||||
messenger.info(f'Open: {str(json_file)}')
|
|
||||||
validate_thread(deepcopy(self.nodes), self.list_date)
|
|
||||||
else:
|
|
||||||
messenger.error(f'Playlist not exists: {str(json_file)}')
|
|
||||||
self.error = True
|
|
||||||
|
|
||||||
|
|
||||||
class GetSourceIter:
|
|
||||||
"""
|
|
||||||
read values from json playlist,
|
|
||||||
get current clip in time,
|
|
||||||
set ffmpeg source command
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.prev_date = get_date(True)
|
|
||||||
self.list_start = playlist.start
|
|
||||||
self.last_time = 0
|
|
||||||
self.first = True
|
|
||||||
self.last = False
|
|
||||||
self.clip_nodes = []
|
|
||||||
self.node_count = 0
|
|
||||||
self.node = None
|
|
||||||
self.prev_node = None
|
|
||||||
self.next_node = None
|
|
||||||
self.playlist_reader = PlaylistReader(get_date(True), 0.0)
|
|
||||||
self.last_error = False
|
|
||||||
|
|
||||||
probe = MediaProbe()
|
|
||||||
probe.load(storage.filler)
|
|
||||||
|
|
||||||
self.filler_duration = get_float(probe.format.get('duration'), 60)
|
|
||||||
|
|
||||||
def get_playlist(self):
|
|
||||||
"""
|
|
||||||
read playlist from given date and fill clip_nodes
|
|
||||||
when playlist is not available, reset relevant values
|
|
||||||
"""
|
|
||||||
self.playlist_reader.read()
|
|
||||||
|
|
||||||
if self.last_error and not self.playlist_reader.error and \
|
|
||||||
self.playlist_reader.list_date == self.prev_date:
|
|
||||||
# when last playlist where not exists but now is there and
|
|
||||||
# is still the same playlist date,
|
|
||||||
# set self.first to true to seek in clip
|
|
||||||
# only in this situation seek in is correct!!
|
|
||||||
self.first = True
|
|
||||||
self.last_error = self.playlist_reader.error
|
|
||||||
|
|
||||||
if self.playlist_reader.nodes and \
|
|
||||||
self.playlist_reader.nodes.get('program'):
|
|
||||||
self.clip_nodes = self.playlist_reader.nodes.get('program')
|
|
||||||
|
|
||||||
if playlist.loop and playlist.length:
|
|
||||||
self.loop_nodes()
|
|
||||||
|
|
||||||
self.node_count = len(self.clip_nodes)
|
|
||||||
|
|
||||||
if self.playlist_reader.error:
|
|
||||||
self.clip_nodes = []
|
|
||||||
self.node_count = 0
|
|
||||||
self.playlist_reader.last_mod_time = 0.0
|
|
||||||
self.last_error = self.playlist_reader.error
|
|
||||||
|
|
||||||
def loop_nodes(self):
|
|
||||||
total_duration = 0
|
|
||||||
nodes_ = deepcopy(self.clip_nodes)
|
|
||||||
|
|
||||||
while total_duration < playlist.length:
|
|
||||||
for node in nodes_:
|
|
||||||
total_duration += node['out'] - node['in']
|
|
||||||
self.clip_nodes.append(node)
|
|
||||||
|
|
||||||
if total_duration >= playlist.length:
|
|
||||||
break
|
|
||||||
|
|
||||||
def init_time(self):
|
|
||||||
"""
|
|
||||||
get current time in second and shift it when is necessary
|
|
||||||
"""
|
|
||||||
self.last_time = get_time('full_sec')
|
|
||||||
|
|
||||||
if playlist.length:
|
|
||||||
total_playtime = playlist.length
|
|
||||||
else:
|
|
||||||
total_playtime = 86400.0
|
|
||||||
|
|
||||||
if self.last_time < playlist.start:
|
|
||||||
self.last_time += total_playtime
|
|
||||||
|
|
||||||
def check_for_next_playlist(self, begin):
|
|
||||||
"""
|
|
||||||
check if playlist length is 24 hours and matches current length,
|
|
||||||
to get the date for a new playlist
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.node is not None:
|
|
||||||
out = self.node['out']
|
|
||||||
delta = 0
|
|
||||||
|
|
||||||
if self.node['duration'] > self.node['out']:
|
|
||||||
out = self.node['duration']
|
|
||||||
|
|
||||||
if self.last:
|
|
||||||
seek = self.node['seek'] if self.node['seek'] > 0 else 0
|
|
||||||
delta, _ = get_delta(begin)
|
|
||||||
delta += seek + sync_op.threshold
|
|
||||||
|
|
||||||
next_start = begin - playlist.start + out + delta
|
|
||||||
else:
|
|
||||||
delta, _ = get_delta(begin)
|
|
||||||
next_start = begin - playlist.start + sync_op.threshold + delta
|
|
||||||
|
|
||||||
if playlist.length and next_start >= playlist.length:
|
|
||||||
self.prev_date = get_date(False, next_start)
|
|
||||||
self.playlist_reader.list_date = self.prev_date
|
|
||||||
self.playlist_reader.last_mod_time = 0.0
|
|
||||||
self.last_time = playlist.start - 1
|
|
||||||
self.clip_nodes = []
|
|
||||||
|
|
||||||
def previous_and_next_node(self, index):
|
|
||||||
"""
|
|
||||||
set previous and next clip node
|
|
||||||
"""
|
|
||||||
self.prev_node = self.clip_nodes[index - 1] if index > 0 else None
|
|
||||||
|
|
||||||
if index < self.node_count - 1:
|
|
||||||
self.next_node = self.clip_nodes[index + 1]
|
|
||||||
else:
|
|
||||||
self.next_node = None
|
|
||||||
|
|
||||||
def generate_cmd(self):
|
|
||||||
"""
|
|
||||||
extend clip node with ffmpeg source cmd and filters
|
|
||||||
"""
|
|
||||||
self.node = timed_source(self.node, self.last)
|
|
||||||
if self.node:
|
|
||||||
self.node['filter'] = build_filtergraph(self.node, self.prev_node,
|
|
||||||
self.next_node)
|
|
||||||
|
|
||||||
def generate_placeholder(self):
|
|
||||||
"""
|
|
||||||
when playlist not exists, or is not long enough,
|
|
||||||
generate a placeholder node
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.init_time()
|
|
||||||
begin = self.last_time
|
|
||||||
|
|
||||||
self.node = {
|
|
||||||
'begin': begin,
|
|
||||||
'number': 0,
|
|
||||||
'in': 0,
|
|
||||||
'seek': 0,
|
|
||||||
'out': self.filler_duration - 0.001,
|
|
||||||
'duration': self.filler_duration
|
|
||||||
}
|
|
||||||
|
|
||||||
self.generate_cmd()
|
|
||||||
self.check_for_next_playlist(begin)
|
|
||||||
|
|
||||||
def eof_handling(self, begin):
|
|
||||||
"""
|
|
||||||
handle except playlist end
|
|
||||||
"""
|
|
||||||
if playlist.loop and self.node:
|
|
||||||
# when loop parameter is set and playlist node exists,
|
|
||||||
# jump to playlist start and play again
|
|
||||||
self.list_start = get_time('full_sec')
|
|
||||||
self.node = None
|
|
||||||
messenger.info('Loop playlist')
|
|
||||||
|
|
||||||
elif begin == playlist.start or not self.clip_nodes:
|
|
||||||
# playlist not exist or is corrupt/empty
|
|
||||||
messenger.error('Clip nodes are empty!')
|
|
||||||
self.first = False
|
|
||||||
self.generate_placeholder()
|
|
||||||
|
|
||||||
else:
|
|
||||||
messenger.error('Playlist not long enough!')
|
|
||||||
self.first = False
|
|
||||||
self.last = True
|
|
||||||
self.generate_placeholder()
|
|
||||||
|
|
||||||
def next(self):
|
|
||||||
"""
|
|
||||||
endless loop for reading playlists
|
|
||||||
and getting the right clip node
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
self.get_playlist()
|
|
||||||
begin = self.list_start
|
|
||||||
|
|
||||||
for index, self.node in enumerate(self.clip_nodes):
|
|
||||||
self.node['seek'] = get_float(self.node.get('in'), 0)
|
|
||||||
self.node['duration'] = get_float(self.node.get('duration'),
|
|
||||||
30)
|
|
||||||
self.node['out'] = get_float(self.node.get('out'),
|
|
||||||
self.node['duration'])
|
|
||||||
self.node['begin'] = begin
|
|
||||||
self.node['number'] = index + 1
|
|
||||||
|
|
||||||
# first time we end up here
|
|
||||||
if self.first:
|
|
||||||
self.init_time()
|
|
||||||
out = self.node['out']
|
|
||||||
|
|
||||||
if self.node['duration'] > self.node['out']:
|
|
||||||
out = self.node['duration']
|
|
||||||
|
|
||||||
if self.last_time < begin + out - self.node['seek']:
|
|
||||||
self.previous_and_next_node(index)
|
|
||||||
self.node = handle_list_init(self.node)
|
|
||||||
if self.node:
|
|
||||||
self.node['filter'] = build_filtergraph(
|
|
||||||
self.node, self.prev_node, self.next_node)
|
|
||||||
self.first = False
|
|
||||||
self.last_time = begin
|
|
||||||
|
|
||||||
self.check_for_next_playlist(begin)
|
|
||||||
break
|
|
||||||
elif self.last_time < begin:
|
|
||||||
if index == self.node_count - 1:
|
|
||||||
self.last = True
|
|
||||||
else:
|
|
||||||
self.last = False
|
|
||||||
|
|
||||||
self.previous_and_next_node(index)
|
|
||||||
self.generate_cmd()
|
|
||||||
self.last_time = begin
|
|
||||||
|
|
||||||
self.check_for_next_playlist(begin)
|
|
||||||
break
|
|
||||||
|
|
||||||
begin += self.node['out'] - self.node['seek']
|
|
||||||
else:
|
|
||||||
if not playlist.length and not playlist.loop:
|
|
||||||
# when we reach playlist end, stop script
|
|
||||||
messenger.info('Playlist reached end!')
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.eof_handling(begin)
|
|
||||||
|
|
||||||
if self.node:
|
|
||||||
yield self.node
|
|
@ -1,976 +0,0 @@
|
|||||||
# This file is part of ffplayout.
|
|
||||||
#
|
|
||||||
# ffplayout is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# ffplayout is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with ffplayout. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module contains default variables and helper functions
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
import signal
|
|
||||||
import smtplib
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import urllib
|
|
||||||
from argparse import ArgumentParser
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.utils import formatdate
|
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from platform import system
|
|
||||||
from shutil import which
|
|
||||||
from subprocess import STDOUT, CalledProcessError, check_output
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
# path to user define configs
|
|
||||||
CONFIG_PATH = Path(__file__).parent.absolute().joinpath('conf.d')
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# argument parsing
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
stdin_parser = ArgumentParser(description='python and ffmpeg based playout')
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-c', '--config', help='file path to ffplayout.conf'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-f', '--folder', help='play folder content'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-l', '--log', help='file path for logfile'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-i', '--loop', help='loop playlist infinitely', action='store_true'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-o', '--output', help='set output mode: desktop, hls, stream'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-p', '--playlist', help='path from playlist'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-s', '--start',
|
|
||||||
help='start time in "hh:mm:ss", "now" for start with first'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-t', '--length',
|
|
||||||
help='set length in "hh:mm:ss", "none" for no length check'
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
'-pm', '--play_mode', help='playing mode: folder, playlist, custom...'
|
|
||||||
)
|
|
||||||
|
|
||||||
# read dynamical new arguments
|
|
||||||
for arg_file in CONFIG_PATH.glob('argparse_*'):
|
|
||||||
with open(arg_file, 'r') as _file:
|
|
||||||
config = yaml.safe_load(_file)
|
|
||||||
|
|
||||||
short = config.pop('short') if config.get('short') else None
|
|
||||||
long = config.pop('long') if config.get('long') else None
|
|
||||||
|
|
||||||
stdin_parser.add_argument(
|
|
||||||
*filter(None, [short, long]),
|
|
||||||
**config
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin_args = stdin_parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# clock
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_time(time_format):
|
|
||||||
"""
|
|
||||||
get different time formats:
|
|
||||||
- full_sec > current time in seconds
|
|
||||||
- stamp > current date time in seconds
|
|
||||||
- or current time in HH:MM:SS
|
|
||||||
"""
|
|
||||||
date_time = datetime.today()
|
|
||||||
|
|
||||||
if time_format == 'full_sec':
|
|
||||||
return date_time.hour * 3600 + date_time.minute * 60 \
|
|
||||||
+ date_time.second + date_time.microsecond / 1000000
|
|
||||||
|
|
||||||
if time_format == 'stamp':
|
|
||||||
return float(datetime.now().timestamp())
|
|
||||||
|
|
||||||
return date_time.strftime('%H:%M:%S')
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# default variables and values
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sync_op = SimpleNamespace(time_delta=0, realtime=False)
|
|
||||||
mail = SimpleNamespace()
|
|
||||||
log = SimpleNamespace()
|
|
||||||
pre = SimpleNamespace()
|
|
||||||
ingest = SimpleNamespace()
|
|
||||||
playlist = SimpleNamespace()
|
|
||||||
storage = SimpleNamespace()
|
|
||||||
lower_third = SimpleNamespace()
|
|
||||||
playout = SimpleNamespace()
|
|
||||||
|
|
||||||
ff_proc = SimpleNamespace(decoder=None, encoder=None, server=None)
|
|
||||||
|
|
||||||
|
|
||||||
def str_to_sec(time_str):
|
|
||||||
"""
|
|
||||||
convert time is string in seconds as float
|
|
||||||
"""
|
|
||||||
if time_str in ['now', '', None, 'none']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tms = time_str.split(':')
|
|
||||||
try:
|
|
||||||
return float(tms[0]) * 3600 + float(tms[1]) * 60 + float(tms[2])
|
|
||||||
except ValueError:
|
|
||||||
print('Wrong time format!')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def sec_to_time(seconds):
|
|
||||||
"""
|
|
||||||
convert float number to time string in hh:mm:ss
|
|
||||||
"""
|
|
||||||
min, sec = divmod(seconds, 60)
|
|
||||||
hours, min = divmod(min, 60)
|
|
||||||
return f'{int(hours):d}:{int(min):02d}:{int(sec):02d}'
|
|
||||||
|
|
||||||
|
|
||||||
def get_float(value, default=False):
|
|
||||||
"""
|
|
||||||
test if value is float
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def read_config():
|
|
||||||
"""
|
|
||||||
read yaml config
|
|
||||||
"""
|
|
||||||
|
|
||||||
if stdin_args.config:
|
|
||||||
cfg_path = stdin_args.config
|
|
||||||
elif Path('/etc/ffplayout/ffplayout.yml').is_file():
|
|
||||||
cfg_path = '/etc/ffplayout/ffplayout.yml'
|
|
||||||
else:
|
|
||||||
cfg_path = 'ffplayout.yml'
|
|
||||||
|
|
||||||
with open(cfg_path, 'r') as config_file:
|
|
||||||
return yaml.safe_load(config_file)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
|
||||||
"""
|
|
||||||
this function can reload most settings from configuration file,
|
|
||||||
the change does not take effect immediately, but with the after next file,
|
|
||||||
some settings cannot be changed - like resolution, aspect, or output
|
|
||||||
"""
|
|
||||||
|
|
||||||
cfg = read_config()
|
|
||||||
|
|
||||||
sync_op.threshold = int(cfg['general']['stop_threshold'])
|
|
||||||
|
|
||||||
mail.subject = cfg['mail']['subject']
|
|
||||||
mail.server = cfg['mail']['smtp_server']
|
|
||||||
mail.port = cfg['mail']['smtp_port']
|
|
||||||
mail.s_addr = cfg['mail']['sender_addr']
|
|
||||||
mail.s_pass = cfg['mail']['sender_pass']
|
|
||||||
mail.recip = cfg['mail']['recipient']
|
|
||||||
mail.level = cfg['mail']['mail_level']
|
|
||||||
|
|
||||||
pre.add_logo = cfg['processing']['add_logo']
|
|
||||||
pre.logo = cfg['processing']['logo']
|
|
||||||
pre.logo_scale = cfg['processing']['logo_scale']
|
|
||||||
pre.logo_filter = cfg['processing']['logo_filter']
|
|
||||||
pre.logo_opacity = cfg['processing']['logo_opacity']
|
|
||||||
pre.add_loudnorm = cfg['processing']['add_loudnorm']
|
|
||||||
pre.loud_i = cfg['processing']['loud_i']
|
|
||||||
pre.loud_tp = cfg['processing']['loud_tp']
|
|
||||||
pre.loud_lra = cfg['processing']['loud_lra']
|
|
||||||
|
|
||||||
storage.path = cfg['storage']['path']
|
|
||||||
storage.filler = cfg['storage']['filler_clip']
|
|
||||||
storage.extensions = cfg['storage']['extensions']
|
|
||||||
storage.shuffle = cfg['storage']['shuffle']
|
|
||||||
|
|
||||||
lower_third.add_text = cfg['text']['add_text']
|
|
||||||
lower_third.over_pre = cfg['text']['over_pre']
|
|
||||||
lower_third.address = cfg['text']['bind_address'].replace(':', '\\:')
|
|
||||||
lower_third.fontfile = cfg['text']['fontfile']
|
|
||||||
lower_third.text_from_filename = cfg['text']['text_from_filename']
|
|
||||||
lower_third.style = cfg['text']['style']
|
|
||||||
lower_third.regex = cfg['text']['regex']
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
_cfg = load_config()
|
|
||||||
|
|
||||||
if stdin_args.playlist:
|
|
||||||
playlist.path = stdin_args.playlist
|
|
||||||
else:
|
|
||||||
playlist.path = _cfg['playlist']['path']
|
|
||||||
|
|
||||||
if stdin_args.start is not None:
|
|
||||||
playlist.start = str_to_sec(stdin_args.start)
|
|
||||||
else:
|
|
||||||
playlist.start = str_to_sec(_cfg['playlist']['day_start'])
|
|
||||||
|
|
||||||
if playlist.start is None:
|
|
||||||
playlist.start = get_time('full_sec')
|
|
||||||
|
|
||||||
if stdin_args.length:
|
|
||||||
playlist.length = str_to_sec(stdin_args.length)
|
|
||||||
else:
|
|
||||||
playlist.length = str_to_sec(_cfg['playlist']['length'])
|
|
||||||
|
|
||||||
if stdin_args.loop:
|
|
||||||
playlist.loop = stdin_args.loop
|
|
||||||
else:
|
|
||||||
playlist.loop = _cfg['playlist']['infinit']
|
|
||||||
|
|
||||||
log.to_file = _cfg['logging']['log_to_file']
|
|
||||||
log.backup_count = _cfg['logging']['backup_count']
|
|
||||||
log.path = Path(_cfg['logging']['log_path'])
|
|
||||||
log.level = _cfg['logging']['log_level']
|
|
||||||
log.ff_level = _cfg['logging']['ffmpeg_level']
|
|
||||||
|
|
||||||
|
|
||||||
def pre_audio_codec():
|
|
||||||
"""
|
|
||||||
when add_loudnorm is False we use a different audio encoder,
|
|
||||||
s302m has higher quality, but is experimental
|
|
||||||
and works not well together with the loudnorm filter
|
|
||||||
"""
|
|
||||||
if pre.add_loudnorm:
|
|
||||||
return ['-c:a', 'mp2', '-b:a', '384k', '-ar', '48000', '-ac', '2']
|
|
||||||
|
|
||||||
return ['-c:a', 's302m', '-strict', '-2', '-ar', '48000', '-ac', '2']
|
|
||||||
|
|
||||||
|
|
||||||
ingest.enable = _cfg['ingest']['enable']
|
|
||||||
ingest.input_param = shlex.split(_cfg['ingest']['input_param'])
|
|
||||||
|
|
||||||
if stdin_args.play_mode:
|
|
||||||
pre.mode = stdin_args.play_mode
|
|
||||||
else:
|
|
||||||
pre.mode = _cfg['processing']['mode']
|
|
||||||
|
|
||||||
pre.w = _cfg['processing']['width']
|
|
||||||
pre.h = _cfg['processing']['height']
|
|
||||||
pre.aspect = _cfg['processing']['aspect']
|
|
||||||
pre.fps = _cfg['processing']['fps']
|
|
||||||
pre.v_bitrate = _cfg['processing']['width'] * _cfg['processing']['height'] / 10
|
|
||||||
pre.v_bufsize = pre.v_bitrate / 2
|
|
||||||
pre.output_count = _cfg['processing']['output_count']
|
|
||||||
pre.buffer_size = 1024 * 1024 if system() == 'Windows' else 65424
|
|
||||||
|
|
||||||
pre.settings = [
|
|
||||||
'-pix_fmt', 'yuv420p', '-r', str(pre.fps),
|
|
||||||
'-c:v', 'mpeg2video', '-g', '1',
|
|
||||||
'-b:v', f'{pre.v_bitrate}k',
|
|
||||||
'-minrate', f'{pre.v_bitrate}k',
|
|
||||||
'-maxrate', f'{pre.v_bitrate}k',
|
|
||||||
'-bufsize', f'{pre.v_bufsize}k'
|
|
||||||
] + pre_audio_codec() + ['-f', 'mpegts', '-']
|
|
||||||
|
|
||||||
if stdin_args.output:
|
|
||||||
playout.mode = stdin_args.output
|
|
||||||
else:
|
|
||||||
playout.mode = _cfg['out']['mode']
|
|
||||||
|
|
||||||
playout.preview = _cfg['out']['preview']
|
|
||||||
playout.preview_param =shlex.split(_cfg['out']['preview_param'])
|
|
||||||
playout.output_param = shlex.split(_cfg['out']['output_param'])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# logging
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CustomFormatter(logging.Formatter):
|
|
||||||
"""
|
|
||||||
Logging formatter to add colors and count warning / errors
|
|
||||||
"""
|
|
||||||
|
|
||||||
grey = '\x1b[38;1m'
|
|
||||||
darkgrey = '\x1b[30;1m'
|
|
||||||
yellow = '\x1b[33;1m'
|
|
||||||
red = '\x1b[31;1m'
|
|
||||||
magenta = '\x1b[35;1m'
|
|
||||||
green = '\x1b[32;1m'
|
|
||||||
blue = '\x1b[34;1m'
|
|
||||||
cyan = '\x1b[36;1m'
|
|
||||||
reset = '\x1b[0m'
|
|
||||||
|
|
||||||
timestamp = darkgrey + '[%(asctime)s]' + reset
|
|
||||||
level = '[%(levelname)s]' + reset
|
|
||||||
message = grey + ' %(message)s' + reset
|
|
||||||
|
|
||||||
FORMATS = {
|
|
||||||
logging.DEBUG: timestamp + blue + level + ' ' + message + reset,
|
|
||||||
logging.INFO: timestamp + green + level + ' ' + message + reset,
|
|
||||||
logging.WARNING: timestamp + yellow + level + message + reset,
|
|
||||||
logging.ERROR: timestamp + red + level + ' ' + message + reset
|
|
||||||
}
|
|
||||||
|
|
||||||
def format_message(self, msg):
|
|
||||||
"""
|
|
||||||
match strings with regex and add different color tags to it
|
|
||||||
"""
|
|
||||||
if '"' in msg:
|
|
||||||
msg = re.sub('(".*?")', self.cyan + r'\1' + self.reset, msg)
|
|
||||||
elif '[decoder]' in msg:
|
|
||||||
msg = re.sub(r'(\[decoder\])', self.reset + r'\1', msg)
|
|
||||||
elif '[encoder]' in msg:
|
|
||||||
msg = re.sub(r'(\[encoder\])', self.reset + r'\1', msg)
|
|
||||||
elif '/' in msg or '\\' in msg:
|
|
||||||
msg = re.sub(
|
|
||||||
r'("?/[\w.:/]+|["\w.:]+\\.*?)', self.magenta + r'\1', msg)
|
|
||||||
elif re.search(r'\d', msg):
|
|
||||||
msg = re.sub(
|
|
||||||
r'(\d+-\d+-\d+|\d+:\d+:[\d.]+|-?[\d.]+)',
|
|
||||||
self.yellow + r'\1' + self.reset, msg)
|
|
||||||
|
|
||||||
return msg
|
|
||||||
|
|
||||||
def format(self, record):
|
|
||||||
"""
|
|
||||||
override logging format
|
|
||||||
"""
|
|
||||||
record.msg = self.format_message(record.getMessage())
|
|
||||||
log_fmt = self.FORMATS.get(record.levelno)
|
|
||||||
formatter = logging.Formatter(log_fmt)
|
|
||||||
return formatter.format(record)
|
|
||||||
|
|
||||||
|
|
||||||
# If the log file is specified on the command line then override the default
|
|
||||||
if stdin_args.log:
|
|
||||||
log.path = stdin_args.log
|
|
||||||
|
|
||||||
logger = logging.getLogger('playout')
|
|
||||||
logger.setLevel(log.level)
|
|
||||||
|
|
||||||
if log.to_file and log.path != 'none':
|
|
||||||
if log.path.is_dir():
|
|
||||||
playout_log = log.path.joinpath('ffplayout.log')
|
|
||||||
else:
|
|
||||||
log_dir = Path(__file__).parent.parent.absolute().joinpath('log')
|
|
||||||
log_dir.mkdir(exist_ok=True)
|
|
||||||
playout_log = log_dir.joinpath('ffplayout.log')
|
|
||||||
|
|
||||||
p_format = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s')
|
|
||||||
handler = TimedRotatingFileHandler(playout_log, when='midnight',
|
|
||||||
backupCount=log.backup_count)
|
|
||||||
|
|
||||||
handler.setFormatter(p_format)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
else:
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setFormatter(CustomFormatter())
|
|
||||||
logger.addHandler(console_handler)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# mail sender
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class Mailer:
|
|
||||||
"""
|
|
||||||
mailer class for sending log messages, with level selector
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.level = mail.level
|
|
||||||
self.time = None
|
|
||||||
self.timestamp = get_time('stamp')
|
|
||||||
self.rate_limit = 600
|
|
||||||
self.temp_msg = Path(tempfile.gettempdir()).joinpath('ffplayout.txt')
|
|
||||||
|
|
||||||
def current_time(self):
|
|
||||||
"""
|
|
||||||
set sending time
|
|
||||||
"""
|
|
||||||
self.time = get_time(None)
|
|
||||||
|
|
||||||
def send_mail(self, msg):
|
|
||||||
"""
|
|
||||||
send emails to specified recipients
|
|
||||||
"""
|
|
||||||
if mail.recip:
|
|
||||||
# write message to temp file for rate limit
|
|
||||||
with open(self.temp_msg, 'w+') as msg_file:
|
|
||||||
msg_file.write(msg)
|
|
||||||
|
|
||||||
self.current_time()
|
|
||||||
|
|
||||||
message = MIMEMultipart()
|
|
||||||
message['From'] = mail.s_addr
|
|
||||||
message['To'] = mail.recip
|
|
||||||
message['Subject'] = mail.subject
|
|
||||||
message['Date'] = formatdate(localtime=True)
|
|
||||||
message.attach(MIMEText(f'{self.time} {msg}', 'plain'))
|
|
||||||
text = message.as_string()
|
|
||||||
|
|
||||||
try:
|
|
||||||
server = smtplib.SMTP(mail.server, mail.port)
|
|
||||||
except socket.error as err:
|
|
||||||
logger.error(err)
|
|
||||||
server = None
|
|
||||||
|
|
||||||
if server is not None:
|
|
||||||
server.starttls()
|
|
||||||
try:
|
|
||||||
login = server.login(mail.s_addr, mail.s_pass)
|
|
||||||
except smtplib.SMTPAuthenticationError as serr:
|
|
||||||
logger.error(serr)
|
|
||||||
login = None
|
|
||||||
|
|
||||||
if login is not None:
|
|
||||||
server.sendmail(mail.s_addr,
|
|
||||||
re.split(', |; |,|;', mail.recip), text)
|
|
||||||
server.quit()
|
|
||||||
|
|
||||||
def check_if_new(self, msg):
|
|
||||||
"""
|
|
||||||
send message only when is new or the rate_limit is pass
|
|
||||||
"""
|
|
||||||
if Path(self.temp_msg).is_file():
|
|
||||||
mod_time = Path(self.temp_msg).stat().st_mtime
|
|
||||||
|
|
||||||
with open(self.temp_msg, 'r', encoding='utf-8') as msg_file:
|
|
||||||
last_msg = msg_file.read()
|
|
||||||
|
|
||||||
if msg != last_msg \
|
|
||||||
or get_time('stamp') - mod_time > self.rate_limit:
|
|
||||||
self.send_mail(msg)
|
|
||||||
else:
|
|
||||||
self.send_mail(msg)
|
|
||||||
|
|
||||||
def info(self, msg):
|
|
||||||
"""
|
|
||||||
send emails with level INFO, WARNING and ERROR
|
|
||||||
"""
|
|
||||||
if self.level in ['INFO']:
|
|
||||||
self.check_if_new(msg)
|
|
||||||
|
|
||||||
def warning(self, msg):
|
|
||||||
"""
|
|
||||||
send emails with level WARNING and ERROR
|
|
||||||
"""
|
|
||||||
if self.level in ['INFO', 'WARNING']:
|
|
||||||
self.check_if_new(msg)
|
|
||||||
|
|
||||||
def error(self, msg):
|
|
||||||
"""
|
|
||||||
send emails with level ERROR
|
|
||||||
"""
|
|
||||||
if self.level in ['INFO', 'WARNING', 'ERROR']:
|
|
||||||
self.check_if_new(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class Messenger:
|
|
||||||
"""
|
|
||||||
all logging and mail messages end up here,
|
|
||||||
from here they go to logger and mailer
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._mailer = Mailer()
|
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
|
||||||
def debug(self, msg):
|
|
||||||
"""
|
|
||||||
log debugging messages
|
|
||||||
"""
|
|
||||||
logger.debug(msg.replace('\n', ' '))
|
|
||||||
|
|
||||||
def info(self, msg):
|
|
||||||
"""
|
|
||||||
log and mail info messages
|
|
||||||
"""
|
|
||||||
logger.info(msg.replace('\n', ' '))
|
|
||||||
self._mailer.info(msg)
|
|
||||||
|
|
||||||
def warning(self, msg):
|
|
||||||
"""
|
|
||||||
log and mail warning messages
|
|
||||||
"""
|
|
||||||
logger.warning(msg.replace('\n', ' '))
|
|
||||||
self._mailer.warning(msg)
|
|
||||||
|
|
||||||
def error(self, msg):
|
|
||||||
"""
|
|
||||||
log and mail error messages
|
|
||||||
"""
|
|
||||||
logger.error(msg.replace('\n', ' '))
|
|
||||||
self._mailer.error(msg)
|
|
||||||
|
|
||||||
|
|
||||||
messenger = Messenger()
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# check binaries and ffmpeg libs
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_in_system(name):
|
|
||||||
"""
|
|
||||||
Check whether name is on PATH and marked as executable
|
|
||||||
"""
|
|
||||||
if which(name) is None:
|
|
||||||
messenger.error(f'{name} is not found on system')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def ffmpeg_libs():
|
|
||||||
"""
|
|
||||||
check which external libs are compiled in ffmpeg,
|
|
||||||
for using them later
|
|
||||||
"""
|
|
||||||
is_in_system('ffmpeg')
|
|
||||||
is_in_system('ffprobe')
|
|
||||||
|
|
||||||
cmd = ['ffmpeg', '-filters']
|
|
||||||
libs = []
|
|
||||||
filters = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
info = check_output(cmd, stderr=STDOUT).decode('UTF-8')
|
|
||||||
except CalledProcessError as err:
|
|
||||||
messenger.error('ffmpeg - libs could not be readed!\n'
|
|
||||||
f'Processing is not possible. Error:\n{err}')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
for line in info.split('\n'):
|
|
||||||
if 'configuration:' in line:
|
|
||||||
configs = line.split()
|
|
||||||
|
|
||||||
for cfg in configs:
|
|
||||||
if '--enable-lib' in cfg:
|
|
||||||
libs.append(cfg.replace('--enable-', ''))
|
|
||||||
elif re.match(r'^(?!.*=) [TSC.]+', line):
|
|
||||||
filter_list = line.split()
|
|
||||||
if len(filter_list) > 3:
|
|
||||||
filters.append(filter_list[1])
|
|
||||||
|
|
||||||
return {'libs': libs, 'filters': filters}
|
|
||||||
|
|
||||||
|
|
||||||
FF_LIBS = ffmpeg_libs()
|
|
||||||
|
|
||||||
|
|
||||||
def validate_ffmpeg_libs():
|
|
||||||
"""
|
|
||||||
check if ffmpeg contains some basic libs
|
|
||||||
"""
|
|
||||||
if 'libx264' not in FF_LIBS['libs']:
|
|
||||||
logger.error('ffmpeg contains no libx264!')
|
|
||||||
if 'libfdk-aac' not in FF_LIBS['libs']:
|
|
||||||
logger.warning(
|
|
||||||
'ffmpeg contains no libfdk-aac! No high quality aac...')
|
|
||||||
if 'tpad' not in FF_LIBS['filters']:
|
|
||||||
logger.error('ffmpeg contains no tpad filter!')
|
|
||||||
if 'zmq' not in FF_LIBS['filters']:
|
|
||||||
lower_third.add_text = False
|
|
||||||
logger.warning(
|
|
||||||
'ffmpeg contains no zmq filter! Text messages will not work...')
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# probe media info's
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class MediaProbe:
|
|
||||||
"""
|
|
||||||
get info's about media file, similar to mediainfo
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.remote_source = ['http', 'https', 'ftp', 'smb', 'sftp']
|
|
||||||
self.src = None
|
|
||||||
self.format = {}
|
|
||||||
self.audio = []
|
|
||||||
self.video = []
|
|
||||||
self.is_remote = False
|
|
||||||
|
|
||||||
def load(self, file):
|
|
||||||
"""
|
|
||||||
load media file with ffprobe and get info's out of it
|
|
||||||
"""
|
|
||||||
self.src = file
|
|
||||||
self.format = {}
|
|
||||||
self.audio = []
|
|
||||||
self.video = []
|
|
||||||
|
|
||||||
if self.src and self.src.split('://')[0] in self.remote_source:
|
|
||||||
url = self.src.split('://')
|
|
||||||
self.src = f'{url[0]}://{urllib.parse.quote(url[1])}'
|
|
||||||
self.is_remote = True
|
|
||||||
else:
|
|
||||||
self.is_remote = False
|
|
||||||
|
|
||||||
if not self.src or not Path(self.src).is_file():
|
|
||||||
self.audio.append(None)
|
|
||||||
self.video.append(None)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
cmd = ['ffprobe', '-v', 'quiet', '-print_format',
|
|
||||||
'json', '-show_format', '-show_streams', self.src]
|
|
||||||
|
|
||||||
try:
|
|
||||||
info = json.loads(check_output(cmd).decode('UTF-8'))
|
|
||||||
except CalledProcessError as err:
|
|
||||||
messenger.error(f'MediaProbe error in: "{self.src}"\n{err}')
|
|
||||||
self.audio.append(None)
|
|
||||||
self.video.append(None)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
self.format = info['format']
|
|
||||||
|
|
||||||
if get_float(self.format.get('duration'), 0) > 0.1:
|
|
||||||
self.format['duration'] = float(self.format['duration'])
|
|
||||||
|
|
||||||
for stream in info['streams']:
|
|
||||||
if stream['codec_type'] == 'audio':
|
|
||||||
self.audio.append(stream)
|
|
||||||
|
|
||||||
if stream['codec_type'] == 'video':
|
|
||||||
if stream.get('display_aspect_ratio'):
|
|
||||||
width, height = stream['display_aspect_ratio'].split(':')
|
|
||||||
stream['aspect'] = float(width) / float(height)
|
|
||||||
else:
|
|
||||||
stream['aspect'] = float(
|
|
||||||
stream['width']) / float(stream['height'])
|
|
||||||
|
|
||||||
rate, factor = stream['r_frame_rate'].split('/')
|
|
||||||
stream['fps'] = float(rate) / float(factor)
|
|
||||||
|
|
||||||
self.video.append(stream)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# global helper functions
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def handle_sigterm(sig, frame):
|
|
||||||
"""
|
|
||||||
handler for ctrl+c signal
|
|
||||||
"""
|
|
||||||
raise SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_sighub(sig, frame):
|
|
||||||
"""
|
|
||||||
handling SIGHUP signal for reload configuration
|
|
||||||
Linux/macOS only
|
|
||||||
"""
|
|
||||||
messenger.info('Reload config file')
|
|
||||||
load_config()
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
||||||
|
|
||||||
if system() == 'Linux':
|
|
||||||
signal.signal(signal.SIGHUP, handle_sighub)
|
|
||||||
|
|
||||||
|
|
||||||
def terminate_processes(custom_process=None):
|
|
||||||
"""
|
|
||||||
kill orphaned processes
|
|
||||||
"""
|
|
||||||
if ff_proc.decoder and ff_proc.decoder.poll() is None:
|
|
||||||
ff_proc.decoder.terminate()
|
|
||||||
|
|
||||||
if ff_proc.encoder and ff_proc.encoder.poll() is None:
|
|
||||||
ff_proc.encoder.kill()
|
|
||||||
|
|
||||||
if ff_proc.server and ff_proc.server.poll() is None:
|
|
||||||
ff_proc.server.kill()
|
|
||||||
|
|
||||||
if custom_process:
|
|
||||||
custom_process()
|
|
||||||
|
|
||||||
|
|
||||||
def ffmpeg_stderr_reader(std_errors, prefix):
|
|
||||||
"""
|
|
||||||
read ffmpeg stderr decoder and encoder instance
|
|
||||||
and log the output
|
|
||||||
"""
|
|
||||||
def form_line(line, level):
|
|
||||||
return f'{prefix} {line.replace(level, "").rstrip()}'
|
|
||||||
|
|
||||||
def write_log(line):
|
|
||||||
if '[info]' in line:
|
|
||||||
logger.info(form_line(line, '[info] '))
|
|
||||||
elif '[warning]' in line:
|
|
||||||
logger.warning(form_line(line, '[warning] '))
|
|
||||||
elif '[error]' in line:
|
|
||||||
logger.error(form_line(line, '[error] '))
|
|
||||||
|
|
||||||
try:
|
|
||||||
for line in std_errors:
|
|
||||||
if log.ff_level == 'info':
|
|
||||||
write_log(line.decode())
|
|
||||||
elif log.ff_level == 'warning':
|
|
||||||
write_log(line.decode())
|
|
||||||
else:
|
|
||||||
write_log(line.decode())
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def get_delta(begin):
|
|
||||||
"""
|
|
||||||
get difference between current time and begin from clip in playlist
|
|
||||||
"""
|
|
||||||
current_time = get_time('full_sec')
|
|
||||||
|
|
||||||
if stdin_args.length and str_to_sec(stdin_args.length):
|
|
||||||
target_playtime = str_to_sec(stdin_args.length)
|
|
||||||
elif playlist.length:
|
|
||||||
target_playtime = playlist.length
|
|
||||||
else:
|
|
||||||
target_playtime = 86400.0
|
|
||||||
|
|
||||||
if begin == playlist.start == 0 and 86400.0 - current_time < 4:
|
|
||||||
current_time -= target_playtime
|
|
||||||
|
|
||||||
elif playlist.start >= current_time and not begin == playlist.start:
|
|
||||||
current_time += target_playtime
|
|
||||||
|
|
||||||
current_delta = begin - current_time
|
|
||||||
|
|
||||||
if math.isclose(current_delta, 86400.0, abs_tol=sync_op.threshold):
|
|
||||||
current_delta -= 86400.0
|
|
||||||
|
|
||||||
ref_time = target_playtime + playlist.start
|
|
||||||
total_delta = ref_time - begin + current_delta
|
|
||||||
|
|
||||||
return current_delta, total_delta
|
|
||||||
|
|
||||||
|
|
||||||
def get_date(seek_day, next_start=0):
|
|
||||||
"""
|
|
||||||
get date for correct playlist,
|
|
||||||
when seek_day is set:
|
|
||||||
check if playlist date must be from yesterday
|
|
||||||
"""
|
|
||||||
date_ = date.today()
|
|
||||||
|
|
||||||
if seek_day and playlist.start > get_time('full_sec'):
|
|
||||||
return (date_ - timedelta(1)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
if playlist.start == 0 and next_start >= 86400:
|
|
||||||
return (date_ + timedelta(1)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
return date_.strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
|
|
||||||
def is_advertisement(node):
|
|
||||||
"""
|
|
||||||
check if clip in node is advertisement
|
|
||||||
"""
|
|
||||||
if node and node.get('category') == 'advertisement':
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def valid_json(file):
|
|
||||||
"""
|
|
||||||
simple json validation
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
json_object = json.load(file)
|
|
||||||
return json_object
|
|
||||||
except ValueError:
|
|
||||||
messenger.error(f'Playlist {file.name} is not JSON conform')
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def check_sync(delta, node=None):
|
|
||||||
"""
|
|
||||||
check that we are in tolerance time
|
|
||||||
"""
|
|
||||||
|
|
||||||
if pre.mode == 'playlist' and playlist.start and playlist.length:
|
|
||||||
# save time delta to global variable for syncing
|
|
||||||
# this is needed for real time filter
|
|
||||||
sync_op.time_delta = delta
|
|
||||||
|
|
||||||
if abs(delta) > sync_op.threshold > 0:
|
|
||||||
messenger.error(
|
|
||||||
f'Sync tolerance value exceeded with {delta:.2f} seconds,\n'
|
|
||||||
'program terminated!')
|
|
||||||
messenger.debug(f'Terminate on node: {node}')
|
|
||||||
terminate_processes()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def seek_in(seek):
|
|
||||||
"""
|
|
||||||
seek in clip
|
|
||||||
"""
|
|
||||||
return ['-ss', str(seek)] if seek > 0.0 else []
|
|
||||||
|
|
||||||
|
|
||||||
def set_length(duration, seek, out):
|
|
||||||
"""
|
|
||||||
set new clip length
|
|
||||||
"""
|
|
||||||
return ['-t', str(out - seek)] if out < duration else []
|
|
||||||
|
|
||||||
|
|
||||||
def loop_input(source, src_duration, target_duration):
|
|
||||||
"""
|
|
||||||
loop files n times
|
|
||||||
"""
|
|
||||||
loop_count = math.ceil(target_duration / src_duration)
|
|
||||||
messenger.info(f'Loop "{source}" {loop_count} times, '
|
|
||||||
f'total duration: {target_duration:.2f}')
|
|
||||||
return ['-stream_loop', str(loop_count),
|
|
||||||
'-i', source, '-t', str(target_duration)]
|
|
||||||
|
|
||||||
|
|
||||||
def gen_dummy(duration):
|
|
||||||
"""
|
|
||||||
generate a dummy clip, with black color and empty audio track
|
|
||||||
"""
|
|
||||||
color = '#121212'
|
|
||||||
duration = round(duration, 3)
|
|
||||||
# IDEA: add noise could be an config option
|
|
||||||
# noise = 'noise=alls=50:allf=t+u,hue=s=0'
|
|
||||||
return [
|
|
||||||
'-f', 'lavfi', '-i',
|
|
||||||
f'color=c={color}:s={pre.w}x{pre.h}:d={duration}:r={pre.fps},'
|
|
||||||
'format=pix_fmts=yuv420p',
|
|
||||||
'-f', 'lavfi', '-i', f'anoisesrc=d={duration}:c=pink:r=48000:a=0.05'
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def gen_filler(node):
|
|
||||||
"""
|
|
||||||
generate filler clip to fill empty space in playlist
|
|
||||||
"""
|
|
||||||
probe = MediaProbe()
|
|
||||||
probe.load(storage.filler)
|
|
||||||
duration = node['out'] - node['seek']
|
|
||||||
|
|
||||||
node['probe'] = probe
|
|
||||||
|
|
||||||
if probe.format.get('duration'):
|
|
||||||
node['duration'] = probe.format['duration']
|
|
||||||
node['source'] = storage.filler
|
|
||||||
if node['duration'] > duration:
|
|
||||||
# cut filler
|
|
||||||
messenger.info(
|
|
||||||
f'Generate filler')
|
|
||||||
node['src_cmd'] = ['-i', storage.filler] + set_length(
|
|
||||||
node['duration'], 0, duration)
|
|
||||||
return node
|
|
||||||
|
|
||||||
# loop file n times
|
|
||||||
node['src_cmd'] = loop_input(storage.filler, node['duration'],
|
|
||||||
duration)
|
|
||||||
return node
|
|
||||||
|
|
||||||
# when no filler is set, generate a dummy
|
|
||||||
messenger.warning('No filler clipt is set! Add dummy...')
|
|
||||||
dummy = gen_dummy(duration)
|
|
||||||
node['source'] = dummy[3]
|
|
||||||
node['src_cmd'] = dummy
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def src_or_dummy(node):
|
|
||||||
"""
|
|
||||||
when source path exist, generate input with seek and out time
|
|
||||||
when path not exist, generate dummy clip
|
|
||||||
"""
|
|
||||||
|
|
||||||
probe = MediaProbe()
|
|
||||||
probe.load(node.get('source'))
|
|
||||||
node['probe'] = probe
|
|
||||||
|
|
||||||
# check if input is a remote source
|
|
||||||
if probe.is_remote and probe.video[0]:
|
|
||||||
if node['seek'] > 0.0:
|
|
||||||
messenger.warning(
|
|
||||||
f'Seek in remote source "{node.get("source")}" not supported!')
|
|
||||||
node['src_cmd'] = [
|
|
||||||
'-i', node['source']
|
|
||||||
] + set_length(86400, node['seek'], node['out'])
|
|
||||||
elif node.get('source') and Path(node['source']).is_file():
|
|
||||||
if probe.format.get('duration') and not math.isclose(
|
|
||||||
probe.format['duration'], node['duration'], abs_tol=3):
|
|
||||||
messenger.debug(
|
|
||||||
f"fix duration for: \"{node['source']}\" "
|
|
||||||
f"at \"{sec_to_time(node['begin'])}\"")
|
|
||||||
node['duration'] = probe.format['duration']
|
|
||||||
|
|
||||||
if node['out'] > node['duration']:
|
|
||||||
if node['seek'] > 0.0:
|
|
||||||
messenger.warning(
|
|
||||||
f'Seek in looped source "{node["source"]}" not supported!')
|
|
||||||
node['src_cmd'] = [
|
|
||||||
'-i', node['source']
|
|
||||||
] + set_length(node['duration'], node['seek'],
|
|
||||||
node['out'] - node['seek'])
|
|
||||||
else:
|
|
||||||
# when list starts with looped clip,
|
|
||||||
# the logo length will be wrong
|
|
||||||
node['src_cmd'] = loop_input(node['source'], node['duration'],
|
|
||||||
node['out'])
|
|
||||||
else:
|
|
||||||
node['src_cmd'] = seek_in(node['seek']) + \
|
|
||||||
['-i', node['source']] + set_length(node['duration'],
|
|
||||||
node['seek'], node['out'])
|
|
||||||
else:
|
|
||||||
if 'source' in node:
|
|
||||||
messenger.error(f'File not exist: {node.get("source")}')
|
|
||||||
node = gen_filler(node)
|
|
||||||
|
|
||||||
return node
|
|
@ -1,5 +0,0 @@
|
|||||||
colorama
|
|
||||||
pyyaml
|
|
||||||
requests
|
|
||||||
supervisor
|
|
||||||
watchdog
|
|
@ -1,2 +0,0 @@
|
|||||||
pytest
|
|
||||||
time-machine
|
|
@ -1,20 +0,0 @@
|
|||||||
attrs==21.2.0
|
|
||||||
certifi==2020.12.5
|
|
||||||
chardet==4.0.0
|
|
||||||
colorama==0.4.4
|
|
||||||
idna==2.10
|
|
||||||
iniconfig==1.1.1
|
|
||||||
packaging==20.9
|
|
||||||
pluggy==0.13.1
|
|
||||||
py==1.10.0
|
|
||||||
pyparsing==2.4.7
|
|
||||||
pytest==6.2.4
|
|
||||||
python-dateutil==2.8.1
|
|
||||||
PyYAML==5.4.1
|
|
||||||
requests==2.25.1
|
|
||||||
six==1.16.0
|
|
||||||
supervisor==4.2.2
|
|
||||||
time-machine==2.1.0
|
|
||||||
toml==0.10.2
|
|
||||||
urllib3==1.26.5
|
|
||||||
watchdog==2.1.2
|
|
@ -1,4 +0,0 @@
|
|||||||
Developer Content
|
|
||||||
-----
|
|
||||||
|
|
||||||
The content of this folder is not for normal usage, it contains test scripts for debugging purposes.
|
|
@ -1,129 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""
|
|
||||||
Test script, for testing different situations, like:
|
|
||||||
- different day_start times
|
|
||||||
- different situ where playlist is empty, not long enough or to long
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from threading import Thread
|
|
||||||
from time import sleep
|
|
||||||
from unittest.mock import patch
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
# from ffplayout import playlist
|
|
||||||
|
|
||||||
# set time zone
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
|
|
||||||
|
|
||||||
def run_at(time_tuple):
|
|
||||||
dt = datetime(*time_tuple, tzinfo=_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
@time_machine.travel(dt)
|
|
||||||
def run_in_time_machine():
|
|
||||||
desktop.output()
|
|
||||||
|
|
||||||
print(f'simulated date and time: {dt}\n')
|
|
||||||
|
|
||||||
run_in_time_machine()
|
|
||||||
|
|
||||||
|
|
||||||
def run_time(seconds):
|
|
||||||
"""
|
|
||||||
validate json values in new thread
|
|
||||||
and test if source paths exist
|
|
||||||
"""
|
|
||||||
def timer(seconds):
|
|
||||||
print(f'run test for {seconds} seconds...')
|
|
||||||
sleep(seconds)
|
|
||||||
terminate_processes()
|
|
||||||
print('terminated successfully')
|
|
||||||
|
|
||||||
terminator = Thread(name='timer', target=timer, args=(seconds,))
|
|
||||||
terminator.daemon = True
|
|
||||||
terminator.start()
|
|
||||||
|
|
||||||
|
|
||||||
def print_separater():
|
|
||||||
print('\n')
|
|
||||||
print(79 * '-')
|
|
||||||
print(79 * '-')
|
|
||||||
|
|
||||||
|
|
||||||
def shorten_playlist(file):
|
|
||||||
json_object = json.load(file)
|
|
||||||
del json_object['program'][-1:]
|
|
||||||
return json_object
|
|
||||||
|
|
||||||
|
|
||||||
def extend_playlist(file):
|
|
||||||
json_object = json.load(file)
|
|
||||||
elems = json_object['program'][:2]
|
|
||||||
json_object['program'].extend(elems)
|
|
||||||
return json_object
|
|
||||||
|
|
||||||
|
|
||||||
def clear_playlist(file):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
@patch('ffplayout.playlist.valid_json', shorten_playlist)
|
|
||||||
def run_with_less_elements(time_tuple):
|
|
||||||
run_at(time_tuple)
|
|
||||||
|
|
||||||
|
|
||||||
@patch('ffplayout.playlist.valid_json', extend_playlist)
|
|
||||||
def run_with_more_elements(time_tuple):
|
|
||||||
run_at(time_tuple)
|
|
||||||
|
|
||||||
|
|
||||||
@patch('ffplayout.playlist.valid_json', clear_playlist)
|
|
||||||
def run_with_no_elements(time_tuple):
|
|
||||||
run_at(time_tuple)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from ffplayout.output import desktop
|
|
||||||
from ffplayout.utils import playlist, terminate_processes
|
|
||||||
|
|
||||||
print('\ntest playlists, which are empty')
|
|
||||||
playlist.start = 0
|
|
||||||
run_time(140)
|
|
||||||
run_with_no_elements((2021, 2, 15, 23, 59, 53))
|
|
||||||
|
|
||||||
print_separater()
|
|
||||||
|
|
||||||
print('\ntest playlists, which are to short')
|
|
||||||
playlist.start = 0
|
|
||||||
run_time(140)
|
|
||||||
run_with_less_elements((2021, 2, 15, 23, 58, 3))
|
|
||||||
|
|
||||||
print_separater()
|
|
||||||
|
|
||||||
print('\ntest playlists, which are to long')
|
|
||||||
playlist.start = 0
|
|
||||||
run_time(140)
|
|
||||||
run_with_more_elements((2021, 2, 15, 23, 59, 33))
|
|
||||||
|
|
||||||
print_separater()
|
|
||||||
|
|
||||||
print('\ntest transition from playlists, with day_start at: 05:59:25')
|
|
||||||
playlist.start = 21575
|
|
||||||
run_time(140)
|
|
||||||
run_at((2021, 2, 17, 5, 58, 3))
|
|
||||||
|
|
||||||
print_separater()
|
|
||||||
|
|
||||||
print('\ntest transition from playlists, with day_start at: 20:00:00')
|
|
||||||
playlist.start = 72000
|
|
||||||
run_time(140)
|
|
||||||
run_at((2021, 2, 17, 19, 58, 23))
|
|
@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from time import sleep
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
|
|
||||||
# set time zone
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
# fake date and time
|
|
||||||
SOURCE_TIME = [2021, 2, 27, 23, 55, 0]
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*SOURCE_TIME, tzinfo=_TZ))
|
|
||||||
def main():
|
|
||||||
get_source = GetSourceIter()
|
|
||||||
|
|
||||||
for node in get_source.next():
|
|
||||||
messenger.info(f'Play: {node["source"]}')
|
|
||||||
# print(node)
|
|
||||||
sleep(node['out'] - node['seek'])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from ffplayout.player.playlist import GetSourceIter
|
|
||||||
from ffplayout.utils import messenger
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print('\n', end='')
|
|
@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""
|
|
||||||
Test script, for simulating different date and time.
|
|
||||||
This is useful for testing the transition from one playlist to another,
|
|
||||||
specially when the day_start time is in the night.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from importlib import import_module
|
|
||||||
from unittest.mock import patch
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
# set time zone
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
# fake date and time
|
|
||||||
SOURCE_TIME = [2022, 1, 5, 5, 57, 10]
|
|
||||||
FAKE_DELTA = -2.2
|
|
||||||
|
|
||||||
|
|
||||||
def fake_delta(node):
|
|
||||||
"""
|
|
||||||
override list init function for fake delta
|
|
||||||
"""
|
|
||||||
|
|
||||||
delta, total_delta = get_delta(node['begin'])
|
|
||||||
seek = abs(delta) + node['seek'] if abs(delta) + node['seek'] >= 1 else 0
|
|
||||||
seek = round(seek, 3)
|
|
||||||
|
|
||||||
seek += FAKE_DELTA
|
|
||||||
|
|
||||||
if node['out'] - seek > total_delta:
|
|
||||||
out = total_delta + seek
|
|
||||||
else:
|
|
||||||
out = node['out']
|
|
||||||
|
|
||||||
if out - seek > 1:
|
|
||||||
node['out'] = out
|
|
||||||
node['seek'] = seek
|
|
||||||
return src_or_dummy(node)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@patch('ffplayout.player.playlist.handle_list_init', fake_delta)
|
|
||||||
@time_machine.travel(datetime.datetime(*SOURCE_TIME, tzinfo=_TZ))
|
|
||||||
def run_in_time_machine():
|
|
||||||
if stdin_args.output:
|
|
||||||
output = import_module(f'ffplayout.output.{stdin_args.output}').output
|
|
||||||
output()
|
|
||||||
else:
|
|
||||||
desktop.output()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from ffplayout.output import desktop
|
|
||||||
from ffplayout.utils import get_delta, src_or_dummy, stdin_args
|
|
||||||
run_in_time_machine()
|
|
@ -1,68 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""
|
|
||||||
Test script, for simulating speed up the clock.
|
|
||||||
With the WARP_FACTOR you can transform a second to a fraction.
|
|
||||||
With this functionality it is possible to run a 24 hours playlist in a minute,
|
|
||||||
and debug the playlist reader.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
# set time zone
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
# fake date and time
|
|
||||||
SOURCE_TIME = [2021, 2, 12, 5, 0, 0]
|
|
||||||
USE_TIME_MACHINE = True
|
|
||||||
|
|
||||||
# warp time by factor
|
|
||||||
WARP_FACTOR = 1000
|
|
||||||
|
|
||||||
|
|
||||||
def warp_time():
|
|
||||||
get_source = GetSourceIter()
|
|
||||||
stamp = time.time()
|
|
||||||
duration = 0
|
|
||||||
with time_machine.travel(stamp, tick=False) as traveller:
|
|
||||||
for node in get_source.next():
|
|
||||||
duration = node['out'] - node['seek']
|
|
||||||
messenger.info(f'Play: "{node["source"]}"')
|
|
||||||
|
|
||||||
warp_duration = duration / WARP_FACTOR
|
|
||||||
messenger.debug(f'Original duration {duration} '
|
|
||||||
f'warped to {warp_duration:.3f}')
|
|
||||||
|
|
||||||
time.sleep(warp_duration)
|
|
||||||
stamp += duration
|
|
||||||
|
|
||||||
traveller.move_to(stamp)
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*SOURCE_TIME, tzinfo=_TZ))
|
|
||||||
def run_in_time_machine():
|
|
||||||
warp_time()
|
|
||||||
|
|
||||||
|
|
||||||
def run_in_time_warp():
|
|
||||||
warp_time()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from ffplayout.player.playlist import GetSourceIter
|
|
||||||
from ffplayout.utils import messenger
|
|
||||||
|
|
||||||
try:
|
|
||||||
if USE_TIME_MACHINE:
|
|
||||||
run_in_time_machine()
|
|
||||||
else:
|
|
||||||
warp_time()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print('Interrupted')
|
|
@ -1,138 +0,0 @@
|
|||||||
"""
|
|
||||||
test classes and functions in filters/default.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from ..ffplayout.filters.default import (add_audio, add_loudnorm,
|
|
||||||
custom_filter, deinterlace_filter,
|
|
||||||
extend_audio, extend_video,
|
|
||||||
fade_filter, fps_filter,
|
|
||||||
overlay_filter, pad_filter,
|
|
||||||
realtime_filter, scale_filter,
|
|
||||||
split_filter, text_filter)
|
|
||||||
from ..ffplayout.utils import lower_third, pre, sync_op
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_filter():
|
|
||||||
lower_third.add_text = True
|
|
||||||
lower_third.over_pre = True
|
|
||||||
lower_third.address = '127.0.0.1:5555'
|
|
||||||
lower_third.fontfile = ''
|
|
||||||
|
|
||||||
assert text_filter() == [
|
|
||||||
"null,zmq=b=tcp\\\\://'127.0.0.1:5555',drawtext=text=''"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_deinterlace_filter():
|
|
||||||
probe = SimpleNamespace(video=[{'field_order': 'tff'}])
|
|
||||||
|
|
||||||
assert deinterlace_filter(probe) == ['yadif=0:-1:0']
|
|
||||||
|
|
||||||
|
|
||||||
def test_pad_filter():
|
|
||||||
probe = SimpleNamespace(video=[{'aspect': 1.333}])
|
|
||||||
|
|
||||||
assert pad_filter(probe) == ['pad=ih*1024/576/sar:ih:(ow-iw)/2:(oh-ih)/2']
|
|
||||||
|
|
||||||
|
|
||||||
def test_fps_filter():
|
|
||||||
probe = SimpleNamespace(video=[{'fps': 29.97}])
|
|
||||||
|
|
||||||
assert fps_filter(probe) == ['fps=25']
|
|
||||||
|
|
||||||
|
|
||||||
def test_scale_filter():
|
|
||||||
probe = SimpleNamespace(video=[{'width': 1440, 'height': 1080,
|
|
||||||
'aspect': 1.333}])
|
|
||||||
|
|
||||||
assert scale_filter(probe) == ['scale=1024:576', 'setdar=dar=1.778']
|
|
||||||
|
|
||||||
|
|
||||||
def test_fade_filter():
|
|
||||||
assert fade_filter(300, 5, 300) == ['fade=in:st=0:d=0.5']
|
|
||||||
assert fade_filter(300, 5, 300, 'a') == ['afade=in:st=0:d=0.5']
|
|
||||||
assert fade_filter(300, 0, 200) == ['fade=out:st=199.0:d=1.0']
|
|
||||||
assert fade_filter(300, 0, 200, 'a') == ['afade=out:st=199.0:d=1.0']
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_filter():
|
|
||||||
assert overlay_filter(300, True, False, False) == '[v]null'
|
|
||||||
assert overlay_filter(300, False, True, False) == (
|
|
||||||
'movie=docs/logo.png,loop=loop=-1:size=1:start=0,format=rgba,'
|
|
||||||
'colorchannelmixer=aa=0.7,fade=in:st=0:d=1.0:alpha=1[l];'
|
|
||||||
'[v][l]overlay=W-w-12:12:shortest=1')
|
|
||||||
assert overlay_filter(300, False, False, True) == (
|
|
||||||
'movie=docs/logo.png,loop=loop=-1:size=1:start=0,format=rgba,'
|
|
||||||
'colorchannelmixer=aa=0.7,fade=out:st=299:d=1.0:alpha=1[l];'
|
|
||||||
'[v][l]overlay=W-w-12:12:shortest=1')
|
|
||||||
assert overlay_filter(300, False, False, False) == (
|
|
||||||
'movie=docs/logo.png,loop=loop=-1:size=1:start=0,format=rgba,'
|
|
||||||
'colorchannelmixer=aa=0.7[l];[v][l]overlay=W-w-12:12:shortest=1')
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_audio():
|
|
||||||
probe = SimpleNamespace(audio=False, src='/path/file.mp4')
|
|
||||||
|
|
||||||
assert add_audio(probe, 300) == [
|
|
||||||
('aevalsrc=0:channel_layout=stereo:duration=300:sample_rate=48000')]
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_loudnorm():
|
|
||||||
pre.add_loudnorm = True
|
|
||||||
pre.loud_i = -18
|
|
||||||
pre.loud_tp = -1.5
|
|
||||||
pre.loud_lra = 11
|
|
||||||
probe = SimpleNamespace(audio=True)
|
|
||||||
|
|
||||||
assert add_loudnorm(probe) == ['loudnorm=I=-18:TP=-1.5:LRA=11']
|
|
||||||
|
|
||||||
|
|
||||||
def test_extend_audio():
|
|
||||||
probe = SimpleNamespace(audio=[{'duration': 299}])
|
|
||||||
|
|
||||||
assert extend_audio(probe, 300, 0) == ['apad=whole_dur=300']
|
|
||||||
assert extend_audio(probe, 300, 10) == ['apad=whole_dur=290']
|
|
||||||
|
|
||||||
|
|
||||||
def test_extend_video():
|
|
||||||
probe = SimpleNamespace(video=[{'duration': 299}])
|
|
||||||
|
|
||||||
assert extend_video(probe, 300, 0) == [
|
|
||||||
'tpad=stop_mode=add:stop_duration=1.0']
|
|
||||||
|
|
||||||
assert extend_video(probe, 300, 10) == [
|
|
||||||
'tpad=stop_mode=add:stop_duration=1.0']
|
|
||||||
|
|
||||||
|
|
||||||
def test_realtime_filter():
|
|
||||||
sync_op.realtime = True
|
|
||||||
|
|
||||||
assert realtime_filter(300) == ',realtime=speed=1'
|
|
||||||
assert realtime_filter(300, 'a') == ',arealtime=speed=1'
|
|
||||||
|
|
||||||
sync_op.time_delta = -1.0
|
|
||||||
|
|
||||||
assert realtime_filter(300) == ',realtime=speed=1.0033444816053512'
|
|
||||||
|
|
||||||
|
|
||||||
def test_split_filter():
|
|
||||||
pre.output_count = 1
|
|
||||||
assert split_filter('v') == '[vout1]'
|
|
||||||
|
|
||||||
pre.output_count = 3
|
|
||||||
assert split_filter('v') == ',split=3[vout1][vout2][vout3]'
|
|
||||||
assert split_filter('a') == ',asplit=3[aout1][aout2][aout3]'
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
@patch('argparse.ArgumentParser.parse_args', return_value=argparse.Namespace(
|
|
||||||
config='', start='', length='', log='', output='', play_mode='',
|
|
||||||
playlist='', loop='', volume='0.001'))
|
|
||||||
def test_custom_filter(*args):
|
|
||||||
sys.path.append('')
|
|
||||||
# lower_third.fontfile = ''
|
|
||||||
assert custom_filter('a', None) == ['volume=0.001']
|
|
@ -1,88 +0,0 @@
|
|||||||
"""
|
|
||||||
test classes and functions in playlist.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
from ..ffplayout.player.playlist import (handle_list_end, handle_list_init,
|
|
||||||
timed_source)
|
|
||||||
from ..ffplayout.utils import playlist, storage
|
|
||||||
|
|
||||||
# set time zone
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 31, 6, 0, 20], tzinfo=_TZ))
|
|
||||||
def test_handle_list_init():
|
|
||||||
playlist.start = 6 * 60 * 60
|
|
||||||
storage.filler = ''
|
|
||||||
node = {'source': '/store/file.mp4', 'begin': 21620,
|
|
||||||
'in': 0, 'out': 300, 'duration': 300, 'seek': 20}
|
|
||||||
|
|
||||||
color_src = ('color=c=#121212:s=1024x576:d=280.0:r=25,'
|
|
||||||
'format=pix_fmts=yuv420p')
|
|
||||||
|
|
||||||
list_init = handle_list_init(node)
|
|
||||||
list_init.pop('probe')
|
|
||||||
check_result = {
|
|
||||||
'source': color_src,
|
|
||||||
'begin': 21620, 'in': 0, 'out': 300,
|
|
||||||
'duration': 300, 'seek': 20.0,
|
|
||||||
'src_cmd': [
|
|
||||||
'-f', 'lavfi', '-i', color_src,
|
|
||||||
'-f', 'lavfi', '-i', 'anoisesrc=d=280.0:c=pink:r=48000:a=0.05']}
|
|
||||||
|
|
||||||
assert list_init == check_result
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 31, 5, 59, 30], tzinfo=_TZ))
|
|
||||||
def test_handle_list_end():
|
|
||||||
playlist.start = 6 * 60 * 60
|
|
||||||
storage.filler = ''
|
|
||||||
|
|
||||||
node = {'source': '/store/file.mp4', 'begin': 24 * 3600 - 30,
|
|
||||||
'in': 0, 'out': 300, 'duration': 300, 'seek': 0}
|
|
||||||
|
|
||||||
color_src = ('color=c=#121212:s=1024x576:d=30:r=25,'
|
|
||||||
'format=pix_fmts=yuv420p')
|
|
||||||
|
|
||||||
check_result = {
|
|
||||||
'source': color_src,
|
|
||||||
'begin': 24 * 3600 - 30, 'in': 0, 'out': 30,
|
|
||||||
'duration': 300, 'seek': 0,
|
|
||||||
'src_cmd': [
|
|
||||||
'-f', 'lavfi', '-i', color_src,
|
|
||||||
'-f', 'lavfi', '-i', 'anoisesrc=d=30:c=pink:r=48000:a=0.05']}
|
|
||||||
|
|
||||||
list_end = handle_list_end(30, node)
|
|
||||||
list_end.pop('probe')
|
|
||||||
|
|
||||||
assert list_end == check_result
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 31, 5, 50, 00], tzinfo=_TZ))
|
|
||||||
def test_timed_source():
|
|
||||||
playlist.start = 6 * 60 * 60
|
|
||||||
storage.filler = ''
|
|
||||||
|
|
||||||
node = {'source': '/store/file.mp4', 'begin': 24 * 3600 + 21600 - 600,
|
|
||||||
'in': 0, 'out': 300, 'duration': 300, 'seek': 0}
|
|
||||||
|
|
||||||
color_src = ('color=c=#121212:s=1024x576:d=300:r=25,'
|
|
||||||
'format=pix_fmts=yuv420p')
|
|
||||||
|
|
||||||
check_result = {
|
|
||||||
'source': color_src,
|
|
||||||
'begin': 24 * 3600 + 21600 - 600, 'in': 0, 'out': 300,
|
|
||||||
'duration': 300, 'seek': 0,
|
|
||||||
'src_cmd': [
|
|
||||||
'-f', 'lavfi', '-i', color_src,
|
|
||||||
'-f', 'lavfi', '-i', 'anoisesrc=d=300:c=pink:r=48000:a=0.05']}
|
|
||||||
|
|
||||||
src = timed_source(node, False)
|
|
||||||
src.pop('probe')
|
|
||||||
|
|
||||||
assert src == check_result
|
|
@ -1,121 +0,0 @@
|
|||||||
"""
|
|
||||||
test classes and functions in utils.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
from ..ffplayout.utils import (gen_dummy, gen_filler, get_date, get_delta,
|
|
||||||
get_float, is_advertisement, loop_input,
|
|
||||||
playlist, pre, seek_in, set_length,
|
|
||||||
src_or_dummy, storage, str_to_sec)
|
|
||||||
|
|
||||||
# set time zone
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
|
|
||||||
|
|
||||||
def test_str_to_sec():
|
|
||||||
assert str_to_sec('06:00:00') == 21600
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 26, 15, 30, 5], tzinfo=_TZ))
|
|
||||||
def test_get_delta():
|
|
||||||
playlist.start = 0
|
|
||||||
current, total = get_delta(15 * 3600 + 30 * 60 + 1)
|
|
||||||
assert current == -4
|
|
||||||
assert total == 8 * 3600 + 29 * 60 + 55
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 26, 0, 0, 0], tzinfo=_TZ))
|
|
||||||
def test_playlist_start_zero():
|
|
||||||
playlist.start = 0
|
|
||||||
assert get_date(False, 24 * 60 * 60 + 1) == '2021-05-27'
|
|
||||||
assert get_date(False, 24 * 60 * 60 - 1) == '2021-05-26'
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 26, 5, 59, 59], tzinfo=_TZ))
|
|
||||||
def test_playlist_start_six_before():
|
|
||||||
playlist.start = 6 * 60 * 60
|
|
||||||
assert get_date(True) == '2021-05-25'
|
|
||||||
assert get_date(False) == '2021-05-26'
|
|
||||||
|
|
||||||
|
|
||||||
@time_machine.travel(datetime.datetime(*[2021, 5, 26, 6, 0, 0], tzinfo=_TZ))
|
|
||||||
def test_playlist_start_six_after():
|
|
||||||
playlist.start = 6 * 60 * 60
|
|
||||||
assert get_date(False) == '2021-05-26'
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_float():
|
|
||||||
assert get_float('5') == 5
|
|
||||||
assert get_float('5', None) == 5.0
|
|
||||||
assert get_float('5a', None) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_advertisement():
|
|
||||||
assert is_advertisement({'category': 'advertisement'}) is True
|
|
||||||
assert is_advertisement({'category': ''}) is False
|
|
||||||
assert is_advertisement({}) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_seek_in():
|
|
||||||
assert seek_in(10) == ['-ss', '10']
|
|
||||||
assert seek_in(0) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_length():
|
|
||||||
assert set_length(300, 50, 200) == ['-t', '150']
|
|
||||||
assert set_length(300, 0, 300) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_loop_input():
|
|
||||||
assert loop_input('/store/file.mp4', 300, 450) == ['-stream_loop', '2',
|
|
||||||
'-i', '/store/file.mp4',
|
|
||||||
'-t', '450']
|
|
||||||
|
|
||||||
|
|
||||||
def test_gen_dummy():
|
|
||||||
pre.w = 1024
|
|
||||||
pre.h = 576
|
|
||||||
pre.fps = 25
|
|
||||||
assert gen_dummy(30) == ['-f', 'lavfi', '-i',
|
|
||||||
'color=c=#121212:s=1024x576:d=30:r=25,'
|
|
||||||
'format=pix_fmts=yuv420p', '-f', 'lavfi',
|
|
||||||
'-i', 'anoisesrc=d=30:c=pink:r=48000:a=0.05']
|
|
||||||
|
|
||||||
|
|
||||||
def test_gen_filler():
|
|
||||||
storage.filler = ''
|
|
||||||
color_src = 'color=c=#121212:s=1024x576:d=300:r=25,format=pix_fmts=yuv420p'
|
|
||||||
source = gen_filler({'source': '/store/file.mp4',
|
|
||||||
'in': 0, 'out': 300, 'duration': 300, 'seek': 0})
|
|
||||||
filler = {'duration': 300, 'in': 0, 'out': 300, 'seek': 0,
|
|
||||||
'source': color_src,
|
|
||||||
'src_cmd': [
|
|
||||||
'-f', 'lavfi', '-i', color_src,
|
|
||||||
'-f', 'lavfi', '-i', 'anoisesrc=d=300:c=pink:r=48000:a=0.05']
|
|
||||||
}
|
|
||||||
|
|
||||||
source.pop('probe')
|
|
||||||
|
|
||||||
assert source == filler
|
|
||||||
|
|
||||||
|
|
||||||
def test_src_or_dummy():
|
|
||||||
storage.filler = ''
|
|
||||||
color_src = 'color=c=#121212:s=1024x576:d=300:r=25,format=pix_fmts=yuv420p'
|
|
||||||
source = src_or_dummy({'source': '/store/file.mp4',
|
|
||||||
'in': 0, 'out': 300, 'duration': 300, 'seek': 0})
|
|
||||||
|
|
||||||
dummy = {'duration': 300, 'in': 0, 'out': 300, 'seek': 0,
|
|
||||||
'source': color_src,
|
|
||||||
'src_cmd': [
|
|
||||||
'-f', 'lavfi', '-i', color_src,
|
|
||||||
'-f', 'lavfi', '-i', 'anoisesrc=d=300:c=pink:r=48000:a=0.05']}
|
|
||||||
|
|
||||||
source.pop('probe')
|
|
||||||
|
|
||||||
assert source == dummy
|
|
Loading…
x
Reference in New Issue
Block a user