|
1 | 1 | # Copyright (C) 2025, 2026 Marcel Hellkamp |
2 | 2 | # SPDX-License-Identifier: AGPL-3.0-or-later |
3 | 3 |
|
| 4 | + |
| 5 | +import sys |
| 6 | + |
4 | 7 | import click |
5 | 8 | import sqlalchemy.orm |
6 | 9 |
|
7 | 10 | from bbblb import model |
8 | 11 |
|
9 | 12 | from bbblb.services import ServiceRegistry |
10 | 13 | from bbblb.services.db import DBContext |
11 | | -from bbblb.services.recording import RecordingManager |
| 14 | +from bbblb.services.recording import RecordingManager, ConsistencyChecker, FixCategory |
12 | 15 |
|
13 | 16 | from . import main, async_command |
14 | 17 |
|
@@ -137,40 +140,75 @@ async def reader(file): |
137 | 140 |
|
138 | 141 | @recording.command() |
139 | 142 | @click.option( |
140 | | - "--dry-run", "-n", help="Do not actually remove any recordings.", is_flag=True |
| 143 | + "--prefix", |
| 144 | + help="Only scan recording with IDs starting with this prefix.", |
| 145 | + default="", |
| 146 | +) |
| 147 | +@click.option( |
| 148 | + "--fix-orphans", |
| 149 | + help="Remove recordings or formats that do not exist on disk.", |
| 150 | + is_flag=True, |
| 151 | +) |
| 152 | +@click.option( |
| 153 | + "--fix-missing", |
| 154 | + help="Import missing recordings or formats found on disk.", |
| 155 | + is_flag=True, |
| 156 | +) |
| 157 | +@click.option( |
| 158 | + "--fix-state", |
| 159 | + help="Fix the published/unpublished state of recordings to match the on-disk state.", |
| 160 | + is_flag=True, |
| 161 | +) |
| 162 | +@click.option( |
| 163 | + "--fix-tenant", |
| 164 | + help="Fix the recording owner to match their on-disk storage path, which contains the tenant name.", |
| 165 | + is_flag=True, |
| 166 | +) |
| 167 | +@click.option( |
| 168 | + "--fix-metadata", |
| 169 | + help="(NOT IMPLEMENTED) Fix the recording metadata from the most recend on-disk backup.", |
| 170 | + is_flag=True, |
| 171 | +) |
| 172 | +@click.option( |
| 173 | + "--fix-all", help="Fix everything that can be fixed automatically.", is_flag=True |
141 | 174 | ) |
142 | 175 | @async_command() |
143 | | -async def remove_orphans(obj: ServiceRegistry, dry_run: bool): |
144 | | - """Remove recording DB entries that do not exist on disk.""" |
145 | | - db = await obj.use(DBContext) |
| 176 | +async def check_database( |
| 177 | + obj: ServiceRegistry, |
| 178 | + prefix: str, |
| 179 | + fix_orphans: bool, |
| 180 | + fix_missing: bool, |
| 181 | + fix_state: bool, |
| 182 | + fix_tenant: bool, |
| 183 | + fix_metadata: bool, |
| 184 | + fix_all: bool, |
| 185 | +): |
| 186 | + """(experimental) Report and optionally fix issues with the recording database. |
| 187 | +
|
| 188 | + This command scans the actual recording data found on disk and |
| 189 | + checks for missing or inconsistent database entries. It can be used |
| 190 | + to repair or rebuild the recordings database after a crash or when |
| 191 | + your database backup is missing a few recordings. |
| 192 | +
|
| 193 | + Warning, this command may run for a while and consume a lot of memory |
| 194 | + if you have many recordings. It is also NOT safe to run this command |
| 195 | + while BBBLB running and processing new recordings. Stop all BBBLB |
| 196 | + API and worker processes before running this command with enabled |
| 197 | + fixes. Make backups first. |
| 198 | +
|
| 199 | + The command |
| 200 | + """ |
| 201 | + autofix: set[FixCategory] = set() |
| 202 | + if fix_orphans or fix_all: |
| 203 | + autofix.add(FixCategory.ORPHAN) |
| 204 | + if fix_missing or fix_all: |
| 205 | + autofix.add(FixCategory.MISSING) |
| 206 | + if fix_state or fix_all: |
| 207 | + autofix.add(FixCategory.PUB_STATE) |
| 208 | + if fix_tenant or fix_all: |
| 209 | + autofix.add(FixCategory.TENANT) |
| 210 | + |
146 | 211 | importer = await obj.use(RecordingManager) |
147 | | - async with db.session() as session, session.begin(): |
148 | | - stmt = model.Recording.select().options( |
149 | | - sqlalchemy.orm.joinedload(model.Recording.tenant), |
150 | | - sqlalchemy.orm.selectinload(model.Recording.formats), |
151 | | - ) |
152 | | - records = await session.execute(stmt) |
153 | | - for record in records.scalars(): |
154 | | - populated = False |
155 | | - for format in record.formats: |
156 | | - sdir = importer.get_storage_dir( |
157 | | - record.tenant.name, |
158 | | - record.record_id, |
159 | | - format.format, |
160 | | - ) |
161 | | - if sdir.exists(): |
162 | | - populated = True |
163 | | - continue |
164 | | - click.echo( |
165 | | - f"Deleting orphan format: {record.tenant.name}/{record.record_id}/{format.format}" |
166 | | - ) |
167 | | - await session.delete(format) |
168 | | - if not populated: |
169 | | - click.echo( |
170 | | - f"Deleting record without formats: {record.tenant.name}/{record.record_id}" |
171 | | - ) |
172 | | - await session.delete(record) |
173 | | - |
174 | | - if dry_run: |
175 | | - click.echo("Rolling back changes (dry run)") |
176 | | - await session.rollback() |
| 212 | + recovery = ConsistencyChecker(importer, autofix) |
| 213 | + await recovery.scan(prefix=prefix) |
| 214 | + sys.exit(1 if recovery.unfixed_issues else 0) |
0 commit comments