bbblb 0.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bbblb/__init__.py +9 -0
- bbblb/__main__.py +3 -0
- bbblb/asgi.py +4 -0
- bbblb/cli/__init__.py +167 -0
- bbblb/cli/db.py +42 -0
- bbblb/cli/maketoken.py +86 -0
- bbblb/cli/override.py +143 -0
- bbblb/cli/recording.py +178 -0
- bbblb/cli/server.py +215 -0
- bbblb/cli/state.py +305 -0
- bbblb/cli/tenant.py +119 -0
- bbblb/lib/__init__.py +0 -0
- bbblb/lib/bbb.py +237 -0
- bbblb/migrations/__init__.py +0 -0
- bbblb/migrations/env.py +80 -0
- bbblb/migrations/script.py.mako +28 -0
- bbblb/migrations/versions/0650f8bdb0d2_initial_state.py +143 -0
- bbblb/migrations/versions/3e63cbead3b5_tenant_override_table.py +77 -0
- bbblb/migrations/versions/4aff87a582fa_tenant_overrides.py +32 -0
- bbblb/migrations/versions/80e8524b0d7c_server_stats.py +33 -0
- bbblb/migrations/versions/d5e8dccfd5e1_format_delete_cascade.py +47 -0
- bbblb/model.py +521 -0
- bbblb/services/__init__.py +287 -0
- bbblb/services/analytics.py +29 -0
- bbblb/services/bbb.py +122 -0
- bbblb/services/db.py +199 -0
- bbblb/services/health.py +42 -0
- bbblb/services/locks.py +122 -0
- bbblb/services/poller.py +229 -0
- bbblb/services/recording.py +665 -0
- bbblb/services/tenants.py +54 -0
- bbblb/settings.py +272 -0
- bbblb/utils.py +63 -0
- bbblb/web/__init__.py +141 -0
- bbblb/web/bbbapi.py +831 -0
- bbblb/web/bbblbapi.py +492 -0
- bbblb/web/static/favicon.ico +0 -0
- bbblb/web/static/robots.txt +3 -0
- bbblb-0.0.13.dist-info/METADATA +103 -0
- bbblb-0.0.13.dist-info/RECORD +43 -0
- bbblb-0.0.13.dist-info/WHEEL +4 -0
- bbblb-0.0.13.dist-info/entry_points.txt +2 -0
- bbblb-0.0.13.dist-info/licenses/LICENSE.md +660 -0
bbblb/__init__.py
ADDED
bbblb/__main__.py
ADDED
bbblb/asgi.py
ADDED
bbblb/cli/__init__.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import importlib
|
|
4
|
+
import pkgutil
|
|
5
|
+
import typing
|
|
6
|
+
from bbblb.services import bootstrap
|
|
7
|
+
from bbblb.settings import ConfigError, BBBLBConfig
|
|
8
|
+
import click
|
|
9
|
+
import os
|
|
10
|
+
import tabulate
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def async_command():
|
|
15
|
+
"""Decorator that wraps coroutine with asyncio.run and click.pass_obj."""
|
|
16
|
+
|
|
17
|
+
def decorator(func):
|
|
18
|
+
@functools.wraps(func)
|
|
19
|
+
@click.pass_obj
|
|
20
|
+
def sync_wrapper(*args, **kwargs):
|
|
21
|
+
return asyncio.run(func(*args, **kwargs))
|
|
22
|
+
|
|
23
|
+
return sync_wrapper
|
|
24
|
+
|
|
25
|
+
return decorator
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MultiChoice(click.ParamType):
|
|
29
|
+
name = "list"
|
|
30
|
+
|
|
31
|
+
def __init__(self, choices):
|
|
32
|
+
self.choices = tuple(choices)
|
|
33
|
+
|
|
34
|
+
def convert(self, value, param, ctx):
|
|
35
|
+
if not value:
|
|
36
|
+
return []
|
|
37
|
+
if isinstance(value, (list, tuple)):
|
|
38
|
+
return tuple(value)
|
|
39
|
+
values = [v.strip() for v in value.split(",")]
|
|
40
|
+
for v in values:
|
|
41
|
+
if v not in self.choices:
|
|
42
|
+
self.fail(
|
|
43
|
+
f"Invalid choice: '{v}'. Must be one of: {', '.join(self.choices)}",
|
|
44
|
+
param,
|
|
45
|
+
ctx,
|
|
46
|
+
)
|
|
47
|
+
return values
|
|
48
|
+
|
|
49
|
+
def to_info_dict(self):
|
|
50
|
+
info_dict = super().to_info_dict()
|
|
51
|
+
info_dict["choices"] = self.choices
|
|
52
|
+
return info_dict
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Table:
|
|
56
|
+
formats = ["simple", "plain", "raw", "json"]
|
|
57
|
+
option = click.option(
|
|
58
|
+
"--table-format",
|
|
59
|
+
type=click.Choice(formats),
|
|
60
|
+
default=formats[0],
|
|
61
|
+
help="Change the result table format.",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self._rows: list[list[typing.Any]] = []
|
|
66
|
+
self._headers = {}
|
|
67
|
+
|
|
68
|
+
def headers(self, **headers):
|
|
69
|
+
"""Map column names to human readable labels"""
|
|
70
|
+
self._headers.update(headers)
|
|
71
|
+
|
|
72
|
+
def row(self, **values):
|
|
73
|
+
"""Add a row to the table, pamming column names ot values.
|
|
74
|
+
|
|
75
|
+
Missing columns are stored as `None`. Previously unknown columns
|
|
76
|
+
are added to the table.
|
|
77
|
+
"""
|
|
78
|
+
for key in values:
|
|
79
|
+
if key not in self._headers:
|
|
80
|
+
self._headers[key] = key.title()
|
|
81
|
+
for row in self._rows:
|
|
82
|
+
row.append(None)
|
|
83
|
+
self._rows.append([values.get(column, None) for column in self._headers])
|
|
84
|
+
|
|
85
|
+
def print(self, format="simple"):
|
|
86
|
+
if format == "json":
|
|
87
|
+
keys = list(self._headers)
|
|
88
|
+
for row in self._rows:
|
|
89
|
+
click.echo(json.dumps(dict(zip(keys, row))))
|
|
90
|
+
elif format == "raw":
|
|
91
|
+
for row in self._rows:
|
|
92
|
+
click.echo("\t".join(map(str, row)))
|
|
93
|
+
else:
|
|
94
|
+
click.echo(
|
|
95
|
+
tabulate.tabulate(
|
|
96
|
+
self._rows,
|
|
97
|
+
list(self._headers.values()),
|
|
98
|
+
tablefmt=format,
|
|
99
|
+
floatfmt=".2f",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@click.group(
|
|
105
|
+
name="bbblb",
|
|
106
|
+
context_settings=dict(show_default=True, help_option_names=["--help", "-h"]),
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--config-file",
|
|
110
|
+
"-C",
|
|
111
|
+
metavar="FILE",
|
|
112
|
+
envvar="BBBLB_CONFIG",
|
|
113
|
+
help="Load config from file",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--config",
|
|
117
|
+
"-c",
|
|
118
|
+
metavar="KEY=VALUE",
|
|
119
|
+
help="Set or unset a BBBLB config parameter",
|
|
120
|
+
multiple=True,
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"-v", "--verbose", help="Increase verbosity. Can be repeated.", count=True
|
|
124
|
+
)
|
|
125
|
+
@async_command()
|
|
126
|
+
@click.pass_context
|
|
127
|
+
async def main(ctx, obj, config_file, config, verbose):
|
|
128
|
+
import logging
|
|
129
|
+
|
|
130
|
+
logging.basicConfig(
|
|
131
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s", level=logging.WARNING
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if verbose == 0:
|
|
135
|
+
logging.getLogger("bbblb").setLevel(logging.WARNING)
|
|
136
|
+
elif verbose == 1:
|
|
137
|
+
logging.getLogger("bbblb").setLevel(logging.INFO)
|
|
138
|
+
elif verbose == 2:
|
|
139
|
+
logging.getLogger("bbblb").setLevel(logging.DEBUG)
|
|
140
|
+
elif verbose == 3:
|
|
141
|
+
logging.getLogger("bbblb").setLevel(logging.DEBUG)
|
|
142
|
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.DEBUG)
|
|
143
|
+
elif verbose >= 4:
|
|
144
|
+
logging.root.setLevel(logging.DEBUG)
|
|
145
|
+
|
|
146
|
+
config_ = BBBLBConfig()
|
|
147
|
+
|
|
148
|
+
if config_file:
|
|
149
|
+
os.environ["BBBLB_CONFIG"] = config_file
|
|
150
|
+
for kv in config:
|
|
151
|
+
name, _, value = kv.partition("=")
|
|
152
|
+
name = name.upper()
|
|
153
|
+
if name not in config_._options:
|
|
154
|
+
raise ConfigError(f"Unknown config parameter: {name}")
|
|
155
|
+
env_name = f"BBBLB_{name}"
|
|
156
|
+
if value:
|
|
157
|
+
os.environ[env_name] = value
|
|
158
|
+
elif env_name in os.environ:
|
|
159
|
+
del os.environ[env_name]
|
|
160
|
+
|
|
161
|
+
config_.populate()
|
|
162
|
+
ctx.obj = await bootstrap(config_, autostart=False, logging=False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Auto-load all modules in the bbblb.cli package to load all commands.
|
|
166
|
+
for module in pkgutil.iter_modules(__path__):
|
|
167
|
+
importlib.import_module(f"{__package__}.{module.name}")
|
bbblb/cli/db.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from bbblb.services import ServiceRegistry
|
|
3
|
+
from bbblb.services.db import check_migration_state, create_database, migrate_db
|
|
4
|
+
from bbblb.settings import BBBLBConfig
|
|
5
|
+
|
|
6
|
+
from bbblb.cli import async_command, main
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@main.group()
|
|
10
|
+
def db():
|
|
11
|
+
"""Manage database"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@db.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
"--create", help="Create database if needed (only postgres).", is_flag=True
|
|
17
|
+
)
|
|
18
|
+
@async_command()
|
|
19
|
+
async def migrate(obj: ServiceRegistry, create: bool):
|
|
20
|
+
"""
|
|
21
|
+
Migrate database to the current schema version.
|
|
22
|
+
|
|
23
|
+
WARNING: Make backups!
|
|
24
|
+
"""
|
|
25
|
+
config = await obj.use(BBBLBConfig)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
if create:
|
|
29
|
+
await create_database(config.DB)
|
|
30
|
+
current, target = await check_migration_state(config.DB)
|
|
31
|
+
if current != target:
|
|
32
|
+
click.echo(
|
|
33
|
+
f"Migrating database schema from {current or 'empty'!r} to {target!r}..."
|
|
34
|
+
)
|
|
35
|
+
await migrate_db(config.DB)
|
|
36
|
+
click.echo("Migration complete!")
|
|
37
|
+
else:
|
|
38
|
+
click.echo("Database is up to date. Nothing to do")
|
|
39
|
+
except ConnectionRefusedError as e:
|
|
40
|
+
raise RuntimeError(f"Failed to connect to database: {e}")
|
|
41
|
+
except BaseException as e:
|
|
42
|
+
raise RuntimeError(f"Failed to migrate database: {e}")
|
bbblb/cli/maketoken.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from bbblb import model
|
|
2
|
+
from bbblb.services import ServiceRegistry
|
|
3
|
+
import secrets
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import click
|
|
7
|
+
import jwt
|
|
8
|
+
|
|
9
|
+
from bbblb.services.db import DBContext
|
|
10
|
+
from bbblb.settings import BBBLBConfig
|
|
11
|
+
|
|
12
|
+
from . import main, async_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@main.command()
|
|
16
|
+
@click.option("--tenant", "-t", help="Create a Tenant-Token instead of an Admin-Token.")
|
|
17
|
+
@click.option("--server", "-s", help="Create a Server-Token instead of an Admin-Token.")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--expire",
|
|
20
|
+
"-e",
|
|
21
|
+
metavar="SECONDS",
|
|
22
|
+
default=-1,
|
|
23
|
+
help="Number of seconds after which this token should expire.",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--verbose", "-v", help="Print the clear-text token to stdout.", is_flag=True
|
|
27
|
+
)
|
|
28
|
+
@click.argument("subject")
|
|
29
|
+
@click.argument("scope", nargs=-1)
|
|
30
|
+
@async_command()
|
|
31
|
+
async def maketoken(
|
|
32
|
+
obj: ServiceRegistry, subject, expire, server, tenant, scope, verbose
|
|
33
|
+
):
|
|
34
|
+
"""Generate an Admin Token that can be used to authenticate against the BBBLB API.
|
|
35
|
+
|
|
36
|
+
The SUBJECT should be a short name or id that identifies the token
|
|
37
|
+
or token owner. It will be logged when the token is used.
|
|
38
|
+
|
|
39
|
+
SCOPEs limit the capabilities and permissions for this token. If no scope
|
|
40
|
+
is defined, the token will have `admin` privileges.
|
|
41
|
+
|
|
42
|
+
Tenant or Server tokens do not have scopes, their permissions are hard
|
|
43
|
+
coded because tenants or servers can create their own tokens.
|
|
44
|
+
"""
|
|
45
|
+
config = await obj.use(BBBLBConfig)
|
|
46
|
+
db = await obj.use(DBContext)
|
|
47
|
+
|
|
48
|
+
headers = {}
|
|
49
|
+
payload = {
|
|
50
|
+
"sub": subject,
|
|
51
|
+
"aud": config.DOMAIN,
|
|
52
|
+
"scope": " ".join(sorted(set(scope))) or "admin",
|
|
53
|
+
"jti": secrets.token_hex(8),
|
|
54
|
+
}
|
|
55
|
+
if expire > 0:
|
|
56
|
+
payload["exp"] = int(time.time() + int(expire))
|
|
57
|
+
|
|
58
|
+
if server:
|
|
59
|
+
async with db.session() as session:
|
|
60
|
+
stmt = model.Server.select(domain=server)
|
|
61
|
+
try:
|
|
62
|
+
server = (await session.execute(stmt)).scalar_one()
|
|
63
|
+
except model.NoResultFound:
|
|
64
|
+
raise RuntimeError(f"Server not found in database: {server}")
|
|
65
|
+
headers["kid"] = f"bbb:{server.domain}"
|
|
66
|
+
del payload["scope"]
|
|
67
|
+
key = server.secret
|
|
68
|
+
elif tenant:
|
|
69
|
+
async with db.session() as session:
|
|
70
|
+
stmt = model.Tenant.select(name=tenant)
|
|
71
|
+
try:
|
|
72
|
+
tenant = (await session.execute(stmt)).scalar_one()
|
|
73
|
+
except model.NoResultFound:
|
|
74
|
+
raise RuntimeError(f"Tenant not found in database: {tenant}")
|
|
75
|
+
headers["kid"] = f"tenant:{tenant.name}"
|
|
76
|
+
del payload["scope"]
|
|
77
|
+
key = tenant.secret
|
|
78
|
+
else:
|
|
79
|
+
key = config.SECRET
|
|
80
|
+
|
|
81
|
+
token = jwt.encode(payload, key, headers=headers)
|
|
82
|
+
|
|
83
|
+
if verbose:
|
|
84
|
+
click.echo(f"Token Header: {headers}", file=sys.stderr)
|
|
85
|
+
click.echo(f"Token Payload: {payload}", file=sys.stderr)
|
|
86
|
+
click.echo(token)
|
bbblb/cli/override.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from bbblb import model
|
|
3
|
+
from bbblb.services import ServiceRegistry
|
|
4
|
+
from bbblb.services.db import DBContext
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from . import MultiChoice, main, async_command
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@main.group()
|
|
12
|
+
def override():
|
|
13
|
+
"""Manage tenant overrides"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
type_choices = MultiChoice(["create", "join"])
|
|
17
|
+
type_choice = click.Choice(type_choices.choices)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@override.command("list")
|
|
21
|
+
@click.argument("tenant", required=False)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--type",
|
|
24
|
+
help="List specific override types only",
|
|
25
|
+
type=type_choices,
|
|
26
|
+
default=",".join(type_choices.choices),
|
|
27
|
+
)
|
|
28
|
+
@async_command()
|
|
29
|
+
async def override_list(obj: ServiceRegistry, tenant: str, type: list[str]):
|
|
30
|
+
"""List create or join overrides by tenant."""
|
|
31
|
+
db = await obj.use(DBContext)
|
|
32
|
+
|
|
33
|
+
async with db.session() as session:
|
|
34
|
+
if tenant:
|
|
35
|
+
stmt = model.TenantOverride.select(
|
|
36
|
+
model.TenantOverride.tenant.name == tenant
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
stmt = model.TenantOverride.select()
|
|
40
|
+
if type:
|
|
41
|
+
stmt = stmt.where(model.TenantOverride.type.in_(type))
|
|
42
|
+
stmt = stmt.options(model.joinedload(model.TenantOverride.tenant))
|
|
43
|
+
|
|
44
|
+
overrides = (await session.execute(stmt)).scalars().all()
|
|
45
|
+
for ovr in overrides:
|
|
46
|
+
click.echo(f"{ovr.tenant.name} {ovr.type} {ovr.param}{ovr.op}{ovr.value}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@override.command("set")
|
|
50
|
+
@click.option(
|
|
51
|
+
"--clear",
|
|
52
|
+
help="Remove all overrides for that tenant and type before adding new ones.",
|
|
53
|
+
is_flag=True,
|
|
54
|
+
)
|
|
55
|
+
@click.argument("tenant")
|
|
56
|
+
@click.argument("type", type=type_choice)
|
|
57
|
+
@click.argument("overrides", nargs=-1, metavar="NAME=VALUE")
|
|
58
|
+
@async_command()
|
|
59
|
+
async def override_set(
|
|
60
|
+
obj: ServiceRegistry, clear: bool, tenant: str, type: str, overrides: list[str]
|
|
61
|
+
):
|
|
62
|
+
"""Override create or join call parameters for a given tenant.
|
|
63
|
+
|
|
64
|
+
You can define any number of overrides per tenant as PARAM=VALUE
|
|
65
|
+
pairs. PARAM should match a BBB API parameter supported by the given
|
|
66
|
+
type (create or join) and the given VALUE will be enforced on all
|
|
67
|
+
future API calls issued by this tenant. If VALUE is empty, then the
|
|
68
|
+
parameter will be removed from API calls.
|
|
69
|
+
|
|
70
|
+
Instead of the '=' operator you can also use '?' to define a
|
|
71
|
+
fallback for missing parameters instead of an override, '<' to
|
|
72
|
+
define a maximum value for numeric parameters (e.g. duration
|
|
73
|
+
or maxParticipants), or '+' to add items to a comma separated list
|
|
74
|
+
parameter (e.g. disabledFeatures).
|
|
75
|
+
"""
|
|
76
|
+
db = await obj.use(DBContext)
|
|
77
|
+
async with db.session() as session:
|
|
78
|
+
db_tenant = (
|
|
79
|
+
await session.execute(
|
|
80
|
+
model.Tenant.select(name=tenant).options(
|
|
81
|
+
model.selectinload(model.Tenant.overrides)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
).scalar_one_or_none()
|
|
85
|
+
if not db_tenant:
|
|
86
|
+
click.echo(f"Tenant {tenant!r} not found")
|
|
87
|
+
raise SystemExit(1)
|
|
88
|
+
|
|
89
|
+
if clear:
|
|
90
|
+
db_tenant.overrides.clear()
|
|
91
|
+
elif not overrides:
|
|
92
|
+
click.echo("Set at least one override, see --help")
|
|
93
|
+
raise SystemExit(1)
|
|
94
|
+
|
|
95
|
+
for override in overrides:
|
|
96
|
+
split = re.split(r"([?=<+])", override, maxsplit=1)
|
|
97
|
+
param, op, value = split if len(split) == 3 else (override, "=", "")
|
|
98
|
+
if op not in model.OPERATOR__ALL:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Operator must be one of: {', '.join(model.OPERATOR__ALL)}"
|
|
101
|
+
)
|
|
102
|
+
to_update = next(
|
|
103
|
+
(o for o in db_tenant.overrides if o.type == type and o.param == param),
|
|
104
|
+
None,
|
|
105
|
+
)
|
|
106
|
+
if not to_update:
|
|
107
|
+
to_update = model.TenantOverride(type=type, param=param)
|
|
108
|
+
db_tenant.overrides.append(to_update)
|
|
109
|
+
to_update.op = op
|
|
110
|
+
to_update.value = value
|
|
111
|
+
|
|
112
|
+
await session.commit()
|
|
113
|
+
click.echo("OK")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@override.command("unset")
|
|
117
|
+
@click.argument("tenant")
|
|
118
|
+
@click.argument("type", type=type_choice)
|
|
119
|
+
@click.argument("overrides", nargs=-1, metavar="NAME")
|
|
120
|
+
@async_command()
|
|
121
|
+
async def override_unset(
|
|
122
|
+
obj: ServiceRegistry, tenant: str, type: str, overrides: list[str]
|
|
123
|
+
):
|
|
124
|
+
"""Remove specific overrides on a tenant."""
|
|
125
|
+
db = await obj.use(DBContext)
|
|
126
|
+
async with db.session() as session:
|
|
127
|
+
db_tenant = (
|
|
128
|
+
await session.execute(
|
|
129
|
+
model.Tenant.select(name=tenant).options(
|
|
130
|
+
model.selectinload(model.Tenant.overrides)
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
).scalar_one_or_none()
|
|
134
|
+
if not db_tenant:
|
|
135
|
+
click.echo(f"Tenant {tenant!r} not found")
|
|
136
|
+
raise SystemExit(1)
|
|
137
|
+
|
|
138
|
+
for override in list(db_tenant.overrides):
|
|
139
|
+
if override.type == type and override.param in overrides:
|
|
140
|
+
db_tenant.overrides.remove(override)
|
|
141
|
+
click.echo(f"Removed {override.param}")
|
|
142
|
+
|
|
143
|
+
await session.commit()
|
bbblb/cli/recording.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import click
|
|
3
|
+
import sqlalchemy.orm
|
|
4
|
+
|
|
5
|
+
from bbblb import model
|
|
6
|
+
|
|
7
|
+
from bbblb.services import ServiceRegistry
|
|
8
|
+
from bbblb.services.db import DBContext
|
|
9
|
+
from bbblb.services.recording import RecordingManager
|
|
10
|
+
|
|
11
|
+
from . import main, async_command
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.group()
|
|
15
|
+
@async_command()
|
|
16
|
+
async def recording(obj: ServiceRegistry):
|
|
17
|
+
"""Recording management."""
|
|
18
|
+
# Disable auto-import for use in a cli context
|
|
19
|
+
obj.get(RecordingManager, uninitialized_ok=True).auto_import = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@recording.command("list")
|
|
23
|
+
@async_command()
|
|
24
|
+
async def _list(obj: ServiceRegistry):
|
|
25
|
+
"""List all recordings and their formats"""
|
|
26
|
+
db = await obj.use(DBContext)
|
|
27
|
+
async with db.session() as session, session.begin():
|
|
28
|
+
stmt = model.Recording.select().options(
|
|
29
|
+
sqlalchemy.orm.joinedload(model.Recording.tenant),
|
|
30
|
+
sqlalchemy.orm.selectinload(model.Recording.formats),
|
|
31
|
+
)
|
|
32
|
+
for record in (await session.execute(stmt)).scalars():
|
|
33
|
+
click.echo(
|
|
34
|
+
f"{record.tenant.name} {record.record_id} {','.join(f.format for f in record.formats)}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@recording.command("delete")
|
|
39
|
+
@click.argument("record_id", nargs=-1)
|
|
40
|
+
@async_command()
|
|
41
|
+
async def _delete(obj: ServiceRegistry, record_id):
|
|
42
|
+
"""Delete recordings (all formats)"""
|
|
43
|
+
importer = await obj.use(RecordingManager)
|
|
44
|
+
|
|
45
|
+
db = await obj.use(DBContext)
|
|
46
|
+
async with db.session() as session, session.begin():
|
|
47
|
+
stmt = model.Recording.select(model.Recording.record_id.in_(record_id))
|
|
48
|
+
for record in (await session.execute(stmt)).scalars().all():
|
|
49
|
+
await session.delete(record)
|
|
50
|
+
importer.delete(record.tenant.name, record.record_id)
|
|
51
|
+
click.echo(f"Deleted {record.record_id}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@recording.command()
|
|
55
|
+
@click.argument("record_id", nargs=-1)
|
|
56
|
+
@async_command()
|
|
57
|
+
async def publish(obj: ServiceRegistry, record_id):
|
|
58
|
+
"""Publish recordings"""
|
|
59
|
+
await _change_publish_flag(obj, record_id, model.RecordingState.PUBLISHED)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@recording.command()
|
|
63
|
+
@click.argument("record_id", nargs=-1)
|
|
64
|
+
@async_command()
|
|
65
|
+
async def unpublish(obj: ServiceRegistry, record_id):
|
|
66
|
+
"""Unpublish recordings"""
|
|
67
|
+
await _change_publish_flag(obj, record_id, model.RecordingState.UNPUBLISHED)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _change_publish_flag(
|
|
71
|
+
obj: ServiceRegistry, record_id, state: model.RecordingState
|
|
72
|
+
):
|
|
73
|
+
importer = await obj.use(RecordingManager)
|
|
74
|
+
db = await obj.use(DBContext)
|
|
75
|
+
|
|
76
|
+
async with db.session() as session:
|
|
77
|
+
stmt = model.Recording.select(model.Recording.record_id.in_(record_id)).options(
|
|
78
|
+
sqlalchemy.orm.joinedload(model.Recording.tenant)
|
|
79
|
+
)
|
|
80
|
+
records = (await session.execute(stmt)).scalars().all()
|
|
81
|
+
for record in records:
|
|
82
|
+
if record.state != state:
|
|
83
|
+
record.state = state
|
|
84
|
+
await session.commit()
|
|
85
|
+
if state == model.RecordingState.PUBLISHED:
|
|
86
|
+
await asyncio.to_thread(
|
|
87
|
+
importer.publish, record.tenant.name, record.record_id
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
await asyncio.to_thread(
|
|
91
|
+
importer.unpublish, record.tenant.name, record.record_id
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@recording.command("import")
|
|
96
|
+
@click.option("--tenant", help="Override the tenant found in the recording")
|
|
97
|
+
@click.option(
|
|
98
|
+
"--publish/--unpublish",
|
|
99
|
+
help="Publish or unpublish recording after import",
|
|
100
|
+
default=None,
|
|
101
|
+
)
|
|
102
|
+
@click.argument("FILE", type=click.Path(dir_okay=True), default="-")
|
|
103
|
+
@async_command()
|
|
104
|
+
async def _import(obj: ServiceRegistry, tenant: str, publish: bool | None, file: str):
|
|
105
|
+
"""Import one or more recordings from a tar archive"""
|
|
106
|
+
importer = await obj.use(RecordingManager)
|
|
107
|
+
|
|
108
|
+
async def reader(file):
|
|
109
|
+
with click.open_file(file, "rb") as fp:
|
|
110
|
+
while chunk := fp.read(1024 * 64):
|
|
111
|
+
yield chunk
|
|
112
|
+
|
|
113
|
+
task = await importer.start_import(reader(file), force_tenant=tenant)
|
|
114
|
+
await task.wait()
|
|
115
|
+
|
|
116
|
+
for format in task.formats:
|
|
117
|
+
click.echo(
|
|
118
|
+
f"Imported: {format.recording.tenant.name}/{format.recording.record_id} ({format.format})"
|
|
119
|
+
)
|
|
120
|
+
if (
|
|
121
|
+
publish is True
|
|
122
|
+
and format.recording.started != model.RecordingState.PUBLISHED
|
|
123
|
+
):
|
|
124
|
+
await _change_publish_flag(
|
|
125
|
+
obj, [format.recording.record_id], model.RecordingState.PUBLISHED
|
|
126
|
+
)
|
|
127
|
+
elif (
|
|
128
|
+
publish is False
|
|
129
|
+
and format.recording.started != model.RecordingState.UNPUBLISHED
|
|
130
|
+
):
|
|
131
|
+
await _change_publish_flag(
|
|
132
|
+
obj, [format.recording.record_id], model.RecordingState.UNPUBLISHED
|
|
133
|
+
)
|
|
134
|
+
for error in task.errors:
|
|
135
|
+
click.echo(f"ERROR: {error}")
|
|
136
|
+
if task.errors:
|
|
137
|
+
raise SystemExit(1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@recording.command()
|
|
141
|
+
@click.option(
|
|
142
|
+
"--dry-run", "-n", help="Do not actually remove any recordings.", is_flag=True
|
|
143
|
+
)
|
|
144
|
+
@async_command()
|
|
145
|
+
async def remove_orphans(obj: ServiceRegistry, dry_run: bool):
|
|
146
|
+
"""Remove recording DB entries that do not exist on disk."""
|
|
147
|
+
db = await obj.use(DBContext)
|
|
148
|
+
importer = await obj.use(RecordingManager)
|
|
149
|
+
async with db.session() as session, session.begin():
|
|
150
|
+
stmt = model.Recording.select().options(
|
|
151
|
+
sqlalchemy.orm.joinedload(model.Recording.tenant),
|
|
152
|
+
sqlalchemy.orm.selectinload(model.Recording.formats),
|
|
153
|
+
)
|
|
154
|
+
records = await session.execute(stmt)
|
|
155
|
+
for record in records.scalars():
|
|
156
|
+
populated = False
|
|
157
|
+
for format in record.formats:
|
|
158
|
+
sdir = importer.get_storage_dir(
|
|
159
|
+
record.tenant.name,
|
|
160
|
+
record.record_id,
|
|
161
|
+
format.format,
|
|
162
|
+
)
|
|
163
|
+
if sdir.exists():
|
|
164
|
+
populated = True
|
|
165
|
+
continue
|
|
166
|
+
click.echo(
|
|
167
|
+
f"Deleting orphan format: {record.tenant.name}/{record.record_id}/{format.format}"
|
|
168
|
+
)
|
|
169
|
+
await session.delete(format)
|
|
170
|
+
if not populated:
|
|
171
|
+
click.echo(
|
|
172
|
+
f"Deleting record without formats: {record.tenant.name}/{record.record_id}"
|
|
173
|
+
)
|
|
174
|
+
await session.delete(record)
|
|
175
|
+
|
|
176
|
+
if dry_run:
|
|
177
|
+
click.echo("Rolling back changes (dry run)")
|
|
178
|
+
await session.rollback()
|