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.
|
||||
|
||||
**Desktop/Server/Software (please complete the following information):**
|
||||
- OS: [e.g. debian 10]
|
||||
- OS: [e.g. debian 11]
|
||||
- python version
|
||||
- ffmpeg version
|
||||
- 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…
Reference in New Issue
Block a user