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.
Files changed (43) hide show
  1. bbblb/__init__.py +9 -0
  2. bbblb/__main__.py +3 -0
  3. bbblb/asgi.py +4 -0
  4. bbblb/cli/__init__.py +167 -0
  5. bbblb/cli/db.py +42 -0
  6. bbblb/cli/maketoken.py +86 -0
  7. bbblb/cli/override.py +143 -0
  8. bbblb/cli/recording.py +178 -0
  9. bbblb/cli/server.py +215 -0
  10. bbblb/cli/state.py +305 -0
  11. bbblb/cli/tenant.py +119 -0
  12. bbblb/lib/__init__.py +0 -0
  13. bbblb/lib/bbb.py +237 -0
  14. bbblb/migrations/__init__.py +0 -0
  15. bbblb/migrations/env.py +80 -0
  16. bbblb/migrations/script.py.mako +28 -0
  17. bbblb/migrations/versions/0650f8bdb0d2_initial_state.py +143 -0
  18. bbblb/migrations/versions/3e63cbead3b5_tenant_override_table.py +77 -0
  19. bbblb/migrations/versions/4aff87a582fa_tenant_overrides.py +32 -0
  20. bbblb/migrations/versions/80e8524b0d7c_server_stats.py +33 -0
  21. bbblb/migrations/versions/d5e8dccfd5e1_format_delete_cascade.py +47 -0
  22. bbblb/model.py +521 -0
  23. bbblb/services/__init__.py +287 -0
  24. bbblb/services/analytics.py +29 -0
  25. bbblb/services/bbb.py +122 -0
  26. bbblb/services/db.py +199 -0
  27. bbblb/services/health.py +42 -0
  28. bbblb/services/locks.py +122 -0
  29. bbblb/services/poller.py +229 -0
  30. bbblb/services/recording.py +665 -0
  31. bbblb/services/tenants.py +54 -0
  32. bbblb/settings.py +272 -0
  33. bbblb/utils.py +63 -0
  34. bbblb/web/__init__.py +141 -0
  35. bbblb/web/bbbapi.py +831 -0
  36. bbblb/web/bbblbapi.py +492 -0
  37. bbblb/web/static/favicon.ico +0 -0
  38. bbblb/web/static/robots.txt +3 -0
  39. bbblb-0.0.13.dist-info/METADATA +103 -0
  40. bbblb-0.0.13.dist-info/RECORD +43 -0
  41. bbblb-0.0.13.dist-info/WHEEL +4 -0
  42. bbblb-0.0.13.dist-info/entry_points.txt +2 -0
  43. bbblb-0.0.13.dist-info/licenses/LICENSE.md +660 -0
bbblb/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ import logging
2
+
3
+ __version__ = "0.0.13"
4
+ VERSION = __version__.split(".", 2)
5
+ VERSION[-1], _, BUILD = VERSION[-1].partition("-")
6
+
7
+ ROOT_LOGGER = logging.getLogger(__name__)
8
+
9
+ BRANDING = "BBBLB (AGPL-3, https://github.com/defnull/bbblb)"
bbblb/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from bbblb.cli import main
2
+
3
+ main()
bbblb/asgi.py ADDED
@@ -0,0 +1,4 @@
1
+ from bbblb.web import make_app
2
+
3
+ app = make_app()
4
+ __all__ = ["app"]
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()