diff --git a/.vscode/launch.json b/.vscode/launch.json index 3ec4bde..d9853e9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,7 +39,34 @@ "args": [ "-c", "-w", - "-1" + "-3" + ], + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: cctv-scheduler -p -w -3", + "type": "python", + "request": "launch", + "program": "${file}", + "args": [ + "-p", + "-w", + "-3" + ], + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: cctv-scheduler -w -3 -c -p", + "type": "python", + "request": "launch", + "program": "${file}", + "args": [ + "-d", + "-1", + "-c", + "-p" ], "console": "integratedTerminal", "justMyCode": true diff --git a/README.md b/README.md index 0b9631d..e3bacc1 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,10 @@ PTZ IP-Camera management ____ - [`cctv-scheduler.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#cctv-scheduler-py) -- [`publisher.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#publisher-sh) ____ -![cctv-scheduler](info/images/cctv-scheduler-0.4.png) +![cctv-scheduler](info/images/cctv-scheduler-0.5.png) ## `Installation` @@ -30,9 +29,6 @@ Download scripts and configs. ```bash wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/cctv-scheduler.py -O /home/user/cctv-scheduler/cctv-scheduler.py wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/cctv-scheduler.conf -O /home/user/cctv-scheduler/cctv-scheduler.conf -wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.sh -O /home/user/cctv-scheduler/publisher.sh -wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.conf -O /home/user/cctv-scheduler/publisher.conf -wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher-template-page-1007.xml -O /home/user/cctv-scheduler/publisher-template-page-1007.xml ``` ### `Configuration` @@ -41,7 +37,6 @@ Edit configs. ```bash nano /home/user/cctv-scheduler/cctv-scheduler.conf -nano /home/user/cctv-scheduler/publisher.conf ``` ### `Scheduler` @@ -61,6 +56,7 @@ crontab -e > - getting temperature from DS18B20 over SSH, > - saving pictures to FTP. > - converting picture collection to video. +> - publishing video to Telegram chat and Wordpress site. > > This is only a local "proof of concept" for testing and debugging. @@ -98,6 +94,7 @@ crontab -e |**[-s, --sequences]**|run sequences from config file|`None`| |**[--config]**|custom configuration file path|`./cctv-scheduler.conf`| |**[-c, --converter]**|convert JPEG collection to MP4|`None`| +|**[-p, --publisher]**|publish content from templates|`None`| |**[-d, --day]**|day in amount of days from today, for which the publication or conversion is made|`0`| |**[-w, --week]**|week in amount of weeks from today, for which the publication or conversion is made|`0`| |**[-m, --month]**|month in amount of months from today, for which the publication or conversion is made|`0`| @@ -116,54 +113,8 @@ Example usage with cron: # crontab -e * * * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -b 0 * * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -s -1 0 * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -d -1 -7 0 * * 1 /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -w -1 -30 0 1 * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -m -1 -36 0 1 1 * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -y -1 -``` - -____ - -## `publisher`.sh - -**Description:** -> Uploading MP4 to [Wordpress](https://wordpress.com/) and [Telegram](https://web.telegram.org/). -> Additionally: -> -> - editing [Wordpress](https://codex.wordpress.org/XML-RPC_WordPress_API) page from template -> - recompressing video if size [over 50MB](https://core.telegram.org/bots/api#sendvideo) -> -> This is only a local "proof of conept" for testing and debugging. - -**Dependencies:** -> -> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) -> - [curl](https://curl.se/download.html) (tested version 7.74 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) -> - [ffmpeg](https://ffmpeg.org/download.html) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) -> - [libxml2-utils](https://gitlab.gnome.org/GNOME/libxml2) (tested version 2.9.10 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) -> - [jq](https://stedolan.github.io/jq/download/) (tested version 1.6 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) -> - -| POSITION | PARAMETERS | DESCRIPTION | DEFAULT | -|-----------|--------------|------------------------|---------------| -| 1 | **[qn]** |execution without pauses|| -| 2 | **[/path/to/conf]** |path to config| `./publisher.conf` | -| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - 0 day \| '-d' - -X day \| '-w' - -X week \| '-m' - -X month \| '-y' - -X year|`''`| -| 4 | **[1\|2\|3..XXX]** |multiplier for period: '' - 1 day\|week\|month\|year|`1`| -| 5 | **[--onlytg\|--onlywp]** |'--onlytg' - only publish to Telegram \|'--onlywp' - only publish to Wordpress|| - -Example usage in terminal with bash for publish to Telegram today's MP4: - -```bash -bash ./publisher.sh - ./publisher.conf - - - - --onlytg -``` - -Example usage with cron: - -```bash -# crontab -e -1 1 * * * bash /home/user/cctv-scheduler/publisher.sh qn - -d -7 1 * * 1 bash /home/user/cctv-scheduler/publisher.sh qn - -w -30 1 1 * * bash /home/user/cctv-scheduler/publisher.sh qn - -m -36 1 1 1 * bash /home/user/cctv-scheduler/publisher.sh qn - -y +1 0 * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -d -1 -c -p +7 0 * * 1 /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -w -1 -c -p +30 0 1 * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -m -1 -c -p +36 0 1 1 * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -y -1 -c -p ``` diff --git a/archive/0.4/README.md b/archive/0.4/README.md new file mode 100644 index 0000000..0b9631d --- /dev/null +++ b/archive/0.4/README.md @@ -0,0 +1,169 @@ +# cctv-scheduler + +PTZ IP-Camera management +____ + +- [`cctv-scheduler.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#cctv-scheduler-py) +- [`publisher.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#publisher-sh) + +____ + +![cctv-scheduler](info/images/cctv-scheduler-0.4.png) + +## `Installation` + +### `Requirements` + +Cameras settings: +> +> - Configuration -> System -> Security -> Authentication -> RTSP Authentication: digest/basic +> - Configuration -> System -> Security -> Authentication -> WEB Authentication: digest/basic +> - Configuration -> Network -> Advanced Settings -> Integration Protocol -> Enable Hikvision-CGI: Enabled +> - Configuration -> Network -> Advanced Settings -> Integration Protocol -> Hikvision-CGI Authentication: digest/basic + +Look at the description of dependencies and install the necessary. + +### `Downloading` + +Download scripts and configs. + +```bash +wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/cctv-scheduler.py -O /home/user/cctv-scheduler/cctv-scheduler.py +wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/cctv-scheduler.conf -O /home/user/cctv-scheduler/cctv-scheduler.conf +wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.sh -O /home/user/cctv-scheduler/publisher.sh +wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher.conf -O /home/user/cctv-scheduler/publisher.conf +wget https://git.hmp.today/pavel.muhortov/cctv-scheduler/raw/branch/master/publisher-template-page-1007.xml -O /home/user/cctv-scheduler/publisher-template-page-1007.xml +``` + +### `Configuration` + +Edit configs. + +```bash +nano /home/user/cctv-scheduler/cctv-scheduler.conf +nano /home/user/cctv-scheduler/publisher.conf +``` + +### `Scheduler` + +Look at examples and edit scheduler tasks: + +```bash +crontab -e +``` + +## `cctv-scheduler`.py + +**Description:** +> [Hikvision](https://git.hmp.today/pavel.muhortov/cctv-scheduler/src/branch/master/info/hikvision/manual/isapi.pdf) PTZ IP-Camera management. Media streaming. Images to video converting. +> Additionally: +> +> - getting temperature from DS18B20 over SSH, +> - saving pictures to FTP. +> - converting picture collection to video. +> +> This is only a local "proof of concept" for testing and debugging. + +**Dependencies:** +> +> - [Python 3](https://www.python.org/downloads/) (tested version 3.9.5 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> - [paramiko](https://www.paramiko.org/) Python 3 module (tested version 3.1.0) +> - [ffmpeg](https://ffmpeg.org) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> - specified record pictures filesystem organization +> +>```bash +> # record pictures filesystem organization example +>/root/ +> /2022/ +> /12/ +> /52/ +> /31/ +> /image-01_2022.12.31_time.jpeg +> /image-02_2022.12.31_time.jpeg +> /2023/ +> /01/ +> /01/ +> /02/ +> /image-01_2023.01.02_time.jpeg +> /image-02_2023.01.02_time.jpeg +> /03/ +> /image-01_2023.01.03_time.jpeg +> /image-02_2023.01.03_time.jpeg +>``` + +| PARAMETERS | DESCRIPTION | DEFAULT| +|-------------|-------------|--------| +|**[-h]**|print help and exit|| +|**[-b, --broadcast]**|streaming media to destination|`None`| +|**[-s, --sequences]**|run sequences from config file|`None`| +|**[--config]**|custom configuration file path|`./cctv-scheduler.conf`| +|**[-c, --converter]**|convert JPEG collection to MP4|`None`| +|**[-d, --day]**|day in amount of days from today, for which the publication or conversion is made|`0`| +|**[-w, --week]**|week in amount of weeks from today, for which the publication or conversion is made|`0`| +|**[-m, --month]**|month in amount of months from today, for which the publication or conversion is made|`0`| +|**[-y, --year]**|year in amount of years from today, for which the publication or conversion is made|`0`| + +Example usage in terminal with make the script executable: + +```bash +chmod u+x ./cctv-scheduler.py +./cctv-scheduler.py -s --config /home/user/cctv-scheduler/cctv-scheduler.conf +``` + +Example usage with cron: + +```bash +# crontab -e +* * * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -b +0 * * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -s +1 0 * * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -d -1 +7 0 * * 1 /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -w -1 +30 0 1 * * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -m -1 +36 0 1 1 * /usr/bin/python3 /home/user/cctv-scheduler/cctv-scheduler.py -c -y -1 +``` + +____ + +## `publisher`.sh + +**Description:** +> Uploading MP4 to [Wordpress](https://wordpress.com/) and [Telegram](https://web.telegram.org/). +> Additionally: +> +> - editing [Wordpress](https://codex.wordpress.org/XML-RPC_WordPress_API) page from template +> - recompressing video if size [over 50MB](https://core.telegram.org/bots/api#sendvideo) +> +> This is only a local "proof of conept" for testing and debugging. + +**Dependencies:** +> +> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> - [curl](https://curl.se/download.html) (tested version 7.74 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> - [ffmpeg](https://ffmpeg.org/download.html) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> - [libxml2-utils](https://gitlab.gnome.org/GNOME/libxml2) (tested version 2.9.10 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> - [jq](https://stedolan.github.io/jq/download/) (tested version 1.6 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) +> + +| POSITION | PARAMETERS | DESCRIPTION | DEFAULT | +|-----------|--------------|------------------------|---------------| +| 1 | **[qn]** |execution without pauses|| +| 2 | **[/path/to/conf]** |path to config| `./publisher.conf` | +| 3 | **[-d\|-w\|-m\|-y]** |periods: '' - 0 day \| '-d' - -X day \| '-w' - -X week \| '-m' - -X month \| '-y' - -X year|`''`| +| 4 | **[1\|2\|3..XXX]** |multiplier for period: '' - 1 day\|week\|month\|year|`1`| +| 5 | **[--onlytg\|--onlywp]** |'--onlytg' - only publish to Telegram \|'--onlywp' - only publish to Wordpress|| + +Example usage in terminal with bash for publish to Telegram today's MP4: + +```bash +bash ./publisher.sh - ./publisher.conf - - - - --onlytg +``` + +Example usage with cron: + +```bash +# crontab -e +1 1 * * * bash /home/user/cctv-scheduler/publisher.sh qn - -d +7 1 * * 1 bash /home/user/cctv-scheduler/publisher.sh qn - -w +30 1 1 * * bash /home/user/cctv-scheduler/publisher.sh qn - -m +36 1 1 1 * bash /home/user/cctv-scheduler/publisher.sh qn - -y +``` diff --git a/archive/0.4/cctv-scheduler.conf b/archive/0.4/cctv-scheduler.conf new file mode 100644 index 0000000..743d0d3 --- /dev/null +++ b/archive/0.4/cctv-scheduler.conf @@ -0,0 +1,136 @@ +[common] +# By default, a temporary files directory is created in the same path where the script is located. +# If you need change it, uncomment the parameter and set the path you want. +#temp_path = /tmp/cctv-scheduler +# +# By default, logs use the same directory where the script is located. +# If you need change it, uncomment the parameter and set the path you want. +#log_root = /var/log/cctv-scheduler +# +# The default log level is "INFO". +# If you get errors or want to change the logging level, uncomment the parameter and set the level you want: +# DEBUG, INFO, WARNING, ERROR, CRITICAL. +#log_level = DEBUG + + +[enable-broadcast] +# List the broadcast block names. Only blocks with the TRUE value will be used. +camera.test.local = true + + +[enable-sequences] +# List the sequence/camera block names. Only blocks with the TRUE value will be used. +camera.test.local = true + + +[enable-sensors] +# List the sensor block names. Only blocks with the TRUE value will be used. +sensor.test.local = true + + +[enable-convert] +# List the convert block names. Only blocks with the TRUE value will be used. +camera.test.local = true + + +[broadcast-config:camera.test.local] +# Broadcast parameter description block always starts with "broadcast-config:". +src = rtsp://user:pass@192.168.254.253:554/Streaming/Channels/101,http://radio.fm:8000/stream.mp3 +dst = rtp://239.0.0.1:5554 +# Optionality you can change video stream framerate. +#fps = 25 +# +# Optionality you can set YouTube recommended preset: +# 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p. +#preset = 1080p +# +# By default ffmpeg expected in /usr/bin/ffmpeg or C:\Program Files\ffmpeg\bin\ffmpeg.exe. +# If you need change it, uncomment the parameter and set the path you want. +#ffpath = /usr/bin/ffmpeg +# +# If you get program freezes because your network unstable, uncomment the parameter. +#watchdog = true +# +# By default, watchdog waits 5 seconds before terminating the program. +# If you need change it, uncomment the parameter and set the path you want. +#watchsec = 15 +# +# If you use crontab or other scheduler to run, uncomment the parameter to prevent process overlap. +#onlyonce = true + + +[sensor-config:sensor.test.local] +# Remote host's sensor parameter description block always starts with "sensor-config:". +hostname = 192.168.254.252 +username = user +userpass = pass +# To recognize options for polling a sensor, you must specify the type of sensor. +# Supported types: +# ds18b20 +nodetype = ds18b20 +nodename = 28-1a2b3c4d5e6f + + +[camera-config:camera.test.local] +# Camera parameter description block always starts with "camera-config:". +hostname = 192.168.254.253 +username = user +userpass = pass +# If a record directory on a remote host is used, a username and password must be specified. +# Supported protocols: +# FTP, SFTP. +records_root_path = ftp://192.168.254.254/Records/camera.test.local +records_root_user = user +records_root_pass = pass + + +[camera-sequences:camera.test.local] +# Camera sequence description block always starts with "camera-sequences:". +# Place only the sequence of PTZ-actions in this block! +# Variable name can be anything. Only 'downloadjpeg' is using this for filename prefix. +# Available actions: +# setcamerapos, setcameramov, settextonosd, downloadjpeg, capabilities, getcamerapos, +# setptzmovyyu, setptzmovyyd, setptzmovxxl, setptzmovxxr, setptzmovzzi, setptzmovzzo, +# setptzpreset, setptztostop, setmovtohome, setposashome, rebootcamera. +# +# Format (spaces are used for readability only): +# name = action, x, y, zoom, preset, speed, d(ms), w(s), text, notes or anything +step001 = capabilities, -, -, -, -, -, -, 3, , +step002 = getcamerapos, -, -, -, -, -, -, 3, , +step011 = setmovtohome, -, -, -, -, -, -, 15, , +step021 = setptzmovyyd, -, -, -, -, 2, -, 5, , 'speed: 1..7' +step022 = setptzmovyyu, -, -, -, -, 4, -, 3, , 'speed: 1..7' +step023 = setptzmovxxl, -, -, -, -, 4, -, 3, , 'speed: 1..7' +step024 = setptzmovxxr, -, -, -, -, 2, -, 5, , 'speed: 1..7' +step025 = setptzmovzzi, -, -, -, -, 7, -, 3, , 'speed: 1..7' +step026 = setptzmovzzo, -, -, -, -, 7, -, 3, , 'speed: 1..7' +step027 = setptztostop, -, -, -, -, -, -, 3, , +step031 = setptzpreset, -, -, -, 2, 1, -, 15, , 'speed: 1..7' +step041 = setcameramov, 33, 33, 66, -, -, -, 5, , 'x: -100..100, y: -100..100, z: -100..100, duration: 0..180000' +step042 = setcameramov, -66, -66, -99, -, -, 5000, 0, , 'x: -100..100, y: -100..100, z: -100..100, duration: 0..180000' +step043 = setcamerapos, 0, 0, 0, -, -, -, 15, , 'x: 0..3600, y: -900..2700, z: 0..1000' +step051 = setposashome, -, -, -, -, -, -, 3.5, , +step061 = settextonosd, 0, 0, -, -, -, -, 3, hello, 'x|y: osd text position, text: text for osd without quotes or commas' +step062 = settextonosd, 0, 0, -, -, -, -, 3, sensor-config:sensor.test.local, 'if a sensor configuration is specified, the sensor value is used instead of text' +step063 = settextonosd, 0, 0, -, -, -, -, 3, , 'an empty text value is used to clear the osd' +step071 = downloadjpeg, 1920, 1080, -, -, -, -, 5, , 'name: filename prefix, x|y: camera width|height resolution' +step999 = rebootcamera, -, -, -, -, -, -, 120, , + + +[convert-config:camera.test.local] +# Converter parameter description block always starts with "convert-config:". +image_find_names = step071, image-01, image-02 +# If a image root or destination video directories on a remote host is used, a username and password must be specified. +# Supported protocols: +# FTP. +image_root_path = ftp://192.168.254.254/Records/camera.test.local +image_root_user = user +image_root_pass = pass + +video_dest_path = ftp://192.168.254.254/Downloads +video_dest_user = user +video_dest_pass = pass + +video_scale_x = 1920 +video_scale_y = 1080 +video_framerate = 25 \ No newline at end of file diff --git a/archive/0.4/cctv-scheduler.py b/archive/0.4/cctv-scheduler.py new file mode 100644 index 0000000..cd5df83 --- /dev/null +++ b/archive/0.4/cctv-scheduler.py @@ -0,0 +1,3308 @@ +#!/usr/bin/env python3 +# pylint: disable=C0103,C0302,C0114,W0621 + + +import calendar +import base64 +import datetime +import json +import logging +from random import choice +import re +import urllib.request +from argparse import ArgumentParser +from ftplib import FTP +from multiprocessing import Process, Queue +from os import environ, makedirs, path, remove, replace, rmdir, sep, stat, walk +from string import ascii_letters, digits +from subprocess import Popen, PIPE, STDOUT +from sys import platform +from time import sleep +import requests +from paramiko import SSHClient, AutoAddPolicy + + +class Parse: + """Parser of configs, arguments, parameters. + """ + # pylint: disable=C0123 + def __init__(self, parameters, block: str = None) -> None: + """Object constructor. + + Args: + parameters: dictionary as "key":"value" or + ArgumentParser class object or + string path to the file or + string as "var1=val1;var2=val2". + block (str, optional): name of target block from text. Defaults to None. + """ + self.path = '' + self.data = {} + if type(parameters) is dict: + self._dict2dict(parameters) + if type(parameters) is ArgumentParser: + self._dict2dict(self.argv2dict(parameters)) + if type(parameters) is str: + if path.exists(parameters): + self._dict2dict( + self.strs2dict( + self.conf2strs(parameters), + block + ) + ) + self.path = parameters + else: + self._dict2dict(self.strs2dict(parameters, block)) + + def __str__(self) -> str: + """Overrides method for print(object). + + Returns: + str: string with contents of the object's dictionary. + """ + string = '' + for key, val in self.data.items(): + string += str(type(val)) + ' ' + str(key) + ' = ' + str(val) + '\n' + return string + + def _dict2dict(self, dictionary: dict) -> None: + """Updates or adds dictionary data. + + Args: + dictionary (dict): dictionary as "key":"value". + """ + self.data.update(dictionary) + + # pylint: disable=C0206 + def expand(self, store: str = None) -> dict: + """Expand dictionary "key":"name.conf" to dictionary "key":{subkey: subval}. + + Args: + store (str, optional): path to directory with name.conf. Defaults to None. + + Returns: + dict: expanded dictionary as "key":{subkey: subval}. + """ + for key in self.data: + if store: + config = store + sep + self.data[key] + else: + config = self.data[key] + with open(config, encoding='UTF-8') as file: + self.data[key] = Parse(file.read()).data + return self.data + + @classmethod + def argv2dict(cls, parser: ArgumentParser) -> dict: + """Converts startup arguments to a dictionary. + + Args: + parser (ArgumentParser): argparse.ArgumentParser class object. + + Returns: + dict: dictionary as "key":"value". + """ + parser = ArgumentParser(add_help=False, parents=[parser]) + return vars(parser.parse_args()) + + @classmethod + def conf2strs(cls, config: str) -> str: + """Builds a dictionary from a file containing parameters. + + Args: + config (str): path to the config file. + + Returns: + str: string as "var1=val1;\nvar2=val2;". + """ + with open(config, encoding='UTF-8') as file: + raw = file.read() + strs = '' + for line in raw.splitlines(): + if not line.lstrip().startswith('#'): + strs += line + '\n' + return strs + + @classmethod + def strs2dict(cls, strings: str, blockname: str) -> dict: + """Builds a dictionary from a strings containing parameters. + + Args: + strings (str): string as "var1=val1;var2=val2;". + blockname (str): name of target block from text. + + Returns: + dict: dictionary as "key":"value". + """ + dictionary = {} + if blockname: + strings = cls.block(blockname, strings) + for line in strings.replace('\n', ';').split(';'): + if not line.lstrip().startswith('#') and "=" in line: + dictionary[line.split('=')[0].strip()] = ( + line.split('=')[1].strip().split(';')[0].strip() + ) + return dictionary + + @classmethod + def str2bool(cls, value: str) -> bool: + """Converts a string value to boolean. + + Args: + value (str): string containing "true" or "false", "yes" or "no", "1" or "0". + + Returns: + bool: bool True or False. + """ + return str(value).lower() in ("true", "yes", "1") + + @classmethod + def block(cls, blockname: str, text: str) -> str: + """Cuts a block of text between line [blockname] and line [next block] or EOF. + + Args: + blockname (str): string in [] after which the block starts. + text (str): string of text from which the block is needed. + + Returns: + str: string of text between line [block name] and line [next block]. + """ + level = 1 + save = False + result = '' + for line in text.splitlines(): + if line.startswith('[') and blockname in line: + level = line.count('[') + save = True + elif line.startswith('[') and '['*level in line: + save = False + elif save: + result += line + '\n' + return result + + +class Connect: + # pylint: disable=W0105 + """Set of connection methods (functions) for various protocols. + """ + @staticmethod + # pylint: disable=W0102, W0718 + def http( + url: str, + method: str = 'GET', + username: str = '', + password: str = '', + authtype: (str, type(None)) = None, + contenttype: str = 'text/plain', + contentdata: (str, bytes) = '', + headers: dict = {} + ) -> dict: + """Handling HTTP request. + + Args: + url (str): Handling HTTP request. + method (str, optional): HTTP request method. Defaults to 'GET'. + username (str, optional): username for url authentication. Defaults to ''. + password (str, optional): password for url authentication. Defaults to ''. + authtype (str, None, optional): digest|basic authentication type. Defaults to None. + contenttype (str, optional): 'Content-Type' header. Defaults to 'text/plain'. + contentdata (str, bytes, optional): content data. Defaults to ''. + headers (dict, optional): additional headers. Defaults to {}. + + Returns: + dict: {'success':bool,'result':HTTP response or 'ERROR'}. + """ + if Do.args_valid(locals(), Connect.http.__annotations__): + if contentdata != '': + headers['Content-Type'] = contenttype + if isinstance(contentdata, str): + contentdata = bytes(contentdata.encode('utf-8')) + + # Preparing authorization + if authtype: + pswd = urllib.request.HTTPPasswordMgrWithDefaultRealm() + pswd.add_password(None, url, username, password) + if authtype == 'basic': + auth = urllib.request.HTTPBasicAuthHandler(pswd) + token = base64.b64encode((username + ':' + password).encode()) + headers['Authorization'] = 'Basic ' + token.decode('utf-8') + if authtype == 'digest': + auth = urllib.request.HTTPDigestAuthHandler(pswd) + urllib.request.install_opener(urllib.request.build_opener(auth)) + + # Preparing request + request = urllib.request.Request( + url=url, + data=contentdata, + method=method + ) + for key, val in headers.items(): + request.add_header(key, val) + if len(contentdata) > 128: + contentdata = contentdata[:64] + b' ... ' + contentdata[-64:] + logging.debug(msg='' + + '\n' + 'uri: ' + url + + '\n' + 'method: ' + method + + '\n' + 'username: ' + username + + '\n' + 'password: ' + password + + '\n' + 'authtype: ' + str(authtype) + + '\n' + 'headers: ' + json.dumps(headers, indent=2) + + '\n' + 'content-data: ' + str(contentdata) + ) + + # Response + try: + response = urllib.request.urlopen(request).read() + if not response.startswith(b'\xff\xd8'): + response = str(response.decode('utf-8')) + return {"success": True, "result": response} + except Exception as error: + logging.debug(msg='\n' + 'error: ' + str(error)) + return {"success": False, "result": "ERROR"} + + @staticmethod + # pylint: disable=W0718 + def ssh_commands( + command: str, + hostname: str, + username: str, + password: str, + port: int = 22 + ) -> str: + """Handling SSH command executing. + + Args: + command (str): command for executing. + hostname (str): remote hostname or ip address. + username (str): remote host username. + password (str): remote host password. + port (int, optional): remote host connection port. Defaults to 22. + + Returns: + str: terminal response or 'ERROR'. + """ + client = SSHClient() + client.set_missing_host_key_policy(AutoAddPolicy()) + try: + client.connect(hostname=hostname, username=username, password=password, port=port) + stdin, stdout, stderr = client.exec_command(command=command, get_pty=True) + if 'sudo' in command: + stdin.write(password + '\n') + stdin.flush() + stdout.flush() + data = stdout.read() + stderr.read() + client.close() + return data.decode('utf-8') + except Exception as error: + logging.debug( + msg='' + + '\n' + 'host: ' + hostname + ':' + str(port) + + '\n' + 'user: ' + username + + '\n' + 'pass: ' + password + + '\n' + 'command: ' + command + + '\n' + 'error: ' + str(error) + ) + return 'ERROR' + + @staticmethod + # pylint: disable=W0718 + def ssh_put_file( + src_file: str, + dst_file: str, + hostname: str, + username: str, + password: str, + port: int = 22 + ) -> str: + """Handling SFTP upload file. + + Args: + src_file (str): /local/path/to/file. + dst_file (str): /remote/path/to/file. + hostname (str): remote hostname or ip address. + username (str): remote host username. + password (str): remote host password. + port (int, optional): remote host connection port. Defaults to 22. + + Returns: + str: '/remote/path/to/file' or 'ERROR'. + """ + client = SSHClient() + client.set_missing_host_key_policy(AutoAddPolicy()) + try: + client.connect(hostname=hostname, username=username, password=password, port=port) + client.exec_command('mkdir -p ' + path.dirname(dst_file)) + try: + sftp = client.open_sftp() + sftp.put(localpath=src_file, remotepath=dst_file) + sftp.stat(dst_file) + sftp.close() + return dst_file + except Exception as error: + logging.debug( + msg='' + + '\n' + 'dst_file: ' + dst_file + + '\n' + 'error: ' + str(error) + ) + return 'ERROR' + except Exception as error: + logging.debug( + msg='' + + '\n' + 'host: ' + hostname + ':' + str(port) + + '\n' + 'user: ' + username + + '\n' + 'pass: ' + password + + '\n' + 'src_file: ' + src_file + + '\n' + 'dst_file: ' + dst_file + + '\n' + 'error: ' + str(error) + ) + return 'ERROR' + ''' + @staticmethod + def ssh_get_file(src_file: str, dst_file: str, hostname: str, username: str, password: str, port: int = 22) -> str: + """Handling SFTP download file. + + Args: + src_file (str): /remote/path/to/file. + dst_file (str): /local/path/to/file. + hostname (str): remote hostname or ip address. + username (str): remote host username. + password (str): remote host password. + port (int, optional): remote host connection port. Defaults to 22. + + Returns: + str: '/local/path/to/file' or 'ERROR'. + """ + client = SSHClient() + client.set_missing_host_key_policy(AutoAddPolicy()) + try: + client.connect(hostname=hostname, username=username, password=password, port=port) + with client.open_sftp() as sftp: + sftp.get(remotepath=src_file, localpath=dst_file) + client.close() + except Exception as error: + logging.debug( + msg='' + + '\n' + 'host: ' + hostname + ':' + str(port) + + '\n' + 'user: ' + username + + '\n' + 'pass: ' + password + + '\n' + 'src_file: ' + src_file + + '\n' + 'dst_file: ' + dst_file + + '\n' + 'error: ' + str(error) + ) + return 'ERROR' + ''' + + @staticmethod + # pylint: disable=W0718 + def ftp_file_search( + root_path: (str), + search_name: (str, type(None)) = None, + ftp: FTP = None, + hostname: str = None, + username: str = None, + password: str = None + ) -> list: + """Search files over FTP. + + Args: + root_path (str): where to search. + search_name (str, None, optional): full or partial filename for the filter. + Defaults to None. + ftp (FTP, optional): FTP object generated by recursive search. Defaults to None. + hostname (str, optional): ftp hostname. Defaults to None. + username (str, optional): ftp username. Defaults to None. + password (str, optional): ftp password. Defaults to None. + + Returns: + list: list of found files. + """ + parent = False + if not ftp: + try: + ftp = FTP(host=hostname) + ftp.login(user=username, passwd=password) + parent = True + except Exception: + pass + + result = [] + ftp.cwd(root_path) + for file in ftp.mlsd(): + if file[1]['type'] == 'dir': + result = result + Connect.ftp_file_search( + root_path=root_path + "/" + file[0], + search_name=search_name, + ftp=ftp + ) + elif file[1]['type'] == 'file': + if search_name: + if search_name in file[0]: + result.append(root_path + "/" + file[0]) + else: + result.append(root_path + "/" + file[0]) + + if parent: + ftp.close() + result.sort() + return result + + @staticmethod + # pylint: disable=W0718,C0116 + def ftp_get_file(src_file: str, dst_file: str, hostname: str, username: str, password: str): + ftp = FTP(host=hostname) + try: + ftp.login(user=username, passwd=password) + with open(dst_file, "wb+") as file: + ftp.retrbinary(f"RETR {src_file}", file.write) + ftp.quit() + return True + except Exception as error: + logging.debug(msg='\n' + 'error: ' + str(error)) + return False + + @staticmethod + # pylint: disable=W0718,C0116 + def ftp_put_file( + src_file: str, + dst_file: str, + hostname: str, + username: str, + password: str + ) -> bool: + dst_path = dst_file.split('/')[:-1] + ftp = FTP(host=hostname) + try: + ftp.login(user=username, passwd=password) + for path_item in dst_path: + if path_item.strip() == '': + continue + path_item = path_item.replace('/', '') + try: + ftp.cwd(path_item) + except Exception: + ftp.mkd(path_item) + ftp.cwd(path_item) + with open(src_file, "rb") as file: + ftp.storbinary(f"STOR {dst_file}", file) + ftp.quit() + return True + except Exception as error: + logging.debug(msg='\n' + 'error: ' + str(error)) + return False + + +class HikISAPI(Connect): + """Representing Hikvision device with ISAPI. + The class inherits the necessary connection methods of the Connect class + """ + def __init__( + self, + hostname: str, + username: str, userpass: str, + authtype: str = 'digest', + hostport: int = 80, protocol: str = 'http', + channel: int = 101, videoid: int = 1 + ) -> None: + """Object constructor. + + Args: + hostname (str): camera hostname or ip address. + username (str): camera admin username. + userpass (str): camera admin password. + authtype (str, optional): digest|basic camera authentication type. Defaults to 'digest'. + hostport (int, optional): camera connection port. Defaults to 80. + protocol (str, optional): camera connection protocol. Defaults to 'http'. + channel (int, optional): camera channel id. Defaults to 101. + videoid (int, optional): camera video id. Defaults to 1. + """ + self._host = hostname + self._port = hostport + self._user = username + self._pswd = userpass + self._auth = authtype + self._prot = protocol + self._chan = channel + self._viid = videoid + + def __call( + self, + url: str, method: str = 'GET', + contenttype: str = 'application/x-www-form-urlencoded', + contentdata: str = '' + ) -> str: + """Send request to camera. + + Args: + url (str): API path for request. + method (str, optional): HTTP request method. Defaults to 'GET'. + contenttype (str, optional): Content-Type header. + Defaults to 'application/x-www-form-urlencoded'. + contentdata (str, optional): data for send with request. Defaults to ''. + + Returns: + str: HTTP response content or 'ERROR'. + """ + response = self.http( + url=url, method=method, + username=self._user, password=self._pswd, authtype=self._auth, + contenttype=contenttype, contentdata=contentdata + ) + if response['success']: + return response['result'] + else: + return 'ERROR' + + def capabilities(self) -> bool: + """Get camera capabilities. + + Returns: + bool: True if successed. Printing a response with a logger at the INFO level. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._viid) + "/capabilities" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.info(msg='\n' + response + '\n') + return True + else: + return False + + def downloadjpeg( + self, + dst_file: str = path.splitext(__file__)[0] + '.jpeg', + x: int = 1920, + y: int = 1080 + ) -> bool: + """Get static picture from camera. + + Args: + dst_file (str, optional): abs picture's path to save. Defaults to scriptname+'.jpeg'. + x (int, optional): picture width. Defaults to 1920. + y (int, optional): picture height. Defaults to 1080. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/Streaming/channels/" + str(self._viid) + + "/picture?snapShotImageType=JPEG&videoResolutionWidth=" + + str(x) + "&videoResolutionHeight=" + str(y) + ) + with open(dst_file, "wb") as file: + response = self.__call(url=url, method='GET') + if response != 'ERROR': + file.write(response) + logging.debug(msg='\n' + dst_file + '\n') + return True + else: + return False + + def getcamerapos(self) -> bool: + """Get current camera position. + + Returns: + bool: True if successed. Printing a response with a logger at the INFO level. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + "/status" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.info(msg='\n' + response + '\n') + return True + else: + return False + + def rebootcamera(self) -> bool: + """Set camera reboot command. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/System/reboot" + ) + response = self.__call(url=url, method="PUT") + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzmovyyu(self, speed: int = 1) -> bool: + """Start camera moving to up. + + Args: + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=TILT_UP&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzmovyyd(self, speed: int = 1) -> bool: + """Start camera moving to down. + + Args: + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=TILT_DOWN&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzmovxxl(self, speed: int = 1) -> bool: + """Start camera moving to left. + + Args: + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=PAN_LEFT&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzmovxxr(self, speed: int = 1) -> bool: + """Start camera moving to right. + + Args: + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=PAN_RIGHT&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzmovzzi(self, speed: int = 1) -> bool: + """Start camera zoom in. + + Args: + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=ZOOM_OUT&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzmovzzo(self, speed: int = 1) -> bool: + """Start camera zoom out. + + Args: + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=ZOOM_IN&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptzpreset(self, preset: int, speed: int = 1) -> bool: + """Start camera moving to preset. + + Args: + preset (int): saved preset number. + speed (int, optional): moving speed from 1 to 7. Defaults to 1. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=GOTO_PRESET&presetNo=" + str(preset) + + "&speed=" + str(speed) + + "&mode=start" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setptztostop(self) -> bool: + """Stop any camera moving. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/PTZ/channels/" + str(self._viid) + + "/PTZControl?command=GOTO_PRESET&mode=stop" + ) + response = self.__call(url=url, method='GET') + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setcamerapos(self, x: int = 0, y: int = 0, z: int = 0) -> bool: + """Set camera moving to absolute position. + + Args: + x (int, optional): horisontal camera position from 0 to 3600. Defaults to 0. + y (int, optional): vertical camera position from -900 to 2700. Defaults to 0. + z (int, optional): zoom camera position from 0 to 1000. Defaults to 0. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + + "/absolute" + ) + xml = ''.join( + '' + + '' + + '' + str(y) + '' + + '' + str(x) + '' + + '' + str(z) + '' + + '' + ) + response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml) + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def __setcameramovcon(self, x: int = 0, y: int = 0, z: int = 0) -> bool: + """Set camera moving to direction until other signal or 180 seconds elapse. + + Args: + x (int, optional): acceleration of horizontal camera movement from -100 to 100. + Defaults to 0. + y (int, optional): acceleration of vertical camera movement from -100 to 100. + Defaults to 0. + z (int, optional): acceleration of zoom camera movement from -100 to 100. + Defaults to 0. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + + "/continuous" + ) + xml = ''.join( + '' + + '' + + '' + str(x) + '' + + '' + str(y) + '' + + '' + str(z) + '' + + '' + ) + response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml) + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def __setcameramovmom(self, x: int = 0, y: int = 0, z: int = 0, t: int = 180000) -> bool: + """Set camera moving to direction until other signal or duration elapse. + + Args: + x (int, optional): acceleration of horizontal camera movement from -100 to 100. + Defaults to 0. + y (int, optional): acceleration of vertical camera movement from -100 to 100. + Defaults to 0. + z (int, optional): acceleration of zoom camera movement from -100 to 100. + Defaults to 0. + t (int, optional): duration in ms of acceleration from 0 to 180000. + Defaults to 180000. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + + "/momentary" + ) + xml = ''.join( + '' + + '' + + '' + str(x) + '' + + '' + str(y) + '' + + '' + str(z) + '' + + '' + str(t) + '' + + '' + ) + response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml) + if response != 'ERROR': + sleep(t/1000) + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setcameramov(self, x: int = 0, y: int = 0, z: int = 0, t: int = 0) -> bool: + """Set camera moving to direction (polymorph abstraction). + + Args: + x (int, optional): acceleration of horizontal camera movement from -100 to 100. + Defaults to 0. + y (int, optional): acceleration of vertical camera movement from -100 to 100. + Defaults to 0. + z (int, optional): acceleration of zoom camera movement from -100 to 100. + Defaults to 0. + t (int, optional): duration in ms of acceleration from 0 to 180000. + Defaults to 0. + + Returns: + bool: True if successed. + """ + if t == '-' or int(t) == 0: + return self.__setcameramovcon(x=int(x), y=int(y), z=int(z)) + else: + return self.__setcameramovmom(x=int(x), y=int(y), z=int(z), t=int(t)) + + def setmovtohome(self) -> bool: + """Set camera moving to homeposition. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + + "/homeposition/goto" + ) + xml = ''.join( + '' + + '' + ) + response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml) + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def setposashome(self) -> bool: + """Save current camera position as homeposition. + + Returns: + bool: True if successed. + """ + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/PTZCtrl/channels/" + str(self._chan) + + "/homeposition" + ) + xml = ''.join( + '' + + '' + ) + response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml) + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + def settextonosd( + self, + enabled: str = "true", + x: int = 0, + y: int = 0, + message: str = "" + ) -> bool: + """Set message as video overlay text. + + Args: + enabled (str, optional): true or false. Defaults to "true". + x (int, optional): horizontal text position from 0 to video width. Defaults to 0. + y (int, optional): vertical text position from 0 to video heith. Defaults to 0. + message (str, optional): overlay text content. Defaults to "". + + Returns: + bool: True if successed. + """ + if message == '-': + message = "" + url = ( + self._prot + '://' + self._host + ':' + str(self._port) + + "/ISAPI/System/Video/inputs/channels/" + str(self._chan) + + "/overlays/text" + ) + xml = ''.join( + '' + + '' + + '' + + '1' + + '' + enabled + '' + + '' + str(x) + '' + + '' + str(y) + '' + + '' + message + '' + + '' + + '' + ) + response = self.__call(url=url, method="PUT", contenttype="text/xml", contentdata=xml) + if response != 'ERROR': + logging.debug(msg='\n' + response + '\n') + return True + else: + return False + + +class Sensor(Connect): + """Representing sensor connected to remote host. + The class inherits the necessary connection methods of the Connect class + """ + def __init__( + self, + hostname: str, username: str, userpass: str, + nodetype: str, nodename: str, + hostport: int = 22 + ) -> None: + """Object constructor. + + Args: + hostname (str): sensor's remote host hostname or ip address. + username (str): sensor's remote host username. + userpass (str): sensor's remote host password. + nodetype (str): 'ds18b20' or other sensor type. + nodename (str): 28-1a2b3c4d5e6f (ds18b20 example). + hostport (int, optional): sensor's remote host connection port. Defaults to 22. + """ + self._host = hostname + self._port = hostport + self._user = username + self._pswd = userpass + self._type = nodetype + self._node = nodename + + def __call(self, command: str) -> str: + """Send request to sensor's remote host. + + Args: + command (str): command to poll the sensor. + + Returns: + str: sensor's remote host response content. + """ + return self.ssh_commands( + command=command, + hostname=self._host, port=self._port, + username=self._user, password=self._pswd + ) + + # pylint: disable=W0718 + def __temperature(self, nodename: str) -> str: + """Preparating request for ds18b20 sensor type. + + Args: + nodename (str): 28-1a2b3c4d5e6f (ds18b20 example). + + Returns: + str: formatted string with temperature in Celsius. + """ + command = 'cat /sys/bus/w1/devices/' + nodename + '/temperature' + response = self.__call(command=command) + if response != 'ERROR': + try: + temperature = str(int(response)//1000) + "'C" + return temperature + except Exception as error: + logging.debug( + msg='' + + '\n' + 'host: ' + self._host + ':' + str(self._port) + + '\n' + 'user: ' + self._user + + '\n' + 'pass: ' + self._pswd + + '\n' + 'command: ' + command + + '\n' + 'error: ' + str(error) + ) + return 'ERROR' + + def value(self) -> str: + """Public method to get sensor value. + + Returns: + str: sensor value. + """ + if self._type == 'ds18b20': + return self.__temperature(nodename=self._node) + + +class Wordpress(Connect): + """Set of methods (functions) for Wordpress API. + Reference: https://developer.wordpress.org/rest-api/reference/ + + Args: + Connect (_type_): class with 'http' method. + """ + def __init__( + self, + hostname: str, + username: str, + password: str + ): + """Object constructor. + + Args: + hostname (str, optional): www.wordpress.site. + username (str, optional): wordpress username. + password (str, optional): wordpress passwrod. + """ + if Do.args_valid(locals(), self.__init__.__annotations__): + self._host = hostname + self._user = username + self._pass = password + self.api_event = 'https://' + self._host + '/wp-json/tribe/events/v1/events' + self.api_media = 'https://' + self._host + '/wp-json/wp/v2/media' + self.api_pages = 'https://' + self._host + '/wp-json/wp/v2/pages' + self.url_files = 'https://' + self._host + '/wp-content/uploads' + + def event_create( + self, + title: str, + slug: str, + date_start: str, + date_end: str, + date_publish: str = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + all_day: bool = True, + description: str = None + ) -> dict: + """Create event by 'wp-json' and 'The Events Calendar'. + + Args: + title (str, optional): event title. + slug (str, optional): event slug. + date_start (str, optional): '2022-12-31T23:59:59' format. + date_end (str, optional): '2022-12-31T23:59:59' format. + date_publish (_type_, optional): '2022-12-31T23:59:59' format. Defaults to now. + all_day (bool, optional): all day event duration flag. Defaults to True. + description (str, optional): event body. Defaults to None. + + Raises: + ValueError: date formate is not 2022-12-31T23:59:59. + ValueError: description can't be empty. + + Returns: + dict: {'success':bool,'result':'http/url/to/event'}. + """ + if Do.args_valid(locals(), self.event_create.__annotations__): + pattern = "^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})+$" + if ( + not re.fullmatch(pattern, date_start) or + not re.fullmatch(pattern, date_end) or + not re.fullmatch(pattern, date_publish) + ): + raise ValueError("date formate is not 2022-12-31T23:59:59") + if description == '': + raise ValueError("description can't be empty") + + event_json = { + "title": title, + "slug": slug, + "date": date_publish, + "start_date": date_start, + "end_date": date_end, + "all_day": str(all_day), + "description": description + } + response = self.http( + url=self.api_event, + method='POST', + username=self._user, + password=self._pass, + authtype='basic', + contenttype='application/json; charset=UTF-8', + contentdata=json.dumps(event_json, indent=2) + ) + logging.debug( + msg="" + + "\n" + "event API response: " + + "\n" + json.dumps(json.loads(response['result']), indent=2) + ) + if response['success']: + for key, val in json.loads(response['result']).items(): + if key == "url": + logging.info('event created: %s', val) + return {"success": True, "result": val} + else: + logging.warning("event didn't create") + return {"success": False, "result": "ERROR"} + + def media_search( + self, + media_name: (str, type(None)) = None, + media_type: (str, type(None)) = None + ) -> dict: + """Search uploaded media by 'wp-json'. + + Args: + media_name (str, type, optional): results matching a string. Defaults to None. + media_type (str, type, optional): application,image,video,audio,text. Defaults to None. + + Returns: + dict: {'success':bool,'result':['list/of/link/to/media']} + """ + if Do.args_valid(locals(), self.media_search.__annotations__): + url = self.api_media + '?per_page=100' + if media_name: + url = url + '&search=' + media_name + if (media_type == 'application' or + media_type == 'image' or + media_type == 'video' or + media_type == 'audio' or + media_type == 'text' + ): + url = url + '&media_type=' + media_type + + media_list = [] + response = self.http(url=url, method='GET') + logging.debug( + msg="" + + "\n" + "media API response: " + + "\n" + json.dumps(json.loads(response['result']), indent=2) + ) + if response['success']: + for media in json.loads(response['result']): + media_list.append(media['guid']['rendered']) + return {"success": True, "result": media_list} + else: + logging.warning("media didn't list") + return {"success": False, "result": "ERROR"} + + def media_upload( + self, + mediafile: str, + mediatype: str, + aliasfile: str = '' + ) -> dict: + """Upload media by 'wp-json'. + + Args: + mediafile (str, optional): path to file. + mediatype (str, optional): 'image/jpeg', 'video/mp4', etc. + aliasfile (str, optional): uploaded media name. Defaults to original file. + + Raises: + FileExistsError: mediafile is not exist. + + Returns: + dict: {'success':bool,'result':'http/url/to/media'}. + """ + if Do.args_valid(locals(), self.media_upload.__annotations__): + if not path.exists(mediafile): + raise FileExistsError(mediafile + " is not exist") + else: + with open(mediafile, mode='rb') as file: + mediadata = file.read() + if aliasfile == '': + aliasfile = path.basename(mediafile) + + response = self.http( + url=self.api_media, + method='POST', + username=self._user, + password=self._pass, + authtype='basic', + contenttype=mediatype, + contentdata=mediadata, + headers={ + "Accept": "application/json", + 'Content-Disposition': 'attachment; filename=' + aliasfile, + 'Cache-Control': 'no-cache' + } + ) + logging.debug( + msg="" + + "\n" + "media API response: " + + "\n" + json.dumps(json.loads(response['result']), indent=2) + ) + if response['success']: + for key, val in json.loads(response['result']).items(): + if key == "source_url": + logging.info('media uploaded: %s', val) + return {"success": True, "result": val} + else: + logging.warning("media didn't upload") + return {"success": False, "result": "ERROR"} + + def pages_read( + self, + page_id: int + ) -> dict: + """Read page by 'wp-json'. + + Args: + page_id (int): unique identifier for the page. + + Returns: + dict: {'success':bool,'result':'page data'} + """ + if Do.args_valid(locals(), self.pages_read.__annotations__): + page_link = self.api_pages + '/' + str(page_id) + response = self.http(url=page_link) + if response['success']: + logging.debug( + msg="" + + "\n" + "wp page API response: " + + "\n" + json.dumps(json.loads(response['result']), indent=2) + ) + return {"success": True, "result": response['result']} + else: + logging.warning("wp page didn't read") + return {"success": False, "result": "ERROR"} + + def pages_update( + self, + page_id: int, + content: str + ) -> dict: + """Update page by 'wp-json'. + + Args: + page_id (int): unique identifier for the page. + content (str): the content for the page. + + Returns: + dict: {'success':bool,'result':'http/url/to/page'} + """ + if Do.args_valid(locals(), self.pages_update.__annotations__): + page_link = self.api_pages + '/' + str(page_id) + page_json = { + "content": content + } + response = self.http( + url=page_link, + method='POST', + username=self._user, + password=self._pass, + authtype='basic', + contenttype='application/json; charset=UTF-8', + contentdata=json.dumps(page_json) + ) + logging.debug( + msg="" + + "\n" + "wp page API response: " + + "\n" + json.dumps(json.loads(response['result']), indent=2) + ) + if response['success']: + for key, val in json.loads(response['result']).items(): + if key == "link": + logging.info(msg="wp page " + str(page_id) + " updated: " + val) + return {"success": True, "result": val} + else: + logging.warning("wp page didn't update") + return {"success": False, "result": "ERROR"} + + +class Telegram(): + """Set of methods (functions) for Telegram Bot API. + Reference: https://core.telegram.org/bots/api#available-methods + """ + def __init__(self, token: str): + """Object constructor. + + Args: + token (str): Telegram Bot API access token. + """ + if Do.args_valid(locals(), self.__init__.__annotations__): + self._token = token + self.api_root = 'https://api.telegram.org' + self.api_path = self.api_root + '/bot' + self._token + + def send_message(self, chat: str, text: str, parse_mode: str = 'HTML') -> dict: + """Send text message. + + Args: + chat (str): unique identifier for the target chat or username of the target channel. + text (str): text of the message to be sent, 1-4096 characters after entities parsing. + parse_mode (str, optional): 'HTML', 'Markdown', 'MarkdownV2'. Defaults to 'HTML'. + + Returns: + dict: {"success":bool,"result":"API response" or "ERROR"} + """ + if Do.args_valid(locals(), self.send_message.__annotations__): + url=self.api_path + '/sendMessage' + data = { + "chat_id": chat, + "text": text, + "parse_mode": parse_mode, + "disable_notification": True + } + response = requests.post(url=url, json=data, timeout=15) + if response.status_code == 200: + response = response.json() + logging.info(msg="" + + "message '" + + str(response['result']['message_id']) + + "' sent to telegram chat " + + str(chat) + ) + return {'success': True, 'result': response} + else: + logging.warning(msg="message didn't send to telegram chat " + str(chat)) + return {'success': False, 'result': response} + + def delete_message(self, chat: str, message_id: int) -> dict: + """Delete message. + + Args: + chat (str): unique identifier for the target chat or username of the target channel. + message_id (int): identifier of the message to delete. + + Returns: + dict: {"success":bool,"result":"API response" or "ERROR"} + """ + if Do.args_valid(locals(), self.delete_message.__annotations__): + url=self.api_path + '/deleteMessage' + data = {"chat_id": chat, "message_id": message_id} + response = requests.post(url=url, json=data, timeout=15) + if response.status_code == 200: + response = response.json() + logging.info(msg="" + + "message '" + str(message_id) + "' deleted from telegram chat " + + str(chat) + ) + return {'success': True, 'result': response} + else: + logging.warning(msg="" + + "message '" + str(message_id) + "' didn't deleted from telegram chat " + + str(chat) + ) + return {'success': False, 'result': response} + + def __send_media( + self, + chat: str, + media_path: str, + media_type: str, + caption: (str, type(None)), + parse_mode: str, + disable_notification: bool, + additional_url_param: (str, type(None)) + ) -> dict: + """Send media by api.telegram.org. + + Args: + chat (str): unique identifier for the target chat or username of the target channel. + media_path (str): /local/path/to/file, https://url/to/file, file_id=EXISTFILEID. + media_type (str): 'document', 'photo', 'video', 'audio'. + caption (str, None): media caption less 1024 characters. + parse_mode (str): caption 'HTML', 'Markdown', 'MarkdownV2' parse mode. + disable_notification (bool): send silently. + additional_url_param (str, None): example: '&duration=30&width=960&height=540'. + + Raises: + ValueError: "'media_type' value is wrong" + + Returns: + dict: {'success':bool,'result':response}. + """ + if Do.args_valid(locals(), self.__send_media.__annotations__): + if ( + media_type == 'document' or + media_type == 'photo' or + media_type == 'video' or + media_type == 'audio' + ): + url = self.api_path + '/send' + media_type + '?chat_id=' + chat + else: + raise ValueError("'media_type' value is wrong: " + media_type) + + if caption: + url = url + '&caption=' + caption + '&parse_mode=' + parse_mode + if disable_notification: + url = url + "&disable_notification=True" + if additional_url_param: + url = url + additional_url_param + if re.match("^(?:http://|https://|file_id=)", media_path): + media_path = media_path.replace('file_id=', '') + response = requests.post( + url=url + "&" + media_type + "=" + media_path, + timeout=60 + ) + if response.status_code == 200: + response = response.json() + if media_type == 'photo': + file_id = response['result'][media_type][-1]['file_id'] + else: + file_id = response['result'][media_type]['file_id'] + logging.info(msg="" + + media_type + + " '" + + str(file_id) + + "' sent to telegram chat " + + chat + ) + return {'success': True, 'result': response} + else: + response = requests.post( + url=url, + files={media_type: open(media_path, "rb")}, + timeout=60 + ) + if response.status_code == 200: + response = response.json() + if media_type == 'photo': + file_id = response['result'][media_type][-1]['file_id'] + else: + file_id = response['result'][media_type]['file_id'] + logging.info(msg="" + + media_type + + " '" + + str(file_id) + + "' sent to telegram chat " + + chat + ) + return {'success': True, 'result': response} + logging.warning( + msg=media_type + " " + media_path + " didn't send to telegram chat " + str(chat) + ) + return {'success': False, 'result': response} + + def send_document( + self, + chat: str, + document: str, + caption: (str, type(None)) = None, + parse_mode: str = 'HTML', + disable_notification: bool = True + ) -> dict: + """Send document. See self.__send_media(). + """ + if Do.args_valid(locals(), self.send_document.__annotations__): + return self.__send_media( + chat=chat, + media_path=document, + media_type='document', + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + additional_url_param=None + ) + + def send_photo( + self, + chat: str, + photo: str, + caption: (str, type(None)) = None, + parse_mode: str = 'HTML', + disable_notification: bool = True + ) -> dict: + """Send photo. See self.__send_media(). + """ + if Do.args_valid(locals(), self.send_photo.__annotations__): + return self.__send_media( + chat=chat, + media_path=photo, + media_type='photo', + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + additional_url_param=None + ) + + def send_video( + self, + chat: str, + video: str, + width: (int, type(None)) = None, + height: (int, type(None)) = None, + duration: (int, type(None)) = None, + caption: (str, type(None)) = None, + parse_mode: str = 'HTML', + disable_notification: bool = True + ) -> dict: + """Send video. See self.__send_media(). + """ + if Do.args_valid(locals(), self.send_video.__annotations__): + + if width or height or duration: + additional_url_param = '' + if width: + additional_url_param += '&width=' + str(width) + if height: + additional_url_param += '&height=' + str(height) + if duration: + additional_url_param += '&duration=' + str(duration) + else: + additional_url_param = None + + return self.__send_media( + chat=chat, + media_path=video, + media_type='video', + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + additional_url_param=additional_url_param + ) + + def send_audio( + self, + chat: str, + audio: str, + caption: (str, type(None)) = None, + parse_mode: str = 'HTML', + disable_notification: bool = True + ) -> dict: + """Send audio. See self.__send_media(). + """ + if Do.args_valid(locals(), self.send_audio.__annotations__): + return self.__send_media( + chat=chat, + media_path=audio, + media_type='audio', + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + additional_url_param=None + ) + + def send_mediagroup( + self, + chat: str, + media: dict, + caption: (str, type(None)) = None, + parse_mode: str = 'HTML', + disable_notification: bool = True + ) -> dict: + """Send media group of photo, video, audio, documents. + + Args: + chat (str): unique identifier for the target chat or username of the target channel. + media (dict): { + name:{'type':'photo',path:'https://url/to/file',caption:text}, + name:{'type':'video',path:'/local/path/to/file',caption:text}, + name:{'type':'audio',path:'file_id=EXISTFILEID',caption:text}, + }. + caption (str, type, optional): media caption less 1024 characters. Defaults to None. + parse_mode (str): caption 'HTML', 'Markdown', 'MarkdownV2' parse mode. + disable_notification (bool, optional): send silently. Defaults to True. + + Returns: + dict: {'success':bool,'result':response}. + """ + if Do.args_valid(locals(), self.send_mediagroup.__annotations__): + url=self.api_path + '/sendMediaGroup' + files = {} + group = [] + + for media_name in media.keys(): + if re.match("^(?:http://|https://|file_id=)", media[media_name]['path']): + files[media_name] = None + media_source = media[media_name]['path'].replace('file_id=', '') + else: + with open(media[media_name]['path'], mode='rb') as file: + files[media_name] = file.read() + media_source = "attach://" + media_name + + if not caption and media[media_name]['caption']: + media_caption = media[media_name]['caption'] + else: + media_caption = '' + + group.append({ + "type": media[media_name]['type'], + "media": media_source, + "caption": media_caption + } + ) + + if caption: + group[0]['caption'] = caption + group[0]['parse_mode'] = parse_mode + + data = { + 'chat_id': chat, + 'media': json.dumps(group), + "disable_notification": disable_notification + } + + response = requests.post(url=url, data=data, files=files, timeout=300) + if response.status_code == 200: + response = response.json() + logging.info(msg="" + + "mediagroup '" + + str(response['result'][0]['media_group_id']) + + "' sent to telegram chat " + + str(chat) + ) + return {'success': True, 'result': response} + logging.warning(msg="mediagroup didn't send to telegram chat " + str(chat)) + return {'success': False, 'result': response} + + +class Sequence: + """Sequence handling. + """ + @staticmethod + # pylint: disable=W0718 + def run( + device: HikISAPI, sensors: dict, sequence: dict, + records_root_path: str = None, + records_root_user: str = None, + records_root_pass: str = None + ) -> None: + """Sequences executor. + + Args: + device (HikISAPI): HikISAPI object. + sensors (dict): collection as key=sensorname:value=Sensor object. + sequence (dict): sequence steps collection. + records_root_path (str, optional): path (local|smb|ftp,sftp) to records directory. + Defaults to None. + records_root_user (str, optional): username if path on remote host. + Defaults to None. + records_root_pass (str, optional): password if path on remote host. + Defaults to None. + """ + for key, value in sequence.items(): + action = value.split(',')[0].strip() + x = value.split(',')[1].strip() + y = value.split(',')[2].strip() + z = value.split(',')[3].strip() + p = value.split(',')[4].strip() + s = value.split(',')[5].strip() + t = value.split(',')[6].strip() + w = value.split(',')[7].strip() + m = value.split(',')[8].strip() + if 'sensor-config:' in m: + sensor_name = m.split(':')[1].strip() + sensor_value = sensors[sensor_name].value() + if sensor_value != 'ERROR': + m = sensor_value + else: + m = '' + logging.info(msg='' + + ' action:' + key + ' = ' + action + + ',' + x + ',' + y + ',' + z + + ',' + p + ',' + s + ',' + t + + ',' + w + ',' + m + ) + if action == 'capabilities': + response = device.capabilities() + elif action == 'getcamerapos': + response = device.getcamerapos() + elif action == 'rebootcamera': + response = device.rebootcamera() + elif action == 'setptzmovyyu': + response = device.setptzmovyyu(speed=int(s)) + elif action == 'setptzmovyyd': + response = device.setptzmovyyd(speed=int(s)) + elif action == 'setptzmovxxl': + response = device.setptzmovxxl(speed=int(s)) + elif action == 'setptzmovxxr': + response = device.setptzmovxxr(speed=int(s)) + elif action == 'setptzmovzzi': + response = device.setptzmovzzi(speed=int(s)) + elif action == 'setptzmovzzo': + response = device.setptzmovzzo(speed=int(s)) + elif action == 'setptzpreset': + response = device.setptzpreset(preset=int(p), speed=int(s)) + elif action == 'setptztostop': + response = device.setptztostop() + elif action == 'setcamerapos': + response = device.setcamerapos(x=int(x), y=int(y), z=int(z)) + elif action == 'setcameramov': + response = device.setcameramov(x=int(x), y=int(y), z=int(z), t=t) + elif action == 'setmovtohome': + response = device.setmovtohome() + elif action == 'setposashome': + response = device.setposashome() + elif action == 'settextonosd': + response = device.settextonosd(x=int(x), y=int(y), message=m) + elif action == 'downloadjpeg': + records_root_temp = records_root_path + if records_root_temp != path.dirname(path.realpath(__file__)): + records_root_temp = path.dirname(path.realpath(__file__)) + sep + 'temp' + makedirs(records_root_temp, exist_ok=True) + dy = datetime.datetime.now().strftime('%Y') + dm = datetime.datetime.now().strftime('%m') + dv = datetime.datetime.now().strftime('%V') + dd = datetime.datetime.now().strftime('%d') + th = datetime.datetime.now().strftime('%H') + tm = datetime.datetime.now().strftime('%M') + ts = datetime.datetime.now().strftime('%S') + records_file_name = ( + key + '_' + dy + '-' + dm + '-' + dd + '_' + th + '.' + tm + '.' + ts + '.jpeg' + ) + if device.downloadjpeg( + x=int(x), + y=int(y), + dst_file=records_root_temp + sep + records_file_name + ): + hostname = 'localhost' + hostport, hosttype = None, None + username = records_root_user + userpass = records_root_pass + hostpath = records_root_path + if '://' in records_root_path: + hostname = records_root_path.split('/')[2] + hosttype = records_root_path.split('://')[0] + if hosttype == 'ftp': + hostport = 21 + if hosttype == 'sftp': + hostport = 22 + if hosttype == 'smb': + hostport = 445 + hostpath = records_root_path.replace(hosttype + '://' + hostname, '') + if '@' in hostname: + username = hostname.split('@')[0].split(':')[0] + userpass = hostname.split('@')[0].split(':')[1] + hostname = hostname.split('@')[1] + if ':' in hostname: + hostport = int(hostname.split(':')[1]) + hostname = hostname.split(':')[0] + if hosttype == 'ftp': + src_file = records_root_temp + sep + records_file_name + dst_file = ( + hostpath + + '/' + dy + '/' + dm + '/' + dv + '/' + dd + '/' + + records_file_name + ) + if Connect.ftp_put_file( + src_file=src_file, + dst_file=dst_file, + hostname=hostname, + username=username, + password=userpass + ): + try: + remove(src_file) + except OSError: + pass + elif hosttype == 'sftp': + src_file = records_root_temp + sep + records_file_name + dst_file = ( + hostpath + + '/' + dy + '/' + dm + '/' + dv + '/' + dd + '/' + + records_file_name + ) + response = Connect.ssh_put_file( + src_file=src_file, dst_file=dst_file, + hostname=hostname, port=hostport, + username=username, password=userpass) + if response != 'ERROR': + try: + remove(src_file) + except OSError: + pass + response = True + else: + response = False + else: + src_file = records_root_temp + sep + records_file_name + dst_file = ( + hostpath + + sep + dy + sep + dm + sep + dv + sep + dd + sep + + records_file_name) + try: + makedirs( + hostpath + sep + dy + sep + dm + sep + dv + sep + dd, + exist_ok=True + ) + replace(src=src_file, dst=dst_file) + response = True + except Exception as error: + logging.debug(msg='' + + '\n' + 'src_file: ' + src_file + + '\n' + 'dst_file: ' + dst_file + + '\n' + 'error: ' + str(error) + ) + response = False + else: + response = False + if w != '-' or float(w) != 0: + sleep(float(w)) + if response: + logging.info(msg=' result:' + key + ' = OK') + else: + logging.warning(msg='result:' + key + ' = ERROR') + + +class Convert: + """Convert handling. + """ + @staticmethod + # pylint: disable=W0612 + def run( + image_root_path: (str, list), + image_find_names: list, + video_dest_path: str, + video_dest_sufx: str, + video_scale_x: int, + video_scale_y: int, + video_framerate: int, + video_duration: int, + temp_path: str, + image_root_user: (str, type(None)) = None, + image_root_pass: (str, type(None)) = None, + video_dest_user: (str, type(None)) = None, + video_dest_pass: (str, type(None)) = None + ) -> None: + """Converting executor. + + Args: + image_root_path (str, list): source images path. + image_find_names (list): image names to search. + video_dest_path (str): path to destination video. + video_dest_sufx (str): destination video name suffix. + video_scale_x (int): destination video width. + video_scale_y (int): destination video height. + video_framerate (int): destination video frame per second. + video_duration (int): destination video duration. + temp_path (str): path to directory for temp files. + image_root_user (str, type, optional): username to source images ftp,sftp,smb path. + Defaults to None. + image_root_pass (str, type, optional): password to source images ftp,sftp,smb path. + Defaults to None. + video_dest_user (str, type, optional): username to destination video ftp,sftp,smb path. + Defaults to None. + video_dest_pass (str, type, optional): password to destination video ftp,sftp,smb path. + Defaults to None. + """ + if isinstance(image_root_path, str): + image_root_path = [image_root_path] + + temp_path = temp_path + sep + Do.random_string(8) + temp_files = [] + for name in image_find_names: + image_found = [] + for image_root in image_root_path: + if '://' in image_root: + image_hostname = image_root.split('/')[2] + image_hosttype = image_root.split('://')[0] + if image_hosttype == 'ftp': + image_hostport = 21 + if image_hosttype == 'sftp': + image_hostport = 22 + if image_hosttype == 'smb': + image_hostport = 445 + image_hostpath = image_root.replace(image_hosttype + '://' + image_hostname, '') + if '@' in image_hostname: + image_root_user = image_hostname.split('@')[0].split(':')[0] + image_root_pass = image_hostname.split('@')[0].split(':')[1] + image_hostname = image_hostname.split('@')[1] + if ':' in image_hostname: + image_hostport = int(image_hostname.split(':')[1]) + image_hostname = image_hostname.split(':')[0] + if image_hosttype == 'ftp': + image_found = image_found + Connect.ftp_file_search( + root_path=image_hostpath, + search_name=name, + hostname=image_hostname, + username=image_root_user, + password=image_root_pass + ) + makedirs(temp_path, exist_ok=True) + for image in image_found: + if Connect.ftp_get_file( + src_file=image, + dst_file=temp_path + sep + path.basename(image), + hostname=image_hostname, + username=image_root_user, + password=image_root_pass + ): + temp_files.append(temp_path + sep + path.basename(image)) + elif image_hosttype == 'sftp': + pass + else: + pass + + image_temp_list = temp_path + sep + 'convert.list' + image_amount = 0 + with open(image_temp_list, mode='w+', encoding='UTF-8') as converter_list: + for file in Do.file_search(root_path=temp_path, search_name=name): + converter_list.write("\nfile '" + file + "'") + image_amount += 1 + temp_files.append(image_temp_list) + + video_converted = name + '_' + video_dest_sufx + '.mp4' + video_temp_path = temp_path + sep + video_converted + video_conv_conf = ('' + + '-r ' + + str(image_amount) + '/' + str(video_duration) + + ' -f concat -safe 0 -i ' + + image_temp_list + + ' -c:v libx264 -vf scale=' + str(video_scale_x) + ':' + str(video_scale_y) + + ',fps=' + str(video_framerate) + ',format=yuv420p ' + + video_temp_path + + ' -y' + ) + + if FFmpeg.run(raw=video_conv_conf) == 0: + temp_files.append(video_temp_path) + + if '://' in video_dest_path: + video_hostname = video_dest_path.split('/')[2] + video_hosttype = video_dest_path.split('://')[0] + if video_hosttype == 'ftp': + video_hostport = 21 + if video_hosttype == 'sftp': + video_hostport = 22 + if video_hosttype == 'smb': + video_hostport = 445 + video_hostpath = video_dest_path.replace( + video_hosttype + '://' + video_hostname, + '' + ) + if '@' in image_hostname: + video_dest_user = video_hostname.split('@')[0].split(':')[0] + video_dest_pass = video_hostname.split('@')[0].split(':')[1] + video_hostname = video_hostname.split('@')[1] + if ':' in video_hostname: + video_hostport = int(video_hostname.split(':')[1]) + video_hostname = video_hostname.split(':')[0] + if video_hosttype == 'ftp': + if Connect.ftp_put_file( + src_file=video_temp_path, + dst_file=video_hostpath + '/' + video_converted, + hostname=video_hostname, + username=video_dest_user, + password=video_dest_pass + ): + pass + elif image_hosttype == 'sftp': + pass + else: + pass + + for temp_file in temp_files: + try: + remove(temp_file) + except OSError as error: + logging.debug(msg='\n' + 'error: ' + str(error)) + try: + rmdir(temp_path) + except OSError as error: + logging.debug(msg='\n' + 'error: ' + str(error)) + + +class Proc: + """Find a running process from Python. + """ + @classmethod + # pylint: disable=W0612 + def _list_windows(cls) -> list: + """Find all running process with wmi. + + Returns: + list: dictionaries with descriptions of found processes. + """ + execlist = [] + separate = b'\r\r\n' + out, err = Popen( + [ + 'wmic', 'process', 'get', + 'CommandLine,ExecutablePath,Name,ProcessId', + '/format:list' + ], + stdout=PIPE, + stderr=PIPE + ).communicate() + for line in out.split(separate + separate): + execpid, exename, exepath, cmdline = None, None, None, None + for subline in line.split(separate): + if b'ProcessId=' in subline: + execpid = subline.split(b'=')[1].decode('utf-8') + if b'Name=' in subline: + exename = subline.split(b'=')[1].decode('utf-8') + if b'ExecutablePath=' in subline: + exepath = subline.split(b'=')[1].decode('utf-8') + if b'CommandLine=' in subline: + cmdline = subline.split(b'=')[1].decode('utf-8') + if execpid and exename: + execlist.append( + { + 'execpid': execpid, + 'exename': exename, + 'exepath': exepath, + 'cmdline': cmdline + } + ) + return execlist + + @classmethod + # pylint: disable=W0612 + def _list_linux(cls) -> list: + """Find all running process with ps. + + Returns: + list: dictionaries with descriptions of found processes. + """ + execlist = [] + out, err = Popen( + [ + '/bin/ps', '-eo', 'pid,args' + ], + stdout=PIPE, + stderr=PIPE + ).communicate() + for line in out.splitlines(): + execpid = line.split()[0].decode('utf-8') + exepath = line.split()[1].decode('utf-8') + exename = path.basename(exepath) + cmdline = line.split(None, 1)[1].decode('utf-8') + if execpid and exename: + execlist.append( + { + 'execpid': execpid, + 'exename': exename, + 'exepath': exepath, + 'cmdline': cmdline + } + ) + return execlist + + @classmethod + def list_all(cls) -> list: + """Find all running process. + + Returns: + list: dictionaries with descriptions of found processes. + """ + if platform.startswith('linux') or platform.startswith('darwin'): + return cls._list_linux() + elif platform.startswith('win32'): + return cls._list_windows() + else: + return None + + @classmethod + # pylint: disable=W0150 + def search(cls, find: str, exclude: str = None) -> list: + """Find specified processes. + + Args: + find (str): find process pid, name or arguments. + exclude (str, optional): exclude process pid, name or arguments. Defaults to None. + + Returns: + list: dictionaries with descriptions of found processes. + """ + proc_found = [] + try: + for proc in cls.list_all(): + if exclude and ( + exclude in proc['execpid'] or + exclude in proc['exename'] or + exclude in proc['exepath'] or + exclude in proc['cmdline'] + ): + pass + elif ( + find in proc['execpid'] or + find in proc['exename'] or + find in proc['exepath'] or + find in proc['cmdline'] + ): + proc_found.append(proc) + except TypeError as ex: + print('ON', platform, 'PLATFORM', 'search ERROR:', ex) + finally: + if len(proc_found) == 0: + return None + else: + return proc_found + + @classmethod + def kill(cls, pid: int) -> None: + """Kill the process by means of the OS. + + Args: + pid (int): process ID. + """ + if platform.startswith('linux') or platform.startswith('darwin'): + Popen(['kill', '-s', 'SIGKILL', str(pid)]) + elif platform.startswith('win32'): + Popen(['taskkill', '/PID', str(pid), '/F']) + + +class FFmpeg: + """FFmpeg management from Python. + """ + @classmethod + def run( + cls, + src: (str, type(None)) = None, + dst: str = None, + fps: int = None, + preset: str = None, + raw: (str, type(None)) = None, + ffpath: str = None, + watchdog: bool = False, + watchsec: int = None, + onlyonce: bool = False + ) -> int: + """Running the installed ffmpeg. + + Args: + src (str, type, optional): sources urls, example: + 'rtsp://user:pass@host:554/Streaming/Channels/101, anull'. Defaults to None. + dst (str, optional): destination url, example: 'rtp://239.0.0.1:5554'. Defaults to None. + fps (int, optional): frame per second encoding output. Defaults to None. + preset (str, optional): 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p. Defaults to None. + raw (str, type, optional): custom ffmpeg parameters string. Defaults to None. + ffpath (str, optional): custom path to bin, example: /usr/bin/ffmpeg. Defaults to None. + watchdog (bool, optional): detect ffmpeg freeze and terminate. Defaults to False. + watchsec (int, optional): seconds to wait before watchdog terminates. Defaults to None. + onlyonce (bool, optional): detect ffmpeg running copy and terminate. Defaults to False. + + Returns: + int: ffmpeg return code + """ + if not raw: + process = ([] + + cls._bin(ffpath).split() + + cls._src(src).split() + + cls._preset(preset, fps).split() + + cls._dst(dst).split() + ) + else: + process = cls._bin(ffpath).split() + raw.split() + + if onlyonce and Proc.search(' '.join(process)): + print('Process already exist, exit...') + else: + logging.info(msg='Starting ' + ' '.join(process)) + with Popen(process, stdout=PIPE, stderr=STDOUT) as proc: + que = None + if watchdog: + que = Queue() + Process( + target=cls._watchdog, + args=(proc.pid, watchsec, que,), + daemon=True + ).start() + for line in proc.stdout: + if not que: + logging.debug(msg=line) + else: + que.put(line) + return proc.returncode + + @classmethod + def _bin(cls, ffpath: str, tool: str = 'ffmpeg') -> str: + """Returns the path to the bin depending on the OS. + + Args: + ffpath (str): custom path to bin. + tool (str, optional): 'ffmpeg', 'ffprobe'. Defaults to 'ffmpeg'. + + Returns: + str: path to bin or None, if path does not exist. + """ + faq = ( + '\n' + 'Main download page: https://ffmpeg.org/download.html\n' + '\n' + 'Install on Linux (Debian):\n' + '\tsudo apt install -y ffmpeg\n' + '\tTarget: /usr/bin/ffmpeg\n' + '\n' + 'Install on Windows:\n' + '\tDownload and extract: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z\n' + '\tTarget: "%PROGRAMFILES%\\ffmpeg\\bin\\ffmpeg.exe"\n' + '\n' + 'Install on MacOS:\n' + '\tDownload and extract: https://evermeet.cx/ffmpeg/\n' + '\tTarget: /usr/bin/ffmpeg\n' + ) + if not ffpath: + if platform.startswith('linux') or platform.startswith('darwin'): + if tool == 'ffprobe': + ffpath = '/usr/bin/ffprobe' + else: + ffpath = '/usr/bin/ffmpeg' + elif platform.startswith('win32'): + if tool == 'ffprobe': + ffpath = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffprobe.exe" + else: + ffpath = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffmpeg.exe" + if path.exists(ffpath): + return ffpath + else: + print('ON', platform, 'PLATFORM', 'not found', tool, faq) + return None + + @classmethod + def _src(cls, sources: str) -> list: + """Parsing sources into ffmpeg format. + + Args: + sources (str): comma-separated list of sources in string format. + + Returns: + list: ffmpeg format list of sources. + """ + list_sources = [] + for src in sources.split(','): + src = src.strip() + if 'null' in src: + src = ' '.join(['-f lavfi -i', src]) + elif 'rtsp' in src: + src = ' '.join(['-rtsp_transport tcp -i', src]) + else: + src = ' '.join(['-stream_loop -1 -re -i', src]) + list_sources.append(src) + return ' '.join(list_sources) + + @classmethod + def _preset(cls, choice: str, fps: int) -> str: + """Parsing preset into ffmpeg format. + + Args: + choice (str): preset selection. + fps (int): frame per second encoding output. + + Returns: + str: ffmpeg format encoding parameters. + """ + tune = '-tune zerolatency' + video = '-c:v copy' + audio = '-c:a aac -b:a 128k' + width, height, kbps = None, None, None + if choice: + if choice == '240p': + width, height, kbps = 426, 240, 480 + if choice == '360p': + width, height, kbps = 640, 360, 720 + if choice == '480p': + width, height, kbps = 854, 480, 1920 + if choice == '720p': + width, height, kbps = 1280, 720, 3960 + if choice == '1080p': + width, height, kbps = 1920, 1080, 5940 + if choice == '1440p': + width, height, kbps = 2560, 1440, 12960 + if choice == '2160p': + width, height, kbps = 3840, 2160, 32400 + if width and height and kbps: + video = ''.join( + [ + '-vf scale=', + str(width), ':', str(height), + ',setsar=1:1' + ] + ) + video = ' '.join( + [ + video, + '-c:v libx264 -pix_fmt yuv420p -preset ultrafast' + ] + ) + if fps: + video = ' '.join([video, '-r', str(fps), '-g', str(fps * 2)]) + video = ' '.join([video, '-b:v', str(kbps) + 'k']) + return ' '.join([tune, video, audio]) + + @classmethod + def _dst(cls, destination: str) -> str: + """Parsing destination into ffmpeg format. + + Args: + destination (str): destination path or url. + + Returns: + str: ffmpeg format destination. + """ + container = '-f null' + stdout = '-v debug' # '-nostdin -nostats' # '-report' + if destination: + if 'rtmp' in destination: + container = '-f flv' + elif "rtp" in destination: + container = '-f rtp_mpegts' + else: + destination = '-' + return ' '.join([container, destination, stdout]) + + @classmethod + def _watchdog(cls, pid: int, sec: int, que: Queue = None) -> None: + """If no data arrives in the queue, kill the process. + + Args: + pid (int): process ID. + sec (int): seconds to wait for data. + que (Queue, optional): queue pointer. Defaults to None. + """ + if not sec: + sec = 5 + if que: + while True: + while not que.empty(): + print(que.get()) + sleep(sec) + if que.empty(): + Proc.kill(pid) + print('exit by watchdog') + break + exit() + + @classmethod + def probe( + cls, + target: (str, type(None)) = None, + raw: (str, type(None)) = None, + ffpath: str = None + ) -> (dict, bytes, None): + """Running the installed ffprobe. + + Args: + target (str, type, optional): media file path to probe. Defaults to None. + raw (str, type, optional): custom ffprobe parameters string. Defaults to None. + ffpath (str, optional): custom path to bin, example: /usr/bin/ffprobe. Defaults to None. + + Returns: + dict, bytes, None: ffprobe response or None. + """ + if not raw: + command = ([] + + cls._bin(ffpath=ffpath, tool='ffprobe').split() + + ('-i ' + target + + ' -v quiet -print_format json -show_format -show_programs -show_streams').split() + ) + else: + command = cls._bin(ffpath=ffpath, tool='ffprobe').split() + raw.split() + + with Popen(command, stdout=PIPE, stderr=STDOUT) as process: + result = process.communicate() + if process.returncode == 0 and not raw: + return json.loads(result[0].decode('utf-8')) + elif process.returncode == 0 and raw: + return result[0] + else: + return None + + +class Do(): + """Set of various methods (functions) for routine. + """ + @staticmethod + def args_valid(arguments: dict, annotations: dict) -> bool: + """Arguments type validating by annotations. + + Args: + arguments (dict): 'locals()' immediately after starting the function. + annotations (dict): function.name.__annotations__. + + Raises: + TypeError: type of argument is not equal type in annotation. + + Returns: + bool: True if argument types are valid. + """ + for var_name, var_type in annotations.items(): + if not var_name == 'return': + if not isinstance(arguments[var_name], var_type): + raise TypeError("" + + "type of '" + + var_name + + "' = " + + str(arguments[var_name]) + + " is not " + + str(var_type) + ) + return True + + @staticmethod + def date_calc( + target: datetime.date = datetime.datetime.now(), + amount: int = 0, + period: str = None + ) -> dict: + """Calculating start/end dates for period: day, week, month, year. + + Args: + target (datetime.date, optional): date in the calculation period. Defaults to now. + amount (int, optional): +/- periods. Defaults to 0. + period (str, optional): 'y'|'year','m'|'month','w'|'week','d'|'day'. Defaults to None. + + Raises: + ValueError: 'period' value is wrong. + + Returns: + dict: { + 'start':{'y':int,'m':int,'w':int,'d':int}, + 'end':{'y':int,'m':int,'w':int,'d':int} + }. + """ + if Do.args_valid(locals(), Do.date_calc.__annotations__): + date = {} + if not period: + raise ValueError("'period' value is wrong: " + "''") + elif period == 'd' or period == 'day': + delta = target + datetime.timedelta(days=amount) + target = delta + date['period'] = 'day' + elif period == 'w' or period == 'week': + delta = target + datetime.timedelta(weeks=amount) + target_week = str(delta.year) + '-W' + str(delta.isocalendar()[1]) + target = datetime.datetime.strptime(target_week + '-1', "%G-W%V-%u") + delta = target + datetime.timedelta(days=6) + date['period'] = 'week' + elif period == 'm' or period == 'month': + delta_month = (target.month + amount) % 12 + if not delta_month: + delta_month = 12 + delta_year = target.year + ((target.month) + amount - 1) // 12 + delta_days = calendar.monthrange(delta_year, delta_month)[1] + delta = target = target.replace( + year=delta_year, + month=delta_month, + day=1 + ) + delta = delta.replace( + year=delta_year, + month=delta_month, + day=delta_days + ) + date['period'] = 'month' + elif period == 'y' or period == 'year': + target = target.replace( + year=target.year + amount, + month=1, + day=1 + ) + delta = target.replace( + year=target.year, + month=12, + day=31 + ) + date['period'] = 'year' + else: + raise ValueError("'period' value is wrong: " + period) + date['start'] = { + 'y': target.year, + 'm': target.month, + 'w': target.isocalendar()[1], + 'd': target.day + } + date['end'] = { + 'y': delta.year, + 'm': delta.month, + 'w': delta.isocalendar()[1], + 'd': delta.day + } + return date + + @staticmethod + def random_string(length: int) -> str: + """Generate string from lowercase letters, uppercase letters, digits. + + Args: + length (int): string lenght. + + Returns: + str: random string. + """ + return ''.join(choice(ascii_letters + digits) for i in range(length)) + + @staticmethod + # pylint: disable=W0612 + def file_search(root_path: str, search_name: (str, type(None)) = None) -> list: + """Search files. + + Args: + root_path (str): where to search. + search_name (str, type, optional): full or partial filename for the filter. + Defaults to None. + + Returns: + list: list of found files. + """ + found_list = [] + if Do.args_valid(locals(), Do.file_search.__annotations__): + for root, dirs, files in walk(root_path, topdown=False): + for file in files: + if search_name: + if search_name in file: + found_list.append(path.join(path.realpath(root), file)) + else: + found_list.append(path.join(path.realpath(root), file)) + found_list.sort() + return found_list + + @staticmethod + def wp_routine_media( + wp: Wordpress, + targets_media_files: dict, + period: str, + amount: int, + page_id: int + ) -> dict: + """Custom Wordpress media routine - upload media, create media events, update media page. + + Args: + wp (Wordpress): wordpress object. + targets_media_files (dict): {'period':{'name':'local/path/to/file'}}. + period (str, optional): 'y','m','w','d'. + amount (int, optional): +/- periods. + page_id (int): unique identifier for the page. + + Returns: + dict: {'media upload': bool, 'event create': bool, 'pages update': bool} + """ + if Do.args_valid(locals(), Do.wp_routine_media.__annotations__): + default_media_links = { + "d": { + "point-01": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-01_yyyy.mm_.dd_.mp4" + ), + "point-02": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-02_yyyy.mm_.dd_.mp4" + ), + "point-04": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-04_yyyy.mm_.dd_.mp4" + ), + "point-05": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-05_yyyy.mm_.dd_.mp4" + ), + "point-11": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-11_yyyy.mm_.dd_.mp4" + ), + "point-12": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-12_yyyy.mm_.dd_.mp4" + ) + }, + "w": { + "point-01": "https://www.hmp.today/wp-content/uploads/2022/07/point-01_w.mp4", + "point-02": "https://www.hmp.today/wp-content/uploads/2022/07/point-02_w.mp4", + "point-04": "https://www.hmp.today/wp-content/uploads/2022/07/point-04_w.mp4", + "point-05": "https://www.hmp.today/wp-content/uploads/2022/07/point-05_w.mp4", + "point-11": "https://www.hmp.today/wp-content/uploads/2022/07/point-11_w.mp4", + "point-12": "https://www.hmp.today/wp-content/uploads/2022/07/point-12_w.mp4" + }, + "m": { + "point-01": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-01_yyyy.mm_.mp4" + ), + "point-02": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-02_yyyy.mm_.mp4" + ), + "point-04": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-04_yyyy.mm_.mp4" + ), + "point-05": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-05_yyyy.mm_.mp4" + ), + "point-11": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-11_yyyy.mm_.mp4" + ), + "point-12": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-12_yyyy.mm_.mp4" + ) + }, + "y": { + "point-01": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-01_yyyy.mp4" + ), + "point-02": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-02_yyyy.mp4" + ), + "point-04": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-04_yyyy.mp4" + ), + "point-05": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-05_yyyy.mp4" + ), + "point-11": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-11_yyyy.mp4" + ), + "point-12": ( + "https://www.hmp.today/wp-content/uploads/2022/07/point-12_yyyy.mp4" + ) + } + } + current_media_links = default_media_links[period] + result = {} + + # media upload + result['media upload'] = True + for name, link in current_media_links.items(): + file_found = wp.media_search( + media_name=path.basename(targets_media_files[name]), + media_type='video' + ) + if file_found['success'] and len(file_found['result']) > 0: + logging.info( + msg="" + + "upload skipped, " + + targets_media_files[name] + + " found on site" + ) + targets_media_files.pop(name, None) + current_media_links[name] = file_found['result'][0] + else: + file_upload = wp.media_upload(targets_media_files[name], 'video/mp4') + if file_upload['success']: + current_media_links[name] = file_upload['result'] + else: + result['media upload'] = False + + # event create + result['event create'] = True + for name, link in current_media_links.items(): + description = ('' + + '
' + + '' + + '
' + ) + date = Do.date_calc(period=period, amount=amount) + date_start = ('' + + str(date['start']['y']) + + '-' + + str(date['start']['m']).zfill(2) + + '-' + + str(date['start']['d']).zfill(2) + + 'T00:00:00' + ) + date_end = ('' + + str(date['end']['y']) + + '-' + + str(date['end']['m']).zfill(2) + + '-' + + str(date['end']['d']).zfill(2) + + 'T23:59:59' + ) + if period == 'd': + slug = ('' + + name + + '_' + + str(date['start']['y']) + + '-' + + str(date['start']['m']).zfill(2) + + '-' + + str(date['start']['d']).zfill(2) + ) + title = ('' + + name + + ' ' + + str(date['start']['y']) + + '.' + + str(date['start']['m']).zfill(2) + + '.' + + str(date['start']['d']).zfill(2) + ) + if period == 'w': + slug = ('' + + name + + '_' + + str(date['start']['y']) + + '-w' + + str(date['start']['w']).zfill(2) + ) + title = ('' + + name + + ' ' + + str(date['start']['y']) + + '-w' + + str(date['start']['w']).zfill(2) + ) + if period == 'm': + slug = ('' + + name + + '_' + + str(date['start']['y']) + + '-' + + str(date['start']['m']).zfill(2) + ) + title = ('' + + name + + ' ' + + str(date['start']['y']) + + '.' + + str(date['start']['m']).zfill(2) + ) + if period == 'y': + slug = ('' + + name + + '_' + + str(date['start']['y']) + ) + title = ('' + + name + + ' ' + + str(date['start']['y']) + ) + + event_api_slug = wp.api_event + '/by-slug/' + slug + if current_media_links[name] != default_media_links[period][name]: + pass + elif Connect.http(event_api_slug)['success']: + logging.info(msg="event skipped, " + event_api_slug + " found on site") + else: + event_create = wp.event_create( + title=title, + slug=slug, + date_start=date_start, + date_end=date_end, + date_publish=date_start, + description=description + ) + if not event_create['success']: + result['event create'] = False + + # pages update + result['pages update'] = True + page_read = wp.pages_read(page_id) + if page_read['success']: + content = json.loads(page_read['result'])['content']['rendered'] + for name, link in current_media_links.items(): + if period == 'd': + reg_exp = ("" + + "_(?:[0-9]{4}|yyyy)" + + ".(?:[0-9]{2}|mm_)" + + ".(?:[0-9]{2}|dd_)(?:|-[0-9]).mp4" + ) + if period == 'w': + reg_exp = "(?:_[0-9]{4}-w[0-9]{2}|_w)(?:|-[0-9]).mp4" + if period == 'm': + reg_exp = "_(?:[0-9]{4}|yyyy).(?:[0-9]{2}|mm_)(?:|-[0-9]).mp4" + if period == 'y': + reg_exp = "_(?:[0-9]{4}|yyyy)(?:|-[0-9])" + + replace = 0 + new_str = link + pattern = wp.url_files + "/[0-9]{4}/[0-9]{2}/" + name + reg_exp + for old_str in re.findall(pattern, content): + if old_str == new_str: + logging.info( + msg="" + + "page replace skipped, " + + new_str + + " found on page" + ) + else: + content = content.replace(old_str, new_str) + replace += 1 + logging.info(msg="page replace" + old_str + " to " + new_str) + + if replace > 0: + page_update = wp.pages_update(page_id = page_id, content = content) + result['pages update'] = page_update['success'] + + return result + + @staticmethod + # pylint: disable=W0612,W0511 + def tg_routine_media( + tg: Telegram, + targets_media_files: dict, + period: str, + amount: int, + chat: str + ) -> dict: + """Custom Telegram media routine - send mediagroup. + + Args: + tg (Telegram): telegram object + targets_media_files (dict): {'period':{'name':'local/path/to/file'}}. + period (str): 'y','m','w','d'. + amount (int): +/- periods. + chat (str): unique identifier for the target chat or username of the target channel. + + Raises: + ValueError: filename is not local file. + + Returns: + dict: {'success':bool,'result':response}. + """ + if Do.args_valid(locals(), Do.tg_routine_media.__annotations__): + default_caption = ("" + + "`period:` yyyy.mm.dd\n" + + "`source:` https://www.hmp.today/media\n" + + "`stream:` https://youtu.be/ikB20ML5oyo" + ) + group = { + "cover": + { + "type": "photo", + "path": ('' + + 'https://www.hmp.today/wp-content/uploads/' + + '2021/02/site-slider_hmp-qr_bwg-3840x1705-1.png' + ), + "caption": "source: https://www.hmp.today" + } + } + tmp_files = [] + + date = Do.date_calc(period=period, amount=amount) + if period == 'd': + caption_date = ('' + + str(date['start']['y']) + + '.' + + str(date['start']['m']).zfill(2) + + '.' + + str(date['start']['d']).zfill(2) + ) + if period == 'w': + caption_date = ('' + + str(date['start']['y']) + + '-w' + + str(date['start']['w']).zfill(2) + ) + if period == 'm': + caption_date = ('' + + str(date['start']['y']) + + '.' + + str(date['start']['m']).zfill(2) + ) + if period == 'y': + caption_date = ('' + + str(date['start']['y']) + ) + current_caption = default_caption.replace('yyyy.mm.dd', caption_date) + + for media_name, media_path in targets_media_files[period].items(): + if re.match("^(?:http://|https://|file_id=)", media_path): + # todo: check remote file size + raise ValueError(media_name + ' is not local file') + else: + tg_limit_video_size = 50000000 + src_video_info = FFmpeg.probe(target=media_path) + if src_video_info: + cur_video_file = media_path + cur_video_duration = int(float(src_video_info['format']['duration'])) + cur_video_fps = int( + src_video_info['streams'][0]['avg_frame_rate'].split('/')[0] + ) + cur_video_width = int(src_video_info['streams'][0]['width']) + cur_video_height = int(src_video_info['streams'][0]['height']) + + if stat(media_path).st_size >= tg_limit_video_size: + tmp_video_bitrate = int( + tg_limit_video_size*0.9/1000*8/cur_video_duration + ) + tmp_video_file = media_path.replace('.mp4', '_compressed.mp4') + cur_video_width = int(cur_video_width/2) + cur_video_height = int(cur_video_height/2) + + compressing_params = ' '.join( + [ + '-i', media_path, + '-c:v libx264 -b:v', str(tmp_video_bitrate) + 'k', + '-vf scale=' + + str(cur_video_width) + ':' + str(cur_video_height) + + ',fps=' + str(cur_video_fps) + + ',format=yuv420p -preset veryslow', + tmp_video_file, '-y -loglevel quiet -stats' + ] + ) + if FFmpeg.run(raw=compressing_params) == 0: + logging.info(msg=media_path + " comressed to " + tmp_video_file) + tmp_files.append(tmp_video_file) + cur_video_file = tmp_video_file + + response_upload = tg.send_video( + chat=chat, + video=cur_video_file, + width=cur_video_width, + height=cur_video_height, + duration=cur_video_duration + ) + if response_upload: + response_delete = tg.delete_message( + chat=chat, + message_id=response_upload['result']['result']['message_id'] + ) + group[media_name] = { + "type": "video", + "path": ( + 'file_id=' + + response_upload['result']['result']['video']['file_id'] + ) + } + + response_result = tg.send_mediagroup( + chat=chat, + media=group, + caption=current_caption, + parse_mode='Markdown' + ) + for file in tmp_files: + if remove(file): + logging.info(msg="deleted " + file) + return response_result + + +if __name__ == "__main__": + time_start = datetime.datetime.now() + + args = ArgumentParser( + prog='cctv-scheduler', + description='Hikvision PTZ IP-Camera management.', + epilog='Dependencies: ' + '- Python 3 (tested version 3.9.5), ' + '- Python 3 modules: paramiko ' + ) + args.add_argument('--config', type=str, default=path.splitext(__file__)[0] + '.conf', + required=False, + help='custom configuration file path') + args.add_argument('-b', '--broadcast', action='store_true', required=False, + help='streaming media to destination') + args.add_argument('-s', '--sequences', action='store_true', required=False, + help='run sequences from config file') + args.add_argument('-c', '--converter', action='store_true', required=False, + help='convert JPEG collection to MP4') + args.add_argument('-p', '--publisher', action='store_true', required=False, + help='publish content from templates') + args.add_argument('-d', '--day', type=int, default=0, required=False, + help=('day in amount of days from today, ' + + 'for which the publication or conversion is made') + ) + args.add_argument('-w', '--week', type=int, default=0, required=False, + help=('week in amount of weeks from today, ' + + 'for which the publication or conversion is made') + ) + args.add_argument('-m', '--month', type=int, default=0, required=False, + help=('month in amount of months from today, ' + + 'for which the publication or conversion is made') + ) + args.add_argument('-y', '--year', type=int, default=0, required=False, + help=('year in amount of years from today, ' + + 'for which the publication or conversion is made') + ) + args = vars(args.parse_args()) + + log_root = path.dirname(path.realpath(__file__)) + log_level = 'INFO' + temp_path = path.dirname(path.realpath(__file__)) + sep + 'temp' + if path.exists(args['config']): + conf = Parse(parameters=args['config'], block='common') + if 'log_root' in conf.data: + log_root = conf.data['log_root'] + if 'log_level' in conf.data: + if conf.data['log_level'] == 'DEBUG': + log_level = logging.DEBUG + elif conf.data['log_level'] == 'INFO': + log_level = logging.INFO + elif conf.data['log_level'] == 'WARNING': + log_level = logging.WARNING + elif conf.data['log_level'] == 'ERROR': + log_level = logging.ERROR + elif conf.data['log_level'] == 'CRITICAL': + log_level = logging.CRITICAL + logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d_%H.%M.%S', + handlers=[ + logging.FileHandler( + filename=log_root + sep + path.splitext(path.basename(__file__))[0] + '.log', + mode='a' + ), + logging.StreamHandler() + ], + level=log_level + ) + logging.getLogger("paramiko").setLevel(logging.WARNING) + + if 'temp_path' in conf.data: + temp_path = conf.data['temp_path'] + + if args['broadcast']: + logging.info(msg='Starting streaming media to destination') + broadcasts = {} + conf = Parse(parameters=args['config'], block='enable-broadcast') + for key, value in conf.data.items(): + if value == 'true': + broadcast_config = Parse( + parameters=args['config'], + block='broadcast-config:' + key + ).data + src = None + if 'src' in broadcast_config: + src = broadcast_config['src'] + dst = None + if 'dst' in broadcast_config: + dst = broadcast_config['dst'] + fps = None + if 'fps' in broadcast_config: + fps = broadcast_config['fps'] + preset = None + if 'preset' in broadcast_config: + preset = broadcast_config['preset'] + ffpath = None + if 'ffpath' in broadcast_config: + ffpath = broadcast_config['ffpath'] + watchdog = None + if 'watchdog' in broadcast_config: + watchdog = broadcast_config['watchdog'] + watchsec = None + if 'watchsec' in broadcast_config: + watchsec = int(broadcast_config['watchsec']) + onlyonce = None + if 'onlyonce' in broadcast_config: + onlyonce = broadcast_config['onlyonce'] + FFmpeg.run( + src=src, + dst=dst, + fps=fps, + preset=preset, + ffpath=ffpath, + watchdog=watchdog, + watchsec=watchsec, + onlyonce=onlyonce + ) + elif args['sequences']: + logging.info(msg='Starting PTZ sequences from config file') + sensors = {} + conf = Parse(parameters=args['config'], block='enable-sensor') + for key, value in conf.data.items(): + if value == 'true': + device_config = Parse( + parameters=args['config'], + block='sensor-config:' + key + ).data + device_entity = Sensor( + hostname=device_config['hostname'], + username=device_config['username'], + userpass=device_config['userpass'], + nodetype=device_config['nodetype'], + nodename=device_config['nodename'] + ) + sensors[key] = device_entity + + conf = Parse(parameters=args['config'], block='enable-sequences') + for key, value in conf.data.items(): + if value == 'true': + device_sequence = Parse( + parameters=args['config'], + block='camera-sequences:' + key + ).data + device_config = Parse( + parameters=args['config'], + block='camera-config:' + key + ).data + device_entity = HikISAPI( + hostname=device_config['hostname'], + username=device_config['username'], + userpass=device_config['userpass'] + ) + records_root_path = path.dirname(path.realpath(__file__)) + records_root_user = None + records_root_pass = None + if 'records_root_path' in device_config: + records_root_path = device_config['records_root_path'] + if 'records_root_user' in device_config: + records_root_user = device_config['records_root_user'] + if 'records_root_pass' in device_config: + records_root_pass = device_config['records_root_pass'] + Sequence.run( + device=device_entity, + sensors=sensors, + sequence=device_sequence, + records_root_path=records_root_path, + records_root_user=records_root_user, + records_root_pass=records_root_pass + ) + elif args['converter']: + if args['day'] or args['week'] or args['month'] or args['year']: + convert_period = None + convert_amount = None + video_duration = None + if args['day']: + convert_period = 'day' + convert_amount = args['day'] + if args['week']: + convert_period = 'week' + convert_amount = args['week'] + if args['month']: + convert_period = 'month' + convert_amount = args['month'] + if args['year']: + convert_period = 'year' + convert_amount = args['year'] + convert_date = Do.date_calc(period=convert_period, amount=convert_amount) + + logging.info(msg='Starting convert JPEG collection to MP4') + conf = Parse(parameters=args['config'], block='enable-convert') + for key, value in conf.data.items(): + if value == 'true': + convert_config = Parse( + parameters=args['config'], + block='convert-config:' + key + ).data + image_root_path = path.dirname(path.realpath(__file__)) + image_root_user = None + image_root_pass = None + image_find_names = None + if 'image_root_path' in convert_config: + image_root_path = convert_config['image_root_path'] + if 'image_root_user' in convert_config: + image_root_user = convert_config['image_root_user'] + if 'image_root_pass' in convert_config: + image_root_pass = convert_config['image_root_pass'] + image_find_names = [] + for name in convert_config['image_find_names'].split(','): + image_find_names.append(name.strip()) + + if convert_date['period'] == 'day': + image_root_path = ('' + + image_root_path + '/' + + str(convert_date['start']['y']) + '/' + + str(convert_date['start']['m']).zfill(2) + '/' + + str(convert_date['start']['w']).zfill(2) + '/' + + str(convert_date['start']['d']).zfill(2) + ) + video_dest_sufx = ('' + + str(convert_date['start']['y']) + '.' + + str(convert_date['start']['m']).zfill(2) + '.' + + str(convert_date['start']['d']).zfill(2) + ) + video_duration = 1 + if convert_date['period'] == 'week': + if convert_date['start']['m'] == convert_date['end']['m']: + image_root_path = ('' + + image_root_path + '/' + + str(convert_date['start']['y']) + '/' + + str(convert_date['start']['m']).zfill(2) + '/' + + str(convert_date['start']['w']).zfill(2) + ) + else: + image_root_path = [ + ('' + + image_root_path + '/' + + str(convert_date['start']['y']) + '/' + + str(convert_date['start']['m']).zfill(2) + '/' + + str(convert_date['start']['w']).zfill(2) + ), + ('' + + image_root_path + '/' + + str(convert_date['end']['y']) + '/' + + str(convert_date['end']['m']).zfill(2) + '/' + + str(convert_date['end']['w']).zfill(2) + ) + ] + video_dest_sufx = ('' + + str(convert_date['start']['y']) + '-w' + + str(convert_date['start']['w']).zfill(2) + ) + video_duration = 7 + if convert_date['period'] == 'month': + image_root_path = ('' + + image_root_path + '/' + + str(convert_date['start']['y']) + '/' + + str(convert_date['start']['m']).zfill(2) + ) + video_dest_sufx = ('' + + str(convert_date['start']['y']) + '.' + + str(convert_date['start']['m']).zfill(2) + ) + video_duration = 30 + if convert_date['period'] == 'year': + image_root_path = ('' + + image_root_path + '/' + + str(convert_date['start']['y']) + ) + video_dest_sufx = ('' + + str(convert_date['start']['y']) + ) + video_duration = 360 + + video_dest_path = path.dirname(path.realpath(__file__)) + video_dest_user = None + video_dest_pass = None + if 'video_dest_path' in convert_config: + video_dest_path = convert_config['video_dest_path'] + if 'video_dest_user' in convert_config: + video_dest_user = convert_config['video_dest_user'] + if 'video_dest_pass' in convert_config: + video_dest_pass = convert_config['video_dest_pass'] + + Convert.run( + image_root_path=image_root_path, + image_root_user=image_root_user, + image_root_pass=image_root_pass, + image_find_names=image_find_names, + video_dest_path=video_dest_path, + video_dest_sufx=video_dest_sufx, + video_dest_user=video_dest_user, + video_dest_pass=video_dest_pass, + video_scale_x=int(convert_config['video_scale_x']), + video_scale_y=int(convert_config['video_scale_y']), + video_framerate=int(convert_config['video_framerate']), + video_duration=video_duration, + temp_path=temp_path + ) + else: + logging.info(msg='Period was not selected. Exit.') + elif args['publisher']: + logging.info(msg='Starting publish content from templates') + else: + logging.info(msg='Start arguments was not selected. Exit.') + + time_execute = datetime.datetime.now() - time_start + logging.info(msg='execution time is ' + str(time_execute) + '. Exit.') diff --git a/publisher-template-page-1007.xml b/archive/0.4/publisher-template-page-1007.xml similarity index 100% rename from publisher-template-page-1007.xml rename to archive/0.4/publisher-template-page-1007.xml diff --git a/publisher.conf b/archive/0.4/publisher.conf similarity index 100% rename from publisher.conf rename to archive/0.4/publisher.conf diff --git a/publisher.sh b/archive/0.4/publisher.sh similarity index 100% rename from publisher.sh rename to archive/0.4/publisher.sh diff --git a/cctv-scheduler.conf b/cctv-scheduler.conf index 743d0d3..9ddc2b4 100644 --- a/cctv-scheduler.conf +++ b/cctv-scheduler.conf @@ -19,7 +19,7 @@ camera.test.local = true [enable-sequences] -# List the sequence/camera block names. Only blocks with the TRUE value will be used. +# List the sequence camera block names. Only blocks with the TRUE value will be used. camera.test.local = true @@ -33,6 +33,11 @@ sensor.test.local = true camera.test.local = true +[enable-publish] +# List the publish block names. Only blocks with the TRUE value will be used. +camera.test.local = true + + [broadcast-config:camera.test.local] # Broadcast parameter description block always starts with "broadcast-config:". src = rtsp://user:pass@192.168.254.253:554/Streaming/Channels/101,http://radio.fm:8000/stream.mp3 @@ -78,7 +83,7 @@ username = user userpass = pass # If a record directory on a remote host is used, a username and password must be specified. # Supported protocols: -# FTP, SFTP. +# FTP. records_root_path = ftp://192.168.254.254/Records/camera.test.local records_root_user = user records_root_pass = pass @@ -119,8 +124,9 @@ step999 = rebootcamera, -, -, -, -, -, -, [convert-config:camera.test.local] # Converter parameter description block always starts with "convert-config:". +# image_find_names = step071, image-01, image-02 -# If a image root or destination video directories on a remote host is used, a username and password must be specified. +# If image root or destination video directories on a remote host is used, username and password must be specified. # Supported protocols: # FTP. image_root_path = ftp://192.168.254.254/Records/camera.test.local @@ -133,4 +139,25 @@ video_dest_pass = pass video_scale_x = 1920 video_scale_y = 1080 -video_framerate = 25 \ No newline at end of file +video_framerate = 25 + + +[publish-config:camera.test.local] +# Publisher parameter description block always starts with "publish-config:". +video_find_names = step071, image-01, image-02 +# If a video directory on a remote host is used, a username and password must be specified. +# Supported protocols: +# FTP. +video_root_path = ftp://192.168.254.254/Downloads +video_root_user = user +video_root_pass = pass + +wp_enabled = true +wp_site_name = www.site.name +wp_user_name = user +wp_user_pass = pass +wp_update_page_id = 4848 + +tg_enabled = true +tg_api_key = TELEGRAM_API_KEY +tg_chat_id = @blackhole \ No newline at end of file diff --git a/cctv-scheduler.py b/cctv-scheduler.py index cd5df83..56a2748 100644 --- a/cctv-scheduler.py +++ b/cctv-scheduler.py @@ -16,7 +16,7 @@ from multiprocessing import Process, Queue from os import environ, makedirs, path, remove, replace, rmdir, sep, stat, walk from string import ascii_letters, digits from subprocess import Popen, PIPE, STDOUT -from sys import platform +from sys import modules, platform from time import sleep import requests from paramiko import SSHClient, AutoAddPolicy @@ -181,6 +181,403 @@ class Parse: return result +class Proc: + """Find a running process from Python. + """ + @classmethod + # pylint: disable=W0612 + def _list_windows(cls) -> list: + """Find all running process with wmi. + + Returns: + list: dictionaries with descriptions of found processes. + """ + execlist = [] + separate = b'\r\r\n' + out, err = Popen( + [ + 'wmic', 'process', 'get', + 'CommandLine,ExecutablePath,Name,ProcessId', + '/format:list' + ], + stdout=PIPE, + stderr=PIPE + ).communicate() + for line in out.split(separate + separate): + execpid, exename, exepath, cmdline = None, None, None, None + for subline in line.split(separate): + if b'ProcessId=' in subline: + execpid = subline.split(b'=')[1].decode('utf-8') + if b'Name=' in subline: + exename = subline.split(b'=')[1].decode('utf-8') + if b'ExecutablePath=' in subline: + exepath = subline.split(b'=')[1].decode('utf-8') + if b'CommandLine=' in subline: + cmdline = subline.split(b'=')[1].decode('utf-8') + if execpid and exename: + execlist.append( + { + 'execpid': execpid, + 'exename': exename, + 'exepath': exepath, + 'cmdline': cmdline + } + ) + return execlist + + @classmethod + # pylint: disable=W0612 + def _list_linux(cls) -> list: + """Find all running process with ps. + + Returns: + list: dictionaries with descriptions of found processes. + """ + execlist = [] + out, err = Popen( + [ + '/bin/ps', '-eo', 'pid,args' + ], + stdout=PIPE, + stderr=PIPE + ).communicate() + for line in out.splitlines(): + execpid = line.split()[0].decode('utf-8') + exepath = line.split()[1].decode('utf-8') + exename = path.basename(exepath) + cmdline = line.split(None, 1)[1].decode('utf-8') + if execpid and exename: + execlist.append( + { + 'execpid': execpid, + 'exename': exename, + 'exepath': exepath, + 'cmdline': cmdline + } + ) + return execlist + + @classmethod + def list_all(cls) -> list: + """Find all running process. + + Returns: + list: dictionaries with descriptions of found processes. + """ + if platform.startswith('linux') or platform.startswith('darwin'): + return cls._list_linux() + elif platform.startswith('win32'): + return cls._list_windows() + else: + return None + + @classmethod + # pylint: disable=W0150 + def search(cls, find: str, exclude: str = None) -> list: + """Find specified processes. + + Args: + find (str): find process pid, name or arguments. + exclude (str, optional): exclude process pid, name or arguments. Defaults to None. + + Returns: + list: dictionaries with descriptions of found processes. + """ + proc_found = [] + try: + for proc in cls.list_all(): + if exclude and ( + exclude in proc['execpid'] or + exclude in proc['exename'] or + exclude in proc['exepath'] or + exclude in proc['cmdline'] + ): + pass + elif ( + find in proc['execpid'] or + find in proc['exename'] or + find in proc['exepath'] or + find in proc['cmdline'] + ): + proc_found.append(proc) + except TypeError as ex: + print('ON', platform, 'PLATFORM', 'search ERROR:', ex) + finally: + if len(proc_found) == 0: + return None + else: + return proc_found + + @classmethod + def kill(cls, pid: int) -> None: + """Kill the process by means of the OS. + + Args: + pid (int): process ID. + """ + if platform.startswith('linux') or platform.startswith('darwin'): + Popen(['kill', '-s', 'SIGKILL', str(pid)]) + elif platform.startswith('win32'): + Popen(['taskkill', '/PID', str(pid), '/F']) + + +class FFmpeg: + """FFmpeg management from Python. + """ + @classmethod + def run( + cls, + src: (str, type(None)) = None, + dst: str = None, + fps: int = None, + preset: str = None, + raw: (str, type(None)) = None, + ffpath: str = None, + watchdog: bool = False, + watchsec: int = None, + onlyonce: bool = False + ) -> int: + """Running the installed ffmpeg. + + Args: + src (str, type, optional): sources urls, example: + 'rtsp://user:pass@host:554/Streaming/Channels/101, anull'. Defaults to None. + dst (str, optional): destination url, example: 'rtp://239.0.0.1:5554'. Defaults to None. + fps (int, optional): frame per second encoding output. Defaults to None. + preset (str, optional): 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p. Defaults to None. + raw (str, type, optional): custom ffmpeg parameters string. Defaults to None. + ffpath (str, optional): custom path to bin, example: /usr/bin/ffmpeg. Defaults to None. + watchdog (bool, optional): detect ffmpeg freeze and terminate. Defaults to False. + watchsec (int, optional): seconds to wait before watchdog terminates. Defaults to None. + onlyonce (bool, optional): detect ffmpeg running copy and terminate. Defaults to False. + + Returns: + int: ffmpeg return code + """ + if not raw: + process = ([] + + cls._bin(ffpath).split() + + cls._src(src).split() + + cls._preset(preset, fps).split() + + cls._dst(dst).split() + ) + else: + process = cls._bin(ffpath).split() + raw.split() + + if onlyonce and Proc.search(' '.join(process)): + print('Process already exist, exit...') + else: + logging.info(msg='Starting ' + ' '.join(process)) + with Popen(process, stdout=PIPE, stderr=STDOUT) as proc: + que = None + if watchdog: + que = Queue() + Process( + target=cls._watchdog, + args=(proc.pid, watchsec, que,), + daemon=True + ).start() + for line in proc.stdout: + if not que: + logging.debug(msg=line) + else: + que.put(line) + return proc.returncode + + @classmethod + def _bin(cls, ffpath: str, tool: str = 'ffmpeg') -> str: + """Returns the path to the bin depending on the OS. + + Args: + ffpath (str): custom path to bin. + tool (str, optional): 'ffmpeg', 'ffprobe'. Defaults to 'ffmpeg'. + + Returns: + str: path to bin or None, if path does not exist. + """ + faq = ( + '\n' + 'Main download page: https://ffmpeg.org/download.html\n' + '\n' + 'Install on Linux (Debian):\n' + '\tsudo apt install -y ffmpeg\n' + '\tTarget: /usr/bin/ffmpeg\n' + '\n' + 'Install on Windows:\n' + '\tDownload and extract: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z\n' + '\tTarget: "%PROGRAMFILES%\\ffmpeg\\bin\\ffmpeg.exe"\n' + '\n' + 'Install on MacOS:\n' + '\tDownload and extract: https://evermeet.cx/ffmpeg/\n' + '\tTarget: /usr/bin/ffmpeg\n' + ) + if not ffpath: + if platform.startswith('linux') or platform.startswith('darwin'): + if tool == 'ffprobe': + ffpath = '/usr/bin/ffprobe' + else: + ffpath = '/usr/bin/ffmpeg' + elif platform.startswith('win32'): + if tool == 'ffprobe': + ffpath = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffprobe.exe" + else: + ffpath = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffmpeg.exe" + if path.exists(ffpath): + return ffpath + else: + print('ON', platform, 'PLATFORM', 'not found', tool, faq) + return None + + @classmethod + def _src(cls, sources: str) -> list: + """Parsing sources into ffmpeg format. + + Args: + sources (str): comma-separated list of sources in string format. + + Returns: + list: ffmpeg format list of sources. + """ + list_sources = [] + for src in sources.split(','): + src = src.strip() + if 'null' in src: + src = ' '.join(['-f lavfi -i', src]) + elif 'rtsp' in src: + src = ' '.join(['-rtsp_transport tcp -i', src]) + else: + src = ' '.join(['-stream_loop -1 -re -i', src]) + list_sources.append(src) + return ' '.join(list_sources) + + @classmethod + def _preset(cls, choice: str, fps: int) -> str: + """Parsing preset into ffmpeg format. + + Args: + choice (str): preset selection. + fps (int): frame per second encoding output. + + Returns: + str: ffmpeg format encoding parameters. + """ + tune = '-tune zerolatency' + video = '-c:v copy' + audio = '-c:a aac -b:a 128k' + width, height, kbps = None, None, None + if choice: + if choice == '240p': + width, height, kbps = 426, 240, 480 + if choice == '360p': + width, height, kbps = 640, 360, 720 + if choice == '480p': + width, height, kbps = 854, 480, 1920 + if choice == '720p': + width, height, kbps = 1280, 720, 3960 + if choice == '1080p': + width, height, kbps = 1920, 1080, 5940 + if choice == '1440p': + width, height, kbps = 2560, 1440, 12960 + if choice == '2160p': + width, height, kbps = 3840, 2160, 32400 + if width and height and kbps: + video = ''.join( + [ + '-vf scale=', + str(width), ':', str(height), + ',setsar=1:1' + ] + ) + video = ' '.join( + [ + video, + '-c:v libx264 -pix_fmt yuv420p -preset ultrafast' + ] + ) + if fps: + video = ' '.join([video, '-r', str(fps), '-g', str(fps * 2)]) + video = ' '.join([video, '-b:v', str(kbps) + 'k']) + return ' '.join([tune, video, audio]) + + @classmethod + def _dst(cls, destination: str) -> str: + """Parsing destination into ffmpeg format. + + Args: + destination (str): destination path or url. + + Returns: + str: ffmpeg format destination. + """ + container = '-f null' + stdout = '-v debug' # '-nostdin -nostats' # '-report' + if destination: + if 'rtmp' in destination: + container = '-f flv' + elif "rtp" in destination: + container = '-f rtp_mpegts' + else: + destination = '-' + return ' '.join([container, destination, stdout]) + + @classmethod + def _watchdog(cls, pid: int, sec: int, que: Queue = None) -> None: + """If no data arrives in the queue, kill the process. + + Args: + pid (int): process ID. + sec (int): seconds to wait for data. + que (Queue, optional): queue pointer. Defaults to None. + """ + if not sec: + sec = 5 + if que: + while True: + while not que.empty(): + print(que.get()) + sleep(sec) + if que.empty(): + Proc.kill(pid) + print('exit by watchdog') + break + exit() + + @classmethod + def probe( + cls, + target: (str, type(None)) = None, + raw: (str, type(None)) = None, + ffpath: str = None + ) -> (dict, bytes, None): + """Running the installed ffprobe. + + Args: + target (str, type, optional): media file path to probe. Defaults to None. + raw (str, type, optional): custom ffprobe parameters string. Defaults to None. + ffpath (str, optional): custom path to bin, example: /usr/bin/ffprobe. Defaults to None. + + Returns: + dict, bytes, None: ffprobe response or None. + """ + if not raw: + command = ([] + + cls._bin(ffpath=ffpath, tool='ffprobe').split() + + ('-i ' + target + + ' -v quiet -print_format json -show_format -show_programs -show_streams').split() + ) + else: + command = cls._bin(ffpath=ffpath, tool='ffprobe').split() + raw.split() + + with Popen(command, stdout=PIPE, stderr=STDOUT) as process: + result = process.communicate() + if process.returncode == 0 and not raw: + return json.loads(result[0].decode('utf-8')) + elif process.returncode == 0 and raw: + return result[0] + else: + return None + + class Connect: # pylint: disable=W0105 """Set of connection methods (functions) for various protocols. @@ -1936,7 +2333,6 @@ class Convert: if isinstance(image_root_path, str): image_root_path = [image_root_path] - temp_path = temp_path + sep + Do.random_string(8) temp_files = [] for name in image_find_names: image_found = [] @@ -2050,401 +2446,152 @@ class Convert: logging.debug(msg='\n' + 'error: ' + str(error)) -class Proc: - """Find a running process from Python. +class Publish: + """Publish handling. """ - @classmethod + @staticmethod # pylint: disable=W0612 - def _list_windows(cls) -> list: - """Find all running process with wmi. - - Returns: - list: dictionaries with descriptions of found processes. - """ - execlist = [] - separate = b'\r\r\n' - out, err = Popen( - [ - 'wmic', 'process', 'get', - 'CommandLine,ExecutablePath,Name,ProcessId', - '/format:list' - ], - stdout=PIPE, - stderr=PIPE - ).communicate() - for line in out.split(separate + separate): - execpid, exename, exepath, cmdline = None, None, None, None - for subline in line.split(separate): - if b'ProcessId=' in subline: - execpid = subline.split(b'=')[1].decode('utf-8') - if b'Name=' in subline: - exename = subline.split(b'=')[1].decode('utf-8') - if b'ExecutablePath=' in subline: - exepath = subline.split(b'=')[1].decode('utf-8') - if b'CommandLine=' in subline: - cmdline = subline.split(b'=')[1].decode('utf-8') - if execpid and exename: - execlist.append( - { - 'execpid': execpid, - 'exename': exename, - 'exepath': exepath, - 'cmdline': cmdline - } - ) - return execlist - - @classmethod - # pylint: disable=W0612 - def _list_linux(cls) -> list: - """Find all running process with ps. - - Returns: - list: dictionaries with descriptions of found processes. - """ - execlist = [] - out, err = Popen( - [ - '/bin/ps', '-eo', 'pid,args' - ], - stdout=PIPE, - stderr=PIPE - ).communicate() - for line in out.splitlines(): - execpid = line.split()[0].decode('utf-8') - exepath = line.split()[1].decode('utf-8') - exename = path.basename(exepath) - cmdline = line.split(None, 1)[1].decode('utf-8') - if execpid and exename: - execlist.append( - { - 'execpid': execpid, - 'exename': exename, - 'exepath': exepath, - 'cmdline': cmdline - } - ) - return execlist - - @classmethod - def list_all(cls) -> list: - """Find all running process. - - Returns: - list: dictionaries with descriptions of found processes. - """ - if platform.startswith('linux') or platform.startswith('darwin'): - return cls._list_linux() - elif platform.startswith('win32'): - return cls._list_windows() - else: - return None - - @classmethod - # pylint: disable=W0150 - def search(cls, find: str, exclude: str = None) -> list: - """Find specified processes. - - Args: - find (str): find process pid, name or arguments. - exclude (str, optional): exclude process pid, name or arguments. Defaults to None. - - Returns: - list: dictionaries with descriptions of found processes. - """ - proc_found = [] - try: - for proc in cls.list_all(): - if exclude and ( - exclude in proc['execpid'] or - exclude in proc['exename'] or - exclude in proc['exepath'] or - exclude in proc['cmdline'] - ): - pass - elif ( - find in proc['execpid'] or - find in proc['exename'] or - find in proc['exepath'] or - find in proc['cmdline'] - ): - proc_found.append(proc) - except TypeError as ex: - print('ON', platform, 'PLATFORM', 'search ERROR:', ex) - finally: - if len(proc_found) == 0: - return None - else: - return proc_found - - @classmethod - def kill(cls, pid: int) -> None: - """Kill the process by means of the OS. - - Args: - pid (int): process ID. - """ - if platform.startswith('linux') or platform.startswith('darwin'): - Popen(['kill', '-s', 'SIGKILL', str(pid)]) - elif platform.startswith('win32'): - Popen(['taskkill', '/PID', str(pid), '/F']) - - -class FFmpeg: - """FFmpeg management from Python. - """ - @classmethod def run( - cls, - src: (str, type(None)) = None, - dst: str = None, - fps: int = None, - preset: str = None, - raw: (str, type(None)) = None, - ffpath: str = None, - watchdog: bool = False, - watchsec: int = None, - onlyonce: bool = False - ) -> int: - """Running the installed ffmpeg. + video_root_path: (str, list), + video_find_names: list, + temp_path: str, + publish_period: str, + publish_amount: int, + video_root_user: (str, type(None)) = None, + video_root_pass: (str, type(None)) = None, + wp_site_name: (str, type(None)) = None, + wp_user_name: (str, type(None)) = None, + wp_user_pass: (str, type(None)) = None, + wp_update_page_id: (int, type(None)) = None, + tg_api_key: (str, type(None)) = None, + tg_chat_id: (str, type(None)) = None + ) -> None: + """Publishing executor. Args: - src (str, type, optional): sources urls, example: - 'rtsp://user:pass@host:554/Streaming/Channels/101, anull'. Defaults to None. - dst (str, optional): destination url, example: 'rtp://239.0.0.1:5554'. Defaults to None. - fps (int, optional): frame per second encoding output. Defaults to None. - preset (str, optional): 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p. Defaults to None. - raw (str, type, optional): custom ffmpeg parameters string. Defaults to None. - ffpath (str, optional): custom path to bin, example: /usr/bin/ffmpeg. Defaults to None. - watchdog (bool, optional): detect ffmpeg freeze and terminate. Defaults to False. - watchsec (int, optional): seconds to wait before watchdog terminates. Defaults to None. - onlyonce (bool, optional): detect ffmpeg running copy and terminate. Defaults to False. - - Returns: - int: ffmpeg return code + video_root_path (str, list): source video path. + video_find_names (list): video names to search. + temp_path (str): path to directory for temp files. + publish_period (str): +/- periods. + publish_amount (int): 'y'|'year','m'|'month','w'|'week','d'|'day'. + video_root_user (str, None, optional): username to source video ftp,sftp,smb path. + Defaults to None. + video_root_pass (str, None, optional): password to source video ftp,sftp,smb path. + Defaults to None. + wp_site_name (str, None, optional): www.wordpress.site. + Defaults to None. + wp_user_name (str, None, optional): wordpress username. + Defaults to None. + wp_user_pass (str, None, optional): wordpress password. + Defaults to None. + wp_update_page_id (int, None, optional): unique identifier for the page. + Defaults to None. + tg_api_key (str, None, optional): Telegram Bot API access token. + Defaults to None. + tg_chat_id (str, None, optional): unique identifier for + the target chat or username of the target channel. + Defaults to None. """ - if not raw: - process = ([] - + cls._bin(ffpath).split() - + cls._src(src).split() - + cls._preset(preset, fps).split() - + cls._dst(dst).split() + if isinstance(video_root_path, str): + video_root_path = [video_root_path] + + publish_date = Do.date_calc(period=publish_period, amount=publish_amount) + if publish_date['period'] == 'day': + video_find_sufx = ('' + + str(publish_date['start']['y']) + '.' + + str(publish_date['start']['m']).zfill(2) + '.' + + str(publish_date['start']['d']).zfill(2) ) - else: - process = cls._bin(ffpath).split() + raw.split() - - if onlyonce and Proc.search(' '.join(process)): - print('Process already exist, exit...') - else: - logging.info(msg='Starting ' + ' '.join(process)) - with Popen(process, stdout=PIPE, stderr=STDOUT) as proc: - que = None - if watchdog: - que = Queue() - Process( - target=cls._watchdog, - args=(proc.pid, watchsec, que,), - daemon=True - ).start() - for line in proc.stdout: - if not que: - logging.debug(msg=line) - else: - que.put(line) - return proc.returncode - - @classmethod - def _bin(cls, ffpath: str, tool: str = 'ffmpeg') -> str: - """Returns the path to the bin depending on the OS. - - Args: - ffpath (str): custom path to bin. - tool (str, optional): 'ffmpeg', 'ffprobe'. Defaults to 'ffmpeg'. - - Returns: - str: path to bin or None, if path does not exist. - """ - faq = ( - '\n' - 'Main download page: https://ffmpeg.org/download.html\n' - '\n' - 'Install on Linux (Debian):\n' - '\tsudo apt install -y ffmpeg\n' - '\tTarget: /usr/bin/ffmpeg\n' - '\n' - 'Install on Windows:\n' - '\tDownload and extract: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z\n' - '\tTarget: "%PROGRAMFILES%\\ffmpeg\\bin\\ffmpeg.exe"\n' - '\n' - 'Install on MacOS:\n' - '\tDownload and extract: https://evermeet.cx/ffmpeg/\n' - '\tTarget: /usr/bin/ffmpeg\n' - ) - if not ffpath: - if platform.startswith('linux') or platform.startswith('darwin'): - if tool == 'ffprobe': - ffpath = '/usr/bin/ffprobe' - else: - ffpath = '/usr/bin/ffmpeg' - elif platform.startswith('win32'): - if tool == 'ffprobe': - ffpath = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffprobe.exe" - else: - ffpath = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffmpeg.exe" - if path.exists(ffpath): - return ffpath - else: - print('ON', platform, 'PLATFORM', 'not found', tool, faq) - return None - - @classmethod - def _src(cls, sources: str) -> list: - """Parsing sources into ffmpeg format. - - Args: - sources (str): comma-separated list of sources in string format. - - Returns: - list: ffmpeg format list of sources. - """ - list_sources = [] - for src in sources.split(','): - src = src.strip() - if 'null' in src: - src = ' '.join(['-f lavfi -i', src]) - elif 'rtsp' in src: - src = ' '.join(['-rtsp_transport tcp -i', src]) - else: - src = ' '.join(['-stream_loop -1 -re -i', src]) - list_sources.append(src) - return ' '.join(list_sources) - - @classmethod - def _preset(cls, choice: str, fps: int) -> str: - """Parsing preset into ffmpeg format. - - Args: - choice (str): preset selection. - fps (int): frame per second encoding output. - - Returns: - str: ffmpeg format encoding parameters. - """ - tune = '-tune zerolatency' - video = '-c:v copy' - audio = '-c:a aac -b:a 128k' - width, height, kbps = None, None, None - if choice: - if choice == '240p': - width, height, kbps = 426, 240, 480 - if choice == '360p': - width, height, kbps = 640, 360, 720 - if choice == '480p': - width, height, kbps = 854, 480, 1920 - if choice == '720p': - width, height, kbps = 1280, 720, 3960 - if choice == '1080p': - width, height, kbps = 1920, 1080, 5940 - if choice == '1440p': - width, height, kbps = 2560, 1440, 12960 - if choice == '2160p': - width, height, kbps = 3840, 2160, 32400 - if width and height and kbps: - video = ''.join( - [ - '-vf scale=', - str(width), ':', str(height), - ',setsar=1:1' - ] - ) - video = ' '.join( - [ - video, - '-c:v libx264 -pix_fmt yuv420p -preset ultrafast' - ] - ) - if fps: - video = ' '.join([video, '-r', str(fps), '-g', str(fps * 2)]) - video = ' '.join([video, '-b:v', str(kbps) + 'k']) - return ' '.join([tune, video, audio]) - - @classmethod - def _dst(cls, destination: str) -> str: - """Parsing destination into ffmpeg format. - - Args: - destination (str): destination path or url. - - Returns: - str: ffmpeg format destination. - """ - container = '-f null' - stdout = '-v debug' # '-nostdin -nostats' # '-report' - if destination: - if 'rtmp' in destination: - container = '-f flv' - elif "rtp" in destination: - container = '-f rtp_mpegts' - else: - destination = '-' - return ' '.join([container, destination, stdout]) - - @classmethod - def _watchdog(cls, pid: int, sec: int, que: Queue = None) -> None: - """If no data arrives in the queue, kill the process. - - Args: - pid (int): process ID. - sec (int): seconds to wait for data. - que (Queue, optional): queue pointer. Defaults to None. - """ - if not sec: - sec = 5 - if que: - while True: - while not que.empty(): - print(que.get()) - sleep(sec) - if que.empty(): - Proc.kill(pid) - print('exit by watchdog') - break - exit() - - @classmethod - def probe( - cls, - target: (str, type(None)) = None, - raw: (str, type(None)) = None, - ffpath: str = None - ) -> (dict, bytes, None): - """Running the installed ffprobe. - - Args: - target (str, type, optional): media file path to probe. Defaults to None. - raw (str, type, optional): custom ffprobe parameters string. Defaults to None. - ffpath (str, optional): custom path to bin, example: /usr/bin/ffprobe. Defaults to None. - - Returns: - dict, bytes, None: ffprobe response or None. - """ - if not raw: - command = ([] - + cls._bin(ffpath=ffpath, tool='ffprobe').split() - + ('-i ' + target - + ' -v quiet -print_format json -show_format -show_programs -show_streams').split() + if publish_date['period'] == 'week': + video_find_sufx = ('' + + str(publish_date['start']['y']) + '-w' + + str(publish_date['start']['w']).zfill(2) + ) + if publish_date['period'] == 'month': + video_find_sufx = ('' + + str(publish_date['start']['y']) + '.' + + str(publish_date['start']['m']).zfill(2) + ) + if publish_date['period'] == 'year': + video_find_sufx = ('' + + str(publish_date['start']['y']) ) - else: - command = cls._bin(ffpath=ffpath, tool='ffprobe').split() + raw.split() - with Popen(command, stdout=PIPE, stderr=STDOUT) as process: - result = process.communicate() - if process.returncode == 0 and not raw: - return json.loads(result[0].decode('utf-8')) - elif process.returncode == 0 and raw: - return result[0] - else: - return None + temp_path = temp_path + sep + Do.random_string(8) + temp_files = [] + video_files = {publish_date['period']:{}} + for name in video_find_names: + video_found = [] + for video_root in video_root_path: + if '://' in video_root: + video_hostname = video_root.split('/')[2] + video_hosttype = video_root.split('://')[0] + video_hostpath = video_root.replace(video_hosttype + '://' + video_hostname, '') + if '@' in video_hostname: + video_root_user = video_hostname.split('@')[0].split(':')[0] + video_root_pass = video_hostname.split('@')[0].split(':')[1] + video_hostname = video_hostname.split('@')[1] + if ':' in video_hostname: + video_hostport = int(video_hostname.split(':')[1]) + video_hostname = video_hostname.split(':')[0] + if video_hosttype == 'ftp': + video_found = video_found + Connect.ftp_file_search( + root_path=video_hostpath, + search_name=name + '_' + video_find_sufx + '.mp4', + hostname=video_hostname, + username=video_root_user, + password=video_root_pass + ) + makedirs(temp_path, exist_ok=True) + for video in video_found: + if Connect.ftp_get_file( + src_file=video, + dst_file=temp_path + sep + path.basename(video), + hostname=video_hostname, + username=video_root_user, + password=video_root_pass + ): + temp_files.append(temp_path + sep + path.basename(video)) + video_files[publish_period][name] = ( + temp_path + sep + path.basename(video) + ) + elif video_hosttype == 'sftp': + pass + else: + pass + + if tg_api_key: + tg = Telegram(tg_api_key) + Do.tg_routine_media( + tg=tg, + targets_media_files=video_files, + period=publish_date['period'], + amount=publish_amount, + chat=tg_chat_id + ) + if wp_site_name: + wp = Wordpress( + hostname=wp_site_name, + username=wp_user_name, + password=wp_user_pass + ) + Do.wp_routine_media( + wp=wp, + targets_media_files=video_files, + period=publish_date['period'], + amount=publish_amount, + page_id=wp_update_page_id + ) + + for temp_file in temp_files: + try: + remove(temp_file) + except OSError as error: + logging.debug(msg='\n' + 'error: ' + str(error)) + try: + rmdir(temp_path) + except OSError as error: + logging.debug(msg='\n' + 'error: ' + str(error)) class Do(): @@ -2495,6 +2642,7 @@ class Do(): Returns: dict: { + 'period':day|week|month|year, 'start':{'y':int,'m':int,'w':int,'d':int}, 'end':{'y':int,'m':int,'w':int,'d':int} }. @@ -2617,7 +2765,7 @@ class Do(): """ if Do.args_valid(locals(), Do.wp_routine_media.__annotations__): default_media_links = { - "d": { + "day": { "point-01": ( "https://www.hmp.today/wp-content/uploads/2022/07/point-01_yyyy.mm_.dd_.mp4" ), @@ -2637,7 +2785,7 @@ class Do(): "https://www.hmp.today/wp-content/uploads/2022/07/point-12_yyyy.mm_.dd_.mp4" ) }, - "w": { + "week": { "point-01": "https://www.hmp.today/wp-content/uploads/2022/07/point-01_w.mp4", "point-02": "https://www.hmp.today/wp-content/uploads/2022/07/point-02_w.mp4", "point-04": "https://www.hmp.today/wp-content/uploads/2022/07/point-04_w.mp4", @@ -2645,7 +2793,7 @@ class Do(): "point-11": "https://www.hmp.today/wp-content/uploads/2022/07/point-11_w.mp4", "point-12": "https://www.hmp.today/wp-content/uploads/2022/07/point-12_w.mp4" }, - "m": { + "month": { "point-01": ( "https://www.hmp.today/wp-content/uploads/2022/07/point-01_yyyy.mm_.mp4" ), @@ -2665,7 +2813,7 @@ class Do(): "https://www.hmp.today/wp-content/uploads/2022/07/point-12_yyyy.mm_.mp4" ) }, - "y": { + "year": { "point-01": ( "https://www.hmp.today/wp-content/uploads/2022/07/point-01_yyyy.mp4" ), @@ -2693,20 +2841,20 @@ class Do(): result['media upload'] = True for name, link in current_media_links.items(): file_found = wp.media_search( - media_name=path.basename(targets_media_files[name]), + media_name=path.basename(targets_media_files[period][name]), media_type='video' ) if file_found['success'] and len(file_found['result']) > 0: logging.info( msg="" + "upload skipped, " - + targets_media_files[name] + + targets_media_files[period][name] + " found on site" ) - targets_media_files.pop(name, None) + targets_media_files[period].pop(name, None) current_media_links[name] = file_found['result'][0] else: - file_upload = wp.media_upload(targets_media_files[name], 'video/mp4') + file_upload = wp.media_upload(targets_media_files[period][name], 'video/mp4') if file_upload['success']: current_media_links[name] = file_upload['result'] else: @@ -2737,7 +2885,7 @@ class Do(): + str(date['end']['d']).zfill(2) + 'T23:59:59' ) - if period == 'd': + if period == 'd' or period == 'day': slug = ('' + name + '_' @@ -2756,7 +2904,7 @@ class Do(): + '.' + str(date['start']['d']).zfill(2) ) - if period == 'w': + if period == 'w' or period == 'week': slug = ('' + name + '_' @@ -2771,7 +2919,7 @@ class Do(): + '-w' + str(date['start']['w']).zfill(2) ) - if period == 'm': + if period == 'm' or period == 'month': slug = ('' + name + '_' @@ -2786,7 +2934,7 @@ class Do(): + '.' + str(date['start']['m']).zfill(2) ) - if period == 'y': + if period == 'y' or period == 'year': slug = ('' + name + '_' @@ -2821,17 +2969,17 @@ class Do(): if page_read['success']: content = json.loads(page_read['result'])['content']['rendered'] for name, link in current_media_links.items(): - if period == 'd': + if period == 'd' or period == 'day': reg_exp = ("" + "_(?:[0-9]{4}|yyyy)" + ".(?:[0-9]{2}|mm_)" + ".(?:[0-9]{2}|dd_)(?:|-[0-9]).mp4" ) - if period == 'w': + if period == 'w' or period == 'week': reg_exp = "(?:_[0-9]{4}-w[0-9]{2}|_w)(?:|-[0-9]).mp4" - if period == 'm': + if period == 'm' or period == 'month': reg_exp = "_(?:[0-9]{4}|yyyy).(?:[0-9]{2}|mm_)(?:|-[0-9]).mp4" - if period == 'y': + if period == 'y' or period == 'year': reg_exp = "_(?:[0-9]{4}|yyyy)(?:|-[0-9])" replace = 0 @@ -2848,13 +2996,13 @@ class Do(): else: content = content.replace(old_str, new_str) replace += 1 - logging.info(msg="page replace" + old_str + " to " + new_str) + logging.info(msg="page replace " + old_str + " to " + new_str) if replace > 0: page_update = wp.pages_update(page_id = page_id, content = content) result['pages update'] = page_update['success'] - return result + return result @staticmethod # pylint: disable=W0612,W0511 @@ -2900,7 +3048,7 @@ class Do(): tmp_files = [] date = Do.date_calc(period=period, amount=amount) - if period == 'd': + if period == 'd' or period == 'day': caption_date = ('' + str(date['start']['y']) + '.' @@ -2908,19 +3056,19 @@ class Do(): + '.' + str(date['start']['d']).zfill(2) ) - if period == 'w': + if period == 'w' or period == 'week': caption_date = ('' + str(date['start']['y']) + '-w' + str(date['start']['w']).zfill(2) ) - if period == 'm': + if period == 'm' or period == 'month': caption_date = ('' + str(date['start']['y']) + '.' + str(date['start']['m']).zfill(2) ) - if period == 'y': + if period == 'y' or period == 'year': caption_date = ('' + str(date['start']['y']) ) @@ -3001,47 +3149,52 @@ class Do(): if __name__ == "__main__": time_start = datetime.datetime.now() - args = ArgumentParser( - prog='cctv-scheduler', - description='Hikvision PTZ IP-Camera management.', - epilog='Dependencies: ' - '- Python 3 (tested version 3.9.5), ' - '- Python 3 modules: paramiko ' - ) - args.add_argument('--config', type=str, default=path.splitext(__file__)[0] + '.conf', - required=False, - help='custom configuration file path') - args.add_argument('-b', '--broadcast', action='store_true', required=False, - help='streaming media to destination') - args.add_argument('-s', '--sequences', action='store_true', required=False, - help='run sequences from config file') - args.add_argument('-c', '--converter', action='store_true', required=False, - help='convert JPEG collection to MP4') - args.add_argument('-p', '--publisher', action='store_true', required=False, - help='publish content from templates') - args.add_argument('-d', '--day', type=int, default=0, required=False, - help=('day in amount of days from today, ' - + 'for which the publication or conversion is made') - ) - args.add_argument('-w', '--week', type=int, default=0, required=False, - help=('week in amount of weeks from today, ' - + 'for which the publication or conversion is made') - ) - args.add_argument('-m', '--month', type=int, default=0, required=False, - help=('month in amount of months from today, ' - + 'for which the publication or conversion is made') - ) - args.add_argument('-y', '--year', type=int, default=0, required=False, - help=('year in amount of years from today, ' - + 'for which the publication or conversion is made') - ) - args = vars(args.parse_args()) + if 'argparse' in modules: + args = ArgumentParser( + prog='cctv-scheduler', + description='Hikvision PTZ IP-Camera management.', + epilog='Dependencies: ' + '- Python 3 (tested version 3.9.5), ' + '- Python 3 modules: paramiko ' + ) + args.add_argument('--config', type=str, default=path.splitext(__file__)[0] + '.conf', + required=False, + help='custom configuration file path') + args.add_argument('-b', '--broadcast', action='store_true', required=False, + help='streaming media to destination') + args.add_argument('-s', '--sequences', action='store_true', required=False, + help='run sequences from config file') + args.add_argument('-c', '--converter', action='store_true', required=False, + help='convert JPEG collection to MP4') + args.add_argument('-p', '--publisher', action='store_true', required=False, + help='publish content from templates') + args.add_argument('-d', '--day', type=int, default=0, required=False, + help=('day in amount of days from today, ' + + 'for which the publication or conversion is made') + ) + args.add_argument('-w', '--week', type=int, default=0, required=False, + help=('week in amount of weeks from today, ' + + 'for which the publication or conversion is made') + ) + args.add_argument('-m', '--month', type=int, default=0, required=False, + help=('month in amount of months from today, ' + + 'for which the publication or conversion is made') + ) + args.add_argument('-y', '--year', type=int, default=0, required=False, + help=('year in amount of years from today, ' + + 'for which the publication or conversion is made') + ) + args = vars(args.parse_args()) + temp_path = path.dirname(path.realpath(__file__)) + sep + 'temp' log_root = path.dirname(path.realpath(__file__)) log_level = 'INFO' - temp_path = path.dirname(path.realpath(__file__)) + sep + 'temp' if path.exists(args['config']): + + # common conf = Parse(parameters=args['config'], block='common') + if 'temp_path' in conf.data: + temp_path = conf.data['temp_path'] if 'log_root' in conf.data: log_root = conf.data['log_root'] if 'log_level' in conf.data: @@ -3069,11 +3222,9 @@ if __name__ == "__main__": ) logging.getLogger("paramiko").setLevel(logging.WARNING) - if 'temp_path' in conf.data: - temp_path = conf.data['temp_path'] - if args['broadcast']: logging.info(msg='Starting streaming media to destination') + broadcasts = {} conf = Parse(parameters=args['config'], block='enable-broadcast') for key, value in conf.data.items(): @@ -3081,7 +3232,7 @@ if __name__ == "__main__": broadcast_config = Parse( parameters=args['config'], block='broadcast-config:' + key - ).data + ).data src = None if 'src' in broadcast_config: src = broadcast_config['src'] @@ -3106,7 +3257,8 @@ if __name__ == "__main__": onlyonce = None if 'onlyonce' in broadcast_config: onlyonce = broadcast_config['onlyonce'] - FFmpeg.run( + exit_code = None + exit_code = FFmpeg.run( src=src, dst=dst, fps=fps, @@ -3116,8 +3268,13 @@ if __name__ == "__main__": watchsec=watchsec, onlyonce=onlyonce ) + logging.info(msg='' + + 'Finished streaming ' + key + ' with exit code: ' + + str(exit_code) + ) elif args['sequences']: logging.info(msg='Starting PTZ sequences from config file') + sensors = {} conf = Parse(parameters=args['config'], block='enable-sensor') for key, value in conf.data.items(): @@ -3125,14 +3282,14 @@ if __name__ == "__main__": device_config = Parse( parameters=args['config'], block='sensor-config:' + key - ).data + ).data device_entity = Sensor( hostname=device_config['hostname'], username=device_config['username'], userpass=device_config['userpass'], nodetype=device_config['nodetype'], nodename=device_config['nodename'] - ) + ) sensors[key] = device_entity conf = Parse(parameters=args['config'], block='enable-sequences') @@ -3141,16 +3298,16 @@ if __name__ == "__main__": device_sequence = Parse( parameters=args['config'], block='camera-sequences:' + key - ).data + ).data device_config = Parse( parameters=args['config'], block='camera-config:' + key - ).data + ).data device_entity = HikISAPI( hostname=device_config['hostname'], username=device_config['username'], userpass=device_config['userpass'] - ) + ) records_root_path = path.dirname(path.realpath(__file__)) records_root_user = None records_root_pass = None @@ -3167,27 +3324,28 @@ if __name__ == "__main__": records_root_path=records_root_path, records_root_user=records_root_user, records_root_pass=records_root_pass - ) - elif args['converter']: - if args['day'] or args['week'] or args['month'] or args['year']: - convert_period = None - convert_amount = None - video_duration = None - if args['day']: - convert_period = 'day' - convert_amount = args['day'] - if args['week']: - convert_period = 'week' - convert_amount = args['week'] - if args['month']: - convert_period = 'month' - convert_amount = args['month'] - if args['year']: - convert_period = 'year' - convert_amount = args['year'] - convert_date = Do.date_calc(period=convert_period, amount=convert_amount) + ) + logging.info(msg='Finished PTZ sequence ' + key) + elif args['day'] or args['week'] or args['month'] or args['year']: + period = None + amount = None + if args['day']: + period = 'day' + amount = args['day'] + if args['week']: + period = 'week' + amount = args['week'] + if args['month']: + period = 'month' + amount = args['month'] + if args['year']: + period = 'year' + amount = args['year'] + period = Do.date_calc(period=period, amount=amount) + if args['converter']: logging.info(msg='Starting convert JPEG collection to MP4') + conf = Parse(parameters=args['config'], block='enable-convert') for key, value in conf.data.items(): if value == 'true': @@ -3209,66 +3367,66 @@ if __name__ == "__main__": for name in convert_config['image_find_names'].split(','): image_find_names.append(name.strip()) - if convert_date['period'] == 'day': + if period['period'] == 'day': image_root_path = ('' + image_root_path + '/' - + str(convert_date['start']['y']) + '/' - + str(convert_date['start']['m']).zfill(2) + '/' - + str(convert_date['start']['w']).zfill(2) + '/' - + str(convert_date['start']['d']).zfill(2) + + str(period['start']['y']) + '/' + + str(period['start']['m']).zfill(2) + '/' + + str(period['start']['w']).zfill(2) + '/' + + str(period['start']['d']).zfill(2) ) video_dest_sufx = ('' - + str(convert_date['start']['y']) + '.' - + str(convert_date['start']['m']).zfill(2) + '.' - + str(convert_date['start']['d']).zfill(2) + + str(period['start']['y']) + '.' + + str(period['start']['m']).zfill(2) + '.' + + str(period['start']['d']).zfill(2) ) video_duration = 1 - if convert_date['period'] == 'week': - if convert_date['start']['m'] == convert_date['end']['m']: + if period['period'] == 'week': + if period['start']['m'] == period['end']['m']: image_root_path = ('' + image_root_path + '/' - + str(convert_date['start']['y']) + '/' - + str(convert_date['start']['m']).zfill(2) + '/' - + str(convert_date['start']['w']).zfill(2) + + str(period['start']['y']) + '/' + + str(period['start']['m']).zfill(2) + '/' + + str(period['start']['w']).zfill(2) ) else: image_root_path = [ ('' + image_root_path + '/' - + str(convert_date['start']['y']) + '/' - + str(convert_date['start']['m']).zfill(2) + '/' - + str(convert_date['start']['w']).zfill(2) + + str(period['start']['y']) + '/' + + str(period['start']['m']).zfill(2) + '/' + + str(period['start']['w']).zfill(2) ), ('' + image_root_path + '/' - + str(convert_date['end']['y']) + '/' - + str(convert_date['end']['m']).zfill(2) + '/' - + str(convert_date['end']['w']).zfill(2) + + str(period['end']['y']) + '/' + + str(period['end']['m']).zfill(2) + '/' + + str(period['end']['w']).zfill(2) ) ] video_dest_sufx = ('' - + str(convert_date['start']['y']) + '-w' - + str(convert_date['start']['w']).zfill(2) + + str(period['start']['y']) + '-w' + + str(period['start']['w']).zfill(2) ) video_duration = 7 - if convert_date['period'] == 'month': + if period['period'] == 'month': image_root_path = ('' + image_root_path + '/' - + str(convert_date['start']['y']) + '/' - + str(convert_date['start']['m']).zfill(2) + + str(period['start']['y']) + '/' + + str(period['start']['m']).zfill(2) ) video_dest_sufx = ('' - + str(convert_date['start']['y']) + '.' - + str(convert_date['start']['m']).zfill(2) + + str(period['start']['y']) + '.' + + str(period['start']['m']).zfill(2) ) video_duration = 30 - if convert_date['period'] == 'year': + if period['period'] == 'year': image_root_path = ('' + image_root_path + '/' - + str(convert_date['start']['y']) + + str(period['start']['y']) ) video_dest_sufx = ('' - + str(convert_date['start']['y']) + + str(period['start']['y']) ) video_duration = 360 @@ -3295,12 +3453,72 @@ if __name__ == "__main__": video_scale_y=int(convert_config['video_scale_y']), video_framerate=int(convert_config['video_framerate']), video_duration=video_duration, - temp_path=temp_path + temp_path=temp_path + sep + Do.random_string(8) ) - else: - logging.info(msg='Period was not selected. Exit.') - elif args['publisher']: - logging.info(msg='Starting publish content from templates') + logging.info(msg='Finished convert JPEG collection ' + key) + if args['publisher']: + logging.info(msg='Starting publish content from templates') + + conf = Parse(parameters=args['config'], block='enable-publish') + for key, value in conf.data.items(): + if value == 'true': + publish_config = Parse( + parameters=args['config'], + block='publish-config:' + key + ).data + video_root_path = path.dirname(path.realpath(__file__)) + video_root_user = None + video_root_pass = None + video_find_names = None + if 'video_root_path' in publish_config: + video_root_path = publish_config['video_root_path'] + if 'video_root_user' in publish_config: + video_root_user = publish_config['video_root_user'] + if 'video_root_pass' in publish_config: + video_root_pass = publish_config['video_root_pass'] + + video_find_names = [] + for name in publish_config['video_find_names'].split(','): + video_find_names.append(name.strip()) + + wp_site_name = None + wp_user_name = None + wp_user_pass = None + wp_update_page_id = None + if publish_config['wp_enabled'] == 'true': + if 'wp_site_name' in publish_config: + wp_site_name = publish_config['wp_site_name'] + if 'wp_user_name' in publish_config: + wp_user_name = publish_config['wp_user_name'] + if 'wp_user_pass' in publish_config: + wp_user_pass = publish_config['wp_user_pass'] + if 'wp_update_page_id' in publish_config: + wp_update_page_id = int(publish_config['wp_update_page_id']) + + tg_api_key = None + tg_chat_id = None + if publish_config['tg_enabled'] == 'true': + if 'tg_api_key' in publish_config: + tg_api_key = publish_config['tg_api_key'] + if 'tg_chat_id' in publish_config: + tg_chat_id = publish_config['tg_chat_id'] + + Publish.run( + video_root_path=video_root_path, + video_root_user=video_root_user, + video_root_pass=video_root_pass, + video_find_names=video_find_names, + temp_path=temp_path + sep + Do.random_string(8), + publish_period=period['period'], + publish_amount=amount, + wp_site_name=wp_site_name, + wp_user_name=wp_user_name, + wp_user_pass=wp_user_pass, + wp_update_page_id=wp_update_page_id, + tg_api_key=tg_api_key, + tg_chat_id=tg_chat_id + ) + logging.info(msg='Finished publish content ' + key) else: logging.info(msg='Start arguments was not selected. Exit.') diff --git a/info/images/cctv-scheduler-0.5.png b/info/images/cctv-scheduler-0.5.png new file mode 100755 index 0000000..086e11f Binary files /dev/null and b/info/images/cctv-scheduler-0.5.png differ