mirror of
https://github.com/joshuaboniface/rffmpeg.git
synced 2026-07-28 12:36:55 +00:00
841 lines
29 KiB
Python
Executable file
841 lines
29 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
# rffmpeg.py - Remote FFMPEG transcoding wrapper
|
|
#
|
|
# Copyright (C) 2019-2022 Joshua M. Boniface <joshua@boniface.me>
|
|
# and Contributors.
|
|
#
|
|
# 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/>.
|
|
#
|
|
###############################################################################
|
|
|
|
import click
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import yaml
|
|
|
|
from contextlib import contextmanager
|
|
from grp import getgrnam
|
|
from pathlib import Path
|
|
from pwd import getpwnam
|
|
from re import search
|
|
from sqlite3 import connect as sqlite_connect
|
|
from subprocess import run, PIPE
|
|
from time import sleep
|
|
|
|
|
|
# Set up the logger
|
|
log = logging.getLogger("rffmpeg")
|
|
|
|
|
|
# Open a database connection (context manager)
|
|
@contextmanager
|
|
def dbconn(config):
|
|
"""
|
|
Open a database connection.
|
|
"""
|
|
conn = sqlite_connect(config["db_path"])
|
|
conn.execute("PRAGMA foreign_keys = 1")
|
|
cur = conn.cursor()
|
|
yield cur
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def fail(msg):
|
|
"""
|
|
Output an error message and terminate.
|
|
"""
|
|
log.error(msg)
|
|
exit(1)
|
|
|
|
|
|
def load_config():
|
|
"""
|
|
Parse the YAML configuration file (either /etc/rffmpeg/rffmpeg.yml or specified by the envvar
|
|
RFFMPEG_CONFIG) and return a standard dictionary of configuration values.
|
|
"""
|
|
|
|
default_config_file = "/etc/rffmpeg/rffmpeg.yml"
|
|
config_file = os.environ.get("RFFMPEG_CONFIG", default_config_file)
|
|
|
|
with open(config_file, "r") as cfgfh:
|
|
try:
|
|
o_config = yaml.load(cfgfh, Loader=yaml.SafeLoader)
|
|
except Exception as e:
|
|
fail("Failed to parse configuration file: {}".format(e))
|
|
|
|
config = dict()
|
|
|
|
# Parse the base group ("rffmpeg")
|
|
config_base = o_config.get("rffmpeg", dict())
|
|
if not config_base:
|
|
fail("Failed to parse configuration file top level key 'rffmpeg'.")
|
|
|
|
# Parse the logging group ("rffmpeg" -> "logging")
|
|
config_logging = config_base.get("logging", dict())
|
|
if config_logging is None:
|
|
config_logging = dict()
|
|
|
|
# Parse the directories group ("rffmpeg" -> "directories")
|
|
config_directories = config_base.get("directories", dict())
|
|
if config_directories is None:
|
|
config_directories = dict()
|
|
|
|
# Parse the remote group ("rffmpeg" -> "remote")
|
|
config_remote = config_base.get("remote", dict())
|
|
if config_remote is None:
|
|
config_remote = dict()
|
|
|
|
# Parse the commands group ("rffmpeg" -> "commands")
|
|
config_commands = config_base.get("commands", dict())
|
|
if config_commands is None:
|
|
config_commands = dict()
|
|
|
|
# Parse the keys from the logging group
|
|
config["log_to_file"] = config_logging.get("log_to_file", True)
|
|
config["logfile"] = config_logging.get("logfile", "/var/log/jellyfin/rffmpeg.log")
|
|
config["logdebug"] = config_logging.get("debug", False)
|
|
|
|
# Parse the keys from the state group
|
|
config["state_dir"] = config_directories.get("state", "/var/lib/rffmpeg")
|
|
config["persist_dir"] = config_directories.get("persist", "/run/shm")
|
|
config["dir_owner"] = config_directories.get("owner", "jellyfin")
|
|
config["dir_group"] = config_directories.get("group", "sudo")
|
|
|
|
# Parse the keys from the remote group
|
|
config["remote_user"] = config_remote.get("user", "jellyfin")
|
|
config["remote_args"] = config_remote.get(
|
|
"args", ["-i", "/var/lib/jellyfin/.ssh/id_rsa"]
|
|
)
|
|
if config["remote_args"] is None:
|
|
config["remote_args"] = []
|
|
config["persist_time"] = config_remote.get("persist", 300)
|
|
|
|
# Parse the keys from the commands group
|
|
config["ssh_command"] = config_commands.get("ssh", "/usr/bin/ssh")
|
|
config["pre_commands"] = config_commands.get("pre", [])
|
|
if config["pre_commands"] is None:
|
|
config["pre_commands"] = []
|
|
config["ffmpeg_command"] = config_commands.get(
|
|
"ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"
|
|
)
|
|
config["ffprobe_command"] = config_commands.get(
|
|
"ffprobe", "/usr/lib/jellyfin-ffmpeg/ffprobe"
|
|
)
|
|
config["fallback_ffmpeg_command"] = config_commands.get(
|
|
"fallback_ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"
|
|
)
|
|
config["fallback_ffprobe_command"] = config_commands.get(
|
|
"fallback_ffprobe", "/usr/lib/jellyfin-ffmpeg/ffprobe"
|
|
)
|
|
|
|
# Set the database path
|
|
config["db_path"] = config["state_dir"] + "/rffmpeg.db"
|
|
|
|
# Set a list of special flags that cause different behaviour
|
|
config["special_flags"] = [
|
|
"-version",
|
|
"-encoders",
|
|
"-decoders",
|
|
"-hwaccels",
|
|
"-filters",
|
|
"-h",
|
|
]
|
|
|
|
# Set the current PID of this process
|
|
config["current_pid"] = os.getpid()
|
|
|
|
return config
|
|
|
|
|
|
def cleanup(signum="", frame=""):
|
|
"""
|
|
Clean up this processes stored transient data.
|
|
"""
|
|
global config, p
|
|
|
|
with dbconn(config) as cur:
|
|
cur.execute("DELETE FROM states WHERE process_id = ?", (config["current_pid"],))
|
|
cur.execute(
|
|
"DELETE FROM processes WHERE process_id = ?", (config["current_pid"],)
|
|
)
|
|
|
|
|
|
def generate_ssh_command(config, target_hostname):
|
|
"""
|
|
Generate an SSH command for use.
|
|
"""
|
|
ssh_command = list()
|
|
|
|
# Add SSH component
|
|
ssh_command.append(config["ssh_command"])
|
|
ssh_command.append("-q")
|
|
ssh_command.append("-t")
|
|
|
|
# Set our connection details
|
|
ssh_command.extend(["-o", "ConnectTimeout=1"])
|
|
ssh_command.extend(["-o", "ConnectionAttempts=1"])
|
|
ssh_command.extend(["-o", "StrictHostKeyChecking=no"])
|
|
ssh_command.extend(["-o", "UserKnownHostsFile=/dev/null"])
|
|
|
|
# Use SSH control persistence to keep sessions alive for subsequent commands
|
|
if config["persist_time"] > 0:
|
|
ssh_command.extend(["-o", "ControlMaster=auto"])
|
|
ssh_command.extend(
|
|
["-o", "ControlPath={}/ssh-%r@%h:%p".format(config["persist_dir"])]
|
|
)
|
|
ssh_command.extend(["-o", "ControlPersist={}".format(config["persist_time"])])
|
|
|
|
# Add the remote config args
|
|
for arg in config["remote_args"]:
|
|
if arg:
|
|
ssh_command.append(arg)
|
|
|
|
# Add user+host string
|
|
ssh_command.append("{}@{}".format(config["remote_user"], target_hostname))
|
|
|
|
return ssh_command
|
|
|
|
|
|
def run_command(command, stdin, stdout, stderr):
|
|
"""
|
|
Execute the command using subprocess.
|
|
"""
|
|
p = run(
|
|
command,
|
|
shell=False,
|
|
bufsize=0,
|
|
universal_newlines=True,
|
|
stdin=stdin,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
)
|
|
return p
|
|
|
|
|
|
def get_target_host(config):
|
|
"""
|
|
Determine an optimal target host via data on currently active processes and states.
|
|
"""
|
|
log.debug("Determining optimal target host")
|
|
# Select all hosts and active processes from the database
|
|
with dbconn(config) as cur:
|
|
hosts = cur.execute("SELECT * FROM hosts").fetchall()
|
|
processes = cur.execute("SELECT * FROM processes").fetchall()
|
|
|
|
# Generate a mapping dictionary of hosts and processes
|
|
host_mappings = dict()
|
|
for host in hosts:
|
|
hid, hostname, weight, servername = host
|
|
|
|
# Get the latest state
|
|
with dbconn(config) as cur:
|
|
current_state = cur.execute(
|
|
"SELECT * FROM states WHERE host_id = ? ORDER BY id DESC", (hid,)
|
|
).fetchone()
|
|
|
|
if not current_state:
|
|
current_state = "idle"
|
|
marking_pid = 'N/A'
|
|
else:
|
|
current_state = current_state[3]
|
|
marking_pid = current_state[2]
|
|
|
|
# Create the mappings entry
|
|
host_mappings[hid] = {
|
|
"hostname": hostname,
|
|
"weight": weight,
|
|
"servername": servername,
|
|
"current_state": current_state,
|
|
"marking_pid": marking_pid,
|
|
"commands": [proc[2] for proc in processes if proc[1] == hid],
|
|
}
|
|
|
|
lowest_count = 9999
|
|
target_hid = None
|
|
target_hostname = None
|
|
# For each host in the mapping, let's determine if it is suitable
|
|
for hid, host in host_mappings.items():
|
|
log.debug("Trying host ID {} '{}'".format(hid, host["hostname"]))
|
|
# If it's marked as bad, continue
|
|
if host["current_state"] == "bad":
|
|
log.debug("Host previously marked bad by PID {}".format(host['marking_pid']))
|
|
continue
|
|
|
|
# Try to connect to the host and run a very quick command to determine if it is workable
|
|
if host["hostname"] not in ["localhost", "127.0.0.1"]:
|
|
log.debug("Running SSH test")
|
|
test_ssh_command = generate_ssh_command(config, host["hostname"])
|
|
test_ssh_command.remove("-q")
|
|
test_ffmpeg_command = [config["ffmpeg_command"], "-version"]
|
|
ret = run_command(test_ssh_command + test_ffmpeg_command, PIPE, PIPE, PIPE)
|
|
if ret.returncode != 0:
|
|
# Mark the host as bad
|
|
log.warning(
|
|
"Marking host {} ({}) as bad due to retcode {}".format(
|
|
host["hostname"], host["servername"], ret.returncode
|
|
)
|
|
)
|
|
log.debug(
|
|
"SSH test command was: {}".format(
|
|
" ".join(test_ssh_command + test_ffmpeg_command)
|
|
)
|
|
)
|
|
log.debug(
|
|
"SSH test command stdout: {}".format(ret.stdout)
|
|
)
|
|
log.debug(
|
|
"SSH test command stderr: {}".format(ret.stderr)
|
|
)
|
|
with dbconn(config) as cur:
|
|
cur.execute(
|
|
"INSERT INTO states (host_id, process_id, state) VALUES (?, ?, ?)",
|
|
(hid, config["current_pid"], "bad"),
|
|
)
|
|
continue
|
|
log.debug("SSH test succeeded with retcode {}".format(ret.returncode))
|
|
|
|
# If the host state is idle, we can use it immediately
|
|
if host["current_state"] == "idle":
|
|
target_hid = hid
|
|
target_hostname = host["hostname"]
|
|
target_servername = host["servername"]
|
|
log.debug("Selecting host as idle")
|
|
break
|
|
|
|
# Get the modified count of the host
|
|
raw_proc_count = len(host["commands"])
|
|
weighted_proc_count = raw_proc_count // host["weight"]
|
|
|
|
# If this host is currently the least used, provisionally set it as the target
|
|
if weighted_proc_count < lowest_count:
|
|
lowest_count = weighted_proc_count
|
|
target_hid = hid
|
|
target_hostname = host["hostname"]
|
|
log.debug("Selecting host as current lowest proc count (raw count: {}, weighted count: {})".format(raw_proc_count, weighted_proc_count))
|
|
|
|
log.debug("Found optimal host ID {} '{}' ({})".format(target_hid, target_hostname, target_servername))
|
|
return target_hid, target_hostname, target_servername
|
|
|
|
|
|
def run_local_ffmpeg(config, ffmpeg_args):
|
|
"""
|
|
Run ffmpeg locally, either because "localhost" is the target host, or because no good target
|
|
host was found by get_target_host().
|
|
"""
|
|
rffmpeg_ffmpeg_command = list()
|
|
|
|
# Prepare our default stdin/stdout/stderr
|
|
stdin = sys.stdin
|
|
stderr = sys.stderr
|
|
|
|
if "ffprobe" in cmd_name:
|
|
# If we're in ffprobe mode use that command and sys.stdout as stdout
|
|
rffmpeg_ffmpeg_command.append(config["fallback_ffprobe_command"])
|
|
stdout = sys.stdout
|
|
else:
|
|
# Otherwise, we use stderr as stdout
|
|
rffmpeg_ffmpeg_command.append(config["fallback_ffmpeg_command"])
|
|
stdout = sys.stderr
|
|
|
|
# Check for special flags that override the default stdout
|
|
if any(item in config["special_flags"] for item in ffmpeg_args):
|
|
stdout = sys.stdout
|
|
|
|
# Append all the passed arguments directly
|
|
for arg in ffmpeg_args:
|
|
rffmpeg_ffmpeg_command.append("{}".format(arg))
|
|
|
|
log.info("Running command on host 'localhost'")
|
|
log.debug("Local command: {}".format(" ".join(rffmpeg_ffmpeg_command)))
|
|
|
|
with dbconn(config) as cur:
|
|
cur.execute(
|
|
"INSERT INTO processes (host_id, process_id, cmd) VALUES (?, ?, ?)",
|
|
(0, config["current_pid"], cmd_name + " " + " ".join(ffmpeg_args)),
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO states (host_id, process_id, state) VALUES (?, ?, ?)",
|
|
(0, config["current_pid"], "active"),
|
|
)
|
|
|
|
return run_command(rffmpeg_ffmpeg_command, stdin, stdout, stderr)
|
|
|
|
|
|
def run_remote_ffmpeg(config, target_hid, target_hostname, target_servername, ffmpeg_args):
|
|
"""
|
|
Run ffmpeg against the remote target_hostname.
|
|
"""
|
|
rffmpeg_ssh_command = generate_ssh_command(config, target_hostname)
|
|
rffmpeg_ffmpeg_command = list()
|
|
|
|
# Add any pre commands
|
|
for cmd in config["pre_commands"]:
|
|
if cmd:
|
|
rffmpeg_ffmpeg_command.append(cmd)
|
|
|
|
# Prepare our default stdin/stderr
|
|
stdin = sys.stdin
|
|
stderr = sys.stderr
|
|
|
|
if "ffprobe" in cmd_name:
|
|
# If we're in ffprobe mode use that command and sys.stdout as stdout
|
|
rffmpeg_ffmpeg_command.append(config["ffprobe_command"])
|
|
stdout = sys.stdout
|
|
else:
|
|
# Otherwise, we use stderr as stdout
|
|
rffmpeg_ffmpeg_command.append(config["ffmpeg_command"])
|
|
stdout = sys.stderr
|
|
|
|
# Check for special flags that override the default stdout
|
|
if any(item in config["special_flags"] for item in ffmpeg_args):
|
|
stdout = sys.stdout
|
|
|
|
# Append all the passed arguments with requoting of any problematic characters
|
|
for arg in ffmpeg_args:
|
|
# Match bad shell characters: * ' ( ) | [ ] or whitespace
|
|
if search("[*'()|\[\]\s]", arg):
|
|
rffmpeg_ffmpeg_command.append('"{}"'.format(arg))
|
|
else:
|
|
rffmpeg_ffmpeg_command.append("{}".format(arg))
|
|
|
|
log.info("Running command on host '{}' ({})".format(target_hostname, target_servername))
|
|
log.debug(
|
|
"Remote command: {}".format(
|
|
" ".join(rffmpeg_ssh_command + rffmpeg_ffmpeg_command)
|
|
)
|
|
)
|
|
|
|
with dbconn(config) as cur:
|
|
cur.execute(
|
|
"INSERT INTO processes (host_id, process_id, cmd) VALUES (?, ?, ?)",
|
|
(target_hid, config["current_pid"], cmd_name + " " + " ".join(ffmpeg_args)),
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO states (host_id, process_id, state) VALUES (?, ?, ?)",
|
|
(target_hid, config["current_pid"], "active"),
|
|
)
|
|
|
|
return run_command(
|
|
rffmpeg_ssh_command + rffmpeg_ffmpeg_command, stdin, stdout, stderr
|
|
)
|
|
|
|
|
|
def run_ffmpeg(config, ffmpeg_args):
|
|
"""
|
|
Entrypoint for an ffmpeg/ffprobe aliased process.
|
|
"""
|
|
signal.signal(signal.SIGTERM, cleanup)
|
|
signal.signal(signal.SIGINT, cleanup)
|
|
signal.signal(signal.SIGQUIT, cleanup)
|
|
signal.signal(signal.SIGKILL, cleanup)
|
|
signal.signal(signal.SIGHUP, cleanup)
|
|
|
|
if config["logdebug"] is True:
|
|
logging_level = logging.DEBUG
|
|
else:
|
|
logging_level = logging.INFO
|
|
|
|
if config["log_to_file"]:
|
|
logging.basicConfig(
|
|
filename=config["logfile"],
|
|
level=logging_level,
|
|
format="%(asctime)s - %(name)s[%(process)s] - %(levelname)s - %(message)s",
|
|
)
|
|
else:
|
|
logging.basicConfig(
|
|
level=logging_level,
|
|
format="%(asctime)s - %(name)s[%(process)s] - %(levelname)s - %(message)s",
|
|
)
|
|
|
|
log.info("Starting rffmpeg as {} with args: {}".format(cmd_name, " ".join(ffmpeg_args)))
|
|
|
|
target_hid, target_hostname, target_servername = get_target_host(config)
|
|
|
|
if not target_hostname or target_hostname == "localhost":
|
|
ret = run_local_ffmpeg(config, ffmpeg_args)
|
|
else:
|
|
ret = run_remote_ffmpeg(config, target_hid, target_hostname, target_servername, ffmpeg_args)
|
|
|
|
cleanup()
|
|
if ret.returncode == 0:
|
|
log.info("Finished rffmpeg with return code {}".format(ret.returncode))
|
|
else:
|
|
log.error("Finished rffmpeg with return code {}".format(ret.returncode))
|
|
exit(ret.returncode)
|
|
|
|
|
|
def run_control(config):
|
|
"""
|
|
Entrypoint for the Click CLI for managing the rffmpeg system.
|
|
"""
|
|
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], max_content_width=120)
|
|
|
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
def rffmpeg_click():
|
|
"""
|
|
rffmpeg CLI interface
|
|
"""
|
|
# List all DB migrations here
|
|
did_alter_0001AddServername = False
|
|
# Check conditions for migrations
|
|
with dbconn(config) as cur:
|
|
# Migration for new servername (PR #36)
|
|
if cur.execute("SELECT COUNT(*) AS CNTREC FROM pragma_table_info('hosts') WHERE name='servername'").fetchone()[0] == 0:
|
|
cur.execute(
|
|
"ALTER TABLE hosts ADD servername TEXT NOT NULL DEFAULT 'invalid'"
|
|
)
|
|
did_alter_0001AddServername = True
|
|
# Migration for new servername (PR #36)
|
|
if did_alter_0001AddServername:
|
|
with dbconn(config) as cur:
|
|
for host in cur.execute("SELECT * FROM hosts").fetchall():
|
|
if host[3] == 'invalid':
|
|
cur.execute(
|
|
"UPDATE hosts SET servername = ? WHERE hostname = ?", (host[1], host[1])
|
|
)
|
|
|
|
@click.command(name="init", short_help="Initialize the system.")
|
|
@click.option(
|
|
"-y",
|
|
"--yes",
|
|
"confirm_flag",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Confirm initialization.",
|
|
)
|
|
def rffmpeg_click_init(confirm_flag):
|
|
"""
|
|
Initialize the rffmpeg system and database; this will erase all hosts and current state.
|
|
|
|
This command should be run as "sudo" before any attempts to use rffmpeg.
|
|
"""
|
|
if os.getuid() != 0:
|
|
click.echo("Error: This command requires root privileges.")
|
|
exit(1)
|
|
|
|
if not confirm_flag:
|
|
try:
|
|
click.confirm(
|
|
"Are you sure you want to (re)initalize the database",
|
|
prompt_suffix="? ",
|
|
abort=True,
|
|
)
|
|
except Exception:
|
|
fail("Aborting due to failed confirmation.")
|
|
|
|
click.echo("Initializing database")
|
|
|
|
if not Path(config["state_dir"]).is_dir():
|
|
try:
|
|
os.makedirs(config["state_dir"])
|
|
except OSError as e:
|
|
fail(
|
|
"Failed to create state directory '{}': {}".format(
|
|
config["state_dir"], e
|
|
)
|
|
)
|
|
|
|
if Path(config["db_path"]).is_file():
|
|
os.remove(config["db_path"])
|
|
|
|
try:
|
|
with dbconn(config) as cur:
|
|
cur.execute(
|
|
"CREATE TABLE hosts (id INTEGER PRIMARY KEY, hostname TEXT NOT NULL, weight INTEGER DEFAULT 1, servername TEXT NOT NULL)"
|
|
)
|
|
cur.execute(
|
|
"CREATE TABLE processes (id INTEGER PRIMARY KEY, host_id INTEGER, process_id INTEGER, cmd TEXT)"
|
|
)
|
|
cur.execute(
|
|
"CREATE TABLE states (id INTEGER PRIMARY KEY, host_id INTEGER, process_id INTEGER, state TEXT)"
|
|
)
|
|
except Exception as e:
|
|
fail("Failed to create database: {}".format(e))
|
|
|
|
os.chown(
|
|
config["state_dir"],
|
|
getpwnam(config["dir_owner"]).pw_uid,
|
|
getgrnam(config["dir_group"]).gr_gid,
|
|
)
|
|
os.chmod(config["state_dir"], 0o770)
|
|
os.chown(
|
|
config["db_path"],
|
|
getpwnam(config["dir_owner"]).pw_uid,
|
|
getgrnam(config["dir_group"]).gr_gid,
|
|
)
|
|
os.chmod(config["db_path"], 0o660)
|
|
|
|
rffmpeg_click.add_command(rffmpeg_click_init)
|
|
|
|
@click.command(name="status", short_help="Show hosts and status.")
|
|
def rffmpeg_click_status():
|
|
"""
|
|
Show the current status of all rffmpeg target hosts and active processes.
|
|
"""
|
|
with dbconn(config) as cur:
|
|
hosts = cur.execute("SELECT * FROM hosts").fetchall()
|
|
processes = cur.execute("SELECT * FROM processes").fetchall()
|
|
states = cur.execute("SELECT * FROM states").fetchall()
|
|
|
|
# Determine if there are any fallback processes running
|
|
fallback_processes = list()
|
|
for process in processes:
|
|
if process[1] == 0:
|
|
fallback_processes.append(process)
|
|
|
|
# Generate a mapping dictionary of hosts and processes
|
|
host_mappings = dict()
|
|
|
|
if len(fallback_processes) > 0:
|
|
host_mappings[0] = {
|
|
"hostname": "localhost (fallback)",
|
|
"weight": 0,
|
|
"current_state": "fallback",
|
|
"commands": fallback_processes,
|
|
}
|
|
|
|
for host in hosts:
|
|
hid, hostname, weight, servername = host
|
|
|
|
# Get the latest state
|
|
with dbconn(config) as cur:
|
|
current_state = cur.execute(
|
|
"SELECT * FROM states WHERE host_id = ? ORDER BY id DESC", (hid,)
|
|
).fetchone()
|
|
|
|
if not current_state:
|
|
current_state = "idle"
|
|
else:
|
|
current_state = current_state[3]
|
|
|
|
# Create the mappings entry
|
|
host_mappings[hid] = {
|
|
"hostname": hostname,
|
|
"servername": servername,
|
|
"weight": weight,
|
|
"current_state": current_state,
|
|
"commands": [proc for proc in processes if proc[1] == hid],
|
|
}
|
|
|
|
hostname_length = 9
|
|
servername_length = 11
|
|
hid_length = 3
|
|
weight_length = 7
|
|
state_length = 6
|
|
for hid, host in host_mappings.items():
|
|
if len(host["hostname"]) + 1 > hostname_length:
|
|
hostname_length = len(host["hostname"]) + 1
|
|
if len(host["servername"]) + 1 > servername_length:
|
|
servername_length = len(host["servername"]) + 1
|
|
if len(str(hid)) + 1 > hid_length:
|
|
hid_length = len(str(hid)) + 1
|
|
if len(str(host["weight"])) + 1 > weight_length:
|
|
weight_length = len(str(host["weight"])) + 1
|
|
if len(host["current_state"]) + 1 > state_length:
|
|
state_length = len(host["current_state"]) + 1
|
|
|
|
output = list()
|
|
output.append(
|
|
"{bold}{hostname: <{hostname_length}} {servername: <{servername_length}} {hid: <{hid_length}} {weight: <{weight_length}} {state: <{state_length}} {commands}{end_bold}".format(
|
|
bold="\033[1m",
|
|
end_bold="\033[0m",
|
|
hostname="Hostname",
|
|
hostname_length=hostname_length,
|
|
servername="Servername",
|
|
servername_length=servername_length,
|
|
hid="ID",
|
|
hid_length=hid_length,
|
|
weight="Weight",
|
|
weight_length=weight_length,
|
|
state="State",
|
|
state_length=state_length,
|
|
commands="Active Commands",
|
|
)
|
|
)
|
|
|
|
for hid, host in host_mappings.items():
|
|
if len(host["commands"]) < 1:
|
|
first_command = "N/A"
|
|
else:
|
|
first_command = "PID {}: {}".format(
|
|
host["commands"][0][2], host["commands"][0][3]
|
|
)
|
|
|
|
host_entry = list()
|
|
host_entry.append(
|
|
"{hostname: <{hostname_length}} {servername: <{servername_length}} {hid: <{hid_length}} {weight: <{weight_length}} {state: <{state_length}} {commands}".format(
|
|
hostname=host["hostname"],
|
|
hostname_length=hostname_length,
|
|
servername=host["servername"],
|
|
servername_length=servername_length,
|
|
hid=hid,
|
|
hid_length=hid_length,
|
|
weight=host["weight"],
|
|
weight_length=weight_length,
|
|
state=host["current_state"],
|
|
state_length=state_length,
|
|
commands=first_command,
|
|
)
|
|
)
|
|
|
|
for idx, command in enumerate(host["commands"]):
|
|
if idx == 0:
|
|
continue
|
|
host_entry.append(
|
|
"{hostname: <{hostname_length}} {servername: <{servername_length}} {hid: <{hid_length}} {weight: <{weight_length}} {state: <{state_length}} {commands}".format(
|
|
hostname="",
|
|
hostname_length=hostname_length,
|
|
servername="",
|
|
servername_length=servername_length,
|
|
hid="",
|
|
hid_length=hid_length,
|
|
weight="",
|
|
weight_length=weight_length,
|
|
state="",
|
|
state_length=state_length,
|
|
commands="PID {}: {}".format(command[2], command[3]),
|
|
)
|
|
)
|
|
|
|
output.append("\n".join(host_entry))
|
|
|
|
click.echo("\n".join(output))
|
|
|
|
rffmpeg_click.add_command(rffmpeg_click_status)
|
|
|
|
@click.command(name="add", short_help="Add a host.")
|
|
@click.option(
|
|
"-w",
|
|
"--weight",
|
|
"weight",
|
|
required=False,
|
|
default=1,
|
|
help="The weight of the host.",
|
|
)
|
|
@click.option(
|
|
"-n",
|
|
"--name",
|
|
"name",
|
|
required=False,
|
|
default=None,
|
|
help="The name of the host.",
|
|
)
|
|
@click.argument("host")
|
|
def rffmpeg_click_add(weight, name, host):
|
|
"""
|
|
Add a new host with IP or hostname HOST to the database.
|
|
"""
|
|
if name is None:
|
|
name = host
|
|
click.echo("Adding new host '{}' ({})".format(host, name))
|
|
with dbconn(config) as cur:
|
|
cur.execute(
|
|
"INSERT INTO hosts (hostname, weight, servername) VALUES (?, ?, ?)", (host, weight, name)
|
|
)
|
|
|
|
rffmpeg_click.add_command(rffmpeg_click_add)
|
|
|
|
@click.command(name="remove", short_help="Remove a host.")
|
|
@click.argument("host")
|
|
def rffmpeg_click_remove(host):
|
|
"""
|
|
Remove a host with internal ID or IP or hostname or servername HOST from the database.
|
|
"""
|
|
try:
|
|
host = int(host)
|
|
field = "id"
|
|
except ValueError:
|
|
field = "servername"
|
|
fieldAlt = "hostname"
|
|
|
|
with dbconn(config) as cur:
|
|
entry = cur.execute(
|
|
"SELECT * FROM hosts WHERE {} = ?".format(field), (host,)
|
|
).fetchall()
|
|
if len(entry) < 1:
|
|
entry = cur.execute(
|
|
"SELECT * FROM hosts WHERE {} = ?".format(fieldAlt), (host,)
|
|
).fetchall()
|
|
if len(entry) < 1:
|
|
fail("No hosts found to delete!")
|
|
|
|
if len(entry) == 1:
|
|
click.echo("Removing {} host:".format(len(entry)))
|
|
else:
|
|
click.echo("Removing {} hosts:".format(len(entry)))
|
|
for host in entry:
|
|
hid, hostname, weight, servername = host
|
|
click.echo("\tID: {}\tHostname: {}\tServername: {}".format(hid, hostname, servername))
|
|
cur.execute("DELETE FROM hosts WHERE id = ?", (hid,))
|
|
|
|
rffmpeg_click.add_command(rffmpeg_click_remove)
|
|
|
|
@click.command(name="log", short_help="View the rffmpeg log.")
|
|
@click.option(
|
|
"-f",
|
|
"--follow",
|
|
"follow_flag",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Follow new log entries instead of seeing current log entries.",
|
|
)
|
|
def rffmpeg_click_log(follow_flag):
|
|
"""
|
|
View the rffmpeg log file.
|
|
"""
|
|
if follow_flag:
|
|
with open(config["logfile"]) as file_:
|
|
# Go to the end of file
|
|
file_.seek(0, 2)
|
|
while True:
|
|
curr_position = file_.tell()
|
|
line = file_.readline()
|
|
if not line:
|
|
file_.seek(curr_position)
|
|
sleep(0.1)
|
|
else:
|
|
click.echo(line, nl=False)
|
|
else:
|
|
with open(config["logfile"], "r") as logfh:
|
|
click.echo_via_pager(logfh.readlines())
|
|
|
|
rffmpeg_click.add_command(rffmpeg_click_log)
|
|
|
|
return rffmpeg_click(obj={})
|
|
|
|
|
|
# Entrypoint
|
|
if __name__ == "__main__":
|
|
all_args = sys.argv
|
|
cmd_name = all_args[0]
|
|
|
|
# Load the config
|
|
config = load_config()
|
|
|
|
if "rffmpeg" in cmd_name:
|
|
run_control(config)
|
|
else:
|
|
if not Path(config["db_path"]).is_file():
|
|
fail(
|
|
"Failed to find database '{}' - did you forget to run 'rffmpeg init'?".format(
|
|
config["db_path"]
|
|
)
|
|
)
|
|
|
|
ffmpeg_args = all_args[1:]
|
|
run_ffmpeg(config, ffmpeg_args)
|