Merge branch 'feat/28' into fastapi

This commit is contained in:
boyska 2021-09-22 13:27:40 +02:00
commit 52564571f5
18 changed files with 457 additions and 86 deletions

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ build/
dist/ dist/
rec/ rec/
*.egg-info/ *.egg-info/
/venv
/docker/output/*

32
Dockerfile Normal file
View 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
View 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
View 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:

View 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
View 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
View file

26
docker/run.liq Executable file
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
[flake8]
max-line-length=89
ignore=D

View file

@ -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,

View file

@ -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):

View file

@ -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)

View file

@ -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:

View file

@ -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
View 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

View file

@ -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")