wip
This commit is contained in:
parent
5d8e81b6c8
commit
e97f98e5e2
141
bin/ipcam_capture.py
Executable file
141
bin/ipcam_capture.py
Executable file
@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
import __py_include
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import asyncio
|
||||
import signal
|
||||
|
||||
from typing import TextIO
|
||||
from argparse import ArgumentParser
|
||||
from socket import gethostname
|
||||
from asyncio.streams import StreamReader
|
||||
from homekit.config import LinuxBoardsConfig, config as homekit_config
|
||||
from homekit.camera import IpcamConfig, CaptureType
|
||||
from homekit.camera.util import get_hls_directory, get_hls_channel_name, get_recordings_path
|
||||
|
||||
ipcam_config = IpcamConfig()
|
||||
lbc_config = LinuxBoardsConfig()
|
||||
channels = (1, 2)
|
||||
tasks = []
|
||||
restart_delay = 3
|
||||
lock = asyncio.Lock()
|
||||
worker_type: CaptureType
|
||||
|
||||
|
||||
async def read_output(stream: StreamReader,
|
||||
thread_name: str,
|
||||
output: TextIO):
|
||||
try:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
print(f"[{thread_name}] {line.decode().strip()}", file=output)
|
||||
|
||||
except asyncio.LimitOverrunError:
|
||||
print(f"[{thread_name}] Output limit exceeded.", file=output)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{thread_name}] Error occurred while reading output: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
async def run_ffmpeg(cam: int, channel: int):
|
||||
prefix = get_hls_channel_name(cam, channel)
|
||||
|
||||
if homekit_config.app_config.logging_is_verbose():
|
||||
debug_args = ['-v', '-info']
|
||||
else:
|
||||
debug_args = ['-nostats', '-loglevel', 'error']
|
||||
|
||||
protocol = 'tcp' if ipcam_config.should_use_tcp_for_rtsp(cam) else 'udp'
|
||||
user, pw = ipcam_config.get_rtsp_creds()
|
||||
ip = ipcam_config.get_camera_ip(cam)
|
||||
path = ipcam_config.get_camera_type(cam).get_channel_url(channel)
|
||||
ext = ipcam_config.get_camera_container(cam)
|
||||
ffmpeg_command = ['ffmpeg', *debug_args,
|
||||
'-rtsp_transport', protocol,
|
||||
'-i', f'rtsp://{user}:{pw}@{ip}:554{path}',
|
||||
'-c', 'copy',]
|
||||
|
||||
if worker_type == CaptureType.HLS:
|
||||
ffmpeg_command.extend(['-bufsize', '1835k',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-flags', '-global_header',
|
||||
'-hls_time', '2',
|
||||
'-hls_list_size', '3',
|
||||
'-hls_flags', 'delete_segments',
|
||||
os.path.join(get_hls_directory(cam, channel), 'live.m3u8')])
|
||||
|
||||
elif worker_type == CaptureType.RECORD:
|
||||
ffmpeg_command.extend(['-f', 'segment',
|
||||
'-strftime', '1',
|
||||
'-segment_time', '00:10:00',
|
||||
'-segment_atclocktime', '1',
|
||||
os.path.join(get_recordings_path(cam), f'record_%Y-%m-%d-%H.%M.%S.{ext.value}')])
|
||||
|
||||
else:
|
||||
raise ValueError(f'invalid worker type: {worker_type}')
|
||||
|
||||
while True:
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*ffmpeg_command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout_task = asyncio.create_task(read_output(process.stdout, prefix, sys.stdout))
|
||||
stderr_task = asyncio.create_task(read_output(process.stderr, prefix, sys.stderr))
|
||||
|
||||
await asyncio.gather(stdout_task, stderr_task)
|
||||
|
||||
# check the return code of the process
|
||||
if process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode, ffmpeg_command)
|
||||
|
||||
except (FileNotFoundError, PermissionError, subprocess.CalledProcessError) as e:
|
||||
# an error occurred, print the error message
|
||||
error_message = f"Error occurred in {prefix}: {e}"
|
||||
print(error_message, file=sys.stderr)
|
||||
|
||||
# sleep for 5 seconds before restarting the process
|
||||
await asyncio.sleep(restart_delay)
|
||||
|
||||
|
||||
async def run():
|
||||
kwargs = {}
|
||||
if worker_type == CaptureType.RECORD:
|
||||
kwargs['filter_by_server'] = gethostname()
|
||||
for cam in ipcam_config.get_all_cam_names(**kwargs):
|
||||
for channel in channels:
|
||||
task = asyncio.create_task(run_ffmpeg(cam, channel))
|
||||
tasks.append(task)
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except KeyboardInterrupt:
|
||||
print('KeyboardInterrupt: stopping processes...', file=sys.stderr)
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
# wait for subprocesses to terminate
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# send termination signal to all subprocesses
|
||||
for task in tasks:
|
||||
process = task.get_stack()
|
||||
if process:
|
||||
process.send_signal(signal.SIGTERM)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
capture_types = [t.value for t in CaptureType]
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument('type', type=str, metavar='CAPTURE_TYPE', choices=tuple(capture_types),
|
||||
help='capture type (variants: '+', '.join(capture_types)+')')
|
||||
|
||||
arg = homekit_config.load_app(no_config=True, parser=parser)
|
||||
worker_type = CaptureType(arg['type'])
|
||||
|
||||
asyncio.run(run())
|
@ -1,119 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
PROGNAME="$0"
|
||||
PORT=554
|
||||
IP=
|
||||
CREDS=
|
||||
DEBUG=0
|
||||
CHANNEL=1
|
||||
FORCE_UDP=0
|
||||
FORCE_TCP=0
|
||||
EXTENSION="mp4"
|
||||
|
||||
die() {
|
||||
echo >&2 "error: $@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
usage: $PROGNAME [OPTIONS] COMMAND
|
||||
|
||||
Options:
|
||||
--outdir output directory
|
||||
--ip camera IP
|
||||
--port RTSP port (default: 554)
|
||||
--creds
|
||||
--debug
|
||||
--force-tcp
|
||||
--force-udp
|
||||
--channel 1|2
|
||||
|
||||
EOF
|
||||
exit
|
||||
}
|
||||
|
||||
validate_channel() {
|
||||
local c="$1"
|
||||
case "$c" in
|
||||
1|2)
|
||||
:
|
||||
;;
|
||||
*)
|
||||
die "Invalid channel"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
[ -z "$1" ] && usage
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ip | --port | --creds | --outdir)
|
||||
_var=${1:2}
|
||||
_var=${_var^^}
|
||||
printf -v "$_var" '%s' "$2"
|
||||
shift
|
||||
;;
|
||||
|
||||
--debug)
|
||||
DEBUG=1
|
||||
;;
|
||||
|
||||
--force-tcp)
|
||||
FORCE_TCP=1
|
||||
;;
|
||||
|
||||
--force-udp)
|
||||
FORCE_UDP=1
|
||||
;;
|
||||
|
||||
--channel)
|
||||
CHANNEL="$2"
|
||||
shift
|
||||
;;
|
||||
|
||||
--mov)
|
||||
EXTENSION="mov"
|
||||
;;
|
||||
|
||||
--mpv)
|
||||
EXTENSION="mpv"
|
||||
;;
|
||||
|
||||
*)
|
||||
die "Unrecognized argument: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -z "$OUTDIR" ] && die "You must specify output directory (--outdir)."
|
||||
[ -z "$IP" ] && die "You must specify camera IP address (--ip)."
|
||||
[ -z "$PORT" ] && die "Port can't be empty."
|
||||
[ -z "$CREDS" ] && die "You must specify credentials (--creds)."
|
||||
validate_channel "$CHANNEL"
|
||||
|
||||
if [ ! -d "${OUTDIR}" ]; then
|
||||
mkdir "${OUTDIR}" || die "Failed to create ${OUTDIR}/${NAME}!"
|
||||
echo "Created $OUTDIR."
|
||||
fi
|
||||
|
||||
args=
|
||||
if [ "$DEBUG" = "1" ]; then
|
||||
args="$args -v info"
|
||||
else
|
||||
args="$args -nostats -loglevel warning"
|
||||
fi
|
||||
|
||||
if [ "$FORCE_TCP" = "1" ]; then
|
||||
args="$args -rtsp_transport tcp"
|
||||
elif [ "$FORCE_UDP" = "1" ]; then
|
||||
args="$args -rtsp_transport udp"
|
||||
fi
|
||||
|
||||
[ ! -z "$CREDS" ] && CREDS="${CREDS}@"
|
||||
|
||||
ffmpeg $args -i rtsp://${CREDS}${IP}:${PORT}/Streaming/Channels/${CHANNEL} \
|
||||
-c copy -f segment -strftime 1 -segment_time 00:10:00 -segment_atclocktime 1 \
|
||||
"$OUTDIR/record_%Y-%m-%d-%H.%M.%S.${EXTENSION}"
|
@ -1,127 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
PROGNAME="$0"
|
||||
OUTDIR=/var/ipcamfs # should be tmpfs
|
||||
PORT=554
|
||||
NAME=
|
||||
IP=
|
||||
USER=
|
||||
PASSWORD=
|
||||
DEBUG=0
|
||||
CHANNEL=1
|
||||
FORCE_UDP=0
|
||||
FORCE_TCP=0
|
||||
CUSTOM_PATH=
|
||||
|
||||
die() {
|
||||
echo >&2 "error: $@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
usage: $PROGNAME [OPTIONS] COMMAND
|
||||
|
||||
Options:
|
||||
--ip camera IP
|
||||
--port RTSP port (default: 554)
|
||||
--name camera name (chunks will be stored under $OUTDIR/{name}/)
|
||||
--user
|
||||
--password
|
||||
--debug
|
||||
--force-tcp
|
||||
--force-udp
|
||||
--channel 1|2
|
||||
--custom-path PATH
|
||||
|
||||
EOF
|
||||
exit
|
||||
}
|
||||
|
||||
validate_channel() {
|
||||
local c="$1"
|
||||
case "$c" in
|
||||
1|2)
|
||||
:
|
||||
;;
|
||||
*)
|
||||
die "Invalid channel"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
[ -z "$1" ] && usage
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ip|--port|--name|--user|--password)
|
||||
_var=${1:2}
|
||||
_var=${_var^^}
|
||||
printf -v "$_var" '%s' "$2"
|
||||
shift
|
||||
;;
|
||||
|
||||
--debug)
|
||||
DEBUG=1
|
||||
;;
|
||||
|
||||
--force-tcp)
|
||||
FORCE_TCP=1
|
||||
;;
|
||||
|
||||
--force-udp)
|
||||
FORCE_UDP=1
|
||||
;;
|
||||
|
||||
--channel)
|
||||
CHANNEL="$2"
|
||||
shift
|
||||
;;
|
||||
|
||||
--custom-path)
|
||||
CUSTOM_PATH="$2"
|
||||
shift
|
||||
;;
|
||||
|
||||
*)
|
||||
die "Unrecognized argument: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -z "$IP" ] && die "You must specify camera IP address (--ip)."
|
||||
[ -z "$PORT" ] && die "Port can't be empty."
|
||||
[ -z "$NAME" ] && die "You must specify camera name (--name)."
|
||||
[ -z "$USER" ] && die "You must specify username (--user)."
|
||||
[ -z "$PASSWORD" ] && die "You must specify username (--password)."
|
||||
validate_channel "$CHANNEL"
|
||||
|
||||
if [ ! -d "${OUTDIR}/${NAME}" ]; then
|
||||
mkdir "${OUTDIR}/${NAME}" || die "Failed to create ${OUTDIR}/${NAME}!"
|
||||
fi
|
||||
|
||||
args=
|
||||
if [ "$DEBUG" = "1" ]; then
|
||||
args="-v info"
|
||||
else
|
||||
args="-nostats -loglevel error"
|
||||
fi
|
||||
|
||||
if [ "$FORCE_TCP" = "1" ]; then
|
||||
args="$args -rtsp_transport tcp"
|
||||
elif [ "$FORCE_UDP" = "1" ]; then
|
||||
args="$args -rtsp_transport udp"
|
||||
fi
|
||||
|
||||
if [ -z "$CUSTOM_PATH" ]; then
|
||||
path="/Streaming/Channels/${CHANNEL}"
|
||||
else
|
||||
path="$CUSTOM_PATH"
|
||||
fi
|
||||
|
||||
ffmpeg $args -i "rtsp://${USER}:${PASSWORD}@${IP}:${PORT}${path}" \
|
||||
-c:v copy -c:a copy -bufsize 1835k \
|
||||
-pix_fmt yuv420p \
|
||||
-flags -global_header -hls_time 2 -hls_list_size 3 -hls_flags delete_segments \
|
||||
${OUTDIR}/${NAME}/live.m3u8
|
@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import time
|
||||
import shutil
|
||||
@ -9,49 +8,41 @@ import __py_include
|
||||
|
||||
import homekit.telegram.aio as telegram
|
||||
|
||||
from socket import gethostname
|
||||
from argparse import ArgumentParser
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from asyncio import Lock
|
||||
|
||||
from homekit.config import config
|
||||
from homekit.config import config as homekit_config, LinuxBoardsConfig
|
||||
from homekit.util import Addr
|
||||
from homekit import http
|
||||
from homekit.database.sqlite import SQLiteBase
|
||||
from homekit.camera import util as camutil
|
||||
from homekit.camera import util as camutil, IpcamConfig
|
||||
from homekit.camera.types import (
|
||||
TimeFilterType,
|
||||
TelegramLinkType,
|
||||
VideoContainerType
|
||||
)
|
||||
from homekit.camera.util import (
|
||||
get_recordings_path,
|
||||
get_motion_path,
|
||||
is_valid_recording_name,
|
||||
datetime_from_filename
|
||||
)
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, Union, List, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from functools import cmp_to_key
|
||||
|
||||
|
||||
class TimeFilterType(Enum):
|
||||
FIX = 'fix'
|
||||
MOTION = 'motion'
|
||||
MOTION_START = 'motion_start'
|
||||
|
||||
|
||||
class TelegramLinkType(Enum):
|
||||
FRAGMENT = 'fragment'
|
||||
ORIGINAL_FILE = 'original_file'
|
||||
|
||||
|
||||
def valid_recording_name(filename: str) -> bool:
|
||||
return filename.startswith('record_') and filename.endswith('.mp4')
|
||||
|
||||
|
||||
def filename_to_datetime(filename: str) -> datetime:
|
||||
filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '')
|
||||
return datetime.strptime(filename, datetime_format)
|
||||
|
||||
|
||||
def get_all_cams() -> list:
|
||||
return [cam for cam in config['camera'].keys()]
|
||||
ipcam_config = IpcamConfig()
|
||||
lbc_config = LinuxBoardsConfig()
|
||||
|
||||
|
||||
# ipcam database
|
||||
# --------------
|
||||
|
||||
class IPCamServerDatabase(SQLiteBase):
|
||||
class IpcamServerDatabase(SQLiteBase):
|
||||
SCHEMA = 4
|
||||
|
||||
def __init__(self, path=None):
|
||||
@ -67,7 +58,7 @@ class IPCamServerDatabase(SQLiteBase):
|
||||
fix_time INTEGER NOT NULL,
|
||||
motion_time INTEGER NOT NULL
|
||||
)""")
|
||||
for cam in config['camera'].keys():
|
||||
for cam in ipcam_config.get_all_cam_names_for_this_server():
|
||||
self.add_camera(cam)
|
||||
|
||||
if version < 2:
|
||||
@ -135,7 +126,7 @@ class IPCamServerDatabase(SQLiteBase):
|
||||
# ipcam web api
|
||||
# -------------
|
||||
|
||||
class IPCamWebServer(http.HTTPServer):
|
||||
class IpcamWebServer(http.HTTPServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@ -146,16 +137,16 @@ class IPCamWebServer(http.HTTPServer):
|
||||
self.get('/api/timestamp/{name}/{type}', self.get_timestamp)
|
||||
self.get('/api/timestamp/all', self.get_all_timestamps)
|
||||
|
||||
self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes)
|
||||
self.post('/api/debug/fix', self.debug_fix)
|
||||
self.post('/api/debug/cleanup', self.debug_cleanup)
|
||||
|
||||
self.post('/api/timestamp/{name}/{type}', self.set_timestamp)
|
||||
|
||||
self.post('/api/motion/done/{name}', self.submit_motion)
|
||||
self.post('/api/motion/fail/{name}', self.submit_motion_failure)
|
||||
|
||||
self.get('/api/motion/params/{name}', self.get_motion_params)
|
||||
self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params)
|
||||
# self.get('/api/motion/params/{name}', self.get_motion_params)
|
||||
# self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params)
|
||||
|
||||
self.queue_lock = Lock()
|
||||
|
||||
@ -173,7 +164,7 @@ class IPCamWebServer(http.HTTPServer):
|
||||
|
||||
files = get_recordings_files(camera, filter, limit)
|
||||
if files:
|
||||
time = filename_to_datetime(files[len(files)-1]['name'])
|
||||
time = datetime_from_filename(files[len(files)-1]['name'])
|
||||
db.set_timestamp(camera, TimeFilterType.MOTION_START, time)
|
||||
return self.ok({'files': files})
|
||||
|
||||
@ -188,7 +179,7 @@ class IPCamWebServer(http.HTTPServer):
|
||||
if files:
|
||||
times_by_cam = {}
|
||||
for file in files:
|
||||
time = filename_to_datetime(file['name'])
|
||||
time = datetime_from_filename(file['name'])
|
||||
if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time:
|
||||
times_by_cam[file['cam']] = time
|
||||
for cam, time in times_by_cam.items():
|
||||
@ -200,14 +191,14 @@ class IPCamWebServer(http.HTTPServer):
|
||||
cam = int(req.match_info['name'])
|
||||
file = req.match_info['file']
|
||||
|
||||
fullpath = os.path.join(config['camera'][cam]['recordings_path'], file)
|
||||
fullpath = os.path.join(get_recordings_path(cam), file)
|
||||
if not os.path.isfile(fullpath):
|
||||
raise ValueError(f'file "{fullpath}" does not exists')
|
||||
|
||||
return http.FileResponse(fullpath)
|
||||
|
||||
async def camlist(self, req: http.Request):
|
||||
return self.ok(config['camera'])
|
||||
return self.ok(ipcam_config.get_all_cam_names_for_this_server())
|
||||
|
||||
async def submit_motion(self, req: http.Request):
|
||||
data = await req.post()
|
||||
@ -216,7 +207,7 @@ class IPCamWebServer(http.HTTPServer):
|
||||
timecodes = data['timecodes']
|
||||
filename = data['filename']
|
||||
|
||||
time = filename_to_datetime(filename)
|
||||
time = datetime_from_filename(filename)
|
||||
|
||||
try:
|
||||
if timecodes != '':
|
||||
@ -239,27 +230,10 @@ class IPCamWebServer(http.HTTPServer):
|
||||
message = data['message']
|
||||
|
||||
db.add_motion_failure(camera, filename, message)
|
||||
db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename))
|
||||
db.set_timestamp(camera, TimeFilterType.MOTION, datetime_from_filename(filename))
|
||||
|
||||
return self.ok()
|
||||
|
||||
async def debug_migrate_mtimes(self, req: http.Request):
|
||||
written = {}
|
||||
for cam in config['camera'].keys():
|
||||
confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}')
|
||||
for time_type in TimeFilterType:
|
||||
txt_file = os.path.join(confdir, f'{time_type.value}_mtime')
|
||||
if os.path.isfile(txt_file):
|
||||
with open(txt_file, 'r') as fd:
|
||||
data = fd.read()
|
||||
db.set_timestamp(cam, time_type, int(data.strip()))
|
||||
|
||||
if cam not in written:
|
||||
written[cam] = []
|
||||
written[cam].append(time_type)
|
||||
|
||||
return self.ok({'written': written})
|
||||
|
||||
async def debug_fix(self, req: http.Request):
|
||||
asyncio.ensure_future(fix_job())
|
||||
return self.ok()
|
||||
@ -280,26 +254,26 @@ class IPCamWebServer(http.HTTPServer):
|
||||
async def get_all_timestamps(self, req: http.Request):
|
||||
return self.ok(db.get_all_timestamps())
|
||||
|
||||
async def get_motion_params(self, req: http.Request):
|
||||
data = config['motion_params'][int(req.match_info['name'])]
|
||||
lines = [
|
||||
f'threshold={data["threshold"]}',
|
||||
f'min_event_length=3s',
|
||||
f'frame_skip=2',
|
||||
f'downscale_factor=3',
|
||||
]
|
||||
return self.plain('\n'.join(lines)+'\n')
|
||||
|
||||
async def get_motion_roi_params(self, req: http.Request):
|
||||
data = config['motion_params'][int(req.match_info['name'])]
|
||||
return self.plain('\n'.join(data['roi'])+'\n')
|
||||
# async def get_motion_params(self, req: http.Request):
|
||||
# data = config['motion_params'][int(req.match_info['name'])]
|
||||
# lines = [
|
||||
# f'threshold={data["threshold"]}',
|
||||
# f'min_event_length=3s',
|
||||
# f'frame_skip=2',
|
||||
# f'downscale_factor=3',
|
||||
# ]
|
||||
# return self.plain('\n'.join(lines)+'\n')
|
||||
#
|
||||
# async def get_motion_roi_params(self, req: http.Request):
|
||||
# data = config['motion_params'][int(req.match_info['name'])]
|
||||
# return self.plain('\n'.join(data['roi'])+'\n')
|
||||
|
||||
@staticmethod
|
||||
def _getset_timestamp_params(req: http.Request, need_time=False):
|
||||
values = []
|
||||
|
||||
cam = int(req.match_info['name'])
|
||||
assert cam in config['camera'], 'invalid camera'
|
||||
assert cam in ipcam_config.get_all_cam_names_for_this_server(), 'invalid camera'
|
||||
|
||||
values.append(cam)
|
||||
values.append(TimeFilterType(req.match_info['type']))
|
||||
@ -307,7 +281,7 @@ class IPCamWebServer(http.HTTPServer):
|
||||
if need_time:
|
||||
time = req.query['time']
|
||||
if time.startswith('record_'):
|
||||
time = filename_to_datetime(time)
|
||||
time = datetime_from_filename(time)
|
||||
elif time.isnumeric():
|
||||
time = int(time)
|
||||
else:
|
||||
@ -322,30 +296,22 @@ class IPCamWebServer(http.HTTPServer):
|
||||
|
||||
def open_database(database_path: str):
|
||||
global db
|
||||
db = IPCamServerDatabase(database_path)
|
||||
db = IpcamServerDatabase(database_path)
|
||||
|
||||
# update cams list in database, if needed
|
||||
cams = db.get_all_timestamps().keys()
|
||||
for cam in config['camera']:
|
||||
if cam not in cams:
|
||||
stored_cams = db.get_all_timestamps().keys()
|
||||
for cam in ipcam_config.get_all_cam_names_for_this_server():
|
||||
if cam not in stored_cams:
|
||||
db.add_camera(cam)
|
||||
|
||||
|
||||
def get_recordings_path(cam: int) -> str:
|
||||
return config['camera'][cam]['recordings_path']
|
||||
|
||||
|
||||
def get_motion_path(cam: int) -> str:
|
||||
return config['camera'][cam]['motion_path']
|
||||
|
||||
|
||||
def get_recordings_files(cam: Optional[int] = None,
|
||||
time_filter_type: Optional[TimeFilterType] = None,
|
||||
limit=0) -> List[dict]:
|
||||
from_time = 0
|
||||
to_time = int(time.time())
|
||||
|
||||
cams = [cam] if cam is not None else get_all_cams()
|
||||
cams = [cam] if cam is not None else ipcam_config.get_all_cam_names_for_this_server()
|
||||
files = []
|
||||
for cam in cams:
|
||||
if time_filter_type:
|
||||
@ -362,7 +328,7 @@ def get_recordings_files(cam: Optional[int] = None,
|
||||
'name': file,
|
||||
'size': os.path.getsize(os.path.join(recdir, file))}
|
||||
for file in os.listdir(recdir)
|
||||
if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time]
|
||||
if is_valid_recording_name(file) and from_time < datetime_from_filename(file) <= to_time]
|
||||
cam_files.sort(key=lambda file: file['name'])
|
||||
|
||||
if cam_files:
|
||||
@ -382,7 +348,7 @@ def get_recordings_files(cam: Optional[int] = None,
|
||||
async def process_fragments(camera: int,
|
||||
filename: str,
|
||||
fragments: List[Tuple[int, int]]) -> None:
|
||||
time = filename_to_datetime(filename)
|
||||
time = datetime_from_filename(filename)
|
||||
|
||||
rec_dir = get_recordings_path(camera)
|
||||
motion_dir = get_motion_path(camera)
|
||||
@ -392,8 +358,8 @@ async def process_fragments(camera: int,
|
||||
for fragment in fragments:
|
||||
start, end = fragment
|
||||
|
||||
start -= config['motion']['padding']
|
||||
end += config['motion']['padding']
|
||||
start -= ipcam_config['motion_padding']
|
||||
end += ipcam_config['motion_padding']
|
||||
|
||||
if start < 0:
|
||||
start = 0
|
||||
@ -408,14 +374,14 @@ async def process_fragments(camera: int,
|
||||
start_pos=start,
|
||||
duration=duration)
|
||||
|
||||
if fragments and 'telegram' in config['motion'] and config['motion']['telegram']:
|
||||
if fragments and ipcam_config['motion_telegram']:
|
||||
asyncio.ensure_future(motion_notify_tg(camera, filename, fragments))
|
||||
|
||||
|
||||
async def motion_notify_tg(camera: int,
|
||||
filename: str,
|
||||
fragments: List[Tuple[int, int]]):
|
||||
dt_file = filename_to_datetime(filename)
|
||||
dt_file = datetime_from_filename(filename)
|
||||
fmt = '%H:%M:%S'
|
||||
|
||||
text = f'Camera: <b>{camera}</b>\n'
|
||||
@ -423,8 +389,8 @@ async def motion_notify_tg(camera: int,
|
||||
text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename)
|
||||
|
||||
for start, end in fragments:
|
||||
start -= config['motion']['padding']
|
||||
end += config['motion']['padding']
|
||||
start -= ipcam_config['motion_padding']
|
||||
end += ipcam_config['motion_padding']
|
||||
|
||||
if start < 0:
|
||||
start = 0
|
||||
@ -446,7 +412,7 @@ def _tg_links(link_type: TelegramLinkType,
|
||||
camera: int,
|
||||
file: str) -> str:
|
||||
links = []
|
||||
for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']:
|
||||
for link_name, link_template in ipcam_config[f'{link_type.value}_url_templates']:
|
||||
link = link_template.replace('{camera}', str(camera)).replace('{file}', file)
|
||||
links.append(f'<a href="{link}">{link_name}</a>')
|
||||
return ' '.join(links)
|
||||
@ -462,7 +428,7 @@ async def fix_job() -> None:
|
||||
|
||||
try:
|
||||
fix_job_running = True
|
||||
for cam in config['camera'].keys():
|
||||
for cam in ipcam_config.get_all_cam_names_for_this_server():
|
||||
files = get_recordings_files(cam, TimeFilterType.FIX)
|
||||
if not files:
|
||||
logger.debug(f'fix_job: no files for camera {cam}')
|
||||
@ -473,7 +439,7 @@ async def fix_job() -> None:
|
||||
for file in files:
|
||||
fullpath = os.path.join(get_recordings_path(cam), file['name'])
|
||||
await camutil.ffmpeg_recreate(fullpath)
|
||||
timestamp = filename_to_datetime(file['name'])
|
||||
timestamp = datetime_from_filename(file['name'])
|
||||
if timestamp:
|
||||
db.set_timestamp(cam, TimeFilterType.FIX, timestamp)
|
||||
|
||||
@ -482,21 +448,9 @@ async def fix_job() -> None:
|
||||
|
||||
|
||||
async def cleanup_job() -> None:
|
||||
def fn2dt(name: str) -> datetime:
|
||||
name = os.path.basename(name)
|
||||
|
||||
if name.startswith('record_'):
|
||||
return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format)
|
||||
|
||||
m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name)
|
||||
if m:
|
||||
return datetime.strptime(m.group(1), datetime_format)
|
||||
|
||||
raise ValueError(f'unrecognized filename format: {name}')
|
||||
|
||||
def compare(i1: str, i2: str) -> int:
|
||||
dt1 = fn2dt(i1)
|
||||
dt2 = fn2dt(i2)
|
||||
dt1 = datetime_from_filename(i1)
|
||||
dt2 = datetime_from_filename(i2)
|
||||
|
||||
if dt1 < dt2:
|
||||
return -1
|
||||
@ -516,18 +470,19 @@ async def cleanup_job() -> None:
|
||||
cleanup_job_running = True
|
||||
|
||||
gb = float(1 << 30)
|
||||
for storage in config['storages']:
|
||||
disk_number = 0
|
||||
for storage in lbc_config.get_board_disks(gethostname()):
|
||||
disk_number += 1
|
||||
if os.path.exists(storage['mountpoint']):
|
||||
total, used, free = shutil.disk_usage(storage['mountpoint'])
|
||||
free_gb = free // gb
|
||||
if free_gb < config['cleanup_min_gb']:
|
||||
# print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}")
|
||||
if free_gb < ipcam_config['cleanup_min_gb']:
|
||||
cleaned = 0
|
||||
files = []
|
||||
for cam in storage['cams']:
|
||||
for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']):
|
||||
for cam in ipcam_config.get_all_cam_names_for_this_server(filter_by_disk=disk_number):
|
||||
for _dir in (get_recordings_path(cam), get_motion_path(cam)):
|
||||
files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir)))
|
||||
files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files))
|
||||
files = list(filter(lambda path: os.path.isfile(path) and path.endswith(tuple([f'.{t.value}' for t in VideoContainerType])), files))
|
||||
files.sort(key=cmp_to_key(compare))
|
||||
|
||||
for file in files:
|
||||
@ -537,7 +492,7 @@ async def cleanup_job() -> None:
|
||||
cleaned += size
|
||||
except OSError as e:
|
||||
logger.exception(e)
|
||||
if (free + cleaned) // gb >= config['cleanup_min_gb']:
|
||||
if (free + cleaned) // gb >= ipcam_config['cleanup_min_gb']:
|
||||
break
|
||||
else:
|
||||
logger.error(f"cleanup_job: {storage['mountpoint']} not found")
|
||||
@ -550,8 +505,8 @@ cleanup_job_running = False
|
||||
|
||||
datetime_format = '%Y-%m-%d-%H.%M.%S'
|
||||
datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}'
|
||||
db: Optional[IPCamServerDatabase] = None
|
||||
server: Optional[IPCamWebServer] = None
|
||||
db: Optional[IpcamServerDatabase] = None
|
||||
server: Optional[IpcamWebServer] = None
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -562,7 +517,7 @@ if __name__ == '__main__':
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument('--listen', type=str, required=True)
|
||||
parser.add_argument('--database-path', type=str, required=True)
|
||||
arg = config.load_app(no_config=True, parser=parser)
|
||||
arg = homekit_config.load_app(no_config=True, parser=parser)
|
||||
|
||||
open_database(arg.database_path)
|
||||
|
||||
@ -570,10 +525,14 @@ if __name__ == '__main__':
|
||||
|
||||
try:
|
||||
scheduler = AsyncIOScheduler(event_loop=loop)
|
||||
if config['fix_enabled']:
|
||||
scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None)
|
||||
if ipcam_config['fix_enabled']:
|
||||
scheduler.add_job(fix_job, 'interval',
|
||||
seconds=ipcam_config['fix_interval'],
|
||||
misfire_grace_time=None)
|
||||
|
||||
scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None)
|
||||
scheduler.add_job(cleanup_job, 'interval',
|
||||
seconds=ipcam_config['cleanup_interval'],
|
||||
misfire_grace_time=None)
|
||||
scheduler.start()
|
||||
except KeyError:
|
||||
pass
|
||||
@ -581,5 +540,5 @@ if __name__ == '__main__':
|
||||
asyncio.ensure_future(fix_job())
|
||||
asyncio.ensure_future(cleanup_job())
|
||||
|
||||
server = IPCamWebServer(config.get_addr('server.listen'))
|
||||
server = IpcamWebServer(Addr.fromstring(arg.listen))
|
||||
server.run()
|
||||
|
@ -42,7 +42,6 @@ class WebAPIServer(http.HTTPServer):
|
||||
self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits)
|
||||
self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits)
|
||||
|
||||
self.post('/log/bot_request/', self.POST_bot_request_log)
|
||||
self.post('/log/openwrt/', self.POST_openwrt_log)
|
||||
|
||||
self.get('/inverter/consumed_energy/', self.GET_consumed_energy)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import subprocess
|
||||
|
||||
from ..config import app_config as config
|
||||
from ..config import config
|
||||
from threading import Lock
|
||||
from typing import Union, List
|
||||
|
||||
@ -10,14 +10,14 @@ _default_step = 5
|
||||
|
||||
|
||||
def has_control(s: str) -> bool:
|
||||
for control in config['amixer']['controls']:
|
||||
for control in config.app_config['amixer']['controls']:
|
||||
if control['name'] == s:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_caps(s: str) -> List[str]:
|
||||
for control in config['amixer']['controls']:
|
||||
for control in config.app_config['amixer']['controls']:
|
||||
if control['name'] == s:
|
||||
return control['caps']
|
||||
raise KeyError(f'control {s} not found')
|
||||
@ -25,7 +25,7 @@ def get_caps(s: str) -> List[str]:
|
||||
|
||||
def get_all() -> list:
|
||||
controls = []
|
||||
for control in config['amixer']['controls']:
|
||||
for control in config.app_config['amixer']['controls']:
|
||||
controls.append({
|
||||
'name': control['name'],
|
||||
'info': get(control['name']),
|
||||
@ -55,8 +55,8 @@ def nocap(control):
|
||||
|
||||
|
||||
def _get_default_step() -> int:
|
||||
if 'step' in config['amixer']:
|
||||
return int(config['amixer']['step'])
|
||||
if 'step' in config.app_config['amixer']:
|
||||
return int(config.app_config['amixer']['step'])
|
||||
|
||||
return _default_step
|
||||
|
||||
@ -75,7 +75,7 @@ def decr(control, step=None):
|
||||
|
||||
def call(*args, return_code=False) -> Union[int, str]:
|
||||
with _lock:
|
||||
result = subprocess.run([config['amixer']['bin'], *args],
|
||||
result = subprocess.run([config.app_config['amixer']['bin'], *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
if return_code:
|
||||
|
@ -1 +1,2 @@
|
||||
from .types import CameraType
|
||||
from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType
|
||||
from .config import IpcamConfig
|
@ -1,8 +1,9 @@
|
||||
import socket
|
||||
|
||||
from ..config import ConfigUnit, LinuxBoardsConfig
|
||||
from typing import Optional
|
||||
from .types import CameraType, VideoContainerType, VideoCodecType
|
||||
|
||||
|
||||
_lbc = LinuxBoardsConfig()
|
||||
|
||||
|
||||
@ -42,7 +43,8 @@ class IpcamConfig(ConfigUnit):
|
||||
'schema': {'type': 'string', 'check_with': _validate_roi_line}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'rtsp_tcp': {'type': 'boolean'}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -55,7 +57,19 @@ class IpcamConfig(ConfigUnit):
|
||||
|
||||
# TODO FIXME
|
||||
'fragment_url_templates': cls._url_templates_schema(),
|
||||
'original_file_url_templates': cls._url_templates_schema()
|
||||
'original_file_url_templates': cls._url_templates_schema(),
|
||||
|
||||
'hls_path': {'type': 'string', 'required': True},
|
||||
'motion_processing_tmpfs_path': {'type': 'string', 'required': True},
|
||||
|
||||
'rtsp_creds': {
|
||||
'required': True,
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'login': {'type': 'string', 'required': True},
|
||||
'password': {'type': 'string', 'required': True},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@ -79,3 +93,38 @@ class IpcamConfig(ConfigUnit):
|
||||
'schema': {'type': 'string'}
|
||||
}
|
||||
}
|
||||
|
||||
def get_all_cam_names(self,
|
||||
filter_by_server: Optional[str] = None,
|
||||
filter_by_disk: Optional[int] = None) -> list[int]:
|
||||
cams = []
|
||||
if filter_by_server is not None and filter_by_server not in _lbc:
|
||||
raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}')
|
||||
for cam, params in self['cams'].items():
|
||||
if filter_by_server is None or params['server'] == filter_by_server:
|
||||
if filter_by_disk is None or params['disk'] == filter_by_disk:
|
||||
cams.append(int(cam))
|
||||
return cams
|
||||
|
||||
def get_all_cam_names_for_this_server(self,
|
||||
filter_by_disk: Optional[int] = None):
|
||||
return self.get_all_cam_names(filter_by_server=socket.gethostname(),
|
||||
filter_by_disk=filter_by_disk)
|
||||
|
||||
def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]:
|
||||
return self['cams'][cam]['server'], self['cams'][cam]['disk']
|
||||
|
||||
def get_camera_container(self, cam: int) -> VideoContainerType:
|
||||
return VideoContainerType(self['cams'][cam]['container'])
|
||||
|
||||
def get_camera_type(self, cam: int) -> CameraType:
|
||||
return CameraType(self['cams'][cam]['type'])
|
||||
|
||||
def get_rtsp_creds(self) -> tuple[str, str]:
|
||||
return self['rtsp_creds']['login'], self['rtsp_creds']['password']
|
||||
|
||||
def should_use_tcp_for_rtsp(self, cam: int) -> bool:
|
||||
return 'rtsp_tcp' in self['cams'][cam] and self['cams'][cam]['rtsp_tcp']
|
||||
|
||||
def get_camera_ip(self, camera: int) -> str:
|
||||
return f'192.168.5.{camera}'
|
||||
|
@ -6,6 +6,19 @@ class CameraType(Enum):
|
||||
ALIEXPRESS_NONAME = 'ali'
|
||||
HIKVISION = 'hik'
|
||||
|
||||
def get_channel_url(self, channel: int) -> str:
|
||||
if channel not in (1, 2):
|
||||
raise ValueError(f'channel {channel} is invalid')
|
||||
if channel == 1:
|
||||
return ''
|
||||
elif channel == 2:
|
||||
if self.value == CameraType.HIKVISION:
|
||||
return '/Streaming/Channels/2'
|
||||
elif self.value == CameraType.ALIEXPRESS_NONAME:
|
||||
return '/?stream=1.sdp'
|
||||
else:
|
||||
raise ValueError(f'unsupported camera type {self.value}')
|
||||
|
||||
|
||||
class VideoContainerType(Enum):
|
||||
MP4 = 'mp4'
|
||||
@ -15,3 +28,19 @@ class VideoContainerType(Enum):
|
||||
class VideoCodecType(Enum):
|
||||
H264 = 'h264'
|
||||
H265 = 'h265'
|
||||
|
||||
|
||||
class TimeFilterType(Enum):
|
||||
FIX = 'fix'
|
||||
MOTION = 'motion'
|
||||
MOTION_START = 'motion_start'
|
||||
|
||||
|
||||
class TelegramLinkType(Enum):
|
||||
FRAGMENT = 'fragment'
|
||||
ORIGINAL_FILE = 'original_file'
|
||||
|
||||
|
||||
class CaptureType(Enum):
|
||||
HLS = 'hls'
|
||||
RECORD = 'record'
|
||||
|
@ -2,13 +2,21 @@ import asyncio
|
||||
import os.path
|
||||
import logging
|
||||
import psutil
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
from ..util import chunks
|
||||
from ..config import config
|
||||
from ..config import config, LinuxBoardsConfig
|
||||
from .config import IpcamConfig
|
||||
from .types import VideoContainerType
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_temporary_fixing = '.temporary_fixing.mp4'
|
||||
_ipcam_config = IpcamConfig()
|
||||
_lbc_config = LinuxBoardsConfig()
|
||||
|
||||
datetime_format = '%Y-%m-%d-%H.%M.%S'
|
||||
datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}'
|
||||
|
||||
|
||||
def _get_ffmpeg_path() -> str:
|
||||
@ -26,7 +34,8 @@ def time2seconds(time: str) -> int:
|
||||
|
||||
async def ffmpeg_recreate(filename: str):
|
||||
filedir = os.path.dirname(filename)
|
||||
tempname = os.path.join(filedir, _temporary_fixing)
|
||||
_, fileext = os.path.splitext(filename)
|
||||
tempname = os.path.join(filedir, f'.temporary_fixing.{fileext}')
|
||||
mtime = os.path.getmtime(filename)
|
||||
|
||||
args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname]
|
||||
@ -105,3 +114,56 @@ def has_handle(fpath):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_recordings_path(cam: int) -> str:
|
||||
server, disk = _ipcam_config.get_cam_server_and_disk(cam)
|
||||
disks = _lbc_config.get_board_disks(server)
|
||||
disk_mountpoint = disks[disk-1]
|
||||
return f'{disk_mountpoint}/cam-{cam}'
|
||||
|
||||
|
||||
def get_motion_path(cam: int) -> str:
|
||||
return f'{get_recordings_path(cam)}/motion'
|
||||
|
||||
|
||||
def is_valid_recording_name(filename: str) -> bool:
|
||||
if not filename.startswith('record_'):
|
||||
return False
|
||||
|
||||
for container_type in VideoContainerType:
|
||||
if filename.endswith(f'.{container_type.value}'):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def datetime_from_filename(name: str) -> datetime:
|
||||
name = os.path.basename(name)
|
||||
exts = '|'.join([t.value for t in VideoContainerType])
|
||||
|
||||
if name.startswith('record_'):
|
||||
return datetime.strptime(re.match(rf'record_(.*?)\.(?:{exts})', name).group(1), datetime_format)
|
||||
|
||||
m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.(?:{exts})', name)
|
||||
if m:
|
||||
return datetime.strptime(m.group(1), datetime_format)
|
||||
|
||||
raise ValueError(f'unrecognized filename format: {name}')
|
||||
|
||||
|
||||
def get_hls_channel_name(cam: int, channel: int) -> str:
|
||||
name = str(cam)
|
||||
if channel == 2:
|
||||
name += '-low'
|
||||
return name
|
||||
|
||||
|
||||
def get_hls_directory(cam, channel) -> str:
|
||||
dirname = os.path.join(
|
||||
_ipcam_config['hls_path'],
|
||||
get_hls_channel_name(cam, channel)
|
||||
)
|
||||
if not os.path.exists(dirname):
|
||||
os.makedirs(dirname)
|
||||
return dirname
|
@ -53,3 +53,9 @@ class LinuxBoardsConfig(ConfigUnit):
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
def get_board_disks(self, name: str) -> list[dict]:
|
||||
return self[name]['ext_hdd']
|
||||
|
||||
def get_board_disks_count(self, name: str) -> int:
|
||||
return len(self[name]['ext_hdd'])
|
||||
|
@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=save ipcam streams
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=user
|
||||
Group=user
|
||||
EnvironmentFile=/etc/ipcam_capture.conf.d/%i.conf
|
||||
ExecStart=/home/user/homekit/bin/ipcam_capture.sh --outdir $OUTDIR --creds $CREDS --ip $IP --port $PORT $ARGS
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=convert rtsp to hls for viewing live camera feeds in browser
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=user
|
||||
Group=user
|
||||
EnvironmentFile=/etc/ipcam_rtsp2hls.conf.d/%i.conf
|
||||
ExecStart=/home/user/homekit/bin/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
Loading…
x
Reference in New Issue
Block a user