mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b452b4c437 | |||
| 1e7aeaf8ce | |||
| 501cfc8d0f | |||
| 0308f98ef9 | |||
| 0862203d5d | |||
| ee604abe00 | |||
| 26d668b478 | |||
| 04081f04fc | |||
| f7268c507b | |||
| cadb20793a | |||
| 8a417806e4 | |||
| 223c744e3f | |||
| f67335f306 | |||
| 4eb7f29f8e | |||
| 912bac8756 | |||
| 3c97418a70 | |||
| 26a90456f7 | |||
| d068558c8f | |||
| dac6bfe142 | |||
| 6b3e5d747a | |||
| 3066e3a83c | |||
| 5e05b6b2a5 | |||
| d075d60d19 | |||
| 7c631bb76f | |||
| 59cd1f766a | |||
| f0a17b1ce1 | |||
| 17c486f7bf | |||
| 85bde9633f | |||
| 0eb715b0cd | |||
| b0617dcfed | |||
| 6c4c44e933 | |||
| 16dde229cc | |||
| d01d6dd9d1 | |||
| 2a39978245 | |||
| d85ed031a9 | |||
| 5c8c81f97b | |||
| 3368a9b6af | |||
| e87d0519ff | |||
| 731f0471aa | |||
| e458272745 | |||
| d71e417d58 |
+126
-120
@@ -1,6 +1,6 @@
|
|||||||
# Installation on a k8s cluster
|
# Installation on a k8s cluster
|
||||||
|
|
||||||
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
|
This document is a step-by-step guide that describes how to install LaSuite Meet on a k8s cluster without AI features.
|
||||||
|
|
||||||
## Prerequisites for a kubernetes setup
|
## Prerequisites for a kubernetes setup
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, excep
|
|||||||
|
|
||||||
### What will you use to authenticate your users ?
|
### What will you use to authenticate your users ?
|
||||||
|
|
||||||
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
LaSuite Meet uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus LaSuite Meet) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||||
|
|
||||||
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
|
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
|
||||||
|
|
||||||
@@ -134,6 +134,8 @@ keycloak-0 1/1 Running 0 6m48s
|
|||||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
|
||||||
|
|
||||||
From here the important information you will need are :
|
From here the important information you will need are :
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -152,7 +154,7 @@ You can find these values in **examples/keycloak.values.yaml**
|
|||||||
|
|
||||||
### Find livekit server connexion values
|
### Find livekit server connexion values
|
||||||
|
|
||||||
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
|
LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
|
||||||
|
|
||||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||||
|
|
||||||
@@ -194,7 +196,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
|||||||
|
|
||||||
### Find postgresql connexion values
|
### Find postgresql connexion values
|
||||||
|
|
||||||
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||||
@@ -222,7 +224,7 @@ POSTGRES_PASSWORD: pass
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||||
@@ -243,123 +245,127 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
|
|||||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||||
```
|
```
|
||||||
|
|
||||||
You can use Visio on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
||||||
|
|
||||||
## All options
|
## All options
|
||||||
|
|
||||||
These are the environmental options available on meet backend.
|
These are the environmental options available on meet backend.
|
||||||
|
|
||||||
| Option | Description | default |
|
| Option | Description | default |
|
||||||
| ----------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| DATA_DIR | Data directory location | /data |
|
| DATA_DIR | Data directory location | /data |
|
||||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||||
| DB_NAME | Name of the database | meet |
|
| DB_NAME | Name of the database | meet |
|
||||||
| DB_USER | User used to connect to database | dinum |
|
| DB_USER | User used to connect to database | dinum |
|
||||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||||
| DB_HOST | Hostname of the database | localhost |
|
| DB_HOST | Hostname of the database | localhost |
|
||||||
| DB_PORT | Port to connect to database | 5432 |
|
| DB_PORT | Port to connect to database | 5432 |
|
||||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||||
| AWS_S3_REGION_NAME | S3 region | |
|
| AWS_S3_REGION_NAME | S3 region | |
|
||||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||||
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
||||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||||
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
|
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
|
||||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
| DJANGO_EMAIL_HOST | Host of the email server | |
|
| DJANGO_EMAIL_HOST | Host of the email server | |
|
||||||
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
|
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
|
||||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
|
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
|
||||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||||
| EMAIL_BRAND_NAME | Email branding name | |
|
| EMAIL_BRAND_NAME | Email branding name | |
|
||||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||||
| EMAIL_LOGO_IMG | Email logo image | |
|
| EMAIL_LOGO_IMG | Email logo image | |
|
||||||
| EMAIL_DOMAIN | Email domain | |
|
| EMAIL_DOMAIN | Email domain | |
|
||||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||||
| SENTRY_DSN | Sentry server DSN | |
|
| SENTRY_DSN | Sentry server DSN | |
|
||||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||||
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
|
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
|
||||||
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
|
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||||
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
|
||||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
|
||||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
|
||||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||||
| RECORDING_ENABLE | Record meeting option | false |
|
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
| RECORDING_ENABLE | Record meeting option | false |
|
||||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
||||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||||
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
|
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||||
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
|
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||||
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
|
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||||
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
|
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||||
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
|
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
|
||||||
|
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
|
||||||
|
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
|
||||||
|
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
|
||||||
|
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ def get_frontend_configuration(request):
|
|||||||
"is_enabled": settings.RECORDING_ENABLE,
|
"is_enabled": settings.RECORDING_ENABLE,
|
||||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
|
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||||
},
|
},
|
||||||
"telephony": {
|
"telephony": {
|
||||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ class NotificationService:
|
|||||||
"filename": recording.key,
|
"filename": recording.key,
|
||||||
"email": owner_access.user.email,
|
"email": owner_access.user.email,
|
||||||
"sub": owner_access.user.sub,
|
"sub": owner_access.user.sub,
|
||||||
|
"room": recording.room.name,
|
||||||
|
"recording_date": recording.created_at.astimezone(
|
||||||
|
owner_access.user.timezone
|
||||||
|
).strftime("%Y-%m-%d"),
|
||||||
|
"recording_time": recording.created_at.astimezone(
|
||||||
|
owner_access.user.timezone
|
||||||
|
).strftime("%H:%M"),
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Recording-related LiveKit Events Service"""
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from core import models, utils
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingEventsError(Exception):
|
||||||
|
"""Recording event handling fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingEventsService:
|
||||||
|
"""Handles recording-related Livekit webhook events."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_limit_reached(recording):
|
||||||
|
"""Stop recording and notify participants when limit is reached."""
|
||||||
|
|
||||||
|
recording.status = models.RecordingStatusChoices.STOPPED
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
notification_mapping = {
|
||||||
|
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||||
|
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||||
|
}
|
||||||
|
|
||||||
|
notification_type = notification_mapping.get(recording.mode)
|
||||||
|
if not notification_type:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
utils.notify_participants(
|
||||||
|
room_name=str(recording.room.id),
|
||||||
|
notification_data={"type": notification_type},
|
||||||
|
)
|
||||||
|
except utils.NotificationError as e:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to notify participants about recording limit reached: "
|
||||||
|
"room=%s, recording_id=%s, mode=%s",
|
||||||
|
recording.room.id,
|
||||||
|
recording.id,
|
||||||
|
recording.mode,
|
||||||
|
)
|
||||||
|
raise RecordingEventsError(
|
||||||
|
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||||
|
f"recording limit reached (recording_id={recording.id})"
|
||||||
|
) from e
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""LiveKit Events Service"""
|
"""LiveKit Events Service"""
|
||||||
|
|
||||||
|
# pylint: disable=E1101
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
@@ -9,6 +11,10 @@ from django.conf import settings
|
|||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsError,
|
||||||
|
RecordingEventsService,
|
||||||
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .telephony import TelephonyException, TelephonyService
|
from .telephony import TelephonyException, TelephonyService
|
||||||
@@ -84,6 +90,7 @@ class LiveKitEventsService:
|
|||||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||||
self.lobby_service = LobbyService()
|
self.lobby_service = LobbyService()
|
||||||
self.telephony_service = TelephonyService()
|
self.telephony_service = TelephonyService()
|
||||||
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
def receive(self, request):
|
def receive(self, request):
|
||||||
"""Process webhook and route to appropriate handler."""
|
"""Process webhook and route to appropriate handler."""
|
||||||
@@ -115,6 +122,29 @@ class LiveKitEventsService:
|
|||||||
# pylint: disable=not-callable
|
# pylint: disable=not-callable
|
||||||
handler(data)
|
handler(data)
|
||||||
|
|
||||||
|
def _handle_egress_ended(self, data):
|
||||||
|
"""Handle 'egress_ended' event."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(
|
||||||
|
worker_id=data.egress_info.egress_id
|
||||||
|
)
|
||||||
|
except models.Recording.DoesNotExist as err:
|
||||||
|
raise ActionFailedError(
|
||||||
|
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
self.recording_events.handle_limit_reached(recording)
|
||||||
|
except RecordingEventsError as e:
|
||||||
|
raise ActionFailedError(
|
||||||
|
f"Failed to process limit reached event for recording {recording}"
|
||||||
|
) from e
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Lobby Service"""
|
"""Lobby Service"""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -11,13 +10,6 @@ from uuid import UUID
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from livekit.api import ( # pylint: disable=E0611
|
|
||||||
ListRoomsRequest,
|
|
||||||
SendDataRequest,
|
|
||||||
TwirpError,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
|
|||||||
"""Raised when participant is not found."""
|
"""Raised when participant is not found."""
|
||||||
|
|
||||||
|
|
||||||
class LobbyNotificationError(LobbyError):
|
|
||||||
"""Raised when LiveKit notification fails."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LobbyParticipant:
|
class LobbyParticipant:
|
||||||
"""Participant in a lobby system."""
|
"""Participant in a lobby system."""
|
||||||
@@ -211,9 +199,6 @@ class LobbyService:
|
|||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
Create a new participant entry in waiting status and notify room
|
||||||
participants of the new entry request.
|
participants of the new entry request.
|
||||||
|
|
||||||
Raises:
|
|
||||||
LobbyNotificationError: If room notification fails
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
color = utils.generate_color(participant_id)
|
color = utils.generate_color(participant_id)
|
||||||
@@ -226,10 +211,15 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.notify_participants(room_id=room_id)
|
utils.notify_participants(
|
||||||
except LobbyNotificationError:
|
room_name=str(room_id),
|
||||||
|
notification_data={
|
||||||
|
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except utils.NotificationError:
|
||||||
# If room not created yet, there is no participants to notify
|
# If room not created yet, there is no participants to notify
|
||||||
pass
|
logger.exception("Failed to notify room participants")
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -334,44 +324,6 @@ class LobbyService:
|
|||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def notify_participants(self, room_id: UUID):
|
|
||||||
"""Notify room participants about a new waiting participant using LiveKit.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
LobbyNotificationError: If notification fails to send
|
|
||||||
"""
|
|
||||||
|
|
||||||
notification_data = {
|
|
||||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
|
||||||
}
|
|
||||||
|
|
||||||
lkapi = utils.create_livekit_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
room_response = await lkapi.room.list_rooms(
|
|
||||||
ListRoomsRequest(
|
|
||||||
names=[str(room_id)],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if the room exists
|
|
||||||
if not room_response.rooms:
|
|
||||||
return
|
|
||||||
|
|
||||||
await lkapi.room.send_data(
|
|
||||||
SendDataRequest(
|
|
||||||
room=str(room_id),
|
|
||||||
data=json.dumps(notification_data).encode("utf-8"),
|
|
||||||
kind="RELIABLE",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except TwirpError as e:
|
|
||||||
logger.exception("Failed to notify room participants")
|
|
||||||
raise LobbyNotificationError("Failed to notify room participants") from e
|
|
||||||
finally:
|
|
||||||
await lkapi.aclose()
|
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Test RecordingEventsService service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.factories import RecordingFactory
|
||||||
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsError,
|
||||||
|
RecordingEventsService,
|
||||||
|
)
|
||||||
|
from core.utils import NotificationError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Initialize RecordingEventsService."""
|
||||||
|
return RecordingEventsService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "notification_type"),
|
||||||
|
(
|
||||||
|
("screen_recording", "screenRecordingLimitReached"),
|
||||||
|
("transcript", "transcriptionLimitReached"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
||||||
|
"""Test handle_limit_reached stops recording and notifies participants."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
|
service.handle_limit_reached(recording)
|
||||||
|
|
||||||
|
assert recording.status == "stopped"
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "notification_type"),
|
||||||
|
(
|
||||||
|
("screen_recording", "screenRecordingLimitReached"),
|
||||||
|
("transcript", "transcriptionLimitReached"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||||
|
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||||
|
|
||||||
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RecordingEventsError,
|
||||||
|
match=r"Failed to notify participants in room '.+' "
|
||||||
|
r"about recording limit reached \(recording_id=.+\)",
|
||||||
|
):
|
||||||
|
service.handle_limit_reached(recording)
|
||||||
|
|
||||||
|
assert recording.status == "stopped"
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
|
)
|
||||||
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
):
|
):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
):
|
):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
|
|
||||||
# Mock external service calls to isolate the test
|
# Mock external service calls to isolate the test
|
||||||
with (
|
with (
|
||||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
):
|
):
|
||||||
# Make request as a new anonymous user
|
# Make request as a new anonymous user
|
||||||
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||||
),
|
),
|
||||||
@@ -255,7 +255,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
LobbyService,
|
LobbyService,
|
||||||
"_get_or_create_participant_id",
|
"_get_or_create_participant_id",
|
||||||
@@ -315,7 +315,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
|
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Test LiveKitEvents service.
|
Test LiveKitEvents service.
|
||||||
"""
|
"""
|
||||||
# pylint: disable=W0621,W0613, W0212
|
# pylint: disable=W0621,W0613, W0212, E0611
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RecordingFactory, RoomFactory
|
||||||
|
from core.recording.services.recording_events import RecordingEventsService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
ActionFailedError,
|
ActionFailedError,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
@@ -19,6 +21,7 @@ from core.services.livekit_events import (
|
|||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.telephony import TelephonyException, TelephonyService
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -55,6 +58,107 @@ def test_initialization(
|
|||||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||||
assert isinstance(service.lobby_service, LobbyService)
|
assert isinstance(service.lobby_service, LobbyService)
|
||||||
|
assert isinstance(service.telephony_service, TelephonyService)
|
||||||
|
assert isinstance(service.recording_events, RecordingEventsService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "notification_type"),
|
||||||
|
(
|
||||||
|
("screen_recording", "screenRecordingLimitReached"),
|
||||||
|
("transcript", "transcriptionLimitReached"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
|
||||||
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_egress_ended_notification_fails(mock_notify, service):
|
||||||
|
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ActionFailedError,
|
||||||
|
match=r"Failed to process limit reached event for recording .+",
|
||||||
|
):
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_egress_ended_recording_not_found(mock_notify, service):
|
||||||
|
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = "worker-2"
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
|
||||||
|
):
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_egress_ended_recording_not_active(mock_notify, service):
|
||||||
|
"""Should ignore non-active recordings."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = "worker-1"
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "failed_to_stop"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
|
||||||
|
"""Should ignore egress non-limit-reached statuses."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = "worker-1"
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Test lobby service.
|
|||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913
|
||||||
# ruff: noqa: PLR0913
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -14,18 +13,17 @@ from django.core.cache import cache
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
|
||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoomAccessLevel
|
||||||
from core.services.lobby import (
|
from core.services.lobby import (
|
||||||
LobbyNotificationError,
|
|
||||||
LobbyParticipant,
|
LobbyParticipant,
|
||||||
LobbyParticipantNotFound,
|
LobbyParticipantNotFound,
|
||||||
LobbyParticipantParsingError,
|
LobbyParticipantParsingError,
|
||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -414,7 +412,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
|||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_enter_success(
|
def test_enter_success(
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
@@ -443,13 +441,15 @@ def test_enter_success(
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
mock_notify.assert_called_once_with(room_id=room.id)
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(room.id), notification_data={"type": "participantWaiting"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_enter_with_notification_error(
|
def test_enter_with_notification_error(
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
@@ -460,7 +460,7 @@ def test_enter_with_notification_error(
|
|||||||
):
|
):
|
||||||
"""Test participant entry with notification error."""
|
"""Test participant entry with notification error."""
|
||||||
mock_generate_color.return_value = "#123456"
|
mock_generate_color.return_value = "#123456"
|
||||||
mock_notify.side_effect = LobbyNotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -776,116 +776,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
|
|
||||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
# Set up the mock LiveKitAPI and its behavior
|
|
||||||
mock_api_instance = mock.Mock()
|
|
||||||
mock_api_instance.room = mock.Mock()
|
|
||||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
|
||||||
|
|
||||||
# Create a proper response object with an empty rooms list
|
|
||||||
class MockResponse:
|
|
||||||
"""LiveKit API response mock with empty rooms list."""
|
|
||||||
|
|
||||||
rooms = []
|
|
||||||
|
|
||||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
|
||||||
mock_api_instance.aclose = mock.AsyncMock()
|
|
||||||
mock_create_livekit_client.return_value = mock_api_instance
|
|
||||||
|
|
||||||
# Act
|
|
||||||
lobby_service.notify_participants(room.id)
|
|
||||||
|
|
||||||
# Verify that the service checked for existing rooms
|
|
||||||
mock_api_instance.room.list_rooms.assert_called_once()
|
|
||||||
|
|
||||||
# Verify the send_data method was not called since no room exists
|
|
||||||
mock_api_instance.room.send_data.assert_not_called()
|
|
||||||
|
|
||||||
# Verify the connection was properly closed
|
|
||||||
mock_api_instance.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
|
||||||
"""Test successful participant notification."""
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
# Set up the mock LiveKitAPI and its behavior
|
|
||||||
mock_api_instance = mock.Mock()
|
|
||||||
mock_api_instance.room = mock.Mock()
|
|
||||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
|
||||||
|
|
||||||
class MockResponse:
|
|
||||||
"""LiveKit API response mock with non-empty rooms list."""
|
|
||||||
|
|
||||||
rooms = ["room-1"]
|
|
||||||
|
|
||||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
|
||||||
|
|
||||||
mock_api_instance.aclose = mock.AsyncMock()
|
|
||||||
mock_create_livekit_client.return_value = mock_api_instance
|
|
||||||
|
|
||||||
# Call the function
|
|
||||||
lobby_service.notify_participants(room.id)
|
|
||||||
|
|
||||||
# Verify that the service checked for existing rooms
|
|
||||||
mock_api_instance.room.list_rooms.assert_called_once()
|
|
||||||
|
|
||||||
# Verify the send_data method was called
|
|
||||||
mock_api_instance.room.send_data.assert_called_once()
|
|
||||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
|
||||||
assert send_data_request.room == str(room.id)
|
|
||||||
assert (
|
|
||||||
json.loads(send_data_request.data.decode("utf-8"))["type"]
|
|
||||||
== settings.LOBBY_NOTIFICATION_TYPE
|
|
||||||
)
|
|
||||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
|
||||||
|
|
||||||
# Verify aclose was called
|
|
||||||
mock_api_instance.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
|
||||||
"""Test participant notification with API error."""
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
# Set up the mock LiveKitAPI and its behavior
|
|
||||||
mock_api_instance = mock.Mock()
|
|
||||||
mock_api_instance.room = mock.Mock()
|
|
||||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(msg="test error", code=123, status=123)
|
|
||||||
)
|
|
||||||
|
|
||||||
class MockResponse:
|
|
||||||
"""LiveKit API response mock with non-empty rooms list."""
|
|
||||||
|
|
||||||
rooms = ["room-1"]
|
|
||||||
|
|
||||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
|
||||||
|
|
||||||
mock_api_instance.aclose = mock.AsyncMock()
|
|
||||||
mock_create_livekit_client.return_value = mock_api_instance
|
|
||||||
|
|
||||||
# Call the function and expect an exception
|
|
||||||
with pytest.raises(
|
|
||||||
LobbyNotificationError, match="Failed to notify room participants"
|
|
||||||
):
|
|
||||||
lobby_service.notify_participants(room.id)
|
|
||||||
|
|
||||||
# Verify that the service checked for existing rooms
|
|
||||||
mock_api_instance.room.list_rooms.assert_called_once()
|
|
||||||
|
|
||||||
# Verify send_data was called
|
|
||||||
mock_api_instance.room.send_data.assert_called_once()
|
|
||||||
|
|
||||||
# Verify aclose was still called after the exception
|
|
||||||
mock_api_instance.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_room_cache(settings, lobby_service):
|
def test_clear_room_cache(settings, lobby_service):
|
||||||
"""Test clearing room cache actually removes entries from cache."""
|
"""Test clearing room cache actually removes entries from cache."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from core.utils import create_livekit_client
|
import pytest
|
||||||
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
|
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
@@ -60,3 +64,105 @@ def test_create_livekit_client_custom_configuration(
|
|||||||
create_livekit_client(custom_configuration)
|
create_livekit_client(custom_configuration)
|
||||||
|
|
||||||
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_notify_participants_error(mock_create_livekit_client):
|
||||||
|
"""Test participant notification with API error."""
|
||||||
|
|
||||||
|
# Set up the mock LiveKitAPI and its behavior
|
||||||
|
mock_api_instance = mock.Mock()
|
||||||
|
mock_api_instance.room = mock.Mock()
|
||||||
|
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError(msg="test error", code=123, status=123)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
"""LiveKit API response mock with non-empty rooms list."""
|
||||||
|
|
||||||
|
rooms = ["room-1"]
|
||||||
|
|
||||||
|
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||||
|
|
||||||
|
mock_api_instance.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api_instance
|
||||||
|
|
||||||
|
# Call the function and expect an exception
|
||||||
|
with pytest.raises(NotificationError, match="Failed to notify room participants"):
|
||||||
|
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||||
|
|
||||||
|
# Verify that the service checked for existing rooms
|
||||||
|
mock_api_instance.room.list_rooms.assert_called_once()
|
||||||
|
|
||||||
|
# Verify send_data was called
|
||||||
|
mock_api_instance.room.send_data.assert_called_once()
|
||||||
|
|
||||||
|
# Verify aclose was still called after the exception
|
||||||
|
mock_api_instance.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_notify_participants_success_no_room(mock_create_livekit_client):
|
||||||
|
"""Test the notify_participants function when the LiveKit room doesn't exist."""
|
||||||
|
|
||||||
|
# Set up the mock LiveKitAPI and its behavior
|
||||||
|
mock_api_instance = mock.Mock()
|
||||||
|
mock_api_instance.room = mock.Mock()
|
||||||
|
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||||
|
|
||||||
|
# Create a proper response object with an empty rooms list
|
||||||
|
class MockResponse:
|
||||||
|
"""LiveKit API response mock with empty rooms list."""
|
||||||
|
|
||||||
|
rooms = []
|
||||||
|
|
||||||
|
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||||
|
mock_api_instance.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api_instance
|
||||||
|
|
||||||
|
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||||
|
|
||||||
|
# Verify that the service checked for existing rooms
|
||||||
|
mock_api_instance.room.list_rooms.assert_called_once()
|
||||||
|
|
||||||
|
# Verify the send_data method was not called since no room exists
|
||||||
|
mock_api_instance.room.send_data.assert_not_called()
|
||||||
|
|
||||||
|
# Verify the connection was properly closed
|
||||||
|
mock_api_instance.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_notify_participants_success(mock_create_livekit_client):
|
||||||
|
"""Test successful participant notification."""
|
||||||
|
|
||||||
|
# Set up the mock LiveKitAPI and its behavior
|
||||||
|
mock_api_instance = mock.Mock()
|
||||||
|
mock_api_instance.room = mock.Mock()
|
||||||
|
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
"""LiveKit API response mock with non-empty rooms list."""
|
||||||
|
|
||||||
|
rooms = ["room-1"]
|
||||||
|
|
||||||
|
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||||
|
|
||||||
|
mock_api_instance.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api_instance
|
||||||
|
|
||||||
|
# Call the function
|
||||||
|
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||||
|
|
||||||
|
# Verify that the service checked for existing rooms
|
||||||
|
mock_api_instance.room.list_rooms.assert_called_once()
|
||||||
|
|
||||||
|
# Verify the send_data method was called
|
||||||
|
mock_api_instance.room.send_data.assert_called_once()
|
||||||
|
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||||
|
assert send_data_request.room == "room-number-1"
|
||||||
|
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
|
||||||
|
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||||
|
|
||||||
|
# Verify aclose was called
|
||||||
|
mock_api_instance.aclose.assert_called_once()
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ from django.core.files.storage import default_storage
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import botocore
|
import botocore
|
||||||
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
from asgiref.sync import async_to_sync
|
||||||
|
from livekit.api import ( # pylint: disable=E0611
|
||||||
|
AccessToken,
|
||||||
|
ListRoomsRequest,
|
||||||
|
LiveKitAPI,
|
||||||
|
SendDataRequest,
|
||||||
|
TwirpError,
|
||||||
|
VideoGrants,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_color(identity: str) -> str:
|
def generate_color(identity: str) -> str:
|
||||||
@@ -158,3 +166,37 @@ def create_livekit_client(custom_configuration=None):
|
|||||||
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
|
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
|
||||||
|
|
||||||
return LiveKitAPI(session=custom_session, **configuration)
|
return LiveKitAPI(session=custom_session, **configuration)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationError(Exception):
|
||||||
|
"""Notification delivery to room participants fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@async_to_sync
|
||||||
|
async def notify_participants(room_name: str, notification_data: dict):
|
||||||
|
"""Send notification data to all participants in a LiveKit room."""
|
||||||
|
|
||||||
|
lkapi = create_livekit_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
room_response = await lkapi.room.list_rooms(
|
||||||
|
ListRoomsRequest(
|
||||||
|
names=[room_name],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the room exists
|
||||||
|
if not room_response.rooms:
|
||||||
|
return
|
||||||
|
|
||||||
|
await lkapi.room.send_data(
|
||||||
|
SendDataRequest(
|
||||||
|
room=room_name,
|
||||||
|
data=json.dumps(notification_data).encode("utf-8"),
|
||||||
|
kind="RELIABLE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TwirpError as e:
|
||||||
|
raise NotificationError("Failed to notify room participants") from e
|
||||||
|
finally:
|
||||||
|
await lkapi.aclose()
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -29,15 +29,15 @@ msgstr "Berechtigungen"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Wichtige Daten"
|
msgstr "Wichtige Daten"
|
||||||
|
|
||||||
#: core/admin.py:143
|
#: core/admin.py:147
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:146
|
#: core/admin.py:150
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
msgstr "Mehrere Eigentümer"
|
||||||
|
|
||||||
#: core/api/serializers.py:63
|
#: core/api/serializers.py:67
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
@@ -251,7 +251,8 @@ msgstr "PIN-Code für den Raum"
|
|||||||
|
|
||||||
#: core/models.py:392
|
#: core/models.py:392
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
msgstr ""
|
||||||
|
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||||
|
|
||||||
#: core/models.py:398 core/models.py:552
|
#: core/models.py:398 core/models.py:552
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
@@ -366,9 +367,9 @@ msgid "Make sure you have a stable internet connection"
|
|||||||
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:248
|
#: core/templates/mail/html/invitation.html:248
|
||||||
#: core/templates/mail/html/screen_recording.html:240
|
#: core/templates/mail/html/screen_recording.html:245
|
||||||
#: core/templates/mail/text/invitation.txt:21
|
#: core/templates/mail/text/invitation.txt:21
|
||||||
#: core/templates/mail/text/screen_recording.txt:22
|
#: core/templates/mail/text/screen_recording.txt:23
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid " Thank you for using %(brandname)s. "
|
msgid " Thank you for using %(brandname)s. "
|
||||||
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
||||||
@@ -394,33 +395,41 @@ msgstr ""
|
|||||||
msgid " The recording will expire in %(days)s days. "
|
msgid " The recording will expire in %(days)s days. "
|
||||||
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:201
|
#: core/templates/mail/html/screen_recording.html:200
|
||||||
#: core/templates/mail/text/screen_recording.txt:10
|
#: core/templates/mail/text/screen_recording.txt:9
|
||||||
|
msgid ""
|
||||||
|
" Sharing the recording via link is not yet available. Only organizers can "
|
||||||
|
"download it. "
|
||||||
|
msgstr ""
|
||||||
|
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
|
||||||
|
|
||||||
|
#: core/templates/mail/html/screen_recording.html:206
|
||||||
|
#: core/templates/mail/text/screen_recording.txt:11
|
||||||
msgid "To keep this recording permanently:"
|
msgid "To keep this recording permanently:"
|
||||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:203
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:12
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"Open\" button below "
|
msgid "Click the \"Open\" button below "
|
||||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:204
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: core/templates/mail/text/screen_recording.txt:14
|
||||||
msgid "Use the \"Download\" button in the interface "
|
msgid "Use the \"Download\" button in the interface "
|
||||||
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:205
|
#: core/templates/mail/html/screen_recording.html:210
|
||||||
#: core/templates/mail/text/screen_recording.txt:14
|
#: core/templates/mail/text/screen_recording.txt:15
|
||||||
msgid "Save the file to your preferred location"
|
msgid "Save the file to your preferred location"
|
||||||
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:216
|
#: core/templates/mail/html/screen_recording.html:221
|
||||||
#: core/templates/mail/text/screen_recording.txt:16
|
#: core/templates/mail/text/screen_recording.txt:17
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Öffnen"
|
msgstr "Öffnen"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:225
|
#: core/templates/mail/html/screen_recording.html:230
|
||||||
#: core/templates/mail/text/screen_recording.txt:18
|
#: core/templates/mail/text/screen_recording.txt:19
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
@@ -429,18 +438,18 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:162
|
#: meet/settings.py:163
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:163
|
#: meet/settings.py:164
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:164
|
#: meet/settings.py:165
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:165
|
#: meet/settings.py:166
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -29,15 +29,15 @@ msgstr "Permissions"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Important dates"
|
msgstr "Important dates"
|
||||||
|
|
||||||
#: core/admin.py:143
|
#: core/admin.py:147
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "No owner"
|
msgstr "No owner"
|
||||||
|
|
||||||
#: core/admin.py:146
|
#: core/admin.py:150
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Multiple owners"
|
msgstr "Multiple owners"
|
||||||
|
|
||||||
#: core/api/serializers.py:63
|
#: core/api/serializers.py:67
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||||
|
|
||||||
@@ -361,9 +361,9 @@ msgid "Make sure you have a stable internet connection"
|
|||||||
msgstr "Make sure you have a stable internet connection"
|
msgstr "Make sure you have a stable internet connection"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:248
|
#: core/templates/mail/html/invitation.html:248
|
||||||
#: core/templates/mail/html/screen_recording.html:240
|
#: core/templates/mail/html/screen_recording.html:245
|
||||||
#: core/templates/mail/text/invitation.txt:21
|
#: core/templates/mail/text/invitation.txt:21
|
||||||
#: core/templates/mail/text/screen_recording.txt:22
|
#: core/templates/mail/text/screen_recording.txt:23
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid " Thank you for using %(brandname)s. "
|
msgid " Thank you for using %(brandname)s. "
|
||||||
msgstr " Thank you for using %(brandname)s. "
|
msgstr " Thank you for using %(brandname)s. "
|
||||||
@@ -389,33 +389,42 @@ msgstr ""
|
|||||||
msgid " The recording will expire in %(days)s days. "
|
msgid " The recording will expire in %(days)s days. "
|
||||||
msgstr " The recording will expire in %(days)s days. "
|
msgstr " The recording will expire in %(days)s days. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:201
|
#: core/templates/mail/html/screen_recording.html:200
|
||||||
#: core/templates/mail/text/screen_recording.txt:10
|
#: core/templates/mail/text/screen_recording.txt:9
|
||||||
|
msgid ""
|
||||||
|
" Sharing the recording via link is not yet available. Only organizers can "
|
||||||
|
"download it. "
|
||||||
|
msgstr ""
|
||||||
|
" Sharing the recording via link is not yet available. Only organizers can "
|
||||||
|
"download it. "
|
||||||
|
|
||||||
|
#: core/templates/mail/html/screen_recording.html:206
|
||||||
|
#: core/templates/mail/text/screen_recording.txt:11
|
||||||
msgid "To keep this recording permanently:"
|
msgid "To keep this recording permanently:"
|
||||||
msgstr "To keep this recording permanently:"
|
msgstr "To keep this recording permanently:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:203
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:12
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"Open\" button below "
|
msgid "Click the \"Open\" button below "
|
||||||
msgstr "Click the \"Open\" button below "
|
msgstr "Click the \"Open\" button below "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:204
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: core/templates/mail/text/screen_recording.txt:14
|
||||||
msgid "Use the \"Download\" button in the interface "
|
msgid "Use the \"Download\" button in the interface "
|
||||||
msgstr "Use the \"Download\" button in the interface "
|
msgstr "Use the \"Download\" button in the interface "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:205
|
#: core/templates/mail/html/screen_recording.html:210
|
||||||
#: core/templates/mail/text/screen_recording.txt:14
|
#: core/templates/mail/text/screen_recording.txt:15
|
||||||
msgid "Save the file to your preferred location"
|
msgid "Save the file to your preferred location"
|
||||||
msgstr "Save the file to your preferred location"
|
msgstr "Save the file to your preferred location"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:216
|
#: core/templates/mail/html/screen_recording.html:221
|
||||||
#: core/templates/mail/text/screen_recording.txt:16
|
#: core/templates/mail/text/screen_recording.txt:17
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Open"
|
msgstr "Open"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:225
|
#: core/templates/mail/html/screen_recording.html:230
|
||||||
#: core/templates/mail/text/screen_recording.txt:18
|
#: core/templates/mail/text/screen_recording.txt:19
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
@@ -424,18 +433,18 @@ msgstr ""
|
|||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
"team at %(support_email)s. "
|
"team at %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:162
|
#: meet/settings.py:163
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "English"
|
msgstr "English"
|
||||||
|
|
||||||
#: meet/settings.py:163
|
#: meet/settings.py:164
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "French"
|
msgstr "French"
|
||||||
|
|
||||||
#: meet/settings.py:164
|
#: meet/settings.py:165
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Dutch"
|
msgstr "Dutch"
|
||||||
|
|
||||||
#: meet/settings.py:165
|
#: meet/settings.py:166
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -29,15 +29,15 @@ msgstr "Permissions"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Dates importantes"
|
msgstr "Dates importantes"
|
||||||
|
|
||||||
#: core/admin.py:143
|
#: core/admin.py:147
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Pas de propriétaire"
|
msgstr "Pas de propriétaire"
|
||||||
|
|
||||||
#: core/admin.py:146
|
#: core/admin.py:150
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Plusieurs propriétaires"
|
msgstr "Plusieurs propriétaires"
|
||||||
|
|
||||||
#: core/api/serializers.py:63
|
#: core/api/serializers.py:67
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||||
@@ -138,8 +138,8 @@ msgid ""
|
|||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
|
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||||
"des chiffres et les caractères @/./+/-/_."
|
"chiffres et les caractères @/./+/-/_."
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:144
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
@@ -252,7 +252,8 @@ msgstr "Code PIN de la salle"
|
|||||||
|
|
||||||
#: core/models.py:392
|
#: core/models.py:392
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
msgstr ""
|
||||||
|
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||||
|
|
||||||
#: core/models.py:398 core/models.py:552
|
#: core/models.py:398 core/models.py:552
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
@@ -271,9 +272,8 @@ msgid ""
|
|||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
|
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||||
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
|
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||||
"facile."
|
|
||||||
|
|
||||||
#: core/models.py:573
|
#: core/models.py:573
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
@@ -367,9 +367,9 @@ msgid "Make sure you have a stable internet connection"
|
|||||||
msgstr "Assurez-vous d'avoir une connexion Internet stable"
|
msgstr "Assurez-vous d'avoir une connexion Internet stable"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:248
|
#: core/templates/mail/html/invitation.html:248
|
||||||
#: core/templates/mail/html/screen_recording.html:240
|
#: core/templates/mail/html/screen_recording.html:245
|
||||||
#: core/templates/mail/text/invitation.txt:21
|
#: core/templates/mail/text/invitation.txt:21
|
||||||
#: core/templates/mail/text/screen_recording.txt:22
|
#: core/templates/mail/text/screen_recording.txt:23
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid " Thank you for using %(brandname)s. "
|
msgid " Thank you for using %(brandname)s. "
|
||||||
msgstr " Merci d'utiliser %(brandname)s. "
|
msgstr " Merci d'utiliser %(brandname)s. "
|
||||||
@@ -395,33 +395,41 @@ msgstr ""
|
|||||||
msgid " The recording will expire in %(days)s days. "
|
msgid " The recording will expire in %(days)s days. "
|
||||||
msgstr " L'enregistrement expirera dans %(days)s jours. "
|
msgstr " L'enregistrement expirera dans %(days)s jours. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:201
|
#: core/templates/mail/html/screen_recording.html:200
|
||||||
#: core/templates/mail/text/screen_recording.txt:10
|
#: core/templates/mail/text/screen_recording.txt:9
|
||||||
|
msgid ""
|
||||||
|
" Sharing the recording via link is not yet available. Only organizers can "
|
||||||
|
"download it. "
|
||||||
|
msgstr ""
|
||||||
|
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
|
||||||
|
|
||||||
|
#: core/templates/mail/html/screen_recording.html:206
|
||||||
|
#: core/templates/mail/text/screen_recording.txt:11
|
||||||
msgid "To keep this recording permanently:"
|
msgid "To keep this recording permanently:"
|
||||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:203
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:12
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"Open\" button below "
|
msgid "Click the \"Open\" button below "
|
||||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:204
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: core/templates/mail/text/screen_recording.txt:14
|
||||||
msgid "Use the \"Download\" button in the interface "
|
msgid "Use the \"Download\" button in the interface "
|
||||||
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
|
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:205
|
#: core/templates/mail/html/screen_recording.html:210
|
||||||
#: core/templates/mail/text/screen_recording.txt:14
|
#: core/templates/mail/text/screen_recording.txt:15
|
||||||
msgid "Save the file to your preferred location"
|
msgid "Save the file to your preferred location"
|
||||||
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
|
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:216
|
#: core/templates/mail/html/screen_recording.html:221
|
||||||
#: core/templates/mail/text/screen_recording.txt:16
|
#: core/templates/mail/text/screen_recording.txt:17
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Ouvrir"
|
msgstr "Ouvrir"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:225
|
#: core/templates/mail/html/screen_recording.html:230
|
||||||
#: core/templates/mail/text/screen_recording.txt:18
|
#: core/templates/mail/text/screen_recording.txt:19
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
@@ -430,18 +438,18 @@ msgstr ""
|
|||||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||||
"équipe d'assistance à %(support_email)s. "
|
"équipe d'assistance à %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:162
|
#: meet/settings.py:163
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: meet/settings.py:163
|
#: meet/settings.py:164
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
#: meet/settings.py:164
|
#: meet/settings.py:165
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Néerlandais"
|
msgstr "Néerlandais"
|
||||||
|
|
||||||
#: meet/settings.py:165
|
#: meet/settings.py:166
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -29,15 +29,15 @@ msgstr "Rechten"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Belangrijke datums"
|
msgstr "Belangrijke datums"
|
||||||
|
|
||||||
#: core/admin.py:143
|
#: core/admin.py:147
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Geen eigenaar"
|
msgstr "Geen eigenaar"
|
||||||
|
|
||||||
#: core/admin.py:146
|
#: core/admin.py:150
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Meerdere eigenaren"
|
msgstr "Meerdere eigenaren"
|
||||||
|
|
||||||
#: core/api/serializers.py:63
|
#: core/api/serializers.py:67
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||||
@@ -247,7 +247,8 @@ msgstr "Pincode van de kamer"
|
|||||||
|
|
||||||
#: core/models.py:392
|
#: core/models.py:392
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
msgstr ""
|
||||||
|
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||||
|
|
||||||
#: core/models.py:398 core/models.py:552
|
#: core/models.py:398 core/models.py:552
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
@@ -361,9 +362,9 @@ msgid "Make sure you have a stable internet connection"
|
|||||||
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
|
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:248
|
#: core/templates/mail/html/invitation.html:248
|
||||||
#: core/templates/mail/html/screen_recording.html:240
|
#: core/templates/mail/html/screen_recording.html:245
|
||||||
#: core/templates/mail/text/invitation.txt:21
|
#: core/templates/mail/text/invitation.txt:21
|
||||||
#: core/templates/mail/text/screen_recording.txt:22
|
#: core/templates/mail/text/screen_recording.txt:23
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid " Thank you for using %(brandname)s. "
|
msgid " Thank you for using %(brandname)s. "
|
||||||
msgstr " Bedankt voor het gebruik van %(brandname)s. "
|
msgstr " Bedankt voor het gebruik van %(brandname)s. "
|
||||||
@@ -389,33 +390,41 @@ msgstr ""
|
|||||||
msgid " The recording will expire in %(days)s days. "
|
msgid " The recording will expire in %(days)s days. "
|
||||||
msgstr " De opname verloopt over %(days)s dagen. "
|
msgstr " De opname verloopt over %(days)s dagen. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:201
|
#: core/templates/mail/html/screen_recording.html:200
|
||||||
#: core/templates/mail/text/screen_recording.txt:10
|
#: core/templates/mail/text/screen_recording.txt:9
|
||||||
|
msgid ""
|
||||||
|
" Sharing the recording via link is not yet available. Only organizers can "
|
||||||
|
"download it. "
|
||||||
|
msgstr ""
|
||||||
|
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
|
||||||
|
|
||||||
|
#: core/templates/mail/html/screen_recording.html:206
|
||||||
|
#: core/templates/mail/text/screen_recording.txt:11
|
||||||
msgid "To keep this recording permanently:"
|
msgid "To keep this recording permanently:"
|
||||||
msgstr "Om deze opname permanent te bewaren:"
|
msgstr "Om deze opname permanent te bewaren:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:203
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:12
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"Open\" button below "
|
msgid "Click the \"Open\" button below "
|
||||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:204
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: core/templates/mail/text/screen_recording.txt:14
|
||||||
msgid "Use the \"Download\" button in the interface "
|
msgid "Use the \"Download\" button in the interface "
|
||||||
msgstr "Gebruik de \"Download\"-knop in de interface "
|
msgstr "Gebruik de \"Download\"-knop in de interface "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:205
|
#: core/templates/mail/html/screen_recording.html:210
|
||||||
#: core/templates/mail/text/screen_recording.txt:14
|
#: core/templates/mail/text/screen_recording.txt:15
|
||||||
msgid "Save the file to your preferred location"
|
msgid "Save the file to your preferred location"
|
||||||
msgstr "Sla het bestand op naar je gewenste locatie"
|
msgstr "Sla het bestand op naar je gewenste locatie"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:216
|
#: core/templates/mail/html/screen_recording.html:221
|
||||||
#: core/templates/mail/text/screen_recording.txt:16
|
#: core/templates/mail/text/screen_recording.txt:17
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Openen"
|
msgstr "Openen"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:225
|
#: core/templates/mail/html/screen_recording.html:230
|
||||||
#: core/templates/mail/text/screen_recording.txt:18
|
#: core/templates/mail/text/screen_recording.txt:19
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
@@ -424,18 +433,18 @@ msgstr ""
|
|||||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||||
"team via %(support_email)s. "
|
"team via %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:162
|
#: meet/settings.py:163
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Engels"
|
msgstr "Engels"
|
||||||
|
|
||||||
#: meet/settings.py:163
|
#: meet/settings.py:164
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Frans"
|
msgstr "Frans"
|
||||||
|
|
||||||
#: meet/settings.py:164
|
#: meet/settings.py:165
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Nederlands"
|
msgstr "Nederlands"
|
||||||
|
|
||||||
#: meet/settings.py:165
|
#: meet/settings.py:166
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|||||||
@@ -430,6 +430,17 @@ class Base(Configuration):
|
|||||||
OIDC_RP_SCOPES = values.Value(
|
OIDC_RP_SCOPES = values.Value(
|
||||||
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
OIDC_USE_PKCE = values.BooleanValue(
|
||||||
|
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
|
||||||
|
default="S256",
|
||||||
|
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
|
||||||
|
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
|
||||||
|
)
|
||||||
LOGIN_REDIRECT_URL = values.Value(
|
LOGIN_REDIRECT_URL = values.Value(
|
||||||
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -525,6 +536,11 @@ class Base(Configuration):
|
|||||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||||
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
|
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
# Recording max duration in milliseconds - must be synced with LiveKit Egress configuration
|
||||||
|
# Set to None for no max duration
|
||||||
|
RECORDING_MAX_DURATION = values.IntegerValue(
|
||||||
|
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||||
|
)
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.28"
|
version = "0.1.30"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+73
-6
@@ -1,28 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.9.13",
|
"@livekit/components-react": "2.9.13",
|
||||||
"@livekit/components-styles": "1.1.6",
|
"@livekit/components-styles": "1.1.6",
|
||||||
"@livekit/track-processors": "0.5.7",
|
"@livekit/track-processors": "0.5.7",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.5",
|
"@react-aria/toast": "3.0.5",
|
||||||
|
"@react-hook/size": "2.1.2",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.25",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
|
"humanize-duration": "3.33.0",
|
||||||
"i18next": "25.3.1",
|
"i18next": "25.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.0",
|
"i18next-browser-languagedetector": "8.2.0",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"libphonenumber-js": "1.12.9",
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.2",
|
"livekit-client": "2.15.2",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.256.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"@pandacss/dev": "0.54.0",
|
"@pandacss/dev": "0.54.0",
|
||||||
"@tanstack/eslint-plugin-query": "5.81.2",
|
"@tanstack/eslint-plugin-query": "5.81.2",
|
||||||
"@tanstack/react-query-devtools": "5.81.5",
|
"@tanstack/react-query-devtools": "5.81.5",
|
||||||
|
"@types/humanize-duration": "3.27.4",
|
||||||
"@types/node": "22.16.0",
|
"@types/node": "22.16.0",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
@@ -1208,6 +1211,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@juggle/resize-observer": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@livekit/components-core": {
|
"node_modules/@livekit/components-core": {
|
||||||
"version": "0.12.8",
|
"version": "0.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz",
|
||||||
@@ -2699,6 +2708,51 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-hook/latest": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-hook/passive-layout-effect": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-hook/resize-observer": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
|
"@react-hook/latest": "^1.0.2",
|
||||||
|
"@react-hook/passive-layout-effect": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-hook/size": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-hook/size/-/size-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-BmE5asyRDxSuQ9p14FUKJ0iBRgV9cROjqNG9jT/EjCM+xHha1HVqbPoT+14FQg1K7xIydabClCibUY4+1tw/iw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-hook/passive-layout-effect": "^1.2.0",
|
||||||
|
"@react-hook/resize-observer": "^1.2.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-stately/autocomplete": {
|
"node_modules/@react-stately/autocomplete": {
|
||||||
"version": "3.0.0-beta.2",
|
"version": "3.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz",
|
||||||
@@ -3994,6 +4048,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/humanize-duration": {
|
||||||
|
"version": "3.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz",
|
||||||
|
"integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/minimatch": {
|
"node_modules/@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
@@ -6535,6 +6596,12 @@
|
|||||||
"entities": "^4.5.0"
|
"entities": "^4.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/humanize-duration": {
|
||||||
|
"version": "3.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
|
||||||
|
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.3.1",
|
"version": "25.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
|
||||||
@@ -7209,9 +7276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/libphonenumber-js": {
|
"node_modules/libphonenumber-js": {
|
||||||
"version": "1.12.9",
|
"version": "1.12.10",
|
||||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz",
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
|
||||||
"integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==",
|
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -18,18 +18,20 @@
|
|||||||
"@livekit/track-processors": "0.5.7",
|
"@livekit/track-processors": "0.5.7",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.5",
|
"@react-aria/toast": "3.0.5",
|
||||||
|
"@react-hook/size": "2.1.2",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.25",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
|
"humanize-duration": "3.33.0",
|
||||||
"i18next": "25.3.1",
|
"i18next": "25.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.0",
|
"i18next-browser-languagedetector": "8.2.0",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.2",
|
"livekit-client": "2.15.2",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.256.2",
|
||||||
"libphonenumber-js": "1.12.9",
|
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.10.1",
|
"react-aria-components": "1.10.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
"@pandacss/dev": "0.54.0",
|
"@pandacss/dev": "0.54.0",
|
||||||
"@tanstack/eslint-plugin-query": "5.81.2",
|
"@tanstack/eslint-plugin-query": "5.81.2",
|
||||||
"@tanstack/react-query-devtools": "5.81.5",
|
"@tanstack/react-query-devtools": "5.81.5",
|
||||||
|
"@types/humanize-duration": "3.27.4",
|
||||||
"@types/node": "22.16.0",
|
"@types/node": "22.16.0",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ApiConfig {
|
|||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
available_modes?: RecordingMode[]
|
available_modes?: RecordingMode[]
|
||||||
expiration_days?: number
|
expiration_days?: number
|
||||||
|
max_duration?: number
|
||||||
}
|
}
|
||||||
telephony: {
|
telephony: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|||||||
@@ -72,29 +72,33 @@ export const MainNotificationToast = () => {
|
|||||||
) => {
|
) => {
|
||||||
const notification = decodeNotificationDataReceived(payload)
|
const notification = decodeNotificationDataReceived(payload)
|
||||||
|
|
||||||
if (!participant || !notification) return
|
if (!notification) return
|
||||||
|
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case NotificationType.ParticipantMuted:
|
case NotificationType.ParticipantMuted:
|
||||||
toastQueue.add(
|
if (participant) {
|
||||||
{
|
toastQueue.add(
|
||||||
participant,
|
{
|
||||||
type: NotificationType.ParticipantMuted,
|
participant,
|
||||||
},
|
type: NotificationType.ParticipantMuted,
|
||||||
{ timeout: NotificationDuration.ALERT }
|
},
|
||||||
)
|
{ timeout: NotificationDuration.ALERT }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
case NotificationType.ReactionReceived:
|
case NotificationType.ReactionReceived:
|
||||||
if (notification.data?.emoji)
|
if (notification.data?.emoji && participant)
|
||||||
handleEmoji(notification.data.emoji, participant)
|
handleEmoji(notification.data.emoji, participant)
|
||||||
break
|
break
|
||||||
case NotificationType.TranscriptionStarted:
|
case NotificationType.TranscriptionStarted:
|
||||||
case NotificationType.TranscriptionStopped:
|
case NotificationType.TranscriptionStopped:
|
||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
|
case NotificationType.TranscriptionLimitReached:
|
||||||
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
participant,
|
|
||||||
type: notification.type,
|
type: notification.type,
|
||||||
},
|
},
|
||||||
{ timeout: NotificationDuration.ALERT }
|
{ timeout: NotificationDuration.ALERT }
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export enum NotificationType {
|
|||||||
ParticipantWaiting = 'participantWaiting',
|
ParticipantWaiting = 'participantWaiting',
|
||||||
TranscriptionStarted = 'transcriptionStarted',
|
TranscriptionStarted = 'transcriptionStarted',
|
||||||
TranscriptionStopped = 'transcriptionStopped',
|
TranscriptionStopped = 'transcriptionStopped',
|
||||||
|
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||||
|
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
|||||||
return 'transcript.started'
|
return 'transcript.started'
|
||||||
case NotificationType.TranscriptionStopped:
|
case NotificationType.TranscriptionStopped:
|
||||||
return 'transcript.stopped'
|
return 'transcript.stopped'
|
||||||
|
case NotificationType.TranscriptionLimitReached:
|
||||||
|
return 'transcript.limitReached'
|
||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
return 'screenRecording.started'
|
return 'screenRecording.started'
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
return 'screenRecording.stopped'
|
return 'screenRecording.stopped'
|
||||||
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
|
return 'screenRecording.limitReached'
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -40,7 +44,7 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
|||||||
gap={0}
|
gap={0}
|
||||||
>
|
>
|
||||||
{t(key, {
|
{t(key, {
|
||||||
name: participant.name,
|
name: participant?.name,
|
||||||
})}
|
})}
|
||||||
</HStack>
|
</HStack>
|
||||||
</StyledToastContainer>
|
</StyledToastContainer>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function ToastJoined({ state, ...props }: ToastProps) {
|
|||||||
)
|
)
|
||||||
const layoutContext = useMaybeLayoutContext()
|
const layoutContext = useMaybeLayoutContext()
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
|
if (!participant) return
|
||||||
|
|
||||||
const trackReference = {
|
const trackReference = {
|
||||||
participant,
|
participant,
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ToastMessageReceived({ state, ...props }: ToastProps) {
|
|||||||
}
|
}
|
||||||
}, [isChatOpen, toast, state])
|
}, [isChatOpen, toast, state])
|
||||||
|
|
||||||
if (isChatOpen) return null
|
if (isChatOpen || !participant) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export function ToastMuted({ state, ...props }: ToastProps) {
|
|||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
|
if (!participant) return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<HStack
|
<HStack
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Participant } from 'livekit-client'
|
|||||||
import { NotificationType } from '../NotificationType'
|
import { NotificationType } from '../NotificationType'
|
||||||
|
|
||||||
export interface ToastData {
|
export interface ToastData {
|
||||||
participant: Participant
|
participant?: Participant
|
||||||
type: NotificationType
|
type: NotificationType
|
||||||
message?: string
|
message?: string
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function ToastRaised({ state, ...props }: ToastProps) {
|
|||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
|
|
||||||
|
if (!participant) return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<HStack
|
<HStack
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ const renderToast = (
|
|||||||
|
|
||||||
case NotificationType.TranscriptionStarted:
|
case NotificationType.TranscriptionStarted:
|
||||||
case NotificationType.TranscriptionStopped:
|
case NotificationType.TranscriptionStopped:
|
||||||
|
case NotificationType.TranscriptionLimitReached:
|
||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
case NotificationType.RecordingSaving:
|
case NotificationType.RecordingSaving:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Dialog, P } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import humanizeDuration from 'humanize-duration'
|
||||||
|
|
||||||
|
export const LimitReachedAlertDialog = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) => {
|
||||||
|
const { t, i18n } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'recordingStateToast.limitReachedAlert',
|
||||||
|
})
|
||||||
|
const { data } = useConfig()
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
|
||||||
|
<P>
|
||||||
|
{t('description', {
|
||||||
|
duration_message: data?.recording?.max_duration
|
||||||
|
? t('durationMessage', {
|
||||||
|
duration: humanizeDuration(data?.recording?.max_duration, {
|
||||||
|
language: i18n.language,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: '',
|
||||||
|
})}
|
||||||
|
</P>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<Button variant="text" size="sm" onPress={onClose}>
|
||||||
|
{t('button')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSnapshot } from 'valtio/index'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { RoomEvent } from 'livekit-client'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||||
@@ -18,14 +18,18 @@ import {
|
|||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
|
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||||
|
|
||||||
export const RecordingStateToast = () => {
|
export const RecordingStateToast = () => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
keyPrefix: 'recordingStateToast',
|
keyPrefix: 'recordingStateToast',
|
||||||
})
|
})
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
|
||||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
|
|
||||||
const recordingSnap = useSnapshot(recordingStore)
|
const recordingSnap = useSnapshot(recordingStore)
|
||||||
|
|
||||||
@@ -53,13 +57,10 @@ export const RecordingStateToast = () => {
|
|||||||
}, [room.isRecording])
|
}, [room.isRecording])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDataReceived = (
|
const handleDataReceived = (payload: Uint8Array) => {
|
||||||
payload: Uint8Array,
|
|
||||||
participant?: RemoteParticipant
|
|
||||||
) => {
|
|
||||||
const notification = decodeNotificationDataReceived(payload)
|
const notification = decodeNotificationDataReceived(payload)
|
||||||
|
|
||||||
if (!participant || !notification) return
|
if (!notification) return
|
||||||
|
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case NotificationType.TranscriptionStarted:
|
case NotificationType.TranscriptionStarted:
|
||||||
@@ -68,12 +69,20 @@ export const RecordingStateToast = () => {
|
|||||||
case NotificationType.TranscriptionStopped:
|
case NotificationType.TranscriptionStopped:
|
||||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||||
break
|
break
|
||||||
|
case NotificationType.TranscriptionLimitReached:
|
||||||
|
if (isAdminOrOwner) setIsAlertOpen(true)
|
||||||
|
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||||
|
break
|
||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||||
break
|
break
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||||
break
|
break
|
||||||
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
|
if (isAdminOrOwner) setIsAlertOpen(true)
|
||||||
|
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,7 +109,7 @@ export const RecordingStateToast = () => {
|
|||||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||||
}
|
}
|
||||||
}, [room, recordingSnap])
|
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
|
||||||
|
|
||||||
const key = useMemo(() => {
|
const key = useMemo(() => {
|
||||||
switch (recordingSnap.status) {
|
switch (recordingSnap.status) {
|
||||||
@@ -119,7 +128,14 @@ export const RecordingStateToast = () => {
|
|||||||
}
|
}
|
||||||
}, [recordingSnap])
|
}, [recordingSnap])
|
||||||
|
|
||||||
if (!key) return
|
if (!key)
|
||||||
|
return isAdminOrOwner ? (
|
||||||
|
<LimitReachedAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
aria-label="Recording limit exceeded"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
const isStarted = key?.includes('started')
|
const isStarted = key?.includes('started')
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ import { fetchRecording } from '../api/fetchRecording'
|
|||||||
import { RecordingStatus } from '@/features/recording'
|
import { RecordingStatus } from '@/features/recording'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
|
const BetaBadge = () => (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
content: '"Beta"',
|
||||||
|
display: 'block',
|
||||||
|
letterSpacing: '-0.02rem',
|
||||||
|
padding: '0 0.25rem',
|
||||||
|
backgroundColor: 'primary.100',
|
||||||
|
color: '#0063CB',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
margin: '0 0.3125rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
width: 'fit-content',
|
||||||
|
height: 'fit-content',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
export const RecordingDownload = () => {
|
export const RecordingDownload = () => {
|
||||||
const { t } = useTranslation('recording')
|
const { t } = useTranslation('recording')
|
||||||
const { data: configData } = useConfig()
|
const { data: configData } = useConfig()
|
||||||
@@ -27,11 +49,11 @@ export const RecordingDownload = () => {
|
|||||||
enabled: !!recordingId,
|
enabled: !!recordingId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading || !data || isAuthLoading) {
|
if (isLoggedIn === undefined || isAuthLoading) {
|
||||||
return <LoadingScreen />
|
return <LoadingScreen />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (isLoggedIn === false && !isAuthLoading) {
|
||||||
return (
|
return (
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title={t('authentication.title')}
|
title={t('authentication.title')}
|
||||||
@@ -40,7 +62,11 @@ export const RecordingDownload = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isLoading) {
|
||||||
|
return <LoadingScreen />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
return <ErrorScreen title={t('error.title')} body={t('error.body')} />
|
return <ErrorScreen title={t('error.title')} body={t('error.body')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +129,28 @@ export const RecordingDownload = () => {
|
|||||||
>
|
>
|
||||||
{t('success.button')}
|
{t('success.button')}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
borderRadius: '5px',
|
||||||
|
paddingY: '1rem',
|
||||||
|
paddingX: '1rem',
|
||||||
|
maxWidth: '80%',
|
||||||
|
marginTop: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('success.warning.title')} <BetaBadge />
|
||||||
|
</Text>
|
||||||
|
<Text variant="smNote">{t('success.warning.body')}</Text>
|
||||||
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LiveKitRoom } from '@livekit/components-react'
|
import { LiveKitRoom } from '@livekit/components-react'
|
||||||
import { Room, RoomOptions } from 'livekit-client'
|
import {
|
||||||
|
DisconnectReason,
|
||||||
|
MediaDeviceFailure,
|
||||||
|
Room,
|
||||||
|
RoomOptions,
|
||||||
|
supportsAdaptiveStream,
|
||||||
|
supportsDynacast,
|
||||||
|
} from 'livekit-client'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -17,6 +24,8 @@ import posthog from 'posthog-js'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
|
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -67,8 +76,8 @@ export const Conference = ({
|
|||||||
|
|
||||||
const roomOptions = useMemo((): RoomOptions => {
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
return {
|
return {
|
||||||
adaptiveStream: true,
|
adaptiveStream: supportsAdaptiveStream(),
|
||||||
dynacast: true,
|
dynacast: supportsDynacast(),
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
videoCodec: 'vp9',
|
||||||
},
|
},
|
||||||
@@ -85,6 +94,13 @@ export const Conference = ({
|
|||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
|
error: MediaDeviceFailure | null
|
||||||
|
kind: MediaDeviceKind | null
|
||||||
|
}>({
|
||||||
|
error: null,
|
||||||
|
kind: null,
|
||||||
|
})
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
if (isCreateError) {
|
if (isCreateError) {
|
||||||
@@ -124,6 +140,18 @@ export const Conference = ({
|
|||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
|
onDisconnected={(e) => {
|
||||||
|
if (e == DisconnectReason.CLIENT_INITIATED) {
|
||||||
|
navigateTo('feedback', { duplicateIdentity: false })
|
||||||
|
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
|
||||||
|
navigateTo('feedback', { duplicateIdentity: true })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMediaDeviceFailure={(e, kind) => {
|
||||||
|
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
|
||||||
|
setMediaDeviceError({ error: e, kind })
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
@@ -134,6 +162,10 @@ export const Conference = ({
|
|||||||
onClose={() => setShowInviteDialog(false)}
|
onClose={() => setShowInviteDialog(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MediaDeviceErrorAlert
|
||||||
|
{...mediaDeviceError}
|
||||||
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
/>
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
</QueryAware>
|
</QueryAware>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MediaDeviceFailure } from 'livekit-client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Dialog, P } from '@/primitives'
|
||||||
|
|
||||||
|
export type MediaDeviceErrorAlertProps = {
|
||||||
|
error?: MediaDeviceFailure | null
|
||||||
|
kind?: MediaDeviceKind | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaDeviceErrorAlert = ({
|
||||||
|
error,
|
||||||
|
kind,
|
||||||
|
onClose,
|
||||||
|
}: MediaDeviceErrorAlertProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'mediaErrorDialog' })
|
||||||
|
|
||||||
|
if (!error || !kind) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
role="alertdialog"
|
||||||
|
isOpen={!!error}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t(`${error}.title.${kind}`)}
|
||||||
|
>
|
||||||
|
<P>{t(`${error}.body.${kind}`)}</P>
|
||||||
|
<Button variant="text" size="sm" onPress={onClose}>
|
||||||
|
{t('close')}
|
||||||
|
</Button>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import { useMemo, useRef } from 'react'
|
|||||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
import { useSize } from '../hooks/useResizeObserver'
|
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSize from '@react-hook/size'
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -16,7 +16,7 @@ export const FullScreenShareWarning = ({
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width: containerWidth } = useSize(warningContainerRef)
|
const containerWidth = useSize(warningContainerRef)[0]
|
||||||
const { localParticipant } = useLocalParticipant()
|
const { localParticipant } = useLocalParticipant()
|
||||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { styled } from '@/styled-system/jsx'
|
|||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
import { useIsSpeaking } from '@livekit/components-react'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
import useSize from '@react-hook/size'
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,7 +28,7 @@ export const ParticipantPlaceholder = ({
|
|||||||
const participantColor = getParticipantColor(participant)
|
const participantColor = getParticipantColor(participant)
|
||||||
|
|
||||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||||
const { width, height } = useSize(placeholderEl)
|
const [width, height] = useSize(placeholderEl)
|
||||||
|
|
||||||
const minDimension = Math.min(width, height)
|
const minDimension = Math.min(width, height)
|
||||||
const avatarSize = useMemo(
|
const avatarSize = useMemo(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
|
||||||
import { RiPhoneFill } from '@remixicon/react'
|
import { RiPhoneFill } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ConnectionState } from 'livekit-client'
|
import { ConnectionState } from 'livekit-client'
|
||||||
@@ -21,9 +20,6 @@ export const LeaveButton = () => {
|
|||||||
.catch((e) =>
|
.catch((e) =>
|
||||||
console.error('An error occurred while disconnecting:', e)
|
console.error('An error occurred while disconnecting:', e)
|
||||||
)
|
)
|
||||||
.finally(() => {
|
|
||||||
navigateTo('feedback')
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
data-attr="controls-leave"
|
data-attr="controls-leave"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createInteractingObservable } from '@livekit/components-core'
|
||||||
|
import { usePagination } from '@livekit/components-react'
|
||||||
|
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export interface PaginationControlProps
|
||||||
|
extends Pick<
|
||||||
|
ReturnType<typeof usePagination>,
|
||||||
|
'totalPageCount' | 'nextPage' | 'prevPage' | 'currentPage'
|
||||||
|
> {
|
||||||
|
/** Reference to an HTML element that holds the pages, while interacting (`mouseover`)
|
||||||
|
* with it, the pagination controls will appear for a while. */
|
||||||
|
pagesContainer?: React.RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationControl({
|
||||||
|
totalPageCount,
|
||||||
|
nextPage,
|
||||||
|
prevPage,
|
||||||
|
currentPage,
|
||||||
|
pagesContainer: connectedElement,
|
||||||
|
}: PaginationControlProps) {
|
||||||
|
const [interactive, setInteractive] = React.useState(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
let subscription:
|
||||||
|
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
|
||||||
|
| undefined
|
||||||
|
if (connectedElement) {
|
||||||
|
subscription = createInteractingObservable(
|
||||||
|
connectedElement.current,
|
||||||
|
2000
|
||||||
|
).subscribe(setInteractive)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (subscription) {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [connectedElement])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="lk-pagination-control"
|
||||||
|
data-lk-user-interaction={interactive}
|
||||||
|
>
|
||||||
|
<button className="lk-button" onClick={prevPage}>
|
||||||
|
<RiArrowLeftSLine />
|
||||||
|
</button>
|
||||||
|
<span className="lk-pagination-count">{`${currentPage} of ${totalPageCount}`}</span>
|
||||||
|
<button className="lk-button" onClick={nextPage}>
|
||||||
|
<RiArrowRightSLine />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export interface PaginationIndicatorProps {
|
||||||
|
totalPageCount: number
|
||||||
|
currentPage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginationIndicator: (
|
||||||
|
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
|
||||||
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
PaginationIndicatorProps
|
||||||
|
>(function PaginationIndicator(
|
||||||
|
{ totalPageCount, currentPage }: PaginationIndicatorProps,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
|
||||||
|
if (index + 1 === currentPage) {
|
||||||
|
return <span data-lk-active key={index} />
|
||||||
|
} else {
|
||||||
|
return <span key={index} />
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="lk-pagination-indicator">
|
||||||
|
{bubbles}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { getScrollBarWidth } from '@livekit/components-core'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
|
import useSize from '@react-hook/size'
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 130
|
||||||
|
const MIN_WIDTH = 140
|
||||||
|
const MIN_VISIBLE_TILES = 1
|
||||||
|
const ASPECT_RATIO = 16 / 10
|
||||||
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export interface CarouselLayoutProps
|
||||||
|
extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
children: React.ReactNode
|
||||||
|
/** Place the tiles vertically or horizontally next to each other.
|
||||||
|
* If undefined orientation is guessed by the dimensions of the container. */
|
||||||
|
orientation?: 'vertical' | 'horizontal'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `CarouselLayout` component displays a list of tracks in a scroll container.
|
||||||
|
* It will display as many tiles as possible and overflow the rest.
|
||||||
|
* @remarks
|
||||||
|
* To ensure visual stability when tiles are reordered due to track updates,
|
||||||
|
* the component uses the `useVisualStableUpdate` hook.
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const tracks = useTracks([Track.Source.Camera]);
|
||||||
|
* <CarouselLayout tracks={tracks}>
|
||||||
|
* <ParticipantTile />
|
||||||
|
* </CarouselLayout>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function CarouselLayout({
|
||||||
|
tracks,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: CarouselLayoutProps) {
|
||||||
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||||
|
const [width, height] = useSize(asideEl)
|
||||||
|
const carouselOrientation = orientation
|
||||||
|
? orientation
|
||||||
|
: height >= width
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal'
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
|
const tilesThatFit =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
|
||||||
|
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||||
|
maxVisibleTiles = Math.round(prevTiles)
|
||||||
|
} else if (prevTiles !== tilesThatFit) {
|
||||||
|
setPrevTiles(tilesThatFit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (asideEl.current) {
|
||||||
|
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||||
|
asideEl.current.style.setProperty(
|
||||||
|
'--lk-max-visible-tiles',
|
||||||
|
maxVisibleTiles.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [maxVisibleTiles, carouselOrientation])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
key={carouselOrientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import {
|
||||||
|
TrackLoop,
|
||||||
|
usePagination,
|
||||||
|
UseParticipantsOptions,
|
||||||
|
useSwipe,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { mergeProps } from '@/utils/mergeProps'
|
||||||
|
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
||||||
|
import { useGridLayout } from '../../hooks/useGridLayout'
|
||||||
|
import { PaginationControl } from '../controls/PaginationControl'
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export interface GridLayoutProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
|
||||||
|
children: React.ReactNode
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `GridLayout` component displays the nested participants in a grid where every participants has the same size.
|
||||||
|
* It also supports pagination if there are more participants than the grid can display.
|
||||||
|
* @remarks
|
||||||
|
* To ensure visual stability when tiles are reordered due to track updates,
|
||||||
|
* the component uses the `useVisualStableUpdate` hook.
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* <GridLayout tracks={tracks}>
|
||||||
|
* <ParticipantTile />
|
||||||
|
* </GridLayout>
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
|
const gridEl = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
|
const elementProps = React.useMemo(
|
||||||
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
|
[props]
|
||||||
|
)
|
||||||
|
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||||
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
|
||||||
|
useSwipe(gridEl, {
|
||||||
|
onLeftSwipe: pagination.nextPage,
|
||||||
|
onRightSwipe: pagination.prevPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={gridEl}
|
||||||
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
|
{...elementProps}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
|
{tracks.length > layout.maxTiles && (
|
||||||
|
<>
|
||||||
|
<PaginationIndicator
|
||||||
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
/>
|
||||||
|
<PaginationControl pagesContainer={gridEl} {...pagination} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { GRID_LAYOUTS, selectGridLayout } from '@livekit/components-core'
|
||||||
|
import type {
|
||||||
|
GridLayoutDefinition,
|
||||||
|
GridLayoutInfo,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import * as React from 'react'
|
||||||
|
import useSize from '@react-hook/size'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `useGridLayout` hook tries to select the best layout to fit all tiles.
|
||||||
|
* If the available screen space is not enough, it will reduce the number of maximum visible
|
||||||
|
* tiles and select a layout that still works visually within the given limitations.
|
||||||
|
* As the order of tiles changes over time, the hook tries to keep visual updates to a minimum
|
||||||
|
* while trying to display important tiles such as speaking participants or screen shares.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { layout } = useGridLayout(gridElement, trackCount);
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function useGridLayout(
|
||||||
|
/** HTML element that contains the grid. */
|
||||||
|
gridElement: React.RefObject<HTMLDivElement>,
|
||||||
|
/** Count of tracks that should get layed out */
|
||||||
|
trackCount: number,
|
||||||
|
options: {
|
||||||
|
gridLayouts?: GridLayoutDefinition[]
|
||||||
|
} = {}
|
||||||
|
): { layout: GridLayoutInfo; containerWidth: number; containerHeight: number } {
|
||||||
|
const gridLayouts = options.gridLayouts ?? GRID_LAYOUTS
|
||||||
|
const [width, height] = useSize(gridElement)
|
||||||
|
const layout = selectGridLayout(gridLayouts, trackCount, width, height)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (gridElement.current && layout) {
|
||||||
|
gridElement.current.style.setProperty(
|
||||||
|
'--lk-col-count',
|
||||||
|
layout?.columns.toString()
|
||||||
|
)
|
||||||
|
gridElement.current.style.setProperty(
|
||||||
|
'--lk-row-count',
|
||||||
|
layout?.rows.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [gridElement, layout])
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
containerWidth: width,
|
||||||
|
containerHeight: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
const useLatest = <T>(current: T) => {
|
|
||||||
const storedValue = React.useRef(current)
|
|
||||||
React.useEffect(() => {
|
|
||||||
storedValue.current = current
|
|
||||||
})
|
|
||||||
return storedValue
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
|
|
||||||
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export function useResizeObserver<T extends HTMLElement>(
|
|
||||||
target: React.RefObject<T>,
|
|
||||||
callback: UseResizeObserverCallback
|
|
||||||
) {
|
|
||||||
const resizeObserver = getResizeObserver()
|
|
||||||
const storedCallback = useLatest(callback)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
let didUnsubscribe = false
|
|
||||||
|
|
||||||
const targetEl = target.current
|
|
||||||
if (!targetEl) return
|
|
||||||
|
|
||||||
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
|
|
||||||
if (didUnsubscribe) return
|
|
||||||
storedCallback.current(entry, observer)
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeObserver?.subscribe(targetEl as HTMLElement, cb)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
didUnsubscribe = true
|
|
||||||
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb)
|
|
||||||
}
|
|
||||||
}, [target.current, resizeObserver, storedCallback])
|
|
||||||
|
|
||||||
return resizeObserver?.observer
|
|
||||||
}
|
|
||||||
|
|
||||||
function createResizeObserver() {
|
|
||||||
let ticking = false
|
|
||||||
let allEntries: ResizeObserverEntry[] = []
|
|
||||||
|
|
||||||
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map()
|
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(
|
|
||||||
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
|
|
||||||
allEntries = allEntries.concat(entries)
|
|
||||||
if (!ticking) {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
const triggered = new Set<Element>()
|
|
||||||
for (let i = 0; i < allEntries.length; i++) {
|
|
||||||
if (triggered.has(allEntries[i].target)) continue
|
|
||||||
triggered.add(allEntries[i].target)
|
|
||||||
const cbs = callbacks.get(allEntries[i].target)
|
|
||||||
cbs?.forEach((cb) => cb(allEntries[i], obs))
|
|
||||||
}
|
|
||||||
allEntries = []
|
|
||||||
ticking = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ticking = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
observer,
|
|
||||||
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
|
||||||
observer.observe(target)
|
|
||||||
const cbs = callbacks.get(target) ?? []
|
|
||||||
cbs.push(callback)
|
|
||||||
callbacks.set(target, cbs)
|
|
||||||
},
|
|
||||||
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
|
||||||
const cbs = callbacks.get(target) ?? []
|
|
||||||
if (cbs.length === 1) {
|
|
||||||
observer.unobserve(target)
|
|
||||||
callbacks.delete(target)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const cbIndex = cbs.indexOf(callback)
|
|
||||||
if (cbIndex !== -1) cbs.splice(cbIndex, 1)
|
|
||||||
callbacks.set(target, cbs)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _resizeObserver: ReturnType<typeof createResizeObserver>
|
|
||||||
|
|
||||||
const getResizeObserver = () =>
|
|
||||||
!_resizeObserver
|
|
||||||
? (_resizeObserver = createResizeObserver())
|
|
||||||
: _resizeObserver
|
|
||||||
|
|
||||||
export type UseResizeObserverCallback = (
|
|
||||||
entry: ResizeObserverEntry,
|
|
||||||
observer: ResizeObserver
|
|
||||||
) => unknown
|
|
||||||
|
|
||||||
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
|
|
||||||
const [size, setSize] = React.useState({ width: 0, height: 0 })
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (target.current) {
|
|
||||||
const { width, height } = target.current.getBoundingClientRect()
|
|
||||||
setSize({ width, height })
|
|
||||||
}
|
|
||||||
}, [target.current])
|
|
||||||
|
|
||||||
const resizeCallback = React.useCallback(
|
|
||||||
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
// Where the magic happens
|
|
||||||
useResizeObserver(target, resizeCallback)
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,13 @@ import { ParticipantsToggle } from '../../components/controls/Participants/Parti
|
|||||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||||
import { InfoToggle } from '../../components/controls/InfoToggle'
|
import { InfoToggle } from '../../components/controls/InfoToggle'
|
||||||
import { AdminToggle } from '../../components/AdminToggle'
|
import { AdminToggle } from '../../components/AdminToggle'
|
||||||
import { useSize } from '../../hooks/useResizeObserver'
|
|
||||||
import { useState, RefObject } from 'react'
|
import { useState, RefObject } from 'react'
|
||||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSize from '@react-hook/size'
|
||||||
|
|
||||||
const CONTROL_BAR_BREAKPOINT = 1100
|
const CONTROL_BAR_BREAKPOINT = 1100
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export const MoreOptions = ({
|
|||||||
}: {
|
}: {
|
||||||
parentElement: RefObject<HTMLDivElement>
|
parentElement: RefObject<HTMLDivElement>
|
||||||
}) => {
|
}) => {
|
||||||
const { width: parentWidth } = useSize(parentElement)
|
const parentWidth = useSize(parentElement)[0]
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { RoomEvent, Track } from 'livekit-client'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
CarouselLayout,
|
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
FocusLayoutContainer,
|
FocusLayoutContainer,
|
||||||
GridLayout,
|
GridLayout,
|
||||||
@@ -32,6 +31,7 @@ import { RecordingStateToast } from '@/features/recording'
|
|||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
|
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button } from '@/primitives'
|
|||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
||||||
import { useLocation } from 'wouter'
|
import { useLocation, useSearchParams } from 'wouter'
|
||||||
|
|
||||||
// fixme - duplicated with home, refactor in a proper style
|
// fixme - duplicated with home, refactor in a proper style
|
||||||
const Heading = styled('h1', {
|
const Heading = styled('h1', {
|
||||||
@@ -16,17 +16,25 @@ const Heading = styled('h1', {
|
|||||||
lineHeight: '2.5rem',
|
lineHeight: '2.5rem',
|
||||||
letterSpacing: '0',
|
letterSpacing: '0',
|
||||||
paddingBottom: '2rem',
|
paddingBottom: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const FeedbackRoute = () => {
|
export const FeedbackRoute = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const [, setLocation] = useLocation()
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen layout="centered" footer={false}>
|
<Screen layout="centered" footer={false}>
|
||||||
<Center>
|
<Center>
|
||||||
<VStack>
|
<VStack>
|
||||||
<Heading>{t('feedback.heading')}</Heading>
|
<Heading>
|
||||||
|
{t(
|
||||||
|
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||||
{t('feedback.back')}
|
{t('feedback.back')}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
const { t, i18n } = useTranslation('settings')
|
const { t, i18n } = useTranslation('settings')
|
||||||
const { user, isLoggedIn, logout } = useUser()
|
const { user, isLoggedIn, logout } = useUser()
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
const userDisplay =
|
||||||
|
user?.full_name && user?.email
|
||||||
|
? `${user.full_name} (${user.email})`
|
||||||
|
: user?.email
|
||||||
return (
|
return (
|
||||||
<Dialog title={t('dialog.heading')} {...props}>
|
<Dialog title={t('dialog.heading')} {...props}>
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
@@ -18,7 +22,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="settings:account.currentlyLoggedAs"
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
values={{ user: user?.full_name ?? user?.email }}
|
values={{ user: userDisplay }}
|
||||||
components={[<Badge />]}
|
components={[<Badge />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { AccountTab } from './tabs/AccountTab'
|
|||||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||||
import { GeneralTab } from './tabs/GeneralTab'
|
import { GeneralTab } from './tabs/GeneralTab'
|
||||||
import { AudioTab } from './tabs/AudioTab'
|
import { AudioTab } from './tabs/AudioTab'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||||
|
|
||||||
const tabsStyle = css({
|
const tabsStyle = css({
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||||
@@ -51,8 +51,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
|
|
||||||
const dialogEl = useRef<HTMLDivElement>(null)
|
const dialogEl = useRef<HTMLDivElement>(null)
|
||||||
const { width } = useSize(dialogEl)
|
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||||
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { user, isLoggedIn, logout } = useUser()
|
const { user, isLoggedIn, logout } = useUser()
|
||||||
const [name, setName] = useState(room?.localParticipant.name ?? '')
|
const [name, setName] = useState(room?.localParticipant.name ?? '')
|
||||||
|
const userDisplay =
|
||||||
|
user?.full_name && user?.email
|
||||||
|
? `${user.full_name} (${user.email})`
|
||||||
|
: user?.email
|
||||||
|
|
||||||
const handleOnSubmit = () => {
|
const handleOnSubmit = () => {
|
||||||
if (room) room.localParticipant.setName(name)
|
if (room) room.localParticipant.setName(name)
|
||||||
@@ -37,7 +41,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
return !value ? <p>{'Votre Nom ne peut pas être vide'}</p> : null
|
return !value ? <p>{t('account.nameError')}</p> : null
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<H lvl={2}>{t('account.authentication')}</H>
|
<H lvl={2}>{t('account.authentication')}</H>
|
||||||
@@ -46,7 +50,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="settings:account.currentlyLoggedAs"
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
values={{ user: user?.full_name || user?.email }}
|
values={{ user: userDisplay }}
|
||||||
components={[<Badge />]}
|
components={[<Badge />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DialogProps, Field, H, Switch } from '@/primitives'
|
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
|
||||||
|
|
||||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
import {
|
import {
|
||||||
@@ -174,7 +174,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
</RowWrapper>
|
</RowWrapper>
|
||||||
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
|
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
|
||||||
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
|
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
|
||||||
{!isSafari() && (
|
{!isSafari() ? (
|
||||||
<RowWrapper heading={t('audio.speakers.heading')}>
|
<RowWrapper heading={t('audio.speakers.heading')}>
|
||||||
<Field
|
<Field
|
||||||
type="select"
|
type="select"
|
||||||
@@ -193,6 +193,13 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
/>
|
/>
|
||||||
<SoundTester />
|
<SoundTester />
|
||||||
</RowWrapper>
|
</RowWrapper>
|
||||||
|
) : (
|
||||||
|
<RowWrapper heading={t('audio.speakers.heading')}>
|
||||||
|
<Text variant="warning" margin="md">
|
||||||
|
{t('audio.speakers.safariWarning')}
|
||||||
|
</Text>
|
||||||
|
<div />
|
||||||
|
</RowWrapper>
|
||||||
)}
|
)}
|
||||||
{noiseReductionAvailable && (
|
{noiseReductionAvailable && (
|
||||||
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
|
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const initializeSupportSession = (user: ApiUser) => {
|
|||||||
if (!Crisp.isCrispInjected()) return
|
if (!Crisp.isCrispInjected()) return
|
||||||
const { id, email } = user
|
const { id, email } = user
|
||||||
Crisp.setTokenId(`meet-${id}`)
|
Crisp.setTokenId(`meet-${id}`)
|
||||||
Crisp.user.setEmail(email)
|
if (email) Crisp.user.setEmail(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const terminateSupportSession = () => {
|
export const terminateSupportSession = () => {
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"started": "{{name}} hat die Transkription des Treffens gestartet.",
|
"started": "{{name}} hat die Transkription des Treffens gestartet.",
|
||||||
"stopped": "{{name}} hat die Transkription des Treffens gestoppt."
|
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
|
||||||
|
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
|
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
|
||||||
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt."
|
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
|
||||||
|
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"title": "Aufzeichnung nicht verfügbar",
|
"title": "Aufzeichnung nicht verfügbar",
|
||||||
"body": "Diese Aufzeichnung konnte nicht gefunden werden oder wurde gelöscht."
|
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Organisator der Besprechung hat Zugriff darauf. Wenden Sie sich bei Bedarf gerne an ihn."
|
||||||
},
|
},
|
||||||
"expired": {
|
"expired": {
|
||||||
"title": "Aufzeichnung abgelaufen",
|
"title": "Aufzeichnung abgelaufen",
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
"title": "Ihre Aufzeichnung ist bereit!",
|
"title": "Ihre Aufzeichnung ist bereit!",
|
||||||
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
|
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
|
||||||
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
|
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
|
||||||
"button": "Herunterladen"
|
"button": "Herunterladen",
|
||||||
|
"warning": {
|
||||||
|
"title": "Linkfreigabe deaktiviert",
|
||||||
|
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "Sie haben das Meeting verlassen",
|
"heading": {
|
||||||
|
"normal": "Sie haben das Meeting verlassen",
|
||||||
|
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
|
||||||
|
},
|
||||||
"home": "Zur Startseite zurückkehren",
|
"home": "Zur Startseite zurückkehren",
|
||||||
"back": "Dem Meeting erneut beitreten"
|
"back": "Dem Meeting erneut beitreten"
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,19 @@
|
|||||||
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
||||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
|
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
|
||||||
},
|
},
|
||||||
|
"mediaErrorDialog": {
|
||||||
|
"DeviceInUse": {
|
||||||
|
"title": {
|
||||||
|
"audioinput": "Mikrofon konnte nicht aktiviert werden",
|
||||||
|
"videoinput": "Kamera konnte nicht aktiviert werden"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"audioinput": "Ihr Mikrofon wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um es hier zu verwenden.",
|
||||||
|
"videoinput": "Ihre Kamera wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um sie hier zu verwenden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"close": "OK"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentifizierung erforderlich",
|
"heading": "Authentifizierung erforderlich",
|
||||||
@@ -379,6 +395,12 @@
|
|||||||
},
|
},
|
||||||
"any": {
|
"any": {
|
||||||
"started": "Aufzeichnung läuft"
|
"started": "Aufzeichnung läuft"
|
||||||
|
},
|
||||||
|
"limitReachedAlert": {
|
||||||
|
"title": "Aufnahmelimit überschritten",
|
||||||
|
"description": "Die Aufnahme hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
|
||||||
|
"durationMessage": " von {{duration}}",
|
||||||
|
"button": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"heading": "Konto",
|
"heading": "Konto",
|
||||||
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
|
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
|
||||||
"nameLabel": "Ihr Name",
|
"nameLabel": "Ihr Name",
|
||||||
"authentication": "Authentifizierung"
|
"authentication": "Authentifizierung",
|
||||||
|
"nameError": "Ihr Name darf nicht leer sein"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"microphone": {
|
"microphone": {
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"heading": "Lautsprecher",
|
"heading": "Lautsprecher",
|
||||||
"label": "Wählen Sie Ihre Audioausgabe",
|
"label": "Wählen Sie Ihre Audioausgabe",
|
||||||
"test": "Testen",
|
"test": "Testen",
|
||||||
"ongoingTest": "Soundtest läuft…"
|
"ongoingTest": "Soundtest läuft…",
|
||||||
|
"safariWarning": "Die Lautsprecherauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
|
||||||
},
|
},
|
||||||
"permissionsRequired": "Berechtigungen erforderlich"
|
"permissionsRequired": "Berechtigungen erforderlich"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"started": "{{name}} started the meeting transcription.",
|
"started": "{{name}} started the meeting transcription.",
|
||||||
"stopped": "{{name}} stopped the meeting transcription."
|
"stopped": "{{name}} stopped the meeting transcription.",
|
||||||
|
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"started": "{{name}} started the meeting recording.",
|
"started": "{{name}} started the meeting recording.",
|
||||||
"stopped": "{{name}} stopped the meeting recording."
|
"stopped": "{{name}} stopped the meeting recording.",
|
||||||
|
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"title": "Recording unavailable",
|
"title": "Recording unavailable",
|
||||||
"body": "This recording could not be found or was deleted."
|
"body": "The recording is unavailable or may have been deleted. Only the meeting organizer can access it. Feel free to contact them if needed."
|
||||||
},
|
},
|
||||||
"expired": {
|
"expired": {
|
||||||
"title": "Recording expired",
|
"title": "Recording expired",
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
"title": "Your recording is ready!",
|
"title": "Your recording is ready!",
|
||||||
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
|
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
|
||||||
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
|
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
|
||||||
"button": "Download"
|
"button": "Download",
|
||||||
|
"warning": {
|
||||||
|
"title": "Link sharing disabled",
|
||||||
|
"body": "Sharing the recording via link is not yet available. Only organizers can download it."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "You have left the meeting",
|
"heading": {
|
||||||
|
"normal": "You have left the meeting",
|
||||||
|
"duplicateIdentity": "You have joined the meeting from another device"
|
||||||
|
},
|
||||||
"home": "Return to home",
|
"home": "Return to home",
|
||||||
"back": "Rejoin the meeting"
|
"back": "Rejoin the meeting"
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,19 @@
|
|||||||
"description": "Share this link with people you want to invite to the meeting.",
|
"description": "Share this link with people you want to invite to the meeting.",
|
||||||
"permissions": "People with this link do not need your permission to join this meeting."
|
"permissions": "People with this link do not need your permission to join this meeting."
|
||||||
},
|
},
|
||||||
|
"mediaErrorDialog": {
|
||||||
|
"DeviceInUse": {
|
||||||
|
"title": {
|
||||||
|
"audioinput": "Microphone Activation Failed",
|
||||||
|
"videoinput": "Camera Activation Failed"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"audioinput": "Your microphone is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here.",
|
||||||
|
"videoinput": "Your camera is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"close": "OK"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentication Required",
|
"heading": "Authentication Required",
|
||||||
@@ -379,6 +395,12 @@
|
|||||||
},
|
},
|
||||||
"any": {
|
"any": {
|
||||||
"started": "Recording in progress"
|
"started": "Recording in progress"
|
||||||
|
},
|
||||||
|
"limitReachedAlert": {
|
||||||
|
"title": "Recording limit exceeded",
|
||||||
|
"description": "The recording has exceeded the maximum allowed duration{{duration_message}}. It will now be saved automatically.",
|
||||||
|
"durationMessage": " of {{duration}}",
|
||||||
|
"button": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"heading": "Account",
|
"heading": "Account",
|
||||||
"youAreNotLoggedIn": "You are not logged in.",
|
"youAreNotLoggedIn": "You are not logged in.",
|
||||||
"nameLabel": "Your Name",
|
"nameLabel": "Your Name",
|
||||||
"authentication": "Authentication"
|
"authentication": "Authentication",
|
||||||
|
"nameError": "Your name cannot be empty"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"microphone": {
|
"microphone": {
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"heading": "Speakers",
|
"heading": "Speakers",
|
||||||
"label": "Select your audio output",
|
"label": "Select your audio output",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"ongoingTest": "Testing sound…"
|
"ongoingTest": "Testing sound…",
|
||||||
|
"safariWarning": "Speaker selection is not available yet on Safari due to browser limitations."
|
||||||
},
|
},
|
||||||
"permissionsRequired": "Permissions required"
|
"permissionsRequired": "Permissions required"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||||
"stopped": "{{name}} a arrêté la transcription de la réunion."
|
"stopped": "{{name}} a arrêté la transcription de la réunion.",
|
||||||
|
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
|
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
|
||||||
|
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"title": "Enregistrement indisponible",
|
"title": "Enregistrement indisponible",
|
||||||
"body": "Cet enregistrement est introuvable ou a été supprimé."
|
"body": "L’enregistrement est introuvable. Seul l’organisateur de la réunion peut y accéder. N’hésitez pas à le contacter si besoin."
|
||||||
},
|
},
|
||||||
"expired": {
|
"expired": {
|
||||||
"title": "Enregistrement expiré",
|
"title": "Enregistrement expiré",
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
"title": "Votre enregistrement est prêt !",
|
"title": "Votre enregistrement est prêt !",
|
||||||
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
|
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
|
||||||
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
|
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
|
||||||
"button": "Télécharger"
|
"button": "Télécharger",
|
||||||
|
"warning": {
|
||||||
|
"title": "Partage via lien désactivé",
|
||||||
|
"body": "Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "Vous avez quitté la réunion",
|
"heading": {
|
||||||
|
"normal": "Vous avez quitté la réunion",
|
||||||
|
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
|
||||||
|
},
|
||||||
"home": "Retourner à l'accueil",
|
"home": "Retourner à l'accueil",
|
||||||
"back": "Réintégrer la réunion"
|
"back": "Réintégrer la réunion"
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,19 @@
|
|||||||
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
|
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
|
||||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
||||||
},
|
},
|
||||||
|
"mediaErrorDialog": {
|
||||||
|
"DeviceInUse": {
|
||||||
|
"title": {
|
||||||
|
"audioinput": "Activation microphone impossible",
|
||||||
|
"videoinput": "Activation caméra impossible"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"audioinput": "Votre microphone est déjà utilisé dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici.",
|
||||||
|
"videoinput": "Votre caméra est déjà utilisée dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"close": "OK"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentification requise",
|
"heading": "Authentification requise",
|
||||||
@@ -379,6 +395,12 @@
|
|||||||
},
|
},
|
||||||
"any": {
|
"any": {
|
||||||
"started": "Enregistrement en cours"
|
"started": "Enregistrement en cours"
|
||||||
|
},
|
||||||
|
"limitReachedAlert": {
|
||||||
|
"title": "Limite d'enregistrement dépassée",
|
||||||
|
"description": "L'enregistrement a dépassé la durée maximale autorisée{{duration_message}}. Il va maintenant être automatiquement sauvegardé.",
|
||||||
|
"durationMessage": " de {{duration}}",
|
||||||
|
"button": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"heading": "Compte",
|
"heading": "Compte",
|
||||||
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
|
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
|
||||||
"nameLabel": "Votre Nom",
|
"nameLabel": "Votre Nom",
|
||||||
"authentication": "Authentification"
|
"authentication": "Authentification",
|
||||||
|
"nameError": "Votre Nom ne peut pas être vide"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"microphone": {
|
"microphone": {
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"heading": "Haut-parleurs",
|
"heading": "Haut-parleurs",
|
||||||
"label": "Sélectionner votre sortie audio",
|
"label": "Sélectionner votre sortie audio",
|
||||||
"test": "Tester",
|
"test": "Tester",
|
||||||
"ongoingTest": "Test du son…"
|
"ongoingTest": "Test du son…",
|
||||||
|
"safariWarning": "La sélection du haut-parleur n'est pas encore disponible sur Safari en raison de limitations du navigateur."
|
||||||
},
|
},
|
||||||
"permissionsRequired": "Autorisations nécessaires"
|
"permissionsRequired": "Autorisations nécessaires"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||||
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
|
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
|
||||||
|
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
||||||
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
|
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
|
||||||
|
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"title": "Opname niet beschikbaar",
|
"title": "Opname niet beschikbaar",
|
||||||
"body": "Deze opname is niet gevonden of is verwijderd."
|
"body": "De opname is niet beschikbaar of is mogelijk verwijderd. Alleen de organisator van de vergadering heeft toegang. Neem gerust contact met hem of haar op als dat nodig is."
|
||||||
},
|
},
|
||||||
"expired": {
|
"expired": {
|
||||||
"title": "Opname verlopen",
|
"title": "Opname verlopen",
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
"title": "Je opname is klaar!",
|
"title": "Je opname is klaar!",
|
||||||
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
|
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
|
||||||
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
|
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
|
||||||
"button": "Downloaden"
|
"button": "Downloaden",
|
||||||
|
"warning": {
|
||||||
|
"title": "Delen via link uitgeschakeld",
|
||||||
|
"body": "Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"heading": "Je hebt de vergadering verlaten",
|
"heading": {
|
||||||
|
"normal": "Je hebt de vergadering verlaten",
|
||||||
|
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
|
||||||
|
},
|
||||||
"home": "Keer terug naar het hoofdscherm",
|
"home": "Keer terug naar het hoofdscherm",
|
||||||
"back": "Sluit weer bij de vergadering aan"
|
"back": "Sluit weer bij de vergadering aan"
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,19 @@
|
|||||||
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
|
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
|
||||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
|
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
|
||||||
},
|
},
|
||||||
|
"mediaErrorDialog": {
|
||||||
|
"DeviceInUse": {
|
||||||
|
"title": {
|
||||||
|
"audioinput": "Microfoon activeren mislukt",
|
||||||
|
"videoinput": "Camera activeren mislukt"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"audioinput": "Je microfoon wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je microfoon hier te gebruiken.",
|
||||||
|
"videoinput": "Je camera wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je camera hier te gebruiken."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"close": "OK"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Verificatie vereist",
|
"heading": "Verificatie vereist",
|
||||||
@@ -379,6 +395,12 @@
|
|||||||
},
|
},
|
||||||
"any": {
|
"any": {
|
||||||
"started": "Opname bezig"
|
"started": "Opname bezig"
|
||||||
|
},
|
||||||
|
"limitReachedAlert": {
|
||||||
|
"title": "Opnamelimiet overschreden",
|
||||||
|
"description": "De opname heeft de maximaal toegestane duur{{duration_message}} overschreden. Deze wordt nu automatisch opgeslagen.",
|
||||||
|
"durationMessage": " van {{duration}}",
|
||||||
|
"button": "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participantTileFocus": {
|
"participantTileFocus": {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"heading": "Account",
|
"heading": "Account",
|
||||||
"youAreNotLoggedIn": "U bent niet ingelogd.",
|
"youAreNotLoggedIn": "U bent niet ingelogd.",
|
||||||
"nameLabel": "Uw naam",
|
"nameLabel": "Uw naam",
|
||||||
"authentication": "Authenticatie"
|
"authentication": "Authenticatie",
|
||||||
|
"nameError": "Uw naam mag niet leeg zijn"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"microphone": {
|
"microphone": {
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"heading": "Luidsprekers",
|
"heading": "Luidsprekers",
|
||||||
"label": "Selecteer uw audio-uitvoer",
|
"label": "Selecteer uw audio-uitvoer",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"ongoingTest": "Testgeluid ..."
|
"ongoingTest": "Testgeluid ...",
|
||||||
|
"safariWarning": "Luidsprekerselectie is nog niet beschikbaar op Safari vanwege browserbeperkingen."
|
||||||
},
|
},
|
||||||
"permissionsRequired": "Machtigingen vereist"
|
"permissionsRequired": "Machtigingen vereist"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export const text = cva({
|
|||||||
note: {
|
note: {
|
||||||
color: 'default.subtle-text',
|
color: 'default.subtle-text',
|
||||||
},
|
},
|
||||||
|
warning: {
|
||||||
|
color: 'danger.subtle-text',
|
||||||
|
},
|
||||||
smNote: {
|
smNote: {
|
||||||
color: 'default.subtle-text',
|
color: 'default.subtle-text',
|
||||||
textStyle: 'sm',
|
textStyle: 'sm',
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export const routes: Record<
|
|||||||
feedback: {
|
feedback: {
|
||||||
name: 'feedback',
|
name: 'feedback',
|
||||||
path: '/feedback',
|
path: '/feedback',
|
||||||
|
to: (params: { duplicateIdentity?: false }) =>
|
||||||
|
'/feedback' +
|
||||||
|
(params.duplicateIdentity
|
||||||
|
? `?duplicateIdentity=${params?.duplicateIdentity}`
|
||||||
|
: ''),
|
||||||
Component: FeedbackRoute,
|
Component: FeedbackRoute,
|
||||||
},
|
},
|
||||||
legalTerms: {
|
legalTerms: {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</mj-text>
|
</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
{% blocktrans %}
|
||||||
|
Sharing the recording via link is not yet available. Only organizers can download it.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</mj-text>
|
||||||
<mj-text>
|
<mj-text>
|
||||||
<p>{% trans "To keep this recording permanently:" %}</p>
|
<p>{% trans "To keep this recording permanently:" %}</p>
|
||||||
<ol>
|
<ol>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.5.4",
|
"@html-to/text-cli": "0.5.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "0.1.28",
|
"version": "0.1.30",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "0.1.28"
|
version = "0.1.30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class TaskCreation(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
sub: str
|
sub: str
|
||||||
version: Optional[int] = 2
|
version: Optional[int] = 2
|
||||||
|
room: Optional[str]
|
||||||
|
recording_date: Optional[str]
|
||||||
|
recording_time: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks")
|
router = APIRouter(prefix="/tasks")
|
||||||
@@ -33,8 +36,16 @@ async def create_task(request: TaskCreation):
|
|||||||
request.filename, request.email, request.sub
|
request.filename, request.email, request.sub
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
task = process_audio_transcribe_summarize_v2.delay(
|
task = process_audio_transcribe_summarize_v2.apply_async(
|
||||||
request.filename, request.email, request.sub, time.time()
|
args=[
|
||||||
|
request.filename,
|
||||||
|
request.email,
|
||||||
|
request.sub,
|
||||||
|
time.time(),
|
||||||
|
request.room,
|
||||||
|
request.recording_date,
|
||||||
|
request.recording_time,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"id": task.id, "message": "Task created"}
|
return {"id": task.id, "message": "Task created"}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ def get_analytics():
|
|||||||
return Analytics()
|
return Analytics()
|
||||||
|
|
||||||
|
|
||||||
class TasksTracker:
|
class MetadataManager:
|
||||||
"""Tracks task execution metadata and analytics for background tasks."""
|
"""A Redis-based metadata manager for storing and retrieving task metadata."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the task tracker with analytics client."""
|
"""Initialize the task tracker with analytics client."""
|
||||||
@@ -70,13 +70,27 @@ class TasksTracker:
|
|||||||
return f"{self._key_prefix}{task_id}"
|
return f"{self._key_prefix}{task_id}"
|
||||||
|
|
||||||
def _save_metadata(self, task_id, metadata):
|
def _save_metadata(self, task_id, metadata):
|
||||||
"""Wip."""
|
"""Save metadata for a specific task to Redis."""
|
||||||
self._redis.hset(self._get_redis_key(task_id), mapping=metadata)
|
self._redis.hset(self._get_redis_key(task_id), mapping=metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_value(value):
|
||||||
|
"""Convert a string value to the most appropriate Python type."""
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
|
||||||
def _get_metadata(self, task_id):
|
def _get_metadata(self, task_id):
|
||||||
"""Wip."""
|
"""Retrieve and parse metadata for a specific task from Redis."""
|
||||||
raw_metadata = self._redis.hgetall(self._get_redis_key(task_id))
|
raw_metadata = self._redis.hgetall(self._get_redis_key(task_id))
|
||||||
return {k.decode("utf-8"): v.decode("utf-8") for k, v in raw_metadata.items()}
|
return {
|
||||||
|
k.decode("utf-8"): self._convert_value(v.decode("utf-8"))
|
||||||
|
for k, v in raw_metadata.items()
|
||||||
|
}
|
||||||
|
|
||||||
def has_task_id(self, task_id):
|
def has_task_id(self, task_id):
|
||||||
"""Check if task_id exists in tasks metadata cache."""
|
"""Check if task_id exists in tasks metadata cache."""
|
||||||
@@ -93,12 +107,13 @@ class TasksTracker:
|
|||||||
"retries": 0,
|
"retries": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
_required_args_count = 4
|
_required_args_count = 7
|
||||||
if len(task_args) != _required_args_count:
|
if len(task_args) != _required_args_count:
|
||||||
logger.error("Invalid number of arguments.")
|
logger.error("Invalid number of arguments.")
|
||||||
return
|
return
|
||||||
|
|
||||||
filename, email, _, received_at = task_args
|
filename, email, _, received_at, *_ = task_args
|
||||||
|
|
||||||
initial_metadata = {
|
initial_metadata = {
|
||||||
**initial_metadata,
|
**initial_metadata,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
@@ -182,9 +197,7 @@ class TasksTracker:
|
|||||||
metadata = self._get_metadata(task_id)
|
metadata = self._get_metadata(task_id)
|
||||||
|
|
||||||
if "start_time" in metadata:
|
if "start_time" in metadata:
|
||||||
metadata["execution_time"] = str(round(
|
metadata["execution_time"] = round(time.time() - metadata["start_time"], 2)
|
||||||
time.time() - float(metadata["start_time"]), 2
|
|
||||||
))
|
|
||||||
del metadata["start_time"]
|
del metadata["start_time"]
|
||||||
|
|
||||||
metadata["task_id"] = task_id
|
metadata["task_id"] = task_id
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Celery Config."""
|
||||||
|
|
||||||
|
# https://github.com/danihodovic/celery-exporter
|
||||||
|
# Enable task events for Prometheus monitoring via celery-exporter.
|
||||||
|
|
||||||
|
# worker_send_task_events: Sends task lifecycle events (e.g., started, succeeded),
|
||||||
|
# allowing the exporter to track task execution metrics and durations.
|
||||||
|
worker_send_task_events = True
|
||||||
|
|
||||||
|
# task_send_sent_event: Sends an event when a task is dispatched to the broker,
|
||||||
|
# enabling full lifecycle tracking from submission to completion (including queue time).
|
||||||
|
task_send_sent_event = True
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Celery workers."""
|
"""Celery workers."""
|
||||||
|
|
||||||
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
@@ -16,14 +19,14 @@ from requests import Session, exceptions
|
|||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util import Retry
|
from urllib3.util import Retry
|
||||||
|
|
||||||
from summary.core.analytics import TasksTracker, get_analytics
|
from summary.core.analytics import MetadataManager, get_analytics
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.prompt import get_instructions
|
from summary.core.prompt import get_instructions
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
analytics = get_analytics()
|
analytics = get_analytics()
|
||||||
|
|
||||||
tasks_tracker = TasksTracker()
|
metadata_manager = MetadataManager()
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +37,8 @@ celery = Celery(
|
|||||||
broker_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
celery.config_from_object("summary.core.celery_config")
|
||||||
|
|
||||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||||
|
|
||||||
@signals.celeryd_init.connect
|
@signals.celeryd_init.connect
|
||||||
@@ -136,19 +141,19 @@ def post_with_retries(url, data):
|
|||||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||||
"""Signal handler called before task execution begins."""
|
"""Signal handler called before task execution begins."""
|
||||||
task_args = args or []
|
task_args = args or []
|
||||||
tasks_tracker.create(task_id, task_args)
|
metadata_manager.create(task_id, task_args)
|
||||||
|
|
||||||
|
|
||||||
@signals.task_retry.connect
|
@signals.task_retry.connect
|
||||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||||
"""Signal handler called when task execution retries."""
|
"""Signal handler called when task execution retries."""
|
||||||
tasks_tracker.retry(request.id)
|
metadata_manager.retry(request.id)
|
||||||
|
|
||||||
|
|
||||||
@signals.task_failure.connect
|
@signals.task_failure.connect
|
||||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||||
"""Signal handler called when task execution fails permanently."""
|
"""Signal handler called when task execution fails permanently."""
|
||||||
tasks_tracker.capture(task_id, settings.posthog_event_failure)
|
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(max_retries=settings.celery_max_retries)
|
@celery.task(max_retries=settings.celery_max_retries)
|
||||||
@@ -233,7 +238,14 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
|||||||
max_retries=settings.celery_max_retries,
|
max_retries=settings.celery_max_retries,
|
||||||
)
|
)
|
||||||
def process_audio_transcribe_summarize_v2(
|
def process_audio_transcribe_summarize_v2(
|
||||||
self, filename: str, email: str, sub: str, received_at: float
|
self,
|
||||||
|
filename: str,
|
||||||
|
email: str,
|
||||||
|
sub: str,
|
||||||
|
received_at: float,
|
||||||
|
room: Optional[str],
|
||||||
|
recording_date: Optional[str],
|
||||||
|
recording_time: Optional[str],
|
||||||
):
|
):
|
||||||
"""Process an audio file by transcribing it and generating a summary.
|
"""Process an audio file by transcribing it and generating a summary.
|
||||||
|
|
||||||
@@ -267,9 +279,12 @@ def process_audio_transcribe_summarize_v2(
|
|||||||
logger.debug("Recording filepath: %s", temp_file_path)
|
logger.debug("Recording filepath: %s", temp_file_path)
|
||||||
|
|
||||||
audio_file = File(temp_file_path)
|
audio_file = File(temp_file_path)
|
||||||
tasks_tracker.track(task_id, {"audio_length": audio_file.info.length})
|
metadata_manager.track(task_id, {"audio_length": audio_file.info.length})
|
||||||
|
|
||||||
if audio_file.info.length > settings.recording_max_duration:
|
if (
|
||||||
|
settings.recording_max_duration is not None
|
||||||
|
and audio_file.info.length > settings.recording_max_duration
|
||||||
|
):
|
||||||
error_msg = "Recording too long: %.2fs > %.2fs limit" % (
|
error_msg = "Recording too long: %.2fs > %.2fs limit" % (
|
||||||
audio_file.info.length,
|
audio_file.info.length,
|
||||||
settings.recording_max_duration,
|
settings.recording_max_duration,
|
||||||
@@ -291,7 +306,7 @@ def process_audio_transcribe_summarize_v2(
|
|||||||
transcription = openai_client.audio.transcriptions.create(
|
transcription = openai_client.audio.transcriptions.create(
|
||||||
model=settings.openai_asr_model, file=audio_file
|
model=settings.openai_asr_model, file=audio_file
|
||||||
)
|
)
|
||||||
tasks_tracker.track(
|
metadata_manager.track(
|
||||||
task_id,
|
task_id,
|
||||||
{
|
{
|
||||||
"transcription_time": round(
|
"transcription_time": round(
|
||||||
@@ -312,10 +327,18 @@ def process_audio_transcribe_summarize_v2(
|
|||||||
else format_segments(transcription)
|
else format_segments(transcription)
|
||||||
)
|
)
|
||||||
|
|
||||||
tasks_tracker.track_transcription_metadata(task_id, transcription)
|
metadata_manager.track_transcription_metadata(task_id, transcription)
|
||||||
|
|
||||||
|
if not room or not recording_date or not recording_time:
|
||||||
|
title = settings.document_default_title
|
||||||
|
else:
|
||||||
|
title = settings.document_title_template.format(
|
||||||
|
room=room,
|
||||||
|
room_recording_date=recording_date,
|
||||||
|
room_recording_time=recording_time,
|
||||||
|
)
|
||||||
data = {
|
data = {
|
||||||
"title": "Transcription",
|
"title": title,
|
||||||
"content": formatted_transcription,
|
"content": formatted_transcription,
|
||||||
"email": email,
|
"email": email,
|
||||||
"sub": sub,
|
"sub": sub,
|
||||||
@@ -329,6 +352,6 @@ def process_audio_transcribe_summarize_v2(
|
|||||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||||
logger.debug("Response body: %s", response.text)
|
logger.debug("Response body: %s", response.text)
|
||||||
|
|
||||||
tasks_tracker.capture(task_id, settings.posthog_event_success)
|
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||||
|
|
||||||
# TODO - integrate summarize the transcript and create a new document.
|
# TODO - integrate summarize the transcript and create a new document.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Application configuration and settings."""
|
"""Application configuration and settings."""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Annotated, Optional, List
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
|||||||
app_api_token: str
|
app_api_token: str
|
||||||
|
|
||||||
# Audio recordings
|
# Audio recordings
|
||||||
recording_max_duration: int = 5400 # 1h30
|
recording_max_duration: Optional[int] = None
|
||||||
|
|
||||||
# Celery settings
|
# Celery settings
|
||||||
celery_broker_url: str = "redis://redis/0"
|
celery_broker_url: str = "redis://redis/0"
|
||||||
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
|
|||||||
webhook_api_token: str
|
webhook_api_token: str
|
||||||
webhook_url: str
|
webhook_url: str
|
||||||
|
|
||||||
|
# Output related settings
|
||||||
|
document_default_title: Optional[str] = "Transcription"
|
||||||
|
document_title_template: Optional[str] = (
|
||||||
|
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||||
|
)
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
sentry_is_enabled: bool = False
|
sentry_is_enabled: bool = False
|
||||||
sentry_dsn: Optional[str] = None
|
sentry_dsn: Optional[str] = None
|
||||||
|
|||||||
Reference in New Issue
Block a user