diff --git a/README.md b/README.md index 20db280..fb4d996 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,27 @@ # cctv-scheduler PTZ IP-Camera management +![cctv-scheduler](info/images/cctv-scheduler-0.1.png) + * [`sequences.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#sequences-sh) * [`converter.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#converter-sh) * [`publisher.sh`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#publisher-sh) +* [`streaming.py`](https://git.hmp.today/pavel.muhortov/cctv-scheduler#streaming-py) ____ ## `sequences.sh` **Description:** -> Hikvision PTZ-camera sequences. +> [Hikvision](https://git.hmp.today/pavel.muhortov/cctv-scheduler/src/branch/master/info/hikvision/manual/isapi.pdf) PTZ-camera sequences. > Additionally: -> - getting temperature from ds-18b20 over SSH, +> - getting temperature from DS18B20 over SSH, > - saving pictures to FTP. > > This is only a local "proof of conept" for testing and debugging. **Dependencies:** -> - bash (tested version 5.1.4 on Debian GNU/Linux 11) -> - curl (tested version 7.74 on Debian GNU/Linux 11) -> - sshpass (tested version 1.09 on Debian GNU/Linux 11) +> - [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/)) +> - [sshpass](https://www.cyberciti.biz/faq/noninteractive-shell-script-ssh-password-provider/) (tested version 1.09 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) | POSITION | PARAMETERS | DESCRIPTION | DEFAULT | @@ -44,8 +47,8 @@ ____ > This is only a local "proof of conept" for testing and debugging. **Dependencies:** -> - bash (tested version 5.1.4 on Debian GNU/Linux 11) -> - ffmpeg (tested version 4.3.4 on Debian GNU/Linux 11) +> - [bash](https://www.gnu.org/software/bash/) (tested version 5.1.4 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/)) > - filesystem organization: >```bash > # filesystem organisation example @@ -89,19 +92,19 @@ Example usage with cron: ____ ## `publisher.sh` **Description:** -> Uploading MP4 to Wordpress and Telegram. +> Uploading MP4 to [Wordpress](https://wordpress.com/) and [Telegram](https://web.telegram.org/). > Additionally: -> - editing Wordpress page from template -> - recompressing video if size over 50MB +> - 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 (tested version 5.1.4 on Debian GNU/Linux 11) -> - curl (tested version 7.74 on Debian GNU/Linux 11) -> - ffmpeg (tested version 4.3.4 on Debian GNU/Linux 11) -> - libxml2-utils (tested version 2.9.10 on Debian GNU/Linux 11) -> - jq (tested version 1.6 on Debian GNU/Linux 11) +> - [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 | @@ -125,3 +128,41 @@ Example usage with cron: 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 ``` +____ +## `streaming`.py +**Description:** +> FFmpeg management from Python + +**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/)) +> - [ffmpeg](https://ffmpeg.org/download.html) (tested version 4.3.4 on [Debian GNU/Linux 11](http://ftp.debian.org/debian/dists/bullseye/)) + +| PARAMETERS | DESCRIPTION | DEFAULT| +|-------------|-------------|--------| +|**-s**, **--src**|sources urls|**REQUIRED**| +|**[-h]**|print help and exit|| +|**[--preset]**|240p, 360p, 480p, 720p, 1080p, 1440p, 2160p|`None`| +|**[--fps]**|frame per second encoding output|`None`| +|**[--dst]**|destination url|`None`| +|**[--ffpath]**|alternative path to bin|`None`| +|**[--watchdog]**|detect ffmpeg freeze and terminate|| +|**[--sec]**|seconds to wait before the watchdog terminates|15| +|**[--mono]**|detect ffmpeg running copy and terminate|| + +Example usage in terminal with make the script executable: +```bash +chmod u+x ./streaming.py +./streaming.py -s rtsp://user:pass@host:554/Streaming/Channels/101 --dst rtp://239.0.0.1:5554 +``` +Example usage with cron: +```bash +# crontab -e +* * * * * /usr/bin/python3 /home/user/cctv-scheduler/streaming.py -s rtsp://user:pass@host:554/Streaming/Channels/video,http://Streaming/Channels/audio --dst rtmp://a.rtmp.youtube.com/live2/YOUKEY --mono --watchdog --sec 30 >> /dev/null 2>&1 +* * * * * /usr/bin/python3 /home/user/cctv-scheduler/streaming.py -s ~/media.mp4 --dst rtmp://b.rtmp.youtube.com/live2?backup=1/YOUKEY --mono >> /dev/null 2>&1 +``` +Example usage in Python: +```Python +from streaming import FFmpeg + +FFmpeg.run(src='null, anull', preset='240p', fps=10) +``` \ No newline at end of file diff --git a/info/images/cctv-scheduler-0.1.png b/info/images/cctv-scheduler-0.1.png new file mode 100644 index 0000000..3aa6ddd Binary files /dev/null and b/info/images/cctv-scheduler-0.1.png differ diff --git a/streaming.py b/streaming.py new file mode 100644 index 0000000..9fb8b2c --- /dev/null +++ b/streaming.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 + + +from multiprocessing import Process, Queue +from os import path, environ +from subprocess import Popen, PIPE, STDOUT +from sys import platform +from time import sleep + + +class Proc: + """ + Find a running process from Python + """ + @classmethod + def _list_windows(cls): + """ + Find all running process with wmi + :return: list of 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 + def _list_linux(cls): + """ + Find all running process with ps + :return: list of 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(cls): + """ + Find all running process + :return: list of 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 + def search(cls, find: str, exclude: str = None): + """ + Find specified processes + :param find: find process pid, name or arguments + :param exclude: exclude process pid, name or arguments + :return: list of dictionaries with descriptions of found processes + """ + proc_found = [] + try: + for proc in cls.list(): + 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): + """ + Kill the process by means of the OS + :param pid: process ID + :return: None + """ + 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, preset: str = None, fps: int = None, dst: str = None, + ffpath: str = None, watchdog: bool = False, sec: int = 5, mono: bool = False): + """ + Running the installed ffmpeg + :param src: sources urls (example: "rtsp://user:pass@host:554/Streaming/Channels/101, anull") + :param preset: 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p + :param fps: frame per second encoding output + :param dst: destination url (example: rtp://239.0.0.1:5554) + :param ffpath: alternative path to bin (example: /usr/bin/ffmpeg) + :param watchdog: detect ffmpeg freeze and terminate + :param sec: seconds to wait before the watchdog terminates + :param mono: detect ffmpeg running copy and terminate + :return: None + """ + process = cls._bin(ffpath).split()+cls._src(src).split()+cls._preset(preset, fps).split()+cls._dst(dst).split() + if mono and Proc.search(' '.join(process)): + print('Process already exist, exit...') + else: + with Popen(process, stdout=PIPE, stderr=STDOUT) as proc: + que = None + if watchdog: + que = Queue() + Process(target=cls._watchdog, args=(proc.pid, sec, que,), daemon=True).start() + for line in proc.stdout: + if not que: + print(line, flush=True) + else: + que.put(line) + exit() + + @classmethod + def _bin(cls, path_ffmpeg: str): + """ + Returns the path to the ffmpeg depending on the OS + :param path_ffmpeg: alternative path to bin + :return: path to ffmpeg + """ + 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 archive from: 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 archive from: https://evermeet.cx/ffmpeg/\n' + '\tTarget: /usr/bin/ffmpeg\n') + if not path_ffmpeg: + if platform.startswith('linux') or platform.startswith('darwin'): + path_ffmpeg = '/usr/bin/ffmpeg' + elif platform.startswith('win32'): + path_ffmpeg = environ['PROGRAMFILES'] + "\\ffmpeg\\bin\\ffmpeg.exe" + if path.exists(path_ffmpeg): + return path_ffmpeg + else: + print('ON', platform, 'PLATFORM', 'not found ffmpeg', faq) + return None + + @classmethod + def _src(cls, sources: str): + """ + Parsing sources into ffmpeg format + :param sources: comma-separated list of sources in string format + :return: 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): + """ + Parsing preset into ffmpeg format + :param choice: preset selection + :param fps: frame per second encoding output + :return: 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): + """ + Parsing destination into ffmpeg format + :param destination: + :return: 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 = 5, que: Queue = None): + """ + If no data arrives in the queue, kill the process + :param pid: process ID + :param sec: seconds to wait for data + :param que: queue pointer + :return: None + """ + 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() + + +if __name__ == "__main__": + from argparse import ArgumentParser + + args = ArgumentParser( + prog='streaming', + description='FFmpeg management from Python', + epilog='Dependencies: ' + 'Python 3 (tested version 3.9.5 on Debian GNU/Linux 11), ' + 'ffmpeg (tested version 4.3.4 on Debian GNU/Linux 11)' + ) + args.add_argument('-s', '--src', type=str, required=True, + help='sources urls (example: "rtsp://user:pass@host:554/Streaming/Channels/101, anull")') + args.add_argument('--preset', type=str, default=None, required=False, + help='240p, 360p, 480p, 720p, 1080p, 1440p, 2160p') + args.add_argument('--fps', type=int, default=None, required=False, + help='frame per second encoding output') + args.add_argument('--dst', type=str, default=None, required=False, + help='destination url (example: rtp://239.0.0.1:5554)') + args.add_argument('--ffpath', type=str, default=None, required=False, + help='alternative path to bin (example: /usr/bin/ffmpeg)') + args.add_argument('--watchdog', action='store_true', required=False, + help='detect ffmpeg freeze and terminate') + args.add_argument('--sec', type=int, default=15, required=False, + help='seconds to wait before the watchdog terminates') + args.add_argument('--mono', action='store_true', required=False, + help='detect ffmpeg running copy and terminate') + args = vars(args.parse_args()) + + FFmpeg.run(src=args['src'], preset=args['preset'], fps=args['fps'], dst=args['dst'], + ffpath=args['ffpath'], watchdog=args['watchdog'], sec=args['sec'], mono=args['mono'])