Merge branch 'feat/28' into fastapi
This commit is contained in:
commit
52564571f5
18 changed files with 457 additions and 86 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -5,3 +5,5 @@ build/
|
||||||
dist/
|
dist/
|
||||||
rec/
|
rec/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
/venv
|
||||||
|
/docker/output/*
|
||||||
|
|
32
Dockerfile
Normal file
32
Dockerfile
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
FROM python:3.7
|
||||||
|
|
||||||
|
ARG hostuid=1000
|
||||||
|
ARG hostgid=1000
|
||||||
|
|
||||||
|
ENV TECHREC_CONFIG=/src/techrec/docker/config.py
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . /src/techrec
|
||||||
|
|
||||||
|
RUN groupadd -g ${hostgid} techrec \
|
||||||
|
&& useradd -g techrec -u ${hostuid} -m techrec \
|
||||||
|
&& mkdir -p /src/techrec \
|
||||||
|
&& mkdir -p /src/db \
|
||||||
|
&& chown -R techrec:techrec /src \
|
||||||
|
&& apt-get -qq update \
|
||||||
|
&& apt-get install -qq -y ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER techrec
|
||||||
|
|
||||||
|
|
||||||
|
RUN python -m venv ./venv \
|
||||||
|
&& ./venv/bin/python -m pip install wheel \
|
||||||
|
&& ./venv/bin/python -m pip install -e ./techrec
|
||||||
|
|
||||||
|
VOLUME ["/src/db"]
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/src/venv/bin/techrec"]
|
||||||
|
CMD ["-vv", "serve"]
|
61
Makefile
Normal file
61
Makefile
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
DOCKER := docker
|
||||||
|
DOCKERC := docker-compose
|
||||||
|
PORT := 8000
|
||||||
|
VENV := venv
|
||||||
|
CONFIG := dev_config.py
|
||||||
|
PY := python
|
||||||
|
OWNER := ${USER}
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
$(DOCKERC) build \
|
||||||
|
--no-cache \
|
||||||
|
--build-arg=hostgid=$(shell id -g) \
|
||||||
|
--build-arg=hostuid=$(shell id -u) \
|
||||||
|
--build-arg=audiogid=$(shell cat /etc/group | grep audio | awk -F: '{print $3}')
|
||||||
|
|
||||||
|
docker-build-liquidsoap:
|
||||||
|
$(DOCKER) pull savonet/liquidsoap:main
|
||||||
|
$(DOCKERC) build \
|
||||||
|
--no-cache \
|
||||||
|
--build-arg=audiogid=$(shell cat /etc/group | grep audio | awk -F: '{print $3}') \
|
||||||
|
liquidsoap
|
||||||
|
|
||||||
|
docker-build-techrec:
|
||||||
|
$(DOCKERC) build \
|
||||||
|
--no-cache \
|
||||||
|
--build-arg=hostgid=$(shell id -g ${OWNER}) \
|
||||||
|
--build-arg=hostuid=$(shell id -u ${OWNER}) \
|
||||||
|
techrec
|
||||||
|
|
||||||
|
docker-stop:
|
||||||
|
$(DOCKERC) down -v
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
$(DOCKERC) run --rm --service-ports techrec
|
||||||
|
|
||||||
|
docker-shell-techrec:
|
||||||
|
$(eval CONTAINER = $(shell docker ps|grep techrec_run|awk '{print $$12}'))
|
||||||
|
$(DOCKER) exec -ti $(CONTAINER) bash
|
||||||
|
|
||||||
|
docker-shell-storage:
|
||||||
|
$(DOCKERC) exec storage bash
|
||||||
|
|
||||||
|
docker-shell-liquidsoap:
|
||||||
|
$(eval CONTAINER = $(shell docker ps|grep liquidsoap|awk '{print $$12}'))
|
||||||
|
$(DOCKER) exec -ti $(CONTAINER) bash
|
||||||
|
|
||||||
|
docker-logs-storage:
|
||||||
|
$(DOCKERC) logs -f storage
|
||||||
|
|
||||||
|
docker-logs-liquidsoap:
|
||||||
|
$(DOCKERC) logs -f liquidsoap
|
||||||
|
|
||||||
|
local-install:
|
||||||
|
$(PY) -m venv $(VENV)
|
||||||
|
./$(VENV)/bin/pip install -e .
|
||||||
|
|
||||||
|
local-serve:
|
||||||
|
env TECHREC_CONFIG=$(CONFIG) ./$(VENV)/bin/techrec -vv serve
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: docker-build docker-build-liquidsoap docker-build-techrec docker-stop docker-run docker-shell-techrec docker-shell-storage docker-shell-liquidsoap docker-logs-storage docker-logs-liquidsoap local-install local-serve
|
49
docker-compose.yaml
Normal file
49
docker-compose.yaml
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
liquidsoap:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile.liquidsoap
|
||||||
|
volumes:
|
||||||
|
- ./docker/run.liq:/run.liq
|
||||||
|
- ./docker/run.sh:/run.sh
|
||||||
|
- rec:/rec
|
||||||
|
devices:
|
||||||
|
- /dev/snd:/dev/snd
|
||||||
|
entrypoint: /run.sh
|
||||||
|
depends_on:
|
||||||
|
- storageprepare
|
||||||
|
|
||||||
|
storage:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
- rec:/var/www/rec
|
||||||
|
- ./docker/storage.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
ports:
|
||||||
|
- 18080:80
|
||||||
|
depends_on:
|
||||||
|
- storageprepare
|
||||||
|
|
||||||
|
storageprepare:
|
||||||
|
image: bash
|
||||||
|
volumes:
|
||||||
|
- rec:/rec
|
||||||
|
command: chmod 777 /rec
|
||||||
|
|
||||||
|
techrec:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- .:/src/techrec
|
||||||
|
- rec:/rec
|
||||||
|
- ./docker/output:/src/output
|
||||||
|
- db:/src/db
|
||||||
|
ports:
|
||||||
|
- 8000:8000
|
||||||
|
depends_on:
|
||||||
|
- liquidsoap
|
||||||
|
- storage
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rec:
|
||||||
|
db:
|
10
docker/Dockerfile.liquidsoap
Normal file
10
docker/Dockerfile.liquidsoap
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
FROM savonet/liquidsoap:main
|
||||||
|
|
||||||
|
ENV audiogid=995
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN groupadd -g ${audiogid} hostaudio \
|
||||||
|
&& usermod -a -G hostaudio liquidsoap
|
||||||
|
|
||||||
|
USER liquidsoap
|
9
docker/config.py
Normal file
9
docker/config.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
DB_URI = "sqlite:////src/db/techrec.db"
|
||||||
|
AUDIO_INPUT = "http://storage"
|
||||||
|
# decomment this if you want to test with local audio source
|
||||||
|
# AUDIO_INPUT = "/rec"
|
||||||
|
AUDIO_OUTPUT = "/src/output"
|
||||||
|
DEBUG = True
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 8000
|
||||||
|
FFMPEG_OPTIONS = ["-loglevel", "warning"]
|
0
docker/output/.gitkeep
Normal file
0
docker/output/.gitkeep
Normal file
26
docker/run.liq
Executable file
26
docker/run.liq
Executable file
|
@ -0,0 +1,26 @@
|
||||||
|
#!/usr/bin/liquidsoap
|
||||||
|
|
||||||
|
settings.log.stdout.set(true);
|
||||||
|
settings.log.file.set(false);
|
||||||
|
settings.log.level.set(3);
|
||||||
|
|
||||||
|
# settings.server.telnet.set(true);
|
||||||
|
# settings.server.telnet.bind_addr.set("127.0.0.1");
|
||||||
|
# settings.server.telnet.port.set(6666);
|
||||||
|
|
||||||
|
rorinput = input.alsa(device="default", bufferize=true);
|
||||||
|
#rorinput = input.pulseaudio( );
|
||||||
|
|
||||||
|
# rorinput = insert_metadata(id="trx",rorinput);
|
||||||
|
rorinput = rewrite_metadata([("artist","Radio OndaRossa")],rorinput);
|
||||||
|
|
||||||
|
# ESCPOST
|
||||||
|
output.file(
|
||||||
|
id="rorrec",
|
||||||
|
reopen_when={0m},
|
||||||
|
%mp3(bitrate=80, samplerate=44100, stereo=true,stereo_mode="joint_stereo"),
|
||||||
|
"/rec/%Y-%m/%d/rec-%Y-%m-%d-%H-%M-%S.mp3",
|
||||||
|
# %vorbis(quality=0.3, samplerate=44100, channels=2),
|
||||||
|
# "/rec/%Y-%m/%d/rec-%Y-%m-%d-%H-%M-%S.ogg",
|
||||||
|
rorinput
|
||||||
|
);
|
11
docker/run.sh
Executable file
11
docker/run.sh
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -xueo pipefail
|
||||||
|
|
||||||
|
FILEPATH="/rec/$(date +%Y-%m)/$(date +%d)/rec-$(date +%Y-%m-%d-%H)-00-00.mp3"
|
||||||
|
mkdir -p $(dirname ${FILEPATH})
|
||||||
|
if ! [[ -f ${FILEPATH} ]]; then
|
||||||
|
ffmpeg -f lavfi -i anullsrc=r=11025:cl=mono -t 3600 -acodec mp3 ${FILEPATH}
|
||||||
|
fi
|
||||||
|
|
||||||
|
/run.liq
|
9
docker/storage.conf
Normal file
9
docker/storage.conf
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name storage;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /var/www/rec;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
}
|
3
setup.cfg
Normal file
3
setup.cfg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[flake8]
|
||||||
|
max-line-length=89
|
||||||
|
ignore=D
|
16
setup.py
16
setup.py
|
@ -1,7 +1,19 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
from distutils.core import setup
|
from distutils.core import setup
|
||||||
import setuptools
|
|
||||||
|
REQUIREMENTS = [
|
||||||
|
"SQLAlchemy==0.8.3",
|
||||||
|
"aiofiles==0.6.0",
|
||||||
|
"aiohttp==3.7.4",
|
||||||
|
"click==7.1.2",
|
||||||
|
"fastapi==0.62.0",
|
||||||
|
"h11==0.11.0",
|
||||||
|
"pydantic==1.7.3",
|
||||||
|
"starlette==0.13.6",
|
||||||
|
"typing-extensions==3.7.4.3",
|
||||||
|
"uvicorn==0.13.1",
|
||||||
|
]
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="techrec",
|
name="techrec",
|
||||||
|
@ -14,7 +26,7 @@ setup(
|
||||||
author_email="piuttosto@logorroici.org",
|
author_email="piuttosto@logorroici.org",
|
||||||
packages=["techrec"],
|
packages=["techrec"],
|
||||||
package_dir={"techrec": "techrec"},
|
package_dir={"techrec": "techrec"},
|
||||||
install_requires=["SQLAlchemy==0.8.3", "fastapi==0.62.0", "aiofiles==0.6.0"],
|
install_requires=REQUIREMENTS,
|
||||||
classifiers=["Programming Language :: Python :: 3.7"],
|
classifiers=["Programming Language :: Python :: 3.7"],
|
||||||
entry_points={"console_scripts": ["techrec = techrec.cli:main"]},
|
entry_points={"console_scripts": ["techrec = techrec.cli:main"]},
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
|
|
|
@ -4,27 +4,41 @@ import os.path
|
||||||
import sys
|
import sys
|
||||||
from argparse import Action, ArgumentParser
|
from argparse import Action, ArgumentParser
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
from . import forge, maint, server
|
from . import forge, maint, server
|
||||||
from .config_manager import get_config
|
from .config_manager import get_config
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout)
|
|
||||||
logger = logging.getLogger("cli")
|
logger = logging.getLogger("cli")
|
||||||
|
|
||||||
|
|
||||||
CWD = os.getcwd()
|
CWD = os.getcwd()
|
||||||
|
OK_CODES = [200, 301, 302]
|
||||||
|
|
||||||
|
|
||||||
|
def is_writable(d):
|
||||||
|
return os.access(d, os.W_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def check_remote_store(url: str) -> None:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url) as req:
|
||||||
|
if req.code not in OK_CODES:
|
||||||
|
logger.warn(f"Audio input {url} not responding")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warn(f"Audio input {url} not accessible: {e}")
|
||||||
|
|
||||||
|
|
||||||
def pre_check_permissions():
|
def pre_check_permissions():
|
||||||
def is_writable(d):
|
audio_input = get_config()["AUDIO_INPUT"]
|
||||||
return os.access(d, os.W_OK)
|
if audio_input.startswith("http://") or audio_input.startswith("https://"):
|
||||||
|
check_remote_store(audio_input)
|
||||||
if is_writable(get_config()["AUDIO_INPUT"]):
|
else:
|
||||||
yield "Audio input '%s' writable" % get_config()["AUDIO_INPUT"]
|
if is_writable(audio_input):
|
||||||
if not os.access(get_config()["AUDIO_INPUT"], os.R_OK):
|
yield "Audio input '%s' writable" % audio_input
|
||||||
yield "Audio input '%s' unreadable" % get_config()["AUDIO_INPUT"]
|
if not os.access(audio_input, os.R_OK):
|
||||||
|
yield "Audio input '%s' unreadable" % audio_input
|
||||||
sys.exit(10)
|
sys.exit(10)
|
||||||
if is_writable(os.getcwd()):
|
if is_writable(CWD):
|
||||||
yield "Code writable"
|
yield "Code writable"
|
||||||
if not is_writable(get_config()["AUDIO_OUTPUT"]):
|
if not is_writable(get_config()["AUDIO_OUTPUT"]):
|
||||||
yield "Audio output '%s' not writable" % get_config()["AUDIO_OUTPUT"]
|
yield "Audio output '%s' not writable" % get_config()["AUDIO_OUTPUT"]
|
||||||
|
@ -60,8 +74,10 @@ class DateTimeAction(Action):
|
||||||
raise ValueError("'%s' is not a valid datetime" % values)
|
raise ValueError("'%s' is not a valid datetime" % values)
|
||||||
setattr(namespace, self.dest, parsed_val)
|
setattr(namespace, self.dest, parsed_val)
|
||||||
|
|
||||||
|
|
||||||
code_dir = os.path.dirname(os.path.realpath(__file__))
|
code_dir = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
|
||||||
|
|
||||||
def common_pre():
|
def common_pre():
|
||||||
prechecks = [pre_check_user, pre_check_permissions, pre_check_ffmpeg]
|
prechecks = [pre_check_user, pre_check_permissions, pre_check_ffmpeg]
|
||||||
configs = ["default_config.py"]
|
configs = ["default_config.py"]
|
||||||
|
@ -71,7 +87,8 @@ def common_pre():
|
||||||
continue
|
continue
|
||||||
path = os.path.realpath(conf)
|
path = os.path.realpath(conf)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
logger.warn("Configuration file '%s' does not exist; skipping" % path)
|
logger.warn(
|
||||||
|
"Configuration file '%s' does not exist; skipping" % path)
|
||||||
continue
|
continue
|
||||||
configs.append(path)
|
configs.append(path)
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
|
|
|
@ -7,8 +7,15 @@ import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import (Column, DateTime, Boolean, Integer, String, create_engine,
|
from sqlalchemy import (
|
||||||
inspect)
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Boolean,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
create_engine,
|
||||||
|
inspect,
|
||||||
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
@ -29,6 +36,7 @@ class Rec(Base):
|
||||||
endtime = Column(DateTime, nullable=True)
|
endtime = Column(DateTime, nullable=True)
|
||||||
filename = Column(String, nullable=True)
|
filename = Column(String, nullable=True)
|
||||||
ready = Column(Boolean, default=False)
|
ready = Column(Boolean, default=False)
|
||||||
|
error = Column(String, nullable=True, default=None)
|
||||||
|
|
||||||
def __init__(self, name="", starttime=None, endtime=None, filename=None):
|
def __init__(self, name="", starttime=None, endtime=None, filename=None):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
@ -58,6 +66,14 @@ class Rec(Base):
|
||||||
contents += ",Filename: '%s'" % self.filename
|
contents += ",Filename: '%s'" % self.filename
|
||||||
return "<Rec(%s)>" % contents
|
return "<Rec(%s)>" % contents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self.error is not None:
|
||||||
|
return 'ERROR'
|
||||||
|
if self.ready:
|
||||||
|
return 'DONE'
|
||||||
|
return 'WIP'
|
||||||
|
|
||||||
|
|
||||||
class RecDB:
|
class RecDB:
|
||||||
def __init__(self, uri):
|
def __init__(self, uri):
|
||||||
|
@ -66,7 +82,8 @@ class RecDB:
|
||||||
self.log = logging.getLogger(name=self.__class__.__name__)
|
self.log = logging.getLogger(name=self.__class__.__name__)
|
||||||
|
|
||||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.FATAL)
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.FATAL)
|
||||||
logging.getLogger("sqlalchemy.engine.base.Engine").setLevel(logging.FATAL)
|
logging.getLogger(
|
||||||
|
"sqlalchemy.engine.base.Engine").setLevel(logging.FATAL)
|
||||||
logging.getLogger("sqlalchemy.dialects").setLevel(logging.FATAL)
|
logging.getLogger("sqlalchemy.dialects").setLevel(logging.FATAL)
|
||||||
logging.getLogger("sqlalchemy.pool").setLevel(logging.FATAL)
|
logging.getLogger("sqlalchemy.pool").setLevel(logging.FATAL)
|
||||||
logging.getLogger("sqlalchemy.orm").setLevel(logging.FATAL)
|
logging.getLogger("sqlalchemy.orm").setLevel(logging.FATAL)
|
||||||
|
@ -171,7 +188,7 @@ class RecDB:
|
||||||
return query.filter(Rec.filename == None)
|
return query.filter(Rec.filename == None)
|
||||||
|
|
||||||
def _query_saved(self, query=None):
|
def _query_saved(self, query=None):
|
||||||
"""Still not saved"""
|
"""saved, regardless of status"""
|
||||||
if query is None:
|
if query is None:
|
||||||
query = self.get_session().query(Rec)
|
query = self.get_session().query(Rec)
|
||||||
return query.filter(Rec.filename != None)
|
return query.filter(Rec.filename != None)
|
||||||
|
|
|
@ -4,20 +4,12 @@ import sys
|
||||||
|
|
||||||
HOST = "localhost"
|
HOST = "localhost"
|
||||||
PORT = "8000"
|
PORT = "8000"
|
||||||
# pastelog is just "paste", but customized to accept logging options
|
|
||||||
WSGI_SERVER = "pastelog"
|
|
||||||
# these are pastelog-specific options for logging engine
|
|
||||||
TRANSLOGGER_OPTS = {
|
|
||||||
"logger_name": "accesslog",
|
|
||||||
"set_logger_level": logging.WARNING,
|
|
||||||
"setup_console_handler": False,
|
|
||||||
}
|
|
||||||
WSGI_SERVER_OPTIONS = {}
|
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
DB_URI = "sqlite:///techrec.db"
|
DB_URI = "sqlite:///techrec.db"
|
||||||
AUDIO_OUTPUT = "output/"
|
AUDIO_OUTPUT = "output/"
|
||||||
AUDIO_INPUT = "rec/"
|
AUDIO_INPUT = "rec/"
|
||||||
|
AUDIO_INPUT_BASICAUTH = None # Could be a ("user", "pass") tuple instead
|
||||||
AUDIO_INPUT_FORMAT = "%Y-%m/%d/rec-%Y-%m-%d-%H-%M-%S.mp3"
|
AUDIO_INPUT_FORMAT = "%Y-%m/%d/rec-%Y-%m-%d-%H-%M-%S.mp3"
|
||||||
AUDIO_OUTPUT_FORMAT = "techrec-%(startdt)s-%(endtime)s-%(name)s.mp3"
|
AUDIO_OUTPUT_FORMAT = "techrec-%(startdt)s-%(endtime)s-%(name)s.mp3"
|
||||||
FORGE_TIMEOUT = 20
|
FORGE_TIMEOUT = 20
|
||||||
|
@ -35,7 +27,7 @@ TAG_LICENSE_URI = None
|
||||||
# defaults
|
# defaults
|
||||||
STATIC_FILES = "static/"
|
STATIC_FILES = "static/"
|
||||||
STATIC_PAGES = "pages/"
|
STATIC_PAGES = "pages/"
|
||||||
if getattr(sys, 'frozen', False): # pyinstaller
|
if getattr(sys, "frozen", False): # pyinstaller
|
||||||
STATIC_FILES = os.path.join(sys._MEIPASS, STATIC_FILES)
|
STATIC_FILES = os.path.join(sys._MEIPASS, STATIC_FILES)
|
||||||
STATIC_PAGES = os.path.join(sys._MEIPASS, STATIC_PAGES)
|
STATIC_PAGES = os.path.join(sys._MEIPASS, STATIC_PAGES)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
@ -7,29 +8,41 @@ from time import sleep
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from .config_manager import get_config
|
from .config_manager import get_config
|
||||||
|
from .http_retriever import download
|
||||||
|
|
||||||
logger = logging.getLogger("forge")
|
logger = logging.getLogger("forge")
|
||||||
Validator = Callable[[datetime, datetime, str], bool]
|
Validator = Callable[[datetime, datetime, str], bool]
|
||||||
|
|
||||||
def get_timefile_exact(time) -> str:
|
|
||||||
|
async def get_timefile_exact(time) -> str:
|
||||||
"""
|
"""
|
||||||
time is of type `datetime`; it is not "rounded" to match the real file;
|
time is of type `datetime`; it is not "rounded" to match the real file;
|
||||||
that work is done in get_timefile(time)
|
that work is done in get_timefile(time)
|
||||||
"""
|
"""
|
||||||
return os.path.join(
|
repo = get_config()["AUDIO_INPUT"]
|
||||||
get_config()["AUDIO_INPUT"], time.strftime(get_config()["AUDIO_INPUT_FORMAT"])
|
path = os.path.join(
|
||||||
|
repo, time.strftime(get_config()["AUDIO_INPUT_FORMAT"])
|
||||||
)
|
)
|
||||||
|
if path.startswith("http://") or path.startswith("https://"):
|
||||||
|
logger.info(f"downloading: {path}")
|
||||||
|
local = await download(
|
||||||
|
path,
|
||||||
|
basic_auth=get_config()['AUDIO_INPUT_BASICAUTH'],
|
||||||
|
)
|
||||||
|
return local
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def round_timefile(exact:datetime) -> datetime:
|
def round_timefile(exact: datetime) -> datetime:
|
||||||
"""
|
"""
|
||||||
This will round the datetime, so to match the file organization structure
|
This will round the datetime, so to match the file organization structure
|
||||||
"""
|
"""
|
||||||
return datetime(exact.year, exact.month, exact.day, exact.hour)
|
return datetime(exact.year, exact.month, exact.day, exact.hour)
|
||||||
|
|
||||||
|
|
||||||
def get_timefile(exact:datetime) -> str:
|
async def get_timefile(exact: datetime) -> str:
|
||||||
return get_timefile_exact(round_timefile(exact))
|
file = await get_timefile_exact(round_timefile(exact))
|
||||||
|
return file
|
||||||
|
|
||||||
|
|
||||||
def get_files_and_intervals(start, end, rounder=round_timefile):
|
def get_files_and_intervals(start, end, rounder=round_timefile):
|
||||||
|
@ -69,13 +82,16 @@ def mp3_join(named_intervals):
|
||||||
for (filename, start_cut, end_cut) in named_intervals:
|
for (filename, start_cut, end_cut) in named_intervals:
|
||||||
# this happens only one time, and only at the first iteration
|
# this happens only one time, and only at the first iteration
|
||||||
if start_cut:
|
if start_cut:
|
||||||
assert startskip is None
|
if startskip is not None:
|
||||||
|
raise Exception("error in first cut iteration")
|
||||||
startskip = start_cut
|
startskip = start_cut
|
||||||
# this happens only one time, and only at the first iteration
|
# this happens only one time, and only at the last iteration
|
||||||
if end_cut:
|
if end_cut:
|
||||||
assert endskip is None
|
if endskip is not None:
|
||||||
|
raise Exception("error in last iteration")
|
||||||
endskip = end_cut
|
endskip = end_cut
|
||||||
assert "|" not in filename
|
if "|" in filename:
|
||||||
|
raise Exception(f"'|' in {filename}")
|
||||||
files.append(filename)
|
files.append(filename)
|
||||||
|
|
||||||
cmdline = [ffmpeg, "-i", "concat:%s" % "|".join(files)]
|
cmdline = [ffmpeg, "-i", "concat:%s" % "|".join(files)]
|
||||||
|
@ -88,18 +104,33 @@ def mp3_join(named_intervals):
|
||||||
cmdline += ["-t", str(len(files) * 3600 - (startskip + endskip))]
|
cmdline += ["-t", str(len(files) * 3600 - (startskip + endskip))]
|
||||||
return cmdline
|
return cmdline
|
||||||
|
|
||||||
def create_mp3(start: datetime, end: datetime, outfile: str, options={}, validator: Optional[Validator] = None, **kwargs):
|
|
||||||
|
async def create_mp3(
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
outfile: str,
|
||||||
|
options={},
|
||||||
|
validator: Optional[Validator] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
if validator is None:
|
if validator is None:
|
||||||
validator = lambda s,e,f: True
|
|
||||||
intervals = [
|
def validator(s, e, f):
|
||||||
(get_timefile(begin), start_cut, end_cut)
|
return True
|
||||||
for begin, start_cut, end_cut in get_files_and_intervals(start, end)
|
|
||||||
]
|
intervals = []
|
||||||
|
for begin, start_cut, end_cut in get_files_and_intervals(start, end):
|
||||||
|
try:
|
||||||
|
filename = await get_timefile(begin)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError("Error while retrieving file: %s" % e) from e
|
||||||
|
intervals.append((filename, start_cut, end_cut))
|
||||||
if os.path.exists(outfile):
|
if os.path.exists(outfile):
|
||||||
raise OSError("file '%s' already exists" % outfile)
|
raise OSError("file '%s' already exists" % outfile)
|
||||||
for path, _s, _e in intervals:
|
for path, _s, _e in intervals:
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
raise OSError("file '%s' does not exist; recording system broken?" % path)
|
raise OSError(
|
||||||
|
"file '%s' does not exist; recording system broken?" % path)
|
||||||
|
|
||||||
# metadata date/time formatted according to
|
# metadata date/time formatted according to
|
||||||
# https://wiki.xiph.org/VorbisComment#Date_and_time
|
# https://wiki.xiph.org/VorbisComment#Date_and_time
|
||||||
|
@ -107,7 +138,8 @@ def create_mp3(start: datetime, end: datetime, outfile: str, options={}, validat
|
||||||
if outfile.endswith(".mp3"):
|
if outfile.endswith(".mp3"):
|
||||||
metadata["TRDC"] = start.replace(microsecond=0).isoformat()
|
metadata["TRDC"] = start.replace(microsecond=0).isoformat()
|
||||||
metadata["RECORDINGTIME"] = metadata["TRDC"]
|
metadata["RECORDINGTIME"] = metadata["TRDC"]
|
||||||
metadata["ENCODINGTIME"] = datetime.now().replace(microsecond=0).isoformat()
|
metadata["ENCODINGTIME"] = datetime.now().replace(
|
||||||
|
microsecond=0).isoformat()
|
||||||
else:
|
else:
|
||||||
metadata["DATE"] = start.replace(microsecond=0).isoformat()
|
metadata["DATE"] = start.replace(microsecond=0).isoformat()
|
||||||
metadata["ENCODER"] = "https://git.lattuga.net/techbloc/techrec"
|
metadata["ENCODER"] = "https://git.lattuga.net/techbloc/techrec"
|
||||||
|
@ -126,9 +158,21 @@ def create_mp3(start: datetime, end: datetime, outfile: str, options={}, validat
|
||||||
metadata_list.append("-metadata")
|
metadata_list.append("-metadata")
|
||||||
metadata_list.append("%s=%s" % (tag, value))
|
metadata_list.append("%s=%s" % (tag, value))
|
||||||
|
|
||||||
prefix, suffix = os.path.basename(outfile).split('.', 1)
|
prefix, suffix = os.path.basename(outfile).split(".", 1)
|
||||||
tmp_file = tempfile.NamedTemporaryFile(suffix='.%s' % suffix, prefix='forge-%s' % prefix, delete=False)
|
tmp_file = tempfile.NamedTemporaryFile(
|
||||||
cmd = mp3_join(intervals) + metadata_list + ['-y'] + get_config()["FFMPEG_OPTIONS"] + [tmp_file.name]
|
suffix=".%s" % suffix,
|
||||||
|
prefix="forge-%s" % prefix,
|
||||||
|
delete=False,
|
||||||
|
# This is needed to avoid errors with the rename across different mounts
|
||||||
|
dir=os.path.dirname(outfile),
|
||||||
|
)
|
||||||
|
cmd = (
|
||||||
|
mp3_join(intervals)
|
||||||
|
+ metadata_list
|
||||||
|
+ ["-y"]
|
||||||
|
+ get_config()["FFMPEG_OPTIONS"]
|
||||||
|
+ [tmp_file.name]
|
||||||
|
)
|
||||||
logger.info("Running %s", " ".join(cmd))
|
logger.info("Running %s", " ".join(cmd))
|
||||||
p = Popen(cmd)
|
p = Popen(cmd)
|
||||||
if get_config()["FORGE_TIMEOUT"] == 0:
|
if get_config()["FORGE_TIMEOUT"] == 0:
|
||||||
|
@ -145,7 +189,7 @@ def create_mp3(start: datetime, end: datetime, outfile: str, options={}, validat
|
||||||
os.kill(p.pid, 15)
|
os.kill(p.pid, 15)
|
||||||
try:
|
try:
|
||||||
os.remove(tmp_file.name)
|
os.remove(tmp_file.name)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise Exception("timeout") # TODO: make a specific TimeoutError
|
raise Exception("timeout") # TODO: make a specific TimeoutError
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
|
@ -161,4 +205,4 @@ def main_cmd(options):
|
||||||
log = logging.getLogger("forge_main")
|
log = logging.getLogger("forge_main")
|
||||||
outfile = os.path.abspath(os.path.join(options.cwd, options.outfile))
|
outfile = os.path.abspath(os.path.join(options.cwd, options.outfile))
|
||||||
log.debug("will forge an mp3 into %s" % (outfile))
|
log.debug("will forge an mp3 into %s" % (outfile))
|
||||||
create_mp3(options.starttime, options.endtime, outfile)
|
asyncio.run(create_mp3(options.starttime, options.endtime, outfile))
|
||||||
|
|
52
techrec/http_retriever.py
Normal file
52
techrec/http_retriever.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from tempfile import mkdtemp
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
import aiohttp # type: ignore
|
||||||
|
|
||||||
|
CHUNK_SIZE = 2 ** 12
|
||||||
|
|
||||||
|
log = getLogger("http")
|
||||||
|
|
||||||
|
|
||||||
|
async def download(
|
||||||
|
remote: str,
|
||||||
|
staging: Optional[str] = None,
|
||||||
|
basic_auth: Optional[Tuple[str, str]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
This will download to AUDIO_STAGING the remote file and return the local
|
||||||
|
path of the downloaded file
|
||||||
|
"""
|
||||||
|
_, filename = os.path.split(remote)
|
||||||
|
if staging:
|
||||||
|
base = staging
|
||||||
|
else:
|
||||||
|
# if no staging is specified, and you want to clean the storage
|
||||||
|
# used by techrec: rm -rf /tmp/techrec*
|
||||||
|
base = mkdtemp(prefix="techrec-", dir="/tmp")
|
||||||
|
local = os.path.join(base, filename)
|
||||||
|
|
||||||
|
session_args = {}
|
||||||
|
if basic_auth is not None:
|
||||||
|
session_args["auth"] = aiohttp.BasicAuth(
|
||||||
|
login=basic_auth[0], password=basic_auth[1], encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
log.debug("Downloading %s with %s options", remote, ",".join(session_args.keys()))
|
||||||
|
async with aiohttp.ClientSession(**session_args) as session:
|
||||||
|
async with session.get(remote) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise ValueError(
|
||||||
|
"Could not download %s: error %d" % (remote, resp.status)
|
||||||
|
)
|
||||||
|
with open(local, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await resp.content.read(CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
log.debug("Downloading %s complete", remote)
|
||||||
|
return local
|
|
@ -39,14 +39,16 @@ def rec_sanitize(rec):
|
||||||
d["endtime"] = date_write(d["endtime"])
|
d["endtime"] = date_write(d["endtime"])
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
global db
|
global db
|
||||||
common_pre()
|
common_pre()
|
||||||
if get_config()['DEBUG']:
|
if get_config()["DEBUG"]:
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
db = RecDB(get_config()["DB_URI"])
|
db = RecDB(get_config()["DB_URI"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/date/date")
|
@app.get("/date/date")
|
||||||
def date():
|
def date():
|
||||||
n = datetime.now()
|
n = datetime.now()
|
||||||
|
@ -76,11 +78,13 @@ def help():
|
||||||
+ "/custom?strftime=FORMAT : get now().strftime(FORMAT)"
|
+ "/custom?strftime=FORMAT : get now().strftime(FORMAT)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CreateInfo(BaseModel):
|
class CreateInfo(BaseModel):
|
||||||
starttime: Optional[str] = None
|
starttime: Optional[str] = None
|
||||||
endtime: Optional[str] = None
|
endtime: Optional[str] = None
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/create")
|
@app.post("/api/create")
|
||||||
async def create(req: CreateInfo = None):
|
async def create(req: CreateInfo = None):
|
||||||
ret = {}
|
ret = {}
|
||||||
|
@ -100,9 +104,11 @@ async def create(req: CreateInfo = None):
|
||||||
"Nuova registrazione creata! (id:%d)" % ret.id, rec=rec_sanitize(rec)
|
"Nuova registrazione creata! (id:%d)" % ret.id, rec=rec_sanitize(rec)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeleteInfo(BaseModel):
|
class DeleteInfo(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/delete")
|
@app.post("/api/delete")
|
||||||
def delete(req: DeleteInfo):
|
def delete(req: DeleteInfo):
|
||||||
if db.delete(req.id):
|
if db.delete(req.id):
|
||||||
|
@ -113,15 +119,18 @@ def delete(req: DeleteInfo):
|
||||||
|
|
||||||
def timefield_factory():
|
def timefield_factory():
|
||||||
return int(time.time())
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
TimeField = Field(default_factory=timefield_factory)
|
TimeField = Field(default_factory=timefield_factory)
|
||||||
|
|
||||||
|
|
||||||
class UpdateInfo(BaseModel):
|
class UpdateInfo(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
starttime: int =Field(default_factory=timefield_factory)
|
starttime: int = Field(default_factory=timefield_factory)
|
||||||
endtime: int =Field(default_factory=timefield_factory)
|
endtime: int = Field(default_factory=timefield_factory)
|
||||||
filename: Optional[str] = None
|
filename: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/update/{recid}")
|
@app.post("/api/update/{recid}")
|
||||||
async def update(recid: int, req: UpdateInfo):
|
async def update(recid: int, req: UpdateInfo):
|
||||||
newrec = {}
|
newrec = {}
|
||||||
|
@ -142,10 +151,12 @@ async def update(recid: int, req: UpdateInfo):
|
||||||
class GenerateInfo(BaseModel):
|
class GenerateInfo(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
class GenerateResponse(BaseModel):
|
class GenerateResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/generate/{recid}")
|
@app.post("/api/generate/{recid}")
|
||||||
async def generate(recid: int, response: Response, background_tasks: BackgroundTasks):
|
async def generate(recid: int, response: Response, background_tasks: BackgroundTasks):
|
||||||
# prendiamo la rec in causa
|
# prendiamo la rec in causa
|
||||||
|
@ -162,9 +173,9 @@ async def generate(recid: int, response: Response, background_tasks: BackgroundT
|
||||||
> get_config()["FORGE_MAX_DURATION"]
|
> get_config()["FORGE_MAX_DURATION"]
|
||||||
):
|
):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code = 400,
|
status_code=400,
|
||||||
status= "error",
|
status="error",
|
||||||
message= "The requested recording is too long"
|
message="The requested recording is too long"
|
||||||
+ " (%d seconds)" % (rec.endtime - rec.starttime).total_seconds(),
|
+ " (%d seconds)" % (rec.endtime - rec.starttime).total_seconds(),
|
||||||
)
|
)
|
||||||
rec.filename = get_config()["AUDIO_OUTPUT_FORMAT"] % {
|
rec.filename = get_config()["AUDIO_OUTPUT_FORMAT"] % {
|
||||||
|
@ -204,6 +215,7 @@ async def generate(recid: int, response: Response, background_tasks: BackgroundT
|
||||||
rec=rec_sanitize(rec),
|
rec=rec_sanitize(rec),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_duration(fname) -> float:
|
def get_duration(fname) -> float:
|
||||||
lineout = check_output(
|
lineout = check_output(
|
||||||
[
|
[
|
||||||
|
@ -214,7 +226,8 @@ def get_duration(fname) -> float:
|
||||||
"format=duration",
|
"format=duration",
|
||||||
"-i",
|
"-i",
|
||||||
fname,
|
fname,
|
||||||
]).split(b'\n')
|
]
|
||||||
|
).split(b"\n")
|
||||||
duration = next(l for l in lineout if l.startswith(b"duration="))
|
duration = next(l for l in lineout if l.startswith(b"duration="))
|
||||||
value = duration.split(b"=")[1]
|
value = duration.split(b"=")[1]
|
||||||
return float(value)
|
return float(value)
|
||||||
|
@ -225,23 +238,30 @@ def get_validator(expected_duration_s: float, error_threshold_s: float) -> Valid
|
||||||
try:
|
try:
|
||||||
duration = get_duration(fpath)
|
duration = get_duration(fpath)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception('Error determining duration of %s', fpath)
|
logger.exception("Error determining duration of %s", fpath)
|
||||||
return False
|
return False
|
||||||
logger.debug('expect %s to be %.1f±%.1fs, is %.1f', fpath, expected_duration_s, error_threshold_s, duration)
|
logger.debug(
|
||||||
|
"expect %s to be %.1f±%.1fs, is %.1f",
|
||||||
|
fpath,
|
||||||
|
expected_duration_s,
|
||||||
|
error_threshold_s,
|
||||||
|
duration,
|
||||||
|
)
|
||||||
if duration > expected_duration_s + error_threshold_s:
|
if duration > expected_duration_s + error_threshold_s:
|
||||||
return False
|
return False
|
||||||
if duration < expected_duration_s - error_threshold_s:
|
if duration < expected_duration_s - error_threshold_s:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return validator
|
return validator
|
||||||
|
|
||||||
|
|
||||||
def generate_mp3(db_id: int, **kwargs):
|
async def generate_mp3(db_id: int, **kwargs):
|
||||||
'''creates and mark it as ready in the db'''
|
"""creates and mark it as ready in the db"""
|
||||||
if get_config()['FORGE_VERIFY']:
|
if get_config()["FORGE_VERIFY"]:
|
||||||
validator = get_validator(
|
validator = get_validator(
|
||||||
(kwargs['end'] - kwargs['start']).total_seconds(),
|
(kwargs["end"] - kwargs["start"]).total_seconds(),
|
||||||
get_config()['FORGE_VERIFY_THRESHOLD']
|
get_config()["FORGE_VERIFY_THRESHOLD"],
|
||||||
)
|
)
|
||||||
retries = 10
|
retries = 10
|
||||||
else:
|
else:
|
||||||
|
@ -249,12 +269,19 @@ def generate_mp3(db_id: int, **kwargs):
|
||||||
retries = 1
|
retries = 1
|
||||||
|
|
||||||
for i in range(retries):
|
for i in range(retries):
|
||||||
result = create_mp3(validator=validator, **kwargs)
|
try:
|
||||||
logger.debug('Create mp3 for %d -> %s', db_id, result)
|
result = await create_mp3(validator=validator, **kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error creating audio for %d -> %s", db_id, str(exc))
|
||||||
|
rec = db._search(_id=db_id)[0]
|
||||||
|
rec.error = str(exc)
|
||||||
|
db.get_session(rec).commit()
|
||||||
|
return False
|
||||||
|
logger.debug("Create mp3 for %d -> %s", db_id, result)
|
||||||
if result:
|
if result:
|
||||||
break
|
break
|
||||||
elif i < retries - 1:
|
elif i < retries - 1:
|
||||||
logger.debug("waiting %d", i+1)
|
logger.debug("waiting %d", i + 1)
|
||||||
time.sleep(i + 1) # waiting time increases at each retry
|
time.sleep(i + 1) # waiting time increases at each retry
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not create mp3 for %d: validation failed", db_id)
|
logger.warning("Could not create mp3 for %d: validation failed", db_id)
|
||||||
|
@ -266,18 +293,13 @@ def generate_mp3(db_id: int, **kwargs):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ready/{recid}")
|
@app.get("/api/ready/{recid}")
|
||||||
def check_job(recid: int):
|
def check_job(recid: int):
|
||||||
rec = db._search(_id=recid)[0]
|
rec = db._search(_id=recid)[0]
|
||||||
|
|
||||||
def ret(status):
|
out = {"job_id": recid, "job_status": rec.status}
|
||||||
return {"job_status": status, "job_id": recid}
|
|
||||||
|
|
||||||
if rec.ready:
|
|
||||||
return ret("DONE")
|
|
||||||
return ret("WIP")
|
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/get/ongoing")
|
@app.get("/api/get/ongoing")
|
||||||
|
@ -341,9 +363,12 @@ def serve_pages(request: Request):
|
||||||
fpath = os.path.join(get_config()["STATIC_PAGES"], page)
|
fpath = os.path.join(get_config()["STATIC_PAGES"], page)
|
||||||
return FileResponse(fpath)
|
return FileResponse(fpath)
|
||||||
|
|
||||||
|
|
||||||
def main_cmd(options):
|
def main_cmd(options):
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host=get_config()['HOST'], port=int(get_config()['PORT']))
|
|
||||||
|
uvicorn.run(app, host=get_config()["HOST"], port=int(get_config()["PORT"]))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.warn("Usage of server.py is not supported anymore; use cli.py")
|
logger.warn("Usage of server.py is not supported anymore; use cli.py")
|
||||||
|
|
Loading…
Reference in a new issue