Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
6e370b165b build(deps): bump the uv group across 2 directories with 1 update
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.4
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.13.4
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 22:51:38 +00:00
Juan Diego García
b570d202dc chore(main): release 0.43.0 (#940) 2026-03-31 19:27:00 -05:00
Juan Diego García
8c4f5e9c0f fix: cpu usage + email improvements (#944)
* fix: cpu usage on server ws manager, 100% to 0% on idle

* fix:  change email icon to white and prefill email in daily room for authenticated users

* fix: improve email sending with full ts transcript
2026-03-31 16:34:10 -05:00
Juan Diego García
ec8b49738e feat: show trash for soft deleted transcripts and hard delete option (#942)
* feat: show trash for soft deleted transcripts and hard delete option

* fix: test fixtures

* docs: aws new permissions
2026-03-31 13:15:52 -05:00
Juan Diego García
cc9c5cd4a5 fix: add parakeet as default transcriber and fix diarizer image (#939) 2026-03-31 10:22:57 -05:00
39 changed files with 1935 additions and 270 deletions

View File

@@ -1,5 +1,18 @@
# Changelog
## [0.43.0](https://github.com/GreyhavenHQ/reflector/compare/v0.42.0...v0.43.0) (2026-03-31)
### Features
* show trash for soft deleted transcripts and hard delete option ([#942](https://github.com/GreyhavenHQ/reflector/issues/942)) ([ec8b497](https://github.com/GreyhavenHQ/reflector/commit/ec8b49738e8e76f6e5d2496a42cb454ef6c2d7c7))
### Bug Fixes
* add parakeet as default transcriber and fix diarizer image ([#939](https://github.com/GreyhavenHQ/reflector/issues/939)) ([cc9c5cd](https://github.com/GreyhavenHQ/reflector/commit/cc9c5cd4a5f4123ef957ad82461ca37a727d1ba6))
* cpu usage + email improvements ([#944](https://github.com/GreyhavenHQ/reflector/issues/944)) ([8c4f5e9](https://github.com/GreyhavenHQ/reflector/commit/8c4f5e9c0f893f4cb029595505b53136f04760f4))
## [0.42.0](https://github.com/GreyhavenHQ/reflector/compare/v0.41.0...v0.42.0) (2026-03-30)

View File

@@ -95,6 +95,12 @@ DAILYCO_STORAGE_AWS_BUCKET_NAME=<your-bucket-from-daily-setup>
DAILYCO_STORAGE_AWS_REGION=us-east-1
DAILYCO_STORAGE_AWS_ROLE_ARN=<your-role-arn-from-daily-setup>
# Worker credentials for reading/deleting recordings from Daily's S3 bucket.
# Required when transcript storage uses a different bucket or credentials
# (e.g., selfhosted with Garage or a separate S3 account).
DAILYCO_STORAGE_AWS_ACCESS_KEY_ID=<your-aws-access-key>
DAILYCO_STORAGE_AWS_SECRET_ACCESS_KEY=<your-aws-secret-key>
# Transcript storage (should already be configured from main setup)
# TRANSCRIPT_STORAGE_BACKEND=aws
# TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID=<your-key>
@@ -103,6 +109,19 @@ DAILYCO_STORAGE_AWS_ROLE_ARN=<your-role-arn-from-daily-setup>
# TRANSCRIPT_STORAGE_AWS_REGION=<your-bucket-region>
```
:::info Two separate credential sets for Daily.co
- **`ROLE_ARN`** — Used by Daily's API to *write* recordings into your S3 bucket (configured via Daily dashboard).
- **`ACCESS_KEY_ID` / `SECRET_ACCESS_KEY`** — Used by Reflector workers to *read* recordings for transcription and *delete* them on consent denial or permanent transcript deletion.
Required IAM permissions for the worker key on the Daily recordings bucket:
- `s3:GetObject` — Download recording files for processing
- `s3:DeleteObject` — Remove files on consent denial, trash destroy, or data retention cleanup
- `s3:ListBucket` — Scan for recordings needing reprocessing
If the worker keys are not set, Reflector falls back to the transcript storage master key, which then needs cross-bucket access to the Daily bucket.
:::
---
## Restart Services

View File

@@ -305,6 +305,48 @@ TRANSCRIPT_STORAGE_AWS_REGION=us-east-1
TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL=http://minio:9000
```
### S3 IAM Permissions Reference
Reflector uses up to 3 separate S3 credential sets, each scoped to a specific bucket. When using AWS IAM in production, each key should have only the permissions it needs.
**Transcript storage key** (`TRANSCRIPT_STORAGE_AWS_*`) — the main bucket for processed files:
```json
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::reflector-media/*", "arn:aws:s3:::reflector-media"]
}
```
Used for: processed MP3 audio, waveform JSON, temporary pipeline files. Deletions happen during trash "Destroy", consent-denied cleanup, and public mode data retention.
**Daily.co worker key** (`DAILYCO_STORAGE_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY`) — for reading and cleaning up Daily recordings:
```json
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::your-daily-bucket/*", "arn:aws:s3:::your-daily-bucket"]
}
```
Used for: downloading multitrack recording files for processing, deleting track files and composed video on consent denial or trash destroy. No `s3:PutObject` needed — Daily's own API writes via the Role ARN.
**Whereby worker key** (`WHEREBY_STORAGE_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY`) — same pattern as Daily:
```json
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::your-whereby-bucket/*", "arn:aws:s3:::your-whereby-bucket"]
}
```
> **Fallback behavior:** If platform-specific worker keys are not set, Reflector falls back to the transcript storage master key with a bucket override. This means the master key would need cross-bucket access to the Daily/Whereby buckets. For least-privilege, configure platform-specific keys so each only accesses its own bucket.
> **Garage / single-bucket setups:** When using Garage or a single S3 bucket for everything, one master key with full permissions on that bucket is sufficient. The IAM scoping above only matters when using separate buckets per platform (typical in AWS production).
## What Authentication Enables
By default, Reflector runs in **public mode** (`AUTH_BACKEND=none`, `PUBLIC_MODE=true`) — anyone can create and view transcripts without logging in. Transcripts are anonymous (not linked to any user) and cannot be edited or deleted after creation.

View File

@@ -114,8 +114,8 @@ modal secret create reflector-gpu REFLECTOR_GPU_APIKEY="$API_KEY"
# --- Deploy Functions ---
echo ""
echo "Deploying transcriber (Whisper)..."
TRANSCRIBER_URL=$(modal deploy reflector_transcriber.py 2>&1 | grep -o 'https://[^ ]*web.modal.run' | head -1)
echo "Deploying transcriber (Parakeet)..."
TRANSCRIBER_URL=$(modal deploy reflector_transcriber_parakeet.py 2>&1 | grep -o 'https://[^ ]*web.modal.run' | head -1)
if [ -z "$TRANSCRIBER_URL" ]; then
echo "Error: Failed to deploy transcriber. Check Modal dashboard for details."
exit 1

View File

@@ -113,12 +113,14 @@ def download_pyannote_audio():
diarizer_image = (
modal.Image.debian_slim(python_version="3.10")
modal.Image.from_registry(
"nvidia/cuda:11.8.0-cudnn8-devel-ubuntu22.04", add_python="3.10"
)
.pip_install(
"pyannote.audio==3.1.0",
"requests",
"onnx",
"torchaudio",
"torchaudio==2.0.1",
"onnxruntime-gpu",
"torch==2.0.0",
"transformers==4.34.0",
@@ -133,14 +135,6 @@ diarizer_image = (
secrets=[modal.Secret.from_name("hf_token")],
)
.run_function(migrate_cache_llm)
.env(
{
"LD_LIBRARY_PATH": (
"/usr/local/lib/python3.10/site-packages/nvidia/cudnn/lib/:"
"/opt/conda/lib/python3.10/site-packages/nvidia/cublas/lib/"
)
}
)
)

140
gpu/self_hosted/uv.lock generated
View File

@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -24,76 +24,76 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]

View File

@@ -120,7 +120,8 @@ class Meeting(BaseModel):
daily_composed_video_s3_key: str | None = None
daily_composed_video_duration: int | None = None
# Email recipients for transcript notification
email_recipients: list[str] | None = None
# Each entry is {"email": str, "include_link": bool} or a legacy plain str
email_recipients: list[dict | str] | None = None
class MeetingController:
@@ -399,15 +400,27 @@ class MeetingController:
async with get_database().transaction(isolation="serializable"):
yield
async def add_email_recipient(self, meeting_id: str, email: str) -> list[str]:
"""Add an email to the meeting's email_recipients list (no duplicates)."""
async def add_email_recipient(
self, meeting_id: str, email: str, *, include_link: bool = True
) -> list[dict]:
"""Add an email to the meeting's email_recipients list (no duplicates).
Each entry is stored as {"email": str, "include_link": bool}.
Legacy plain-string entries are normalised on read.
"""
async with self.transaction():
meeting = await self.get_by_id(meeting_id)
if not meeting:
raise ValueError(f"Meeting {meeting_id} not found")
current = meeting.email_recipients or []
if email not in current:
current.append(email)
# Normalise legacy string entries
current: list[dict] = [
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if not any(r["email"] == email for r in current):
current.append({"email": email, "include_link": include_link})
await self.update_meeting(meeting_id, email_recipients=current)
return current

View File

@@ -78,6 +78,14 @@ class RecordingController:
)
await get_database().execute(query)
async def restore_by_id(self, id: str) -> None:
query = recordings.update().where(recordings.c.id == id).values(deleted_at=None)
await get_database().execute(query)
async def hard_delete_by_id(self, id: str) -> None:
query = recordings.delete().where(recordings.c.id == id)
await get_database().execute(query)
async def set_meeting_id(
self,
recording_id: NonEmptyString,

View File

@@ -138,6 +138,7 @@ class SearchParameters(BaseModel):
source_kind: SourceKind | None = None
from_datetime: datetime | None = None
to_datetime: datetime | None = None
include_deleted: bool = False
class SearchResultDB(BaseModel):
@@ -387,7 +388,10 @@ class SearchController:
transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True)
)
base_query = base_query.where(transcripts.c.deleted_at.is_(None))
if params.include_deleted:
base_query = base_query.where(transcripts.c.deleted_at.isnot(None))
else:
base_query = base_query.where(transcripts.c.deleted_at.is_(None))
if params.query_text is not None:
# because already initialized based on params.query_text presence above
@@ -396,7 +400,13 @@ class SearchController:
transcripts.c.search_vector_en.op("@@")(search_query)
)
if params.user_id:
if params.include_deleted:
# Trash view: only show user's own deleted transcripts.
# Defense-in-depth: require user_id to prevent leaking all users' trash.
if not params.user_id:
return [], 0
base_query = base_query.where(transcripts.c.user_id == params.user_id)
elif params.user_id:
base_query = base_query.where(
sqlalchemy.or_(
transcripts.c.user_id == params.user_id, rooms.c.is_shared
@@ -421,6 +431,8 @@ class SearchController:
if params.query_text is not None:
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
elif params.include_deleted:
order_by = sqlalchemy.desc(transcripts.c.deleted_at)
else:
order_by = sqlalchemy.desc(transcripts.c.created_at)

View File

@@ -24,7 +24,7 @@ from reflector.db.utils import is_postgresql
from reflector.logger import logger
from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
from reflector.storage import get_source_storage, get_transcripts_storage
from reflector.utils import generate_uuid4
from reflector.utils.webvtt import topics_to_webvtt
@@ -676,6 +676,126 @@ class TranscriptController:
)
await get_database().execute(query)
async def restore_by_id(
self,
transcript_id: str,
user_id: str | None = None,
) -> bool:
"""
Restore a soft-deleted transcript by clearing deleted_at.
Also restores the associated recording if present.
Returns True if the transcript was restored, False otherwise.
"""
transcript = await self.get_by_id(transcript_id)
if not transcript:
return False
if transcript.deleted_at is None:
return False
if user_id is not None and transcript.user_id != user_id:
return False
query = (
transcripts.update()
.where(transcripts.c.id == transcript_id)
.values(deleted_at=None)
)
await get_database().execute(query)
if transcript.recording_id:
try:
await recordings_controller.restore_by_id(transcript.recording_id)
except Exception as e:
logger.warning(
"Failed to restore recording",
exc_info=e,
recording_id=transcript.recording_id,
)
return True
async def hard_delete(self, transcript_id: str) -> None:
"""
Permanently delete a transcript, its recording, and all associated files.
Only deletes transcript-owned resources:
- Transcript row and recording row from DB (first, to make data inaccessible)
- Transcript audio in S3 storage
- Recording files in S3 (both object_key and track_keys, since a recording can have both)
- Local files (data_path directory)
Does NOT delete: meetings, consent records, rooms, or any shared entity.
Requires the transcript to be soft-deleted first (deleted_at must be set).
"""
transcript = await self.get_by_id(transcript_id)
if not transcript:
return
if transcript.deleted_at is None:
return
# Collect file references before deleting DB rows
recording = None
recording_storage = None
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
# Determine the correct storage backend for recording files.
# Recordings from different platforms (daily, whereby) live in
# platform-specific buckets with separate credentials.
if recording and recording.meeting_id:
from reflector.db.meetings import meetings_controller # noqa: PLC0415
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if meeting:
recording_storage = get_source_storage(meeting.platform)
if recording_storage is None:
recording_storage = get_transcripts_storage()
# 1. Hard-delete DB rows first (makes data inaccessible immediately)
if recording:
await recordings_controller.hard_delete_by_id(recording.id)
await get_database().execute(
transcripts.delete().where(transcripts.c.id == transcript_id)
)
# 2. Delete transcript audio from S3 (always uses transcript storage)
transcript_storage = get_transcripts_storage()
if transcript.audio_location == "storage" and not transcript.audio_deleted:
try:
await transcript_storage.delete_file(transcript.storage_audio_path)
except Exception as e:
logger.warning(
"Failed to delete transcript audio from storage",
exc_info=e,
transcript_id=transcript_id,
path=transcript.storage_audio_path,
)
# 3. Delete recording files from S3 (both object_key and track_keys —
# a recording can have both, unlike consent cleanup which uses elif).
# Uses platform-specific storage resolved above.
if recording and recording.bucket_name and recording_storage:
keys_to_delete = []
if recording.track_keys:
keys_to_delete = recording.track_keys
if recording.object_key:
keys_to_delete.append(recording.object_key)
for key in keys_to_delete:
try:
await recording_storage.delete_file(
key, bucket=recording.bucket_name
)
except Exception as e:
logger.warning(
"Failed to delete recording file",
exc_info=e,
key=key,
bucket=recording.bucket_name,
)
# 4. Delete local files
transcript.unlink()
async def remove_by_recording_id(self, recording_id: str):
"""
Soft-delete a transcript by recording_id

View File

@@ -1,11 +1,13 @@
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from html import escape
import aiosmtplib
import structlog
from reflector.db.transcripts import Transcript
from reflector.db.transcripts import SourceKind, Transcript
from reflector.settings import settings
from reflector.utils.transcript_formats import transcript_to_text_timestamped
logger = structlog.get_logger(__name__)
@@ -18,35 +20,111 @@ def get_transcript_url(transcript: Transcript) -> str:
return f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
def _build_plain_text(transcript: Transcript, url: str) -> str:
def _get_timestamped_text(transcript: Transcript) -> str:
"""Build the full timestamped transcript text using existing utility."""
if not transcript.topics:
return ""
is_multitrack = transcript.source_kind == SourceKind.ROOM
return transcript_to_text_timestamped(
transcript.topics, transcript.participants, is_multitrack=is_multitrack
)
def _build_plain_text(transcript: Transcript, url: str, include_link: bool) -> str:
title = transcript.title or "Unnamed recording"
lines = [
f"Your transcript is ready: {title}",
"",
f"View it here: {url}",
]
lines = [f"Reflector: {title}", ""]
if transcript.short_summary:
lines.extend(["", "Summary:", transcript.short_summary])
lines.extend(["Summary:", transcript.short_summary, ""])
timestamped = _get_timestamped_text(transcript)
if timestamped:
lines.extend(["Transcript:", timestamped, ""])
if include_link:
lines.append(f"View transcript: {url}")
lines.append("")
lines.append(
"This email was sent because you requested to receive "
"the transcript from a meeting."
)
return "\n".join(lines)
def _build_html(transcript: Transcript, url: str) -> str:
title = transcript.title or "Unnamed recording"
def _build_html(transcript: Transcript, url: str, include_link: bool) -> str:
title = escape(transcript.title or "Unnamed recording")
summary_html = ""
if transcript.short_summary:
summary_html = f"<p style='color:#555;'>{transcript.short_summary}</p>"
summary_html = (
f'<p style="color:#555;margin-bottom:16px;">'
f"{escape(transcript.short_summary)}</p>"
)
transcript_html = ""
timestamped = _get_timestamped_text(transcript)
if timestamped:
# Build styled transcript lines
styled_lines = []
for line in timestamped.split("\n"):
if not line.strip():
continue
# Lines are formatted as "[MM:SS] Speaker: text"
if line.startswith("[") and "] " in line:
bracket_end = line.index("] ")
timestamp = escape(line[: bracket_end + 1])
rest = line[bracket_end + 2 :]
if ": " in rest:
colon_pos = rest.index(": ")
speaker = escape(rest[:colon_pos])
text = escape(rest[colon_pos + 2 :])
styled_lines.append(
f'<div style="margin-bottom:4px;">'
f'<span style="color:#888;font-size:12px;">{timestamp}</span> '
f"<strong>{speaker}:</strong> {text}</div>"
)
else:
styled_lines.append(
f'<div style="margin-bottom:4px;">{escape(line)}</div>'
)
else:
styled_lines.append(
f'<div style="margin-bottom:4px;">{escape(line)}</div>'
)
transcript_html = (
'<h3 style="margin-top:20px;margin-bottom:8px;">Transcript</h3>'
'<div style="background:#f7f7f7;padding:16px;border-radius:6px;'
'font-size:13px;line-height:1.6;max-height:600px;overflow-y:auto;">'
f"{''.join(styled_lines)}</div>"
)
link_html = ""
if include_link:
link_html = (
'<p style="margin-top:20px;">'
f'<a href="{url}" style="display:inline-block;padding:10px 20px;'
"background:#4A90D9;color:#fff;text-decoration:none;"
'border-radius:4px;">View Transcript</a></p>'
)
return f"""\
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<h2>Your transcript is ready</h2>
<p><strong>{title}</strong></p>
<h2 style="margin-bottom:4px;">{title}</h2>
{summary_html}
<p><a href="{url}" style="display:inline-block;padding:10px 20px;background:#4A90D9;color:#fff;text-decoration:none;border-radius:4px;">View Transcript</a></p>
<p style="color:#999;font-size:12px;">This email was sent because you requested to receive the transcript from a meeting.</p>
{transcript_html}
{link_html}
<p style="color:#999;font-size:12px;margin-top:20px;">This email was sent because you requested to receive the transcript from a meeting.</p>
</div>"""
async def send_transcript_email(to_emails: list[str], transcript: Transcript) -> int:
async def send_transcript_email(
to_emails: list[str],
transcript: Transcript,
*,
include_link: bool = True,
) -> int:
"""Send transcript notification to all emails. Returns count sent."""
if not is_email_configured() or not to_emails:
return 0
@@ -57,12 +135,12 @@ async def send_transcript_email(to_emails: list[str], transcript: Transcript) ->
for email_addr in to_emails:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Transcript Ready: {title}"
msg["Subject"] = f"Reflector: {title}"
msg["From"] = settings.SMTP_FROM_EMAIL
msg["To"] = email_addr
msg.attach(MIMEText(_build_plain_text(transcript, url), "plain"))
msg.attach(MIMEText(_build_html(transcript, url), "html"))
msg.attach(MIMEText(_build_plain_text(transcript, url, include_link), "plain"))
msg.attach(MIMEText(_build_html(transcript, url, include_link), "html"))
try:
await aiosmtplib.send(

View File

@@ -64,3 +64,9 @@ TIMEOUT_HEAVY = 1200 # Transcription, fan-out LLM tasks (Hatchet execution_time
TIMEOUT_HEAVY_HTTP = (
1150 # httpx timeout for transcribe_track — below 1200 so Hatchet doesn't race
)
TIMEOUT_EXTRA_HEAVY = (
3600 # Detect Topics, fan-out LLM tasks (Hatchet execution_timeout)
)
TIMEOUT_EXTRA_HEAVY_HTTP = (
3400 # httpx timeout for detect_topics — below 3600 so Hatchet doesn't race
)

View File

@@ -41,6 +41,7 @@ from reflector.hatchet.broadcast import (
from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.constants import (
TIMEOUT_AUDIO,
TIMEOUT_EXTRA_HEAVY,
TIMEOUT_HEAVY,
TIMEOUT_LONG,
TIMEOUT_MEDIUM,
@@ -693,7 +694,7 @@ async def generate_waveform(input: PipelineInput, ctx: Context) -> WaveformResul
@daily_multitrack_pipeline.task(
parents=[process_tracks],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
execution_timeout=timedelta(seconds=TIMEOUT_EXTRA_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
@@ -1277,6 +1278,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
return ConsentResult()
consent_denied = False
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if meeting:
@@ -1339,6 +1341,22 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
# Delete cloud video if present
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
await meetings_controller.update_meeting(
meeting.id,
daily_composed_video_s3_key=None,
daily_composed_video_duration=None,
)
ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
except Exception as e:
error_msg = f"Failed to delete cloud video: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
if deletion_errors:
logger.warning(
"[Hatchet] cleanup_consent completed with errors",
@@ -1349,7 +1367,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
ctx.log(f"cleanup_consent completed with {len(deletion_errors)} errors")
else:
await transcripts_controller.update(transcript, {"audio_deleted": True})
ctx.log("cleanup_consent: all audio deleted successfully")
ctx.log("cleanup_consent: all audio and video deleted successfully")
return ConsentResult()
@@ -1493,22 +1511,41 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
recipients = (
list(meeting.email_recipients)
# Normalise meeting recipients (legacy strings → dicts)
meeting_recipients: list[dict] = (
[
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
# Room-level email always gets a link (room owner)
from reflector.db.rooms import rooms_controller # noqa: PLC0415
room_email = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
room_email = room.email_transcript_to
if not recipients:
# Build two groups: with link and without link
with_link = [
r["email"] for r in meeting_recipients if r.get("include_link", True)
]
without_link = [
r["email"] for r in meeting_recipients if not r.get("include_link", True)
]
if room_email:
if room_email not in with_link:
with_link.append(room_email)
without_link = [e for e in without_link if e != room_email]
if not with_link and not without_link:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
@@ -1516,7 +1553,15 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(recipients, transcript)
count = 0
if with_link:
count += await send_transcript_email(
with_link, transcript, include_link=True
)
if without_link:
count += await send_transcript_email(
without_link, transcript, include_link=False
)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)

View File

@@ -688,7 +688,10 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
)
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
from reflector.storage import get_transcripts_storage # noqa: PLC0415
from reflector.storage import ( # noqa: PLC0415
get_source_storage,
get_transcripts_storage,
)
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
@@ -697,6 +700,7 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
consent_denied = False
recording = None
meeting = None
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
@@ -756,6 +760,22 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
# Delete cloud video if present
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
await meetings_controller.update_meeting(
meeting.id,
daily_composed_video_s3_key=None,
daily_composed_video_duration=None,
)
ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
except Exception as e:
error_msg = f"Failed to delete cloud video: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
if deletion_errors:
logger.warning(
"[Hatchet] cleanup_consent completed with errors",
@@ -764,7 +784,7 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
)
else:
await transcripts_controller.update(transcript, {"audio_deleted": True})
ctx.log("cleanup_consent: all audio deleted successfully")
ctx.log("cleanup_consent: all audio and video deleted successfully")
return ConsentResult()
@@ -896,22 +916,41 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
recipients = (
list(meeting.email_recipients)
# Normalise meeting recipients (legacy strings → dicts)
meeting_recipients: list[dict] = (
[
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
# Room-level email always gets a link (room owner)
from reflector.db.rooms import rooms_controller # noqa: PLC0415
room_email = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
room_email = room.email_transcript_to
if not recipients:
# Build two groups: with link and without link
with_link = [
r["email"] for r in meeting_recipients if r.get("include_link", True)
]
without_link = [
r["email"] for r in meeting_recipients if not r.get("include_link", True)
]
if room_email:
if room_email not in with_link:
with_link.append(room_email)
without_link = [e for e in without_link if e != room_email]
if not with_link and not without_link:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
@@ -919,7 +958,15 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(recipients, transcript)
count = 0
if with_link:
count += await send_transcript_email(
with_link, transcript, include_link=True
)
if without_link:
count += await send_transcript_email(
without_link, transcript, include_link=False
)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)

View File

@@ -397,22 +397,41 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
recipients = (
list(meeting.email_recipients)
# Normalise meeting recipients (legacy strings → dicts)
meeting_recipients: list[dict] = (
[
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
# Room-level email always gets a link (room owner)
from reflector.db.rooms import rooms_controller # noqa: PLC0415
room_email = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
room_email = room.email_transcript_to
if not recipients:
# Build two groups: with link and without link
with_link = [
r["email"] for r in meeting_recipients if r.get("include_link", True)
]
without_link = [
r["email"] for r in meeting_recipients if not r.get("include_link", True)
]
if room_email:
if room_email not in with_link:
with_link.append(room_email)
without_link = [e for e in without_link if e != room_email]
if not with_link and not without_link:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
@@ -420,7 +439,15 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(recipients, transcript)
count = 0
if with_link:
count += await send_transcript_email(
with_link, transcript, include_link=True
)
if without_link:
count += await send_transcript_email(
without_link, transcript, include_link=False
)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)

View File

@@ -61,7 +61,7 @@ from reflector.processors.types import (
)
from reflector.processors.types import Transcript as TranscriptProcessorType
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
from reflector.storage import get_source_storage, get_transcripts_storage
from reflector.views.transcripts import GetTranscriptTopic
from reflector.ws_events import TranscriptEventName
from reflector.ws_manager import WebsocketManager, get_ws_manager
@@ -671,6 +671,22 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
logger.error(error_msg, exc_info=e)
deletion_errors.append(error_msg)
# Delete cloud video if present
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
await meetings_controller.update_meeting(
meeting.id,
daily_composed_video_s3_key=None,
daily_composed_video_duration=None,
)
logger.info(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
except Exception as e:
error_msg = f"Failed to delete cloud video: {e}"
logger.error(error_msg, exc_info=e)
deletion_errors.append(error_msg)
if deletion_errors:
logger.warning(
f"Consent cleanup completed with {len(deletion_errors)} errors",
@@ -678,7 +694,7 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
)
else:
await transcripts_controller.update(transcript, {"audio_deleted": True})
logger.info("Consent cleanup done - all audio deleted")
logger.info("Consent cleanup done - all audio and video deleted")
@get_transcript

View File

@@ -168,8 +168,9 @@ async def add_email_recipient(
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
include_link = user is not None
recipients = await meetings_controller.add_email_recipient(
meeting_id, request.email
meeting_id, request.email, include_link=include_link
)
return {"status": "success", "email_recipients": recipients}

View File

@@ -309,6 +309,7 @@ async def transcripts_search(
source_kind: Optional[SourceKind] = None,
from_datetime: SearchFromDatetimeParam = None,
to_datetime: SearchToDatetimeParam = None,
include_deleted: bool = False,
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
] = None,
@@ -316,6 +317,12 @@ async def transcripts_search(
"""Full-text search across transcript titles and content."""
user_id = user["sub"] if user else None
if include_deleted and not user_id:
raise HTTPException(
status_code=401,
detail="Authentication required to view deleted transcripts",
)
if from_datetime and to_datetime and from_datetime > to_datetime:
raise HTTPException(
status_code=400, detail="'from' must be less than or equal to 'to'"
@@ -330,6 +337,7 @@ async def transcripts_search(
source_kind=source_kind,
from_datetime=from_datetime,
to_datetime=to_datetime,
include_deleted=include_deleted,
)
results, total = await search_controller.search_transcripts(search_params)
@@ -615,6 +623,54 @@ async def transcript_delete(
return DeletionStatus(status="ok")
@router.post("/transcripts/{transcript_id}/restore", response_model=DeletionStatus)
async def transcript_restore(
transcript_id: str,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
"""Restore a soft-deleted transcript."""
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.deleted_at is None:
raise HTTPException(status_code=400, detail="Transcript is not deleted")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.restore_by_id(transcript.id, user_id=user_id)
await get_ws_manager().send_json(
room_id=f"user:{user_id}",
message={"event": "TRANSCRIPT_RESTORED", "data": {"id": transcript.id}},
)
return DeletionStatus(status="ok")
@router.delete("/transcripts/{transcript_id}/destroy", response_model=DeletionStatus)
async def transcript_destroy(
transcript_id: str,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
"""Permanently delete a transcript and all associated files."""
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.deleted_at is None:
raise HTTPException(
status_code=400, detail="Transcript must be soft-deleted first"
)
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.hard_delete(transcript.id)
await get_ws_manager().send_json(
room_id=f"user:{user_id}",
message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}},
)
return DeletionStatus(status="ok")
@router.get(
"/transcripts/{transcript_id}/topics",
response_model=list[GetTranscriptTopic],
@@ -741,5 +797,7 @@ async def transcript_send_email(
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
sent = await send_transcript_email([request.email], transcript)
sent = await send_transcript_email(
[request.email], transcript, include_link=(transcript.share_mode == "public")
)
return SendEmailResponse(sent=sent)

View File

@@ -146,7 +146,6 @@ else:
app.conf.broker_connection_retry_on_startup = True
app.autodiscover_tasks(
[
"reflector.pipelines.main_live_pipeline",
"reflector.worker.healthcheck",
"reflector.worker.process",
"reflector.worker.cleanup",

View File

@@ -12,6 +12,7 @@ from celery import shared_task
from celery.utils.log import get_task_logger
from pydantic import ValidationError
from reflector.asynctask import asynctask
from reflector.dailyco_api import FinishedRecordingResponse, RecordingResponse
from reflector.db.daily_participant_sessions import (
DailyParticipantSession,
@@ -25,9 +26,6 @@ from reflector.db.transcripts import (
transcripts_controller,
)
from reflector.hatchet.client import HatchetClientManager
from reflector.pipelines.main_live_pipeline import asynctask
from reflector.pipelines.topic_processing import EmptyPipeline
from reflector.processors import AudioFileWriterProcessor
from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
from reflector.redis_cache import RedisAsyncLock
from reflector.settings import settings
@@ -908,6 +906,11 @@ async def convert_audio_and_waveform(transcript) -> None:
transcript_id=transcript.id,
)
from reflector.pipelines.topic_processing import EmptyPipeline # noqa: PLC0415
from reflector.processors.audio_file_writer import (
AudioFileWriterProcessor, # noqa: PLC0415
)
upload_path = transcript.data_path / "upload.webm"
mp3_path = transcript.audio_mp3_filename

View File

@@ -8,8 +8,8 @@ import structlog
from celery import shared_task
from celery.utils.log import get_task_logger
from reflector.asynctask import asynctask
from reflector.db.rooms import rooms_controller
from reflector.pipelines.main_live_pipeline import asynctask
from reflector.utils.webhook import (
WebhookRoomPayload,
WebhookTestPayload,

View File

@@ -113,6 +113,7 @@ TranscriptWsEvent = Annotated[
UserEventName = Literal[
"TRANSCRIPT_CREATED",
"TRANSCRIPT_DELETED",
"TRANSCRIPT_RESTORED",
"TRANSCRIPT_STATUS",
"TRANSCRIPT_FINAL_TITLE",
"TRANSCRIPT_DURATION",
@@ -161,6 +162,15 @@ class UserWsTranscriptDeleted(BaseModel):
data: UserTranscriptDeletedData
class UserTranscriptRestoredData(BaseModel):
id: NonEmptyString
class UserWsTranscriptRestored(BaseModel):
event: Literal["TRANSCRIPT_RESTORED"] = "TRANSCRIPT_RESTORED"
data: UserTranscriptRestoredData
class UserWsTranscriptStatus(BaseModel):
event: Literal["TRANSCRIPT_STATUS"] = "TRANSCRIPT_STATUS"
data: UserTranscriptStatusData
@@ -180,6 +190,7 @@ UserWsEvent = Annotated[
Union[
UserWsTranscriptCreated,
UserWsTranscriptDeleted,
UserWsTranscriptRestored,
UserWsTranscriptStatus,
UserWsTranscriptFinalTitle,
UserWsTranscriptDuration,

View File

@@ -107,7 +107,8 @@ class WebsocketManager:
while True:
# timeout=1.0 prevents tight CPU loop when no messages available
message = await pubsub_subscriber.get_message(
ignore_subscribe_messages=True
ignore_subscribe_messages=True,
timeout=1.0,
)
if message is not None:
room_id = message["channel"].decode("utf-8")

206
server/tests/test_email.py Normal file
View File

@@ -0,0 +1,206 @@
"""Tests for reflector.email — transcript email composition and sending."""
from unittest.mock import AsyncMock, patch
import pytest
from reflector.db.transcripts import (
SourceKind,
Transcript,
TranscriptParticipant,
TranscriptTopic,
)
from reflector.email import (
_build_html,
_build_plain_text,
get_transcript_url,
send_transcript_email,
)
from reflector.processors.types import Word
def _make_transcript(
*,
title: str | None = "Weekly Standup",
short_summary: str | None = "Team discussed sprint progress.",
with_topics: bool = True,
share_mode: str = "private",
source_kind: SourceKind = SourceKind.FILE,
) -> Transcript:
topics = []
participants = []
if with_topics:
participants = [
TranscriptParticipant(id="p1", speaker=0, name="Alice"),
TranscriptParticipant(id="p2", speaker=1, name="Bob"),
]
topics = [
TranscriptTopic(
title="Intro",
summary="Greetings",
timestamp=0.0,
duration=10.0,
words=[
Word(text="Hello", start=0.0, end=0.5, speaker=0),
Word(text="everyone", start=0.5, end=1.0, speaker=0),
Word(text="Thanks", start=5.0, end=5.5, speaker=1),
Word(text="for", start=5.5, end=5.8, speaker=1),
Word(text="joining", start=5.8, end=6.2, speaker=1),
],
),
]
return Transcript(
id="tx-123",
title=title,
short_summary=short_summary,
topics=topics,
participants=participants,
share_mode=share_mode,
source_kind=source_kind,
)
URL = "http://localhost:3000/transcripts/tx-123"
class TestBuildPlainText:
def test_full_content_with_link(self):
t = _make_transcript()
text = _build_plain_text(t, URL, include_link=True)
assert text.startswith("Reflector: Weekly Standup")
assert "Team discussed sprint progress." in text
assert "[00:00] Alice:" in text
assert "[00:05] Bob:" in text
assert URL in text
def test_full_content_without_link(self):
t = _make_transcript()
text = _build_plain_text(t, URL, include_link=False)
assert "Reflector: Weekly Standup" in text
assert "Team discussed sprint progress." in text
assert "[00:00] Alice:" in text
assert URL not in text
def test_no_summary(self):
t = _make_transcript(short_summary=None)
text = _build_plain_text(t, URL, include_link=True)
assert "Summary:" not in text
assert "[00:00] Alice:" in text
def test_no_topics(self):
t = _make_transcript(with_topics=False)
text = _build_plain_text(t, URL, include_link=True)
assert "Transcript:" not in text
assert "Reflector: Weekly Standup" in text
def test_unnamed_recording(self):
t = _make_transcript(title=None)
text = _build_plain_text(t, URL, include_link=True)
assert "Reflector: Unnamed recording" in text
class TestBuildHtml:
def test_full_content_with_link(self):
t = _make_transcript()
html = _build_html(t, URL, include_link=True)
assert "Weekly Standup" in html
assert "Team discussed sprint progress." in html
assert "Alice" in html
assert "Bob" in html
assert URL in html
assert "View Transcript" in html
def test_full_content_without_link(self):
t = _make_transcript()
html = _build_html(t, URL, include_link=False)
assert "Weekly Standup" in html
assert "Alice" in html
assert URL not in html
assert "View Transcript" not in html
def test_no_summary(self):
t = _make_transcript(short_summary=None)
html = _build_html(t, URL, include_link=True)
assert "sprint progress" not in html
assert "Alice" in html
def test_no_topics(self):
t = _make_transcript(with_topics=False)
html = _build_html(t, URL, include_link=True)
assert "Transcript" not in html or "View Transcript" in html
def test_html_escapes_title(self):
t = _make_transcript(title='<script>alert("xss")</script>')
html = _build_html(t, URL, include_link=True)
assert "<script>" not in html
assert "&lt;script&gt;" in html
class TestGetTranscriptUrl:
def test_url_format(self):
t = _make_transcript()
url = get_transcript_url(t)
assert url.endswith("/transcripts/tx-123")
class TestSendTranscriptEmail:
@pytest.mark.asyncio
async def test_include_link_default_true(self):
t = _make_transcript()
with (
patch("reflector.email.is_email_configured", return_value=True),
patch(
"reflector.email.aiosmtplib.send", new_callable=AsyncMock
) as mock_send,
):
count = await send_transcript_email(["a@test.com"], t)
assert count == 1
call_args = mock_send.call_args
msg = call_args[0][0]
assert msg["Subject"] == "Reflector: Weekly Standup"
# Default include_link=True, so HTML part should contain the URL
html_part = msg.get_payload()[1].get_payload()
assert "/transcripts/tx-123" in html_part
@pytest.mark.asyncio
async def test_include_link_false(self):
t = _make_transcript()
with (
patch("reflector.email.is_email_configured", return_value=True),
patch(
"reflector.email.aiosmtplib.send", new_callable=AsyncMock
) as mock_send,
):
count = await send_transcript_email(["a@test.com"], t, include_link=False)
assert count == 1
msg = mock_send.call_args[0][0]
html_part = msg.get_payload()[1].get_payload()
assert "/transcripts/tx-123" not in html_part
plain_part = msg.get_payload()[0].get_payload()
assert "/transcripts/tx-123" not in plain_part
@pytest.mark.asyncio
async def test_skips_when_not_configured(self):
t = _make_transcript()
with patch("reflector.email.is_email_configured", return_value=False):
count = await send_transcript_email(["a@test.com"], t)
assert count == 0
@pytest.mark.asyncio
async def test_skips_empty_recipients(self):
t = _make_transcript()
with patch("reflector.email.is_email_configured", return_value=True):
count = await send_transcript_email([], t)
assert count == 0

View File

@@ -1,5 +1,9 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from reflector.db.meetings import meetings_controller
from reflector.db.recordings import Recording, recordings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import SourceKind, transcripts_controller
@@ -390,3 +394,463 @@ async def test_transcripts_list_filtered_by_room_id(authenticated_client, client
ids = [t["id"] for t in items]
assert in_room.id in ids
assert other.id not in ids
# ---------------------------------------------------------------------------
# Restore tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_transcript_restore(authenticated_client, client):
"""Soft-delete then restore, verify accessible again."""
response = await client.post("/transcripts", json={"name": "restore-me"})
assert response.status_code == 200
tid = response.json()["id"]
# Soft-delete
response = await client.delete(f"/transcripts/{tid}")
assert response.status_code == 200
# 404 while deleted
response = await client.get(f"/transcripts/{tid}")
assert response.status_code == 404
# Restore
response = await client.post(f"/transcripts/{tid}/restore")
assert response.status_code == 200
assert response.json()["status"] == "ok"
# Accessible again
response = await client.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["name"] == "restore-me"
# deleted_at is cleared
transcript = await transcripts_controller.get_by_id(tid)
assert transcript.deleted_at is None
@pytest.mark.asyncio
async def test_transcript_restore_recording_also_restored(authenticated_client, client):
"""Restoring a transcript also restores its recording."""
recording = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="restore-test.mp4",
recorded_at=datetime.now(timezone.utc),
)
)
transcript = await transcripts_controller.add(
name="restore-with-recording",
source_kind=SourceKind.ROOM,
recording_id=recording.id,
user_id="randomuserid",
)
# Soft-delete
response = await client.delete(f"/transcripts/{transcript.id}")
assert response.status_code == 200
# Both should be soft-deleted
rec = await recordings_controller.get_by_id(recording.id)
assert rec.deleted_at is not None
# Restore
response = await client.post(f"/transcripts/{transcript.id}/restore")
assert response.status_code == 200
# Recording also restored
rec = await recordings_controller.get_by_id(recording.id)
assert rec.deleted_at is None
tr = await transcripts_controller.get_by_id(transcript.id)
assert tr.deleted_at is None
@pytest.mark.asyncio
async def test_transcript_restore_not_deleted(authenticated_client, client):
"""Restoring a non-deleted transcript returns 400."""
response = await client.post("/transcripts", json={"name": "not-deleted"})
assert response.status_code == 200
tid = response.json()["id"]
response = await client.post(f"/transcripts/{tid}/restore")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_transcript_restore_not_found(authenticated_client, client):
"""Restoring a nonexistent transcript returns 404."""
response = await client.post("/transcripts/nonexistent-id/restore")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_transcript_restore_forbidden(authenticated_client, client):
"""Cannot restore another user's deleted transcript."""
# Create transcript owned by a different user
transcript = await transcripts_controller.add(
name="other-user-restore",
source_kind=SourceKind.FILE,
user_id="some-other-user",
)
# Soft-delete directly in DB
await transcripts_controller.remove_by_id(transcript.id, user_id="some-other-user")
# Try to restore as randomuserid (authenticated_client)
response = await client.post(f"/transcripts/{transcript.id}/restore")
assert response.status_code == 403
# ---------------------------------------------------------------------------
# Destroy tests
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_destroy_storage():
"""Mock storage backends so hard_delete doesn't require S3 credentials."""
with (
patch(
"reflector.db.transcripts.get_transcripts_storage",
return_value=AsyncMock(delete_file=AsyncMock()),
),
patch(
"reflector.db.transcripts.get_source_storage",
return_value=AsyncMock(delete_file=AsyncMock()),
),
):
yield
@pytest.mark.asyncio
async def test_transcript_destroy(authenticated_client, client, mock_destroy_storage):
"""Soft-delete then destroy, verify transcript gone from DB."""
response = await client.post("/transcripts", json={"name": "destroy-me"})
assert response.status_code == 200
tid = response.json()["id"]
# Soft-delete first
response = await client.delete(f"/transcripts/{tid}")
assert response.status_code == 200
# Destroy
response = await client.delete(f"/transcripts/{tid}/destroy")
assert response.status_code == 200
assert response.json()["status"] == "ok"
# Gone from DB entirely
transcript = await transcripts_controller.get_by_id(tid)
assert transcript is None
@pytest.mark.asyncio
async def test_transcript_destroy_not_soft_deleted(authenticated_client, client):
"""Cannot destroy a transcript that hasn't been soft-deleted."""
response = await client.post("/transcripts", json={"name": "not-soft-deleted"})
assert response.status_code == 200
tid = response.json()["id"]
response = await client.delete(f"/transcripts/{tid}/destroy")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_transcript_destroy_with_recording(
authenticated_client, client, mock_destroy_storage
):
"""Destroying a transcript also hard-deletes its recording from DB."""
recording = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="destroy-test.mp4",
recorded_at=datetime.now(timezone.utc),
)
)
transcript = await transcripts_controller.add(
name="destroy-with-recording",
source_kind=SourceKind.ROOM,
recording_id=recording.id,
user_id="randomuserid",
)
# Soft-delete
response = await client.delete(f"/transcripts/{transcript.id}")
assert response.status_code == 200
# Destroy
response = await client.delete(f"/transcripts/{transcript.id}/destroy")
assert response.status_code == 200
# Both gone from DB
assert await transcripts_controller.get_by_id(transcript.id) is None
assert await recordings_controller.get_by_id(recording.id) is None
@pytest.mark.asyncio
async def test_transcript_destroy_forbidden(authenticated_client, client):
"""Cannot destroy another user's deleted transcript."""
transcript = await transcripts_controller.add(
name="other-user-destroy",
source_kind=SourceKind.FILE,
user_id="some-other-user",
)
await transcripts_controller.remove_by_id(transcript.id, user_id="some-other-user")
# Try to destroy as randomuserid (authenticated_client)
response = await client.delete(f"/transcripts/{transcript.id}/destroy")
assert response.status_code == 403
# ---------------------------------------------------------------------------
# Isolation tests — verify unrelated data is NOT deleted
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_transcript_destroy_does_not_delete_meeting(
authenticated_client, client, mock_destroy_storage
):
"""Destroying a transcript must NOT delete its associated meeting."""
room = await rooms_controller.add(
name="room-for-meeting-isolation",
user_id="randomuserid",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
now = datetime.now(timezone.utc)
meeting = await meetings_controller.create(
id="meeting-isolation-test",
room_name=room.name,
room_url="https://example.com/room",
host_room_url="https://example.com/room-host",
start_date=now,
end_date=now + timedelta(hours=1),
room=room,
)
recording = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="meeting-iso.mp4",
recorded_at=now,
meeting_id=meeting.id,
)
)
transcript = await transcripts_controller.add(
name="transcript-with-meeting",
source_kind=SourceKind.ROOM,
recording_id=recording.id,
meeting_id=meeting.id,
room_id=room.id,
user_id="randomuserid",
)
# Soft-delete then destroy
await transcripts_controller.remove_by_id(transcript.id, user_id="randomuserid")
response = await client.delete(f"/transcripts/{transcript.id}/destroy")
assert response.status_code == 200
# Transcript and recording are gone
assert await transcripts_controller.get_by_id(transcript.id) is None
assert await recordings_controller.get_by_id(recording.id) is None
# Meeting still exists
m = await meetings_controller.get_by_id(meeting.id)
assert m is not None
assert m.id == meeting.id
@pytest.mark.asyncio
async def test_transcript_destroy_does_not_affect_other_transcripts(
authenticated_client, client, mock_destroy_storage
):
"""Destroying one transcript must not affect another transcript or its recording."""
user_id = "randomuserid"
rec1 = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="sibling1.mp4",
recorded_at=datetime.now(timezone.utc),
)
)
rec2 = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="sibling2.mp4",
recorded_at=datetime.now(timezone.utc),
)
)
t1 = await transcripts_controller.add(
name="sibling-1",
source_kind=SourceKind.FILE,
recording_id=rec1.id,
user_id=user_id,
)
t2 = await transcripts_controller.add(
name="sibling-2",
source_kind=SourceKind.FILE,
recording_id=rec2.id,
user_id=user_id,
)
# Soft-delete and destroy t1
await transcripts_controller.remove_by_id(t1.id, user_id=user_id)
response = await client.delete(f"/transcripts/{t1.id}/destroy")
assert response.status_code == 200
# t1 and rec1 gone
assert await transcripts_controller.get_by_id(t1.id) is None
assert await recordings_controller.get_by_id(rec1.id) is None
# t2 and rec2 untouched
t2_after = await transcripts_controller.get_by_id(t2.id)
assert t2_after is not None
assert t2_after.deleted_at is None
rec2_after = await recordings_controller.get_by_id(rec2.id)
assert rec2_after is not None
assert rec2_after.deleted_at is None
@pytest.mark.asyncio
async def test_transcript_destroy_meeting_with_multiple_transcripts(
authenticated_client, client, mock_destroy_storage
):
"""Destroying one transcript from a meeting must not affect the other
transcript, its recording, or the shared meeting."""
user_id = "randomuserid"
room = await rooms_controller.add(
name="room-multi-transcript",
user_id=user_id,
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
now = datetime.now(timezone.utc)
meeting = await meetings_controller.create(
id="meeting-multi-transcript-test",
room_name=room.name,
room_url="https://example.com/room",
host_room_url="https://example.com/room-host",
start_date=now,
end_date=now + timedelta(hours=1),
room=room,
)
rec1 = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="multi1.mp4",
recorded_at=now,
meeting_id=meeting.id,
)
)
rec2 = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="multi2.mp4",
recorded_at=now,
meeting_id=meeting.id,
)
)
t1 = await transcripts_controller.add(
name="multi-t1",
source_kind=SourceKind.ROOM,
recording_id=rec1.id,
meeting_id=meeting.id,
room_id=room.id,
user_id=user_id,
)
t2 = await transcripts_controller.add(
name="multi-t2",
source_kind=SourceKind.ROOM,
recording_id=rec2.id,
meeting_id=meeting.id,
room_id=room.id,
user_id=user_id,
)
# Soft-delete and destroy t1
await transcripts_controller.remove_by_id(t1.id, user_id=user_id)
response = await client.delete(f"/transcripts/{t1.id}/destroy")
assert response.status_code == 200
# t1 + rec1 gone
assert await transcripts_controller.get_by_id(t1.id) is None
assert await recordings_controller.get_by_id(rec1.id) is None
# t2 + rec2 + meeting all still exist
assert (await transcripts_controller.get_by_id(t2.id)) is not None
assert (await recordings_controller.get_by_id(rec2.id)) is not None
assert (await meetings_controller.get_by_id(meeting.id)) is not None
# ---------------------------------------------------------------------------
# Search tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_search_include_deleted(authenticated_client, client):
"""Search with include_deleted=true returns only deleted transcripts."""
response = await client.post("/transcripts", json={"name": "search-deleted"})
assert response.status_code == 200
tid = response.json()["id"]
# Soft-delete
response = await client.delete(f"/transcripts/{tid}")
assert response.status_code == 200
# Normal search should not include it
response = await client.get("/transcripts/search", params={"q": ""})
assert response.status_code == 200
ids = [r["id"] for r in response.json()["results"]]
assert tid not in ids
# Search with include_deleted should include it
response = await client.get(
"/transcripts/search", params={"q": "", "include_deleted": True}
)
assert response.status_code == 200
ids = [r["id"] for r in response.json()["results"]]
assert tid in ids
@pytest.mark.asyncio
async def test_search_exclude_deleted_by_default(authenticated_client, client):
"""Normal search excludes deleted transcripts by default."""
response = await client.post(
"/transcripts", json={"name": "search-exclude-deleted"}
)
assert response.status_code == 200
tid = response.json()["id"]
# Verify it appears in search
response = await client.get("/transcripts/search", params={"q": ""})
assert response.status_code == 200
ids = [r["id"] for r in response.json()["results"]]
assert tid in ids
# Soft-delete
response = await client.delete(f"/transcripts/{tid}")
assert response.status_code == 200
# Verify it no longer appears in default search
response = await client.get("/transcripts/search", params={"q": ""})
assert response.status_code == 200
ids = [r["id"] for r in response.json()["results"]]
assert tid not in ids

72
server/uv.lock generated
View File

@@ -74,7 +74,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -85,42 +85,42 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
{ url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
{ url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
{ url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
{ url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
{ url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
{ url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
{ url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
{ url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
]
[[package]]

View File

@@ -34,11 +34,11 @@ export default function DeleteTranscriptDialog({
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Delete transcript
Move to Trash
</Dialog.Header>
<Dialog.Body>
Are you sure you want to delete this transcript? This action cannot
be undone.
This transcript will be moved to the trash. You can restore it later
from the Trash view.
{title && (
<Text mt={3} fontWeight="600">
{title}
@@ -71,7 +71,7 @@ export default function DeleteTranscriptDialog({
ml={3}
disabled={!!isLoading}
>
Delete
Move to Trash
</Button>
</Dialog.Footer>
</Dialog.Content>

View File

@@ -0,0 +1,83 @@
import React from "react";
import { Button, Dialog, Text } from "@chakra-ui/react";
interface DestroyTranscriptDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
cancelRef: React.RefObject<any>;
isLoading?: boolean;
title?: string;
date?: string;
source?: string;
}
export default function DestroyTranscriptDialog({
isOpen,
onClose,
onConfirm,
cancelRef,
isLoading,
title,
date,
source,
}: DestroyTranscriptDialogProps) {
return (
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) onClose();
}}
initialFocusEl={() => cancelRef.current}
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Permanently Destroy Transcript
</Dialog.Header>
<Dialog.Body>
<Text color="red.600" fontWeight="medium">
This will permanently delete this transcript and all its
associated audio files. This action cannot be undone.
</Text>
{title && (
<Text mt={3} fontWeight="600">
{title}
</Text>
)}
{date && (
<Text color="gray.600" fontSize="sm">
Date: {date}
</Text>
)}
{source && (
<Text color="gray.600" fontSize="sm">
Source: {source}
</Text>
)}
</Dialog.Body>
<Dialog.Footer>
<Button
ref={cancelRef as any}
onClick={onClose}
disabled={!!isLoading}
variant="outline"
colorPalette="gray"
>
Cancel
</Button>
<Button
colorPalette="red"
onClick={onConfirm}
ml={3}
disabled={!!isLoading}
>
Destroy
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
);
}

View File

@@ -1,8 +1,9 @@
"use client";
import React from "react";
import { Box, Stack, Link, Heading } from "@chakra-ui/react";
import { Box, Stack, Link, Heading, Flex } from "@chakra-ui/react";
import NextLink from "next/link";
import { LuTrash2 } from "react-icons/lu";
import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
@@ -13,6 +14,9 @@ interface FilterSidebarProps {
selectedSourceKind: SourceKind | null;
selectedRoomId: string;
onFilterChange: (sourceKind: SourceKind | null, roomId: string) => void;
isTrashView: boolean;
onTrashClick: () => void;
isAuthenticated: boolean;
}
export default function FilterSidebar({
@@ -20,6 +24,9 @@ export default function FilterSidebar({
selectedSourceKind,
selectedRoomId,
onFilterChange,
isTrashView,
onTrashClick,
isAuthenticated,
}: FilterSidebarProps) {
const myRooms = rooms.filter((room) => !room.is_shared);
const sharedRooms = rooms.filter((room) => room.is_shared);
@@ -32,8 +39,14 @@ export default function FilterSidebar({
fontSize="sm"
href="#"
onClick={() => onFilterChange(null, "")}
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
color={
!isTrashView && selectedSourceKind === null
? "blue.500"
: "gray.600"
}
fontWeight={
!isTrashView && selectedSourceKind === null ? "bold" : "normal"
}
>
All Transcripts
</Link>
@@ -51,12 +64,16 @@ export default function FilterSidebar({
href="#"
onClick={() => onFilterChange("room", room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
!isTrashView &&
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
!isTrashView &&
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "bold"
: "normal"
}
@@ -79,12 +96,16 @@ export default function FilterSidebar({
href="#"
onClick={() => onFilterChange("room" as SourceKind, room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
!isTrashView &&
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
!isTrashView &&
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "bold"
: "normal"
}
@@ -101,9 +122,15 @@ export default function FilterSidebar({
as={NextLink}
href="#"
onClick={() => onFilterChange("live", "")}
color={selectedSourceKind === "live" ? "blue.500" : "gray.600"}
color={
!isTrashView && selectedSourceKind === "live"
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "live" ? "bold" : "normal"}
fontWeight={
!isTrashView && selectedSourceKind === "live" ? "bold" : "normal"
}
fontSize="sm"
>
Live Transcripts
@@ -112,13 +139,39 @@ export default function FilterSidebar({
as={NextLink}
href="#"
onClick={() => onFilterChange("file", "")}
color={selectedSourceKind === "file" ? "blue.500" : "gray.600"}
color={
!isTrashView && selectedSourceKind === "file"
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "file" ? "bold" : "normal"}
fontWeight={
!isTrashView && selectedSourceKind === "file" ? "bold" : "normal"
}
fontSize="sm"
>
Uploaded Files
</Link>
{isAuthenticated && (
<>
<Box borderBottomWidth="1px" my={2} />
<Link
as={NextLink}
href="#"
onClick={onTrashClick}
color={isTrashView ? "red.600" : "red.500"}
_hover={{ color: "red.400" }}
fontWeight={isTrashView ? "bold" : "normal"}
fontSize="sm"
>
<Flex align="center" gap={1}>
<LuTrash2 />
Trash
</Flex>
</Link>
</>
)}
</Stack>
</Box>
);

View File

@@ -1,17 +1,21 @@
import React from "react";
import { IconButton, Icon, Menu } from "@chakra-ui/react";
import { LuMenu, LuTrash, LuRotateCw } from "react-icons/lu";
import { IconButton, Menu } from "@chakra-ui/react";
import { LuMenu, LuTrash, LuRotateCw, LuUndo2 } from "react-icons/lu";
interface TranscriptActionsMenuProps {
transcriptId: string;
onDelete: (transcriptId: string) => void;
onReprocess: (transcriptId: string) => void;
onDelete?: (transcriptId: string) => void;
onReprocess?: (transcriptId: string) => void;
onRestore?: (transcriptId: string) => void;
onDestroy?: (transcriptId: string) => void;
}
export default function TranscriptActionsMenu({
transcriptId,
onDelete,
onReprocess,
onRestore,
onDestroy,
}: TranscriptActionsMenuProps) {
return (
<Menu.Root closeOnSelect={true} lazyMount={true}>
@@ -22,21 +26,42 @@ export default function TranscriptActionsMenu({
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="reprocess"
onClick={() => onReprocess(transcriptId)}
>
<LuRotateCw /> Reprocess
</Menu.Item>
<Menu.Item
value="delete"
onClick={(e) => {
e.stopPropagation();
onDelete(transcriptId);
}}
>
<LuTrash /> Delete
</Menu.Item>
{onReprocess && (
<Menu.Item
value="reprocess"
onClick={() => onReprocess(transcriptId)}
>
<LuRotateCw /> Reprocess
</Menu.Item>
)}
{onDelete && (
<Menu.Item
value="delete"
onClick={(e) => {
e.stopPropagation();
onDelete(transcriptId);
}}
>
<LuTrash /> Delete
</Menu.Item>
)}
{onRestore && (
<Menu.Item value="restore" onClick={() => onRestore(transcriptId)}>
<LuUndo2 /> Restore
</Menu.Item>
)}
{onDestroy && (
<Menu.Item
value="destroy"
color="red.500"
onClick={(e) => {
e.stopPropagation();
onDestroy(transcriptId);
}}
>
<LuTrash /> Destroy
</Menu.Item>
)}
</Menu.Content>
</Menu.Positioner>
</Menu.Root>

View File

@@ -29,8 +29,11 @@ interface TranscriptCardsProps {
results: SearchResult[];
query: string;
isLoading?: boolean;
onDelete: (transcriptId: string) => void;
onReprocess: (transcriptId: string) => void;
isTrash?: boolean;
onDelete?: (transcriptId: string) => void;
onReprocess?: (transcriptId: string) => void;
onRestore?: (transcriptId: string) => void;
onDestroy?: (transcriptId: string) => void;
}
function highlightText(text: string, query: string): React.ReactNode {
@@ -102,13 +105,19 @@ const transcriptHref = (
function TranscriptCard({
result,
query,
isTrash,
onDelete,
onReprocess,
onRestore,
onDestroy,
}: {
result: SearchResult;
query: string;
onDelete: (transcriptId: string) => void;
onReprocess: (transcriptId: string) => void;
isTrash?: boolean;
onDelete?: (transcriptId: string) => void;
onReprocess?: (transcriptId: string) => void;
onRestore?: (transcriptId: string) => void;
onDestroy?: (transcriptId: string) => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
@@ -136,22 +145,36 @@ function TranscriptCard({
};
return (
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
<Box
borderWidth={1}
p={4}
borderRadius="md"
fontSize="sm"
borderLeftWidth={isTrash ? "3px" : 1}
borderLeftColor={isTrash ? "red.400" : undefined}
bg={isTrash ? "gray.50" : undefined}
>
<Flex justify="space-between" alignItems="flex-start" gap="2">
<Box>
<TranscriptStatusIcon status={result.status} />
</Box>
<Box flex="1">
{/* Title with highlighting and text fragment for deep linking */}
<Link
as={NextLink}
href={transcriptHref(result.id, mainSnippet, query)}
fontWeight="600"
display="block"
mb={2}
>
{highlightText(resultTitle, query)}
</Link>
{/* Title — plain text in trash (deleted transcripts return 404) */}
{isTrash ? (
<Text fontWeight="600" mb={2} color="gray.600">
{highlightText(resultTitle, query)}
</Text>
) : (
<Link
as={NextLink}
href={transcriptHref(result.id, mainSnippet, query)}
fontWeight="600"
display="block"
mb={2}
>
{highlightText(resultTitle, query)}
</Link>
)}
{/* Metadata - Horizontal on desktop, vertical on mobile */}
<Flex
@@ -272,8 +295,10 @@ function TranscriptCard({
</Box>
<TranscriptActionsMenu
transcriptId={result.id}
onDelete={onDelete}
onReprocess={onReprocess}
onDelete={isTrash ? undefined : onDelete}
onReprocess={isTrash ? undefined : onReprocess}
onRestore={isTrash ? onRestore : undefined}
onDestroy={isTrash ? onDestroy : undefined}
/>
</Flex>
</Box>
@@ -284,8 +309,11 @@ export default function TranscriptCards({
results,
query,
isLoading,
isTrash,
onDelete,
onReprocess,
onRestore,
onDestroy,
}: TranscriptCardsProps) {
return (
<Box position="relative">
@@ -315,8 +343,11 @@ export default function TranscriptCards({
key={result.id}
result={result}
query={query}
isTrash={isTrash}
onDelete={onDelete}
onReprocess={onReprocess}
onRestore={onRestore}
onDestroy={onDestroy}
/>
))}
</Stack>

View File

@@ -19,6 +19,7 @@ import {
parseAsStringLiteral,
} from "nuqs";
import { LuX } from "react-icons/lu";
import { toaster } from "../../components/ui/toaster";
import type { components } from "../../reflector-api";
type Room = components["schemas"]["Room"];
@@ -29,6 +30,9 @@ import {
useTranscriptsSearch,
useTranscriptDelete,
useTranscriptProcess,
useTranscriptRestore,
useTranscriptDestroy,
useAuthReady,
} from "../../lib/apiHooks";
import FilterSidebar from "./_components/FilterSidebar";
import Pagination, {
@@ -40,6 +44,7 @@ import Pagination, {
} from "./_components/Pagination";
import TranscriptCards from "./_components/TranscriptCards";
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
import DestroyTranscriptDialog from "./_components/DestroyTranscriptDialog";
import { formatLocalDate } from "../../lib/time";
import { RECORD_A_MEETING_URL } from "../../api/urls";
import { useUserName } from "../../lib/useUserName";
@@ -175,14 +180,17 @@ const UnderSearchFormFilterIndicators: React.FC<{
const EmptyResult: React.FC<{
searchQuery: string;
}> = ({ searchQuery }) => {
isTrash?: boolean;
}> = ({ searchQuery, isTrash }) => {
return (
<Flex flexDir="column" alignItems="center" justifyContent="center" py={8}>
<Text textAlign="center">
{searchQuery
? `No results found for "${searchQuery}". Try adjusting your search terms.`
: "No transcripts found, but you can "}
{!searchQuery && (
{isTrash
? "Trash is empty."
: searchQuery
? `No results found for "${searchQuery}". Try adjusting your search terms.`
: "No transcripts found, but you can "}
{!isTrash && !searchQuery && (
<>
<Link href={RECORD_A_MEETING_URL} color="blue.500">
record a meeting
@@ -196,6 +204,8 @@ const EmptyResult: React.FC<{
};
export default function TranscriptBrowser() {
const { isAuthenticated } = useAuthReady();
const [urlSearchQuery, setUrlSearchQuery] = useQueryState(
"q",
parseAsString.withDefault("").withOptions({ shallow: false }),
@@ -216,6 +226,12 @@ export default function TranscriptBrowser() {
parseAsString.withDefault("").withOptions({ shallow: false }),
);
const [urlTrash, setUrlTrash] = useQueryState(
"trash",
parseAsStringLiteral(["1"] as const).withOptions({ shallow: false }),
);
const isTrashView = urlTrash === "1";
const [urlPage, setPage] = useQueryState(
"page",
parseAsInteger.withDefault(1).withOptions({ shallow: false }),
@@ -231,7 +247,7 @@ export default function TranscriptBrowser() {
return;
}
_setSafePage(maybePage.value);
}, [urlPage]);
}, [urlPage, setPage]);
const pageSize = 20;
@@ -240,11 +256,12 @@ export default function TranscriptBrowser() {
() => ({
q: urlSearchQuery,
extras: {
room_id: urlRoomId || undefined,
source_kind: urlSourceKind || undefined,
room_id: isTrashView ? undefined : urlRoomId || undefined,
source_kind: isTrashView ? undefined : urlSourceKind || undefined,
include_deleted: isTrashView ? true : undefined,
},
}),
[urlSearchQuery, urlRoomId, urlSourceKind],
[urlSearchQuery, urlRoomId, urlSourceKind, isTrashView],
);
const {
@@ -266,35 +283,55 @@ export default function TranscriptBrowser() {
const totalPages = getTotalPages(totalResults, pageSize);
// reset pagination when search results change (detected by total change; good enough approximation)
// reset pagination when search filters change
useEffect(() => {
// operation is idempotent
setPage(FIRST_PAGE).then(() => {});
}, [JSON.stringify(searchFilters)]);
}, [searchFilters, setPage]);
const userName = useUserName();
const [deletionLoading, setDeletionLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const cancelRef = React.useRef(null);
const destroyCancelRef = React.useRef(null);
// Delete (soft-delete / move to trash)
const [transcriptToDeleteId, setTranscriptToDeleteId] =
React.useState<string>();
// Destroy (hard-delete)
const [transcriptToDestroyId, setTranscriptToDestroyId] =
React.useState<string>();
const handleFilterTranscripts = (
sourceKind: SourceKind | null,
roomId: string,
) => {
if (isTrashView) {
setUrlTrash(null);
}
setUrlSourceKind(sourceKind);
setUrlRoomId(roomId);
setPage(1);
};
const handleTrashClick = () => {
setUrlTrash(isTrashView ? null : "1");
setUrlSourceKind(null);
setUrlRoomId(null);
setPage(1);
};
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const onCloseDestroy = () => setTranscriptToDestroyId(undefined);
const deleteTranscript = useTranscriptDelete();
const processTranscript = useTranscriptProcess();
const restoreTranscript = useTranscriptRestore();
const destroyTranscript = useTranscriptDestroy();
const confirmDeleteTranscript = (transcriptId: string) => {
if (deletionLoading) return;
setDeletionLoading(true);
if (actionLoading) return;
setActionLoading(true);
deleteTranscript.mutate(
{
params: {
@@ -303,12 +340,12 @@ export default function TranscriptBrowser() {
},
{
onSuccess: () => {
setDeletionLoading(false);
setActionLoading(false);
onCloseDeletion();
reloadSearch();
},
onError: () => {
setDeletionLoading(false);
setActionLoading(false);
},
},
);
@@ -322,18 +359,83 @@ export default function TranscriptBrowser() {
});
};
const handleRestoreTranscript = (transcriptId: string) => {
if (actionLoading) return;
setActionLoading(true);
restoreTranscript.mutate(
{
params: {
path: { transcript_id: transcriptId },
},
},
{
onSuccess: () => {
setActionLoading(false);
reloadSearch();
toaster.create({
duration: 3000,
render: () => (
<Box bg="green.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Transcript restored</Text>
</Box>
),
});
},
onError: () => {
setActionLoading(false);
},
},
);
};
const confirmDestroyTranscript = (transcriptId: string) => {
if (actionLoading) return;
setActionLoading(true);
destroyTranscript.mutate(
{
params: {
path: { transcript_id: transcriptId },
},
},
{
onSuccess: () => {
setActionLoading(false);
onCloseDestroy();
reloadSearch();
},
onError: () => {
setActionLoading(false);
},
},
);
};
// Dialog data for delete
const transcriptToDelete = results?.find(
(i) => i.id === transcriptToDeleteId,
);
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
const dialogDate = transcriptToDelete?.created_at
const deleteDialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
const deleteDialogDate = transcriptToDelete?.created_at
? formatLocalDate(transcriptToDelete.created_at)
: undefined;
const dialogSource =
const deleteDialogSource =
transcriptToDelete?.source_kind === "room" && transcriptToDelete?.room_id
? transcriptToDelete.room_name || transcriptToDelete.room_id
: transcriptToDelete?.source_kind;
// Dialog data for destroy
const transcriptToDestroy = results?.find(
(i) => i.id === transcriptToDestroyId,
);
const destroyDialogTitle = transcriptToDestroy?.title || "Unnamed Transcript";
const destroyDialogDate = transcriptToDestroy?.created_at
? formatLocalDate(transcriptToDestroy.created_at)
: undefined;
const destroyDialogSource =
transcriptToDestroy?.source_kind === "room" && transcriptToDestroy?.room_id
? transcriptToDestroy.room_name || transcriptToDestroy.room_id
: transcriptToDestroy?.source_kind;
if (searchLoading && results.length === 0) {
return (
<Flex
@@ -361,17 +463,24 @@ export default function TranscriptBrowser() {
mb={4}
>
<Heading size="lg">
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
{(searchLoading || deletionLoading) && <Spinner size="sm" />}
{isTrashView
? "Trash"
: userName
? `${userName}'s Transcriptions`
: "Your Transcriptions"}{" "}
{(searchLoading || actionLoading) && <Spinner size="sm" />}
</Heading>
</Flex>
<Flex flexDir={{ base: "column", md: "row" }}>
<FilterSidebar
rooms={rooms}
selectedSourceKind={urlSourceKind}
selectedRoomId={urlRoomId}
selectedSourceKind={isTrashView ? null : urlSourceKind}
selectedRoomId={isTrashView ? "" : urlRoomId}
onFilterChange={handleFilterTranscripts}
isTrashView={isTrashView}
onTrashClick={handleTrashClick}
isAuthenticated={isAuthenticated}
/>
<Flex
@@ -384,8 +493,8 @@ export default function TranscriptBrowser() {
>
<SearchForm
setPage={setPage}
sourceKind={urlSourceKind}
roomId={urlRoomId}
sourceKind={isTrashView ? null : urlSourceKind}
roomId={isTrashView ? null : urlRoomId}
searchQuery={urlSearchQuery}
setSearchQuery={setUrlSearchQuery}
setSourceKind={setUrlSourceKind}
@@ -406,12 +515,15 @@ export default function TranscriptBrowser() {
results={results}
query={urlSearchQuery}
isLoading={searchLoading}
onDelete={setTranscriptToDeleteId}
onReprocess={handleProcessTranscript}
isTrash={isTrashView}
onDelete={isTrashView ? undefined : setTranscriptToDeleteId}
onReprocess={isTrashView ? undefined : handleProcessTranscript}
onRestore={isTrashView ? handleRestoreTranscript : undefined}
onDestroy={isTrashView ? setTranscriptToDestroyId : undefined}
/>
{!searchLoading && results.length === 0 && (
<EmptyResult searchQuery={urlSearchQuery} />
<EmptyResult searchQuery={urlSearchQuery} isTrash={isTrashView} />
)}
</Flex>
</Flex>
@@ -423,10 +535,24 @@ export default function TranscriptBrowser() {
transcriptToDeleteId && confirmDeleteTranscript(transcriptToDeleteId)
}
cancelRef={cancelRef}
isLoading={deletionLoading}
title={dialogTitle}
date={dialogDate}
source={dialogSource}
isLoading={actionLoading}
title={deleteDialogTitle}
date={deleteDialogDate}
source={deleteDialogSource}
/>
<DestroyTranscriptDialog
isOpen={!!transcriptToDestroyId}
onClose={onCloseDestroy}
onConfirm={() =>
transcriptToDestroyId &&
confirmDestroyTranscript(transcriptToDestroyId)
}
cancelRef={destroyCancelRef}
isLoading={actionLoading}
title={destroyDialogTitle}
date={destroyDialogDate}
source={destroyDialogSource}
/>
</Flex>
);

View File

@@ -212,8 +212,13 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
const showConsentModalRef = useRef(showConsentModal);
showConsentModalRef.current = showConsentModal;
const userEmail =
auth.status === "authenticated" || auth.status === "refreshing"
? auth.user.email
: null;
const { showEmailModal } = useEmailTranscriptDialog({
meetingId: assertMeetingId(meeting.id),
userEmail,
});
const showEmailModalRef = useRef(showEmailModal);
showEmailModalRef.current = showEmailModal;

View File

@@ -136,6 +136,7 @@ export function UserEventsProvider({
switch (msg.event) {
case "TRANSCRIPT_CREATED":
case "TRANSCRIPT_DELETED":
case "TRANSCRIPT_RESTORED":
case "TRANSCRIPT_STATUS":
case "TRANSCRIPT_FINAL_TITLE":
case "TRANSCRIPT_DURATION":

View File

@@ -57,6 +57,7 @@ export function useTranscriptsSearch(
offset?: number;
room_id?: string;
source_kind?: SourceKind;
include_deleted?: boolean;
} = {},
) {
return $api.useQuery(
@@ -70,6 +71,7 @@ export function useTranscriptsSearch(
offset: options.offset,
room_id: options.room_id,
source_kind: options.source_kind,
include_deleted: options.include_deleted,
},
},
},
@@ -105,6 +107,38 @@ export function useTranscriptProcess() {
});
}
export function useTranscriptRestore() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/restore", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
setError(error as Error, "There was an error restoring the transcript");
},
});
}
export function useTranscriptDestroy() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}/destroy", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
setError(error as Error, "There was an error destroying the transcript");
},
});
}
const ACTIVE_TRANSCRIPT_STATUSES = new Set<TranscriptStatus>([
"processing",
"uploaded",

View File

@@ -6,13 +6,15 @@ import { Box, Button, Input, Text, VStack, HStack } from "@chakra-ui/react";
interface EmailTranscriptDialogProps {
onSubmit: (email: string) => void;
onDismiss: () => void;
initialEmail?: string;
}
export function EmailTranscriptDialog({
onSubmit,
onDismiss,
initialEmail,
}: EmailTranscriptDialogProps) {
const [email, setEmail] = useState("");
const [email, setEmail] = useState(initialEmail ?? "");
const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);
useEffect(() => {

View File

@@ -11,10 +11,12 @@ const TOAST_CHECK_INTERVAL_MS = 100;
type UseEmailTranscriptDialogParams = {
meetingId: MeetingId;
userEmail?: string | null;
};
export function useEmailTranscriptDialog({
meetingId,
userEmail,
}: UseEmailTranscriptDialogParams) {
const [modalOpen, setModalOpen] = useState(false);
const addEmailMutation = useMeetingAddEmailRecipient();
@@ -83,6 +85,7 @@ export function useEmailTranscriptDialog({
duration: null,
render: ({ dismiss }) => (
<EmailTranscriptDialog
initialEmail={userEmail ?? undefined}
onSubmit={(email) => {
handleSubmitEmail(email);
dismiss();
@@ -120,7 +123,7 @@ export function useEmailTranscriptDialog({
}
}, TOAST_CHECK_INTERVAL_MS);
});
}, [handleSubmitEmail, modalOpen]);
}, [handleSubmitEmail, modalOpen, userEmail]);
return {
showEmailModal,

View File

@@ -388,6 +388,46 @@ export interface paths {
patch: operations["v1_transcript_update"];
trace?: never;
};
"/v1/transcripts/{transcript_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Transcript Restore
* @description Restore a soft-deleted transcript.
*/
post: operations["v1_transcript_restore"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/destroy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Transcript Destroy
* @description Permanently delete a transcript and all associated files.
*/
delete: operations["v1_transcript_destroy"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/topics": {
parameters: {
query?: never;
@@ -2391,6 +2431,14 @@ export interface components {
*/
title: string;
};
/** UserTranscriptRestoredData */
UserTranscriptRestoredData: {
/**
* Id
* @description A non-empty string
*/
id: string;
};
/** UserTranscriptStatusData */
UserTranscriptStatusData: {
/**
@@ -2446,6 +2494,15 @@ export interface components {
event: "TRANSCRIPT_FINAL_TITLE";
data: components["schemas"]["UserTranscriptFinalTitleData"];
};
/** UserWsTranscriptRestored */
UserWsTranscriptRestored: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT_RESTORED";
data: components["schemas"]["UserTranscriptRestoredData"];
};
/** UserWsTranscriptStatus */
UserWsTranscriptStatus: {
/**
@@ -3293,6 +3350,7 @@ export interface operations {
from?: string | null;
/** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */
to?: string | null;
include_deleted?: boolean;
};
header?: never;
path?: never;
@@ -3427,6 +3485,68 @@ export interface operations {
};
};
};
v1_transcript_restore: {
parameters: {
query?: never;
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeletionStatus"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_destroy: {
parameters: {
query?: never;
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeletionStatus"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_get_topics: {
parameters: {
query?: never;
@@ -3995,9 +4115,7 @@ export interface operations {
};
v1_transcript_get_video_url: {
parameters: {
query?: {
token?: string | null;
};
query?: never;
header?: never;
path: {
transcript_id: string;
@@ -4254,6 +4372,7 @@ export interface operations {
"application/json":
| components["schemas"]["UserWsTranscriptCreated"]
| components["schemas"]["UserWsTranscriptDeleted"]
| components["schemas"]["UserWsTranscriptRestored"]
| components["schemas"]["UserWsTranscriptStatus"]
| components["schemas"]["UserWsTranscriptFinalTitle"]
| components["schemas"]["UserWsTranscriptDuration"];

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 267 B