commit 42772a31edabbb7cdb1ac30cebeb0dff05aa4a53 Author: InnovA AI Date: Mon Mar 16 21:47:37 2026 +0000 Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1a676ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,63 @@ +# Git +.git +.gitignore + +# Docker +.dockerignore +Dockerfile +docker-compose*.yml + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +venv/ +.venv/ + +# Data directories (mounted as volumes) +data/ +uploads/ +instance/ +exports/ +auto-process/ + +# Non-runtime directories +temp/ +docs/ +site/ +plan/ +unraid/ +tests/ +.claude/ +.migrate/ +.github/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo + +# Logs and misc +*.log +*.md +!requirements*.txt +LICENSE +CLAUDE.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8653a3f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Ensure shell scripts always use LF line endings, even on Windows +*.sh text eol=lf + +# Ensure Python files use LF line endings +*.py text eol=lf + +# Docker files should use LF +Dockerfile text eol=lf +docker-compose*.yml text eol=lf +.dockerignore text eol=lf + +# Config files should use LF +*.example text eol=lf +*.conf text eol=lf +*.config text eol=lf + +# Documentation uses LF +*.md text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary +*.gif binary +*.webp binary +*.db binary +*.pyc binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6326e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +.playwright-mcp/ +venv/ +__pycache__/ +instance/ +uploads/ +*.db +*.db-journal +__pycache__/ +*.pyc +*.log +*.env +.migrate/ +project_files.md +*.md +notes.md +docker-compose.yml +changes.txt + +!README.md +!CLA.md +!CONTRIBUTING.md +!CHANGES.md +!UPSTREAM-SYNC.md +!.github/CLA-SETUP.md +!docs/**/*.md +!deployment/**/*.md +!client_docs/**/*.md +!client_docs/*.md +docker-compose.dev.yml +docker-compose.lite.yml +docker-compose.postgres.yml +.clinerules +temp/ +.claude/ +plan/ + +# Offline vendor dependencies (downloaded during Docker build) +static/vendor/ + +# Docs build artifacts +docs/_site/ +docs/.jekyll-cache/ +docs/README.md +site/ +.cache/ +docs/overrides/.cache/ + +# Documentation deployment files (examples only) +docs/.github-deploy.yml + +# Documentation conversion scripts (one-time use) +scripts/convert_to_mkdocs.py +unraid/ + +# Internal ops files — do not commit to public repo +CLAUDE.md +UPSTREAM-SYNC.md +sync-upstream.sh +scripts/pre-sync-check.sh +scripts/check-dictia.sh diff --git a/.upstream-version b/.upstream-version new file mode 100644 index 0000000..d8fc6e6 --- /dev/null +++ b/.upstream-version @@ -0,0 +1 @@ +v0.8.14-alpha diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..59d20cc --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,121 @@ +# CHANGES — DictIA (fork de Speakr) + +Ce fichier documente les modifications apportees par InnovA AI au projet +[Speakr](https://github.com/murtaza-nasir/speakr) original, conformement a +la licence AGPL-3.0 (section 5). + +--- + +## [dictia-fr-prompts] — 2026-03-09 + +### Traduction FR et amelioration des prompts LLM + +**Prompts traduits et optimises** : +- Prompt de resume fallback : traduit en FR avec sections claires (Sujets, Decisions, Actions) +- Message systeme de resume : role prompting en FR ("expert en redaction de comptes rendus") +- Prompt d'identification des locuteurs : traduit en FR avec exemples francophones +- Prompt d'extraction d'evenements : traduit en FR avec exemples negatifs/positifs adaptes +- Message systeme d'extraction d'evenements : traduit en FR +- Labels de contexte (date, dossier, etiquettes, info utilisateur) : tous traduits en FR +- Directives linguistiques traduites en FR + +**Templates traduits (fallback text)** : +- `account.html` : section Nommage complete, Hotwords, Invite initiale, langues, poste, entreprise +- `account.html` : liste des langues de transcription (Anglais, Francais, etc.) +- `account.html` : onglets Transcription/Exportation/Nommage +- `admin.html` : tags d'equipe (langue, intervenants, retention, partage auto, protection) +- `fr.json` : correction "Created" → "Cree" + +--- + +## [v0.8.13-sync] — 2026-02-26 + +### Sync upstream Speakr v0.8.9-alpha → v0.8.13-alpha + +Mise a jour majeure incluant 4 releases upstream : + +**Nouvelles features (depuis upstream)** : +- Video retention (`VIDEO_RETENTION=true`) — garde les videos pour lecture in-browser +- Uploads paralleles (`MAX_CONCURRENT_UPLOADS=3`) +- Detection de doublons SHA-256 +- Speaker API avec authentification bearer token +- Volume controls sur tous les players audio/video +- Speaker search/filter sur la page management +- Auto-scroll follow-along sur les pages partagees +- Fix ffprobe timeout dynamique pour gros fichiers video + +**Bugfixes upstream inclus** : +- Fix enregistrements incognito pas affiches apres transcription +- Fix parametre langue pour connecteurs sans diarisation +- Fix defaults PostgreSQL double-quotes +- Fix MAX_CONTENT_LENGTH pas mis a jour +- Fix copyright dynamique sur pages partagees + +### Reorganisation du repo + +- **Branche unique `dictia`** : remplace les 6 anciennes branches (dictia-branding, dictia-deployment, feature/defaults-fr, feature/loi25-audit-trail, feat/logo-dictia, fix/logo-size) +- **Miroir upstream** : nouveau repo `Innova-AI/speakr-upstream` sur Gitea, sync auto toutes les 8h +- **Process simplifie** : mise a jour upstream = 3 commandes (fetch + merge + build) +- Anciennes branches supprimees, `dictia` est la branche par defaut +- Documentation mise a jour (CLAUDE.md, UPSTREAM-SYNC.md) + +--- + +## [dictia-branding] — 2026-02-11 + +### Rebranding visuel Speakr → DictIA + +Toutes les occurrences visuelles modifiees : + +**Titres de pages** : index, login, register, account, admin, group-admin, inquire, auth/* +**Headers et logos** : header principal, login, register, account, admin, group-admin, inquire, share, auth/* +**PWA** : manifest.json, sw.js, offline.html, loading_overlay.html +**Traductions** : footer d export dans les 6 fichiers locales +**Logo** : logo-dictia.png (micro + ondes + reseau IA) +**Traductions FR** : page login complete, modal aide audio systeme + +### Footer legal (Loi 25 du Quebec + AGPL-3.0) + +Footer sur toutes les pages contenant : +- Lien vers le code source (obligation AGPL-3.0) +- Lien Politique de confidentialite (Loi 25) +- Lien Conditions d utilisation (Loi 25) + +--- + +## [dictia-audit] — 2026-02-11 + +### Audit trail Loi 25 + +- `src/models/access_log.py` : modele AccessLog +- `src/models/auth_log.py` : modele AuthLog +- `src/api/audit.py` : endpoints API audit +- Integration dans `src/app.py` (ENABLE_AUDIT_LOG) + +--- + +## [dictia-defaults] — 2026-02-11 + +### Defaults FR + +- `src/init_db.py` : dossiers actives, prompt FR structure, limites augmentees +- `enable_folders=true`, `max_file_size_mb=10000`, `transcript_length_limit=50000` + +--- + +## [dictia-deployment] — 2026-02-11 + +### Infrastructure de deploiement + +- Docker Compose : cloud, local-gpu, local-cpu +- ASR Proxy GCP GPU (fallback multi-zone Canada) +- Securite : iptables, Docker log rotation +- Nginx reverse proxy + Tailscale HTTPS +- Scripts : backup, restore, update, health-check +- Documentation : quickstart, VPS setup, maintenance, troubleshooting + +--- + +Projet original : Speakr par Murtaza Nasir — https://github.com/murtaza-nasir/speakr +Copyright original : (c) Speakr contributors, sous licence AGPL-3.0 +Modifications : (c) 2026 InnovA AI — https://innova-ai.ca diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9a38473 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +############################################################################### +# Stage 1: Builder — install Python deps and download vendor assets +############################################################################### +FROM python:3.11-slim AS builder + +ARG PRODUCTION=0 +ARG LIGHTWEIGHT=1 + +WORKDIR /app + +# gcc is needed to compile C extensions during pip install +RUN apt-get update && apt-get install -y --no-install-recommends gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt requirements-embeddings.txt constraints.txt ./ +RUN pip install --no-cache-dir --prefix=/install -c constraints.txt -r requirements.txt && \ + if [ "$LIGHTWEIGHT" = "0" ]; then \ + pip install --no-cache-dir --prefix=/install -c constraints.txt -r requirements-embeddings.txt; \ + fi + +# Download vendor assets (JS/CSS/fonts) +RUN mkdir -p /app/static/vendor +COPY scripts/download_offline_deps.py scripts/ +RUN pip install --no-cache-dir requests && \ + PRODUCTION=${PRODUCTION} python scripts/download_offline_deps.py && \ + echo "✓ Vendor dependencies downloaded successfully" + +############################################################################### +# Stage 2: FFmpeg — download static binaries (much smaller than apt ffmpeg) +############################################################################### +FROM python:3.11-slim AS ffmpeg-stage + +RUN apt-get update && apt-get install -y --no-install-recommends wget xz-utils \ + && rm -rf /var/lib/apt/lists/* \ + && ARCH=$(dpkg --print-architecture) \ + && wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ARCH}-static.tar.xz -O /tmp/ff.tar.xz \ + && mkdir -p /tmp/ffmpeg-dir \ + && tar xf /tmp/ff.tar.xz -C /tmp/ffmpeg-dir --strip-components=1 \ + && mv /tmp/ffmpeg-dir/ffmpeg /usr/local/bin/ffmpeg \ + && mv /tmp/ffmpeg-dir/ffprobe /usr/local/bin/ffprobe \ + && chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \ + && rm -rf /tmp/ff.tar.xz /tmp/ffmpeg-dir + +############################################################################### +# Stage 3: Runtime — lean final image with only what's needed +############################################################################### +FROM python:3.11-slim + +WORKDIR /app + +# Copy static ffmpeg binaries (~150MB vs ~450MB from apt) +COPY --from=ffmpeg-stage /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg +COPY --from=ffmpeg-stage /usr/local/bin/ffprobe /usr/local/bin/ffprobe + +# Copy installed Python packages from builder +COPY --from=builder /install /usr/local + +# Copy downloaded vendor assets from builder +COPY --from=builder /app/static/vendor /app/static/vendor + +# Copy application code +COPY . . + +# Create necessary directories +RUN mkdir -p /data/uploads /data/instance && chmod 755 /data/uploads /data/instance + +# Set environment variables +ENV FLASK_APP=src/app.py +ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +ENV UPLOAD_FOLDER=/data/uploads +ENV PYTHONPATH=/app +ENV HF_HOME=/data/instance/huggingface + +# Add entrypoint script +COPY scripts/docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 8899 + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["gunicorn", "--workers", "3", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..178d8f7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +DictIA — Transcription Audio par IA +Copyright (C) 2026 InnovA AI + +AGPL-3.0 — voir LICENSE +Oeuvre originale: github.com/murtaza-nasir/speakr (C) 2024-2026 Murtaza Nasir diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5ae33a --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# DictIA — Transcription Audio par IA + +Application de transcription audio propulsee par l'intelligence artificielle. Transformez vos enregistrements en texte structure avec identification des locuteurs, resumes automatiques et recherche semantique. + +## Fonctionnalites + +- Transcription automatique avec diarisation (identification des locuteurs) +- Resumes et points d'action generes par IA +- Recherche semantique dans vos transcriptions (mode Inquire) +- Interface web moderne (PWA installable) +- Support multilingue (francais, anglais, allemand, espagnol, russe, chinois) +- Gestion des dossiers, tags et partage +- Conformite Loi 25 (Quebec) — journal d'audit integre +- 100% auto-heberge — vos donnees restent chez vous + +## Demarrage rapide + +Voir le [guide de demarrage](client_docs/guide-utilisateur/premiers-pas.md). + +## Documentation + +- [Guide utilisateur](client_docs/guide-utilisateur/index.md) +- [Guide administrateur](client_docs/guide-admin/index.md) +- [Depannage](client_docs/depannage/index.md) + +## Deploiement + +Voir le [guide de deploiement](deployment/README.md) et les profils Docker dans `deployment/profiles/`. + +## Licence + +AGPL-3.0 — voir [LICENSE](LICENSE). +Base sur le projet open source [Speakr](https://github.com/murtaza-nasir/speakr) par Murtaza Nasir. + +Copyright (C) 2026 InnovA AI diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..d8fc6e6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v0.8.14-alpha diff --git a/client_docs/depannage/fonctionnalites.md b/client_docs/depannage/fonctionnalites.md new file mode 100644 index 0000000..b4c3a1d --- /dev/null +++ b/client_docs/depannage/fonctionnalites.md @@ -0,0 +1,175 @@ +# Dépannage : Fonctionnalités + +Ce guide couvre les problèmes liés aux fonctionnalités spécifiques de DictIA et les solutions pour les résoudre. + +--- + +## Identification des locuteurs + +### Les locuteurs ne sont pas identifiés + +Si vos transcriptions n'affichent aucune distinction entre les locuteurs (tout le texte est attribué à un seul intervenant), cela peut être dû à la configuration du service de transcription. + +**Que faire** : + +- Vérifiez que l'identification des locuteurs est bien activée dans vos paramètres. Lors du téléversement ou du retraitement d'un enregistrement, assurez-vous que l'option d'identification des locuteurs est cochée. +- Vérifiez les paramètres de nombre de locuteurs. Si le nombre minimum et maximum sont tous les deux réglés à 1, l'identification est effectivement désactivée. Utilisez une plage raisonnable, par exemple 2 à 6 locuteurs pour la plupart des réunions. +- Si le problème persiste, contactez le support InnovA AI. + +### Tous les locuteurs affichent « LOCUTEUR INCONNU » + +Si la transcription affiche « LOCUTEUR INCONNU » au lieu de locuteurs numérotés (LOCUTEUR_00, LOCUTEUR_01, etc.), cela indique un problème de configuration du service de transcription côté serveur. + +**Que faire** : + +- Contactez le support InnovA AI pour vérifier la configuration du service de transcription. + +### Comment identifier les locuteurs après la transcription + +Après la transcription, les locuteurs apparaissent avec des étiquettes génériques (LOCUTEUR_01, LOCUTEUR_02, etc.). Pour les identifier : + +1. Cliquez sur l'étiquette d'un locuteur dans la transcription. +2. Attribuez-lui un nom (par exemple, « Marie Tremblay »). +3. Ce profil de locuteur est sauvegardé dans votre bibliothèque personnelle. +4. Lors de futurs enregistrements, DictIA peut suggérer automatiquement l'identité des locuteurs en fonction de leurs profils vocaux. + +Gérez votre bibliothèque de locuteurs dans *Paramètres du compte* > *Gestion des locuteurs*. + +--- + +## Partage d'enregistrements + +### Le bouton de partage est désactivé + +Le partage par lien externe nécessite que votre instance DictIA soit accessible via une connexion sécurisée (HTTPS). Si le bouton de partage est désactivé ou affiche une erreur, cela signifie que cette condition n'est pas remplie. + +**Que faire** : + +- Contactez le support InnovA AI pour configurer l'accès HTTPS sur votre instance. + +### Les liens de partage ne fonctionnent pas + +Si vous avez créé un lien de partage mais que les destinataires ne peuvent pas y accéder : + +**Que faire** : + +- Vérifiez que le lien n'a pas été révoqué. Consultez vos partages actifs dans l'interface de gestion des partages. +- Assurez-vous que le destinataire dispose d'une connexion internet et utilise un navigateur récent. +- Si le problème persiste, contactez le support InnovA AI. + +### Partage interne (entre utilisateurs) + +Pour partager un enregistrement avec un collègue qui dispose d'un compte DictIA : + +- Utilisez la fonction de partage interne plutôt que les liens externes. +- Recherchez l'utilisateur par son nom d'utilisateur et définissez le niveau d'accès souhaité : lecture seule, modification ou re-partage. +- Le destinataire verra l'enregistrement partagé dans sa liste. + +--- + +## Recherche IA (recherche sémantique) + +### La Recherche IA ne retourne aucun résultat + +La Recherche IA utilise une technologie de recherche sémantique qui comprend le sens et le contexte de vos questions, plutôt que de chercher des mots-clés exacts. + +**Causes possibles** : + +- **Enregistrements non indexés** : Vos enregistrements doivent être traités et indexés avant d'être consultables via la Recherche IA. Ce processus peut prendre un certain temps après la transcription. +- **Formulation de la question** : La Recherche IA fonctionne mieux avec des questions complètes plutôt qu'avec des mots isolés. + +**Que faire** : + +- Posez des questions complètes. Par exemple, « Qu'avons-nous décidé concernant le budget? » fonctionne mieux que simplement « budget décision ». +- Patientez quelques minutes après une nouvelle transcription pour que l'indexation soit complétée. +- Si aucun enregistrement n'apparaît jamais dans les résultats de recherche, contactez le support InnovA AI pour vérifier que la recherche sémantique est activée et fonctionnelle. + +### Résultats de recherche non pertinents + +**Que faire** : + +- Reformulez votre question de manière plus précise ou plus détaillée. +- Essayez différentes formulations pour le même sujet. +- Gardez en tête que la Recherche IA comprend le sens, pas les mots exacts. « Combien coûte le projet? » trouvera du contenu qui parle de « budget » ou de « montant estimé ». + +--- + +## Application mobile + +### Installation de l'application mobile + +DictIA est une application web progressive (PWA). Cela signifie que vous n'avez pas besoin de l'installer depuis un magasin d'applications. Pour l'installer sur votre appareil : + +**Sur iPhone/iPad (Safari)** : + +1. Ouvrez DictIA dans Safari. +2. Appuyez sur le bouton de partage (icône avec une flèche vers le haut). +3. Sélectionnez « Sur l'écran d'accueil ». + +**Sur Android (Chrome)** : + +1. Ouvrez DictIA dans Chrome. +2. Appuyez sur le menu (trois points) en haut à droite. +3. Sélectionnez « Installer l'application » ou « Ajouter à l'écran d'accueil ». + +### L'application mobile ne fonctionne pas correctement + +**Que faire** : + +- Assurez-vous d'utiliser un navigateur compatible (Safari sur iOS, Chrome sur Android). +- Vérifiez votre connexion internet. +- Essayez de supprimer l'application de votre écran d'accueil et de la réinstaller. +- Videz le cache du navigateur si l'application se comporte de manière inattendue. + +### L'enregistrement audio ne fonctionne pas sur mobile + +Certains navigateurs mobiles ont des restrictions sur l'accès au microphone. + +**Que faire** : + +- Assurez-vous que DictIA a la permission d'accéder au microphone de votre appareil. +- Sur iOS, utilisez Safari (les autres navigateurs peuvent avoir des limitations). +- Vérifiez que votre navigateur est à jour. + +--- + +## Connexion et accès au compte + +### Impossible de se connecter + +**Que faire** : + +- Vérifiez que vous utilisez le bon nom d'utilisateur et le bon mot de passe. Ceux-ci sont sensibles à la casse (majuscules/minuscules). +- Si vous avez oublié votre mot de passe, utilisez la fonction de réinitialisation du mot de passe sur la page de connexion (si disponible sur votre instance). +- Contactez votre administrateur ou le support InnovA AI si vous ne parvenez pas à récupérer l'accès à votre compte. + +### Compte verrouillé + +Si votre compte semble verrouillé ou désactivé : + +**Que faire** : + +- Contactez votre administrateur DictIA pour vérifier le statut de votre compte. + +--- + +## Avis d'enregistrement (conformité légale) + +Dans de nombreuses juridictions, vous devez informer les participants qu'ils sont enregistrés. DictIA permet d'afficher un avis de consentement avant tout enregistrement. + +Si l'avis d'enregistrement ne s'affiche pas alors qu'il devrait : + +**Que faire** : + +- Contactez votre administrateur ou le support InnovA AI pour activer et configurer l'avis d'enregistrement dans les paramètres système. + +--- + +## Besoin d'aide supplémentaire? + +Si les solutions ci-dessus ne résolvent pas votre problème, contactez le support InnovA AI en fournissant : + +- La description du problème et les étapes pour le reproduire. +- Le navigateur et l'appareil utilisés. +- Des captures d'écran si possible. +- Votre version de DictIA (disponible dans *Paramètres* > *À propos*). diff --git a/client_docs/depannage/index.md b/client_docs/depannage/index.md new file mode 100644 index 0000000..b8cf839 --- /dev/null +++ b/client_docs/depannage/index.md @@ -0,0 +1,49 @@ +# Dépannage + +Cette section vous aide à identifier et résoudre les problèmes courants que vous pourriez rencontrer avec DictIA. Les guides sont organisés par catégorie pour vous permettre de trouver rapidement la solution appropriée. + +--- + +## Guides de dépannage + +### [Problèmes de transcription](transcription.md) + +Résolvez les problèmes liés à la transcription de vos enregistrements : + +- Qualité de transcription insuffisante +- Problèmes de langue et de détection automatique +- Vocabulaire personnalisé et termes spécialisés +- Résumés qui ne se génèrent pas correctement +- Formats audio non reconnus + +### [Problèmes de performance](performance.md) + +Résolvez les problèmes de lenteur et de traitement : + +- Interface web lente ou saccadée +- Fichiers volumineux et délais de traitement +- Enregistrements bloqués dans la file d'attente +- Délais d'expiration sur les longs enregistrements + +### [Problèmes de fonctionnalités](fonctionnalites.md) + +Résolvez les problèmes liés aux fonctionnalités spécifiques : + +- Identification des locuteurs +- Partage d'enregistrements +- Recherche IA (recherche sémantique) +- Application mobile +- Connexion et accès au compte + +--- + +## Informations utiles pour le support + +Si les solutions proposées ne résolvent pas votre problème, contactez le support InnovA AI en fournissant les informations suivantes : + +- **Version de DictIA** : Disponible dans l'onglet *À propos* des paramètres de votre compte. +- **Description du problème** : Décrivez les étapes que vous avez suivies et le comportement observé. +- **Messages d'erreur** : Notez tout message d'erreur affiché à l'écran. +- **Navigateur utilisé** : Indiquez le navigateur et sa version (ex. : Chrome 120, Firefox 121). + +**Contact** : Communiquez avec l'équipe InnovA AI via les canaux de support mis à votre disposition par votre administrateur. diff --git a/client_docs/depannage/performance.md b/client_docs/depannage/performance.md new file mode 100644 index 0000000..bae06e3 --- /dev/null +++ b/client_docs/depannage/performance.md @@ -0,0 +1,93 @@ +# Dépannage : Performance + +Ce guide couvre les problèmes de performance courants dans DictIA et les solutions pour les résoudre. + +--- + +## Transcription lente + +Le temps de traitement d'une transcription dépend de plusieurs facteurs : + +- **Durée de l'enregistrement** : Plus le fichier audio est long, plus le traitement prend de temps. Un enregistrement d'une heure prend naturellement plus de temps qu'un enregistrement de 5 minutes. +- **Taille du fichier** : Les fichiers volumineux nécessitent plus de temps de transfert et de traitement. +- **Charge du système** : Si plusieurs utilisateurs téléversent des enregistrements simultanément, les temps de traitement augmentent. +- **Modèle de transcription** : Les modèles plus précis sont généralement plus lents. Un modèle optimisé pour la vitesse traite plus rapidement mais peut être légèrement moins précis. + +**Que faire** : + +- Patientez. La plupart des transcriptions se complètent en quelques minutes pour des enregistrements de durée normale (moins de 30 minutes). +- Pour les fichiers très volumineux, prévoyez un temps de traitement plus long. +- Si les transcriptions sont systématiquement très lentes, contactez le support InnovA AI. + +--- + +## Fichiers volumineux qui échouent + +Certains fichiers très volumineux (plus de 25 Mo) peuvent échouer lors du traitement si le service de transcription impose des limites de taille. + +**Que faire** : + +- DictIA peut être configuré pour découper automatiquement les fichiers volumineux en segments plus petits. Si vos fichiers échouent systématiquement, contactez le support InnovA AI pour activer ou ajuster le découpage automatique. +- Si possible, compressez vos fichiers audio avant de les téléverser (par exemple, convertissez un fichier WAV en MP3). + +--- + +## Délai d'expiration sur les longs enregistrements + +Les enregistrements très longs (plus de 30 minutes) peuvent dépasser le délai d'attente maximal du service de transcription. + +**Que faire** : + +- Si un enregistrement long échoue avec un message de délai d'expiration, contactez le support InnovA AI pour ajuster le paramètre de délai maximal. +- En attendant, vous pouvez découper votre enregistrement en segments plus courts (par exemple, par tranches de 30 minutes) et les téléverser séparément. + +--- + +## Enregistrements bloqués après un redémarrage + +Si le système a été redémarré pendant le traitement d'enregistrements, ceux-ci reprennent automatiquement au prochain démarrage. La file d'attente est persistante, donc aucun téléversement n'est perdu. + +**Que faire** : + +- Patientez quelques minutes après un redémarrage pour que les enregistrements en cours reprennent leur traitement. +- Si un enregistrement reste bloqué plus de 30 minutes après un redémarrage, contactez le support InnovA AI. + +--- + +## L'interface web est lente ou saccadée + +La performance du navigateur peut se dégrader dans certaines situations : + +### Transcriptions très longues + +Les enregistrements de plus de 2 heures génèrent un volume important de texte que certains navigateurs peinent à afficher de manière fluide. L'affichage en mode « bulles » pour les transcriptions avec identification des locuteurs est particulièrement exigeant en ressources. + +**Que faire** : + +- Utilisez l'affichage simple plutôt que l'affichage en bulles pour les transcriptions très longues. +- Fermez les autres onglets de votre navigateur pour libérer de la mémoire. + +### Cache du navigateur + +Le cache local peut devenir volumineux ou corrompu avec le temps, ce qui ralentit l'interface. + +**Que faire** : + +- Effectuez un rafraîchissement complet de la page avec **Ctrl+Maj+R** (Windows) ou **Cmd+Maj+R** (Mac). +- Videz le cache de votre navigateur si le problème persiste. + +### Navigateur et appareil + +- Assurez-vous d'utiliser une version récente de votre navigateur (Chrome, Firefox, Edge ou Safari). +- Sur les appareils mobiles ou les ordinateurs plus anciens, la performance peut être réduite avec de très longues transcriptions. + +--- + +## Besoin d'aide supplémentaire? + +Si les solutions ci-dessus ne résolvent pas votre problème, contactez le support InnovA AI en fournissant : + +- La description du problème et les étapes pour le reproduire. +- Le navigateur et l'appareil utilisés. +- La durée approximative de l'enregistrement concerné. +- Votre version de DictIA (disponible dans *Paramètres* > *À propos*). diff --git a/client_docs/depannage/transcription.md b/client_docs/depannage/transcription.md new file mode 100644 index 0000000..0c24c98 --- /dev/null +++ b/client_docs/depannage/transcription.md @@ -0,0 +1,136 @@ +# Dépannage : Transcription + +Ce guide couvre les problèmes courants liés à la transcription de vos enregistrements dans DictIA et les solutions à appliquer. + +--- + +## La transcription ne démarre pas + +Lorsqu'un enregistrement reste au statut « En attente » ou « En file d'attente » pendant une période prolongée, voici les causes possibles : + +**Statut « En file d'attente »** : Les enregistrements affichent ce statut lorsqu'ils attendent un processus de traitement disponible. Si plusieurs enregistrements sont en file d'attente, ils seront traités dans un ordre équitable entre les utilisateurs. Aucun utilisateur ne peut monopoliser la file d'attente. + +**Que faire** : + +- Patientez quelques minutes. Si votre instance est partagée avec d'autres utilisateurs, votre enregistrement sera traité à son tour. +- Si l'enregistrement reste bloqué plus de 30 minutes, contactez le support InnovA AI. + +--- + +## La transcription échoue immédiatement + +Un échec rapide de la transcription indique généralement un problème de configuration côté serveur. + +**Que faire** : + +- Vérifiez que votre fichier audio n'est pas corrompu en l'écoutant dans un autre lecteur. +- Réessayez le téléversement de votre fichier. +- Si le problème persiste, contactez le support InnovA AI. + +--- + +## Qualité de transcription insuffisante + +La précision de la transcription dépend fortement de la qualité audio. Voici les facteurs qui influencent les résultats : + +### Qualité audio + +- **Bruit de fond** : Le bruit ambiant, la musique ou les conversations en arrière-plan dégradent considérablement les résultats. +- **Chevauchement des voix** : Lorsque plusieurs personnes parlent en même temps, la transcription devient imprécise. +- **Microphone** : Un mauvais positionnement du microphone ou un micro de faible qualité affecte la clarté. + +**Bonnes pratiques pour un enregistrement de qualité** : + +- Enregistrez dans un environnement calme. +- Utilisez un microphone de bonne qualité, positionné près des intervenants. +- Pour les réunions, placez le micro au centre de la table. +- Évitez la musique de fond ou les bruits parasites. + +### Problème de langue + +Si la langue de transcription configurée ne correspond pas à la langue réellement parlée dans l'enregistrement, la précision diminue fortement. + +**Que faire** : + +- Vérifiez le paramètre de langue dans vos réglages de compte, sous *Préférences de langue*. +- Vous pouvez sélectionner une langue spécifique ou laisser la détection automatique si vos enregistrements sont dans des langues variées. +- Pour la transcription en chinois, assurez-vous que le modèle utilisé est compatible. Certains modèles simplifiés ne prennent pas correctement en charge le chinois. Si vous obtenez du texte romanisé au lieu de caractères chinois, contactez le support InnovA AI. + +--- + +## Termes spécialisés mal transcrits + +Il est fréquent que les noms propres, acronymes, termes techniques ou noms de marque soient mal transcrits. DictIA offre deux fonctionnalités pour corriger cela : + +### Vocabulaire personnalisé + +Fournissez une liste de termes séparés par des virgules que le moteur de transcription doit prioriser. Par exemple, si « PyAnnote » est systématiquement transcrit comme « piano » ou « pie annotate », ajoutez-le à votre vocabulaire personnalisé. + +### Indice de transcription (Initial Prompt) + +Donnez au moteur de transcription un contexte plus large sur le contenu de votre enregistrement, ce qui l'aide à faire de meilleurs choix lexicaux. + +### Niveaux de configuration + +Ces paramètres peuvent être définis à plusieurs niveaux, du plus prioritaire au moins prioritaire : + +1. **Par téléversement** : Dans les options avancées lors du téléversement d'un fichier. +2. **Par étiquette** : Dans les paramètres d'une étiquette (appliqué automatiquement lorsque cette étiquette est sélectionnée). +3. **Par dossier** : Dans les paramètres d'un dossier (appliqué à tous les enregistrements du dossier). +4. **Par utilisateur** : Dans les *Paramètres du compte*, section *Options de transcription* (vos valeurs par défaut personnelles). + +--- + +## Erreur « Format non reconnu » + +Si vous recevez une erreur indiquant que le format audio n'est pas reconnu, cela signifie que le service de transcription ne prend pas en charge le codec de votre fichier. + +**Que faire** : + +- Convertissez votre fichier audio dans un format plus courant (MP3 ou WAV) avant de le téléverser. +- Si le problème se produit fréquemment, contactez le support InnovA AI pour qu'une solution automatique soit configurée. + +--- + +## Le résumé ne se génère pas + +Si aucun résumé n'est produit après la transcription : + +**Que faire** : + +- Vérifiez que la transcription elle-même a été complétée avec succès. +- Essayez de retraiter le résumé en cliquant sur « Retraiter le résumé » dans la page de l'enregistrement. +- Si le problème persiste, contactez le support InnovA AI. + +--- + +## La langue du résumé ne correspond pas à vos préférences + +Si les résumés sont générés dans une langue différente de celle configurée dans vos préférences : + +**Que faire** : + +- Vérifiez vos préférences de langue dans *Paramètres du compte* > *Préférences de langue*. +- Essayez de retraiter le résumé après avoir confirmé vos préférences. +- Si le problème persiste malgré des préférences correctes, contactez le support InnovA AI. Ce comportement peut être lié au modèle IA utilisé par votre instance. + +--- + +## Transcription modifiable + +Sachez que les transcriptions sont entièrement modifiables après leur génération. Si vous constatez des erreurs : + +- Cliquez sur le bouton *Modifier* au-dessus de la transcription. +- Corrigez les termes mal transcrits, les noms propres ou les attributions de locuteurs. +- Vos modifications sont préservées, même si vous retraitez le résumé ou utilisez la Conversation IA. + +--- + +## Besoin d'aide supplémentaire? + +Si les solutions ci-dessus ne résolvent pas votre problème, contactez le support InnovA AI en fournissant : + +- La description du problème et les étapes pour le reproduire. +- Le format et la durée approximative de votre fichier audio. +- Tout message d'erreur affiché à l'écran. +- Votre version de DictIA (disponible dans *Paramètres* > *À propos*). diff --git a/client_docs/guide-admin/gestion-groupes.md b/client_docs/guide-admin/gestion-groupes.md new file mode 100644 index 0000000..8b34ffc --- /dev/null +++ b/client_docs/guide-admin/gestion-groupes.md @@ -0,0 +1,227 @@ +# Gestion des groupes + +Les groupes permettent d'organiser la collaboration dans votre instance DictIA en regroupant les utilisateurs et en automatisant l'accès aux enregistrements grâce à des étiquettes de groupe. Cette fonctionnalité réduit la charge administrative tout en maintenant la sécurité et le contrôle sur l'accès au contenu. + +## Prérequis + +La fonctionnalité de groupes repose sur le partage interne de DictIA. Le partage interne et la visibilité des noms d'utilisateur doivent être activés pour que l'onglet Groupes apparaisse dans le tableau de bord d'administration. Contactez le support InnovA AI pour ajuster ces paramètres. + +### Considérations de confidentialité + +La visibilité des noms d'utilisateur affecte l'ensemble de l'instance : + +- **Noms visibles** : Les utilisateurs voient les noms d'utilisateur réels lorsqu'ils recherchent des collègues et consultent du contenu partagé. Recommandé pour les petits groupes de confiance. +- **Noms masqués** : Les noms d'utilisateur sont cachés de l'interface. Les utilisateurs doivent connaître le nom d'utilisateur exact de leurs collègues pour partager des enregistrements. Approche axée sur la confidentialité, conforme à la Loi 25. + +La fonctionnalité de groupe fonctionne de manière identique dans les deux modes; seul l'affichage change. + +## Créer et gérer des groupes + +Les groupes sont créés et gérés exclusivement par les administrateurs via le tableau de bord d'administration. + +### Créer un groupe + +1. Accédez au **Tableau de bord d'administration** et sélectionnez l'onglet **Groupes** +2. Cliquez sur **Créer un groupe** +3. Fournissez un nom qui identifie clairement l'objectif du groupe (ex. : "Ingénierie", "Ventes Québec", "Projet Alpha") +4. Ajoutez une description (optionnel mais recommandé) expliquant le rôle du groupe +5. Cliquez sur **Créer un groupe** pour finaliser + +Le groupe apparaît immédiatement dans la liste, sans aucun membre. Vous devez ajouter explicitement les membres, y compris vous-même si nécessaire. + +### Gérer les membres + +Cliquez sur l'icône de gestion des utilisateurs à côté d'un groupe pour ouvrir la fenêtre de gestion. Cette interface affiche les membres actuels, leurs rôles et fournit les outils pour ajouter ou retirer des membres. + +Pour ajouter un membre, sélectionnez un utilisateur dans le menu déroulant et choisissez son rôle : + +- **Membre** : Peut utiliser les étiquettes de groupe et accéder aux enregistrements étiquetés. Convient à la plupart des participants. +- **Administrateur de groupe** : Toutes les capacités d'un membre, plus la gestion des membres du groupe, la création et suppression d'étiquettes de groupe, et l'accès aux fonctions de gestion du groupe. + +Cliquez sur **Ajouter un membre** pour accorder l'accès. L'utilisateur obtient immédiatement la visibilité sur les étiquettes du groupe. Il n'obtient pas automatiquement l'accès aux enregistrements existants, uniquement aux nouveaux enregistrements étiquetés après son ajout. + +### Modifier les rôles des membres + +Les rôles peuvent évoluer selon les responsabilités. Cliquez sur le menu déroulant de rôle à côté d'un membre pour le basculer entre Membre et Administrateur de groupe. Les changements prennent effet immédiatement. + +### Retirer des membres + +Cliquez sur l'icône de suppression à côté d'un membre pour le retirer du groupe. Le retrait est immédiat : + +- L'utilisateur perd l'accès aux étiquettes de groupe +- Il ne recevra plus les nouveaux enregistrements étiquetés +- Son accès aux enregistrements déjà partagés est conservé +- Ses notes personnelles sur les enregistrements du groupe sont préservées + +Pour révoquer complètement l'accès aux enregistrements existants, vous devez révoquer manuellement les partages internes individuels. + +### Supprimer un groupe + +Cliquez sur l'icône de suppression à côté d'un groupe pour le supprimer entièrement. Une confirmation est demandée. La suppression : + +- Retire tous les membres +- Supprime toutes les étiquettes du groupe +- Préserve tous les enregistrements (y compris ceux précédemment étiquetés) +- Préserve tous les partages internes créés par les étiquettes + +La suppression est irréversible. Si vous souhaitez temporairement désactiver un groupe, retirez plutôt tous les membres. + +## Étiquettes de groupe + +Les étiquettes de groupe automatisent le partage au sein des groupes. Contrairement aux étiquettes personnelles qui organisent le contenu individuel, les étiquettes de groupe déclenchent des autorisations d'accès pour tous les membres du groupe lorsqu'elles sont appliquées. + +### Créer des étiquettes de groupe + +Depuis l'onglet Groupes, cliquez sur l'icône d'étiquettes à côté du groupe concerné. Fournissez : + +- **Nom de l'étiquette** : Un nom décrivant le type de contenu ou l'objectif (ex. : "Revues de sprint", "Appels clients", "Contrats juridiques"). Évitez les noms génériques comme "Important" ou "Groupe". +- **Couleur** : Pour distinguer visuellement l'étiquette. Établissez des conventions de couleurs (ex. : bleu pour le technique, vert pour les ventes, rouge pour le juridique). + +### Politiques de rétention par étiquette + +Les étiquettes de groupe peuvent remplacer les paramètres globaux de rétention avec des périodes spécifiques. Cette fonctionnalité permet à différents types de contenu d'avoir des cycles de vie différents. + +- **Champ vide** : Utilise les paramètres globaux de rétention +- **Nombre de jours** : Les enregistrements avec cette étiquette seront supprimés automatiquement après la période spécifiée + +Exemples de configuration : + +| Type de contenu | Rétention | Justification | +|----------------|-----------|---------------| +| Réunions juridiques | 2555 jours (7 ans) | Conformité et archivage légal | +| Réunions quotidiennes | 14 jours | Contenu éphémère quotidien | +| Planification marketing | 180 jours | Cycle de planification de campagne | + +Lorsqu'un enregistrement possède plusieurs étiquettes avec différentes périodes de rétention, la période la **plus courte** s'applique. Cela garantit la conformité avec la politique la plus restrictive, tel que recommandé par la Loi 25. + +### Protection contre la suppression + +Activez **Protéger contre la suppression** pour rendre les enregistrements avec cette étiquette immunisés contre la suppression automatique, peu importe leur âge ou les paramètres de rétention. + +Utilisez la protection pour les enregistrements qui doivent être conservés indéfiniment : + +- Documents juridiques et de conformité +- Décisions d'affaires critiques +- Matériel de formation et d'intégration +- Documentation de référence + +La protection peut être retirée ultérieurement en modifiant l'étiquette. + +### Paramètres de partage automatique + +Les étiquettes de groupe offrent deux niveaux de partage automatique qui se déclenchent lorsqu'un membre du groupe applique l'étiquette : + +- **Partager avec tous les membres du groupe** (par défaut et recommandé) : L'enregistrement est partagé avec tous les membres du groupe, en mode consultation et modification. +- **Partager avec les administrateurs de groupe seulement** : Seuls les utilisateurs avec le rôle administrateur dans ce groupe reçoivent automatiquement l'accès. Convient au contenu sensible nécessitant une supervision. + +### Gérer les étiquettes existantes + +Les étiquettes existantes apparaissent dans la fenêtre de gestion avec leurs paramètres actuels. Cliquez sur l'icône de modification pour ajuster le nom, la couleur, la rétention, la protection ou les paramètres de partage. Les modifications s'appliquent aux futurs usages sans affecter rétroactivement les partages déjà effectués. + +La suppression d'une étiquette la retire de tous les enregistrements, mais ne supprime ni les enregistrements ni les accès déjà accordés. + +### Synchroniser les partages de groupe + +Si des membres ont été ajoutés après que des enregistrements aient déjà été étiquetés, la fonctionnalité **Synchroniser les partages de groupe** permet d'appliquer rétroactivement le partage automatique. + +Cliquez sur **Synchroniser les partages de groupe** dans la fenêtre de gestion. L'opération : + +- Identifie tous les enregistrements avec les étiquettes du groupe +- Vérifie les partages existants avec les membres actuels +- Crée les partages manquants +- Respecte les paramètres de partage de chaque étiquette +- Évite les doublons + +La synchronisation est sécuritaire et peut être exécutée plusieurs fois sans effet indésirable. + +## Rôle d'administrateur de groupe + +Les administrateurs de groupe sont des membres avec des permissions élevées, limitées à la portée de leur groupe. + +### Capacités + +Les administrateurs de groupe peuvent : + +- Ajouter et retirer des membres +- Modifier les rôles des membres +- Créer, modifier et supprimer des étiquettes de groupe +- Synchroniser les partages du groupe + +Les administrateurs de groupe ne peuvent **pas** : + +- Créer ou supprimer des groupes +- Gérer des groupes dont ils ne sont pas administrateurs +- Accéder aux fonctionnalités d'administration du système +- Voir les statistiques d'autres groupes + +Cette délégation permet de distribuer la gestion aux responsables d'équipe tout en maintenant le contrôle centralisé. + +## Bonnes pratiques + +### Structure des groupes + +**Petites organisations (moins de 10 utilisateurs)** : Créez des groupes par département (Ingénierie, Ventes, RH). Utilisez les étiquettes de groupe pour les noms de projets ou types de contenu. + +**Grandes organisations (plus de 10 utilisateurs)** : Créez des groupes par produit, division ou projet majeur. Des groupes plus sélectifs évitent la surcharge d'information. + +### Conventions de nommage des étiquettes + +Établissez des conventions dès le début : + +- Par projet : "Projet-Alpha", "Initiative-T3-2026" +- Par type de contenu : "Revues-Sprint", "Appels-Clients", "Contrats-Juridiques" +- Par département : "Ing-Architecture", "Ventes-Formation", "RH-Entrevues" + +### Stratégie de rétention + +Définissez des valeurs par défaut réfléchies qui équilibrent les coûts de stockage avec les besoins de conformité, particulièrement en regard de la Loi 25 : + +- **Rétention globale** : 90 jours (capture la plupart du contenu) +- **Étiquettes juridiques** : 2555 jours (7 ans pour les archives légales) +- **Étiquettes de conformité** : Protégées (rétention permanente) +- **Réunions d'équipe** : 180 jours (fenêtre de collaboration raisonnable) +- **Réunions quotidiennes** : 14 jours (contenu éphémère) + +Révisez vos politiques de rétention chaque trimestre. + +## Intégration avec les autres fonctionnalités + +### Recherche IA + +Les étiquettes de groupe apparaissent automatiquement dans les filtres de la Recherche IA, permettant une recherche sémantique ciblée par groupe. Les enregistrements partagés via les étiquettes de groupe sont inclus dans les résultats de recherche sémantique. + +### Rétention et suppression automatique + +Les politiques de rétention par étiquette s'intègrent au système de suppression automatique de DictIA : + +1. Si l'enregistrement a des étiquettes protégées → Jamais supprimé +2. Si l'enregistrement a des étiquettes avec rétention personnalisée → La période la plus courte s'applique +3. Sinon → La rétention globale s'applique + +### Partage public + +L'appartenance à un groupe n'affecte pas les capacités de partage public. La capacité d'un utilisateur à créer des liens de partage public est contrôlée indépendamment par ses permissions individuelles. + +--- + +## Dépannage + +### L'onglet Groupes n'est pas visible + +Le partage interne n'est peut-être pas activé. Contactez le support InnovA AI pour vérifier la configuration. + +### Les utilisateurs ne voient pas les étiquettes de groupe + +Vérifiez que l'utilisateur est bien membre du groupe et qu'il est connecté. Rafraîchissez la page pour charger les listes d'étiquettes mises à jour. + +### Le partage automatique ne fonctionne pas + +Modifiez l'étiquette de groupe et vérifiez que l'option de partage est bien activée. Si le problème persiste, contactez le support InnovA AI. + +### Un administrateur de groupe ne peut pas accéder à l'interface de gestion + +Vérifiez que le rôle de l'utilisateur est bien "Administrateur" dans le groupe. Demandez-lui de se déconnecter et de se reconnecter pour rafraîchir sa session. Le lien **Gestion des groupes** devrait apparaître dans son menu utilisateur. + +--- + +Retour au [Guide d'administration](index.md) → diff --git a/client_docs/guide-admin/gestion-utilisateurs.md b/client_docs/guide-admin/gestion-utilisateurs.md new file mode 100644 index 0000000..1f39dbb --- /dev/null +++ b/client_docs/guide-admin/gestion-utilisateurs.md @@ -0,0 +1,84 @@ +# Gestion des utilisateurs + +La section Gestion des utilisateurs est le centre de contrôle pour gérer les accès à votre instance DictIA et les permissions de chaque utilisateur. Chaque compte utilisateur est géré ici, de la création initiale jusqu'à la désactivation éventuelle. Pour les tendances d'utilisation à l'échelle du système, consultez les [statistiques du système](statistiques.md). + +## Comprendre le tableau des utilisateurs + +En ouvrant la section Gestion des utilisateurs, vous accédez à un tableau complet de tous les utilisateurs de votre système. Chaque ligne présente les informations essentielles : l'adresse courriel identifie l'utilisateur, le badge administrateur indique son niveau de privilège, le nombre d'enregistrements révèle son niveau d'activité, et la mesure du stockage montre sa consommation de ressources. + +La barre de recherche en haut du tableau filtre les résultats instantanément à mesure que vous tapez, ce qui facilite la recherche d'utilisateurs spécifiques. Le tableau se met à jour en temps réel lorsque vous apportez des modifications. + +## Ajouter un nouvel utilisateur + +Pour créer un compte utilisateur, cliquez sur le bouton **Ajouter un utilisateur** dans le coin supérieur droit. La fenêtre qui apparaît demande les informations essentielles : nom d'utilisateur, adresse courriel et mot de passe. Vous décidez également immédiatement si cette personne a besoin de privilèges d'administrateur, bien que vous puissiez toujours modifier cela ultérieurement. + +Le nom d'utilisateur devient l'identité de l'utilisateur dans DictIA, apparaissant dans l'interface et organisant ses enregistrements. L'adresse courriel sert à la fois d'identifiant de connexion et de point de contact. Le mot de passe que vous définissez est temporaire; les utilisateurs devraient le changer immédiatement après leur première connexion via leurs [paramètres de compte](../guide-utilisateur/parametres.md). + +Les privilèges d'administrateur sont puissants et devraient être accordés avec parcimonie. Les utilisateurs administrateurs peuvent voir et modifier tous les [paramètres du système](parametres-systeme.md), gérer les autres utilisateurs, configurer les [consignes par défaut](prompts.md) et surveiller la [recherche sémantique](recherche-semantique.md). La plupart des utilisateurs n'auront jamais besoin de ces capacités. + +### Permissions des utilisateurs + +Au-delà du rôle d'administrateur, DictIA offre des permissions granulaires par utilisateur qui contrôlent des capacités spécifiques. + +**Permission de partage public** : Le bouton d'icône globe dans la ligne de chaque utilisateur active ou désactive sa capacité à créer des liens de partage public. Lorsque cette option est activée (vert), l'utilisateur peut générer des liens sécurisés pour partager des enregistrements à l'externe avec n'importe qui. Lorsqu'elle est désactivée (gris), l'utilisateur ne peut utiliser que le partage interne avec d'autres utilisateurs DictIA. + +Utilisez cette permission pour contrôler le partage d'informations à l'externe par utilisateur. Vous pourriez accorder le partage public aux gestionnaires, aux équipes de vente ou au personnel marketing qui communiquent régulièrement avec des parties prenantes externes, tout en le restreignant pour les utilisateurs qui manipulent des données internes sensibles. + +### Gestion du budget de jetons IA + +Contrôlez les coûts d'IA en définissant des budgets mensuels de jetons pour chaque utilisateur. Lors de la création ou de la modification d'un utilisateur, vous pouvez spécifier une limite mensuelle de jetons qui restreint son utilisation de l'IA. + +**Fonctionnement des budgets de jetons** : + +- **Avertissement à 80 %** : Les utilisateurs voient un indicateur jaune lorsqu'ils atteignent 80 % de leur budget mensuel +- **Bloqué à 100 %** : Une fois le budget épuisé, les opérations d'IA (conversation IA, résumé, etc.) sont bloquées jusqu'au mois suivant +- **Réinitialisation du budget** : Les compteurs se réinitialisent automatiquement au début de chaque mois civil +- **Sans limite** : Laissez le champ de budget vide pour une utilisation illimitée + +Les budgets de jetons sont définis par incréments de 10 000 jetons avec un minimum de 100 000. Le budget couvre toutes les opérations d'IA, y compris la génération de résumés, la conversation IA, la génération de titres et l'extraction d'événements. Consultez les statistiques détaillées d'utilisation des jetons dans la section [Statistiques du système](statistiques.md#statistiques-dutilisation-des-jetons). + +### Gestion du budget de transcription + +Contrôlez les coûts de transcription en définissant des budgets mensuels de transcription (en minutes) pour chaque utilisateur. + +**Fonctionnement des budgets de transcription** : + +- **Avertissement à 80 %** : Les utilisateurs voient un indicateur jaune lorsqu'ils atteignent 80 % de leur budget mensuel de transcription +- **Bloqué à 100 %** : Une fois le budget épuisé, les nouvelles transcriptions sont bloquées jusqu'au mois suivant +- **Réinitialisation du budget** : Les minutes de transcription se réinitialisent automatiquement au début de chaque mois civil +- **Sans limite** : Laissez le champ de budget vide pour une utilisation illimitée de la transcription + +Les budgets de transcription sont définis en minutes avec un minimum de 10 minutes. Consultez les statistiques détaillées de transcription dans la section [Statistiques du système](statistiques.md#statistiques-dutilisation-de-la-transcription). + +## Gérer les utilisateurs existants + +Chaque ligne d'utilisateur comprend des boutons d'action qui vous donnent un contrôle complet sur ce compte. + +**Modifier** : Le bouton de modification ouvre une fenêtre où vous pouvez mettre à jour le nom d'utilisateur ou l'adresse courriel. Utile lorsqu'une personne change de nom, d'adresse courriel ou lorsque vous devez corriger des erreurs de saisie initiale. + +**Basculer le rôle d'administrateur** : Promouvoir un utilisateur au rôle d'administrateur lui accorde l'accès à tout ce que vous pouvez voir et faire. Rétrograder un administrateur révoque immédiatement toutes ses capacités administratives. Le système vous empêche de retirer vos propres droits d'administrateur pour éviter tout verrouillage accidentel. + +**Supprimer** : La suppression d'un utilisateur est permanente et ne peut pas être annulée. Tous ses enregistrements, notes et paramètres seront supprimés avec son compte. Le système demande une confirmation avant de procéder. + +## Surveiller les tendances d'utilisation + +Les colonnes de nombre d'enregistrements et de stockage révèlent comment les utilisateurs interagissent avec votre instance DictIA. + +- Un nombre élevé d'enregistrements peut indiquer des utilisateurs avancés qui dépendent fortement du système +- Un nombre faible peut suggérer des utilisateurs qui ont besoin de formation ou qui n'ont peut-être pas besoin de compte +- Un stockage disproportionnellement élevé peut indiquer le téléversement d'enregistrements très longs + +Ces informations vous aident à établir des politiques d'utilisation appropriées et à planifier la capacité du système. + +## Considérations de sécurité + +Chaque compte utilisateur est un vecteur de sécurité potentiel. Voici les bonnes pratiques à suivre : + +- **Mots de passe robustes** : Encouragez les utilisateurs à utiliser des mots de passe uniques et à les changer régulièrement +- **Comptes administrateurs** : Limitez l'accès administrateur au strict minimum nécessaire. Lorsqu'une personne n'a plus besoin de ces privilèges, révoquez-les immédiatement +- **Comptes inactifs** : Effectuez des audits réguliers pour identifier et supprimer les comptes inactifs qui pourraient présenter des risques +- **Conformité Loi 25** : Assurez-vous que seuls les utilisateurs légitimes ont accès aux enregistrements contenant des renseignements personnels + +--- + +Suivant : [Statistiques du système](statistiques.md) → diff --git a/client_docs/guide-admin/index.md b/client_docs/guide-admin/index.md new file mode 100644 index 0000000..4b9d5b8 --- /dev/null +++ b/client_docs/guide-admin/index.md @@ -0,0 +1,135 @@ +# Guide d'administration + +Bienvenue dans le guide d'administration de DictIA. En tant qu'administrateur, vous contrôlez l'ensemble de votre instance DictIA : gestion des utilisateurs, surveillance du système et configuration de l'intelligence artificielle. + +## Sections administratives + +
+
+
👥
+

Gestion des utilisateurs

+

Créez des comptes, gérez les permissions, surveillez l'utilisation et contrôlez l'accès à votre instance DictIA.

+ Gérer les utilisateurs → +
+ +
+
🤝
+

Gestion des groupes

+

Créez des groupes, attribuez des rôles, configurez les étiquettes de partage automatique et facilitez la collaboration.

+ Gérer les groupes → +
+ +
+
📊
+

Statistiques du système

+

Surveillez la santé du système, suivez les tendances d'utilisation et identifiez les enjeux avant qu'ils n'affectent vos utilisateurs.

+ Voir les statistiques → +
+ +
+
🔧
+

Paramètres du système

+

Configurez les limites globales, les délais d'attente, les tailles de fichiers et les comportements qui s'appliquent à tous les utilisateurs.

+ Configurer le système → +
+ +
+
🤖
+

Modèles d'IA

+

Découvrez les modèles d'intelligence artificielle utilisés pour la génération de résumés, la conversation IA et plus encore.

+ Voir les modèles → +
+ +
+
+

Consignes par défaut

+

Personnalisez le comportement de l'IA avec des consignes de résumé qui façonnent le traitement du contenu.

+ Configurer les consignes → +
+ +
+
🔍
+

Recherche sémantique

+

Gérez les capacités de recherche intelligente, surveillez l'état de l'indexation sémantique et contrôlez la Recherche IA.

+ Gérer la recherche → +
+ +
+
🗑️
+

Rétention et suppression automatique

+

Configurez la gestion automatisée du cycle de vie des données avec des politiques de rétention flexibles, conformes à la Loi 25.

+ Gérer la rétention → +
+ +
+
🔐
+

Authentification unique (SSO)

+

Intégrez DictIA avec votre fournisseur d'identité pour simplifier la connexion de vos utilisateurs.

+ Configurer le SSO → +
+
+ +## Actions rapides + +
+
+ +
+ Ajouter un utilisateur +

Gestion des utilisateurs → Bouton Ajouter → Saisir les informations → Définir les permissions

+
+
+ +
+ 🤝 +
+ Créer un groupe +

Gestion des groupes → Créer un groupe → Ajouter des membres → Configurer les étiquettes

+
+
+ +
+ 📈 +
+ Vérifier la santé du système +

Statistiques → Consulter les métriques → Vérifier le traitement → Surveiller le stockage

+
+
+ +
+ ⚙️ +
+ Modifier les paramètres +

Paramètres du système → Ajuster les limites → Configurer les délais → Enregistrer

+
+
+ +
+ 🔄 +
+ Vérifier l'indexation sémantique +

Recherche sémantique → Vérifier le statut → Traiter les éléments en attente → Suivre la progression

+
+
+
+ +## Besoin d'aide? + +
+
+ 📖 + Consultez le Guide de dépannage +
+
+ 📧 + Contactez le support InnovA AI pour toute question technique +
+
+ 💾 + InnovA AI effectue des sauvegardes régulières de vos données +
+
+ +--- + +Prêt à administrer votre instance DictIA? Commencez par la [Gestion des utilisateurs](gestion-utilisateurs.md) → diff --git a/client_docs/guide-admin/modeles-ia.md b/client_docs/guide-admin/modeles-ia.md new file mode 100644 index 0000000..986b446 --- /dev/null +++ b/client_docs/guide-admin/modeles-ia.md @@ -0,0 +1,62 @@ +# Modèles d'IA + +DictIA utilise des modèles d'intelligence artificielle pour plusieurs fonctionnalités clés de la plateforme. Cette section explique à quoi servent ces modèles et ce qui peut être ajusté. + +## Fonctionnalités alimentées par l'IA + +Les modèles d'IA de DictIA sont utilisés pour : + +- **Génération de résumés** : Création de résumés intelligents de vos transcriptions +- **Génération de titres** : Attribution automatique de titres descriptifs aux enregistrements +- **Extraction d'événements** : Identification d'événements dignes d'être ajoutés au calendrier +- **Conversation IA** : Réponses aux questions sur vos enregistrements +- **Identification des locuteurs** : Détection des noms des locuteurs à partir du contexte de la conversation + +## Configuration des modèles + +La configuration technique des modèles d'IA (choix du fournisseur, clés d'API, paramètres du modèle) est gérée par l'équipe InnovA AI. Contactez le support InnovA AI pour ajuster ces paramètres. + +### Ce que vous pouvez contrôler via l'interface + +Bien que la configuration technique soit gérée par InnovA AI, vous pouvez influencer le comportement de l'IA via : + +- **[Consignes par défaut](prompts.md)** : Personnalisez les instructions envoyées à l'IA pour la génération de résumés. C'est le moyen le plus direct d'adapter l'IA à vos besoins. +- **[Limite de longueur de transcription](parametres-systeme.md#limite-de-longueur-de-transcription)** : Contrôlez la quantité de texte envoyée à l'IA, ce qui affecte la qualité et les coûts. +- **[Budgets de jetons par utilisateur](gestion-utilisateurs.md#gestion-du-budget-de-jetons-ia)** : Définissez des limites mensuelles de consommation d'IA pour chaque utilisateur. + +### Modèles disponibles + +DictIA peut être configuré avec différents modèles d'IA selon vos besoins : + +- **Modèles pour les résumés** : Des modèles de différentes capacités sont disponibles, du plus économique au plus performant +- **Modèle distinct pour la conversation IA** : Un modèle séparé peut être configuré spécifiquement pour les interactions en temps réel, permettant d'optimiser le rapport qualité/coût + +### Optimisation des coûts + +Pour réduire les coûts tout en maintenant la qualité : + +1. **Ajustez les consignes** : Des consignes bien rédigées produisent de meilleurs résultats avec moins de jetons +2. **Définissez des budgets** : Utilisez les budgets de jetons pour contrôler la consommation par utilisateur +3. **Ajustez la limite de transcription** : Limitez la quantité de texte envoyée à l'IA pour les longs enregistrements + +Pour des ajustements plus avancés (changement de modèle, modification des paramètres techniques), contactez le support InnovA AI. + +### Qualité des résumés + +Si la qualité des résumés ne répond pas à vos attentes, voici les actions que vous pouvez entreprendre : + +1. **Révisez vos [consignes par défaut](prompts.md)** : C'est souvent la solution la plus efficace +2. **Vérifiez la limite de transcription** : Une limite trop basse peut empêcher l'IA d'avoir suffisamment de contexte +3. **Contactez le support InnovA AI** : L'équipe peut recommander un modèle plus performant ou ajuster les paramètres techniques + +### Coûts élevés + +Si vous observez des coûts d'IA plus élevés que prévu : + +1. Vérifiez les [statistiques d'utilisation des jetons](statistiques.md#statistiques-dutilisation-des-jetons) pour identifier les gros consommateurs +2. Ajustez les [budgets de jetons](gestion-utilisateurs.md#gestion-du-budget-de-jetons-ia) pour les utilisateurs concernés +3. Contactez le support InnovA AI pour explorer les options de modèles plus économiques + +--- + +Suivant : [Consignes par défaut](prompts.md) | Retour au [Guide d'administration](index.md) diff --git a/client_docs/guide-admin/parametres-systeme.md b/client_docs/guide-admin/parametres-systeme.md new file mode 100644 index 0000000..5f8e31d --- /dev/null +++ b/client_docs/guide-admin/parametres-systeme.md @@ -0,0 +1,115 @@ +# Paramètres du système + +Les paramètres du système permettent de configurer les comportements fondamentaux qui affectent chaque utilisateur et chaque enregistrement de votre instance DictIA. Ces paramètres globaux façonnent le fonctionnement du système, des limites techniques aux fonctionnalités visibles par les utilisateurs. + +## Limite de longueur de transcription + +La limite de longueur de transcription détermine la quantité de texte envoyée à l'IA lors de la génération de résumés ou de la conversation IA. Ce paramètre a un effet important sur la qualité et les coûts. + +- **Sans limite** : La transcription complète est envoyée à l'IA, peu importe la longueur. Cela garantit un contexte complet, mais peut devenir coûteux pour les longs enregistrements. Cette limite s'applique également à la détection automatique des locuteurs. +- **Avec limite** (ex. : 50 000 caractères) : Crée un plafond sur la consommation d'API. Les transcriptions très longues seront tronquées, n'envoyant que le début à l'IA. + +Pour les réunions typiques de moins d'une heure, 50 000 caractères capturent généralement tout. Pour les sessions plus longues, vous pouvez augmenter cette limite ou encourager les utilisateurs à diviser leurs enregistrements. + +## Taille maximale des fichiers + +La taille maximale protège votre système contre les téléversements trop volumineux tout en permettant aux utilisateurs de travailler avec des enregistrements raisonnables. La valeur par défaut de 300 Mo accommode plusieurs heures d'audio compressé. + +Augmenter cette limite permet des enregistrements plus longs mais nécessite une considération attentive. Les fichiers plus gros prennent plus de temps à téléverser, consomment plus de stockage et pourraient expirer durant le traitement. + +Si les utilisateurs atteignent fréquemment la limite, demandez-vous s'ils ont réellement besoin d'enregistrements aussi longs. Souvent, diviser les longues sessions en segments logiques produit de meilleurs résultats. + +## Délai d'attente de la transcription + +Le délai d'attente détermine combien de temps DictIA attendra que le service de transcription complète son travail. La valeur par défaut de 1 800 secondes (30 minutes) convient à la plupart des enregistrements. + +- **Trop court** : Les enregistrements plus longs échouent même si le service fonctionne normalement +- **Trop long** : Les ressources système restent mobilisées inutilement en cas d'échec réel + +Ajustez ce paramètre en fonction de vos enregistrements les plus longs typiques. Pour les enregistrements de plusieurs heures, une valeur de 3 600 secondes ou plus peut être nécessaire. Contactez le support InnovA AI pour ajuster ce paramètre si nécessaire. + +## Avis d'enregistrement + +L'avis d'enregistrement s'affiche avant que les utilisateurs ne démarrent une session d'enregistrement. Idéal pour les mentions légales, les rappels de politiques ou les directives d'utilisation. Ce message en format Markdown garantit que les utilisateurs comprennent leurs responsabilités avant de créer du contenu. + +Les organisations utilisent souvent cet avis pour les exigences de conformité, notamment la Loi 25 au Québec : + +- Rappeler les exigences de consentement +- Référencer les politiques de gestion des données +- Mentionner les directives d'utilisation appropriée + +!!! info "Support complet du Markdown" + L'avis d'enregistrement supporte le formatage Markdown complet : titres, listes, gras, italique, liens, blocs de code et citations. + + Exemple : + ```markdown + ## Consentement requis + + En démarrant cet enregistrement, vous acceptez de : + + 1. Obtenir le consentement de tous les participants + 2. Respecter la [politique de confidentialité](https://exemple.com/confidentialite) + 3. Traiter les enregistrements conformément à la **Loi 25** + + > **Important** : Les enregistrements contenant des renseignements personnels + > doivent être gérés selon la politique de rétention en vigueur. + ``` + +Gardez les avis concis et pertinents. Les utilisateurs voient ce message fréquemment; un texte trop long sera simplement ignoré. + +## Avis de téléversement + +L'avis de téléversement fonctionne comme l'avis d'enregistrement, mais s'affiche lorsque les utilisateurs téléversent des fichiers. Chaque fois qu'un utilisateur glisse-dépose des fichiers ou les sélectionne pour téléversement, il voit cet avis et doit l'accepter. + +Utile lorsque les fichiers téléversés peuvent contenir du contenu de tiers ou lorsque votre organisation doit rappeler les politiques de gestion des données. Supporte le formatage Markdown complet. + +Laissez ce champ vide pour désactiver complètement l'avis de téléversement. + +## Bannière personnalisée + +La bannière personnalisée affiche un message persistant en haut de la zone de contenu principal pour tous les utilisateurs. Utile pour les annonces, les avis de maintenance ou les rappels de conformité. + +La bannière apparaît sous l'en-tête et au-dessus du contenu principal. Les utilisateurs peuvent la fermer en cliquant sur le X, mais elle réapparaît au rafraîchissement de la page. + +Exemples d'utilisation : + +```markdown +**Mise à jour système** — DictIA sera brièvement indisponible dimanche de 22h à minuit pour maintenance. +``` + +```markdown +Tous les enregistrements sont assujettis à notre [politique d'utilisation](https://exemple.com/politique). Contactez le support pour toute question. +``` + +Laissez ce champ vide pour masquer la bannière. + +## Impact à l'échelle du système + +Chaque paramètre de cette page affecte tous les utilisateurs immédiatement. Les modifications prennent effet dès l'enregistrement, sans nécessiter de redémarrage. Cette application immédiate signifie que vous devriez tester les changements avec soin et communiquer les modifications importantes à vos utilisateurs. + +Le bouton de rafraîchissement recharge les paramètres, utile si plusieurs administrateurs effectuent des changements simultanément. + +## Paramètres supplémentaires + +Certains paramètres du système ne sont pas accessibles via l'interface web et nécessitent une intervention technique. Ces paramètres incluent notamment : + +- **Collaboration et partage** : Activation du partage interne, du partage public et de la visibilité des noms d'utilisateur +- **Permissions utilisateur** : Contrôle de la capacité des utilisateurs à supprimer leurs propres enregistrements +- **Rétention et suppression automatique** : Voir la section [Rétention](retention.md) pour plus de détails +- **Dossiers** : Activation de la fonctionnalité de dossiers pour organiser les enregistrements +- **Compression audio** : Configuration de la compression automatique des fichiers audio +- **Conservation vidéo** : Option de conserver les fichiers vidéo pour la lecture dans le navigateur + +Contactez le support InnovA AI pour ajuster ces paramètres. + +## Dépannage + +Lorsque les enregistrements échouent de manière constante, vérifiez s'ils dépassent vos limites configurées. Les utilisateurs ne réalisent pas toujours que leurs enregistrements dépassent les limites, surtout lorsqu'ils téléversent du contenu existant. + +Si les coûts d'API augmentent de façon inattendue, vérifiez votre limite de longueur de transcription. Un seul utilisateur téléversant de nombreux longs enregistrements pourrait augmenter considérablement la consommation si aucune limite n'est définie. + +Pour tout problème technique, contactez le support InnovA AI. + +--- + +Suivant : [Modèles d'IA](modeles-ia.md) → diff --git a/client_docs/guide-admin/prompts.md b/client_docs/guide-admin/prompts.md new file mode 100644 index 0000000..0110958 --- /dev/null +++ b/client_docs/guide-admin/prompts.md @@ -0,0 +1,121 @@ +# Consignes par défaut + +L'onglet Consignes par défaut vous permet de façonner la manière dont l'IA interprète et résume les enregistrements dans l'ensemble de votre instance DictIA. C'est ici que vous établissez l'intelligence de base que les utilisateurs expérimentent lorsqu'ils n'ont pas personnalisé leurs propres consignes. + +## Comprendre la hiérarchie des consignes + +DictIA utilise une hiérarchie pour déterminer quelle consigne utiliser pour chaque enregistrement. Ce système offre de la flexibilité tout en maintenant le contrôle. + +1. **Consignes par étiquette** (priorité la plus haute) : Lorsqu'un enregistrement possède des étiquettes avec des consignes associées, celles-ci ont la priorité absolue. Plusieurs consignes d'étiquettes se combinent intelligemment, permettant des configurations sophistiquées pour des types de contenu spécialisés. + +2. **Consigne personnelle de l'utilisateur** : Définie dans les paramètres du compte de l'utilisateur. Permet aux individus d'adapter les résumés à leurs besoins spécifiques sans affecter les autres. + +3. **Consigne par défaut de l'administrateur** : Configurée sur cette page, elle sert de fondation pour la majorité des résumés. C'est ce que les nouveaux utilisateurs expérimentent et ce sur quoi les utilisateurs à long terme comptent. + +4. **Consigne système de secours** : Si tout le reste échoue, une consigne intégrée garantit que des résumés sont toujours générés. + +## Rédiger des consignes efficaces + +Votre consigne par défaut est plus qu'une instruction technique : c'est un modèle de compréhension. Considérez votre base d'utilisateurs lors de la conception : + +- Un cabinet juridique pourrait se concentrer sur les détails de dossiers et les précédents +- Une institution de recherche pourrait mettre l'accent sur les méthodologies et les résultats +- Une agence créative pourrait mettre en valeur les concepts et les commentaires des clients + +### Conseils de rédaction + +- **Structurez clairement** : Utilisez des listes à puces ou des sections numérotées pour organiser la sortie +- **Spécifiez le niveau de détail** : "Aperçu bref" versus "Analyse complète" produisent des résultats très différents +- **Concevez pour la polyvalence** : Cette consigne s'applique à tout, des vérifications de 5 minutes aux ateliers de 2 heures +- **Évitez les exigences trop spécifiques** : Pas tous les enregistrements contiennent des "décisions" ou des "éléments d'action" +- **Utilisez un langage conditionnel** : Des phrases comme "si applicable" ou "lorsque discuté" permettent à l'IA de sauter les sections non pertinentes + +## L'éditeur de consignes + +La zone de texte affiche votre consigne actuelle avec le support complet du Markdown. Les modifications sont enregistrées immédiatement lorsque vous cliquez sur **Enregistrer les modifications**. Il n'y a pas de brouillon ni de mise en attente : les modifications affectent tous les nouveaux résumés instantanément. + +Les utilisateurs peuvent régénérer les résumés pour appliquer les consignes mises à jour aux enregistrements existants. + +Le bouton **Réinitialiser par défaut** offre un filet de sécurité, permettant de revenir à la consigne originale si vos personnalisations ne produisent pas les résultats attendus. + +L'horodatage indique la dernière modification, utile pour coordonner les changements entre plusieurs administrateurs. + +## Voir la structure complète de la consigne IA + +La section dépliable **Voir la structure complète de la consigne IA** révèle comment votre consigne s'intègre dans l'instruction complète envoyée à l'IA. Cette vue technique montre la consigne système, votre consigne personnalisée et l'intégration de la transcription. + +Comprendre cette structure vous aide à : + +- Éviter de répéter des instructions déjà gérées par la consigne système +- Comprendre pourquoi certaines formulations fonctionnent mieux +- Diagnostiquer les problèmes lorsque les résumés ne répondent pas aux attentes + +## Cas d'utilisation créatifs avec les étiquettes + +Les étiquettes avec des consignes personnalisées permettent des transformations puissantes du contenu : + +### Enregistrements de recettes + +Créez une étiquette "Recette" avec une consigne comme : "Convertis cette narration culinaire en une recette formatée avec la liste des ingrédients, les étapes, les temps de cuisson et le nombre de portions." + +L'IA transforme un flux de conscience désordonné en une recette propre et utilisable. + +### Notes de cours + +Une étiquette "Cours" pourrait utiliser : "Extrais les concepts principaux, les exemples, la terminologie clé avec définitions et les applications pratiques mentionnées. Organise en format plan pour des notes d'étude." + +### Éléments d'action de réunion + +Créez une étiquette "Réunion de projet" avec : "Concentre-toi exclusivement sur les éléments d'action, les décisions prises et les prochaines étapes. Pour chaque élément d'action, identifie le responsable et les échéances mentionnées." + +### Sessions de remue-méninges + +Une étiquette "Idées" avec : "Liste chaque idée distincte mentionnée, peu importe la brièveté de la discussion. Pour chaque idée, note les réactions ou préoccupations immédiates." + +### Revues de code + +Étiquette "Revue de code" : "Pour chaque morceau de code ou système discuté, liste : 1) Ce qui a été revu, 2) Problèmes identifiés, 3) Changements suggérés, 4) Qui implémentera les correctifs." + +## Combinaison d'étiquettes et ordre + +Lorsqu'un enregistrement possède plusieurs étiquettes avec des consignes, elles se combinent dans l'ordre d'application. Exemples : + +### Cours personnel + cours spécifique + +- Étiquette "Mes cours" : "Organise comme notes d'étude avec des en-têtes clairs." +- Étiquette "Biologie 301" : "Porte une attention particulière aux processus biologiques et à la terminologie." +- **Résultat** : Des notes d'étude formatées avec un focus sur le contenu biologique. + +### Réunion client + revue juridique + +- Étiquette "Réunion client" : "Extrais les exigences, préoccupations et préférences du client." +- Étiquette "Revue juridique" : "Identifie les considérations légales et les facteurs de risque mentionnés." +- **Résultat** : Les besoins du client plus les implications juridiques dans un seul résumé. + +### Ordre des étiquettes + +Les consignes plus spécifiques devraient généralement venir en dernier, car elles raffinent la sortie des consignes générales. Commencez large (type de format) puis ajoutez les spécificités (domaines de focus). + +Testez vos combinaisons d'étiquettes avec des enregistrements de référence pour voir quel ordre produit les résultats souhaités. + +## Coordonner avec les consignes utilisateur + +Votre consigne par défaut devrait compléter, et non rivaliser avec, la personnalisation des utilisateurs. Concevez-la comme une base solide qui fonctionne pour la plupart des cas, tout en encourageant les utilisateurs avancés à personnaliser selon leurs besoins. + +Communiquez votre stratégie de consignes aux utilisateurs pour qu'ils puissent décider si la personnalisation leur serait bénéfique. Si certains départements ont besoin de résumés spécialisés, fournissez des consignes recommandées qu'ils peuvent utiliser. + +## Mesurer l'efficacité des consignes + +- **Fréquence des modifications** : Si les utilisateurs modifient souvent les résumés générés, votre consigne pourrait avoir besoin d'ajustement +- **Échantillonnage périodique** : Révisez un échantillon de résumés régulièrement pour évaluer la qualité +- **Rétroaction des utilisateurs** : Sollicitez des commentaires sur la qualité des résumés, en particulier auprès des utilisateurs qui n'ont pas personnalisé leurs consignes + +## Pièges courants à éviter + +- **Consignes trop restrictives** : Pas tous les enregistrements contiennent des "décisions" ou des "éléments d'action". Forcer l'IA à trouver ces éléments quand ils n'existent pas produit du contenu de remplissage sans valeur. +- **Demandes impossibles** : Demander les "préoccupations non exprimées" ou "ce qui n'a pas été discuté" dépasse les capacités de l'analyse de transcription. L'IA ne peut travailler qu'avec ce qui a été réellement dit. +- **Consignes trop longues** : Chaque instruction ajoute de la complexité. Concentrez-vous sur l'essentiel plutôt que d'essayer de capturer chaque détail possible. + +--- + +Suivant : [Recherche sémantique](recherche-semantique.md) → diff --git a/client_docs/guide-admin/recherche-semantique.md b/client_docs/guide-admin/recherche-semantique.md new file mode 100644 index 0000000..e9eb258 --- /dev/null +++ b/client_docs/guide-admin/recherche-semantique.md @@ -0,0 +1,60 @@ +# Recherche sémantique + +La section Recherche sémantique contrôle l'intelligence derrière la Recherche IA, la capacité de recherche sémantique de DictIA qui permet aux utilisateurs de trouver de l'information dans tous leurs enregistrements en posant des questions en langage naturel. + +## Comprendre la Recherche IA + +La Recherche IA fonctionne en plusieurs étapes : + +1. Chaque transcription est découpée en segments de texte qui se chevauchent +2. Ces segments sont convertis en représentations mathématiques (indexation sémantique) +3. Les segments indexés sont stockés dans un format permettant la recherche +4. Lorsqu'un utilisateur pose une question, celle-ci est convertie dans le même format mathématique et comparée à tous les segments stockés + +Cette approche va bien au-delà de la simple correspondance de mots-clés. Le système comprend que "préoccupations budgétaires" est lié à "contraintes financières" et "dépassements de coûts", même si les mots exacts diffèrent. Cette compréhension sémantique rend la Recherche IA très puissante pour découvrir de l'information que les utilisateurs ne se rappellent pas précisément. + +## Statut du traitement + +Les cartes de statut donnent un aperçu immédiat de la santé de votre recherche sémantique : + +- **Enregistrements totaux** : Le nombre de fichiers audio dans votre système +- **Traités pour la Recherche IA** : Le nombre d'enregistrements convertis en segments indexés et recherchables. Ce nombre devrait éventuellement correspondre au total, avec un léger décalage pendant le traitement +- **En attente de traitement** : Les enregistrements en attente d'indexation sémantique. Ce nombre augmente lorsque les utilisateurs téléversent du nouveau contenu et diminue à mesure que le traitement avance +- **Segments totaux** : Les morceaux détaillés dans lesquels vos enregistrements ont été divisés. Un enregistrement typique d'une heure génère environ 50 à 60 segments +- **Statut de l'indexation** : Un indicateur rapide de santé. "Disponible" en vert signifie que tout fonctionne correctement + +## Progression du traitement + +La barre de progression montre l'avancement en temps réel dans la file d'attente d'indexation sémantique : + +- **100 %** : Tous les enregistrements sont traités et recherchables +- **Pourcentages inférieurs** : Le traitement est en cours +- **Barre bloquée** : Le traitement s'est peut-être arrêté. Contactez le support InnovA AI + +## Gérer la file d'attente + +Le bouton **Actualiser le statut** met à jour toutes les statistiques et indicateurs de progression. L'interface ne se rafraîchit pas automatiquement; des actualisations manuelles garantissent que vous voyez les informations actuelles. + +Si le système indique que des enregistrements nécessitent un traitement mais que la progression n'avance pas, contactez le support InnovA AI pour diagnostiquer le problème. + +Le système de traitement est conçu pour être résilient. Si le traitement échoue pour un enregistrement spécifique, le système le marque et passe au suivant plutôt que de rester bloqué. + +## Conseils pour de meilleurs résultats de recherche + +La qualité des résultats de recherche dépend en partie de la formulation des requêtes. Encouragez les utilisateurs à : + +- **Poser des questions complètes** plutôt que de taper des mots-clés. "Qu'est-ce que Marie a dit au sujet du budget?" fonctionne mieux que simplement "Marie budget". +- **Utiliser des termes variés** : La recherche sémantique comprend les synonymes et les concepts connexes +- **Être spécifique** lorsque c'est possible : Plus la question est précise, plus les résultats seront pertinents + +## Considérations de performance + +La recherche sémantique croît de façon prévisible avec votre contenu. Chaque segment nécessite environ 2 Ko de stockage. Un enregistrement typique d'une heure nécessite environ 100 Ko de stockage d'indexation. Le système maintient des performances rapides même avec de grandes quantités de données. + +Si votre instance croît de façon significative, les politiques de [rétention](retention.md) aident à gérer à la fois le stockage et la performance de la recherche. La recherche sémantique n'inclut que les enregistrements actifs; la suppression du contenu obsolète améliore les performances de recherche. + +Pour toute question technique sur la performance de la recherche sémantique, contactez le support InnovA AI. + +--- + +Retour au [Guide d'administration](index.md) → diff --git a/client_docs/guide-admin/retention.md b/client_docs/guide-admin/retention.md new file mode 100644 index 0000000..2ab42d7 --- /dev/null +++ b/client_docs/guide-admin/retention.md @@ -0,0 +1,212 @@ +# Rétention et suppression automatique + +Le système de suppression automatique assure une gestion du cycle de vie de vos enregistrements, en conformité avec vos politiques de rétention des données et les exigences de la Loi 25 du Québec. + +## Vue d'ensemble + +Le système de rétention automatisée vous aide à : + +- **Respecter les politiques de rétention** : Suppression automatique des enregistrements après une période définie, conformément à la Loi 25 +- **Gérer le stockage** : Prévenir la croissance illimitée des fichiers audio +- **Préserver les données essentielles** : Conserver les transcriptions et métadonnées même après la suppression de l'audio +- **Protéger les enregistrements importants** : Exempter des enregistrements spécifiques de la suppression automatique + +## Modes de suppression + +DictIA offre deux modes de suppression : + +### Mode audio seulement + +- **Supprime** : Le fichier audio uniquement +- **Conserve** : La transcription, le résumé, les notes et les métadonnées +- **Cas d'utilisation** : Conservation à long terme avec optimisation du stockage +- **Résultat** : Les enregistrements apparaissent en mode "Archivé", la transcription reste recherchable + +### Mode suppression complète + +- **Supprime** : L'enregistrement complet, incluant l'audio, la transcription, le résumé et les notes +- **Conserve** : Rien - l'enregistrement est définitivement supprimé +- **Cas d'utilisation** : Suppression complète des données pour la conformité à la Loi 25 +- **Résultat** : L'enregistrement est entièrement retiré du système + +## Système de rétention multiniveau + +DictIA utilise un système hiérarchique de politiques de rétention : + +### 1. Rétention globale (à l'échelle du système) + +La période de rétention globale s'applique à tous les enregistrements, sauf si elle est remplacée par une étiquette spécifique. Contactez le support InnovA AI pour ajuster ce paramètre. + +### 2. Rétention par étiquette + +Les étiquettes peuvent remplacer la période de rétention globale avec des périodes personnalisées. Cela est particulièrement puissant avec les étiquettes de groupe. + +| Type de contenu | Rétention | Justification | +|----------------|-----------|---------------| +| Enregistrements sans étiquette | Globale (ex. : 90 jours) | Valeur par défaut du système | +| Archives légales | 2555 jours (7 ans) | Conformité juridique | +| Réunions quotidiennes | 14 jours | Contenu éphémère | + +Lorsqu'un enregistrement possède plusieurs étiquettes avec différentes périodes de rétention, la période la **plus courte** s'applique. Cela garantit la conformité avec la politique la plus restrictive. + +### 3. Protection par étiquette + +Les étiquettes individuelles peuvent protéger les enregistrements contre la suppression automatique de manière permanente. + +**Hiérarchie d'exemple** : + +- Rétention globale : 90 jours +- Étiquette "Revues de sprint" : 180 jours (plus long que la globale) +- Étiquette "Réunions quotidiennes" : 14 jours (plus court que la globale) +- Étiquette "Juridique" avec protection activée : Jamais supprimé (permanent) + +## Protéger des enregistrements + +Pour protéger des enregistrements contre la suppression automatique : + +1. Accédez à **Paramètres du compte** → onglet **Étiquettes** +2. Cliquez sur **Créer une étiquette** ou **Modifier** une étiquette existante +3. Activez la case **Protéger contre la suppression automatique** +4. Appliquez cette étiquette aux enregistrements à protéger + +Les enregistrements avec des étiquettes protégées sont exemptés de la suppression automatique, peu importe leur âge ou la période de rétention. + +## Enregistrements archivés + +Lorsque le mode de suppression est "audio seulement", les enregistrements deviennent "archivés" après la suppression de l'audio. + +### Accéder aux enregistrements archivés + +1. Ouvrez la barre latérale **Enregistrements** +2. Cliquez sur **Filtres avancés** +3. Activez l'option **Enregistrements archivés** + +### Fonctionnalités disponibles pour les enregistrements archivés + +| Fonctionnalité | Disponible | Notes | +|----------------|------------|-------| +| Lire la transcription | Oui | Transcription complète accessible | +| Rechercher le contenu | Oui | La recherche textuelle fonctionne toujours | +| Lire le résumé | Oui | Le résumé IA est préservé | +| Voir/modifier les notes | Oui | Toutes les métadonnées accessibles | +| Écouter l'audio | Non | Le fichier audio a été supprimé | +| Retraiter | Non | L'audio source n'est plus disponible | +| Partager | Oui | La transcription peut être partagée | +| Exporter | Oui | Téléchargement de la transcription, du résumé et des notes | + +### Indicateurs d'archivage + +- **Barre latérale** : Badge gris "Archivé" à côté du titre de l'enregistrement +- **Lecteur** : Bannière informative indiquant que l'audio a été supprimé mais que la transcription reste disponible +- **Filtre** : Option de vue "Archivé" dans les filtres avancés + +## Nettoyage des profils de locuteurs + +Par défaut, les profils de locuteurs et les empreintes vocales sont préservés même lorsque tous les enregistrements associés sont supprimés. Cela est dû au fait que les empreintes vocales sont des données agrégées qui ne peuvent pas être reconstruites à partir d'enregistrements individuels. + +Pour les déploiements qui doivent traiter les empreintes vocales comme des données biométriques (conformité Loi 25), le nettoyage automatique peut être activé. Contactez le support InnovA AI pour ajuster ce paramètre. + +### Conformité Loi 25 et protection de la vie privée + +Lorsque le nettoyage automatique est activé : + +- **Minimisation des données** : Les données vocales sont supprimées lorsqu'elles ne sont plus nécessaires +- **Droit à l'effacement** : Les profils vocaux sont supprimés lorsque les enregistrements sont retirés +- **Transparence** : L'activité de nettoyage est journalisée à des fins d'audit +- **Automatisation** : Aucune intervention manuelle n'est requise lorsque combiné aux politiques de rétention + +## Cas d'utilisation pratiques + +### Usage personnel + +Vous enregistrez tout pendant votre journée de travail. La plupart de ces enregistrements sont éphémères. Configurez une rétention globale de 30 jours avec suppression audio seulement. Après un mois, les fichiers audio disparaissent mais les transcriptions recherchables demeurent. Si quelque chose s'avère important, étiquetez-le avec une étiquette protégée avant l'expiration. + +### Collaboration de groupe + +| Type de contenu | Approche de rétention | Raison | +|----------------|----------------------|--------| +| Réunions quotidiennes | Étiquette avec rétention de 14 jours | Mises à jour de routine, aucune valeur à long terme | +| Planification de sprint | Étiquette avec rétention de 90 jours | Valeur de référence pour le trimestre en cours | +| Décisions d'architecture | Étiquette avec protection activée | Documenter les choix importants de façon permanente | +| Appels clients (ventes) | Étiquette avec rétention de 1 an | Durée du cycle de vente + suivi | +| Entrevues (RH) | Étiquette avec rétention de 2 ans | Délai typique de litige en emploi | +| Réunions juridiques | Étiquette protégée | Rétention indéfinie pour conformité | + +Chaque groupe configure ses étiquettes une seule fois. Les membres n'ont qu'à étiqueter normalement leurs enregistrements et la gestion du cycle de vie se fait automatiquement. + +### Exigences de conformité (Loi 25) + +La Loi 25 du Québec exige que les organisations gèrent adéquatement les renseignements personnels, incluant leur durée de conservation. Le système de rétention de DictIA vous aide à : + +- Établir des périodes de rétention conformes à vos obligations légales +- Automatiser la suppression des données lorsque la période de conservation est expirée +- Protéger les enregistrements qui doivent être conservés pour des raisons légales +- Documenter vos politiques de rétention de manière transparente + +### Gestion des coûts de stockage + +Les fichiers audio sont volumineux (une réunion d'une heure peut représenter 50 à 100 Mo). Les transcriptions sont légères (10 à 20 Ko pour la même réunion). Le mode suppression audio seulement conserve le texte recherchable tout en libérant le stockage. + +Avec une rétention de 90 jours en mode audio seulement, les enregistrements de plus de 90 jours perdent leur audio mais restent entièrement recherchables via la Recherche IA. Vous pouvez lire les transcriptions, consulter les résumés et voir les notes, mais vous ne pouvez plus écouter l'audio original. + +## Bonnes pratiques + +### Pour la conformité Loi 25 + +1. Définissez des périodes de rétention appropriées à votre secteur d'activité +2. Utilisez des étiquettes protégées pour les documents nécessitant une rétention indéfinie (ex. : "Rétention légale", "Permanent") +3. Documentez votre politique de rétention dans la documentation de conformité de votre organisation +4. Activez le nettoyage des profils de locuteurs si les empreintes vocales constituent des renseignements personnels dans votre contexte + +### Pour la gestion du stockage + +1. Commencez avec le mode suppression audio seulement pour préserver les transcriptions recherchables +2. Utilisez des périodes plus courtes pour le contenu de routine +3. Protégez le contenu important avec des étiquettes dédiées + +### Pour les groupes + +1. Établissez une rétention globale conservatrice comme base +2. Configurez des étiquettes de groupe avec des rétentions personnalisées selon les besoins +3. Utilisez des étiquettes protégées pour le contenu nécessitant une conservation permanente +4. Documentez les politiques de rétention pour que les membres du groupe comprennent les attentes + +## Processus de suppression + +Le processus de suppression automatique fonctionne ainsi : + +1. **Vérification automatique** (quotidienne) +2. **Identification** des enregistrements plus anciens que la période de rétention +3. **Pour chaque enregistrement** : + - Vérification des étiquettes de protection + - Si protégé → l'enregistrement est exempt +4. **Suppression** selon le mode configuré (audio seulement ou complet) + +## Dépannage + +### La suppression automatique ne fonctionne pas + +Contactez le support InnovA AI pour vérifier que le système est correctement configuré et actif. + +### Trop d'enregistrements sont supprimés + +1. Vérifiez vos étiquettes de protection et assurez-vous qu'elles sont appliquées aux enregistrements importants +2. Contactez le support InnovA AI pour augmenter la période de rétention globale + +### Les enregistrements archivés n'apparaissent pas + +1. Activez le filtre **Enregistrements archivés** dans la barre latérale +2. Vérifiez que le mode de suppression est bien "audio seulement" (le mode suppression complète ne crée pas d'archives) + +## Sécurité et irréversibilité + +- La suppression automatique est irréversible : les fichiers audio supprimés ne peuvent pas être récupérés +- Les enregistrements protégés ne sont jamais supprimés automatiquement +- L'activité de suppression est journalisée à des fins d'audit et de conformité Loi 25 +- Seuls les administrateurs peuvent configurer les politiques de rétention + +Pour toute question ou problème concernant la suppression automatique, contactez le support InnovA AI. + +--- + +Retour au [Guide d'administration](index.md) → diff --git a/client_docs/guide-admin/sso.md b/client_docs/guide-admin/sso.md new file mode 100644 index 0000000..07ba501 --- /dev/null +++ b/client_docs/guide-admin/sso.md @@ -0,0 +1,103 @@ +# Authentification unique (SSO) + +DictIA supporte l'authentification unique (SSO) via le protocole OpenID Connect (OIDC), vous permettant d'intégrer la plateforme avec votre fournisseur d'identité existant. + +## Qu'est-ce que le SSO? + +L'authentification unique permet à vos utilisateurs de se connecter à DictIA en utilisant les mêmes identifiants que pour les autres applications de votre organisation. Au lieu de gérer un mot de passe séparé pour DictIA, les utilisateurs se connectent via votre fournisseur d'identité centralisé. + +### Avantages du SSO + +- **Simplicité pour les utilisateurs** : Un seul ensemble d'identifiants pour toutes les applications +- **Sécurité renforcée** : Politique de mots de passe centralisée, authentification multifacteur gérée par le fournisseur d'identité +- **Administration simplifiée** : Gestion centralisée des accès, désactivation rapide des comptes +- **Conformité Loi 25** : Meilleur contrôle de l'accès aux renseignements personnels grâce à la gestion centralisée des identités + +## Fournisseurs d'identité supportés + +DictIA est compatible avec tout fournisseur d'identité supportant le protocole OpenID Connect (OIDC), notamment : + +- **Microsoft Entra ID** (anciennement Azure AD) +- **Google Workspace** +- **Keycloak** +- **Auth0** +- **Okta** +- Et tout autre fournisseur OIDC compatible + +## Configuration + +La configuration technique du SSO (identifiants client, URL de découverte, redirections) est effectuée par l'équipe InnovA AI. Contactez le support InnovA AI pour mettre en place le SSO sur votre instance. + +### Informations à fournir + +Pour configurer le SSO, vous devrez fournir à InnovA AI : + +- Le **fournisseur d'identité** que vous utilisez +- Les **identifiants client** (Client ID et Client Secret) émis par votre fournisseur +- L'**URL de découverte** OIDC de votre fournisseur +- Les **domaines de courriel autorisés** pour l'auto-inscription (si souhaité) + +### Options de configuration + +Lors de la mise en place, vous pourrez choisir parmi les options suivantes : + +**Auto-inscription** : Les nouveaux utilisateurs sont automatiquement créés lors de leur première connexion SSO, à condition que leur domaine de courriel soit autorisé. Cela élimine le besoin de créer manuellement chaque compte. + +**Restriction de domaine** : Limitez l'accès SSO aux courriels de domaines spécifiques (ex. : votre-entreprise.com). Utile pour s'assurer que seuls les employés autorisés peuvent accéder à DictIA. + +**Désactivation de la connexion par mot de passe** : Forcez l'utilisation exclusive du SSO pour tous les utilisateurs réguliers. Les administrateurs conservent la possibilité de se connecter par mot de passe comme mécanisme de secours. + +## Expérience utilisateur + +### Première connexion + +Lors de sa première connexion via SSO, l'utilisateur : + +1. Clique sur le bouton de connexion SSO sur la page de connexion de DictIA +2. Est redirigé vers la page de connexion de votre fournisseur d'identité +3. Se connecte avec ses identifiants habituels +4. Est automatiquement redirigé vers DictIA avec une session active + +Si l'auto-inscription est activée, le compte DictIA est créé automatiquement. Sinon, un administrateur doit d'abord créer le compte. + +### Lier un compte existant + +Les utilisateurs qui possèdent déjà un compte DictIA peuvent lier leur compte SSO : + +1. Se connecter à DictIA avec leur mot de passe habituel +2. Accéder à **Compte** → **Authentification unique** +3. Cliquer sur **Lier le compte** pour associer leur identité SSO + +Une fois lié, l'utilisateur peut se connecter via SSO ou par mot de passe (si la connexion par mot de passe n'est pas désactivée). + +### Délier un compte SSO + +Les utilisateurs peuvent délier leur compte SSO depuis **Compte** → **Authentification unique**. Les utilisateurs qui ont créé leur compte via SSO (sans mot de passe) doivent d'abord définir un mot de passe avant de pouvoir délier leur compte SSO. + +## Considérations de sécurité + +- Lorsqu'un utilisateur se connecte via SSO avec un courriel correspondant à un compte DictIA existant, les comptes sont automatiquement liés +- La sécurité de l'accès SSO dépend de votre fournisseur d'identité : assurez-vous que celui-ci est correctement sécurisé (authentification multifacteur, politique de mots de passe forte) +- Utilisez la restriction de domaine pour limiter l'accès aux seuls courriels de votre organisation + +## Dépannage + +### La connexion SSO échoue immédiatement + +Les identifiants client ou l'URL de découverte sont peut-être incorrects. Contactez le support InnovA AI pour vérifier la configuration. + +### Un utilisateur est créé sans adresse courriel + +Certains fournisseurs d'identité ne retournent pas l'adresse courriel par défaut. Contactez le support InnovA AI pour ajuster la configuration des attributs (claims). + +### Le domaine de courriel est rejeté + +Vérifiez que le domaine de courriel de l'utilisateur fait partie de la liste des domaines autorisés. Contactez le support InnovA AI pour ajouter un domaine si nécessaire. + +### Un compte SSO est déjà lié à un autre utilisateur + +Chaque identité SSO ne peut être liée qu'à un seul compte DictIA. L'utilisateur peut délier son compte SSO depuis ses paramètres de compte, puis le relié à un autre compte. + +--- + +Retour au [Guide d'administration](index.md) → diff --git a/client_docs/guide-admin/statistiques.md b/client_docs/guide-admin/statistiques.md new file mode 100644 index 0000000..5e5566c --- /dev/null +++ b/client_docs/guide-admin/statistiques.md @@ -0,0 +1,82 @@ +# Statistiques du système + +La section Statistiques du système transforme les données brutes en informations exploitables sur votre instance DictIA. D'un coup d'oeil, vous pouvez voir combien d'utilisateurs vous desservez, combien d'enregistrements ils ont créés, combien de stockage ils consomment et si tout se traite correctement. + +## Vue d'ensemble des métriques clés + +Quatre cartes en haut de la page de statistiques vous donnent un aperçu immédiat de l'envergure de votre système : + +- **Utilisateurs totaux** : La taille de votre base d'utilisateurs actuelle +- **Enregistrements totaux** : Le contenu cumulatif dans votre système +- **Stockage total** : L'espace disque réellement consommé +- **Requêtes totales** : L'utilisation de la Recherche IA (lorsque activée) + +Ces chiffres racontent une histoire sur la santé et la croissance de votre instance. Une base d'utilisateurs en croissance avec une augmentation proportionnelle des enregistrements suggère une adoption saine. Un stockage qui croît plus rapidement que les enregistrements peut indiquer que les utilisateurs téléversent des fichiers plus longs. + +## Distribution des statuts d'enregistrement + +Cette section décompose vos enregistrements en quatre états : + +- **Complétés** : Entièrement traités et prêts à l'utilisation. Cela devrait représenter la grande majorité de votre contenu. +- **En traitement** : En cours de transcription ou d'analyse. +- **En attente** : En file d'attente. Un nombre élevé constant pourrait indiquer une surcharge du système. +- **En erreur** : Des problèmes ont été rencontrés. Ces cas méritent toujours une investigation. + +Dans un système en santé, vous verrez principalement des enregistrements complétés avec peut-être quelques-uns en traitement à tout moment. + +## Analyse du stockage + +La section **Principaux utilisateurs par stockage** révèle qui consomme le plus de ressources. Chaque utilisateur est listé avec sa consommation totale de stockage et son nombre d'enregistrements, vous donnant le contexte pour comprendre s'ils ont plusieurs petits fichiers ou quelques gros fichiers. + +Ces informations sont précieuses pour la planification de la capacité. Si un utilisateur consomme un stockage disproportionné, il peut être utile de comprendre son cas d'utilisation et d'adapter les politiques en conséquence. + +## Comprendre les tendances d'utilisation + +Les statistiques ne sont pas que des chiffres : ce sont des informations qui attendent d'être découvertes. + +- Les pics soudains d'enregistrements peuvent coïncider avec des lancements de projets ou des périodes de pointe +- Une croissance du stockage qui dépasse celle des enregistrements pourrait indiquer des fichiers audio plus longs ou de meilleure qualité +- Un suivi régulier vous aide à repérer les tendances avant qu'elles ne deviennent des problèmes + +## Planification de la capacité + +Les statistiques du système sont votre boule de cristal pour les besoins d'infrastructure. Les tendances de croissance du stockage vous indiquent quand une augmentation de capacité sera nécessaire. Utilisez ces informations de manière proactive pour anticiper les besoins. Contactez le support InnovA AI si vous observez une croissance rapide qui pourrait nécessiter une augmentation des ressources. + +## Statistiques d'utilisation des jetons + +La section Utilisation des jetons offre une visibilité sur la consommation d'API de modèles d'IA à travers votre instance. + +**Cartes de résumé** : Affichent l'utilisation quotidienne, les totaux mensuels, les coûts estimés et les utilisateurs approchant de leurs limites de budget. + +**Graphiques quotidiens et mensuels** : Des graphiques interactifs affichent les tendances de consommation de jetons sur les 30 derniers jours et les 12 derniers mois. + +**Ventilation par utilisateur** : Un tableau détaillé montre la consommation mensuelle de jetons de chaque utilisateur avec sa limite de budget (si définie). Les barres de progression indiquent le pourcentage utilisé : + +- **Vert** : Sous 80 % du budget +- **Jaune** : Entre 80 et 100 % (zone d'avertissement) +- **Rouge** : À 100 % ou plus (bloqué) + +Utilisez ces statistiques pour identifier les gros utilisateurs, valider les allocations de budget et prévoir les coûts d'API. Si certains utilisateurs atteignent régulièrement leurs limites, vous pourriez avoir besoin d'augmenter leurs budgets ou d'examiner leurs habitudes d'utilisation. Consultez la [Gestion du budget de jetons](gestion-utilisateurs.md#gestion-du-budget-de-jetons-ia) pour définir les limites individuelles. + +## Statistiques d'utilisation de la transcription + +La section Utilisation de la transcription offre une visibilité sur la consommation de services de transcription vocale, distincte de l'utilisation des jetons. + +**Cartes de résumé** : + +- **Minutes aujourd'hui** : Minutes de transcription utilisées aujourd'hui par tous les utilisateurs +- **Ce mois-ci** : Total des minutes transcrites dans le mois civil en cours +- **Coût mensuel** : Coûts estimés basés sur le service de transcription utilisé +- **Alertes de budget** : Nombre d'utilisateurs approchant (80 %+) ou dépassant (100 %) leurs budgets + +**Ventilation par utilisateur** : Une liste détaillée montre l'utilisation mensuelle de transcription de chaque utilisateur avec sa limite de budget (si définie). Les barres de progression indiquent la consommation : + +- **Vert** : Sous 80 % du budget +- **Jaune** : Entre 80 et 100 % (zone d'avertissement) +- **Rouge** : À 100 % ou plus (bloqué pour les nouvelles transcriptions) + +Utilisez ces statistiques pour surveiller les tendances d'utilisation et valider les allocations de budget. Consultez la [Gestion du budget de transcription](gestion-utilisateurs.md#gestion-du-budget-de-transcription) pour définir les limites individuelles. + +--- + +Suivant : [Paramètres du système](parametres-systeme.md) → diff --git a/client_docs/guide-utilisateur/application-mobile.md b/client_docs/guide-utilisateur/application-mobile.md new file mode 100644 index 0000000..970312a --- /dev/null +++ b/client_docs/guide-utilisateur/application-mobile.md @@ -0,0 +1,197 @@ +# Application mobile + +DictIA est une application web progressive (PWA) qui peut etre installee sur votre appareil pour une experience similaire a une application native, avec la prevention de la mise en veille de l'ecran pendant l'enregistrement. + +## Avantages de l'application mobile + +- **Installable** : ajoutez DictIA a votre ecran d'accueil comme une application native. +- **Chargement rapide** : les ressources mises en cache se chargent instantanement. +- **Prevention de la mise en veille** : empeche l'ecran de s'eteindre automatiquement pendant l'enregistrement. +- **Pas de boutique d'applications** : installez directement depuis votre navigateur. +- **Mises a jour automatiques** : vous obtenez toujours la derniere version. + +## Installer DictIA sur votre appareil + +### Sur Android (Chrome ou Edge) + +1. **Ouvrez DictIA** dans Chrome ou Edge. +2. Recherchez l'invite **« Ajouter a l'ecran d'accueil »** en bas de l'ecran. +3. Appuyez sur **Ajouter** ou **Installer**. +4. Si l'invite n'apparait pas : + - Appuyez sur le menu a trois points (⋮) dans le navigateur. + - Selectionnez **« Ajouter a l'ecran d'accueil »** ou **« Installer l'application »**. + - Suivez les instructions. +5. **Lancez** l'application depuis votre ecran d'accueil. + +!!! tip "Invite d'installation" + Si vous ne voyez pas l'invite d'installation, visitez DictIA quelques fois. Le navigateur propose l'installation apres avoir detecte une utilisation reguliere. + +### Sur iOS (Safari) + +1. **Ouvrez DictIA** dans Safari. +2. Appuyez sur le bouton **Partager** (icone carre avec fleche vers le haut) en bas de l'ecran. +3. Faites defiler et appuyez sur **« Sur l'ecran d'accueil »**. +4. Modifiez le nom si desire et appuyez sur **Ajouter**. +5. **Lancez** l'application depuis votre ecran d'accueil. + +!!! note "Limitations iOS" + - La prevention de la mise en veille necessite iOS 16.4 ou superieur. + - L'execution en arriere-plan est plus limitee que sur Android. + - Certaines fonctionnalites fonctionnent mieux sur iOS 17 et versions ulterieures. + +### Sur ordinateur de bureau (Chrome, Edge ou Brave) + +1. **Ouvrez DictIA** dans votre navigateur. +2. Recherchez l'**icone d'installation** (⊕) dans la barre d'adresse. +3. Cliquez dessus et selectionnez **Installer**. +4. Si l'icone n'apparait pas : + - Cliquez sur le menu a trois points. + - Selectionnez **« Installer DictIA »** ou **« Ajouter aux applications »**. +5. **Lancez** depuis votre menu d'applications ou votre raccourci de bureau. + +## Fonctionnalites de l'application mobile + +### Prevention de la mise en veille de l'ecran + +Empeche l'ecran de votre appareil de s'eteindre automatiquement pendant l'enregistrement : + +- **Activation automatique** : s'active des que vous commencez un enregistrement. +- **Ecran toujours allume** : empeche la mise en veille tant que l'application est au premier plan. +- **Recuperation automatique** : se reactive si l'ecran se reveille pendant un enregistrement. +- **Consideration de la batterie** : actif uniquement pendant les sessions d'enregistrement. + +### Notifications persistantes + +Affiche l'etat de l'enregistrement dans votre barre de notifications (mobile uniquement) : + +- Rappel visuel que l'enregistrement est actif +- Retour rapide a l'application en un seul appui +- Silencieuse : pas de son ni de vibration + +### Acces hors connexion + +Une fois installee, DictIA met en cache les fichiers essentiels pour un acces hors ligne : + +- Interface de l'application +- Feuilles de style et polices +- Code de l'application + +!!! info "Limites hors connexion" + La transcription et les fonctionnalites d'IA necessitent une connexion internet. L'acces hors ligne permet de consulter l'interface, mais les appels au serveur ne sont pas disponibles. + +## Enregistrement sur mobile — Limitation importante + +!!! danger "Gardez l'application visible sur les appareils mobiles" + **Les navigateurs mobiles (Chrome, Safari, etc.) suspendent l'enregistrement audio lorsque l'application est reduite ou l'ecran verrouille.** Il s'agit d'une limitation fondamentale des navigateurs qui ne peut pas etre contournee. + + **Ce que cela signifie :** + + - L'enregistrement **se met en pause** si vous reduisez la fenetre. + - L'enregistrement **se met en pause** si vous verrouillez votre ecran. + - L'enregistrement **se met en pause** si vous passez a une autre application. + - L'enregistrement **continue** si vous gardez DictIA visible au premier plan. + - La prevention de la mise en veille **empeche l'ecran de s'eteindre automatiquement** pendant que DictIA est visible. + + **Pour les longues reunions sur mobile :** + + 1. Gardez DictIA ouvert et visible (ne reduisez pas, ne verrouillez pas, ne changez pas d'application). + 2. Ou utilisez l'enregistreur vocal natif de votre telephone, puis televersez le fichier dans DictIA par la suite. + + **Les navigateurs de bureau fonctionnent differemment** : l'enregistrement continue meme lorsque la fenetre est reduite. + +**Si vous reduisez accidentellement l'application :** + +- L'audio de l'enregistrement se met en pause (silence). +- Le minuteur continue de compter. +- Lorsque vous revenez a l'application, l'enregistrement reprend. +- Un silence sera present dans l'enregistrement final. + +## Permissions + +### Permissions requises + +- **Acces au microphone** : demande lors du premier enregistrement. Actif uniquement pendant l'enregistrement. +- **Acces a l'audio systeme** (ordinateur de bureau) : pour capturer l'audio des onglets de navigateur ou des applications. + +### Permissions recommandees + +- **Notifications** (mobile) : pour afficher un rappel visuel pendant l'enregistrement actif. Demande lors du premier enregistrement sur mobile. + +!!! tip "Reactiver les permissions" + Si vous refusez accidentellement une permission, vous pouvez la reinitialiser dans les parametres de votre navigateur ou appareil : + + - **Android Chrome** : Parametres > Parametres du site > URL de DictIA > Permissions + - **iOS Safari** : Reglages > Safari > URL de DictIA > Permissions + - **Ordinateur** : Cliquez sur l'icone de cadenas dans la barre d'adresse > Permissions + +## Compatibilite des navigateurs + +### Navigateurs mobiles + +| Fonctionnalite | Chrome Android | Safari iOS | Samsung Internet | +|----------------|----------------|------------|------------------| +| Installation | Android 5+ | iOS 11.3+ | 4.0+ | +| Prevention mise en veille | Chrome 84+ | iOS 16.4+ | 13.0+ | +| Notifications | Chrome 42+ | iOS 16.4+ | 4.0+ | + +### Navigateurs de bureau + +| Fonctionnalite | Chrome | Edge | Brave | Firefox | Safari | +|----------------|--------|------|-------|---------|--------| +| Installation | 73+ | 79+ | Toutes | Limitee | Limitee | +| Prevention mise en veille | 84+ | 84+ | Toutes | Non | 16.4+ | + +!!! info "Firefox et Safari sur ordinateur" + Firefox et Safari ont un support limite pour l'installation en tant qu'application sur ordinateur, mais toutes les fonctionnalites principales fonctionnent dans le navigateur. + +## Depannage + +### L'enregistrement s'arrete lorsque l'ecran se verrouille + +C'est un comportement attendu sur les navigateurs mobiles. Gardez l'application visible ou utilisez l'enregistreur vocal natif de votre telephone. + +### L'installation n'est pas proposee + +- Visitez DictIA plusieurs fois (le navigateur peut necessiter plusieurs visites). +- Essayez un autre navigateur (Chrome ou Edge offrent le meilleur support). +- Videz le cache du navigateur et rechargez. + +## Bonnes pratiques + +### Pour l'enregistrement mobile + +1. **Gardez l'application visible** : ne reduisez pas, ne verrouillez pas, ne changez pas d'application. +2. **Gardez votre telephone branche** pour les longs enregistrements. +3. **Fermez les applications inutiles** pour liberer de la memoire. +4. **Evitez de recevoir des appels** pendant l'enregistrement (cela mettra en pause l'enregistrement). +5. **Pour les longues reunions** : envisagez d'utiliser l'enregistreur natif de votre telephone. + +### Pour des performances optimales + +1. **Installez DictIA comme application** pour de meilleures performances. +2. **Gardez votre navigateur a jour** pour beneficier des dernieres fonctionnalites. +3. **Surveillez le stockage** dans les parametres de votre appareil. + +## Desinstaller l'application + +### Android + +1. Appuyez longuement sur l'icone de l'application sur l'ecran d'accueil. +2. Selectionnez **Infos sur l'appli** ou glissez vers **Supprimer**. +3. Appuyez sur **Desinstaller**. + +### iOS + +1. Appuyez longuement sur l'icone de l'application. +2. Selectionnez **Supprimer l'app**. +3. Confirmez la suppression. + +### Ordinateur de bureau + +1. Ouvrez l'application installee. +2. Cliquez sur le menu a trois points. +3. Selectionnez **Desinstaller DictIA**. + +--- + +Si vous avez des questions sur les fonctionnalites de l'application mobile, contactez le support InnovA AI. diff --git a/client_docs/guide-utilisateur/dossiers.md b/client_docs/guide-utilisateur/dossiers.md new file mode 100644 index 0000000..706e34d --- /dev/null +++ b/client_docs/guide-utilisateur/dossiers.md @@ -0,0 +1,115 @@ +# Dossiers + +Les dossiers offrent un moyen simple d'organiser vos enregistrements en groupes logiques. Contrairement aux etiquettes qui peuvent etre appliquees plusieurs fois pour categoriser le contenu, chaque enregistrement appartient a un seul dossier (ou a aucun). Pensez aux dossiers comme a des repertoires sur votre ordinateur : une facon claire et hierarchique de regrouper les enregistrements lies. + +## Creer des dossiers + +Accedez a la gestion des dossiers depuis **Parametres du compte > Dossiers**. + +Cliquez sur **Creer un dossier** pour ajouter un nouveau dossier. Chaque dossier possede : + +- **Nom** : un nom court et descriptif (obligatoire). +- **Couleur** : un identifiant visuel affiche sur les pastilles de dossier et dans la barre laterale. + +### Parametres du dossier + +Chaque dossier peut avoir des parametres personnalises qui s'appliquent aux enregistrements qui y sont places : + +| Parametre | Description | +|-----------|-------------| +| **Consigne personnalisee** | Instructions de resume IA specifiques a ce dossier | +| **Langue par defaut** | Langue de transcription pour les nouveaux enregistrements | +| **Min/Max locuteurs** | Indications sur le nombre de locuteurs pour l'identification | +| **Vocabulaire personnalise** | Liste de mots ou expressions a prioriser par le moteur de transcription | +| **Consigne de transcription** | Description du contexte pour guider la transcription | +| **Jours de retention** | Duree de conservation specifique au dossier | +| **Protection** | Exempter le contenu du dossier de la suppression automatique | + +## Utiliser les dossiers + +### Selecteur de dossier dans la barre laterale + +La barre laterale inclut un menu deroulant de dossiers en haut. Utilisez-le pour : + +- **Filtrer par dossier** : selectionnez un dossier pour n'afficher que ses enregistrements. +- **Voir tous les enregistrements** : selectionnez « Tous les dossiers ». +- **Voir les enregistrements non classes** : selectionnez « Aucun dossier ». + +### Deplacer des enregistrements dans les dossiers + +Plusieurs facons d'assigner un enregistrement a un dossier : + +1. **Pendant le televersement** : selectionnez un dossier avant de televerser. +2. **Depuis le detail de l'enregistrement** : utilisez le selecteur de dossier dans l'en-tete. +3. **Operations en lot** : selectionnez plusieurs enregistrements et utilisez l'action de dossier. + +### Pastilles de dossier + +Les enregistrements affichent une petite pastille coloree indiquant leur dossier. Elle apparait dans : + +- La liste des enregistrements dans la barre laterale +- L'en-tete du detail de l'enregistrement +- Les resultats de recherche + +Cliquez sur la pastille pour filtrer rapidement par ce dossier. + +## Dossiers de groupe + +Si votre organisation utilise les groupes, les dossiers peuvent etre partages : + +- **Dossiers personnels** : visibles uniquement par vous. +- **Dossiers de groupe** : partages avec tous les membres du groupe. + +Les dossiers de groupe peuvent avoir le partage automatique active : lorsque vous deplacez un enregistrement dans un dossier de groupe, tous les membres du groupe recoivent automatiquement l'acces. + +## Dossiers vs Etiquettes + +Les deux outils aident a organiser vos enregistrements, mais servent des objectifs differents : + +| Aspect | Dossiers | Etiquettes | +|--------|----------|------------| +| **Attribution** | Un seul dossier par enregistrement | Plusieurs etiquettes par enregistrement | +| **Objectif** | Organisation principale | Categorisation et filtrage | +| **Parametres** | Consigne IA, langue, retention | Consigne IA, langue, retention | +| **Visuel** | Pastille, filtre dans la barre laterale | Pastilles colorees | + +**Utilisez les dossiers pour** : l'organisation par projet, la separation par client, les types de reunions. + +**Utilisez les etiquettes pour** : les preoccupations transversales, le suivi de statut, la categorisation par sujet. + +Vous pouvez utiliser les deux ensemble : placez un enregistrement dans le dossier « Client A » et etiquetez-le avec « Actions a suivre » et « Suivi requis ». + +## Bonnes pratiques + +### Conventions de nommage + +Choisissez des noms de dossier clairs et coherents : + +- **Par projet** : « Refonte du site web », « Planification T1 », « Lancement produit » +- **Par client** : « Acme Corp », « Beta Inc », « Gamma LLC » +- **Par type** : « Meles d'equipe », « Appels clients », « Entrevues » + +### Consignes de dossier + +Utilisez des consignes specifiques au dossier pour des resumes coherents. Par exemple, pour les reunions clients : + +``` +Creer un resume avec : +- Sujets discutes +- Demandes et preoccupations du client +- Engagements pris +- Prochaines etapes avec responsables +``` + +### Strategies de retention + +Definissez la retention au niveau du dossier selon les besoins : + +- **Projets actifs** : aucune limite de retention +- **Projets termines** : 90 jours apres la cloture +- **Reunions de routine** : 30 jours +- **Enregistrements de conformite** : utilisez le drapeau de protection + +--- + +Suivant : [Groupes](groupes.md) diff --git a/client_docs/guide-utilisateur/enregistrement.md b/client_docs/guide-utilisateur/enregistrement.md new file mode 100644 index 0000000..fbd2c6f --- /dev/null +++ b/client_docs/guide-utilisateur/enregistrement.md @@ -0,0 +1,197 @@ +# Enregistrement et televersement + +DictIA offre deux facons d'ajouter du contenu a votre bibliotheque : televerser des fichiers audio existants ou enregistrer directement dans votre navigateur. Les deux methodes incluent les memes fonctionnalites d'organisation, d'etiquetage et de traitement. + +## Acceder a l'ecran d'enregistrement + +Cliquez sur le bouton **+ Nouvel enregistrement** dans la barre de navigation superieure pour ouvrir l'interface d'enregistrement. + +## Televerser des fichiers audio + +L'interface de televersement propose une zone de glisser-deposer en haut de l'ecran. Vous pouvez : + +- **Glisser** des fichiers audio directement depuis votre explorateur de fichiers vers cette zone. +- **Cliquer** sur la zone pour ouvrir un selecteur de fichiers. + +### Formats pris en charge + +DictIA prend en charge un large eventail de formats audio et video : + +- **Audio** : MP3, WAV, M4A, FLAC, AAC, OGG +- **Video** : MP4, MOV, AVI (DictIA extrait et traite la piste audio) +- **Mobile** : AMR, 3GP, 3GPP + +La taille maximale par defaut est de 500 Mo. Votre administrateur peut modifier cette limite. + +!!! info "Compression audio automatique" + DictIA compresse automatiquement les fichiers sans perte (WAV, AIFF) pour economiser l'espace de stockage. Un fichier WAV de 500 Mo devient generalement environ 50 Mo apres compression. Les formats deja compresses comme le MP3 sont conserves tels quels. + +Lorsque vous televersez un fichier, il apparait immediatement dans la file d'attente avec une barre de progression. Une fois televerse, vous pouvez ajouter des etiquettes, definir un titre personnalise et configurer les options de traitement avant de lancer la transcription. + +## Enregistrer de l'audio en direct + +Sous la zone de televersement, trois modes d'enregistrement sont disponibles : + +### Enregistrement au microphone + +Le bouton rouge du microphone capture l'audio depuis votre microphone selectionne. Ce mode est ideal pour : + +- Les reunions en personne +- Les notes vocales personnelles +- Les entrevues + +Votre navigateur vous demandera l'autorisation d'acceder au microphone si elle n'a pas deja ete accordee. Vous pouvez selectionner quel microphone utiliser si plusieurs appareils sont connectes. + +### Enregistrement de l'audio systeme + +Le bouton bleu capture tout le son joue par votre ordinateur. Ce mode est ideal pour : + +- Les reunions en ligne lorsque vous ecoutez principalement +- Les webinaires et presentations en ligne +- Les balados ou tout contenu audio diffuse sur votre ordinateur + +Lorsque vous cliquez sur ce bouton, une fenetre de partage d'ecran apparait. Il est essentiel de cocher la case **« Partager l'audio du systeme »** pour que l'enregistrement fonctionne. + +### Enregistrement combine (microphone + systeme) + +Le bouton violet enregistre simultanement votre microphone et l'audio systeme dans une seule piste synchronisee. C'est le mode recommande pour les reunions en ligne ou vous participez activement, car il capture les deux cotes de la conversation. + +## Activer l'enregistrement de l'audio systeme + +### Enregistrer depuis un onglet du navigateur + +Choisissez l'option **onglet** si l'audio provient d'un onglet specifique (YouTube, Google Meet, Zoom dans le navigateur, etc.). + +Selectionnez l'onglet contenant votre source audio, puis **cochez la case « Partager l'audio de l'onglet »** en bas de la fenetre de dialogue. Sans cette case cochee, vous n'enregistrerez que du silence. + +### Enregistrer depuis l'ecran complet + +Choisissez l'option **ecran** si l'audio provient d'applications de bureau ou de plusieurs sources : + +- Applications comme Zoom, Microsoft Teams, Skype ou Discord +- Audio de plusieurs onglets simultanement +- Sons systeme + +Selectionnez votre ecran (peu importe lequel si vous avez plusieurs moniteurs, DictIA n'enregistre que l'audio). **Cochez la case « Partager l'audio du systeme »** en bas de la fenetre. + +!!! warning "Limitation macOS" + L'option « Partager l'audio du systeme » est disponible sur Windows et Linux, mais peut ne pas etre disponible sur macOS en raison de restrictions du systeme d'exploitation. Les utilisateurs macOS peuvent generalement enregistrer l'audio des onglets de navigateur uniquement. + +## Pendant l'enregistrement + +### Surveillance audio en temps reel + +L'interface affiche des visualiseurs audio en direct pour chaque source active, confirmant que l'audio est bien capture. Un minuteur affiche le temps ecoule et une estimation de la taille du fichier. + +### Prise de notes en direct avec Markdown + +Vous pouvez prendre des notes structurees pendant l'enregistrement. La zone de notes supporte le format Markdown complet avec une barre de formatage. Vos notes sont enregistrees automatiquement et seront associees a l'enregistrement. + +Utilisations courantes des notes en direct : + +- Capturer les actions a suivre et les echeances +- Noter les noms des participants et leurs roles +- Documenter les decisions et leur justification +- Ajouter du contexte non perceptible dans l'audio + +## Finaliser votre enregistrement + +Apres avoir arrete un enregistrement ou selectionne un fichier televerse, l'ecran de finalisation vous permet d'ajouter des metadonnees et de configurer les options de traitement. + +### Ajouter des etiquettes + +Les etiquettes sont des pastilles colorees que vous pouvez selectionner pour categoriser votre enregistrement. Vous pouvez appliquer plusieurs etiquettes a un seul enregistrement. + +!!! tip "Selectionnez vos etiquettes avant le televersement" + Les etiquettes peuvent avoir des consignes IA personnalisees associees. En les selectionnant avant le traitement, les bonnes consignes de resume seront appliquees automatiquement. + +**Empilage intelligent des consignes** : lorsque vous selectionnez plusieurs etiquettes, leurs consignes sont concatenees dans l'ordre de selection. Vous pouvez ainsi combiner une etiquette generale (ex. : « Reunions ») avec une etiquette specifique (ex. : un client ou un projet). + +### Options avancees de transcription + +Si votre administrateur a configure un service de transcription avec identification des locuteurs, une section **Options avancees** est disponible. Vous pouvez y specifier : + +- **La langue de transcription** si votre contenu n'est pas en francais. +- **Le nombre minimum et maximum de locuteurs** pour une meilleure precision de l'identification. + +#### Vocabulaire personnalise + +Le champ **Vocabulaire personnalise** vous permet de fournir une liste de mots ou expressions (separees par des virgules) que le moteur de transcription doit prioriser. Utile pour les termes techniques, noms propres, acronymes ou noms de marques. + +Exemple : `DictIA, InnovA AI, WhisperX` + +#### Consigne de transcription + +Le champ **Consigne de transcription** fournit du contexte au moteur de transcription. Par exemple : « Ceci est une reunion sur les outils de transcription audio par IA. » + +!!! info "Hierarchie de priorite" + Les valeurs saisies dans le formulaire de televersement ont priorite sur les parametres par defaut des etiquettes, qui ont priorite sur ceux des dossiers, qui ont priorite sur vos parametres personnels dans les [parametres du compte](parametres.md). + +### Actions finales + +- **Televerser / Lancer le traitement** : demarre la transcription immediatement avec vos parametres. +- **Supprimer** : supprime l'enregistrement sans le sauvegarder. + +## Recuperation apres un incident + +DictIA sauvegarde automatiquement vos enregistrements en cours toutes les 5 secondes dans le stockage de votre navigateur. Si votre navigateur plante, si l'onglet se ferme accidentellement ou si votre ordinateur redemarre, vous ne perdrez pas votre travail. + +**Ce qui est recupere :** + +- Tout l'audio enregistre jusqu'au moment de l'interruption +- Le mode d'enregistrement (microphone, audio systeme ou combine) +- Les notes saisies +- Les etiquettes et parametres selectionnes + +**Comment ca fonctionne :** + +1. Lorsque vous revenez sur DictIA apres un incident, une fenetre de recuperation apparait automatiquement. +2. Examinez les details de l'enregistrement (duree, taille, horodatage). +3. Cliquez sur **Restaurer l'enregistrement** pour continuer, ou **Supprimer** pour recommencer. + +!!! warning "Limitations" + - Les donnees de recuperation sont stockees dans le navigateur (par navigateur, par appareil). + - Effacer les donnees du navigateur supprime les informations de recuperation. + - Jusqu'a 5 secondes d'audio peuvent etre perdues. + +## Bonnes pratiques + +### Enregistrement sur appareils mobiles + +!!! danger "Gardez l'application visible sur les appareils mobiles" + Les navigateurs mobiles (Chrome, Safari, etc.) **mettent en pause l'enregistrement audio** lorsque vous reduisez l'application ou verrouillez votre ecran. Il s'agit d'une limitation fondamentale des navigateurs. + + **Pour enregistrer avec succes sur mobile :** + + - Gardez DictIA **visible** au premier plan en tout temps. + - Ne **reduisez pas** la fenetre et ne changez pas d'application. + - Ne **verrouillez pas** votre ecran pendant l'enregistrement. + + **Pour les longues reunions sur mobile :** + + 1. Gardez DictIA ouvert et visible (le verrouillage d'ecran est empeche automatiquement). + 2. Ou utilisez l'enregistreur vocal natif de votre telephone, puis televersez le fichier dans DictIA. + + **Les navigateurs de bureau n'ont pas cette limitation** : l'enregistrement continue meme lorsque la fenetre est reduite. + +Pour une meilleure experience mobile, installez DictIA comme application mobile. Consultez le [guide de l'application mobile](application-mobile.md). + +### Optimiser la qualite audio + +- Trouvez un endroit calme avec un minimum d'echo et de bruit de fond. +- Positionnez votre microphone a 15-30 cm de votre bouche. +- Pour l'enregistrement d'audio systeme, fermez les applications inutiles qui pourraient produire des sons de notification. +- Lors de l'enregistrement combine (microphone + systeme), **utilisez des ecouteurs** pour eviter l'echo. + +!!! info "Enregistrements avec telephone" + Les telephones intelligents ont des algorithmes de reduction de bruit qui peuvent filtrer les voix eloignees lors d'enregistrements multi-locuteurs. Pour de meilleurs resultats, utilisez un microphone externe ou placez le telephone a distance egale de tous les locuteurs. + +### Organisation des le depart + +- Incluez des informations cles dans les titres (type de reunion, sujet, projet). +- Appliquez les etiquettes immediatement pendant que le contexte est frais. +- Utilisez la prise de notes en direct pour capturer les informations non audibles (noms des participants, decisions visuelles partagees, etc.). + +--- + +Suivant : [Travailler avec les transcriptions](transcriptions.md) diff --git a/client_docs/guide-utilisateur/groupes.md b/client_docs/guide-utilisateur/groupes.md new file mode 100644 index 0000000..ebfcb6d --- /dev/null +++ b/client_docs/guide-utilisateur/groupes.md @@ -0,0 +1,139 @@ +# Collaboration en groupe + +Les groupes transforment la facon dont les equipes collaborent dans DictIA en permettant un partage automatique sans effort manuel. Lorsque votre organisation utilise des groupes, les enregistrements atteignent automatiquement les bonnes personnes au bon moment. + +## Comprendre les groupes + +Les groupes dans DictIA sont des ensembles d'utilisateurs qui ont regulierement besoin d'acceder aux memes enregistrements. Contrairement au partage individuel ou vous accordez l'acces utilisateur par utilisateur, les groupes utilisent des etiquettes intelligentes qui partagent automatiquement le contenu avec tous les membres lorsqu'elles sont appliquees. + +Votre administrateur cree et gere les groupes. Une fois ajoute a un groupe, vous avez acces aux etiquettes de groupe et recevez automatiquement les enregistrements lorsque des collegues appliquent ces etiquettes. + +Les groupes fonctionnent aux cotes de vos outils d'organisation personnels : vos etiquettes personnelles restent privees et independantes, tandis que les etiquettes de groupe creent un contexte partage. + +## Etiquettes de groupe + +Les etiquettes de groupe sont le moteur de la collaboration. Elles ressemblent aux etiquettes regulieres, mais lorsque quelqu'un en applique une a un enregistrement, tous les membres du groupe recoivent automatiquement l'acces. + +### Reconnaitre les etiquettes de groupe + +Les etiquettes de groupe s'affichent avec une icone distinctive d'utilisateurs dans toute l'interface DictIA. Dans votre interface de gestion des etiquettes, les etiquettes de groupe affichent le nom du groupe et ne peuvent pas etre supprimees par les utilisateurs reguliers. + +### Appliquer les etiquettes de groupe + +Appliquer une etiquette de groupe fonctionne exactement comme une etiquette personnelle : ouvrez un enregistrement, cliquez sur le bouton d'etiquettes et selectionnez parmi vos etiquettes disponibles. La difference se fait en coulisse : DictIA cree automatiquement des partages internes pour chaque membre du groupe. + +Le partage automatique accorde aux membres du groupe l'acces en consultation et en edition. Ils peuvent lire les transcriptions et les resumes, ecouter l'audio, modifier les notes et les metadonnees, et ajouter leurs propres etiquettes. Cependant, seul le proprietaire original peut supprimer l'enregistrement. + +!!! info "Partage intelligent" + - Si un membre a deja acces par un partage precedent, aucun doublon n'est cree. + - Les personnes ajoutees au groupe ulterieurement ne recoivent pas automatiquement acces aux anciens enregistrements, uniquement aux nouveaux. + +### Utiliser les etiquettes de groupe efficacement + +- **Appliquez les etiquettes immediatement** apres les reunions ou discussions importantes pour assurer la visibilite de l'equipe. +- **Combinez etiquettes de groupe et personnelles** : utilisez les etiquettes de groupe pour identifier l'audience et vos etiquettes personnelles pour le suivi de votre travail. +- **Utilisez plusieurs etiquettes de groupe** lorsque les enregistrements concernent plusieurs equipes, mais assurez-vous que chaque groupe etiquete a reellement besoin d'y acceder. + +### Fonctionnalites avancees des etiquettes de groupe + +Votre administrateur peut configurer des fonctionnalites speciales sur les etiquettes de groupe : + +- **Politiques de retention** : une etiquette « Documents juridiques » pourrait conserver les enregistrements sept ans, tandis qu'une etiquette « Melees quotidiennes » pourrait les supprimer automatiquement apres deux semaines. +- **Protection** : les etiquettes protegees empechent toute suppression automatique, quel que soit l'age de l'enregistrement. +- **Consignes IA personnalisees** : chaque etiquette peut avoir sa propre consigne de resume. Les revues techniques recoivent des resumes techniques detailles, tandis que les comptes rendus executifs obtiennent des vues d'ensemble de haut niveau. + +## Travailler avec les enregistrements de groupe + +Les enregistrements partages via les etiquettes de groupe apparaissent dans votre interface principale aux cotes de vos propres enregistrements. + +### Identifier le contenu de groupe + +Les cartes d'enregistrement affichent plusieurs indicateurs : + +- **Badge « Groupe »** (bleu) : indique que l'enregistrement a des etiquettes de groupe. +- **Badge « Partage »** (violet) : apparait si vous n'etes pas le proprietaire. +- **Icone d'utilisateurs** sur les etiquettes : distingue les etiquettes de groupe des etiquettes personnelles. + +Le filtre **Partages avec moi** dans la barre laterale isole les enregistrements partages, y compris ceux partages via les etiquettes de groupe. + +### Vos permissions sur les enregistrements de groupe + +En tant que membre du groupe, vous recevez la permission d'edition sur les enregistrements avec des etiquettes de groupe. Vous pouvez : + +- Modifier le titre, la liste des participants, la date de reunion +- Editer les notes et ajouter du contexte +- Ajouter des etiquettes pour une meilleure organisation + +Limitations importantes : + +- Vous **ne pouvez pas supprimer** les enregistrements de groupe, meme avec la permission d'edition. +- Vous **ne pouvez pas partager** les enregistrements de groupe avec des utilisateurs en dehors du groupe sans permission de repartage. + +### Notes personnelles sur les enregistrements de groupe + +Vous pouvez ajouter des notes personnelles qui restent completement privees. Ces notes ne sont jamais visibles par le proprietaire de l'enregistrement ni par les autres membres du groupe. + +Les notes personnelles persistent tant que vous avez acces a l'enregistrement. Si vous quittez le groupe, vos notes sont automatiquement supprimees. + +## Adhesion au groupe + +### Rejoindre un groupe + +L'adhesion est geree par les administrateurs. Lorsque vous etes ajoute a un groupe : + +- Vous avez immediatement acces aux etiquettes du groupe. +- Vous commencez a recevoir les enregistrements lorsque des collegues utilisent les etiquettes de groupe a l'avenir. +- Vous n'avez **pas** acces retroactif aux anciens enregistrements etiquetes. + +### Roles dans le groupe + +- **Membres reguliers** : peuvent utiliser les etiquettes de groupe et acceder aux enregistrements etiquetes par d'autres. +- **Administrateurs de groupe** : peuvent ajouter ou retirer des membres, creer et modifier des etiquettes de groupe, et gerer la structure organisationnelle du groupe. + +### Quitter un groupe + +Lorsque vous quittez un groupe : + +- Les etiquettes de groupe disparaissent de vos selecteurs. +- Votre acces aux enregistrements precedemment partages est conserve, mais vous ne recevrez plus de nouveaux enregistrements. +- Vos notes personnelles sur les enregistrements de groupe sont conservees si vous gardez l'acces par d'autres mecanismes de partage. + +## Administration de groupe (pour les administrateurs de groupe) + +Si vous etes administrateur de groupe, vous avez acces a une interface de gestion via le lien **Gestion de groupe** dans votre menu de compte. + +### Gerer les membres + +- Recherchez des utilisateurs par nom, selectionnez leur role initial et ajoutez-les. +- Changez les roles entre membre et administrateur. +- Le retrait d'un membre coupe l'acces aux futurs enregistrements de groupe. + +### Creer et gerer les etiquettes de groupe + +Les etiquettes de groupe sont le vocabulaire organisationnel de votre equipe. Creez des etiquettes qui correspondent a la facon dont votre equipe pense le contenu : noms de projets, types de contenu, categories de reunions. + +Lors de la creation, vous pouvez definir des periodes de retention personnalisees et des drapeaux de protection selon les besoins du groupe. + +### Synchroniser les partages de groupe + +Si votre groupe a commence a utiliser des etiquettes avant que le partage automatique soit pleinement en place, la fonctionnalite **Synchroniser les partages de groupe** applique retroactivement le partage aux enregistrements deja etiquetes. + +## Integration avec la Recherche IA + +Les etiquettes de groupe apparaissent dans les options de filtrage de la Recherche IA, ce qui vous permet de cibler vos recherches dans le contexte d'un groupe specifique. + +Les enregistrements partages via les etiquettes de groupe sont automatiquement inclus dans vos recherches en Recherche IA, creant une base de connaissances complete qui couvre a la fois vos enregistrements personnels et le contenu de groupe. + +Vos notes personnelles sur les enregistrements de groupe restent privees meme dans la Recherche IA. + +## Bonnes pratiques + +- **Utilisez les etiquettes de groupe des le debut** d'un projet pour batir un historique complet. +- **Etablissez des conventions d'equipe** sur quand utiliser les etiquettes de groupe versus le partage individuel. +- **Choisissez des noms descriptifs** comme « Planification sprint » ou « Retours clients » plutot que des noms generiques comme « Important ». +- **Revisez periodiquement** les etiquettes de groupe avec votre administrateur pour vous assurer qu'elles restent pertinentes. +- **Utilisez vos notes personnelles** liberalement pour votre suivi individuel : elles ne sont visibles que par vous. + +--- + +Suivant : [Parametres du compte](parametres.md) diff --git a/client_docs/guide-utilisateur/index.md b/client_docs/guide-utilisateur/index.md new file mode 100644 index 0000000..0ba8309 --- /dev/null +++ b/client_docs/guide-utilisateur/index.md @@ -0,0 +1,49 @@ +# Guide utilisateur + +Bienvenue dans le guide utilisateur de DictIA. Cette section vous accompagne pas a pas dans l'utilisation quotidienne de la plateforme, de la creation de vos premiers enregistrements jusqu'aux fonctionnalites avancees de collaboration et de recherche par intelligence artificielle. + +DictIA transforme vos enregistrements audio en transcriptions intelligentes, enrichies de resumes automatiques, d'identification des locuteurs et de fonctionnalites de recherche semantique. + +--- + +## Sections du guide + +
+ +- :material-rocket-launch: **[Premiers pas](premiers-pas.md)** + + Decouvrez l'interface de DictIA, la navigation et la disposition des panneaux principaux. + +- :material-microphone: **[Enregistrement et televersement](enregistrement.md)** + + Enregistrez de l'audio directement dans votre navigateur ou televersez des fichiers existants. + +- :material-text-box-outline: **[Transcriptions](transcriptions.md)** + + Consultez, modifiez et exportez vos transcriptions. Utilisez le resume IA, les notes et la conversation IA. + +- :material-magnify: **[Recherche IA](recherche-ia.md)** + + Interrogez l'ensemble de vos enregistrements en langage naturel grace a la recherche semantique. + +- :material-share-variant: **[Partage](partage.md)** + + Partagez vos enregistrements avec des collegues ou creez des liens publics securises. + +- :material-folder-outline: **[Dossiers](dossiers.md)** + + Organisez vos enregistrements dans des dossiers pour une gestion par projet, client ou type de contenu. + +- :material-account-group: **[Groupes](groupes.md)** + + Collaborez en equipe grace aux groupes et aux etiquettes de groupe avec partage automatique. + +- :material-cog: **[Parametres du compte](parametres.md)** + + Personnalisez votre experience : langue, consignes IA, gestion des locuteurs et des etiquettes. + +- :material-cellphone: **[Application mobile](application-mobile.md)** + + Installez DictIA sur votre appareil mobile pour un acces rapide et l'enregistrement en deplacement. + +
diff --git a/client_docs/guide-utilisateur/parametres.md b/client_docs/guide-utilisateur/parametres.md new file mode 100644 index 0000000..72dbf9b --- /dev/null +++ b/client_docs/guide-utilisateur/parametres.md @@ -0,0 +1,205 @@ +# Parametres du compte + +Les parametres de votre compte sont le centre de controle pour personnaliser DictIA selon vos besoins. Chaque preference que vous definissez ici facon votre experience quotidienne. Accedez a ces parametres en cliquant sur votre nom d'utilisateur dans la barre de navigation, puis en selectionnant **Compte**. + +## Onglet Informations du compte + +### Informations personnelles + +Mettez a jour votre nom complet, votre titre de poste et votre organisation. Ces informations aident a vous identifier dans les environnements collaboratifs. + +### Preferences linguistiques + +Trois parametres de langue distincts faconnent votre experience : + +- **Langue de l'interface** : transforme tous les menus, boutons et messages dans la langue choisie (francais, anglais, espagnol, chinois ou allemand). +- **Langue de transcription** : accepte un code de langue ISO (ex. : « fr » ou « en ») pour optimiser la precision de la reconnaissance. Laissez vide pour activer la detection automatique du contenu multilingue. +- **Langue du resume et de la conversation IA** : assure que tout le contenu genere par l'IA apparait dans votre langue preferee, peu importe la langue de l'audio source. + +### Statistiques du compte + +Le panneau de droite affiche vos metriques d'enregistrement : + +- Nombre total d'enregistrements et nombre de traitements termines +- Enregistrements en cours de traitement et ceux en erreur + +### Preferences de traitement + +#### Resume automatique + +L'option **Resume automatique** controle si les resumes sont generes automatiquement apres la transcription. Active par defaut. Desactivez-la si vous preferez declencher manuellement la generation de resumes. + +#### Identification automatique des locuteurs + +Lorsque vous avez des profils vocaux avec des empreintes (provenant du service de transcription avancee), l'**Identification automatique des locuteurs** peut reconnaitre et etiqueter les locuteurs dans les nouveaux enregistrements. + +- **Activer/Desactiver** : activez l'identification automatique. +- **Seuil de confiance** : + - **Bas** : plus de correspondances, mais possibilite de faux positifs. + - **Moyen** : precision equilibree (recommande). + - **Eleve** : moins de correspondances, mais confiance plus elevee. + +!!! tip "Construire des profils vocaux" + L'identification automatique necessite des profils vocaux avec empreintes. Ceux-ci sont crees lorsque vous identifiez des locuteurs dans des enregistrements traites avec le service de transcription avancee. Plus vous identifiez un locuteur dans differents enregistrements, meilleure sera la reconnaissance. + +### Actions du compte + +- **Aller aux enregistrements** : acces direct a votre bibliotheque. +- **Changer le mot de passe** : ouvre un dialogue securise pour mettre a jour vos identifiants. +- **Gerer les locuteurs** : raccourci vers la gestion des profils de locuteurs. + +## Onglet Consignes personnalisees + +### Votre consigne de resume personnalisee + +Le grand champ de texte accepte des instructions detaillees en langage naturel qui deviennent les directives de l'IA pour generer vos resumes. Pensez-y comme a l'enseignement a un assistant de la facon exacte dont vous souhaitez que vos notes de reunion soient preparees. + +Par exemple, vous pourriez demander des sections specifiques comme « Decisions techniques cles » pour les reunions d'ingenierie ou « Observations sur les patients » pour des consultations medicales. + +### Consigne par defaut en cours + +Sous votre consigne personnalisee, vous pouvez voir la consigne par defaut qui s'applique lorsque vous laissez la votre vide. Cette transparence montre exactement quelles instructions de base l'IA suit. + +### Hierarchie des consignes + +Comprendre l'ordre de priorite vous aide a utiliser cette fonctionnalite efficacement : + +1. **Consignes des etiquettes** (priorite la plus elevee) +2. **Votre consigne personnalisee** +3. **Consigne par defaut de l'administrateur** +4. **Consigne systeme** (dernier recours) + +### Empilage des consignes + +Les consignes de plusieurs etiquettes se combinent intelligemment lorsqu'elles sont appliquees ensemble. Un enregistrement etiquete a la fois « Client » et « Technique » recoit les deux jeux d'instructions. + +### Indications de transcription + +Sous la consigne de resume, deux champs ameliorent la precision de la transcription : + +- **Vocabulaire personnalise par defaut** : liste de mots ou expressions separees par des virgules que le moteur de transcription doit prioriser (ex. : noms de marques, acronymes, terminologie technique). +- **Consigne de transcription par defaut** : breve description du contenu typique de vos enregistrements (ex. : « Ceci est une reunion sur le developpement logiciel et les outils d'IA. »). + +Ces valeurs par defaut s'appliquent a tous vos enregistrements, sauf si elles sont remplacees par les parametres d'etiquettes, de dossiers ou les valeurs saisies dans le formulaire de televersement. + +!!! tip "Vocabulaire personnalise vs Consigne de transcription" + Utilisez le **vocabulaire personnalise** pour les termes specifiques que le modele a tendance a mal orthographier. Utilisez la **consigne de transcription** pour un contexte plus large qui aide le modele a mieux comprendre le domaine. + +### Rediger des consignes efficaces + +Redigez vos consignes en fonction de la facon dont vous utilisez reellement les resumes. Cherchez-vous a extraire des actions a suivre ? A identifier des decisions strategiques ? A documenter des details techniques ? Votre consigne devrait demander exactement ce dont vous avez besoin. + +## Onglet Transcriptions partagees + +Cet onglet offre une visibilite et un controle complets sur tous les enregistrements que vous avez partages via des liens publics. + +### Informations de partage + +Chaque entree affiche le titre de l'enregistrement, la date de creation du lien, les options selectionnees (resume et/ou notes) et l'URL complete. + +### Gerer les options de partage + +Modifiez a tout moment ce qui est inclus dans un partage en basculant les cases du resume et des notes. Les changements prennent effet immediatement pour toute personne accedant au lien, sans generer de nouveau lien. + +### Revoquer l'acces + +Le bouton de suppression (icone corbeille) revoque instantanement l'acces. Une fois supprime, toute personne tentant d'acceder au lien verra un message d'erreur. + +## Onglet Gestion des locuteurs + +Cet onglet fournit une interface complete pour gerer tous les locuteurs identifies a travers vos enregistrements. + +### Creation automatique des locuteurs + +Les locuteurs sont automatiquement sauvegardes lorsque vous les identifiez lors de l'edition de transcriptions. Le systeme construit votre bibliotheque au fil du temps. + +### Informations sur les fiches de locuteur + +Chaque fiche affiche : + +- Le nom du locuteur +- Les statistiques d'utilisation (nombre d'identifications) +- La date de derniere utilisation +- La date d'ajout + +#### Profil vocal + +Pour les locuteurs avec des donnees de reconnaissance vocale : + +- **Badge de profil vocal** : indique le niveau de confiance (eleve, moyen, bas). +- **Nombre d'echantillons** : nombre d'empreintes vocales collectees. +- **Bouton d'echantillons vocaux** : ecoutez des extraits representatifs pour verification. +- **Indicateur de force du profil** : retour visuel sur la capacite du systeme a reconnaitre ce locuteur. + +### Maintenance + +- Supprimez des locuteurs individuellement avec l'icone corbeille. +- Utilisez **Tout supprimer** pour reinitialiser votre base. +- Revisez regulierement pour retirer les locuteurs obsoletes et consolider les doublons. + +## Onglet Gestion des etiquettes + +La gestion des etiquettes transforme de simples libelles en instructions de traitement puissantes. Chaque etiquette porte plusieurs capacites : une couleur, une consigne IA optionnelle, des parametres de transcription, des politiques de retention et des configurations de partage. + +### Affichage et indicateurs visuels + +Chaque carte d'etiquette affiche des badges visuels indiquant sa configuration : + +- **Badges de retention** : periode de retention (ex. : « 90 jours ») ou statut de protection (symbole infini) +- **Badges de partage** : indique s'il s'agit d'une etiquette de groupe avec partage automatique +- **Badges de langue** : parametres de langue personnalises +- **Badges de locuteurs** : parametres par defaut (ex. : « Max 10 » locuteurs) +- **Consignes personnalisees** : apercu des instructions de resume IA + +### Creer une etiquette + +Cliquez sur **Creer une etiquette**. Le systeme vous guide pour : + +1. Choisir un nom +2. Selectionner une couleur +3. Optionnellement ajouter une consigne personnalisee +4. Definir les parametres par defaut de transcription (nombre de locuteurs attendu, vocabulaire personnalise, consigne de transcription) + +### Empilage des consignes + +Les consignes s'empilent intelligemment lorsque plusieurs etiquettes sont appliquees. Par exemple, etiqueter un enregistrement avec « Reunions BSB » et « Reunions etudiantes » combine les deux consignes pour un resume complet. + +### Vocabulaire personnalise et consigne de transcription par etiquette + +Les etiquettes peuvent avoir un **vocabulaire personnalise** et une **consigne de transcription** par defaut. Par exemple, une etiquette « Rondes medicales » pourrait inclure `epinephrine, tachycardie, intubation` comme vocabulaire et « Discussion d'equipe medicale en contexte de soins intensifs » comme consigne. + +### Gerer les etiquettes + +Les boutons de modification et de suppression sur chaque carte offrent un controle complet. Supprimer une etiquette ne modifie pas les enregistrements deja etiquetes : ils conservent leurs etiquettes pour reference historique. + +## Onglet A propos + +L'onglet A propos presente une vue d'ensemble de votre installation DictIA : version, configuration systeme, fonctionnalites principales et liens vers les ressources. + +### Informations de version + +Le badge de version vous indique quelle version de DictIA vous utilisez, information essentielle pour le depannage. + +### Configuration systeme + +Cette section detaille les capacites de votre instance : le modele d'IA utilise pour les resumes et la conversation IA, ainsi que la configuration de votre service de transcription. + +### Liens utiles + +Des boutons d'acces rapide vous connectent aux ressources essentielles : documentation et support. + +## Securite et confidentialite + +- **Deconnectez-vous** lorsque vous utilisez un ordinateur partage. +- **Revisez regulierement** vos enregistrements partages et revoquez l'acces lorsque ce n'est plus necessaire. +- **Tenez compte des implications** des parametres de langue dans les contextes internationaux. + +## Optimiser vos parametres + +Commencez par une configuration de base : nom, preferences de langue et une consigne simple. A mesure que vous vous familiarisez avec DictIA, ajoutez des etiquettes, affinez vos consignes et construisez votre bibliotheque de locuteurs. + +Si vous avez des questions ou rencontrez des problemes, contactez le support InnovA AI. + +--- + +Retour au [Guide utilisateur](index.md) diff --git a/client_docs/guide-utilisateur/partage.md b/client_docs/guide-utilisateur/partage.md new file mode 100644 index 0000000..44c7b5b --- /dev/null +++ b/client_docs/guide-utilisateur/partage.md @@ -0,0 +1,105 @@ +# Partage et collaboration + +DictIA offre des options de collaboration flexibles pour s'adapter a votre flux de travail. Partagez des enregistrements en interne avec des collegues, organisez l'acces via des groupes, ou creez des liens publics pour des parties prenantes externes. + +## Partage interne (utilisateur a utilisateur) + +Le partage interne permet la collaboration directe entre les utilisateurs de votre instance DictIA. Le destinataire doit se connecter pour acceder a l'enregistrement et ses permissions sont suivies et applicables. + +### Partager un enregistrement + +Lorsque vous consultez un enregistrement, cliquez sur l'icone de partage (icone d'utilisateurs) dans la barre d'outils. Dans la fenetre de partage : + +1. Recherchez le nom de l'utilisateur (saisissez au moins deux caracteres). +2. Choisissez le niveau de permission : + + - **Consultation seule** : le destinataire peut lire la transcription, consulter le resume, ecouter l'audio et utiliser la conversation IA. Il ne peut rien modifier. + - **Edition** : en plus de la consultation, le destinataire peut modifier le titre, les participants, la date, le resume partage et les etiquettes. Il ne peut pas supprimer l'enregistrement ni le partager avec d'autres. + - **Repartage** : toutes les capacites d'edition, plus la possibilite de partager l'enregistrement avec d'autres utilisateurs et de gerer ces partages. Seul le proprietaire original peut supprimer l'enregistrement. + +3. Cliquez sur le nom de l'utilisateur pour l'ajouter. Le partage prend effet immediatement. + +### Gerer vos partages + +La fenetre de partage affiche toutes les personnes ayant acces a un enregistrement, avec leurs permissions et la date d'octroi. L'icone rouge de suppression permet de revoquer instantanement l'acces. + +La revocation est immediate et complete : le destinataire perd l'acces et ses notes personnelles sur l'enregistrement sont supprimees. + +### Notes personnelles sur les enregistrements partages + +Lorsqu'un enregistrement est partage avec vous, vous pouvez ajouter vos propres notes privees, invisibles pour le proprietaire et les autres destinataires. + +!!! info "Notes vs Resume" + - **Notes** : toujours personnelles et privees. Chaque utilisateur peut creer ses propres notes sans permission speciale. + - **Resume** : partage globalement. Visible par tous les utilisateurs ayant acces. Seuls ceux avec la permission d'edition peuvent le modifier. + +### Boite de reception et favoris independants + +Chaque utilisateur maintient son propre etat de boite de reception et de favoris pour les enregistrements partages. Marquer un enregistrement comme lu ou en favori n'affecte pas les autres utilisateurs. + +### Voir les enregistrements partages avec vous + +Les enregistrements partages apparaissent dans votre liste principale avec des indicateurs visuels distinctifs (titre colore et bordure subtile). Utilisez le filtre **Partages avec moi** dans la barre laterale pour voir uniquement ces enregistrements. + +## Liens de partage publics + +Vous pouvez creer des liens securises pour partager un enregistrement avec des personnes qui n'ont pas de compte DictIA. + +### Creer un lien de partage + +En consultant un enregistrement, cliquez sur le bouton de partage dans la barre d'outils. Deux options vous sont proposees : + +- **Partager le resume** : permet aux destinataires de voir le resume genere par l'IA. +- **Partager les notes** : permet aux destinataires de voir vos notes personnelles. + +La transcription est toujours incluse comme contenu principal. + +Cliquez sur **Creer un lien de partage** pour generer l'URL securisee. Le lien est permanent jusqu'a sa revocation explicite. + +### Ce que les destinataires voient + +Les destinataires accedent a une vue epuree et professionnelle de votre enregistrement : + +- Le titre de l'enregistrement +- Le lecteur audio avec controles de lecture complets +- La transcription avec les etiquettes de locuteurs et les options d'affichage (vue simple et a bulles) +- Le resume et/ou les notes (selon vos choix) +- Un bouton de copie pour extraire facilement le texte + +L'experience est en lecture seule : les destinataires ne peuvent rien modifier et n'ont acces a aucune autre partie de votre compte. + +### Gerer vos liens de partage + +Tous vos liens de partage sont centralises dans l'onglet **Transcriptions partagees** de vos [parametres du compte](parametres.md). Vous pouvez : + +- Modifier les options de partage (resume et/ou notes) sans generer de nouveaux liens. +- Copier a nouveau l'URL si necessaire. +- Supprimer un partage pour revoquer instantanement l'acces. + +## Scenarios pratiques + +| Scenario | Approche | Pourquoi | +|----------|----------|----------| +| Enregistrements familiaux | Creer un groupe « Famille », etiqueter les evenements | Acces automatique pour tous les membres | +| Club de lecture | Creer un groupe avec etiquette protegee | Enregistrements preserves, partage automatique | +| Projet temporaire a 3 personnes | Partage individuel avec edition | Collaboration temporaire, facile a revoquer | +| Reunions de departement | Creer un groupe, etiqueter les reunions | Nouveaux membres recoivent les futures reunions | +| Presentations clients | Partage individuel en consultation seule | Acces controle, pas de modification accidentelle | + +## Combiner les approches + +Utilisez les groupes pour la collaboration reguliere et le partage individuel pour les exceptions. Votre equipe a un acces automatique aux discussions via les etiquettes de groupe. Quand une decision necessite un examen par la direction, partagez cet enregistrement specifique individuellement. + +## Bonnes pratiques + +- **Partagez rapidement** apres les reunions pendant que le contexte est frais. +- **Utilisez la consultation seule par defaut** et n'accordez l'edition que lorsque les destinataires doivent vraiment contribuer. +- **Revisez periodiquement** vos enregistrements partages pour vous assurer que l'acces reste approprie. +- **Traitez les liens de partage** avec le meme soin que des documents sensibles : partagez-les uniquement par des canaux securises. + +!!! warning "Rappel important" + Les liens de partage ne necessitent pas d'authentification. Toute personne disposant du lien peut acceder a l'enregistrement. Revoquer un lien empeche tout acces futur, mais ne peut pas recuperer l'information deja consultee ou telechargee. + +--- + +Suivant : [Dossiers](dossiers.md) diff --git a/client_docs/guide-utilisateur/premiers-pas.md b/client_docs/guide-utilisateur/premiers-pas.md new file mode 100644 index 0000000..96771a4 --- /dev/null +++ b/client_docs/guide-utilisateur/premiers-pas.md @@ -0,0 +1,123 @@ +# Premiers pas + +L'interface principale de DictIA est l'endroit ou vous passerez la majorite de votre temps. Elle est concue en trois panneaux qui facilitent la navigation entre vos enregistrements, la lecture des transcriptions et l'interaction avec votre contenu. + +## Comprendre la disposition de l'interface + +L'interface principale est organisee en trois sections qui fonctionnent ensemble. + +- **Panneau de gauche** : la barre laterale liste tous vos enregistrements audio avec des options de filtrage et de recherche. +- **Panneau central** : affiche la transcription complete de l'enregistrement selectionne, avec identification des locuteurs si disponible. +- **Panneau de droite** : contient le lecteur audio, le resume genere par l'IA, vos notes personnelles et la conversation IA. + +Au-dessus des panneaux central et droit, une barre de metadonnees affiche les informations de l'enregistrement et les boutons d'action. + +## Barre de navigation superieure + +La barre de navigation en haut de l'ecran offre un acces rapide aux fonctionnalites essentielles : + +- **Logo DictIA** : vous ramene a la vue principale depuis n'importe quelle page. +- **Bouton Recherche IA** : ouvre l'interface de recherche semantique pour interroger l'ensemble de vos enregistrements en langage naturel. +- **Bouton Nouvel enregistrement** : permet de televerser un fichier audio ou d'enregistrer directement dans votre navigateur. +- **Menu utilisateur** (a droite) : acces aux parametres du compte, aux preferences de langue et a la deconnexion. + +## Barre laterale gauche — Liste et filtres + +La barre laterale est votre centre de commande pour organiser et retrouver vos enregistrements. + +### Barre de recherche + +En haut de la barre laterale, un champ de recherche vous permet de retrouver rapidement un enregistrement par son titre ou son contenu. + +### Utiliser les filtres + +Cliquez sur **Filtres actifs** pour deployer le panneau de filtrage. Vous pouvez filtrer par : + +- **Etiquettes** : selectionnez ou deselectionnez les etiquettes affichees sous forme de pastilles colorees. +- **Plage de dates** : utilisez les raccourcis (Aujourd'hui, Hier, Cette semaine, Semaine derniere) ou definissez une plage personnalisee. +- **Partages** : activez le filtre **Partages avec moi** pour voir uniquement les enregistrements partages par d'autres utilisateurs. + +Lorsque des filtres sont actifs, leur nombre s'affiche a cote du libelle. Un bouton permet de reinitialiser tous les filtres d'un coup. + +### Cartes d'enregistrement + +Chaque enregistrement s'affiche sous forme de carte montrant : + +- Le titre +- Les participants (si l'identification des locuteurs a ete utilisee) +- La date et la duree +- Les etiquettes sous forme de pastilles colorees +- Des icones indiquant l'etat du traitement (termine, en cours ou en erreur) + +## Panneau central — Vue de la transcription + +Le panneau central affiche le texte complet de votre enregistrement. Si l'identification des locuteurs est activee, chaque intervention est etiquetee avec un indicateur de couleur (LOCUTEUR_01, LOCUTEUR_02, etc.). Vous pouvez cliquer sur n'importe quelle phrase pour positionner le lecteur audio a cet endroit precis. + +### Options d'affichage + +- **Bouton Copier** : copie la transcription complete dans le presse-papier. +- **Bouton Modifier** : active l'edition en ligne pour corriger la transcription. +- **Bascule Simple / Bulles** : alterne entre un affichage en texte continu et un format de conversation a bulles (disponible uniquement avec l'identification des locuteurs). + +## Barre de metadonnees de l'enregistrement + +Cette barre affiche le titre, les participants, la date, la taille du fichier et la duree. Les boutons d'action a droite permettent de : + +- **Marquer en favori** (icone etoile) +- **Modifier les details** (icone crayon) +- **Gerer les etiquettes** (icone etiquette) +- **Retraiter la transcription** (icone rafraichir) +- **Partager** (icone partage) +- **Supprimer** (icone corbeille) + +## Panneau de droite — Lecture, resume, notes et conversation IA + +### Controles de lecture + +Le lecteur audio en haut du panneau de droite vous permet de lire, mettre en pause et naviguer dans l'enregistrement. Vous pouvez ajuster la vitesse de lecture de 0,5x a 2x. Si l'identification des locuteurs est activee, cliquer sur une phrase dans la transcription positionne automatiquement le lecteur a ce moment precis. + +### Onglet Resume + +L'onglet **Resume** affiche une vue d'ensemble generee par l'IA : points cles, decisions prises et actions a suivre. Le resume est cree automatiquement apres la transcription et peut etre regenere avec differents parametres. + +### Onglet Notes + +L'onglet **Notes** est votre espace personnel pour ajouter du contexte, des reflexions ou des suivis lies a l'enregistrement. Ces notes sont privees et consultables uniquement par vous. Elles prennent en charge le format Markdown. + +### Onglet Conversation IA + +L'onglet **Conversation IA** met a votre disposition un assistant qui peut repondre a vos questions sur l'enregistrement. Posez des questions comme « Quelles sont les decisions prises ? » ou « Qu'a dit Marie a propos du budget ? » et l'IA analysera la transcription pour vous fournir des reponses pertinentes. + +## Options de retraitement + +DictIA offre deux types de retraitement pour mettre a jour vos enregistrements : + +### Retraitement complet + +Le retraitement complet retranscrit entierement votre fichier audio. Ceci est utile lorsque vous souhaitez modifier les parametres d'identification des locuteurs ou utiliser un modele de transcription different. **Attention** : le retraitement complet ecrase les modifications manuelles apportees a la transcription. + +### Retraitement du resume + +Le retraitement du resume genere un nouveau titre et un nouveau resume a partir de la transcription existante, sans modifier le texte transcrit. Cette option est plus rapide et preserve vos corrections manuelles. + +## Travailler avec plusieurs enregistrements + +Vous pouvez combiner plusieurs filtres pour retrouver exactement ce que vous cherchez. Par exemple, filtrer par l'etiquette « Reunion » et la semaine derniere, puis rechercher « budget » dans le contenu. + +Les options de tri permettent d'organiser vos enregistrements par date de creation (televersement) ou par date de reunion (date reelle de l'enregistrement). + +## Conseils pour une utilisation efficace + +- **Adoptez une strategie d'etiquetage coherente** des le depart. Creez des etiquettes par projet, client ou type de reunion. +- **Utilisez la recherche avec des termes precis** : noms de projets, sujets specifiques ou termes techniques. +- **Prenez des notes immediatement** apres vos reunions pendant que le contexte est frais. +- **Pour les longs enregistrements**, utilisez la conversation IA pour poser des questions specifiques plutot que de relire la transcription en entier. +- L'interface se met a jour en temps reel : pas besoin de recharger la page apres un televersement ou un traitement. + +## Fonctionnalites de collaboration + +Si vous travaillez en equipe, les fonctionnalites de collaboration de DictIA permettent de partager automatiquement les enregistrements avec les bonnes personnes. Consultez les sections [Partage](partage.md) et [Groupes](groupes.md) pour en savoir plus. + +--- + +Suivant : [Enregistrement et televersement](enregistrement.md) diff --git a/client_docs/guide-utilisateur/recherche-ia.md b/client_docs/guide-utilisateur/recherche-ia.md new file mode 100644 index 0000000..e6a4ff7 --- /dev/null +++ b/client_docs/guide-utilisateur/recherche-ia.md @@ -0,0 +1,80 @@ +# Recherche IA + +La Recherche IA transforme l'ensemble de votre bibliotheque d'enregistrements en une base de connaissances intelligente que vous pouvez interroger en langage naturel. Plutot que de chercher dans chaque enregistrement individuellement, posez simplement vos questions et recevez des reponses completes tirees de tous vos enregistrements pertinents. + +## Comprendre la Recherche IA + +La Recherche IA fonctionne comme un assistant qui a ecoute chacun de vos enregistrements et peut instantanement retrouver et synthetiser l'information de n'importe lequel d'entre eux. + +Cette capacite de recherche semantique va bien au-dela de la simple correspondance de mots cles. Le systeme comprend les concepts, les relations et le contexte. Si vous demandez « preoccupations budgetaires », il trouvera les discussions sur les contraintes financieres, les depassements de couts ou les problemes de financement, meme si l'expression exacte « preoccupations budgetaires » n'a jamais ete prononcee. + +## Acceder a la Recherche IA + +Cliquez sur le bouton **Recherche IA** dans la barre de navigation superieure. L'interface s'ouvre avec une zone de recherche en haut et des options de filtrage sur le cote gauche. + +La beaute de la Recherche IA reside dans sa simplicite : tapez votre question comme vous la poseriez a un collegue, et le systeme se charge de la complexite. + +## Utiliser les filtres pour cibler votre recherche + +La barre laterale gauche contient des filtres qui vous aident a circonscrire votre recherche avant de poser votre question : + +- **Filtre par etiquettes** : selectionnez les enregistrements avec des etiquettes specifiques. Ideal pour chercher au sein d'un projet ou d'un type de reunion particulier. +- **Filtre par locuteur** : concentrez-vous sur ce qu'une personne specifique a dit a travers tous les enregistrements. Tres utile pour suivre les engagements d'une personne ou comprendre sa perspective. +- **Filtre par plage de dates** : ciblez des discussions recentes ou une periode specifique. Utile pour suivre l'evolution des discussions au fil du temps. + +## Poser des questions efficaces + +La cle pour obtenir d'excellents resultats est de poser des questions claires et specifiques avec du contexte. + +Au lieu de chercher un mot isole comme « echeance », posez une question complete : **« Quelles sont les echeances a venir pour le projet de refonte du site web ? »** + +Le systeme excelle avec differents types de requetes : + +- **Syntheses thematiques** : « Qu'a-t-on discute au sujet de la migration vers le nuage au cours du dernier mois ? » +- **Listes d'actions** : « Quelles sont les actions a suivre assignees lors des reunions de cette semaine ? » +- **Recherches specifiques** : « Quels engagements ont ete pris envers le client Acme ? » +- **Analyses de tendances** : « Comment les priorites du projet ont-elles evolue au cours du dernier trimestre ? » +- **Identification de problemes** : « Quels risques ont ete identifies pour le lancement du produit ? » + +## Comprendre vos resultats + +Les resultats de la Recherche IA ne sont pas une simple liste de correspondances : il s'agit d'une reponse coherente qui combine l'information provenant de plusieurs enregistrements en une synthese unifiee. + +Chaque element d'information inclut une citation au format **(Enregistrement ID : XX)** qui renvoie directement a l'enregistrement source. Cliquez sur ces liens pour acceder au contexte complet et verifier l'information. + +Le systeme organise intelligemment l'information en regroupant les points connexes et en les presentant dans un flux logique. Si plusieurs enregistrements traitent du meme sujet, la reponse synthetise ces discussions en montrant l'evolution de la conversation au fil du temps. + +## Applications pratiques + +### Preparation de reunions + +Passez en revue rapidement ce qui a ete discute precedemment sur les sujets a l'ordre du jour, les questions restees sans reponse et les engagements pris. + +### Gestion de projets + +Suivez les decisions prises lors de reunions multiples, identifiez les actions a suivre assignees aux membres de l'equipe et surveillez l'evolution des exigences. + +### Recherche et analyse + +Analysez les tendances dans les commentaires clients, suivez les mentions de concurrents ou identifiez les themes recurrents dans vos entrevues. + +### Conformite et documentation + +Retrouvez rapidement les engagements pris envers des clients, localisez les discussions sur les exigences reglementaires ou verifiez ce qui a ete convenu lors de negociations. + +## Strategies de recherche avancees + +- **Commencez par des questions larges** pour comprendre le paysage de l'information disponible, puis affinez avec des questions de plus en plus specifiques. +- **Combinez les filtres strategiquement** : pour preparer une reunion client, filtrez par l'etiquette du client et la plage de dates pertinente, puis demandez les enjeux en suspens. +- **Utilisez la terminologie de vos discussions** : le systeme apprend des modeles dans vos enregistrements, donc utiliser un vocabulaire coherent donne de meilleurs resultats. + +## Limites a garder en tete + +- La Recherche IA interroge uniquement le texte transcrit, pas l'audio directement. Les erreurs de transcription peuvent affecter les resultats. +- Le systeme ne peut pas inferer l'information qui n'a pas ete explicitement dite dans les enregistrements. +- Les enregistrements doivent etre entierement traites avant d'etre interrogeables ; les enregistrements tres recents pourraient ne pas apparaitre immediatement. +- La prise en charge linguistique est optimisee pour le francais et l'anglais. Les autres langues peuvent fonctionner avec des resultats variables. + +--- + +Suivant : [Partage](partage.md) diff --git a/client_docs/guide-utilisateur/transcriptions.md b/client_docs/guide-utilisateur/transcriptions.md new file mode 100644 index 0000000..ff84930 --- /dev/null +++ b/client_docs/guide-utilisateur/transcriptions.md @@ -0,0 +1,184 @@ +# Travailler avec les transcriptions + +Une fois votre audio traite, DictIA met a votre disposition un ensemble d'outils pour consulter, modifier et interagir avec vos transcriptions. + +## Vue de la transcription + +Lorsque vous selectionnez un enregistrement dans la barre laterale, le panneau central affiche la transcription complete avec des indicateurs visuels pour les differents locuteurs. + +## Identification des locuteurs + +Si votre enregistrement a ete traite avec l'identification des locuteurs activee, chaque intervention est etiquetee avec des indicateurs colores (LOCUTEUR_01, LOCUTEUR_02, etc.). + +### Identifier les locuteurs + +Pour attribuer de vrais noms aux etiquettes generiques, cliquez sur le bouton d'identification des locuteurs dans la barre d'outils. Une fenetre s'ouvre ou vous pouvez : + +- Voir un echantillon du dialogue de chaque locuteur detecte pour vous aider a les identifier. +- Beneficier de suggestions intelligentes basees sur les profils vocaux de vos enregistrements precedents, avec des scores de confiance. +- Saisir les vrais noms pour chaque locuteur : ils remplaceront les etiquettes generiques dans toute la transcription. + +Le systeme memorise ces attributions et construit des profils vocaux, rendant l'identification plus rapide et plus precise dans les enregistrements suivants. + +### Gerer les locuteurs enregistres + +Tous les locuteurs que vous identifiez sont automatiquement sauvegardes dans votre base de locuteurs. Vous pouvez les gerer depuis l'onglet **Gestion des locuteurs** dans vos [parametres du compte](parametres.md). + +Chaque fiche de locuteur affiche le nom, les statistiques d'utilisation, la date de derniere utilisation et la date d'ajout. Vous pouvez supprimer des locuteurs individuellement ou utiliser le bouton **Tout supprimer** pour reinitialiser votre base. + +## Modifier les transcriptions + +DictIA propose deux modes d'edition selon le type de transcription. + +### Edition de texte simple + +Pour les transcriptions sans identification des locuteurs, cliquez sur **Modifier** dans la barre d'outils pour acceder a un editeur de texte simple. Vous pouvez corriger les erreurs, ajouter de la ponctuation et ameliorer le formatage. Vos modifications sont conservees meme lors de la regeneration des resumes. + +### Edition avancee par segments + +Pour les transcriptions avec identification des locuteurs, DictIA offre un editeur par segments qui preserve la structure et le minutage. + +Pour chaque segment, vous pouvez : + +- **Modifier le nom du locuteur** : cliquez sur le champ du locuteur pour le changer, avec des suggestions intelligentes depuis votre base de locuteurs. +- **Ajuster les horodatages** : affinez les heures de debut et de fin de chaque segment. +- **Modifier le texte** : corrigez les erreurs de transcription directement dans le champ texte. +- **Gerer les segments** : supprimez les segments indesirables ou ajoutez-en de nouveaux. + +Vos modifications sont sauvegardees lorsque vous cliquez sur **Enregistrer les modifications**. + +!!! warning "Retraitement complet" + Si vous lancez un retraitement complet de la transcription, vos modifications manuelles seront ecrasees. Pour conserver vos corrections, utilisez plutot le retraitement du resume uniquement. + +## Options d'affichage + +### Vue simple + +La vue par defaut presente la transcription sous forme de texte continu avec des etiquettes de locuteurs en ligne. Ideale pour la lecture rapide et la copie de texte. + +### Vue a bulles + +La vue a bulles formate la transcription comme une conversation avec des bulles de message distinctes pour chaque locuteur. Particulierement utile pour les entrevues, debats ou tout echange rapide. + +## Synchronisation audio et mode suivi + +Pour les enregistrements avec identification des locuteurs, DictIA offre une synchronisation bidirectionnelle entre le lecteur audio et la transcription. + +### Navigation par clic + +Cliquez sur n'importe quelle partie de la transcription pour acceder directement a ce moment dans l'audio. Fonctionne dans les deux modes d'affichage. + +### Mise en evidence du segment actif + +Pendant la lecture audio, DictIA met automatiquement en evidence le texte actuellement prononce avec un encadre arrondi et une animation subtile. + +### Mode suivi avec defilement automatique + +Activez le mode suivi en cochant la case dans l'en-tete de la transcription (icone de fleches verticales). La transcription defilera automatiquement pour garder le segment actif au centre de la vue. Particulierement utile pour : + +- Suivre de longs enregistrements sans defilement manuel +- Reviser des sections specifiques a differentes vitesses de lecture +- Maintenir la synchronisation entre le visuel et l'audio + +Votre preference de mode suivi est sauvegardee automatiquement et persiste entre les sessions. + +## Utiliser le resume + +L'onglet **Resume** dans le panneau de droite contient une vue d'ensemble generee par l'IA. Le resume capture typiquement : + +- Les principaux sujets abordes +- Les decisions ou conclusions prises +- Les actions a suivre et les responsables +- Les dates ou echeances importantes mentionnees +- Les problemes ou preoccupations souleves + +### Personnaliser les consignes de resume + +DictIA permet de personnaliser la generation du resume a trois niveaux : + +1. **Niveau administrateur** : consigne par defaut pour tous les utilisateurs. +2. **Niveau utilisateur** : votre consigne personnelle dans les [parametres du compte](parametres.md), qui remplace celle de l'administrateur. +3. **Niveau etiquette** : chaque etiquette peut avoir sa propre consigne. Par exemple, une etiquette « Reunion juridique » pourrait se concentrer sur la conformite, tandis qu'une etiquette « Planification produit » mettrait l'accent sur les fonctionnalites et les echeances. + +### Ordre de priorite des consignes + +1. **Consignes des etiquettes** (priorite la plus elevee) : si l'enregistrement a des etiquettes avec des consignes, celles-ci priment. Plusieurs etiquettes combinent leurs consignes intelligemment. +2. **Consigne utilisateur** : appliquee en l'absence de consignes d'etiquettes. +3. **Consigne administrateur** : utilisee lorsque aucune consigne utilisateur n'est configuree. +4. **Consigne systeme** : dernier recours si aucune consigne n'existe a aucun niveau. + +### Modifier et exporter les resumes + +Le resume est entierement modifiable avec un editeur Markdown. Cliquez sur l'icone crayon pour entrer en mode edition. Les modifications sont enregistrees automatiquement. + +Vous pouvez exporter le resume de deux facons : + +- **Copier** : copie le resume au format Markdown dans le presse-papier. +- **Telecharger** : exporte le resume en fichier Microsoft Word (.docx). + +### Retraiter le resume avec des consignes differentes + +Cliquez sur le bouton de retraitement dans la barre d'outils du resume pour regenerer le resume. Trois options sont disponibles : + +- **Consigne par defaut** : applique la hierarchie de consignes standard. +- **Consigne d'une etiquette** : selectionnez n'importe quelle etiquette pour appliquer sa consigne, meme si l'enregistrement n'a pas cette etiquette. Ideal pour experimenter. +- **Consigne personnalisee** : saisissez une consigne unique pour cette seule regeneration (ex. : « Extraire tous les termes techniques et leurs definitions »). + +Le retraitement du resume ne modifie jamais votre transcription, y compris vos corrections manuelles. + +## Extraction d'evenements + +Lorsque l'extraction d'evenements est activee dans vos parametres du compte, DictIA identifie automatiquement les evenements dignes d'etre ajoutes a votre calendrier : reunions, echeances, rendez-vous et autres elements sensibles au temps. + +### Consulter les evenements extraits + +Apres le traitement, un onglet **Evenements** apparait dans le panneau de droite si des evenements ont ete detectes. Chaque evenement affiche le titre, la date, l'heure et une description extraite de la conversation. + +### Exporter vers le calendrier + +Chaque evenement peut etre exporte en fichier ICS compatible avec Google Agenda, Outlook, Apple Calendar et Thunderbird. Cliquez sur le bouton de telechargement a cote de l'evenement. Lorsque aucune heure precise n'est mentionnee, les evenements sont programmes a 9 h par defaut. + +## Conversation IA + +L'onglet **Conversation IA** met a votre disposition un assistant qui peut repondre a des questions sur votre enregistrement. Particulierement utile pour les longs enregistrements ou la recherche d'informations specifiques prendrait beaucoup de temps. + +### Poser des questions efficaces + +Vous pouvez poser differents types de questions : + +- Questions factuelles : « Qu'a dit Marie a propos du budget ? » +- Questions d'analyse : « Quelles sont les principales preoccupations soulevees ? » +- Questions de synthese : « Resume les actions a suivre pour l'equipe marketing. » + +La conversation IA maintient le contexte, vous pouvez donc poser des questions de suivi. + +### Exporter les conversations IA + +- **Copier dans le presse-papier** : copie la conversation complete au format question-reponse. +- **Telecharger en Word** : exporte l'historique complet en fichier Microsoft Word (.docx). + +## Notes personnelles + +L'onglet **Notes** est votre espace personnel et prive pour ajouter du contexte et des reflexions sur l'enregistrement. + +!!! info "Notes vs Resume" + - **Notes** : toujours privees et visibles uniquement par vous, meme sur les enregistrements partages. Aucune permission speciale n'est requise pour ajouter des notes. + - **Resume** : contenu partage visible par tous les utilisateurs ayant acces a l'enregistrement. Seuls les utilisateurs avec la permission d'edition peuvent modifier le resume. + +Les notes supportent le format Markdown complet et sont entierement consultables via la recherche, aux cotes des transcriptions. Elles sont enregistrees automatiquement au fur et a mesure de la saisie. + +## Copier et exporter + +### Copier la transcription + +Le bouton **Copier** dans la barre d'outils copie la transcription complete en texte brut dans le presse-papier, en conservant la structure des paragraphes. + +### Telecharger la transcription + +Le bouton **Telecharger** offre des options d'exportation flexibles grace a des modeles personnalisables. Selectionnez parmi vos modeles de transcription enregistres ou exportez la transcription brute. Les modeles vous permettent de controler le formatage : horodatages pour les sous-titres, format centre sur les locuteurs pour les entrevues, etc. + +Si l'identification des locuteurs est activee, le texte exporte inclut les noms des locuteurs (generiques ou reels si vous les avez identifies). + +--- + +Suivant : [Recherche IA](recherche-ia.md) diff --git a/client_docs/index.md b/client_docs/index.md new file mode 100644 index 0000000..3cb7907 --- /dev/null +++ b/client_docs/index.md @@ -0,0 +1,48 @@ +# Bienvenue sur DictIA + +DictIA est une plateforme de transcription intelligente propulsée par l'intelligence artificielle, développée par **InnovA AI**. Elle transforme vos enregistrements audio en contenu structuré, consultable et exploitable : transcriptions, résumés automatiques, identification des locuteurs et recherche sémantique. + +--- + +## Sections de la documentation + +
+
+

Guide Utilisateur

+

Apprenez à enregistrer, transcrire et organiser vos contenus audio avec DictIA.

+ Consulter le guide utilisateur +
+ +
+

Guide Administrateur

+

Configurez les utilisateurs, les groupes, les modèles IA et les paramètres système de votre instance DictIA.

+ Consulter le guide administrateur +
+ +
+

Dépannage

+

Résolvez les problèmes courants liés à la transcription, à la performance et aux fonctionnalités.

+ Consulter le dépannage +
+
+ +--- + +## Fonctionnalités principales + +- **Transcription IA** : Convertissez vos enregistrements audio en texte avec une précision élevée, dans plusieurs langues. +- **Résumés automatiques** : Obtenez un résumé structuré de chaque enregistrement, avec les points clés et les actions à suivre. +- **Identification des locuteurs** : Distinguez automatiquement les différents intervenants dans un enregistrement. +- **Recherche IA** : Posez des questions en langage naturel pour retrouver l'information à travers tous vos enregistrements. +- **Conversation IA** : Interagissez avec vos transcriptions pour extraire des informations, générer des rapports ou poser des questions. +- **Organisation flexible** : Classez vos enregistrements avec des étiquettes, des dossiers et des groupes. +- **Partage sécurisé** : Partagez des enregistrements avec vos collègues ou via des liens externes sécurisés. +- **Application mobile** : Accédez à DictIA depuis votre appareil mobile grâce à l'application web progressive. + +--- + +## Besoin d'aide? + +Si vous rencontrez un problème ou avez une question, consultez la section [Dépannage](depannage/index.md) ou contactez le support InnovA AI. + +**Support InnovA AI** : Pour toute question technique ou demande d'assistance, contactez l'équipe InnovA AI via les canaux de support mis à votre disposition par votre administrateur. diff --git a/config/docker-compose.example.yml b/config/docker-compose.example.yml new file mode 100644 index 0000000..385b6a7 --- /dev/null +++ b/config/docker-compose.example.yml @@ -0,0 +1,65 @@ +services: + app: + # Use 'lite' tag for a smaller image (~700MB vs ~4.4GB) without PyTorch + # Semantic search in Inquire Mode falls back to text search; all other features work normally + image: dictia:latest + container_name: dictia + restart: unless-stopped + ports: + - "8899:8899" + + # --- Configuration --- + # Environment variables are loaded from the .env file. + # + # To get started: + # 1. Copy this file to your project root: + # cp config/docker-compose.example.yml docker-compose.yml + # + # 2. Copy the unified transcription config (RECOMMENDED): + # cp config/env.transcription.example .env + # + # This supports all providers with auto-detection: + # - OpenAI GPT-4o with diarization (set TRANSCRIPTION_MODEL=gpt-4o-transcribe-diarize) + # - Self-hosted ASR/WhisperX (set ASR_BASE_URL=http://your-asr:9000) + # - Legacy Whisper (set TRANSCRIPTION_MODEL=whisper-1) + # + # Legacy config files (still supported): + # - config/env.whisper.example - Standard Whisper API + # - config/env.whisperx.example - WhisperX with voice profiles + # - config/env.asr.example - Basic ASR with diarization + # + # 3. Edit the .env file to add your API keys: + # - TRANSCRIPTION_API_KEY (for OpenAI) or ASR_BASE_URL (for self-hosted) + # - TEXT_MODEL_API_KEY (REQUIRED for summaries, titles, and chat) + # + # 4. Start DictIA: + # docker compose up -d + env_file: + - .env + + environment: + # Set log level for troubleshooting + # Use ERROR for production (minimal logs) + # Use INFO for debugging issues (recommended when troubleshooting) + # Use DEBUG for detailed development logging + - LOG_LEVEL=ERROR + + # --- Volume Configuration --- + # Choose ONE of the following volume configurations. + # Option 1 (Recommended): Bind mounts to local folders. + volumes: + - ./uploads:/data/uploads + - ./instance:/data/instance + # Optional: Uncomment if using auto-export feature (ENABLE_AUTO_EXPORT=true) + # - ./exports:/data/exports + # Optional: Uncomment if using auto-processing feature (ENABLE_AUTO_PROCESSING=true) + # - ./auto-process:/data/auto-process + + # Option 2: Docker-managed volumes. + # volumes: + # - dictia-uploads:/data/uploads + # - dictia-instance:/data/instance + # # Optional: Uncomment if using auto-export feature + # # - dictia-exports:/data/exports + # # Optional: Uncomment if using auto-processing feature + # # - dictia-auto-process:/data/auto-process diff --git a/config/env.asr.example b/config/env.asr.example new file mode 100644 index 0000000..b8cb8de --- /dev/null +++ b/config/env.asr.example @@ -0,0 +1,259 @@ +# ----------------------------------------------------------------------------- +# DictIA Configuration: ASR Endpoint (Legacy) +# +# ⚠️ DEPRECATION NOTICE: This configuration style is still supported but +# we recommend using the new unified configuration in env.transcription.example +# which supports all transcription providers with auto-detection. +# +# Migration: Simply set ASR_BASE_URL and the connector will auto-detect ASR mode. +# USE_ASR_ENDPOINT=true is no longer required (but still works for backwards compat). +# +# Instructions: +# 1. Copy this file to a new file named .env +# cp env.asr.example .env +# 2. Fill in the required URLs, API keys, and settings below. +# ----------------------------------------------------------------------------- + +# --- Text Generation Model (for summaries, titles, etc.) --- +TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +TEXT_MODEL_API_KEY=your_openrouter_api_key +TEXT_MODEL_NAME=openai/gpt-4o-mini + +# --- GPT-5 Specific Settings (only used with OpenAI API and GPT-5 models) --- +# If using GPT-5 models (gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-chat-latest) with OpenAI API, +# these parameters will be used instead of temperature. +# +# Example GPT-5 configuration: +# TEXT_MODEL_BASE_URL=https://api.openai.com/v1 +# TEXT_MODEL_NAME=gpt-5-mini +# +# Reasoning effort: minimal, low, medium, high (default: medium) +# - minimal: Fastest responses, minimal reasoning tokens +# - low: Fast responses with basic reasoning +# - medium: Balanced reasoning and speed (recommended) +# - high: Maximum reasoning for complex tasks +GPT5_REASONING_EFFORT=medium +# +# Verbosity: low, medium, high (default: medium) +# - low: Concise responses +# - medium: Balanced detail +# - high: Detailed explanations +GPT5_VERBOSITY=medium + +# --- Chat Model Configuration (Optional) --- +# Configure a separate model for real-time chat interactions. +# If not set, chat will use the TEXT_MODEL_* settings above. +# +# Use cases: +# - Use a faster model for chat while using a more capable model for summarization +# - Use a cheaper model for interactive chat to reduce costs +# - Use different service tiers for different operations +# +# CHAT_MODEL_API_KEY=your_chat_api_key +# CHAT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +# CHAT_MODEL_NAME=openai/gpt-4o + +# --- Chat GPT-5 Settings (only used with OpenAI API and GPT-5 chat models) --- +# These settings allow independent control of GPT-5 parameters for chat. +# If not set, falls back to the main GPT5_* settings above. +# +# CHAT_GPT5_REASONING_EFFORT=medium +# CHAT_GPT5_VERBOSITY=medium + +# --- LLM Streaming Compatibility --- +# Some LLM servers (e.g., certain vLLM configurations) don't support OpenAI's +# stream_options parameter. If chat streaming hangs or fails, try disabling this. +# Note: When disabled, token usage tracking for chat will not be available. +# ENABLE_STREAM_OPTIONS=false + +# --- Transcription Service (ASR Endpoint) --- +# New connector architecture auto-detects ASR mode when ASR_BASE_URL is set. +# USE_ASR_ENDPOINT=true is deprecated but still works for backwards compatibility. +# +# Note: ASR endpoints handle chunking internally - CHUNK_LIMIT settings are ignored. + +# ASR Endpoint URL (setting this auto-enables ASR mode) +# For containers in same docker-compose: Use container name and internal port +# Example: http://whisper-asr:9000 (NOT the host port 6002 or external IP) +# For external ASR: Use http://192.168.1.100:9000 or http://asr.example.com:9000 +ASR_BASE_URL=http://whisper-asr:9000 + +# Deprecated: No longer needed, kept for backwards compatibility +# USE_ASR_ENDPOINT=true + +# Speaker diarization options +ASR_DIARIZE=true +# ASR_MIN_SPEAKERS=1 # Hint for minimum speakers +# ASR_MAX_SPEAKERS=5 # Hint for maximum speakers +# ASR_RETURN_SPEAKER_EMBEDDINGS=false # Only enable for WhisperX ASR service + +# --- ASR Chunking (for GPUs with limited memory) --- +# Self-hosted ASR services may crash on long files due to GPU memory exhaustion. +# Enable app-level chunking to split long files before sending to ASR. +# Default: false (ASR service handles files internally) +# ASR_ENABLE_CHUNKING=true + +# Maximum audio duration per chunk in seconds (default: 7200 = 2 hours) +# Lower this value if your GPU runs out of memory on long files. +# Common values: 600 (10 min), 1200 (20 min), 1800 (30 min), 3600 (1 hour) +# ASR_MAX_DURATION_SECONDS=7200 + +# --- Application Settings --- +# Set to "true" to allow user registration, "false" to disable +ALLOW_REGISTRATION=false +# Comma-separated list of allowed email domains for registration. +# Leave empty to allow all domains. Example: company.com,subsidiary.org +REGISTRATION_ALLOWED_DOMAINS= +SUMMARY_MAX_TOKENS=8000 +CHAT_MAX_TOKENS=5000 + +# Timezone for displaying dates and times in the UI +# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC") +TIMEZONE="UTC" + +# Set the logging level for the application. +# Options: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL="INFO" + +# --- Audio Compression --- +# Automatically compress lossless uploads (WAV, AIFF) to save storage +AUDIO_COMPRESS_UPLOADS=true + +# Target codec: mp3 (lossy, smallest), flac (lossless), opus (lossy, efficient) +AUDIO_CODEC=mp3 + +# Bitrate for lossy codecs (ignored for FLAC) +AUDIO_BITRATE=128k + +# Unsupported codecs - comma-separated list of codecs to exclude from supported list +# Use this if your transcription service doesn't support certain codecs +# Supported codecs by default: pcm_s16le, pcm_s24le, pcm_f32le, mp3, flac, opus, vorbis, aac +# Example: AUDIO_UNSUPPORTED_CODECS=opus,vorbis +# AUDIO_UNSUPPORTED_CODECS= + +# --- Admin User (created on first run) --- +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme + +# --- Inquire Mode (AI search across all recordings) --- +# Set to "true" to enable semantic search and chat across all recordings +# Requires additional dependencies (already included in Docker image) +ENABLE_INQUIRE_MODE=false + +# --- Automated File Processing (Black Hole Directory) --- +# Set to "true" to enable automated file processing +ENABLE_AUTO_PROCESSING=false + +# --- Automated Export Settings --- +# Automatically export transcriptions and summaries to markdown files +ENABLE_AUTO_EXPORT=false + +# Directory where exports will be saved (per-user subdirectories created automatically) +AUTO_EXPORT_DIR=/data/exports + +# What to include in exports +AUTO_EXPORT_TRANSCRIPTION=true +AUTO_EXPORT_SUMMARY=true + +# Processing mode: admin_only, user_directories, or single_user +AUTO_PROCESS_MODE=admin_only + +# Directory to watch for new audio files +AUTO_PROCESS_WATCH_DIR=/data/auto-process + +# How often to check for new files (seconds) +AUTO_PROCESS_CHECK_INTERVAL=30 + +# How long to wait (seconds) to confirm a file has stopped changing before processing. +# Increase for slow network transfers (NFS, SMB). Default: 5 +# AUTO_PROCESS_STABILITY_TIME=5 + +# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user) +# AUTO_PROCESS_DEFAULT_USERNAME=admin + +# --- Auto-Deletion & Retention Settings --- +# Enable automated deletion of old recordings +ENABLE_AUTO_DELETION=false + +# Number of days to retain recordings (0 = disabled) +# Example: 90 means recordings older than 90 days will be processed +GLOBAL_RETENTION_DAYS=90 + +# Deletion mode: 'audio_only' keeps transcription, 'full_recording' deletes everything +# audio_only: Deletes audio file but keeps transcription/summary/notes (recommended) +# full_recording: Permanently deletes the entire recording from database +DELETION_MODE=audio_only + +# --- Permission-Based Deletion Controls --- +# Allow all users to delete their recordings, or restrict to admins only +# true: All users can delete their own recordings (default) +# false: Only admins can delete recordings +USERS_CAN_DELETE=true + +# Delete speaker profiles when all their recordings are removed. +# Default: false (speaker profiles and voice embeddings are preserved) +# Set to true for privacy-sensitive deployments where biometric voice data +# should not outlive the recordings it was derived from. +# DELETE_ORPHANED_SPEAKERS=false + +# --- Internal Sharing Settings --- +# Enable user-to-user sharing of recordings (works independently of groups) +ENABLE_INTERNAL_SHARING=false + +# Show usernames in the UI (when sharing/viewing shared recordings) +# true: Display usernames throughout the interface +# false: Hide usernames (users must know each other's usernames to share) +SHOW_USERNAMES_IN_UI=false + +# --- Public Sharing Settings --- +# Enable creation of public share links (anonymous access) +# true: Users can create public links to share recordings externally (default) +# false: Public sharing is disabled globally +ENABLE_PUBLIC_SHARING=true + +# Note: Admins can control public sharing permissions per-user in the admin dashboard +# even when ENABLE_PUBLIC_SHARING is true + +# --- Incognito Mode (HIPAA-friendly) --- +# Enable incognito mode for privacy-sensitive transcriptions +# When enabled, users can upload recordings that are: +# - Processed on the server but NOT saved to the database +# - Stored only in the browser's sessionStorage (lost when tab closes) +# - Audio files are immediately deleted after processing +# Useful for HIPAA compliance or sensitive recordings +# Default: false (feature hidden) +ENABLE_INCOGNITO_MODE=false + +# Make incognito mode the default for in-app recordings (toggle starts ON) +INCOGNITO_MODE_DEFAULT=false + +# --- Video Retention --- +# When enabled, uploaded video files keep their video stream for in-browser playback +# The audio is extracted to a temp file for transcription, then cleaned up +# Default: false (video uploads extract audio only, video stream is discarded) +VIDEO_RETENTION=false + +# --- Concurrent Uploads --- +# Maximum number of simultaneous file uploads (default: 3) +MAX_CONCURRENT_UPLOADS=3 + +# --- Background Processing Queues --- +# Separate queues for transcription (slow) and summary (fast) jobs +# This prevents slow ASR jobs from blocking quick summary generation + +# Transcription queue workers (for ASR processing, default: 2) +JOB_QUEUE_WORKERS=2 + +# Summary queue workers (for LLM summarization, default: 2) +SUMMARY_QUEUE_WORKERS=2 + +# Maximum retry attempts for failed jobs (default: 3) +JOB_MAX_RETRIES=3 + +# --- Docker Settings (rarely need to be changed) --- +# Database URI - SQLite (default) or PostgreSQL +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +# For PostgreSQL, use: postgresql://username:password@hostname:5432/database_name +# Example: postgresql://speakr:password@postgres:5432/speakr +UPLOAD_FOLDER=/data/uploads diff --git a/config/env.email.example b/config/env.email.example new file mode 100644 index 0000000..80590f7 --- /dev/null +++ b/config/env.email.example @@ -0,0 +1,109 @@ +############################################################################### +# Email Verification & Password Reset Configuration +############################################################################### + +# Enable email verification for new user registrations. +# When enabled, new users must verify their email before full access. +# Default: false +ENABLE_EMAIL_VERIFICATION=false + +# Require email verification to log in. +# Only effective when ENABLE_EMAIL_VERIFICATION=true. +# When true, users cannot log in until they verify their email. +# Default: false +REQUIRE_EMAIL_VERIFICATION=false + +############################################################################### +# SMTP Configuration +############################################################################### + +# SMTP server hostname (required for email functionality) +# Examples: smtp.gmail.com, smtp.sendgrid.net, smtp.mailgun.org +SMTP_HOST=smtp.gmail.com + +# SMTP server port +# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 25 (unencrypted) +# Default: 587 +SMTP_PORT=587 + +# SMTP authentication username (usually your email address) +SMTP_USERNAME=your-email@gmail.com + +# SMTP authentication password +# For Gmail: Use an App Password (not your regular password) +# https://support.google.com/accounts/answer/185833 +SMTP_PASSWORD=your-app-password + +# Use TLS/STARTTLS encryption (recommended for port 587) +# Default: true +SMTP_USE_TLS=true + +# Use SSL encryption (for port 465) +# Note: Only enable one of SMTP_USE_TLS or SMTP_USE_SSL +# Default: false +SMTP_USE_SSL=false + +# Email address that appears in the "From" field +# Should be a valid email address, ideally matching your domain +SMTP_FROM_ADDRESS=noreply@yourdomain.com + +# Display name that appears alongside the from address +# Default: Speakr +SMTP_FROM_NAME=Speakr + +############################################################################### +# Provider-Specific Examples +############################################################################### + +# --- Gmail --- +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USERNAME=your-email@gmail.com +# SMTP_PASSWORD=your-app-password # Generate at https://myaccount.google.com/apppasswords + +# --- SendGrid --- +# SMTP_HOST=smtp.sendgrid.net +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USERNAME=apikey +# SMTP_PASSWORD=your-sendgrid-api-key + +# --- Mailgun --- +# SMTP_HOST=smtp.mailgun.org +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USERNAME=postmaster@your-domain.mailgun.org +# SMTP_PASSWORD=your-mailgun-password + +# --- Amazon SES --- +# SMTP_HOST=email-smtp.us-east-1.amazonaws.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USERNAME=your-ses-smtp-username +# SMTP_PASSWORD=your-ses-smtp-password + +# --- Microsoft 365 / Outlook --- +# SMTP_HOST=smtp.office365.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USERNAME=your-email@yourdomain.com +# SMTP_PASSWORD=your-password + +############################################################################### +# Notes +############################################################################### + +# Token Expiry Times: +# - Email verification links expire after 24 hours +# - Password reset links expire after 1 hour + +# Migration Behavior: +# - Existing users are automatically marked as email_verified=true +# - New users (when feature is enabled) start as email_verified=false + +# Security Recommendations: +# - Always use TLS or SSL encryption +# - Use app-specific passwords when available (Gmail, etc.) +# - Consider using a dedicated email service (SendGrid, Mailgun, SES) +# - Set a strong SECRET_KEY in your Flask configuration diff --git a/config/env.sso.example b/config/env.sso.example new file mode 100644 index 0000000..418c9d6 --- /dev/null +++ b/config/env.sso.example @@ -0,0 +1,32 @@ +############################################################################### +# SSO (OIDC) Authentication +############################################################################### + +# Enable SSO (Single Sign-On) authentication. Requires discovery URL and client credentials. +ENABLE_SSO=false + +# Display name for the provider (shown in UI button) +SSO_PROVIDER_NAME=Keycloak + +# OIDC client credentials +SSO_CLIENT_ID=speakr +SSO_CLIENT_SECRET=change-me + +# OIDC discovery document URL (well-known endpoint) +SSO_DISCOVERY_URL=https://keycloak.example.com/realms/master/.well-known/openid-configuration + +# Public redirect URI exposed by Speakr (must be registered in the IdP) +SSO_REDIRECT_URI=https://speakr.example.com/auth/sso/callback + +# Auto-registration settings +# Allow automatic account creation for new users signing in via SSO. +SSO_AUTO_REGISTER=true + +# Comma-separated list of allowed email domains for auto-registration. +# Leave empty to allow all domains (e.g., example.com,company.org). +SSO_ALLOWED_DOMAINS= + +# Claims used to map user profile fields +SSO_DEFAULT_USERNAME_CLAIM=preferred_username +SSO_DEFAULT_NAME_CLAIM=name + diff --git a/config/env.transcription.example b/config/env.transcription.example new file mode 100644 index 0000000..fc16712 --- /dev/null +++ b/config/env.transcription.example @@ -0,0 +1,289 @@ +# ============================================================================= +# Transcription Connector Configuration +# ============================================================================= +# +# DictIA supports multiple transcription providers through a connector-based +# architecture. This file documents all available configuration options. +# +# Quick Start (Simplified): +# 1. For OpenAI with diarization: Set TRANSCRIPTION_MODEL=gpt-4o-transcribe-diarize +# 2. For self-hosted ASR: Set ASR_BASE_URL=http://your-asr:9000 +# 3. For legacy Whisper: Set TRANSCRIPTION_API_KEY and optionally TRANSCRIPTION_MODEL +# +# Auto-Detection Priority: +# 1. TRANSCRIPTION_CONNECTOR - explicit connector name (if you need full control) +# 2. ASR_BASE_URL - if set, uses ASR endpoint connector +# 3. TRANSCRIPTION_MODEL contains 'gpt-4o' - uses OpenAI Transcribe connector +# 4. Default - uses OpenAI Whisper connector with TRANSCRIPTION_MODEL or whisper-1 + +# ============================================================================= +# TEXT GENERATION MODEL (REQUIRED for summaries, titles, chat) +# ============================================================================= +# DictIA uses a text/LLM model for generating summaries, titles, and chat. +# This is separate from the transcription model (STT). +# +# You can use OpenRouter (recommended - access to many models) or direct OpenAI API. + +# OpenRouter example (recommended - supports many models): +TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +TEXT_MODEL_API_KEY=your_openrouter_api_key +TEXT_MODEL_NAME=openai/gpt-4o-mini + +# OpenAI direct example: +# TEXT_MODEL_BASE_URL=https://api.openai.com/v1 +# TEXT_MODEL_API_KEY=sk-your_openai_api_key +# TEXT_MODEL_NAME=gpt-4o-mini + +# --- GPT-5 Specific Settings (only used with OpenAI API and GPT-5 models) --- +# Reasoning effort: minimal, low, medium, high (default: medium) +GPT5_REASONING_EFFORT=medium +# Verbosity: low, medium, high (default: medium) +GPT5_VERBOSITY=medium + +# --- Chat Model Configuration (Optional) --- +# Configure a separate model for real-time chat interactions. +# If not set, chat will use the TEXT_MODEL_* settings above. +# CHAT_MODEL_API_KEY=your_chat_api_key +# CHAT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +# CHAT_MODEL_NAME=openai/gpt-4o + +# ============================================================================= +# CONNECTOR SELECTION (Auto-detected if not set) +# ============================================================================= +# Options: openai_whisper, openai_transcribe, asr_endpoint +# Leave empty to auto-detect based on other settings +# TRANSCRIPTION_CONNECTOR= + +# Feature flag to enable/disable new connector architecture (default: true) +# Set to false to use legacy code path for troubleshooting +# USE_NEW_TRANSCRIPTION_ARCHITECTURE=true + +# ============================================================================= +# OPENAI CONFIGURATION (Required for openai_whisper and openai_transcribe) +# ============================================================================= +TRANSCRIPTION_API_KEY=your_openai_api_key +TRANSCRIPTION_BASE_URL=https://api.openai.com/v1 + +# Model Selection - determines which connector is used: +# +# whisper-1 - Legacy Whisper model, no diarization, $0.006/min +# Supports: srt, vtt, json, verbose_json output formats +# +# gpt-4o-transcribe - High quality transcription, no diarization, $0.006/min +# Better accuracy than whisper-1, accepts prompts +# +# gpt-4o-mini-transcribe - Cost-effective option, no diarization, $0.003/min +# Good for high-volume, budget-conscious use +# +# gpt-4o-transcribe-diarize - Speaker diarization!, $0.006/min +# Identifies speakers as A, B, C, D... +# Requires chunking_strategy for audio >30s +# +TRANSCRIPTION_MODEL=gpt-4o-transcribe-diarize + +# Legacy Whisper model name (used when TRANSCRIPTION_MODEL is not set) +# WHISPER_MODEL=whisper-1 + +# ============================================================================= +# ASR ENDPOINT CONFIGURATION (For self-hosted whisper services) +# ============================================================================= +# Note: USE_ASR_ENDPOINT is deprecated. Just set ASR_BASE_URL instead. +# The connector will auto-detect ASR mode when ASR_BASE_URL is set. +# USE_ASR_ENDPOINT=true # Deprecated - kept for backwards compatibility + +# Base URL of your ASR service (required if USE_ASR_ENDPOINT=true) +# Supports: whisper-asr-webservice, WhisperX, and compatible services +# ASR_BASE_URL=http://whisper-asr:9000 + +# Request timeout in seconds (default: 1800 = 30 minutes) +# Increase for very long audio files +# ASR_TIMEOUT=1800 + +# Enable speaker diarization (default: true) +# ASR_DIARIZE=true + +# Speaker count hints (optional, helps with diarization accuracy) +# ASR_MIN_SPEAKERS=1 +# ASR_MAX_SPEAKERS=5 + +# Return speaker embeddings for speaker identification (WhisperX only) +# Enables automatic speaker matching across recordings +# ASR_RETURN_SPEAKER_EMBEDDINGS=false + +# ============================================================================= +# CHUNKING CONFIGURATION (For large files) +# ============================================================================= +# Chunking is now connector-aware with this priority: +# 1. Connector handles internally (openai_transcribe, asr_endpoint) → No app chunking +# 2. ENABLE_CHUNKING=false → Disable chunking (only affects openai_whisper) +# 3. CHUNK_LIMIT set → Use your settings +# 4. Connector defaults → Use connector's recommended limits +# 5. App default → 20MB size-based +# +# For openai_transcribe/asr_endpoint: These settings are IGNORED (connector handles it) +# For openai_whisper: These settings control chunking behavior + +# ENABLE_CHUNKING=false # Uncomment to disable chunking for openai_whisper + +# Chunk limit - supports size (20MB) or duration (600s, 10m) +CHUNK_LIMIT=20MB + +# Overlap between chunks in seconds (helps with transcription accuracy at boundaries) +CHUNK_OVERLAP_SECONDS=3 + +# ============================================================================= +# EXAMPLE CONFIGURATIONS (Simplified) +# ============================================================================= +# +# --- OpenAI with Speaker Diarization (Recommended) --- +# Just two environment variables needed: +# TRANSCRIPTION_API_KEY=sk-xxx +# TRANSCRIPTION_MODEL=gpt-4o-transcribe-diarize +# +# --- Self-hosted WhisperX (Best for privacy) --- +# Just one environment variable needed (auto-detects ASR mode): +# ASR_BASE_URL=http://whisper-asr:9000 +# Optional: +# ASR_DIARIZE=true +# ASR_RETURN_SPEAKER_EMBEDDINGS=true +# +# --- OpenAI Whisper (Legacy, no diarization) --- +# TRANSCRIPTION_API_KEY=sk-xxx +# TRANSCRIPTION_MODEL=whisper-1 +# +# --- Custom Whisper model (local or compatible endpoint) --- +# TRANSCRIPTION_API_KEY=not-needed +# TRANSCRIPTION_BASE_URL=http://localhost:8080/v1 +# TRANSCRIPTION_MODEL=Systran/faster-distil-whisper-large-v3 + +# ============================================================================= +# APPLICATION SETTINGS +# ============================================================================= + +# --- Admin User (created on first run) --- +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme + +# --- Registration & Access --- +ALLOW_REGISTRATION=false +# Comma-separated list of allowed email domains for registration. +# Leave empty to allow all domains. Example: company.com,subsidiary.org +REGISTRATION_ALLOWED_DOMAINS= + +# --- Token Limits --- +SUMMARY_MAX_TOKENS=8000 +CHAT_MAX_TOKENS=5000 + +# --- Timezone --- +# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC") +TIMEZONE="UTC" + +# --- Logging --- +LOG_LEVEL="INFO" + +# ============================================================================= +# AUDIO PROCESSING +# ============================================================================= + +# --- Audio Compression --- +# Automatically compress lossless uploads (WAV, AIFF) to save storage +AUDIO_COMPRESS_UPLOADS=true + +# Target codec: mp3 (lossy, smallest), flac (lossless), opus (lossy, efficient) +AUDIO_CODEC=mp3 + +# Bitrate for lossy codecs (ignored for FLAC) +AUDIO_BITRATE=128k + +# Unsupported codecs - comma-separated list of codecs to exclude +# Example: AUDIO_UNSUPPORTED_CODECS=opus,vorbis +# AUDIO_UNSUPPORTED_CODECS= + +# ============================================================================= +# OPTIONAL FEATURES +# ============================================================================= + +# --- Inquire Mode (AI search across all recordings) --- +ENABLE_INQUIRE_MODE=false + +# --- Automated File Processing (Black Hole Directory) --- +ENABLE_AUTO_PROCESSING=false +# AUTO_PROCESS_MODE=admin_only +# AUTO_PROCESS_WATCH_DIR=/data/auto-process + +# --- Automated Export --- +ENABLE_AUTO_EXPORT=false +# AUTO_EXPORT_DIR=/data/exports + +# --- Auto-Deletion & Retention --- +ENABLE_AUTO_DELETION=false +# GLOBAL_RETENTION_DAYS=90 +# DELETION_MODE=audio_only + +# --- Sharing Settings --- +ENABLE_INTERNAL_SHARING=false +ENABLE_PUBLIC_SHARING=true +# SHOW_USERNAMES_IN_UI=false + +# --- Permission Controls --- +USERS_CAN_DELETE=true + +# Delete speaker profiles when all their recordings are removed. +# Default: false (speaker profiles and voice embeddings are preserved) +# Set to true for privacy-sensitive deployments where biometric voice data +# should not outlive the recordings it was derived from. +# DELETE_ORPHANED_SPEAKERS=false + +# --- Video Retention --- +# When enabled, uploaded video files keep their video stream for in-browser playback +# The audio is extracted to a temp file for transcription, then cleaned up +# Default: false (video uploads extract audio only, video stream is discarded) +VIDEO_RETENTION=false + +# --- Video Passthrough to ASR --- +# Send original video files directly to ASR without extracting audio. +# Useful for custom ASR backends that handle video internally (e.g., multi-track audio extraction). +# When enabled, video files bypass audio extraction, codec conversion, and chunking. +# Only affects video files — audio uploads are processed normally. +# Default: false +# VIDEO_PASSTHROUGH_ASR=false + +# --- Concurrent Uploads --- +# Maximum number of simultaneous file uploads (default: 3) +MAX_CONCURRENT_UPLOADS=3 + +# ============================================================================= +# BACKGROUND PROCESSING +# ============================================================================= + +# Transcription queue workers (default: 2) +JOB_QUEUE_WORKERS=2 + +# Summary queue workers (default: 2) +SUMMARY_QUEUE_WORKERS=2 + +# Maximum retry attempts for failed jobs (default: 3) +JOB_MAX_RETRIES=3 + +# ============================================================================= +# DOCKER/DATABASE SETTINGS +# ============================================================================= + +# Database URI - SQLite (default) or PostgreSQL +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +# For PostgreSQL: postgresql://username:password@hostname:5432/database_name + +UPLOAD_FOLDER=/data/uploads + +# ============================================================================= +# FUTURE: Additional Provider Notes +# ============================================================================= +# The connector architecture is designed to support additional providers. +# Future connectors may include: +# +# - Deepgram: Known for excellent diarization and real-time transcription +# - AssemblyAI: Strong diarization with speaker labels +# - Google Cloud Speech-to-Text: Enterprise-grade with speaker diarization +# +# To request a new connector, please open an issue on GitHub. diff --git a/config/env.whisper.example b/config/env.whisper.example new file mode 100644 index 0000000..1640040 --- /dev/null +++ b/config/env.whisper.example @@ -0,0 +1,256 @@ +# ----------------------------------------------------------------------------- +# DictIA Configuration: Standard Whisper API (Legacy) +# +# ⚠️ DEPRECATION NOTICE: This configuration style is still supported but +# we recommend using the new unified configuration in env.transcription.example +# which supports all transcription providers with auto-detection. +# +# Migration: See TRANSCRIPTION_CONNECTOR documentation in env.transcription.example +# For OpenAI Whisper, simply set: +# TRANSCRIPTION_API_KEY=your_key +# TRANSCRIPTION_MODEL=whisper-1 (or gpt-4o-transcribe-diarize for diarization) +# +# Instructions: +# 1. Copy this file to a new file named .env +# cp env.whisper.example .env +# 2. Fill in the required API keys and settings below. +# ----------------------------------------------------------------------------- + +# --- Text Generation Model (for summaries, titles, etc.) --- +TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +TEXT_MODEL_API_KEY=your_openrouter_api_key +TEXT_MODEL_NAME=openai/gpt-4o-mini + +# --- GPT-5 Specific Settings (only used with OpenAI API and GPT-5 models) --- +# If using GPT-5 models (gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-chat-latest) with OpenAI API, +# these parameters will be used instead of temperature. +# +# Example GPT-5 configuration: +# TEXT_MODEL_BASE_URL=https://api.openai.com/v1 +# TEXT_MODEL_NAME=gpt-5-mini +# +# Reasoning effort: minimal, low, medium, high (default: medium) +# - minimal: Fastest responses, minimal reasoning tokens +# - low: Fast responses with basic reasoning +# - medium: Balanced reasoning and speed (recommended) +# - high: Maximum reasoning for complex tasks +GPT5_REASONING_EFFORT=medium +# +# Verbosity: low, medium, high (default: medium) +# - low: Concise responses +# - medium: Balanced detail +# - high: Detailed explanations +GPT5_VERBOSITY=medium + +# --- Chat Model Configuration (Optional) --- +# Configure a separate model for real-time chat interactions. +# If not set, chat will use the TEXT_MODEL_* settings above. +# +# Use cases: +# - Use a faster model for chat while using a more capable model for summarization +# - Use a cheaper model for interactive chat to reduce costs +# - Use different service tiers for different operations +# +# CHAT_MODEL_API_KEY=your_chat_api_key +# CHAT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +# CHAT_MODEL_NAME=openai/gpt-4o + +# --- Chat GPT-5 Settings (only used with OpenAI API and GPT-5 chat models) --- +# These settings allow independent control of GPT-5 parameters for chat. +# If not set, falls back to the main GPT5_* settings above. +# +# CHAT_GPT5_REASONING_EFFORT=medium +# CHAT_GPT5_VERBOSITY=medium + +# --- LLM Streaming Compatibility --- +# Some LLM servers (e.g., certain vLLM configurations) don't support OpenAI's +# stream_options parameter. If chat streaming hangs or fails, try disabling this. +# Note: When disabled, token usage tracking for chat will not be available. +# ENABLE_STREAM_OPTIONS=false + +# --- Transcription Service (OpenAI Whisper API) --- +# New connector architecture is enabled by default. +# Available models: +# whisper-1 - Legacy, no diarization +# gpt-4o-transcribe - High quality, no diarization +# gpt-4o-mini-transcribe - Cost-effective, no diarization +# gpt-4o-transcribe-diarize - Speaker diarization! (recommended) +TRANSCRIPTION_BASE_URL=https://api.openai.com/v1 +TRANSCRIPTION_API_KEY=your_openai_api_key +TRANSCRIPTION_MODEL=whisper-1 + +# Legacy model name (deprecated, use TRANSCRIPTION_MODEL instead) +# WHISPER_MODEL=whisper-1 + +# --- Application Settings --- +# Set to "true" to allow user registration, "false" to disable +ALLOW_REGISTRATION=false +# Comma-separated list of allowed email domains for registration. +# Leave empty to allow all domains. Example: company.com,subsidiary.org +REGISTRATION_ALLOWED_DOMAINS= +SUMMARY_MAX_TOKENS=8000 +CHAT_MAX_TOKENS=5000 + +# Timezone for displaying dates and times in the UI +# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC") +TIMEZONE="UTC" + +# Set the logging level for the application. +# Options: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL="INFO" + +# --- Large File Chunking --- +# Chunking is now connector-aware: +# - openai_transcribe/asr_endpoint: Handled internally, these settings ignored +# - openai_whisper: Uses these settings for files >25MB +# +# ENABLE_CHUNKING=false # Uncomment to disable (only for openai_whisper) + +# Chunk limit - supports size (20MB) or duration (600s, 10m) +CHUNK_LIMIT=20MB + +# Overlap between chunks (seconds) +CHUNK_OVERLAP_SECONDS=3 + +# --- Audio Compression --- +# Automatically compress lossless uploads (WAV, AIFF) to save storage +AUDIO_COMPRESS_UPLOADS=true + +# Target codec: mp3 (lossy, smallest), flac (lossless), opus (lossy, efficient) +AUDIO_CODEC=mp3 + +# Bitrate for lossy codecs (ignored for FLAC) +AUDIO_BITRATE=128k + +# Unsupported codecs - comma-separated list of codecs to exclude from supported list +# Use this if your transcription service doesn't support certain codecs +# Supported codecs by default: pcm_s16le, pcm_s24le, pcm_f32le, mp3, flac, opus, vorbis, aac +# Example: AUDIO_UNSUPPORTED_CODECS=opus,vorbis +# AUDIO_UNSUPPORTED_CODECS= + +# --- Admin User (created on first run) --- +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme + +# --- Inquire Mode (AI search across all recordings) --- +# Set to "true" to enable semantic search and chat across all recordings +# Requires additional dependencies (already included in Docker image) +ENABLE_INQUIRE_MODE=false + +# --- Automated File Processing (Black Hole Directory) --- +# Set to "true" to enable automated file processing +ENABLE_AUTO_PROCESSING=false + +# --- Automated Export Settings --- +# Automatically export transcriptions and summaries to markdown files +ENABLE_AUTO_EXPORT=false + +# Directory where exports will be saved (per-user subdirectories created automatically) +AUTO_EXPORT_DIR=/data/exports + +# What to include in exports +AUTO_EXPORT_TRANSCRIPTION=true +AUTO_EXPORT_SUMMARY=true + +# Processing mode: admin_only, user_directories, or single_user +AUTO_PROCESS_MODE=admin_only + +# Directory to watch for new audio files +AUTO_PROCESS_WATCH_DIR=/data/auto-process + +# How often to check for new files (seconds) +AUTO_PROCESS_CHECK_INTERVAL=30 + +# How long to wait (seconds) to confirm a file has stopped changing before processing. +# Increase for slow network transfers (NFS, SMB). Default: 5 +# AUTO_PROCESS_STABILITY_TIME=5 + +# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user) +# AUTO_PROCESS_DEFAULT_USERNAME=admin + +# --- Auto-Deletion & Retention Settings --- +# Enable automated deletion of old recordings +ENABLE_AUTO_DELETION=false + +# Number of days to retain recordings (0 = disabled) +# Example: 90 means recordings older than 90 days will be processed +GLOBAL_RETENTION_DAYS=90 + +# Deletion mode: 'audio_only' keeps transcription, 'full_recording' deletes everything +# audio_only: Deletes audio file but keeps transcription/summary/notes (recommended) +# full_recording: Permanently deletes the entire recording from database +DELETION_MODE=audio_only + +# --- Permission-Based Deletion Controls --- +# Allow all users to delete their recordings, or restrict to admins only +# true: All users can delete their own recordings (default) +# false: Only admins can delete recordings +USERS_CAN_DELETE=true + +# Delete speaker profiles when all their recordings are removed. +# Default: false (speaker profiles and voice embeddings are preserved) +# Set to true for privacy-sensitive deployments where biometric voice data +# should not outlive the recordings it was derived from. +# DELETE_ORPHANED_SPEAKERS=false + +# --- Internal Sharing Settings --- +# Enable user-to-user sharing of recordings (works independently of groups) +ENABLE_INTERNAL_SHARING=false + +# Show usernames in the UI (when sharing/viewing shared recordings) +# true: Display usernames throughout the interface +# false: Hide usernames (users must know each other's usernames to share) +SHOW_USERNAMES_IN_UI=false + +# --- Public Sharing Settings --- +# Enable creation of public share links (anonymous access) +# true: Users can create public links to share recordings externally (default) +# false: Public sharing is disabled globally +ENABLE_PUBLIC_SHARING=true + +# Note: Admins can control public sharing permissions per-user in the admin dashboard +# even when ENABLE_PUBLIC_SHARING is true + +# --- Incognito Mode (HIPAA-friendly) --- +# Enable incognito mode for privacy-sensitive transcriptions +# When enabled, users can upload recordings that are: +# - Processed on the server but NOT saved to the database +# - Stored only in the browser's sessionStorage (lost when tab closes) +# - Audio files are immediately deleted after processing +# Useful for HIPAA compliance or sensitive recordings +# Default: false (feature hidden) +ENABLE_INCOGNITO_MODE=false + +# Make incognito mode the default for in-app recordings (toggle starts ON) +INCOGNITO_MODE_DEFAULT=false + +# --- Video Retention --- +# When enabled, uploaded video files keep their video stream for in-browser playback +# The audio is extracted to a temp file for transcription, then cleaned up +# Default: false (video uploads extract audio only, video stream is discarded) +VIDEO_RETENTION=false + +# --- Concurrent Uploads --- +# Maximum number of simultaneous file uploads (default: 3) +MAX_CONCURRENT_UPLOADS=3 + +# --- Background Processing Queues --- +# Separate queues for transcription (slow) and summary (fast) jobs +# This prevents slow ASR jobs from blocking quick summary generation + +# Transcription queue workers (for ASR processing, default: 2) +JOB_QUEUE_WORKERS=2 + +# Summary queue workers (for LLM summarization, default: 2) +SUMMARY_QUEUE_WORKERS=2 + +# Maximum retry attempts for failed jobs (default: 3) +JOB_MAX_RETRIES=3 + +# --- Docker Settings (rarely need to be changed) --- +# Database URI - SQLite (default) or PostgreSQL +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +# For PostgreSQL, use: postgresql://username:password@hostname:5432/database_name +# Example: postgresql://speakr:password@postgres:5432/speakr +UPLOAD_FOLDER=/data/uploads diff --git a/config/env.whisperx.example b/config/env.whisperx.example new file mode 100644 index 0000000..0a8fa75 --- /dev/null +++ b/config/env.whisperx.example @@ -0,0 +1,241 @@ +# ----------------------------------------------------------------------------- +# DictIA Configuration: WhisperX ASR Endpoint (with Voice Profiles) +# +# ⚠️ DEPRECATION NOTICE: This configuration style is still supported but +# we recommend using the new unified configuration in env.transcription.example +# which supports all transcription providers with auto-detection. +# +# Migration: Simply set ASR_BASE_URL and the connector will auto-detect ASR mode. +# USE_ASR_ENDPOINT=true is no longer required (but still works for backwards compat). +# +# This configuration is for use with the WhisperX ASR Service: +# https://github.com/murtaza-nasir/whisperx-asr-service +# +# Features supported: +# - Speaker diarization with pyannote/speaker-diarization-community-1 +# - Voice profile embeddings (256-dimensional) for speaker recognition +# - Automatic speaker matching across recordings +# - Better timestamp alignment between speakers and words +# +# Instructions: +# 1. Copy this file to a new file named .env +# cp config/env.whisperx.example .env +# 2. Fill in the required URLs, API keys, and settings below. +# 3. Set up WhisperX ASR Service (see installation guide) +# ----------------------------------------------------------------------------- + +# --- Text Generation Model (for summaries, titles, etc.) --- +TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +TEXT_MODEL_API_KEY=your_openrouter_api_key +TEXT_MODEL_NAME=openai/gpt-4o-mini + +# --- GPT-5 Specific Settings (only used with OpenAI API and GPT-5 models) --- +# If using GPT-5 models (gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-chat-latest) with OpenAI API, +# these parameters will be used instead of temperature. +# +# Example GPT-5 configuration: +# TEXT_MODEL_BASE_URL=https://api.openai.com/v1 +# TEXT_MODEL_NAME=gpt-5-mini +# +# Reasoning effort: minimal, low, medium, high (default: medium) +# - minimal: Fastest responses, minimal reasoning tokens +# - low: Fast responses with basic reasoning +# - medium: Balanced reasoning and speed (recommended) +# - high: Maximum reasoning for complex tasks +GPT5_REASONING_EFFORT=medium +# +# Verbosity: low, medium, high (default: medium) +# - low: Concise responses +# - medium: Balanced detail +# - high: Detailed explanations +GPT5_VERBOSITY=medium + +# --- Auto-Identify Speaker Response Format --- +# When enabled, auto-identify uses JSON Schema response format (structured outputs) +# to constrain LLM output to valid SPEAKER_XX keys. Falls back to json_object mode +# if the model doesn't support it. Leave disabled for widest model compatibility. +# AUTO_IDENTIFY_RESPONSE_SCHEMA=1 + +# --- Chat Model Configuration (Optional) --- +# Configure a separate model for real-time chat interactions. +# If not set, chat will use the TEXT_MODEL_* settings above. +# +# Use cases: +# - Use a faster model for chat while using a more capable model for summarization +# - Use a cheaper model for interactive chat to reduce costs +# - Use different service tiers for different operations +# +# CHAT_MODEL_API_KEY=your_chat_api_key +# CHAT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +# CHAT_MODEL_NAME=openai/gpt-4o + +# --- Chat GPT-5 Settings (only used with OpenAI API and GPT-5 chat models) --- +# These settings allow independent control of GPT-5 parameters for chat. +# If not set, falls back to the main GPT5_* settings above. +# +# CHAT_GPT5_REASONING_EFFORT=medium +# CHAT_GPT5_VERBOSITY=medium + +# --- Transcription Service (WhisperX ASR Endpoint) --- +# New connector architecture auto-detects ASR mode when ASR_BASE_URL is set. +# USE_ASR_ENDPOINT=true is deprecated but still works for backwards compatibility. +# +# Note: ASR endpoints handle chunking internally - CHUNK_LIMIT settings are ignored. + +# WhisperX ASR Endpoint URL (setting this auto-enables ASR mode) +# For containers in same docker-compose: Use container name and internal port +# Example: http://whisperx-asr:9000 (NOT the host port or external IP) +# For external ASR: Use http://192.168.1.100:9000 or http://asr.example.com:9000 +ASR_BASE_URL=http://whisperx-asr:9000 + +# Deprecated: No longer needed, kept for backwards compatibility +# USE_ASR_ENDPOINT=true + +# Speaker diarization options +ASR_DIARIZE=true +# ASR_MIN_SPEAKERS=1 # Hint for minimum speakers +# ASR_MAX_SPEAKERS=5 # Default maximum speakers + +# Enable speaker embeddings for voice profile matching (WhisperX only) +ASR_RETURN_SPEAKER_EMBEDDINGS=true + +# --- Application Settings --- +# Set to "true" to allow user registration, "false" to disable +ALLOW_REGISTRATION=false +# Comma-separated list of allowed email domains for registration. +# Leave empty to allow all domains. Example: company.com,subsidiary.org +REGISTRATION_ALLOWED_DOMAINS= +SUMMARY_MAX_TOKENS=8000 +CHAT_MAX_TOKENS=5000 + +# Timezone for displaying dates and times in the UI +# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC") +TIMEZONE="UTC" + +# Set the logging level for the application. +# Options: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL="INFO" + +# --- Audio Compression --- +# Automatically compress lossless uploads (WAV, AIFF) to save storage +AUDIO_COMPRESS_UPLOADS=true + +# Target codec: mp3 (lossy, smallest), flac (lossless), opus (lossy, efficient) +AUDIO_CODEC=mp3 + +# Bitrate for lossy codecs (ignored for FLAC) +AUDIO_BITRATE=128k + +# --- Admin User (created on first run) --- +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme + +# --- Inquire Mode (AI search across all recordings) --- +# Set to "true" to enable semantic search and chat across all recordings +# Requires additional dependencies (already included in Docker image) +ENABLE_INQUIRE_MODE=false + +# --- Automated File Processing (Black Hole Directory) --- +# Set to "true" to enable automated file processing +ENABLE_AUTO_PROCESSING=false + +# --- Automated Export Settings --- +# Automatically export transcriptions and summaries to markdown files +ENABLE_AUTO_EXPORT=false + +# Directory where exports will be saved (per-user subdirectories created automatically) +AUTO_EXPORT_DIR=/data/exports + +# What to include in exports +AUTO_EXPORT_TRANSCRIPTION=true +AUTO_EXPORT_SUMMARY=true + +# Processing mode: admin_only, user_directories, or single_user +AUTO_PROCESS_MODE=admin_only + +# Directory to watch for new audio files +AUTO_PROCESS_WATCH_DIR=/data/auto-process + +# How often to check for new files (seconds) +AUTO_PROCESS_CHECK_INTERVAL=30 + +# How long to wait (seconds) to confirm a file has stopped changing before processing. +# Increase for slow network transfers (NFS, SMB). Default: 5 +# AUTO_PROCESS_STABILITY_TIME=5 + +# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user) +# AUTO_PROCESS_DEFAULT_USERNAME=admin + +# --- Auto-Deletion & Retention Settings --- +# Enable automated deletion of old recordings +ENABLE_AUTO_DELETION=false + +# Number of days to retain recordings (0 = disabled) +# Example: 90 means recordings older than 90 days will be processed +GLOBAL_RETENTION_DAYS=90 + +# Deletion mode: 'audio_only' keeps transcription, 'full_recording' deletes everything +# audio_only: Deletes audio file but keeps transcription/summary/notes (recommended) +# full_recording: Permanently deletes the entire recording from database +DELETION_MODE=audio_only + +# --- Permission-Based Deletion Controls --- +# Allow all users to delete their recordings, or restrict to admins only +# true: All users can delete their own recordings (default) +# false: Only admins can delete recordings +USERS_CAN_DELETE=true + +# Delete speaker profiles when all their recordings are removed. +# Default: false (speaker profiles and voice embeddings are preserved) +# Set to true for privacy-sensitive deployments where biometric voice data +# should not outlive the recordings it was derived from. +# DELETE_ORPHANED_SPEAKERS=false + +# --- Internal Sharing Settings --- +# Enable user-to-user sharing of recordings (works independently of groups) +ENABLE_INTERNAL_SHARING=false + +# Show usernames in the UI (when sharing/viewing shared recordings) +# true: Display usernames throughout the interface +# false: Hide usernames (users must know each other's usernames to share) +SHOW_USERNAMES_IN_UI=false + +# --- Public Sharing Settings --- +# Enable creation of public share links (anonymous access) +# true: Users can create public links to share recordings externally (default) +# false: Public sharing is disabled globally +ENABLE_PUBLIC_SHARING=true + +# Note: Admins can control public sharing permissions per-user in the admin dashboard +# even when ENABLE_PUBLIC_SHARING is true + +# --- Video Retention --- +# When enabled, uploaded video files keep their video stream for in-browser playback +# The audio is extracted to a temp file for transcription, then cleaned up +# Default: false (video uploads extract audio only, video stream is discarded) +VIDEO_RETENTION=false + +# --- Concurrent Uploads --- +# Maximum number of simultaneous file uploads (default: 3) +MAX_CONCURRENT_UPLOADS=3 + +# --- Background Processing Queues --- +# Separate queues for transcription (slow) and summary (fast) jobs +# This prevents slow ASR jobs from blocking quick summary generation + +# Transcription queue workers (for ASR processing, default: 2) +JOB_QUEUE_WORKERS=2 + +# Summary queue workers (for LLM summarization, default: 2) +SUMMARY_QUEUE_WORKERS=2 + +# Maximum retry attempts for failed jobs (default: 3) +JOB_MAX_RETRIES=3 + +# --- Docker Settings (rarely need to be changed) --- +# Database URI - SQLite (default) or PostgreSQL +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +# For PostgreSQL, use: postgresql://username:password@hostname:5432/database_name +# Example: postgresql://speakr:password@postgres:5432/speakr +UPLOAD_FOLDER=/data/uploads diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 0000000..51369e3 --- /dev/null +++ b/constraints.txt @@ -0,0 +1 @@ +scipy<1.15 diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 0000000..0569f71 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,105 @@ +# DictIA — Deployment Infrastructure + +Infrastructure de deploiement reproductible pour DictIA . + +## Choix de profil + +``` +Quel est ton setup? + | + +-- VPS / serveur cloud? + | --> cloud (ASR Proxy GCP GPU on demand) + | + +-- Machine locale avec GPU NVIDIA? + | --> local-gpu (WhisperX sur GPU, le plus rapide) + | + +-- Machine locale sans GPU? + --> local-cpu (WhisperX sur CPU, lent mais fonctionnel) +``` + +## Quickstart + +```bash +git clone https://gitea.innova-ai.ca/Innova-AI/dictia-public.git +cd dictia +git checkout dictia-branding +bash deployment/setup.sh +``` + +Le script detecte le hardware et guide l'installation. + +## Architecture + +``` +deployment/ +├── setup.sh # Installateur principal +├── docker/ +│ ├── docker-compose.cloud.yml +│ ├── docker-compose.local-cpu.yml +│ ├── docker-compose.local-gpu.yml +│ └── .env.example +├── asr-proxy/ # Proxy GCP GPU (cloud seulement) +│ ├── proxy.py +│ ├── dashboard.html +│ ├── requirements.txt +│ ├── setup.sh +│ └── asr-proxy.service +├── security/ # Securite Docker (cloud) +│ ├── docker-daemon.json +│ ├── iptables-rules.sh +│ └── docker-iptables.service +├── config/ +│ ├── nginx/dictia.conf +│ ├── tailscale/setup-serve.sh +│ └── systemd/dictia.service +├── tools/ +│ ├── backup.sh +│ ├── restore.sh +│ ├── update.sh +│ └── health-check.sh +└── docs/ + ├── QUICKSTART.md + ├── VPS-SETUP.md + ├── LOCAL-SETUP.md + ├── MAINTENANCE.md + └── TROUBLESHOOTING.md +``` + +### Profil Cloud + +``` +Internet --> Tailscale --> VPS + | + DictIA :8899 + | + ASR Proxy :9090 + | + GCP GPU (auto start/stop) + | + WhisperX :9000 +``` + +### Profil Local GPU/CPU + +``` +localhost:8899 --> DictIA container + | + WhisperX container :9000 + | + GPU local (ou CPU) +``` + +## Documentation + +- [QUICKSTART.md](docs/QUICKSTART.md) — Demarrage rapide par profil +- [VPS-SETUP.md](docs/VPS-SETUP.md) — Setup VPS complet from scratch +- [LOCAL-SETUP.md](docs/LOCAL-SETUP.md) — Setup local GPU/CPU +- [MAINTENANCE.md](docs/MAINTENANCE.md) — Backup, restore, update, monitoring +- [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — Problemes courants + solutions + +## Mise a jour upstream + +Tous les fichiers dans `deployment/` sont specifiques a DictIA. +Aucun conflit lors des merges upstream, sauf `deployment/setup.sh` +(qui remplace le setup.sh original de Speakr). + diff --git a/deployment/asr-proxy/.gitignore b/deployment/asr-proxy/.gitignore new file mode 100644 index 0000000..8ff2efb --- /dev/null +++ b/deployment/asr-proxy/.gitignore @@ -0,0 +1,5 @@ +gcp-credentials.json +usage-stats.json +venv/ +__pycache__/ +*.pyc diff --git a/deployment/asr-proxy/asr-proxy.service b/deployment/asr-proxy/asr-proxy.service new file mode 100644 index 0000000..917ec8a --- /dev/null +++ b/deployment/asr-proxy/asr-proxy.service @@ -0,0 +1,22 @@ +# TEMPLATE — Ne pas copier directement dans /etc/systemd/system/. +# Les variables ${ASR_PROXY_USER} et ${ASR_PROXY_DIR} sont des placeholders. +# Le fichier service réel est généré par setup.sh (via heredoc bash) avec les +# valeurs résolues de $SERVICE_USER et $INSTALL_DIR. +# Usage : sudo bash setup.sh (installe et active le service automatiquement) + +[Unit] +Description=DictIA ASR Proxy - GPU Auto-Start/Stop for WhisperX +After=network.target + +[Service] +Type=simple +User=${ASR_PROXY_USER} +Restart=always +RestartSec=10 +WorkingDirectory=${ASR_PROXY_DIR} +ExecStart=${ASR_PROXY_DIR}/venv/bin/python proxy.py +Environment=GOOGLE_APPLICATION_CREDENTIALS=${ASR_PROXY_DIR}/gcp-credentials.json +Environment=STATS_FILE=${ASR_PROXY_DIR}/usage-stats.json + +[Install] +WantedBy=multi-user.target diff --git a/deployment/asr-proxy/dashboard.html b/deployment/asr-proxy/dashboard.html new file mode 100644 index 0000000..ba1ca7b --- /dev/null +++ b/deployment/asr-proxy/dashboard.html @@ -0,0 +1,1534 @@ + + + + + +DictIA GPU Monitor + + + + + + + +
+ Connection error: unable to reach proxy +
+ +
+ + +
+

DICTIA GPU MONITOR

+
+
+ proxy: connecting... +
+
+ + +
+
+
+
+
+
+
---
+
---
+
+ 0 active requests +
+
+ + +
+
+
--
+
GPU Time
+
This Month
+
+
+
--
+
Estimated Cost
+
USD
+
+
+
--
+
Total Requests
+
This Month
+
+
+
--
+
Remaining
+
of --h
+
+
+ + +
+
+
+ Monthly Budget + --% +
+
+
+
+
--h / --h
+
+
+ + +
+ + +
+ + +
+
+
Instance Details
+
+
+
IP
+
---
+
+
+
Machine
+
---
+
+
+
GPU
+
---
+
+
+
Idle
+
---
+
+
+
OAuth Token
+
---
+
+
+
Cost Rate
+
---
+
+
+
+
+ + +
+
+
Zone Fallback Map
+
+ +
+
+
+ + +
+
+
Request History
+
+ + + + + + + + + + + + + +
TimeTypeDurationStatusZone
No requests yet
+
+
+
+ + +
+
+
Event Log
+
+
Waiting for data...
+
+
+
+ + + + +
+ + + + + diff --git a/deployment/asr-proxy/proxy.py b/deployment/asr-proxy/proxy.py new file mode 100644 index 0000000..db20d1a --- /dev/null +++ b/deployment/asr-proxy/proxy.py @@ -0,0 +1,741 @@ +"""DictIA ASR Proxy - Auto-start/stop GCP GPU for WhisperX + Ollama. + +Uses Google Cloud Compute REST API directly (no gcloud CLI needed). +Proxies both ASR (WhisperX) and LLM (Ollama) requests. +Multi-zone fallback across Canada (Montreal + Toronto). +""" + +import asyncio +import json +import logging +import os +import time + +import httpx +import jwt as pyjwt +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, Response + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +log = logging.getLogger("asr-proxy") + +# Config — paths relative to this script's directory by default +SCRIPT_DIR = Path(__file__).parent +GCP_PROJECT = os.getenv("GCP_PROJECT", "speakr-gpu") +WHISPERX_PORT = int(os.getenv("WHISPERX_PORT", "9000")) +OLLAMA_PORT = int(os.getenv("OLLAMA_PORT", "11434")) +IDLE_TIMEOUT = int(os.getenv("IDLE_TIMEOUT", "300")) +CREDS_FILE = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", str(SCRIPT_DIR / "gcp-credentials.json")) +STATS_FILE = os.getenv("STATS_FILE", str(SCRIPT_DIR / "usage-stats.json")) +MONTHLY_LIMIT_HOURS = float(os.getenv("MONTHLY_LIMIT_HOURS", "30")) +# Real GCP cost per GPU-hour (g2-standard-4 + L4): GPU ($0.837) + vCPU ($0.151) + RAM ($0.069) +GPU_COST_PER_HOUR = float(os.getenv("GPU_COST_PER_HOUR", "1.06")) +# Fixed monthly costs: SSD disks ($5.66) + snapshots ($4.19) ≈ $9.85/month +FIXED_MONTHLY_COST = float(os.getenv("FIXED_MONTHLY_COST", "9.85")) +SNAPSHOT_NAME = "whisperx-gpu-snapshot" +HEALTH_POLL_INTERVAL = 5 +BOOT_TIMEOUT = 300 + +# Zone fallback order — Canada only, Montreal first +ZONE_FALLBACKS = [ + { + "zone": "northamerica-northeast1-b", + "instance": "whisperx-gpu-mtl1", + "machine_type": "g2-standard-4", + "accelerator": "nvidia-l4", + "accel_count": 1, + "label": "Montreal-b (L4)", + }, + { + "zone": "northamerica-northeast1-c", + "instance": "whisperx-gpu-mtl2", + "machine_type": "n1-standard-4", + "accelerator": "nvidia-tesla-t4", + "accel_count": 1, + "label": "Montreal-c (T4)", + }, + { + "zone": "northamerica-northeast2-a", + "instance": "whisperx-gpu-tor1", + "machine_type": "g2-standard-4", + "accelerator": "nvidia-l4", + "accel_count": 1, + "label": "Toronto-a (L4)", + }, + { + "zone": "northamerica-northeast2-b", + "instance": "whisperx-gpu", + "machine_type": "g2-standard-4", + "accelerator": "nvidia-l4", + "accel_count": 1, + "label": "Toronto-b (L4)", + }, +] + +STARTUP_SCRIPT = """#!/bin/bash +systemctl start docker +sleep 5 +docker start whisperx-asr 2>/dev/null || true +systemctl start ollama 2>/dev/null || true +""" + +app = FastAPI(title="DictIA ASR Proxy") + +# State +last_request_time = 0.0 +active_requests = 0 +gpu_ip: str | None = None +active_zone: dict | None = None +shutdown_task: asyncio.Task | None = None + +# Request history tracking (in-memory, last 20 requests) +request_history: list[dict] = [] +MAX_HISTORY = 20 + +# Zone status tracking +zone_status: dict[str, dict] = {} + +# Startup lock and failure cooldown +_startup_lock: asyncio.Lock | None = None +_last_failure_time: float = 0 +FAILURE_COOLDOWN = 180 + +# OAuth2 token cache +_access_token: str | None = None +_token_expiry: float = 0 + + +# --- Usage Stats --- + +def load_stats() -> dict: + try: + with open(STATS_FILE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"gpu_seconds": 0, "month": time.strftime("%Y-%m"), "requests": 0, "last_start": 0} + + +def save_stats(stats: dict): + with open(STATS_FILE, "w") as f: + json.dump(stats, f, indent=2) + + +def track_gpu_time(): + stats = load_stats() + current_month = time.strftime("%Y-%m") + if stats.get("month") != current_month: + stats = {"gpu_seconds": 0, "month": current_month, "requests": 0, "last_start": 0} + if stats.get("last_start", 0) > 0: + elapsed = time.time() - stats["last_start"] + stats["gpu_seconds"] += elapsed + stats["last_start"] = 0 + save_stats(stats) + + +def check_budget() -> tuple[bool, float]: + stats = load_stats() + current_month = time.strftime("%Y-%m") + if stats.get("month") != current_month: + return True, 0.0 + hours_used = stats.get("gpu_seconds", 0) / 3600 + return hours_used < MONTHLY_LIMIT_HOURS, hours_used + + +# --- GCP Auth --- + +async def get_access_token() -> str: + global _access_token, _token_expiry + if _access_token and time.time() < _token_expiry - 60: + return _access_token + with open(CREDS_FILE) as f: + creds = json.load(f) + cred_type = creds.get("type", "authorized_user") + async with httpx.AsyncClient() as client: + if cred_type == "service_account": + now = int(time.time()) + payload = { + "iss": creds["client_email"], + "scope": "https://www.googleapis.com/auth/compute", + "aud": "https://oauth2.googleapis.com/token", + "iat": now, + "exp": now + 3600, + } + signed = pyjwt.encode(payload, creds["private_key"], algorithm="RS256") + resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": signed, + }, + ) + else: + resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "client_id": creds["client_id"], + "client_secret": creds["client_secret"], + "refresh_token": creds["refresh_token"], + "grant_type": "refresh_token", + }, + ) + resp.raise_for_status() + data = resp.json() + _access_token = data["access_token"] + _token_expiry = time.time() + data.get("expires_in", 3600) + log.info(f"Refreshed GCP access token ({cred_type})") + return _access_token + + +# --- GCP Compute API --- + +COMPUTE_BASE = "https://compute.googleapis.com/compute/v1" + + +async def gcp_api(method: str, url: str, **kwargs) -> httpx.Response: + token = await get_access_token() + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.request( + method, url, + headers={"Authorization": f"Bearer {token}"}, + **kwargs, + ) + return resp + + +async def get_instance_info(zone: str, instance: str) -> dict | None: + url = f"{COMPUTE_BASE}/projects/{GCP_PROJECT}/zones/{zone}/instances/{instance}" + resp = await gcp_api("GET", url) + if resp.status_code == 404: + return None + if resp.status_code >= 400: + log.error(f"GCP API error {resp.status_code}: {resp.text}") + return None + return resp.json() + + +def extract_ip(instance_data: dict) -> str: + interfaces = instance_data.get("networkInterfaces", []) + if interfaces: + access = interfaces[0].get("accessConfigs", []) + if access: + return access[0].get("natIP", "") + return "" + + +async def start_instance_in_zone(zone: str, instance: str) -> bool: + url = f"{COMPUTE_BASE}/projects/{GCP_PROJECT}/zones/{zone}/instances/{instance}/start" + resp = await gcp_api("POST", url) + if resp.status_code < 400: + log.info(f"Start requested: {instance} in {zone}") + return True + log.warning(f"Failed to start {instance} in {zone}: {resp.status_code} {resp.text}") + return False + + +async def stop_instance_in_zone(zone: str, instance: str): + url = f"{COMPUTE_BASE}/projects/{GCP_PROJECT}/zones/{zone}/instances/{instance}/stop" + resp = await gcp_api("POST", url) + if resp.status_code < 400: + log.info(f"Stop requested: {instance} in {zone}") + else: + log.error(f"Failed to stop {instance} in {zone}: {resp.status_code}") + + +async def create_instance_from_snapshot(config: dict) -> bool: + zone = config["zone"] + instance = config["instance"] + machine = config["machine_type"] + accel = config["accelerator"] + accel_count = config["accel_count"] + + log.info(f"Creating {instance} in {zone} from snapshot...") + + body = { + "name": instance, + "machineType": f"zones/{zone}/machineTypes/{machine}", + "disks": [{ + "boot": True, + "autoDelete": True, + "initializeParams": { + "diskSizeGb": "50", + "diskType": f"zones/{zone}/diskTypes/pd-ssd", + "sourceSnapshot": f"global/snapshots/{SNAPSHOT_NAME}", + }, + }], + "networkInterfaces": [{ + "network": "global/networks/default", + "accessConfigs": [{"type": "ONE_TO_ONE_NAT", "name": "External NAT"}], + }], + "guestAccelerators": [{ + "acceleratorType": f"zones/{zone}/acceleratorTypes/{accel}", + "acceleratorCount": accel_count, + }], + "scheduling": { + "onHostMaintenance": "TERMINATE", + "automaticRestart": False, + }, + "tags": {"items": ["whisperx-gpu"]}, + "metadata": { + "items": [{"key": "startup-script", "value": STARTUP_SCRIPT}], + }, + } + + url = f"{COMPUTE_BASE}/projects/{GCP_PROJECT}/zones/{zone}/instances" + resp = await gcp_api("POST", url, json=body) + + if resp.status_code < 400: + log.info(f"Created {instance} in {zone}") + return True + + error_text = resp.text + if "ZONE_RESOURCE_POOL_EXHAUSTED" in error_text: + log.warning(f"No capacity in {zone} -- skipping") + elif "QUOTA" in error_text.upper(): + log.warning(f"Quota exceeded for {zone}: {error_text[:200]}") + else: + log.error(f"Failed to create in {zone}: {resp.status_code} {error_text[:200]}") + return False + + +# --- Core Logic --- + +async def wait_for_running(zone: str, instance: str, timeout: int = 120, grace: int = 15) -> bool: + gone_count = 0 + start_time = time.time() + for _ in range(timeout // 5): + info = await get_instance_info(zone, instance) + if info and info.get("status") == "RUNNING": + return True + status = info.get("status", "UNKNOWN") if info else "GONE" + elapsed = time.time() - start_time + if status == "GONE": + gone_count += 1 + if gone_count >= 2: + log.warning(f"{instance} in {zone}: instance disappeared (no capacity)") + return False + if status in ("STOPPING",): + log.warning(f"{instance} in {zone}: status {status} (no capacity)") + return False + if status in ("TERMINATED", "STOPPED") and elapsed > grace: + log.warning(f"{instance} in {zone}: status {status} after {elapsed:.0f}s (no capacity)") + return False + await asyncio.sleep(5) + return False + + +async def delete_instance(zone: str, instance: str): + url = f"{COMPUTE_BASE}/projects/{GCP_PROJECT}/zones/{zone}/instances/{instance}" + resp = await gcp_api("DELETE", url) + if resp.status_code < 400: + log.info(f"Deleted {instance} in {zone} to free quota") + elif resp.status_code == 404: + pass + else: + log.warning(f"Failed to delete {instance} in {zone}: {resp.status_code}") + + +async def ensure_gpu_running() -> str: + global gpu_ip, active_zone, _last_failure_time + + if _last_failure_time > 0: + remaining = FAILURE_COOLDOWN - (time.time() - _last_failure_time) + if remaining > 0: + log.info(f"GPU cooldown active ({int(remaining)}s remaining), waiting...") + await asyncio.sleep(remaining) + _last_failure_time = 0 + + async with _startup_lock: + ok, hours = check_budget() + if not ok: + raise RuntimeError(f"Monthly GPU limit reached ({hours:.1f}h / {MONTHLY_LIMIT_HOURS}h)") + + if active_zone: + info = await get_instance_info(active_zone["zone"], active_zone["instance"]) + if info and info.get("status") == "RUNNING": + gpu_ip = extract_ip(info) + if gpu_ip: + return gpu_ip + + errors = [] + + for config in ZONE_FALLBACKS: + zone = config["zone"] + instance = config["instance"] + label = config["label"] + + log.info(f"Trying {label}...") + info = await get_instance_info(zone, instance) + + if info is None: + created = await create_instance_from_snapshot(config) + if not created: + zone_status[label] = { + "status": "no_capacity", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": "no capacity", + } + errors.append(f"{label}: no capacity") + continue + if not await wait_for_running(zone, instance, grace=30): + zone_status[label] = { + "status": "error", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": "created but failed to start", + } + errors.append(f"{label}: created but failed to start") + await delete_instance(zone, instance) + await asyncio.sleep(3) + continue + else: + status = info.get("status", "UNKNOWN") + + if status == "RUNNING": + pass + elif status in ("TERMINATED", "STOPPED"): + zone_status[label] = { + "status": "starting", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": None, + } + started = await start_instance_in_zone(zone, instance) + if not started: + zone_status[label] = { + "status": "error", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": "start rejected", + } + errors.append(f"{label}: start rejected") + continue + if not await wait_for_running(zone, instance, grace=20): + zone_status[label] = { + "status": "error", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": "didn't reach RUNNING", + } + errors.append(f"{label}: didn't reach RUNNING") + continue + elif status in ("STAGING", "PROVISIONING"): + zone_status[label] = { + "status": "starting", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": None, + } + if not await wait_for_running(zone, instance): + zone_status[label] = { + "status": "error", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": f"stuck in {status}", + } + errors.append(f"{label}: stuck in {status}") + continue + elif status == "STOPPING": + log.info(f"{label}: STOPPING, deleting to free quota") + await delete_instance(zone, instance) + await asyncio.sleep(3) + zone_status[label] = { + "status": "error", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": "was STOPPING, deleted", + } + errors.append(f"{label}: was STOPPING, deleted") + continue + + info = await get_instance_info(zone, instance) + if info and info.get("status") == "RUNNING": + gpu_ip = extract_ip(info) + if gpu_ip: + active_zone = config + _last_failure_time = 0 + zone_status[label] = { + "status": "running", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": None, + } + stats = load_stats() + stats["last_start"] = time.time() + stats["requests"] = stats.get("requests", 0) + 1 + stats["active_zone"] = label + save_stats(stats) + log.info(f"GPU ready in {label}, IP: {gpu_ip}") + return gpu_ip + + zone_status[label] = { + "status": "error", + "last_tried": time.strftime("%Y-%m-%dT%H:%M:%S"), + "last_error": "running but no IP", + } + errors.append(f"{label}: running but no IP") + + _last_failure_time = time.time() + raise RuntimeError( + f"No GPU available in any Canadian zone. Tried: {'; '.join(errors)}" + ) + + +async def ensure_gpu_ready() -> str: + ip = await ensure_gpu_running() + url = f"http://{ip}:{WHISPERX_PORT}/health" + log.info(f"Waiting for WhisperX at {url}...") + async with httpx.AsyncClient(timeout=10) as client: + for _ in range(BOOT_TIMEOUT // HEALTH_POLL_INTERVAL): + try: + resp = await client.get(url) + if resp.status_code == 200: + log.info("WhisperX is healthy!") + return ip + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout): + pass + await asyncio.sleep(HEALTH_POLL_INTERVAL) + raise RuntimeError("WhisperX did not become healthy in time") + + +async def ensure_ollama_ready() -> str: + ip = await ensure_gpu_running() + url = f"http://{ip}:{OLLAMA_PORT}/api/tags" + log.info(f"Waiting for Ollama at {url}...") + async with httpx.AsyncClient(timeout=10) as client: + for _ in range(BOOT_TIMEOUT // HEALTH_POLL_INTERVAL): + try: + resp = await client.get(url) + if resp.status_code == 200: + log.info("Ollama is healthy!") + return ip + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout): + pass + await asyncio.sleep(HEALTH_POLL_INTERVAL) + raise RuntimeError("Ollama did not become healthy in time") + + +async def idle_shutdown_loop(): + while True: + await asyncio.sleep(60) + if last_request_time == 0 or active_zone is None: + continue + if active_requests > 0: + continue + elapsed = time.time() - last_request_time + if elapsed >= IDLE_TIMEOUT: + try: + zone = active_zone["zone"] + instance = active_zone["instance"] + label = active_zone["label"] + info = await get_instance_info(zone, instance) + if info and info.get("status") == "RUNNING": + log.info(f"Idle {int(elapsed)}s -- stopping {label}") + await stop_instance_in_zone(zone, instance) + track_gpu_time() + except Exception as e: + log.error(f"Error stopping: {e}") + + +# --- Endpoints --- + +@app.on_event("startup") +async def on_startup(): + global shutdown_task, _startup_lock + _startup_lock = asyncio.Lock() + await get_access_token() + shutdown_task = asyncio.create_task(idle_shutdown_loop()) + zones = ", ".join(c["label"] for c in ZONE_FALLBACKS) + log.info(f"DictIA ASR Proxy started. Zones: [{zones}]. Idle: {IDLE_TIMEOUT}s, limit: {MONTHLY_LIMIT_HOURS}h") + + +@app.post("/asr") +async def asr_proxy(request: Request): + global last_request_time, active_requests + + body = await request.body() + headers = { + k: v for k, v in request.headers.items() + if k.lower() not in ("host", "transfer-encoding") + } + + last_request_time = time.time() + active_requests += 1 + start_time = time.time() + result_status = 200 + try: + ip = await ensure_gpu_ready() + target = f"http://{ip}:{WHISPERX_PORT}/asr" + log.info(f"Forwarding {len(body)} bytes to {target}") + async with httpx.AsyncClient(timeout=httpx.Timeout(7200.0)) as client: + resp = await client.post(target, content=body, headers=headers) + last_request_time = time.time() + result_status = resp.status_code + ct = resp.headers.get("content-type", "") + if "application/json" in ct: + return JSONResponse(content=resp.json(), status_code=resp.status_code) + else: + return JSONResponse(content=resp.text, status_code=resp.status_code) + except httpx.ReadTimeout: + result_status = 504 + return JSONResponse({"error": "Transcription timeout (2h)"}, status_code=504) + except Exception as e: + result_status = 502 + log.error(f"Proxy error: {e}") + return JSONResponse({"error": str(e)}, status_code=502) + finally: + active_requests -= 1 + last_request_time = time.time() + request_history.insert(0, { + "time": time.strftime("%Y-%m-%dT%H:%M:%S"), + "type": "ASR", + "duration_sec": round(time.time() - start_time, 1), + "status": result_status, + "zone": active_zone["label"] if active_zone else "none", + }) + if len(request_history) > MAX_HISTORY: + request_history.pop() + + +@app.get("/health") +async def health(): + zone_label = active_zone["label"] if active_zone else "none" + gpu_status = "unknown" + if active_zone: + try: + info = await get_instance_info(active_zone["zone"], active_zone["instance"]) + gpu_status = info.get("status", "unknown") if info else "not_found" + except Exception: + pass + ok, hours = check_budget() + stats = load_stats() + return { + "proxy": "healthy", + "gpu_instance": gpu_status, + "gpu_zone": zone_label, + "active_requests": active_requests, + "idle_timeout": IDLE_TIMEOUT, + "usage": { + "month": stats.get("month"), + "gpu_hours": round(hours, 2), + "gpu_limit_hours": MONTHLY_LIMIT_HOURS, + "requests_count": stats.get("requests", 0), + "budget_ok": ok, + }, + "gpu_ip": gpu_ip, + "machine_type": active_zone.get("machine_type", "unknown") if active_zone else "unknown", + "gpu_model": active_zone.get("accelerator", "unknown") if active_zone else "unknown", + "idle_seconds": round(time.time() - last_request_time) if last_request_time > 0 else 0, + "auto_shutdown_in": max(0, IDLE_TIMEOUT - round(time.time() - last_request_time)) if last_request_time > 0 and active_zone else None, + "token_expires_in": round(_token_expiry - time.time()) if _token_expiry > 0 else None, + } + + +@app.get("/stats") +async def get_stats(): + stats = load_stats() + hours = stats.get("gpu_seconds", 0) / 3600 + gpu_cost = hours * GPU_COST_PER_HOUR + total_cost = gpu_cost + FIXED_MONTHLY_COST + return { + "month": stats.get("month"), + "gpu_hours": round(hours, 2), + "gpu_minutes": round(hours * 60, 1), + "estimated_cost_usd": round(total_cost, 2), + "gpu_cost_usd": round(gpu_cost, 2), + "fixed_cost_usd": FIXED_MONTHLY_COST, + "monthly_limit_hours": MONTHLY_LIMIT_HOURS, + "remaining_hours": round(MONTHLY_LIMIT_HOURS - hours, 2), + "requests_count": stats.get("requests", 0), + "active_zone": stats.get("active_zone", "none"), + "cost_per_hour": GPU_COST_PER_HOUR, + "recent_requests": request_history[:10], + "zone_fallbacks": [ + { + "label": config["label"], + "zone": config["zone"], + "machine": config["machine_type"], + "gpu": config["accelerator"], + **zone_status.get(config["label"], {"status": "unknown", "last_tried": None, "last_error": None}), + } + for config in ZONE_FALLBACKS + ], + } + + +@app.post("/gpu/start") +async def gpu_start(): + try: + ip = await ensure_gpu_ready() + label = active_zone["label"] if active_zone else "unknown" + return {"status": "running", "ip": ip, "zone": label} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=503) + + +@app.post("/gpu/stop") +async def gpu_stop(): + if not active_zone: + return {"status": "no active instance"} + try: + await stop_instance_in_zone(active_zone["zone"], active_zone["instance"]) + track_gpu_time() + return {"status": "stopped", "zone": active_zone["label"]} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + +DASHBOARD_HTML = Path(__file__).parent / "dashboard.html" + + +@app.get("/", response_class=HTMLResponse) +async def dashboard(): + if DASHBOARD_HTML.exists(): + return HTMLResponse(DASHBOARD_HTML.read_text(encoding="utf-8")) + return HTMLResponse("

Dashboard not found

Place dashboard.html next to proxy.py

", status_code=404) + + +@app.api_route("/v1/{path:path}", methods=["POST", "GET"]) +async def llm_proxy(request: Request, path: str): + global last_request_time, active_requests + + body = await request.body() + headers = { + k: v for k, v in request.headers.items() + if k.lower() not in ("host", "transfer-encoding") + } + + last_request_time = time.time() + active_requests += 1 + start_time = time.time() + result_status = 200 + try: + ip = await ensure_ollama_ready() + target = f"http://{ip}:{OLLAMA_PORT}/v1/{path}" + log.info(f"Forwarding LLM request to {target}") + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client: + resp = await client.request(request.method, target, content=body, headers=headers) + last_request_time = time.time() + result_status = resp.status_code + return Response( + content=resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type"), + ) + except httpx.ReadTimeout: + result_status = 504 + return JSONResponse({"error": "LLM timeout (5min)"}, status_code=504) + except Exception as e: + result_status = 502 + log.error(f"LLM proxy error: {e}") + return JSONResponse({"error": str(e)}, status_code=502) + finally: + active_requests -= 1 + last_request_time = time.time() + request_history.insert(0, { + "time": time.strftime("%Y-%m-%dT%H:%M:%S"), + "type": "LLM", + "duration_sec": round(time.time() - start_time, 1), + "status": result_status, + "zone": active_zone["label"] if active_zone else "none", + }) + if len(request_history) > MAX_HISTORY: + request_history.pop() + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=9090) diff --git a/deployment/asr-proxy/requirements.txt b/deployment/asr-proxy/requirements.txt new file mode 100644 index 0000000..f301f93 --- /dev/null +++ b/deployment/asr-proxy/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn==0.30.0 +httpx==0.27.0 +PyJWT==2.9.0 +cryptography>=43.0.0 diff --git a/deployment/asr-proxy/setup.sh b/deployment/asr-proxy/setup.sh new file mode 100644 index 0000000..f0d88f8 --- /dev/null +++ b/deployment/asr-proxy/setup.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# DictIA ASR Proxy — Setup script +# Installs the GCP GPU proxy for cloud deployments. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_DIR="${ASR_PROXY_DIR:-$SCRIPT_DIR}" +SERVICE_USER="${ASR_PROXY_USER:-$(whoami)}" + +echo "=== DictIA ASR Proxy Setup ===" +echo "Install directory: $INSTALL_DIR" +echo "Service user: $SERVICE_USER" +echo + +# 1. Create virtual environment +if [ ! -d "$INSTALL_DIR/venv" ]; then + echo "[1/4] Creating Python virtual environment..." + python3 -m venv "$INSTALL_DIR/venv" +else + echo "[1/4] Virtual environment already exists." +fi + +# 2. Install dependencies +echo "[2/4] Installing Python dependencies..." +"$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade pip +"$INSTALL_DIR/venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements.txt" + +# 3. GCP credentials +if [ ! -f "$INSTALL_DIR/gcp-credentials.json" ]; then + echo "[3/4] GCP credentials not found." + echo " Place your GCP service account or OAuth credentials at:" + echo " $INSTALL_DIR/gcp-credentials.json" + echo + echo " For service account: download JSON from GCP Console > IAM > Service Accounts" + echo " For user credentials: run 'gcloud auth application-default login' and copy the file" + echo + read -rp " Path to credentials file (or press Enter to skip): " CREDS_PATH + if [ -n "$CREDS_PATH" ] && [ -f "$CREDS_PATH" ]; then + cp "$CREDS_PATH" "$INSTALL_DIR/gcp-credentials.json" + chmod 600 "$INSTALL_DIR/gcp-credentials.json" + echo " Credentials copied." + else + echo " Skipped. You must add credentials before starting the proxy." + fi +else + echo "[3/4] GCP credentials found." +fi + +# 4. Install systemd service +echo "[4/4] Installing systemd service..." +SERVICE_FILE="/etc/systemd/system/asr-proxy.service" + +cat > /tmp/asr-proxy.service </dev/null 2>&1; then + echo "ERROR: Tailscale is not running or not connected." + echo " Install: curl -fsSL https://tailscale.com/install.sh | sh" + echo " Connect: sudo tailscale up" + exit 1 +fi + +HOSTNAME=$(tailscale status --json | python3 -c "import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))" 2>/dev/null || echo "unknown") +echo "Tailscale hostname: $HOSTNAME" +echo + +# DictIA app on :443 → localhost:8899 +echo "[1/2] Setting up DictIA app (port 443 → 8899)..." +if [ "$MODE" = "funnel" ]; then + tailscale funnel --bg --https=443 http://localhost:8899 +else + tailscale serve --bg --https=443 http://localhost:8899 +fi + +# ASR Proxy dashboard on :9443 → localhost:9090 +echo "[2/2] Setting up ASR Proxy dashboard (port 9443 → 9090)..." +if [ "$MODE" = "funnel" ]; then + tailscale funnel --bg --https=9443 http://localhost:9090 +else + tailscale serve --bg --https=9443 http://localhost:9090 +fi + +echo +echo "=== Setup complete ===" +echo "DictIA: https://$HOSTNAME/" +echo "ASR Dashboard: https://$HOSTNAME:9443/" +echo +echo "Verify with: tailscale serve status" diff --git a/deployment/docker/.env.example b/deployment/docker/.env.example new file mode 100644 index 0000000..fc204f9 --- /dev/null +++ b/deployment/docker/.env.example @@ -0,0 +1,124 @@ +# ============================================================================= +# DictIA — Unified Environment Configuration +# ============================================================================= +# +# Copy this file to the project root as .env and edit the values. +# cp deployment/docker/.env.example .env +# +# This template combines upstream settings with DictIA deployment vars. +# See: config/env.transcription.example for full upstream documentation. + +# ============================================================================= +# FLASK SECRET KEY (REQUIRED — auto-generated by setup.sh) +# ============================================================================= +SECRET_KEY=change-me-to-a-random-string + +# ============================================================================= +# DEPLOYMENT PROFILE (used by deployment scripts) +# ============================================================================= +# Options: cloud, local-cpu, local-gpu +DICTIA_PROFILE=cloud + +# ============================================================================= +# TEXT GENERATION MODEL (REQUIRED for summaries, titles, chat) +# ============================================================================= +TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +TEXT_MODEL_API_KEY=your_openrouter_api_key +TEXT_MODEL_NAME=openai/gpt-4o-mini + +# ============================================================================= +# TRANSCRIPTION CONFIGURATION +# ============================================================================= +# For cloud profile (ASR Proxy → GCP GPU): +# ASR_BASE_URL is set automatically in docker-compose.cloud.yml +# No need to set it here. +# +# For local profiles (WhisperX sidecar): +# ASR_BASE_URL is set automatically in docker-compose.local-*.yml +# No need to set it here. +# +# For OpenAI API instead of self-hosted ASR: +# TRANSCRIPTION_API_KEY=sk-your_openai_api_key +# TRANSCRIPTION_MODEL=gpt-4o-transcribe-diarize + +# ASR model (for local WhisperX profiles) +ASR_MODEL=large-v3 + +# HuggingFace token (required for diarization with pyannote) +# Get yours at: https://huggingface.co/settings/tokens +# Must accept: https://huggingface.co/pyannote/speaker-diarization-3.1 +HF_TOKEN= + +# ============================================================================= +# ASR PROXY — CLOUD PROFILE ONLY +# ============================================================================= +# GCP project for GPU instances +# GCP_PROJECT=your-gcp-project + +# Monthly GPU budget limit in hours (default: 50) +# MONTHLY_LIMIT_HOURS=50 + +# Idle timeout before auto-stopping GPU (seconds, default: 300) +# IDLE_TIMEOUT=300 + +# ============================================================================= +# APPLICATION SETTINGS +# ============================================================================= +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme + +ALLOW_REGISTRATION=false +TIMEZONE="America/Toronto" +LOG_LEVEL=ERROR +LOCALE=fr_CA +DEFAULT_LANGUAGE=fr +SHOW_USERNAMES_IN_UI=true +SESSION_COOKIE_HTTPONLY=true +SESSION_COOKIE_SAMESITE=Lax +SESSION_COOKIE_SECURE=true + +# ============================================================================= +# OPTIONAL FEATURES +# ============================================================================= +ENABLE_INQUIRE_MODE=false +ENABLE_AUTO_PROCESSING=false +ENABLE_AUTO_EXPORT=false +ENABLE_AUTO_DELETION=false +ENABLE_INTERNAL_SHARING=true +ENABLE_PUBLIC_SHARING=true +ENABLE_FOLDERS=true +VIDEO_RETENTION=true +USERS_CAN_DELETE=true + +# ============================================================================= +# BACKGROUND PROCESSING +# ============================================================================= +JOB_QUEUE_WORKERS=4 +SUMMARY_QUEUE_WORKERS=4 +JOB_MAX_RETRIES=3 +MAX_CONCURRENT_UPLOADS=3 + +# ============================================================================= +# TRANSCRIPTION SETTINGS +# ============================================================================= +TRANSCRIPTION_CONNECTOR=asr_endpoint +USE_NEW_TRANSCRIPTION_ARCHITECTURE=true +ENABLE_CHUNKING=true +CHUNK_LIMIT=2400s +CHUNK_OVERLAP_SECONDS=5 + +# ============================================================================= +# LLM / SUMMARY SETTINGS +# ============================================================================= +SUMMARY_LANGUAGE=fr +SUMMARY_MAX_TOKENS=16000 +CHAT_MAX_TOKENS=12000 +ENABLE_STREAM_OPTIONS=false +ENABLE_THINKING=false + +# ============================================================================= +# DOCKER/DATABASE +# ============================================================================= +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +UPLOAD_FOLDER=/data/uploads diff --git a/deployment/docker/docker-compose.cloud.yml b/deployment/docker/docker-compose.cloud.yml new file mode 100644 index 0000000..d4ae233 --- /dev/null +++ b/deployment/docker/docker-compose.cloud.yml @@ -0,0 +1,40 @@ +# DictIA — Cloud deployment (VPS + ASR Proxy GCP GPU) +# +# Usage: +# docker compose -f deployment/docker/docker-compose.cloud.yml up -d +# +# ASR is handled by the external asr-proxy (port 9090) which auto-starts +# a GCP GPU instance on demand. DictIA connects via host.docker.internal. + +services: + dictia: + build: + context: ../.. + dockerfile: Dockerfile + image: innova-ai/dictia:latest + container_name: dictia + restart: unless-stopped + ports: + - "8899:8899" + env_file: + - ../../.env + environment: + - LOG_LEVEL=${LOG_LEVEL:-ERROR} + - ASR_BASE_URL=http://host.docker.internal:9090 + volumes: + - ../../data/uploads:/data/uploads + - ../../data/instance:/data/instance + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - dictia-network + +networks: + dictia-network: + driver: bridge diff --git a/deployment/docker/docker-compose.local-cpu.yml b/deployment/docker/docker-compose.local-cpu.yml new file mode 100644 index 0000000..0a0f060 --- /dev/null +++ b/deployment/docker/docker-compose.local-cpu.yml @@ -0,0 +1,64 @@ +# DictIA — Local CPU deployment (WhisperX on CPU + DictIA) +# +# Usage: +# docker compose -f deployment/docker/docker-compose.local-cpu.yml up -d +# +# Warning: CPU transcription is significantly slower than GPU. +# Expect ~10x real-time for large files (e.g., 1h audio = ~10h processing). + +services: + whisperx-asr: + image: ghcr.io/jim60105/whisperx-asr:latest + container_name: whisperx-asr + restart: unless-stopped + ports: + - "9000:9000" + environment: + - ASR_MODEL=${ASR_MODEL:-large-v3} + - ASR_ENGINE=whisperx + - DEVICE=cpu + - COMPUTE_TYPE=float32 + - HF_TOKEN=${HF_TOKEN:-} + volumes: + - whisperx-cache:/root/.cache + deploy: + resources: + limits: + memory: 18G + networks: + - dictia-network + + dictia: + build: + context: ../.. + dockerfile: Dockerfile + image: innova-ai/dictia:latest + container_name: dictia + restart: unless-stopped + ports: + - "8899:8899" + env_file: + - ../../.env + environment: + - LOG_LEVEL=${LOG_LEVEL:-ERROR} + - ASR_BASE_URL=http://whisperx-asr:9000 + volumes: + - ../../data/uploads:/data/uploads + - ../../data/instance:/data/instance + depends_on: + - whisperx-asr + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - dictia-network + +volumes: + whisperx-cache: + +networks: + dictia-network: + driver: bridge diff --git a/deployment/docker/docker-compose.local-gpu.yml b/deployment/docker/docker-compose.local-gpu.yml new file mode 100644 index 0000000..488fd74 --- /dev/null +++ b/deployment/docker/docker-compose.local-gpu.yml @@ -0,0 +1,69 @@ +# DictIA — Local GPU deployment (WhisperX on NVIDIA GPU + DictIA) +# +# Usage: +# docker compose -f deployment/docker/docker-compose.local-gpu.yml up -d +# +# Prerequisites: +# - NVIDIA GPU with CUDA support +# - nvidia-container-toolkit installed +# - Docker configured with nvidia runtime + +services: + whisperx-asr: + image: ghcr.io/jim60105/whisperx-asr:latest-cuda + container_name: whisperx-asr + restart: unless-stopped + ports: + - "9000:9000" + environment: + - ASR_MODEL=${ASR_MODEL:-large-v3} + - ASR_ENGINE=whisperx + - DEVICE=cuda + - COMPUTE_TYPE=float16 + - HF_TOKEN=${HF_TOKEN:-} + volumes: + - whisperx-cache:/root/.cache + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + networks: + - dictia-network + + dictia: + build: + context: ../.. + dockerfile: Dockerfile + image: innova-ai/dictia:latest + container_name: dictia + restart: unless-stopped + ports: + - "8899:8899" + env_file: + - ../../.env + environment: + - LOG_LEVEL=${LOG_LEVEL:-ERROR} + - ASR_BASE_URL=http://whisperx-asr:9000 + volumes: + - ../../data/uploads:/data/uploads + - ../../data/instance:/data/instance + depends_on: + - whisperx-asr + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - dictia-network + +volumes: + whisperx-cache: + +networks: + dictia-network: + driver: bridge diff --git a/deployment/docs/LOCAL-SETUP.md b/deployment/docs/LOCAL-SETUP.md new file mode 100644 index 0000000..f534972 --- /dev/null +++ b/deployment/docs/LOCAL-SETUP.md @@ -0,0 +1,118 @@ +# Setup Local — DictIA + +Guide pour deployer DictIA localement avec GPU NVIDIA ou CPU. + +## Profil local-gpu + +### Prerequis + +- NVIDIA GPU avec support CUDA +- [nvidia-container-toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) +- Docker + Docker Compose V2 +- 8GB+ RAM (16GB recommande) +- Token HuggingFace (pour la diarisation) + +### Installation nvidia-container-toolkit + +```bash +# Ubuntu/Debian +curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \ + sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg +curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ + sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ + sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list +sudo apt-get update +sudo apt-get install -y nvidia-container-toolkit +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker + +# Verifier +docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi +``` + +### Setup DictIA + +```bash +cd dictia +bash deployment/setup.sh --profile local-gpu +``` + +Le setup va verifier: +- nvidia-container-toolkit installe +- GPU accessible depuis Docker +- Assez de RAM disponible + +### Configuration du modele + +Par defaut, WhisperX utilise `large-v3`. Pour changer: + +```bash +# Editer .env +ASR_MODEL=large-v3 # Meilleure qualite +# ASR_MODEL=medium # Plus rapide, qualite correcte +# ASR_MODEL=small # Tres rapide, qualite reduite +``` + +--- + +## Profil local-cpu + +### Prerequis + +- Docker + Docker Compose V2 +- 18GB+ RAM (WhisperX CPU est gourmand) +- Patience (transcription ~10x temps reel) + +### Setup + +```bash +cd dictia +bash deployment/setup.sh --profile local-cpu +``` + +### Limitations + +- Transcription lente: 1h d'audio prend ~10h +- Utilise float32 (pas de GPU acceleration) +- Limite memoire a 18GB par defaut +- Recommande pour: tests, petits fichiers, demos + +Pour reduire l'utilisation memoire, utiliser un modele plus petit: + +```bash +# Editer .env +ASR_MODEL=small # ou medium, base, tiny +``` + +--- + +## Verification + +```bash +# Health check +bash deployment/tools/health-check.sh + +# Test rapide: ouvrir le navigateur +open http://localhost:8899 + +# Verifier WhisperX +curl http://localhost:9000/health +``` + +## Gestion des containers + +```bash +COMPOSE_FILE=deployment/docker/docker-compose.local-gpu.yml # ou local-cpu + +# Logs +docker compose -f $COMPOSE_FILE logs -f + +# Redemarrer +docker compose -f $COMPOSE_FILE restart + +# Arreter +docker compose -f $COMPOSE_FILE down + +# Voir l'utilisation GPU +nvidia-smi # (profil GPU seulement) +``` diff --git a/deployment/docs/MAINTENANCE.md b/deployment/docs/MAINTENANCE.md new file mode 100644 index 0000000..f43b963 --- /dev/null +++ b/deployment/docs/MAINTENANCE.md @@ -0,0 +1,136 @@ +# Maintenance — DictIA + +## Backup + +```bash +# Backup complet (data, .env, volumes, stats ASR) +bash deployment/tools/backup.sh + +# Backup dans un repertoire specifique +bash deployment/tools/backup.sh /mnt/backups +``` + +Les backups sont sauvegardes dans `backups/` avec rotation automatique (garde les 5 derniers). + +Contenu d'un backup: +- `data/` — uploads et base de donnees SQLite +- `dot-env` — fichier de configuration +- `asr-usage-stats.json` — stats d'utilisation GPU +- `whisperx-cache.tar.gz` — cache modeles (si volume Docker) +- `manifest.json` — metadonnees du backup + +### Schedule recommande + +| Frequence | Action | +|-----------|--------| +| Quotidien | `bash deployment/tools/backup.sh` | +| Hebdomadaire | Copier le backup sur un stockage externe | +| Mensuel | Verifier la restauration sur un environnement de test | + +Pour automatiser avec cron: + +```bash +# Backup quotidien a 3h du matin +0 3 * * * /opt/dictia/deployment/tools/backup.sh >> /var/log/dictia-backup.log 2>&1 +``` + +## Restore + +```bash +# Lister les backups disponibles +ls -la backups/ + +# Restaurer un backup +bash deployment/tools/restore.sh backups/dictia-20260211-030000.tar.gz +``` + +Le script: +1. Valide l'archive (presence du manifest) +2. Demande confirmation +3. Arrete les containers +4. Restaure les fichiers +5. Redemarre les containers + +## Mise a jour + +```bash +# Mise a jour complete (git pull + rebuild + restart) +bash deployment/tools/update.sh + +# Rebuild seulement (sans git pull) +bash deployment/tools/update.sh --no-pull + +# Git pull seulement (sans rebuild) +bash deployment/tools/update.sh --no-build +``` + +Le script: +1. Detecte le profil actif automatiquement +2. `git pull origin dictia-branding` +3. `docker build -t innova-ai/dictia:latest .` +4. Pull WhisperX upstream (profils locaux) +5. `docker compose down && up -d` +6. Attend le health check +7. Nettoie les images dangling + +## Monitoring + +### Health check + +```bash +# Diagnostic complet (humain) +bash deployment/tools/health-check.sh + +# JSON (pour alertes/scripts) +bash deployment/tools/health-check.sh --json + +# Code de sortie seulement (0=ok, 1=probleme) +bash deployment/tools/health-check.sh --quiet +``` + +### Logs + +```bash +# DictIA +docker logs dictia -f --tail 100 + +# WhisperX (profils locaux) +docker logs whisperx-asr -f --tail 100 + +# ASR Proxy (profil cloud) +journalctl -u asr-proxy -f +``` + +### Dashboard GPU (profil cloud) + +Le dashboard de monitoring GPU est accessible a: +- `http://localhost:9090` (local) +- `https://votre-hostname.tailnet.ts.net:9443` (Tailscale) + +Affiche: statut GPU, cout mensuel, historique des requetes, zones de fallback. + +### Metriques cles + +```bash +# Espace disque (les transcriptions grossissent) +df -h /opt/dictia/data/ + +# Utilisation memoire (WhisperX est gourmand) +docker stats --no-stream + +# Stats GPU (profil cloud) +curl -s http://localhost:9090/stats | python3 -m json.tool +``` + +## Maintenance Docker + +```bash +# Nettoyer les images orphelines +docker image prune -f + +# Nettoyer tout (attention: supprime les volumes non utilises) +# docker system prune -a --volumes + +# Verifier l'espace Docker +docker system df +``` diff --git a/deployment/docs/QUICKSTART.md b/deployment/docs/QUICKSTART.md new file mode 100644 index 0000000..f057175 --- /dev/null +++ b/deployment/docs/QUICKSTART.md @@ -0,0 +1,90 @@ +# Quickstart — DictIA + +## Prerequis communs + +- Docker + Docker Compose V2 +- Git +- 2GB+ RAM disponible + +```bash +git clone https://gitea.innova-ai.ca/Innova-AI/dictia.git +cd dictia +git checkout dictia-branding +``` + +--- + +## Profil Cloud (VPS + GCP GPU) + +Le GPU demarre automatiquement quand quelqu'un transcrit, et s'arrete apres 5 min d'inactivite. + +```bash +# 1. Setup interactif +bash deployment/setup.sh --profile cloud + +# 2. Setup ASR Proxy (GCP credentials requises) +bash deployment/asr-proxy/setup.sh + +# 3. Optionnel: Tailscale Serve pour HTTPS +bash deployment/config/tailscale/setup-serve.sh +``` + +**Requis**: credentials GCP (service account ou OAuth) dans `deployment/asr-proxy/gcp-credentials.json`. + +--- + +## Profil Local GPU + +Transcription locale sur GPU NVIDIA. Le plus rapide. + +```bash +# Prerequis: nvidia-container-toolkit +# https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html + +# Setup +bash deployment/setup.sh --profile local-gpu +``` + +**Requis**: token HuggingFace pour la diarisation (pyannote). + +--- + +## Profil Local CPU + +Transcription sur CPU. Lent mais fonctionnel pour tester. + +```bash +bash deployment/setup.sh --profile local-cpu +``` + +Prevoir ~10x le temps reel (1h audio = ~10h de traitement). + +--- + +## Apres l'installation + +```bash +# Verifier que tout fonctionne +bash deployment/tools/health-check.sh + +# Ouvrir DictIA +open http://localhost:8899 +``` + +Se connecter avec les identifiants admin configures pendant le setup. + +## Commandes utiles + +```bash +# Logs en temps reel +docker compose -f deployment/docker/docker-compose..yml logs -f + +# Redemarrer +docker compose -f deployment/docker/docker-compose..yml restart + +# Mise a jour +bash deployment/tools/update.sh + +# Backup +bash deployment/tools/backup.sh +``` diff --git a/deployment/docs/TROUBLESHOOTING.md b/deployment/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..d733a7c --- /dev/null +++ b/deployment/docs/TROUBLESHOOTING.md @@ -0,0 +1,177 @@ +# Troubleshooting — DictIA + +## WhisperX OOM (Out of Memory) + +**Symptome**: Container `whisperx-asr` crash ou restart en boucle. + +**Cause**: Modele trop gros pour la RAM/VRAM disponible. + +**Solutions**: +```bash +# Utiliser un modele plus petit dans .env +ASR_MODEL=medium # au lieu de large-v3 + +# Augmenter la limite memoire (local-cpu) +# Editer docker-compose.local-cpu.yml +deploy: + resources: + limits: + memory: 24G # au lieu de 18G +``` + +## Diarisation 403 Forbidden + +**Symptome**: Erreur 403 lors de la transcription avec diarisation. + +**Cause**: Token HuggingFace manquant ou conditions non acceptees. + +**Solution**: +1. Creer un token: https://huggingface.co/settings/tokens +2. Accepter les conditions: https://huggingface.co/pyannote/speaker-diarization-3.1 +3. Ajouter dans `.env`: +```bash +HF_TOKEN=hf_votre_token +``` +4. Redemarrer: `docker compose -f deployment/docker/docker-compose..yml restart` + +## GPU non detecte (local-gpu) + +**Symptome**: `nvidia-smi` fonctionne mais Docker ne voit pas le GPU. + +**Solution**: +```bash +# Installer nvidia-container-toolkit +sudo apt install -y nvidia-container-toolkit +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker + +# Verifier +docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi +``` + +## Upload echoue (fichiers volumineux) + +**Symptome**: Upload de gros fichiers (>100MB) echoue. + +**Causes possibles**: +- Timeout Nginx/reverse proxy +- Limite upload trop basse + +**Solutions**: +```bash +# Si Nginx: verifier client_max_body_size dans dictia.conf +client_max_body_size 500M; + +# Si Tailscale Serve: pas de limite cote Tailscale + +# Timeout gunicorn (dans le Dockerfile, deja a 600s) +# Pour des fichiers tres longs, augmenter dans docker-compose: +environment: + - GUNICORN_TIMEOUT=1200 +``` + +## Container dictia "unhealthy" + +**Symptome**: `docker ps` montre "unhealthy" pour le container dictia. + +**Diagnostic**: +```bash +# Voir les logs +docker logs dictia --tail 50 + +# Tester manuellement +docker exec dictia python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8899/health')" +``` + +**Causes courantes**: +- `.env` mal configure (SECRET_KEY manquant) +- Base de donnees corrompue (restaurer backup) +- Port 8899 deja utilise + +## ASR Proxy: "No GPU available" + +**Symptome**: Transcription echoue avec "No GPU available in any Canadian zone". + +**Causes**: +- GCP n'a pas de GPU disponible (capacite epuisee) +- Credentials GCP expirees +- Budget mensuel atteint + +**Diagnostic**: +```bash +# Verifier le statut du proxy +curl -s http://localhost:9090/health | python3 -m json.tool + +# Verifier les stats (budget) +curl -s http://localhost:9090/stats | python3 -m json.tool + +# Voir les logs +journalctl -u asr-proxy --since "1 hour ago" +``` + +**Solutions**: +- Attendre (GCP libere des GPUs regulierement) +- Le proxy reessaie automatiquement apres un cooldown de 3 minutes +- Verifier le dashboard: http://localhost:9090 + +## Build Docker lent/echoue + +**Symptome**: `docker build` prend trop de temps ou echoue. + +**Solutions**: +```bash +# Limiter les ressources si le VPS est petit +docker build --memory=2g --cpus=2 -t innova-ai/dictia:latest . + +# Nettoyer le cache Docker si le disque est plein +docker builder prune -f +docker image prune -f +``` + +## Base de donnees corrompue + +**Symptome**: Erreur SQLite au demarrage. + +**Solution**: +```bash +# Restaurer le dernier backup +bash deployment/tools/restore.sh backups/dictia-LATEST.tar.gz + +# Ou recreer la base (perd les donnees) +rm data/instance/transcriptions.db +docker compose -f deployment/docker/docker-compose..yml restart +``` + +## Port 8899 deja utilise + +```bash +# Trouver qui utilise le port +sudo lsof -i :8899 +# ou +sudo ss -tlnp | grep 8899 + +# Arreter le processus ou changer le port dans docker-compose +ports: + - "8900:8899" # utiliser 8900 a la place +``` + +## Mise a jour qui casse tout + +```bash +# Rollback: revenir au commit precedent +cd dictia +git log --oneline -5 # trouver le bon commit +git checkout + +# Rebuild et redemarrer +docker build -t innova-ai/dictia:latest . +docker compose -f deployment/docker/docker-compose..yml down +docker compose -f deployment/docker/docker-compose..yml up -d +``` + +## Commande de diagnostic rapide + +```bash +# Tout verifier d'un coup +bash deployment/tools/health-check.sh --json | python3 -m json.tool +``` diff --git a/deployment/docs/VPS-SETUP.md b/deployment/docs/VPS-SETUP.md new file mode 100644 index 0000000..deff17d --- /dev/null +++ b/deployment/docs/VPS-SETUP.md @@ -0,0 +1,148 @@ +# Setup VPS from scratch — DictIA + +Guide complet pour deployer DictIA sur un VPS Ubuntu. +Teste sur OVH VPS avec Ubuntu 22.04/24.04. + +## 1. Preparation du VPS + +```bash +# Mise a jour systeme +sudo apt update && sudo apt upgrade -y + +# Installer les essentiels +sudo apt install -y curl git +``` + +## 2. Docker + +```bash +# Installer Docker (methode officielle) +curl -fsSL https://get.docker.com | sh + +# Ajouter l'utilisateur au groupe docker +sudo usermod -aG docker $USER + +# Se reconnecter pour appliquer le groupe +exit +# (reconnecter via SSH) + +# Verifier +docker --version +docker compose version +``` + +## 3. Tailscale (recommande) + +Tailscale fournit un VPN mesh pour acceder au VPS sans exposer de ports publics. + +```bash +# Installer Tailscale +curl -fsSL https://tailscale.com/install.sh | sh + +# Connecter au tailnet +sudo tailscale up + +# Verifier +tailscale status +``` + +## 4. DictIA + +```bash +# Cloner le repo +cd ~ +git clone https://gitea.innova-ai.ca/Innova-AI/dictia.git +cd dictia +git checkout dictia-branding + +# Lancer le setup +bash deployment/setup.sh --profile cloud +``` + +Le setup va: +- Generer le `.env` avec vos identifiants +- Creer les repertoires de donnees +- Builder l'image Docker +- Demarrer les containers + +## 5. ASR Proxy (GCP GPU) + +```bash +# Installer le proxy +bash deployment/asr-proxy/setup.sh + +# Ajouter les credentials GCP +# Copier votre fichier de credentials dans: +cp ~/gcp-credentials.json deployment/asr-proxy/gcp-credentials.json + +# Demarrer le service +sudo systemctl start asr-proxy +sudo systemctl status asr-proxy +``` + +## 6. Securite + +```bash +# Docker daemon config (log rotation) +sudo cp deployment/security/docker-daemon.json /etc/docker/daemon.json +sudo systemctl restart docker + +# Firewall iptables (bloque trafic non-Tailscale) +sudo bash deployment/security/iptables-rules.sh + +# Service systemd pour les regles au boot +sudo cp deployment/security/docker-iptables.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable docker-iptables +``` + +## 7. Tailscale Serve (HTTPS) + +```bash +# Expose DictIA et le dashboard ASR via Tailscale HTTPS +bash deployment/config/tailscale/setup-serve.sh + +# Verifier +tailscale serve status +``` + +DictIA sera accessible a `https://votre-hostname.tailnet.ts.net/`. + +## 8. Service systemd (auto-start) + +```bash +# Adapter le chemin dans le fichier si necessaire +sudo cp deployment/config/systemd/dictia.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable dictia +``` + +## 9. Verification + +```bash +# Health check complet +bash deployment/tools/health-check.sh + +# Verifier les endpoints +curl -s http://localhost:8899/health +curl -s http://localhost:9090/health +``` + +## 10. Premier backup + +```bash +bash deployment/tools/backup.sh +``` + +--- + +## Checklist post-installation + +- [ ] DictIA repond sur :8899 +- [ ] ASR Proxy repond sur :9090 +- [ ] Tailscale Serve configure +- [ ] iptables: seul Tailscale peut acceder +- [ ] Docker: log rotation configuree +- [ ] Service systemd enable (auto-start au boot) +- [ ] Premier backup effectue +- [ ] Identifiants admin testes diff --git a/deployment/profiles/docker-compose.dictia16.yml b/deployment/profiles/docker-compose.dictia16.yml new file mode 100644 index 0000000..a553bb7 --- /dev/null +++ b/deployment/profiles/docker-compose.dictia16.yml @@ -0,0 +1,101 @@ +# ============================================================================= +# DictIA 16 — Docker Compose +# GPU : RTX 5070 Ti (16 Go VRAM) +# ============================================================================= +# +# Services : +# - dictia : Application principale DictIA +# - whisperx-asr : Service de transcription WhisperX Large-v3 +# - ollama : LLM local Mistral 7B (résumés, chat, Q&A) +# +# Démarrage : +# 1. cp config/env.dictia16.example .env +# 2. docker compose -f config/docker-compose.dictia16.yml up -d +# 3. Télécharger Mistral : docker exec ollama ollama pull mistral +# +# Note : Aucune clé API nécessaire — tout tourne en local (100% privé). +# ============================================================================= + +services: + + # --------------------------------------------------------------------------- + # Application DictIA + # --------------------------------------------------------------------------- + dictia: + image: dictia:latest + container_name: dictia + restart: unless-stopped + ports: + - "8899:8899" + env_file: + - ../.env + environment: + - LOG_LEVEL=ERROR + volumes: + - ../uploads:/data/uploads + - ../instance:/data/instance + # Décommenter pour l'export automatique : + # - ../exports:/data/exports + # Décommenter pour le traitement automatique : + # - ../auto-process:/data/auto-process + depends_on: + - whisperx-asr + - ollama + networks: + - dictia-net + + # --------------------------------------------------------------------------- + # WhisperX ASR — Transcription locale (WhisperX Large-v3) + # RTX 5070 Ti : BATCH_SIZE=32, COMPUTE_TYPE=float16 + # --------------------------------------------------------------------------- + whisperx-asr: + image: murtazanasir/whisperx-asr-service:latest + container_name: whisperx-asr + restart: unless-stopped + environment: + - HF_TOKEN=${HF_TOKEN} + - DEVICE=cuda + - COMPUTE_TYPE=float16 + - BATCH_SIZE=32 + - DEFAULT_MODEL=large-v3 + volumes: + - whisperx-models:/root/.cache + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + networks: + - dictia-net + + # --------------------------------------------------------------------------- + # Ollama — LLM local Mistral 7B + # Résumés, points d'action, Q&A — 100% local, aucune donnée externe + # --------------------------------------------------------------------------- + ollama: + image: ollama/ollama:latest + container_name: ollama + restart: unless-stopped + volumes: + - ollama-models:/root/.ollama + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + networks: + - dictia-net + +networks: + dictia-net: + driver: bridge + +volumes: + whisperx-models: + driver: local + ollama-models: + driver: local diff --git a/deployment/profiles/docker-compose.dictia8.yml b/deployment/profiles/docker-compose.dictia8.yml new file mode 100644 index 0000000..dc4c6ed --- /dev/null +++ b/deployment/profiles/docker-compose.dictia8.yml @@ -0,0 +1,75 @@ +# ============================================================================= +# DictIA 8 — Docker Compose +# GPU : RTX 5060 (8 Go VRAM) +# ============================================================================= +# +# Services : +# - dictia : Application principale DictIA +# - whisperx-asr : Service de transcription WhisperX Large-v3 +# +# Démarrage : +# 1. cp config/env.dictia8.example .env +# 2. Remplir TEXT_MODEL_API_KEY dans .env +# 3. docker compose -f config/docker-compose.dictia8.yml up -d +# ============================================================================= + +services: + + # --------------------------------------------------------------------------- + # Application DictIA + # --------------------------------------------------------------------------- + dictia: + image: dictia:latest + container_name: dictia + restart: unless-stopped + ports: + - "8899:8899" + env_file: + - ../.env + environment: + - LOG_LEVEL=ERROR + volumes: + - ../uploads:/data/uploads + - ../instance:/data/instance + # Décommenter pour l'export automatique : + # - ../exports:/data/exports + # Décommenter pour le traitement automatique : + # - ../auto-process:/data/auto-process + depends_on: + - whisperx-asr + networks: + - dictia-net + + # --------------------------------------------------------------------------- + # WhisperX ASR — Transcription locale (WhisperX Large-v3) + # RTX 5060 : BATCH_SIZE=16, COMPUTE_TYPE=float16 + # --------------------------------------------------------------------------- + whisperx-asr: + image: murtazanasir/whisperx-asr-service:latest + container_name: whisperx-asr + restart: unless-stopped + environment: + - HF_TOKEN=${HF_TOKEN} + - DEVICE=cuda + - COMPUTE_TYPE=float16 + - BATCH_SIZE=16 + - DEFAULT_MODEL=large-v3 + volumes: + - whisperx-models:/root/.cache + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + networks: + - dictia-net + +networks: + dictia-net: + driver: bridge + +volumes: + whisperx-models: + driver: local diff --git a/deployment/profiles/env.dictia16.example b/deployment/profiles/env.dictia16.example new file mode 100644 index 0000000..8335fe1 --- /dev/null +++ b/deployment/profiles/env.dictia16.example @@ -0,0 +1,134 @@ +# ============================================================================= +# DictIA 16 — Configuration (.env) +# GPU : RTX 5070 Ti (16 Go VRAM) +# ============================================================================= +# +# Architecture : +# - Transcription : WhisperX Large-v3 (local, ~5,5 Go VRAM) +# - LLM (résumés) : Mistral 7B local via Ollama (~6,4 Go VRAM) +# - Mode : Séquentiel (transcription puis résumé) +# - Total VRAM : ~11,9 Go / 16 Go (marge ~4,1 Go) +# +# Démarrage rapide : +# 1. cp config/env.dictia16.example .env +# 2. Aucune clé API nécessaire — tout tourne en local +# 3. docker compose -f config/docker-compose.dictia16.yml up -d +# ============================================================================= + +# ============================================================================= +# MODÈLE DE TEXTE — Résumés, titres, chat (LLM LOCAL) +# ============================================================================= +# DictIA 16 utilise Mistral 7B en local via Ollama. +# Aucune donnée ne quitte le serveur — 100% privé. + +TEXT_MODEL_BASE_URL=http://ollama:11434/v1 +TEXT_MODEL_API_KEY=not-required +TEXT_MODEL_NAME=mistral + +# --- Modèle de chat séparé (optionnel) --- +# Même modèle par défaut, mais peut être changé pour un modèle plus rapide. +# CHAT_MODEL_API_KEY=not-required +# CHAT_MODEL_BASE_URL=http://ollama:11434/v1 +# CHAT_MODEL_NAME=mistral + +# ============================================================================= +# TRANSCRIPTION — WhisperX ASR local (REQUIS) +# ============================================================================= +# WhisperX tourne en local dans un conteneur Docker séparé. +# Le service ASR est défini dans docker-compose.dictia16.yml. + +ASR_BASE_URL=http://whisperx-asr:9000 + +# Diarisation (identification automatique des locuteurs) — recommandé +ASR_DIARIZE=true +ASR_RETURN_SPEAKER_EMBEDDINGS=true + +# Nombre de locuteurs attendus (optionnel — aide la précision) +# ASR_MIN_SPEAKERS=1 +# ASR_MAX_SPEAKERS=6 + +# ============================================================================= +# PARAMÈTRES ADMINISTRATEUR +# ============================================================================= +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@votreentreprise.com +ADMIN_PASSWORD=changeme + +# ============================================================================= +# ACCÈS ET INSCRIPTION +# ============================================================================= +# Désactiver l'inscription publique (accès sur invitation uniquement) +ALLOW_REGISTRATION=false + +# Restreindre l'inscription aux domaines autorisés +# Exemple : REGISTRATION_ALLOWED_DOMAINS=votreentreprise.com +REGISTRATION_ALLOWED_DOMAINS= + +# ============================================================================= +# FUSEAU HORAIRE +# ============================================================================= +# Exemples : America/Toronto, America/Montreal, America/New_York, UTC +TIMEZONE="America/Toronto" + +# ============================================================================= +# LIMITES DE TOKENS +# ============================================================================= +SUMMARY_MAX_TOKENS=8000 +CHAT_MAX_TOKENS=5000 + +# ============================================================================= +# COMPRESSION AUDIO +# ============================================================================= +AUDIO_COMPRESS_UPLOADS=true +AUDIO_CODEC=mp3 +AUDIO_BITRATE=128k + +# ============================================================================= +# FONCTIONNALITÉS OPTIONNELLES +# ============================================================================= + +# Inquire Mode — recherche IA sur tous les enregistrements +# Peut être activé sur DictIA 16 (plus de VRAM disponible) +ENABLE_INQUIRE_MODE=false + +# Traitement automatique de fichiers (dossier surveillé) +ENABLE_AUTO_PROCESSING=false +# AUTO_PROCESS_MODE=admin_only +# AUTO_PROCESS_WATCH_DIR=/data/auto-process + +# Export automatique +ENABLE_AUTO_EXPORT=false +# AUTO_EXPORT_DIR=/data/exports +# AUTO_EXPORT_TRANSCRIPTION=true +# AUTO_EXPORT_SUMMARY=true + +# Suppression automatique / rétention +ENABLE_AUTO_DELETION=false +# GLOBAL_RETENTION_DAYS=90 +# DELETION_MODE=audio_only + +# ============================================================================= +# PARTAGE +# ============================================================================= +ENABLE_INTERNAL_SHARING=false +ENABLE_PUBLIC_SHARING=true +USERS_CAN_DELETE=true + +# ============================================================================= +# FILES D'ATTENTE DE TRAITEMENT +# ============================================================================= +JOB_QUEUE_WORKERS=2 +SUMMARY_QUEUE_WORKERS=2 +JOB_MAX_RETRIES=3 + +# ============================================================================= +# BASE DE DONNÉES ET STOCKAGE +# ============================================================================= +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +UPLOAD_FOLDER=/data/uploads + +# ============================================================================= +# JOURNALISATION +# ============================================================================= +# ERROR = production (minimal), INFO = débogage, DEBUG = développement +LOG_LEVEL=ERROR diff --git a/deployment/profiles/env.dictia8.example b/deployment/profiles/env.dictia8.example new file mode 100644 index 0000000..3efbbe5 --- /dev/null +++ b/deployment/profiles/env.dictia8.example @@ -0,0 +1,126 @@ +# ============================================================================= +# DictIA 8 — Configuration (.env) +# GPU : RTX 5060 (8 Go VRAM) +# ============================================================================= +# +# Architecture : +# - Transcription : WhisperX Large-v3 (local, ~5,5 Go VRAM) +# - LLM (résumés) : API cloud via OpenRouter (VRAM insuffisante pour LLM local) +# +# Démarrage rapide : +# 1. cp config/env.dictia8.example .env +# 2. Remplir TRANSCRIPTION_API_KEY et TEXT_MODEL_API_KEY +# 3. docker compose -f config/docker-compose.dictia8.yml up -d +# ============================================================================= + +# ============================================================================= +# MODÈLE DE TEXTE — Résumés, titres, chat (REQUIS) +# ============================================================================= +# DictIA 8 utilise un LLM cloud via OpenRouter (VRAM insuffisante pour LLM local). +# Inscrivez-vous sur https://openrouter.ai pour obtenir une clé API. + +TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1 +TEXT_MODEL_API_KEY=votre_cle_openrouter +TEXT_MODEL_NAME=openai/gpt-4o-mini + +# ============================================================================= +# TRANSCRIPTION — WhisperX ASR local (REQUIS) +# ============================================================================= +# WhisperX tourne en local dans un conteneur Docker séparé. +# Le service ASR est défini dans docker-compose.dictia8.yml. + +ASR_BASE_URL=http://whisperx-asr:9000 + +# Diarisation (identification automatique des locuteurs) — recommandé +ASR_DIARIZE=true +ASR_RETURN_SPEAKER_EMBEDDINGS=true + +# Nombre de locuteurs attendus (optionnel — aide la précision) +# ASR_MIN_SPEAKERS=1 +# ASR_MAX_SPEAKERS=6 + +# ============================================================================= +# PARAMÈTRES ADMINISTRATEUR +# ============================================================================= +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@votreentreprise.com +ADMIN_PASSWORD=changeme + +# ============================================================================= +# ACCÈS ET INSCRIPTION +# ============================================================================= +# Désactiver l'inscription publique (accès sur invitation uniquement) +ALLOW_REGISTRATION=false + +# Restreindre l'inscription aux domaines autorisés +# Exemple : REGISTRATION_ALLOWED_DOMAINS=votreentreprise.com +REGISTRATION_ALLOWED_DOMAINS= + +# ============================================================================= +# FUSEAU HORAIRE +# ============================================================================= +# Exemples : America/Toronto, America/Montreal, America/New_York, UTC +TIMEZONE="America/Toronto" + +# ============================================================================= +# LIMITES DE TOKENS +# ============================================================================= +SUMMARY_MAX_TOKENS=8000 +CHAT_MAX_TOKENS=5000 + +# ============================================================================= +# COMPRESSION AUDIO +# ============================================================================= +AUDIO_COMPRESS_UPLOADS=true +AUDIO_CODEC=mp3 +AUDIO_BITRATE=128k + +# ============================================================================= +# FONCTIONNALITÉS OPTIONNELLES +# ============================================================================= + +# Inquire Mode — recherche IA sur tous les enregistrements +# Désactivé sur DictIA 8 (VRAM insuffisante pour embeddings locaux) +ENABLE_INQUIRE_MODE=false + +# Traitement automatique de fichiers (dossier surveillé) +ENABLE_AUTO_PROCESSING=false +# AUTO_PROCESS_MODE=admin_only +# AUTO_PROCESS_WATCH_DIR=/data/auto-process + +# Export automatique +ENABLE_AUTO_EXPORT=false +# AUTO_EXPORT_DIR=/data/exports +# AUTO_EXPORT_TRANSCRIPTION=true +# AUTO_EXPORT_SUMMARY=true + +# Suppression automatique / rétention +ENABLE_AUTO_DELETION=false +# GLOBAL_RETENTION_DAYS=90 +# DELETION_MODE=audio_only + +# ============================================================================= +# PARTAGE +# ============================================================================= +ENABLE_INTERNAL_SHARING=false +ENABLE_PUBLIC_SHARING=true +USERS_CAN_DELETE=true + +# ============================================================================= +# FILES D'ATTENTE DE TRAITEMENT +# ============================================================================= +JOB_QUEUE_WORKERS=2 +SUMMARY_QUEUE_WORKERS=2 +JOB_MAX_RETRIES=3 + +# ============================================================================= +# BASE DE DONNÉES ET STOCKAGE +# ============================================================================= +SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db +UPLOAD_FOLDER=/data/uploads + +# ============================================================================= +# JOURNALISATION +# ============================================================================= +# ERROR = production (minimal), INFO = débogage, DEBUG = développement +LOG_LEVEL=ERROR diff --git a/deployment/security/docker-daemon.json b/deployment/security/docker-daemon.json new file mode 100644 index 0000000..217a460 --- /dev/null +++ b/deployment/security/docker-daemon.json @@ -0,0 +1,8 @@ +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "storage-driver": "overlay2" +} diff --git a/deployment/security/docker-iptables.service b/deployment/security/docker-iptables.service new file mode 100644 index 0000000..5a78b28 --- /dev/null +++ b/deployment/security/docker-iptables.service @@ -0,0 +1,12 @@ +[Unit] +Description=DictIA Docker iptables rules +After=docker.service tailscaled.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/bash /opt/dictia/deployment/security/iptables-rules.sh + +[Install] +WantedBy=multi-user.target diff --git a/deployment/security/iptables-rules.sh b/deployment/security/iptables-rules.sh new file mode 100644 index 0000000..376cd7c --- /dev/null +++ b/deployment/security/iptables-rules.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# DictIA — iptables rules for cloud VPS +# +# Allows Docker internal traffic to reach the ASR proxy on port 9090. +# Blocks direct external access to Docker container IPs. +# Tailscale + UFW handle the main firewall — this script adds Docker-specific rules. +# +# Usage: sudo bash iptables-rules.sh +set -euo pipefail + +echo "=== DictIA iptables rules ===" + +# Allow Docker containers (172.16.0.0/12) to reach ASR proxy on port 9090 +# This rule goes BEFORE the default DROP policy so containers can talk to the proxy +iptables -C INPUT -s 172.16.0.0/12 -p tcp --dport 9090 -j ACCEPT 2>/dev/null \ + || iptables -I INPUT 1 -s 172.16.0.0/12 -p tcp --dport 9090 -j ACCEPT + +# Block direct external access to Docker container IPs (raw table, before conntrack) +# Protects containers on non-default bridge networks (e.g., dictia-network) +for NETWORK_ID in $(docker network ls --filter driver=bridge --format '{{.ID}}' 2>/dev/null); do + BRIDGE=$(docker network inspect "$NETWORK_ID" --format '{{.Options.com.docker.network.bridge.name}}' 2>/dev/null || echo "") + [ -z "$BRIDGE" ] && continue + [ "$BRIDGE" = "docker0" ] && continue + + for CONTAINER_IP in $(docker network inspect "$NETWORK_ID" \ + --format '{{range .Containers}}{{.IPv4Address}} {{end}}' 2>/dev/null); do + IP="${CONTAINER_IP%/*}" + [ -z "$IP" ] && continue + iptables -t raw -C PREROUTING -d "$IP" ! -i "$BRIDGE" -j DROP 2>/dev/null \ + || iptables -t raw -A PREROUTING -d "$IP" ! -i "$BRIDGE" -j DROP + echo " Protected $IP on $BRIDGE" + done +done + +echo "Rules applied. Tailscale + Docker internal traffic allowed." +echo "Verify with: sudo iptables -L -n -t raw" diff --git a/deployment/setup.sh b/deployment/setup.sh new file mode 100755 index 0000000..dbf7fe3 --- /dev/null +++ b/deployment/setup.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# DictIA — Main setup script +# +# Interactive installer that detects hardware and configures the appropriate +# deployment profile (cloud, local-cpu, local-gpu). +# +# Usage: +# bash deployment/setup.sh # Interactive mode +# bash deployment/setup.sh --profile cloud # Non-interactive +# bash deployment/setup.sh --profile local-gpu +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROFILE="" + +for arg in "$@"; do + case "$arg" in + --profile=*) PROFILE="${arg#*=}" ;; + --profile) shift_next=true ;; + *) + if [ "${shift_next:-false}" = true ]; then + PROFILE="$arg" + shift_next=false + fi + ;; + esac +done + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*"; } + +echo +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} DictIA — Setup${NC}" +echo -e "${CYAN}========================================${NC}" +echo + +# ========================================================================== +# 1. Hardware Detection +# ========================================================================== +info "Detecting hardware..." + +# Docker +if command -v docker &>/dev/null && docker info &>/dev/null; then + DOCKER_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) + ok "Docker $DOCKER_VERSION" +else + err "Docker not found or not running." + echo " Install Docker: https://docs.docker.com/engine/install/" + exit 1 +fi + +# Docker Compose +if docker compose version &>/dev/null; then + COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "unknown") + ok "Docker Compose $COMPOSE_VERSION" +else + err "Docker Compose not found." + echo " Docker Compose V2 is required (comes with Docker Desktop or docker-compose-plugin)" + exit 1 +fi + +# GPU +HAS_GPU=false +if command -v nvidia-smi &>/dev/null; then + GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo "") + if [ -n "$GPU_NAME" ]; then + HAS_GPU=true + ok "NVIDIA GPU: $GPU_NAME" + # Check nvidia-container-toolkit + if docker info 2>/dev/null | grep -qi nvidia; then + ok "nvidia-container-toolkit detected" + else + warn "nvidia-container-toolkit not detected. Required for local-gpu profile." + echo " Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html" + fi + fi +else + info "No NVIDIA GPU detected" +fi + +# RAM +if command -v free &>/dev/null; then + RAM_GB=$(free -g | awk '/Mem:/{print $2}') + info "RAM: ${RAM_GB}GB" +fi + +# Disk +DISK_AVAIL=$(df -h "$PROJECT_DIR" 2>/dev/null | awk 'NR==2{print $4}') +info "Disk available: $DISK_AVAIL" + +echo + +# ========================================================================== +# 2. Profile Selection +# ========================================================================== +if [ -z "$PROFILE" ]; then + echo -e "${CYAN}Select deployment profile:${NC}" + echo + echo " 1) cloud — VPS with ASR Proxy (GCP GPU on demand)" + echo " Best for: remote servers, pay-per-use GPU" + echo + echo " 2) local-gpu — Local NVIDIA GPU for transcription" + echo " Best for: dedicated GPU server, fastest" + if [ "$HAS_GPU" = false ]; then + echo -e " ${YELLOW}(No GPU detected on this machine)${NC}" + fi + echo + echo " 3) local-cpu — CPU-only transcription (slow)" + echo " Best for: testing, low-volume usage" + echo + read -rp "Choice [1-3]: " CHOICE + case "$CHOICE" in + 1) PROFILE="cloud" ;; + 2) PROFILE="local-gpu" ;; + 3) PROFILE="local-cpu" ;; + *) err "Invalid choice"; exit 1 ;; + esac +fi + +COMPOSE_FILE="$SCRIPT_DIR/docker/docker-compose.$PROFILE.yml" +if [ ! -f "$COMPOSE_FILE" ]; then + err "Compose file not found: $COMPOSE_FILE" + exit 1 +fi + +ok "Profile: $PROFILE" +echo + +# ========================================================================== +# 3. Generate .env +# ========================================================================== +ENV_FILE="$PROJECT_DIR/.env" + +if [ -f "$ENV_FILE" ]; then + warn ".env already exists. Keeping existing configuration." + echo " To reconfigure, delete .env and re-run setup." +else + info "Generating .env..." + + # Generate secret key + SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null \ + || openssl rand -hex 32 2>/dev/null \ + || head -c 64 /dev/urandom | xxd -p | head -c 64) + + # Prompt for admin credentials + read -rp "Admin username [admin]: " ADMIN_USER + ADMIN_USER="${ADMIN_USER:-admin}" + read -rp "Admin email [admin@example.com]: " ADMIN_EMAIL + ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}" + read -rsp "Admin password: " ADMIN_PASS + echo + ADMIN_PASS="${ADMIN_PASS:-changeme}" + + # Prompt for text model API key + echo + info "DictIA needs a text/LLM API key for summaries, titles, and chat." + echo " Recommended: OpenRouter (https://openrouter.ai) — access to many models" + read -rp "Text model API key (or press Enter to skip): " TEXT_API_KEY + TEXT_API_KEY="${TEXT_API_KEY:-your_openrouter_api_key}" + + # HuggingFace token for diarization + if [ "$PROFILE" != "cloud" ]; then + echo + info "For speaker diarization, a HuggingFace token is needed." + echo " Get one at: https://huggingface.co/settings/tokens" + echo " Accept model: https://huggingface.co/pyannote/speaker-diarization-3.1" + read -rp "HuggingFace token (or press Enter to skip): " HF_TOKEN + HF_TOKEN="${HF_TOKEN:-}" + else + HF_TOKEN="" + fi + + # Write .env + cp "$SCRIPT_DIR/docker/.env.example" "$ENV_FILE" + sed -i "s|SECRET_KEY=.*|SECRET_KEY=$SECRET_KEY|" "$ENV_FILE" + sed -i "s|DICTIA_PROFILE=.*|DICTIA_PROFILE=$PROFILE|" "$ENV_FILE" + sed -i "s|ADMIN_USERNAME=.*|ADMIN_USERNAME=$ADMIN_USER|" "$ENV_FILE" + sed -i "s|ADMIN_EMAIL=.*|ADMIN_EMAIL=$ADMIN_EMAIL|" "$ENV_FILE" + sed -i "s|ADMIN_PASSWORD=.*|ADMIN_PASSWORD=$ADMIN_PASS|" "$ENV_FILE" + sed -i "s|TEXT_MODEL_API_KEY=.*|TEXT_MODEL_API_KEY=$TEXT_API_KEY|" "$ENV_FILE" + sed -i "s|HF_TOKEN=.*|HF_TOKEN=$HF_TOKEN|" "$ENV_FILE" + + ok ".env generated" +fi +echo + +# ========================================================================== +# 4. Create data directories +# ========================================================================== +info "Creating data directories..." +mkdir -p "$PROJECT_DIR/data/uploads" "$PROJECT_DIR/data/instance" +ok "data/uploads and data/instance created" +echo + +# ========================================================================== +# 5. Profile-specific setup +# ========================================================================== +case "$PROFILE" in + cloud) + info "Cloud profile — setting up ASR Proxy..." + if [ -f "$SCRIPT_DIR/asr-proxy/setup.sh" ]; then + echo " Run the ASR proxy setup separately:" + echo " bash $SCRIPT_DIR/asr-proxy/setup.sh" + fi + echo + info "Setting up iptables rules..." + if [ -f "$SCRIPT_DIR/security/iptables-rules.sh" ] && [ "$(id -u)" -eq 0 ]; then + bash "$SCRIPT_DIR/security/iptables-rules.sh" + else + echo " Run as root: sudo bash $SCRIPT_DIR/security/iptables-rules.sh" + fi + echo + info "Setting up Tailscale Serve..." + if command -v tailscale &>/dev/null; then + echo " Run: bash $SCRIPT_DIR/config/tailscale/setup-serve.sh" + else + warn "Tailscale not installed." + echo " Install: curl -fsSL https://tailscale.com/install.sh | sh" + fi + ;; + local-gpu) + info "Local GPU profile — verifying NVIDIA runtime..." + if docker info 2>/dev/null | grep -qi nvidia; then + ok "NVIDIA Docker runtime available" + # Quick GPU test + if docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi &>/dev/null; then + ok "GPU test passed" + else + warn "GPU test failed. Check nvidia-container-toolkit installation." + fi + else + err "NVIDIA Docker runtime not found." + echo " Install nvidia-container-toolkit and restart Docker." + echo " https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html" + fi + ;; + local-cpu) + warn "CPU-only transcription is significantly slower than GPU." + echo " Expect ~10x real-time (1h audio = ~10h processing)." + echo " Consider local-gpu or cloud profile for better performance." + ;; +esac + +echo + +# ========================================================================== +# 6. Build and start +# ========================================================================== +info "Building DictIA Docker image..." +cd "$PROJECT_DIR" +docker build -t innova-ai/dictia:latest . +ok "Image built" + +echo +info "Starting DictIA ($PROFILE profile)..." +docker compose -f "$COMPOSE_FILE" up -d +ok "Containers started" + +# ========================================================================== +# 7. Health check +# ========================================================================== +echo +info "Waiting for DictIA to become healthy..." +RETRIES=30 +for i in $(seq 1 $RETRIES); do + if curl -sf -o /dev/null -m 5 http://localhost:8899/health 2>/dev/null; then + ok "DictIA is healthy!" + break + fi + if [ "$i" -eq "$RETRIES" ]; then + warn "Health check timeout. Check logs: docker compose -f $COMPOSE_FILE logs" + fi + sleep 5 +done + +echo +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} DictIA is ready!${NC}" +echo -e "${GREEN}========================================${NC}" +echo +echo " App: http://localhost:8899" +echo " Profile: $PROFILE" +echo " Compose: $COMPOSE_FILE" +echo +echo " Tools:" +echo " Update: bash deployment/tools/update.sh" +echo " Backup: bash deployment/tools/backup.sh" +echo " Health check: bash deployment/tools/health-check.sh" +echo diff --git a/deployment/tools/backup.sh b/deployment/tools/backup.sh new file mode 100644 index 0000000..17ee50a --- /dev/null +++ b/deployment/tools/backup.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# DictIA — Backup script +# +# Creates a timestamped backup of data, env, and Docker volumes. +# Keeps the last N backups (default: 5). +# +# Usage: bash backup.sh [BACKUP_DIR] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +BACKUP_BASE="${1:-$PROJECT_DIR/backups}" +KEEP_COUNT=5 +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +BACKUP_DIR="$BACKUP_BASE/dictia-$TIMESTAMP" + +echo "=== DictIA Backup ===" +echo "Project: $PROJECT_DIR" +echo "Backup: $BACKUP_DIR" +echo + +mkdir -p "$BACKUP_DIR" + +# 1. Data directory +if [ -d "$PROJECT_DIR/data" ]; then + echo "[1/4] Backing up data/..." + cp -a "$PROJECT_DIR/data" "$BACKUP_DIR/data" +else + echo "[1/4] No data/ directory found, skipping." +fi + +# 2. Environment file +if [ -f "$PROJECT_DIR/.env" ]; then + echo "[2/4] Backing up .env..." + cp "$PROJECT_DIR/.env" "$BACKUP_DIR/dot-env" +else + echo "[2/4] No .env found, skipping." +fi + +# 3. ASR Proxy stats +ASR_STATS="$PROJECT_DIR/deployment/asr-proxy/usage-stats.json" +if [ -f "$ASR_STATS" ]; then + echo "[3/4] Backing up ASR proxy stats..." + cp "$ASR_STATS" "$BACKUP_DIR/asr-usage-stats.json" +else + echo "[3/4] No ASR proxy stats, skipping." +fi + +# 4. Docker volumes (if using managed volumes) +echo "[4/4] Checking Docker volumes..." +if docker volume ls --format '{{.Name}}' 2>/dev/null | grep -q "whisperx-cache"; then + echo " Exporting whisperx-cache volume..." + docker run --rm -v whisperx-cache:/source -v "$BACKUP_DIR":/backup \ + alpine tar czf /backup/whisperx-cache.tar.gz -C /source . 2>/dev/null || true +fi + +# Write manifest +cat > "$BACKUP_DIR/manifest.json" </dev/null | wc -l) +if [ "$BACKUP_COUNT" -gt "$KEEP_COUNT" ]; then + echo + echo "Rotating backups (keeping last $KEEP_COUNT)..." + ls -1t "$BACKUP_BASE"/dictia-*.tar.gz | tail -n +"$((KEEP_COUNT + 1))" | xargs rm -f +fi + +echo +echo "=== Backup complete ===" diff --git a/deployment/tools/health-check.sh b/deployment/tools/health-check.sh new file mode 100644 index 0000000..8075289 --- /dev/null +++ b/deployment/tools/health-check.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# DictIA — Health check diagnostic +# +# Checks Docker, containers, endpoints, disk, RAM, and GPU. +# +# Usage: +# bash health-check.sh # Human-readable output +# bash health-check.sh --json # JSON output +# bash health-check.sh --quiet # Exit code only (0=ok, 1=issue) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT="human" +ISSUES=0 + +for arg in "$@"; do + case "$arg" in + --json) OUTPUT="json" ;; + --quiet) OUTPUT="quiet" ;; + esac +done + +declare -A CHECKS + +check() { + local name="$1" + local status="$2" + local detail="${3:-}" + CHECKS["$name"]="$status|$detail" + if [ "$status" = "error" ] || [ "$status" = "warning" ]; then + ISSUES=$((ISSUES + 1)) + fi +} + +# --- Docker --- +if command -v docker &>/dev/null && docker info &>/dev/null; then + check "docker" "ok" "Docker daemon running" +else + check "docker" "error" "Docker not available" +fi + +# --- Containers --- +DICTIA_STATUS=$(docker inspect --format='{{.State.Health.Status}}' dictia 2>/dev/null || echo "not_found") +if [ "$DICTIA_STATUS" = "healthy" ]; then + check "container_dictia" "ok" "healthy" +elif [ "$DICTIA_STATUS" = "not_found" ]; then + check "container_dictia" "error" "container not found" +else + check "container_dictia" "warning" "$DICTIA_STATUS" +fi + +WHISPERX_STATUS=$(docker inspect --format='{{.State.Status}}' whisperx-asr 2>/dev/null || echo "not_found") +if [ "$WHISPERX_STATUS" = "running" ]; then + check "container_whisperx" "ok" "running" +elif [ "$WHISPERX_STATUS" = "not_found" ]; then + check "container_whisperx" "info" "not present (cloud profile?)" +else + check "container_whisperx" "warning" "$WHISPERX_STATUS" +fi + +# --- Endpoints --- +if curl -sf -o /dev/null -m 5 http://localhost:8899/health 2>/dev/null; then + check "endpoint_dictia" "ok" "http://localhost:8899 responding" +else + check "endpoint_dictia" "error" "http://localhost:8899 not responding" +fi + +if curl -sf -o /dev/null -m 5 http://localhost:9000/health 2>/dev/null; then + check "endpoint_whisperx" "ok" "http://localhost:9000 responding" +else + check "endpoint_whisperx" "info" "http://localhost:9000 not responding" +fi + +if curl -sf -o /dev/null -m 5 http://localhost:9090/health 2>/dev/null; then + check "endpoint_asr_proxy" "ok" "http://localhost:9090 responding" +else + check "endpoint_asr_proxy" "info" "http://localhost:9090 not responding" +fi + +# --- Disk --- +DISK_USED=$(df -h "$PROJECT_DIR" 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%') +if [ -n "$DISK_USED" ]; then + if [ "$DISK_USED" -gt 90 ]; then + check "disk" "error" "${DISK_USED}% used" + elif [ "$DISK_USED" -gt 80 ]; then + check "disk" "warning" "${DISK_USED}% used" + else + check "disk" "ok" "${DISK_USED}% used" + fi +fi + +# --- RAM --- +if command -v free &>/dev/null; then + MEM_TOTAL=$(free -m | awk '/Mem:/{print $2}') + MEM_AVAIL=$(free -m | awk '/Mem:/{print $7}') + MEM_USED_PCT=$(( (MEM_TOTAL - MEM_AVAIL) * 100 / MEM_TOTAL )) + if [ "$MEM_USED_PCT" -gt 90 ]; then + check "memory" "warning" "${MEM_USED_PCT}% used (${MEM_AVAIL}MB available)" + else + check "memory" "ok" "${MEM_USED_PCT}% used (${MEM_AVAIL}MB available)" + fi +fi + +# --- GPU --- +if command -v nvidia-smi &>/dev/null; then + GPU_INFO=$(nvidia-smi --query-gpu=name,memory.used,memory.total --format=csv,noheader 2>/dev/null || echo "error") + if [ "$GPU_INFO" != "error" ]; then + check "gpu" "ok" "$GPU_INFO" + else + check "gpu" "warning" "nvidia-smi present but query failed" + fi +fi + +# --- Output --- +if [ "$OUTPUT" = "json" ]; then + echo "{" + echo " \"timestamp\": \"$(date -Is)\"," + echo " \"issues\": $ISSUES," + echo " \"checks\": {" + FIRST=true + for name in "${!CHECKS[@]}"; do + IFS='|' read -r status detail <<< "${CHECKS[$name]}" + if [ "$FIRST" = true ]; then + FIRST=false + else + echo "," + fi + printf ' "%s": {"status": "%s", "detail": "%s"}' "$name" "$status" "$detail" + done + echo + echo " }" + echo "}" +elif [ "$OUTPUT" = "quiet" ]; then + exit $( [ "$ISSUES" -eq 0 ] && echo 0 || echo 1 ) +else + echo "=== DictIA Health Check ===" + echo + for name in docker container_dictia container_whisperx endpoint_dictia endpoint_whisperx endpoint_asr_proxy disk memory gpu; do + if [ -n "${CHECKS[$name]+x}" ]; then + IFS='|' read -r status detail <<< "${CHECKS[$name]}" + case "$status" in + ok) ICON="[OK]" ;; + warning) ICON="[!!]" ;; + error) ICON="[ERR]" ;; + info) ICON="[--]" ;; + esac + printf " %-22s %s %s\n" "$name" "$ICON" "$detail" + fi + done + echo + if [ "$ISSUES" -eq 0 ]; then + echo "All checks passed." + else + echo "$ISSUES issue(s) found." + fi +fi diff --git a/deployment/tools/restore.sh b/deployment/tools/restore.sh new file mode 100644 index 0000000..4c9d46a --- /dev/null +++ b/deployment/tools/restore.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# DictIA — Restore script +# +# Restores a DictIA backup archive created by backup.sh. +# +# Usage: bash restore.sh [PROJECT_DIR] +set -euo pipefail + +ARCHIVE="${1:-}" +PROJECT_DIR="${2:-$(cd "$(dirname "$0")/../.." && pwd)}" + +if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then + echo "Usage: bash restore.sh [project-dir]" + echo + echo "Available backups:" + ls -1t "$PROJECT_DIR/backups"/dictia-*.tar.gz 2>/dev/null | head -5 || echo " (none found)" + exit 1 +fi + +echo "=== DictIA Restore ===" +echo "Archive: $ARCHIVE" +echo "Target: $PROJECT_DIR" +echo + +# Validate archive +echo "Validating archive..." +TMPDIR=$(mktemp -d) +tar xzf "$ARCHIVE" -C "$TMPDIR" +BACKUP_DIR=$(ls -1d "$TMPDIR"/dictia-* | head -1) + +if [ ! -f "$BACKUP_DIR/manifest.json" ]; then + echo "ERROR: Invalid backup archive (no manifest.json)" + rm -rf "$TMPDIR" + exit 1 +fi + +echo "Manifest:" +cat "$BACKUP_DIR/manifest.json" +echo +echo + +# Confirmation +read -rp "This will overwrite current data. Continue? [y/N] " CONFIRM +if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then + echo "Aborted." + rm -rf "$TMPDIR" + exit 0 +fi + +# Stop services +echo +echo "Stopping DictIA services..." +COMPOSE_FILE="" +for f in cloud local-cpu local-gpu; do + if [ -f "$PROJECT_DIR/deployment/docker/docker-compose.$f.yml" ]; then + COMPOSE_FILE="$PROJECT_DIR/deployment/docker/docker-compose.$f.yml" + fi +done +if [ -n "$COMPOSE_FILE" ]; then + docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true +fi + +# Restore data +if [ -d "$BACKUP_DIR/data" ]; then + echo "Restoring data/..." + rm -rf "$PROJECT_DIR/data" + cp -a "$BACKUP_DIR/data" "$PROJECT_DIR/data" +fi + +# Restore .env +if [ -f "$BACKUP_DIR/dot-env" ]; then + echo "Restoring .env..." + cp "$BACKUP_DIR/dot-env" "$PROJECT_DIR/.env" +fi + +# Restore ASR stats +if [ -f "$BACKUP_DIR/asr-usage-stats.json" ]; then + echo "Restoring ASR proxy stats..." + cp "$BACKUP_DIR/asr-usage-stats.json" "$PROJECT_DIR/deployment/asr-proxy/usage-stats.json" +fi + +# Restore Docker volumes +if [ -f "$BACKUP_DIR/whisperx-cache.tar.gz" ]; then + echo "Restoring whisperx-cache volume..." + docker volume create whisperx-cache 2>/dev/null || true + docker run --rm -v whisperx-cache:/target -v "$BACKUP_DIR":/backup \ + alpine sh -c "cd /target && tar xzf /backup/whisperx-cache.tar.gz" 2>/dev/null || true +fi + +# Cleanup +rm -rf "$TMPDIR" + +# Restart services +echo +echo "Restarting DictIA..." +if [ -n "$COMPOSE_FILE" ]; then + docker compose -f "$COMPOSE_FILE" up -d +fi + +echo +echo "=== Restore complete ===" diff --git a/deployment/tools/update.sh b/deployment/tools/update.sh new file mode 100644 index 0000000..54be4b7 --- /dev/null +++ b/deployment/tools/update.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# DictIA — Update script +# +# Pulls latest code, rebuilds Docker image, and restarts services. +# Detects the active deployment profile automatically. +# +# Usage: bash update.sh [--no-pull] [--no-build] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +NO_PULL=false +NO_BUILD=false + +for arg in "$@"; do + case "$arg" in + --no-pull) NO_PULL=true ;; + --no-build) NO_BUILD=true ;; + *) echo "Unknown option: $arg"; exit 1 ;; + esac +done + +echo "=== DictIA Update ===" +echo "Project: $PROJECT_DIR" +echo + +# 1. Detect active compose file +COMPOSE_FILE="" +PROFILE="" +for f in cloud local-cpu local-gpu; do + CF="$PROJECT_DIR/deployment/docker/docker-compose.$f.yml" + if [ -f "$CF" ] && docker compose -f "$CF" ps --quiet 2>/dev/null | grep -q .; then + COMPOSE_FILE="$CF" + PROFILE="$f" + break + fi +done + +if [ -z "$COMPOSE_FILE" ]; then + # Fallback: check .env for profile + if [ -f "$PROJECT_DIR/.env" ]; then + PROFILE=$(grep -E '^DICTIA_PROFILE=' "$PROJECT_DIR/.env" 2>/dev/null | cut -d= -f2 || echo "cloud") + fi + PROFILE="${PROFILE:-cloud}" + COMPOSE_FILE="$PROJECT_DIR/deployment/docker/docker-compose.$PROFILE.yml" +fi + +echo "Profile: $PROFILE" +echo "Compose: $COMPOSE_FILE" +echo + +# 2. Git pull +if [ "$NO_PULL" = false ]; then + echo "[1/5] Pulling latest code..." + cd "$PROJECT_DIR" + git pull origin dictia-branding +else + echo "[1/5] Skipping git pull (--no-pull)" +fi + +# 3. Rebuild DictIA image +if [ "$NO_BUILD" = false ]; then + echo "[2/5] Building DictIA image..." + cd "$PROJECT_DIR" + docker build -t innova-ai/dictia:latest . +else + echo "[2/5] Skipping build (--no-build)" +fi + +# 3b. Pull upstream images (WhisperX) if local profile +if [ "$PROFILE" != "cloud" ] && [ "$NO_BUILD" = false ]; then + echo "[3/5] Pulling upstream images (WhisperX)..." + docker compose -f "$COMPOSE_FILE" pull whisperx-asr 2>/dev/null || true +else + echo "[3/5] Skipping upstream pull (cloud profile or --no-build)" +fi + +# 4. Restart containers +echo "[4/5] Restarting containers..." +docker compose -f "$COMPOSE_FILE" down +docker compose -f "$COMPOSE_FILE" up -d + +# 5. Wait for health +echo "[5/5] Waiting for health check..." +RETRIES=30 +for i in $(seq 1 $RETRIES); do + if docker compose -f "$COMPOSE_FILE" ps | grep -q "healthy"; then + echo " DictIA is healthy!" + break + fi + if [ "$i" -eq "$RETRIES" ]; then + echo " WARNING: Health check timeout. Check: docker compose -f $COMPOSE_FILE logs" + fi + sleep 5 +done + +# Cleanup dangling images +echo +echo "Cleaning up old images..." +docker image prune -f 2>/dev/null || true + +echo +echo "=== Update complete ===" +echo "DictIA: http://localhost:8899" +docker compose -f "$COMPOSE_FILE" ps diff --git a/requirements-embeddings.txt b/requirements-embeddings.txt new file mode 100644 index 0000000..81a9a1e --- /dev/null +++ b/requirements-embeddings.txt @@ -0,0 +1,2 @@ +sentence-transformers==2.7.0 +huggingface-hub>=0.19.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3f53295 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +flask==2.3.3 +flask-sqlalchemy==3.1.1 +flask-login==0.6.3 +flask-wtf==1.2.2 +flask-bcrypt==1.0.1 +Flask-Limiter==3.5.0 +flask-openapi3>=3.0.0 +pydantic>=2.0.0 +authlib>=1.3.0 +itsdangerous>=2.1.0 +email-validator==2.2.0 +openai>=2.2.0 +pywebpush==1.14.0 +werkzeug==2.3.7 +gunicorn==21.2.0 +python-dotenv==1.0.0 +markdown==3.5.1 +pytz==2024.1 +Babel==2.12.1 +bleach==6.1.0 +python-docx==1.1.0 +numpy==1.24.3 +scikit-learn==1.3.0 +scipy<1.15 +psycopg2-binary>=2.9.0 diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000..62b4917 --- /dev/null +++ b/scripts/create_admin.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 + +import os +import sys +import getpass +from email_validator import validate_email, EmailNotValidError + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Try to import from app context +try: + from flask import current_app + app = current_app._get_current_object() + with app.app_context(): + db = app.extensions['sqlalchemy'].db + User = app.extensions['sqlalchemy'].db.metadata.tables['user'] + bcrypt = app.extensions.get('bcrypt') +except (RuntimeError, AttributeError, KeyError): + # If not in app context, import directly + try: + from src.app import app, db, User, bcrypt + except ImportError as e: + print(f"Error: Could not import required modules: {e}") + print("Make sure create_admin.py is runnable and PYTHONPATH is set.") + sys.exit(1) + +def create_admin_user(): + """ + Create an admin user interactively. + """ + print("Creating admin user for Speakr application") + print("=========================================") + + # Get username + while True: + username = input("Enter username (min 3 characters): ").strip() + if len(username) < 3: + print("Username must be at least 3 characters long.") + continue + + # Check if username already exists + with app.app_context(): + existing_user = db.session.query(User).filter_by(username=username).first() + if existing_user: + print(f"Username '{username}' already exists. Please choose another.") + continue + break + + # Get email + skip_domain_check = os.environ.get('SKIP_EMAIL_DOMAIN_CHECK', 'false').lower() == 'true' + while True: + email = input("Enter email address: ").strip() + try: + # Validate email (skip DNS/MX check if SKIP_EMAIL_DOMAIN_CHECK=true) + validate_email(email, check_deliverability=not skip_domain_check) + + # Check if email already exists + with app.app_context(): + existing_email = db.session.query(User).filter_by(email=email).first() + if existing_email: + print(f"Email '{email}' already exists. Please use another.") + continue + break + except EmailNotValidError as e: + print(f"Invalid email: {str(e)}") + + # Get password + while True: + password = getpass.getpass("Enter password (min 8 characters): ") + if len(password) < 8: + print("Password must be at least 8 characters long.") + continue + + confirm_password = getpass.getpass("Confirm password: ") + if password != confirm_password: + print("Passwords do not match. Please try again.") + continue + break + + # Create user + with app.app_context(): + hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') + new_user = User(username=username, email=email, password=hashed_password, is_admin=True) + db.session.add(new_user) + db.session.commit() + + print("\nAdmin user created successfully!") + print(f"Username: {username}") + print(f"Email: {email}") + print("You can now log in to the application with these credentials.") + +if __name__ == "__main__": + create_admin_user() diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..8da7d0d --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +# Create necessary directories +mkdir -p /data/uploads /data/instance +chmod 755 /data/uploads /data/instance + +# Initialize the database if it doesn't exist +if [ ! -f /data/instance/transcriptions.db ]; then + echo "Database doesn't exist. Creating new database..." + python -c "from src.app import app, db; app.app_context().push(); db.create_all()" + echo "Database created successfully." +else + echo "Database exists. Checking for schema updates..." + python -c "from src.app import app; app.app_context().push()" +fi + +# Check if we need to create an admin user (regardless of whether the database exists) +if [ -n "$ADMIN_USERNAME" ] && [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then + echo "Creating admin user using environment variables..." + cd /app && python scripts/docker_create_admin.py +fi + +# Start the application +exec "$@" diff --git a/scripts/docker_create_admin.py b/scripts/docker_create_admin.py new file mode 100644 index 0000000..801afba --- /dev/null +++ b/scripts/docker_create_admin.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import os +import sys +from email_validator import validate_email, EmailNotValidError + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Try to import from app context +try: + from flask import current_app + app = current_app._get_current_object() + with app.app_context(): + db = app.extensions['sqlalchemy'].db + User = app.extensions['sqlalchemy'].db.metadata.tables['user'] + bcrypt = app.extensions.get('bcrypt') +except (RuntimeError, AttributeError, KeyError): + # If not in app context, import directly + try: + from src.app import app, db, User, bcrypt + except ImportError as e: + print(f"Error: Could not import required modules: {e}") + print("Make sure docker_create_admin.py is runnable and PYTHONPATH is set.") + sys.exit(1) + +def create_admin_user_from_env(): + """ + Create an admin user from environment variables. + Required environment variables: + - ADMIN_USERNAME + - ADMIN_EMAIL + - ADMIN_PASSWORD + """ + print("Creating admin user for Speakr application from environment variables") + print("=================================================================") + + # Get values from environment variables + username = os.environ.get('ADMIN_USERNAME') + email = os.environ.get('ADMIN_EMAIL') + password = os.environ.get('ADMIN_PASSWORD') + + # Validate required environment variables + if not username or not email or not password: + print("Error: ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD environment variables must be set.") + sys.exit(1) + + # Validate username + if len(username) < 3: + print("Error: Username must be at least 3 characters long.") + sys.exit(1) + + # Validate email (skip DNS/MX check if SKIP_EMAIL_DOMAIN_CHECK=true) + skip_domain_check = os.environ.get('SKIP_EMAIL_DOMAIN_CHECK', 'false').lower() == 'true' + try: + validate_email(email, check_deliverability=not skip_domain_check) + except EmailNotValidError as e: + print(f"Error: Invalid email: {str(e)}") + sys.exit(1) + + # Validate password + if len(password) < 8: + print("Error: Password must be at least 8 characters long.") + sys.exit(1) + + # Create user + with app.app_context(): + # Check if username already exists + existing_user = db.session.query(User).filter_by(username=username).first() + if existing_user: + print(f"User with username '{username}' already exists.") + sys.exit(0) + + # Check if email already exists + existing_email = db.session.query(User).filter_by(email=email).first() + if existing_email: + print(f"User with email '{email}' already exists.") + sys.exit(0) + + # Create new admin user + hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') + new_user = User(username=username, email=email, password=hashed_password, is_admin=True) + db.session.add(new_user) + db.session.commit() + + print("\nAdmin user created successfully!") + print(f"Username: {username}") + print(f"Email: {email}") + print("You can now log in to the application with these credentials.") + +if __name__ == "__main__": + create_admin_user_from_env() diff --git a/scripts/download_offline_deps.py b/scripts/download_offline_deps.py new file mode 100644 index 0000000..67c81cd --- /dev/null +++ b/scripts/download_offline_deps.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Download all CDN dependencies for offline deployment +""" + +import os +import requests +from pathlib import Path + +# Base directory for vendor files +VENDOR_DIR = Path(__file__).parent.parent / "static" / "vendor" + +# Dependencies to download +DEPENDENCIES = { + "css": { + "fontawesome.min.css": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css", + "easymde.min.css": "https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css", + }, + "js": { + "tailwind.min.js": "https://cdn.tailwindcss.com/3.4.0", + "vue.global.js": "https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js", + "marked.min.js": "https://cdn.jsdelivr.net/npm/marked/marked.min.js", + "easymde.min.js": "https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js", + "axios.min.js": "https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js", + } +} + +# Font Awesome webfonts +FONTAWESOME_FONTS = [ + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-brands-400.ttf", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-brands-400.woff2", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-regular-400.ttf", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-regular-400.woff2", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.ttf", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-v4compatibility.ttf", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-v4compatibility.woff2", +] + +def download_file(url, filepath): + """Download a file from URL to filepath""" + print(f"Downloading {url} to {filepath}") + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + + # Create directory if it doesn't exist + filepath.parent.mkdir(parents=True, exist_ok=True) + + # Write file + with open(filepath, 'wb') as f: + f.write(response.content) + print(f" ✓ Downloaded {filepath.name}") + return True + except Exception as e: + print(f" ✗ Failed to download {url}: {e}") + return False + +def main(): + print("Downloading offline dependencies...") + print(f"Vendor directory: {VENDOR_DIR}") + + # Check if we're in production mode + is_production = os.environ.get('FLASK_ENV') == 'production' or os.environ.get('PRODUCTION') == '1' + + if is_production: + print("⚙️ PRODUCTION MODE: Using production builds") + # Replace Vue.js development build with production build + DEPENDENCIES['js']['vue.global.js'] = "https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js" + else: + print("⚙️ DEVELOPMENT MODE: Using development builds") + + # Download CSS and JS files + for file_type, files in DEPENDENCIES.items(): + print(f"\n{file_type.upper()} Files:") + for filename, url in files.items(): + filepath = VENDOR_DIR / file_type / filename + download_file(url, filepath) + + # Download Font Awesome fonts + print("\nFont Awesome Webfonts:") + for url in FONTAWESOME_FONTS: + filename = url.split("/")[-1] + filepath = VENDOR_DIR / "fonts" / "webfonts" / filename + download_file(url, filepath) + + # Update Font Awesome CSS to use local fonts + fa_css_path = VENDOR_DIR / "css" / "fontawesome.min.css" + if fa_css_path.exists(): + print("\nUpdating Font Awesome CSS to use local fonts...") + with open(fa_css_path, 'r') as f: + content = f.read() + + # Replace CDN URLs with local paths - handle both relative and absolute URLs + content = content.replace( + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/", + "../fonts/webfonts/" + ) + # Also replace any relative URLs that might be in the minified CSS + content = content.replace( + "../webfonts/", + "../fonts/webfonts/" + ) + content = content.replace( + "./webfonts/", + "../fonts/webfonts/" + ) + + with open(fa_css_path, 'w') as f: + f.write(content) + print(" ✓ Updated Font Awesome CSS paths") + + print("\n✅ All dependencies downloaded successfully!") + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_docker.sh b/scripts/migrate_docker.sh new file mode 100644 index 0000000..c2eaa7c --- /dev/null +++ b/scripts/migrate_docker.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Manual migration script for Docker deployments +# NOTE: The first 10 recordings are processed automatically on startup. +# This script is for processing any remaining recordings. + +echo "🎯 Inquire Mode Manual Migration for Docker" +echo "=============================================" + +# Check if container is running +if ! docker compose ps | grep -q "speakr.*Up"; then + echo "❌ Speakr container is not running. Please start it first with:" + echo " docker compose up -d" + exit 1 +fi + +echo "ℹ️ Note: The first 10 recordings are processed automatically on startup." +echo "ℹ️ This script processes any remaining recordings that need chunking." +echo "" +echo "🔍 Checking how many recordings still need processing..." + +# First, do a dry run to see what would be processed +docker compose exec app python migrate_existing_recordings.py --dry-run + +echo "" +echo "⚠️ Do you want to proceed with processing these recordings?" +echo "⚠️ This will create embeddings and may take several minutes." +read -p "Continue? (y/N): " -n 1 -r +echo + +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "🚀 Starting migration..." + docker compose exec app python migrate_existing_recordings.py --process --batch-size 5 + + if [ $? -eq 0 ]; then + echo "✅ Migration completed successfully!" + echo "🎉 Your existing recordings are now ready for Inquire Mode!" + else + echo "❌ Migration failed. Check the logs above for details." + exit 1 + fi +else + echo "❌ Migration cancelled." + exit 0 +fi \ No newline at end of file diff --git a/scripts/migrate_existing_recordings.py b/scripts/migrate_existing_recordings.py new file mode 100644 index 0000000..203ea00 --- /dev/null +++ b/scripts/migrate_existing_recordings.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Migration script to process existing recordings for Inquire Mode. +This script will chunk and vectorize all existing recordings that haven't been processed yet. +""" +import os +import sys +from src.app import app, db, Recording, TranscriptChunk, process_recording_chunks + +def count_recordings_needing_processing(): + """Count how many recordings need chunk processing.""" + with app.app_context(): + # Get all completed recordings + completed_recordings = Recording.query.filter_by(status='COMPLETED').all() + + # Check which ones don't have chunks + recordings_needing_processing = [] + for recording in completed_recordings: + if recording.transcription: # Has transcription + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count() + if chunk_count == 0: # No chunks yet + recordings_needing_processing.append(recording) + + return recordings_needing_processing + +def migrate_existing_recordings(batch_size=10, dry_run=False): + """ + Process existing recordings in batches to create chunks and embeddings. + + Args: + batch_size (int): Number of recordings to process at once + dry_run (bool): If True, just show what would be processed + """ + with app.app_context(): + recordings_to_process = count_recordings_needing_processing() + + print(f"🔍 Found {len(recordings_to_process)} recordings that need chunk processing") + + if len(recordings_to_process) == 0: + print("✅ All recordings are already processed!") + return True + + if dry_run: + print("\n📋 Recordings that would be processed:") + for i, recording in enumerate(recordings_to_process, 1): + print(f" {i}. {recording.title} (ID: {recording.id}) - {len(recording.transcription)} chars") + print(f"\nThis is a dry run. Use --process to actually run the migration.") + return True + + print(f"🚀 Processing {len(recordings_to_process)} recordings in batches of {batch_size}") + + processed = 0 + errors = 0 + + for i in range(0, len(recordings_to_process), batch_size): + batch = recordings_to_process[i:i + batch_size] + print(f"\n📦 Processing batch {i//batch_size + 1} ({len(batch)} recordings)...") + + for recording in batch: + try: + print(f" ⏳ Processing: {recording.title} (ID: {recording.id})") + + success = process_recording_chunks(recording.id) + if success: + processed += 1 + # Get chunk count to report + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count() + print(f" ✅ Created {chunk_count} chunks") + else: + errors += 1 + print(f" ❌ Failed to process recording {recording.id}") + + except Exception as e: + errors += 1 + print(f" ❌ Error processing recording {recording.id}: {e}") + + # Commit batch + try: + db.session.commit() + print(f" 💾 Batch committed successfully") + except Exception as e: + db.session.rollback() + print(f" ❌ Error committing batch: {e}") + errors += len(batch) + + print(f"\n📊 Migration Summary:") + print(f" ✅ Successfully processed: {processed}") + print(f" ❌ Errors: {errors}") + print(f" 📈 Success rate: {(processed/(processed+errors)*100):.1f}%" if (processed+errors) > 0 else "N/A") + + return errors == 0 + +def main(): + """Main function to handle command line arguments.""" + import argparse + + parser = argparse.ArgumentParser(description='Migrate existing recordings for Inquire Mode') + parser.add_argument('--dry-run', action='store_true', + help='Show what would be processed without actually processing') + parser.add_argument('--process', action='store_true', + help='Actually process the recordings') + parser.add_argument('--batch-size', type=int, default=10, + help='Number of recordings to process in each batch (default: 10)') + + args = parser.parse_args() + + if not args.dry_run and not args.process: + print("❌ Please specify either --dry-run or --process") + print("Use --help for more information") + return False + + print("🎯 Inquire Mode Migration Tool") + print("=" * 40) + + try: + if args.dry_run: + success = migrate_existing_recordings(args.batch_size, dry_run=True) + else: + print("⚠️ This will process all existing recordings and create embeddings.") + print("⚠️ This may take a while and use significant CPU/memory.") + + confirm = input("Continue? (y/N): ") + if confirm.lower() != 'y': + print("❌ Migration cancelled by user") + return False + + success = migrate_existing_recordings(args.batch_size, dry_run=False) + + return success + + except KeyboardInterrupt: + print("\n❌ Migration cancelled by user") + return False + except Exception as e: + print(f"❌ Migration failed: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/scripts/migrate_team_to_group.py b/scripts/migrate_team_to_group.py new file mode 100755 index 0000000..e7546c4 --- /dev/null +++ b/scripts/migrate_team_to_group.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Migration script to rename team tables to group tables. +This handles the refactoring from team-based to group-based terminology. +""" +import sys +import os + +# Add the parent directory to the path to import app modules +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.app import app, db +from sqlalchemy import text + +def migrate_tables(): + """Copy data from team tables to group tables and remove old tables.""" + with app.app_context(): + try: + # Check if old tables exist + inspector = db.inspect(db.engine) + existing_tables = inspector.get_table_names() + + print("Existing tables:", existing_tables) + + # Check if we need to migrate data + if 'team' in existing_tables and 'group' in existing_tables: + # Both tables exist - need to copy data + print("\nBoth 'team' and 'group' tables exist. Copying data...") + + # Check if there's data in the old table + result = db.session.execute(text('SELECT COUNT(*) FROM team')) + old_count = result.scalar() + print(f"Found {old_count} records in 'team' table") + + if old_count > 0: + # Copy data from team to group + print("Copying data from 'team' to 'group'...") + db.session.execute(text( + 'INSERT INTO "group" (id, name, description, created_at) ' + 'SELECT id, name, description, created_at FROM team' + )) + db.session.commit() + print(f"✓ Copied {old_count} records to 'group' table") + + # Drop the old team table + print("Dropping old 'team' table...") + db.session.execute(text('DROP TABLE team')) + db.session.commit() + print("✓ Dropped old 'team' table") + + elif 'team' in existing_tables and 'group' not in existing_tables: + # Only old table exists - rename it + print("\nRenaming 'team' table to 'group'...") + db.session.execute(text('ALTER TABLE team RENAME TO "group"')) + db.session.commit() + print("✓ Renamed 'team' to 'group'") + else: + print("\n'team' table not found or already migrated") + + # Migrate team_membership + if 'team_membership' in existing_tables and 'group_membership' in existing_tables: + # Both tables exist - need to copy data + print("\nBoth 'team_membership' and 'group_membership' tables exist. Copying data...") + + # Check if there's data in the old table + result = db.session.execute(text('SELECT COUNT(*) FROM team_membership')) + old_count = result.scalar() + print(f"Found {old_count} records in 'team_membership' table") + + if old_count > 0: + # Copy data from team_membership to group_membership + print("Copying data from 'team_membership' to 'group_membership'...") + db.session.execute(text( + 'INSERT INTO group_membership (id, user_id, group_id, role, joined_at) ' + 'SELECT id, user_id, team_id, role, joined_at FROM team_membership' + )) + db.session.commit() + print(f"✓ Copied {old_count} records to 'group_membership' table") + + # Drop the old team_membership table + print("Dropping old 'team_membership' table...") + db.session.execute(text('DROP TABLE team_membership')) + db.session.commit() + print("✓ Dropped old 'team_membership' table") + + elif 'team_membership' in existing_tables and 'group_membership' not in existing_tables: + # Only old table exists - rename it + print("\nRenaming 'team_membership' table to 'group_membership'...") + db.session.execute(text('ALTER TABLE team_membership RENAME TO group_membership')) + db.session.commit() + print("✓ Renamed 'team_membership' to 'group_membership'") + else: + print("\n'team_membership' table not found or already migrated") + + # Migrate team_id to group_id in tags table + print("\nMigrating tag associations from team_id to group_id...") + result = db.session.execute(text( + 'UPDATE tag SET group_id = team_id WHERE team_id IS NOT NULL AND group_id IS NULL' + )) + db.session.commit() + print(f"✓ Migrated {result.rowcount} tag associations") + + # Migrate share_with_team_lead to share_with_group_lead in tags + result = db.session.execute(text( + 'UPDATE tag SET share_with_group_lead = share_with_team_lead WHERE share_with_team_lead IS NOT NULL AND share_with_group_lead IS NULL' + )) + db.session.commit() + print(f"✓ Migrated {result.rowcount} share_with_lead settings") + + print("\n✅ Migration completed successfully!") + print("\nPlease restart the application for changes to take full effect.") + + except Exception as e: + print(f"\n❌ Error during migration: {e}") + db.session.rollback() + sys.exit(1) + +if __name__ == '__main__': + print("=" * 60) + print("Team to Group Migration Script") + print("=" * 60) + print("\nThis script will rename database tables:") + print(" - 'team' → 'group'") + print(" - 'team_membership' → 'group_membership'") + + # Check for --yes flag to skip confirmation + if '--yes' in sys.argv or '-y' in sys.argv: + print("\nAuto-confirming migration (--yes flag detected)...\n") + else: + print("\nPress Ctrl+C to cancel, or Enter to continue...") + try: + input() + except KeyboardInterrupt: + print("\n\nMigration cancelled.") + sys.exit(0) + + migrate_tables() diff --git a/scripts/parse_asr_json.py b/scripts/parse_asr_json.py new file mode 100755 index 0000000..b394eba --- /dev/null +++ b/scripts/parse_asr_json.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +ASR JSON Parser - Analyzes speaker information in ASR response JSON files +""" + +import json +import sys +from collections import defaultdict, Counter + +def analyze_asr_json(json_data): + """ + Analyze ASR JSON data to understand speaker distribution and identify issues + """ + if not isinstance(json_data, dict) or 'segments' not in json_data: + print("ERROR: Invalid JSON structure. Expected dict with 'segments' key.") + return + + segments = json_data['segments'] + if not isinstance(segments, list): + print("ERROR: 'segments' should be a list.") + return + + print(f"=== ASR JSON Analysis ===") + print(f"Total segments: {len(segments)}") + print() + + # Track segment-level speakers + segment_speakers = [] + segments_with_speaker = 0 + segments_without_speaker = 0 + + # Track word-level speakers + word_speakers = [] + words_with_speaker = 0 + words_without_speaker = 0 + + # Track segments with null speakers + null_speaker_segments = [] + + for i, segment in enumerate(segments): + # Analyze segment-level speaker + segment_speaker = segment.get('speaker') + if segment_speaker is not None: + segment_speakers.append(segment_speaker) + segments_with_speaker += 1 + else: + segments_without_speaker += 1 + null_speaker_segments.append(i) + + # Analyze word-level speakers + words = segment.get('words', []) + for word_data in words: + word_speaker = word_data.get('speaker') + if word_speaker is not None: + word_speakers.append(word_speaker) + words_with_speaker += 1 + else: + words_without_speaker += 1 + + # Print segment-level analysis + print("=== SEGMENT-LEVEL SPEAKERS ===") + print(f"Segments with speakers: {segments_with_speaker}") + print(f"Segments without speakers: {segments_without_speaker}") + + if segment_speakers: + segment_speaker_counts = Counter(segment_speakers) + print(f"Unique segment speakers: {sorted(segment_speaker_counts.keys())}") + print("Segment speaker distribution:") + for speaker, count in segment_speaker_counts.most_common(): + print(f" {speaker}: {count} segments") + else: + print("No segment-level speakers found!") + + print() + + # Print word-level analysis + print("=== WORD-LEVEL SPEAKERS ===") + print(f"Words with speakers: {words_with_speaker}") + print(f"Words without speakers: {words_without_speaker}") + + if word_speakers: + word_speaker_counts = Counter(word_speakers) + print(f"Unique word speakers: {sorted(word_speaker_counts.keys())}") + print("Word speaker distribution:") + for speaker, count in word_speaker_counts.most_common(): + print(f" {speaker}: {count} words") + else: + print("No word-level speakers found!") + + print() + + # Analyze segments without speakers + if null_speaker_segments: + print("=== SEGMENTS WITHOUT SPEAKERS ===") + print(f"Segment indices without speakers: {null_speaker_segments[:10]}{'...' if len(null_speaker_segments) > 10 else ''}") + + print("\nFirst few segments without speakers:") + for i in null_speaker_segments[:5]: + segment = segments[i] + text = segment.get('text', '').strip() + start = segment.get('start') + end = segment.get('end') + words = segment.get('words', []) + + print(f" Segment {i}: '{text}' ({start}-{end}s)") + print(f" Keys: {list(segment.keys())}") + + # Check if words have speakers even when segment doesn't + word_speakers_in_segment = [w.get('speaker') for w in words if w.get('speaker')] + if word_speakers_in_segment: + word_speaker_counts = Counter(word_speakers_in_segment) + print(f" Word speakers: {dict(word_speaker_counts)}") + else: + print(f" No word speakers either") + print() + + # Suggest speaker assignment strategy + print("=== SPEAKER ASSIGNMENT STRATEGY ===") + if segments_without_speaker > 0: + print(f"Found {segments_without_speaker} segments without speakers.") + + if words_with_speaker > 0: + print("RECOMMENDATION: Use word-level speaker information to assign segment speakers.") + print("Strategy: For segments without speakers, find the most common speaker among their words.") + else: + print("RECOMMENDATION: Assign a default speaker label (e.g., 'UNKNOWN_SPEAKER') to segments without speakers.") + else: + print("All segments have speakers assigned. No action needed.") + +def suggest_preprocessing_fix(json_data): + """ + Suggest how to fix the preprocessing based on the JSON structure + """ + print("\n=== PREPROCESSING FIX SUGGESTION ===") + + segments = json_data.get('segments', []) + if not segments: + return + + # Check if we can derive segment speakers from word speakers + segments_fixable = 0 + for segment in segments: + if segment.get('speaker') is None: + words = segment.get('words', []) + word_speakers = [w.get('speaker') for w in words if w.get('speaker')] + if word_speakers: + segments_fixable += 1 + + if segments_fixable > 0: + print(f"✅ {segments_fixable} segments can be fixed using word-level speaker information.") + print("\nSuggested code fix:") + print(""" +# In the ASR processing function, replace the segment processing with: +for i, segment in enumerate(asr_response_data['segments']): + speaker = segment.get('speaker') + text = segment.get('text', '').strip() + + # If segment doesn't have a speaker, try to derive from words + if speaker is None: + words = segment.get('words', []) + word_speakers = [w.get('speaker') for w in words if w.get('speaker')] + if word_speakers: + # Use the most common speaker among the words + from collections import Counter + speaker_counts = Counter(word_speakers) + speaker = speaker_counts.most_common(1)[0][0] + app.logger.info(f"Derived speaker '{speaker}' for segment {i} from word-level data") + else: + speaker = 'UNKNOWN_SPEAKER' + app.logger.warning(f"No speaker info available for segment {i}, using UNKNOWN_SPEAKER") + + simplified_segments.append({ + 'speaker': speaker, + 'sentence': text, + 'start_time': segment.get('start'), + 'end_time': segment.get('end') + }) +""") + else: + print("❌ Segments cannot be fixed using word-level data.") + print("Recommendation: Assign 'UNKNOWN_SPEAKER' to segments without speakers.") + +def main(): + if len(sys.argv) != 2: + print("Usage: python parse_asr_json.py ") + print(" or: python parse_asr_json.py -") + print(" (use '-' to read from stdin)") + sys.exit(1) + + filename = sys.argv[1] + + try: + if filename == '-': + # Read from stdin + json_text = sys.stdin.read() + else: + # Read from file + with open(filename, 'r', encoding='utf-8') as f: + json_text = f.read() + + # Parse JSON + json_data = json.loads(json_text) + + # Analyze the data + analyze_asr_json(json_data) + suggest_preprocessing_fix(json_data) + + except FileNotFoundError: + print(f"ERROR: File '{filename}' not found.") + sys.exit(1) + except json.JSONDecodeError as e: + print(f"ERROR: Invalid JSON - {e}") + sys.exit(1) + except Exception as e: + print(f"ERROR: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..592cd0d --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh +# +# Pre-commit hook: runs PostgreSQL migration compatibility tests +# when migration-related files are staged. +# +# Install: ln -sf ../../scripts/pre-commit .git/hooks/pre-commit + +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) + +# Only run if migration-related files are staged +if echo "$STAGED_FILES" | grep -qE '^(src/init_db\.py|src/utils/database\.py|src/models/)'; then + echo "Migration files changed — running PostgreSQL compatibility checks..." + python tests/test_migration_compatibility.py + if [ $? -ne 0 ]; then + echo "" + echo "Pre-commit hook FAILED: PostgreSQL migration compatibility issues found." + echo "Fix the issues above before committing." + exit 1 + fi + echo "All migration compatibility checks passed." +fi diff --git a/scripts/reset_db.py b/scripts/reset_db.py new file mode 100644 index 0000000..240fc7a --- /dev/null +++ b/scripts/reset_db.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +# Add this near the top if you run this standalone often outside app context +import os +import sys +import shutil +# Add project root to path if necessary for 'app' import +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Load environment variables in case DB path relies on them (optional here) +# from dotenv import load_dotenv +# load_dotenv() + +# Check if running within app context already (e.g., via Flask command) +try: + from flask import current_app + # Ensure app context is pushed if needed for config access + app = current_app._get_current_object() + # Make sure db is initialized within the app context if needed + # (SQLAlchemy initialization in app.py handles this mostly) + with app.app_context(): + db = app.extensions['sqlalchemy'].db # Access db via extensions +except (RuntimeError, AttributeError, KeyError): + # If not in app context, import directly + try: + # Ensure this import reflects the updated app.py with the new model + from src.app import app, db + except ImportError as e: + print(f"Error: Could not import 'app' and 'db': {e}") + print("Make sure reset_db.py is runnable and PYTHONPATH is set.") + sys.exit(1) + +def reset_database(delete_uploads=True): + # Determine the database path relative to the instance folder + # Use app config if available + instance_path = app.instance_path if hasattr(app, 'instance_path') else os.path.join(os.getcwd(), 'instance') + try: + # Ensure app context for config access if not already present + with app.app_context(): + # Use absolute path from config + db_uri = app.config.get('SQLALCHEMY_DATABASE_URI', 'sqlite:///instance/transcriptions.db') + # Handle relative vs absolute paths specified in URI + if db_uri.startswith('sqlite:///'): + # Assume absolute path from URI root if starts with '///' + db_path = db_uri.replace('sqlite:///', '/', 1) # Replace only first + # Ensure instance path reflects the directory containing the DB + instance_path = os.path.dirname(db_path) + elif db_uri.startswith('sqlite://'): + # Assume relative path from instance folder + db_filename = db_uri.split('/')[-1] + db_path = os.path.join(instance_path, db_filename) + else: # Handle other DB types or formats if needed + print(f"Warning: Non-SQLite URI detected: {db_uri}. Deletion logic might need adjustment.") + # Attempt to parse or fallback + db_filename = db_uri.split('/')[-1] # Best guess + db_path = os.path.join(instance_path, db_filename) + + except Exception as config_e: + print(f"Error accessing app config for DB path: {config_e}. Using default.") + # Fallback if config access fails + instance_path = os.path.join(os.getcwd(), 'instance') + db_filename = 'transcriptions.db' + db_path = os.path.join(instance_path, db_filename) + + # Ensure instance directory exists + print(f"Ensuring instance directory exists: {instance_path}") + os.makedirs(instance_path, exist_ok=True) + print(f"Database path identified as: {db_path}") + + # Remove existing database if it exists + if os.path.exists(db_path): + print(f"Removing existing database at {db_path}") + try: + os.remove(db_path) + # Also remove journal file if it exists + journal_path = db_path + "-journal" + if os.path.exists(journal_path): + os.remove(journal_path) + print(f"Removing existing journal file at {journal_path}") + except OSError as e: + print(f"Error removing database file: {e}. Check permissions or if it's in use.") + # Decide whether to exit or continue + # sys.exit(1) + + # Create application context to work with the database + try: + with app.app_context(): + print("Creating new database schema (including 'summary' column)...") + # Create all tables defined in models (app.py) + db.create_all() + print("Database schema created successfully!") + except Exception as e: + print(f"Error creating database schema: {e}") + # Attempt rollback if possible (though less relevant for create_all) + try: + db.session.rollback() + except Exception as rb_e: + print(f"Rollback attempt failed: {rb_e}") + sys.exit(1) + + # Delete all files in the uploads directory if requested + if delete_uploads: + try: + uploads_dir = os.path.join(os.getcwd(), 'uploads') + if os.path.exists(uploads_dir): + print(f"Deleting all files in uploads directory: {uploads_dir}") + for filename in os.listdir(uploads_dir): + file_path = os.path.join(uploads_dir, filename) + try: + if os.path.isfile(file_path): + os.remove(file_path) + print(f"Deleted file: {file_path}") + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + print(f"Deleted directory: {file_path}") + except Exception as e: + print(f"Error deleting {file_path}: {e}") + print("All files in uploads directory have been deleted.") + else: + print(f"Uploads directory not found: {uploads_dir}") + # Create the directory if it doesn't exist + os.makedirs(uploads_dir, exist_ok=True) + print(f"Created uploads directory: {uploads_dir}") + except Exception as e: + print(f"Error cleaning uploads directory: {e}") + +if __name__ == "__main__": + print("Attempting to reset the database and clean up all data...") + reset_database(delete_uploads=True) + print("Database reset process finished.") diff --git a/scripts/resize_logo.py b/scripts/resize_logo.py new file mode 100644 index 0000000..58399ad --- /dev/null +++ b/scripts/resize_logo.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Logo Resizer Script for Speakr +Resizes a source PNG image to all required icon sizes for PWA and favicon support. + +Usage: + python resize_logo.py + +Requirements: + pip install Pillow + +This script will create all the necessary icon sizes in the static/img/ directory. +""" + +import sys +import os +from PIL import Image, ImageDraw +import argparse + +def create_maskable_version(image, size): + """Create a maskable version with safe zone padding (20% on all sides)""" + # Calculate the size of the logo with padding + logo_size = int(size * 0.6) # Logo takes 60% of the canvas (20% padding on each side) + + # Create new image with transparent background + maskable = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + + # Resize the original logo + logo_resized = image.resize((logo_size, logo_size), Image.Resampling.LANCZOS) + + # Calculate position to center the logo + x = (size - logo_size) // 2 + y = (size - logo_size) // 2 + + # Paste the logo onto the center of the canvas + maskable.paste(logo_resized, (x, y), logo_resized if logo_resized.mode == 'RGBA' else None) + + return maskable + +def resize_logo(source_path, output_dir="static/img"): + """Resize the source image to all required sizes""" + + # Check if source file exists + if not os.path.exists(source_path): + print(f"Error: Source file '{source_path}' not found!") + return False + + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + try: + # Open the source image + with Image.open(source_path) as img: + # Convert to RGBA if not already (for transparency support) + if img.mode != 'RGBA': + img = img.convert('RGBA') + + print(f"Source image: {img.size[0]}x{img.size[1]} pixels") + print(f"Output directory: {output_dir}") + print() + + # Define all the sizes we need + sizes = { + # Essential PWA icons + 'icon-192x192.png': 192, + 'icon-512x512.png': 512, + + # Additional recommended icons + 'icon-16x16.png': 16, + 'icon-32x32.png': 32, + 'icon-180x180.png': 180, # Apple touch icon + + # Maskable version + 'icon-maskable-512x512.png': 512, + } + + # Resize to each required size + for filename, size in sizes.items(): + output_path = os.path.join(output_dir, filename) + + if 'maskable' in filename: + # Create maskable version with safe zone + resized = create_maskable_version(img, size) + print(f"✓ Created maskable icon: {filename} ({size}x{size})") + else: + # Regular resize + resized = img.resize((size, size), Image.Resampling.LANCZOS) + print(f"✓ Created icon: {filename} ({size}x{size})") + + # Save the resized image + resized.save(output_path, 'PNG', optimize=True) + + print() + print("🎉 All icons created successfully!") + print() + print("Next steps:") + print("1. Replace static/img/favicon.svg with your SVG version (if you have one)") + print("2. Clear browser cache and test the new icons") + print("3. Test PWA installation to verify icons appear correctly") + + return True + + except Exception as e: + print(f"Error processing image: {e}") + return False + +def create_ico_favicon(source_path, output_dir="static/img"): + """Create a multi-size ICO favicon file""" + try: + with Image.open(source_path) as img: + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # Create different sizes for the ICO file + sizes = [16, 32, 48] + images = [] + + for size in sizes: + resized = img.resize((size, size), Image.Resampling.LANCZOS) + images.append(resized) + + # Save as ICO file + ico_path = os.path.join(output_dir, 'favicon.ico') + images[0].save(ico_path, format='ICO', sizes=[(img.width, img.height) for img in images]) + print(f"✓ Created favicon.ico with sizes: {sizes}") + + except Exception as e: + print(f"Warning: Could not create favicon.ico: {e}") + +def main(): + parser = argparse.ArgumentParser(description='Resize logo for Speakr PWA icons') + parser.add_argument('source', help='Source PNG image file') + parser.add_argument('--output-dir', default='static/img', help='Output directory (default: static/img)') + parser.add_argument('--create-ico', action='store_true', help='Also create favicon.ico file') + + args = parser.parse_args() + + print("🎨 Speakr Logo Resizer") + print("=" * 50) + + # Resize the logo + success = resize_logo(args.source, args.output_dir) + + if success and args.create_ico: + print() + create_ico_favicon(args.source, args.output_dir) + + if success: + print() + print("📁 Files created in", args.output_dir + ":") + for file in os.listdir(args.output_dir): + if file.startswith('icon-') and file.endswith('.png'): + file_path = os.path.join(args.output_dir, file) + size = os.path.getsize(file_path) + print(f" {file} ({size:,} bytes)") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python resize_logo.py ") + print("Example: python resize_logo.py my_logo.png") + sys.exit(1) + + main() diff --git a/scripts/resize_logo.sh b/scripts/resize_logo.sh new file mode 100755 index 0000000..dcf0e06 --- /dev/null +++ b/scripts/resize_logo.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# Logo Resizer Script for Speakr (ImageMagick version) +# Resizes a source PNG image to all required icon sizes for PWA and favicon support. +# +# Usage: ./resize_logo.sh +# Requirements: ImageMagick (sudo apt install imagemagick) + +set -e + +# Check if source file is provided +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "Example: $0 my_logo.png" + exit 1 +fi + +SOURCE_FILE="$1" +OUTPUT_DIR="static/img" + +# Check if source file exists +if [ ! -f "$SOURCE_FILE" ]; then + echo "Error: Source file '$SOURCE_FILE' not found!" + exit 1 +fi + +# Check if ImageMagick is installed +if ! command -v convert &> /dev/null; then + echo "Error: ImageMagick is not installed!" + echo "Install it with: sudo apt install imagemagick" + exit 1 +fi + +# Create output directory if it doesn't exist +mkdir -p "$OUTPUT_DIR" + +echo "🎨 Speakr Logo Resizer (ImageMagick)" +echo "==================================================" +echo "Source file: $SOURCE_FILE" +echo "Output directory: $OUTPUT_DIR" +echo + +# Define sizes +declare -A SIZES=( + ["icon-16x16.png"]=16 + ["icon-32x32.png"]=32 + ["icon-180x180.png"]=180 + ["icon-192x192.png"]=192 + ["icon-512x512.png"]=512 +) + +# Resize to each size +for filename in "${!SIZES[@]}"; do + size=${SIZES[$filename]} + output_path="$OUTPUT_DIR/$filename" + + convert "$SOURCE_FILE" -resize "${size}x${size}" "$output_path" + echo "✓ Created icon: $filename (${size}x${size})" +done + +# Create maskable version with padding +echo "✓ Creating maskable icon with safe zone..." +convert "$SOURCE_FILE" -resize 307x307 -gravity center -extent 512x512 -background transparent "$OUTPUT_DIR/icon-maskable-512x512.png" +echo "✓ Created maskable icon: icon-maskable-512x512.png (512x512)" + +# Create favicon.ico (optional) +if command -v convert &> /dev/null; then + echo "✓ Creating favicon.ico..." + convert "$SOURCE_FILE" -resize 16x16 -resize 32x32 -resize 48x48 "$OUTPUT_DIR/favicon.ico" + echo "✓ Created favicon.ico" +fi + +echo +echo "🎉 All icons created successfully!" +echo +echo "📁 Files created:" +ls -la "$OUTPUT_DIR"/icon-*.png "$OUTPUT_DIR"/favicon.ico 2>/dev/null || true + +echo +echo "Next steps:" +echo "1. Replace static/img/favicon.svg with your SVG version (if you have one)" +echo "2. Clear browser cache and test the new icons" +echo "3. Test PWA installation to verify icons appear correctly" diff --git a/scripts/test-docs-build.sh b/scripts/test-docs-build.sh new file mode 100755 index 0000000..f59b9c4 --- /dev/null +++ b/scripts/test-docs-build.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Test script to validate documentation build locally +# This mimics what the GitHub Actions workflow does + +set -e + +echo "Testing documentation build..." + +# Check if we're in the right directory +if [ ! -f "docs/mkdocs.yml" ]; then + echo "Error: docs/mkdocs.yml not found. Run this script from the project root." + exit 1 +fi + +# Create a virtual environment for testing +echo "Creating virtual environment..." +python3 -m venv .venv-docs-test +source .venv-docs-test/bin/activate + +# Install dependencies +echo "Installing dependencies..." +pip install --upgrade pip +pip install -r docs/requirements-docs.txt + +# Build the documentation +echo "Building documentation..." +cd docs +export CI=true # Enable git plugin in CI mode +mkdocs build --strict --site-dir _test_site + +echo "" +echo "✅ Documentation build successful!" +echo "Built site is in: docs/_test_site" +echo "" +echo "To serve locally for testing:" +echo " cd docs && mkdocs serve" + +# Cleanup +cd .. +deactivate +rm -rf .venv-docs-test + +echo "Cleanup complete." \ No newline at end of file diff --git a/scripts/update_version.py b/scripts/update_version.py new file mode 100644 index 0000000..1512864 --- /dev/null +++ b/scripts/update_version.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Simple script to update the VERSION file. +Usage: python update_version.py v0.4.3 +""" +import sys +import re + +def update_version(new_version): + # Validate version format (basic check) + if not re.match(r'^v?\d+\.\d+\.\d+', new_version): + print(f"Warning: Version '{new_version}' doesn't follow standard format (v1.2.3)") + + # Ensure version starts with 'v' + if not new_version.startswith('v'): + new_version = 'v' + new_version + + # Write to VERSION file + try: + with open('VERSION', 'w') as f: + f.write(new_version) + print(f"✅ Updated VERSION file to: {new_version}") + + # Optional: Create git tag if in a git repo + import subprocess + try: + # Check if we're in a git repo + subprocess.check_output(['git', 'status'], stderr=subprocess.DEVNULL) + + # Create and push tag + subprocess.check_output(['git', 'tag', new_version], stderr=subprocess.DEVNULL) + print(f"✅ Created git tag: {new_version}") + + # Ask user if they want to push + response = input("Push tag to remote? (y/N): ").strip().lower() + if response == 'y': + subprocess.check_output(['git', 'push', 'origin', new_version]) + print(f"✅ Pushed tag {new_version} to remote") + + except subprocess.CalledProcessError: + print("ℹ️ Not in a git repo or git tag already exists") + except Exception as e: + print(f"ℹ️ Git operations failed: {e}") + + except Exception as e: + print(f"❌ Failed to update VERSION file: {e}") + return False + + return True + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python update_version.py ") + print("Example: python update_version.py v0.4.3") + print("Example: python update_version.py 0.4.3-alpha") + sys.exit(1) + + new_version = sys.argv[1] + success = update_version(new_version) + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/admin.py b/src/api/admin.py new file mode 100644 index 0000000..cb6e9ab --- /dev/null +++ b/src/api/admin.py @@ -0,0 +1,1157 @@ +""" +Administrative functions and user management. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import time +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * +from src.services.retention import is_recording_exempt_from_deletion, get_retention_days_for_recording, process_auto_deletion +from src.services.embeddings import EMBEDDINGS_AVAILABLE, process_recording_chunks +from src.services.token_tracking import token_tracker +from src.services.audit import audit_delete, audit_edit +from src.services.transcription_tracking import transcription_tracker +from src.config.startup import get_file_monitor_functions + +# Create blueprint +admin_bp = Blueprint('admin', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' +GLOBAL_RETENTION_DAYS = int(os.environ.get('GLOBAL_RETENTION_DAYS', '0')) +DELETION_MODE = os.environ.get('DELETION_MODE', 'hard') + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_admin_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +def csrf_exempt(f): + """Decorator placeholder for CSRF exemption - applied after initialization.""" + from functools import wraps + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + wrapper._csrf_exempt = True + return wrapper + + +# --- Routes --- + +@admin_bp.route('/admin', methods=['GET']) +@login_required +def admin(): + # Check if user is admin OR group admin + is_team_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_team_admin: + flash('You do not have permission to access the admin page.', 'danger') + return redirect(url_for('recordings.index')) + + # Redirect group admins to their dedicated management page + if is_team_admin and not current_user.is_admin: + return redirect(url_for('admin.group_management')) + + # Full admins only get here + user_language = current_user.ui_language if current_user.is_authenticated and current_user.ui_language else 'en' + return render_template('admin.html', + title='Admin Dashboard', + inquire_mode_enabled=ENABLE_INQUIRE_MODE, + global_retention_days=GLOBAL_RETENTION_DAYS, + is_group_admin_only=False, + user_language=user_language) + + +@admin_bp.route('/group-management', methods=['GET']) +@login_required +def group_management(): + """Dedicated group management page for group admins (non-full admins).""" + # Check if user is a group admin + is_team_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + if not is_team_admin: + flash('You do not have permission to access group management.', 'danger') + return redirect(url_for('recordings.index')) + + # If they're a full admin, redirect to main admin dashboard + if current_user.is_admin: + return redirect(url_for('admin.admin')) + + user_language = current_user.ui_language if current_user.is_authenticated and current_user.ui_language else 'en' + return render_template('group-admin.html', + title='Group Management', + global_retention_days=GLOBAL_RETENTION_DAYS, + user_language=user_language) + + + +@admin_bp.route('/admin/users', methods=['GET']) +@login_required +def admin_get_users(): + # Check if user is admin OR group admin + is_team_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_team_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + users = User.query.all() + user_data = [] + + for user in users: + # Get recordings count and storage used + recordings_count = len(user.recordings) + storage_used = sum(r.file_size for r in user.recordings if r.file_size) or 0 + + # Get current month token usage + current_usage = token_tracker.get_monthly_usage(user.id) + usage_percentage = (current_usage / user.monthly_token_budget * 100) if user.monthly_token_budget else 0 + + # Get current month transcription usage + current_transcription_usage = transcription_tracker.get_monthly_usage(user.id) + transcription_usage_percentage = (current_transcription_usage / user.monthly_transcription_budget * 100) if user.monthly_transcription_budget else 0 + + user_data.append({ + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'is_admin': user.is_admin, + 'can_share_publicly': user.can_share_publicly, + 'recordings_count': recordings_count, + 'storage_used': storage_used, + 'monthly_token_budget': user.monthly_token_budget, + 'current_token_usage': current_usage, + 'token_usage_percentage': round(usage_percentage, 1), + 'monthly_transcription_budget': user.monthly_transcription_budget, + 'monthly_transcription_budget_minutes': (user.monthly_transcription_budget // 60) if user.monthly_transcription_budget else None, + 'current_transcription_usage': current_transcription_usage, + 'current_transcription_usage_minutes': current_transcription_usage // 60, + 'transcription_usage_percentage': round(transcription_usage_percentage, 1) + }) + + return jsonify(user_data) + + + +@admin_bp.route('/admin/users', methods=['POST']) +@login_required +def admin_add_user(): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Validate required fields + required_fields = ['username', 'email', 'password'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'Missing required field: {field}'}), 400 + + # Check if username or email already exists + if User.query.filter_by(username=data['username']).first(): + return jsonify({'error': 'Username already exists'}), 400 + + if User.query.filter_by(email=data['email']).first(): + return jsonify({'error': 'Email already exists'}), 400 + + # Create new user + hashed_password = bcrypt.generate_password_hash(data['password']).decode('utf-8') + new_user = User( + username=data['username'], + email=data['email'], + password=hashed_password, + is_admin=data.get('is_admin', False), + monthly_token_budget=data.get('monthly_token_budget'), + monthly_transcription_budget=data.get('monthly_transcription_budget') + ) + + db.session.add(new_user) + db.session.commit() + + return jsonify({ + 'id': new_user.id, + 'username': new_user.username, + 'email': new_user.email, + 'is_admin': new_user.is_admin, + 'recordings_count': 0, + 'storage_used': 0, + 'monthly_token_budget': new_user.monthly_token_budget, + 'current_token_usage': 0, + 'token_usage_percentage': 0, + 'monthly_transcription_budget': new_user.monthly_transcription_budget, + 'monthly_transcription_budget_minutes': (new_user.monthly_transcription_budget // 60) if new_user.monthly_transcription_budget else None, + 'current_transcription_usage': 0, + 'current_transcription_usage_minutes': 0, + 'transcription_usage_percentage': 0 + }), 201 + + + +@admin_bp.route('/admin/users/', methods=['PUT']) +@login_required +def admin_update_user(user_id): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + user = db.session.get(User, user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Update user fields + if 'username' in data and data['username'] != user.username: + # Check if username already exists + if User.query.filter_by(username=data['username']).first(): + return jsonify({'error': 'Username already exists'}), 400 + user.username = data['username'] + + if 'email' in data and data['email'] != user.email: + # Check if email already exists + if User.query.filter_by(email=data['email']).first(): + return jsonify({'error': 'Email already exists'}), 400 + user.email = data['email'] + + if 'password' in data and data['password']: + user.password = bcrypt.generate_password_hash(data['password']).decode('utf-8') + + if 'is_admin' in data: + user.is_admin = data['is_admin'] + + if 'can_share_publicly' in data: + user.can_share_publicly = data['can_share_publicly'] + + if 'monthly_token_budget' in data: + # Allow setting to None (unlimited) or a positive integer + budget = data['monthly_token_budget'] + if budget is None or budget == '' or budget == 0: + user.monthly_token_budget = None + else: + user.monthly_token_budget = int(budget) + + if 'monthly_transcription_budget' in data: + # Allow setting to None (unlimited) or a positive integer (in seconds) + budget = data['monthly_transcription_budget'] + if budget is None or budget == '' or budget == 0: + user.monthly_transcription_budget = None + else: + user.monthly_transcription_budget = int(budget) + + db.session.commit() + + # Get recordings count and storage used + recordings_count = len(user.recordings) + storage_used = sum(r.file_size for r in user.recordings if r.file_size) or 0 + + # Get current month token usage + current_usage = token_tracker.get_monthly_usage(user.id) + usage_percentage = (current_usage / user.monthly_token_budget * 100) if user.monthly_token_budget else 0 + + # Get current month transcription usage + current_transcription_usage = transcription_tracker.get_monthly_usage(user.id) + transcription_usage_percentage = (current_transcription_usage / user.monthly_transcription_budget * 100) if user.monthly_transcription_budget else 0 + + return jsonify({ + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'is_admin': user.is_admin, + 'can_share_publicly': user.can_share_publicly, + 'recordings_count': recordings_count, + 'storage_used': storage_used, + 'monthly_token_budget': user.monthly_token_budget, + 'current_token_usage': current_usage, + 'token_usage_percentage': round(usage_percentage, 1), + 'monthly_transcription_budget': user.monthly_transcription_budget, + 'monthly_transcription_budget_minutes': (user.monthly_transcription_budget // 60) if user.monthly_transcription_budget else None, + 'current_transcription_usage': current_transcription_usage, + 'current_transcription_usage_minutes': current_transcription_usage // 60, + 'transcription_usage_percentage': round(transcription_usage_percentage, 1) + }) + + + +@admin_bp.route('/admin/users/', methods=['DELETE']) +@login_required +def admin_delete_user(user_id): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Prevent deleting self + if user_id == current_user.id: + return jsonify({'error': 'Cannot delete your own account'}), 400 + + user = db.session.get(User, user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Capture details before deletion for audit + _audit_details = {'username': user.username, 'email': user.email} + + # Delete user's recordings and audio files + total_chunks = 0 + if ENABLE_INQUIRE_MODE: + total_chunks = TranscriptChunk.query.filter_by(user_id=user_id).count() + if total_chunks > 0: + current_app.logger.info(f"Deleting {total_chunks} transcript chunks with embeddings for user {user_id}") + + for recording in user.recordings: + try: + if recording.audio_path and os.path.exists(recording.audio_path): + os.remove(recording.audio_path) + except Exception as e: + current_app.logger.error(f"Error deleting audio file {recording.audio_path}: {e}") + + # Delete user (cascade will handle all related data including chunks/embeddings) + db.session.delete(user) + db.session.commit() + + # Audit AFTER successful deletion to avoid logging failures + audit_delete('user', user_id, details=_audit_details) + db.session.commit() + + if ENABLE_INQUIRE_MODE and total_chunks > 0: + current_app.logger.info(f"Successfully deleted {total_chunks} embeddings and chunks for user {user_id}") + + return jsonify({'success': True}) + + + +@admin_bp.route('/admin/users//toggle-admin', methods=['POST']) +@login_required +def admin_toggle_admin(user_id): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Prevent changing own admin status + if user_id == current_user.id: + return jsonify({'error': 'Cannot change your own admin status'}), 400 + + user = db.session.get(User, user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Toggle admin status + user.is_admin = not user.is_admin + db.session.commit() + + return jsonify({'success': True, 'is_admin': user.is_admin}) + + + +@admin_bp.route('/admin/stats', methods=['GET']) +@login_required +def admin_get_stats(): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Get total users + total_users = User.query.count() + + # Get total recordings + total_recordings = Recording.query.count() + + # Get recordings by status + completed_recordings = Recording.query.filter_by(status='COMPLETED').count() + processing_recordings = Recording.query.filter(Recording.status.in_(['PROCESSING', 'SUMMARIZING'])).count() + pending_recordings = Recording.query.filter_by(status='PENDING').count() + failed_recordings = Recording.query.filter_by(status='FAILED').count() + + # Get total storage used + total_storage = db.session.query(db.func.sum(Recording.file_size)).scalar() or 0 + + # Get top users by storage + top_users_query = db.session.query( + User.id, + User.username, + db.func.count(Recording.id).label('recordings_count'), + db.func.sum(Recording.file_size).label('storage_used') + ).join(Recording, User.id == Recording.user_id, isouter=True) \ + .group_by(User.id) \ + .order_by(db.func.sum(Recording.file_size).desc()) \ + .limit(5) + + top_users = [] + for user_id, username, recordings_count, storage_used in top_users_query: + top_users.append({ + 'id': user_id, + 'username': username, + 'recordings_count': recordings_count or 0, + 'storage_used': storage_used or 0 + }) + + # Get total queries (chat requests) + # This is a placeholder - you would need to track this in your database + total_queries = 0 + + return jsonify({ + 'total_users': total_users, + 'total_recordings': total_recordings, + 'completed_recordings': completed_recordings, + 'processing_recordings': processing_recordings, + 'pending_recordings': pending_recordings, + 'failed_recordings': failed_recordings, + 'total_storage': total_storage, + 'top_users': top_users, + 'total_queries': total_queries + }) + + +# --- Token Usage Stats --- + +@admin_bp.route('/admin/token-stats', methods=['GET']) +@login_required +def admin_get_token_stats(): + """Get overall token usage statistics.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + # Get today's usage + today_usage = token_tracker.get_today_usage() + + # Get current month usage for all users + monthly_stats = token_tracker.get_monthly_stats(months=1) + current_month = monthly_stats[-1] if monthly_stats else {'tokens': 0, 'cost': 0} + + # Get per-user stats for current month + user_stats = token_tracker.get_user_stats() + + # Calculate totals + total_monthly_tokens = current_month.get('tokens', 0) + total_monthly_cost = current_month.get('cost', 0) + + return jsonify({ + 'today': today_usage, + 'current_month': { + 'tokens': total_monthly_tokens, + 'cost': total_monthly_cost + }, + 'user_count_with_usage': len([u for u in user_stats if u['current_usage'] > 0]), + 'users_over_80_percent': len([u for u in user_stats if u['percentage'] >= 80]), + 'users_at_100_percent': len([u for u in user_stats if u['percentage'] >= 100]) + }) + + except Exception as e: + current_app.logger.error(f"Error getting token stats: {e}") + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/admin/token-stats/daily', methods=['GET']) +@login_required +def admin_get_daily_token_stats(): + """Get daily token usage for charts (last 30 days).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + days = request.args.get('days', 30, type=int) + user_id = request.args.get('user_id', type=int) + + daily_stats = token_tracker.get_daily_stats(days=days, user_id=user_id) + + return jsonify({ + 'stats': daily_stats, + 'days': days + }) + + except Exception as e: + current_app.logger.error(f"Error getting daily token stats: {e}") + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/admin/token-stats/monthly', methods=['GET']) +@login_required +def admin_get_monthly_token_stats(): + """Get monthly token usage for charts (last 12 months).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + months = request.args.get('months', 12, type=int) + + monthly_stats = token_tracker.get_monthly_stats(months=months) + + return jsonify({ + 'stats': monthly_stats, + 'months': months + }) + + except Exception as e: + current_app.logger.error(f"Error getting monthly token stats: {e}") + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/admin/token-stats/users', methods=['GET']) +@login_required +def admin_get_user_token_stats(): + """Get per-user token usage for current month.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + user_stats = token_tracker.get_user_stats() + + return jsonify({ + 'users': user_stats + }) + + except Exception as e: + current_app.logger.error(f"Error getting user token stats: {e}") + return jsonify({'error': str(e)}), 500 + + +# --- Transcription Usage Stats --- + + +@admin_bp.route('/admin/transcription-stats', methods=['GET']) +@login_required +def admin_get_transcription_stats(): + """Get overall transcription usage statistics.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + # Get today's usage + today_usage = transcription_tracker.get_today_usage() + + # Get current month usage for all users + monthly_stats = transcription_tracker.get_monthly_stats(months=1) + current_month = monthly_stats[-1] if monthly_stats else {'seconds': 0, 'minutes': 0, 'cost': 0} + + # Get per-user stats for current month + user_stats = transcription_tracker.get_user_stats() + + # Calculate totals + total_monthly_seconds = current_month.get('seconds', 0) + total_monthly_cost = current_month.get('cost', 0) + + return jsonify({ + 'today': today_usage, + 'current_month': { + 'seconds': total_monthly_seconds, + 'minutes': total_monthly_seconds // 60, + 'cost': total_monthly_cost + }, + 'user_count_with_usage': len([u for u in user_stats if u['current_usage_seconds'] > 0]), + 'users_over_80_percent': len([u for u in user_stats if u['percentage'] >= 80]), + 'users_at_100_percent': len([u for u in user_stats if u['percentage'] >= 100]) + }) + + except Exception as e: + current_app.logger.error(f"Error getting transcription stats: {e}") + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/admin/transcription-stats/daily', methods=['GET']) +@login_required +def admin_get_daily_transcription_stats(): + """Get daily transcription usage for charts (last 30 days).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + days = request.args.get('days', 30, type=int) + user_id = request.args.get('user_id', type=int) + + daily_stats = transcription_tracker.get_daily_stats(days=days, user_id=user_id) + + return jsonify({ + 'stats': daily_stats, + 'days': days + }) + + except Exception as e: + current_app.logger.error(f"Error getting daily transcription stats: {e}") + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/admin/transcription-stats/monthly', methods=['GET']) +@login_required +def admin_get_monthly_transcription_stats(): + """Get monthly transcription usage for charts (last 12 months).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + months = request.args.get('months', 12, type=int) + + monthly_stats = transcription_tracker.get_monthly_stats(months=months) + + return jsonify({ + 'stats': monthly_stats, + 'months': months + }) + + except Exception as e: + current_app.logger.error(f"Error getting monthly transcription stats: {e}") + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/admin/transcription-stats/users', methods=['GET']) +@login_required +def admin_get_user_transcription_stats(): + """Get per-user transcription usage for current month.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + user_stats = transcription_tracker.get_user_stats() + + return jsonify({ + 'users': user_stats + }) + + except Exception as e: + current_app.logger.error(f"Error getting user transcription stats: {e}") + return jsonify({'error': str(e)}), 500 + + +# --- Transcript Template Routes --- + + +@admin_bp.route('/admin/settings', methods=['GET']) +@login_required +def admin_get_settings(): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + settings = SystemSetting.query.all() + return jsonify([setting.to_dict() for setting in settings]) + + + +@admin_bp.route('/admin/settings', methods=['POST']) +@login_required +def admin_update_setting(): + # Check if user is admin + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + key = data.get('key') + value = data.get('value') + description = data.get('description') + setting_type = data.get('setting_type', 'string') + + if not key: + return jsonify({'error': 'Setting key is required'}), 400 + + # Validate setting type + valid_types = ['string', 'integer', 'boolean', 'float'] + if setting_type not in valid_types: + return jsonify({'error': f'Invalid setting type. Must be one of: {", ".join(valid_types)}'}), 400 + + # Validate value based on type + if setting_type == 'integer': + try: + int(value) if value is not None and value != '' else None + except (ValueError, TypeError): + return jsonify({'error': 'Value must be a valid integer'}), 400 + elif setting_type == 'float': + try: + float(value) if value is not None and value != '' else None + except (ValueError, TypeError): + return jsonify({'error': 'Value must be a valid number'}), 400 + elif setting_type == 'boolean': + if value not in ['true', 'false', '1', '0', 'yes', 'no', True, False, 1, 0]: + return jsonify({'error': 'Value must be a valid boolean (true/false, 1/0, yes/no)'}), 400 + + try: + setting = SystemSetting.set_setting(key, value, description, setting_type) + + # Update Flask's MAX_CONTENT_LENGTH immediately when file size limit changes + if key == 'max_file_size_mb' and value: + try: + new_limit = int(value) * 1024 * 1024 + current_app.config['MAX_CONTENT_LENGTH'] = new_limit + current_app.logger.info(f"Updated MAX_CONTENT_LENGTH to {value}MB") + except (ValueError, TypeError): + pass + + return jsonify(setting.to_dict()) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating setting {key}: {e}") + return jsonify({'error': str(e)}), 500 + +# --- Configuration API --- + + +@admin_bp.route('/admin/auto-deletion/run', methods=['POST']) +@login_required +@csrf_exempt # Exempt since already protected by admin authentication +def run_auto_deletion(): + """Admin endpoint to manually trigger auto-deletion process.""" + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + try: + stats = process_auto_deletion() + return jsonify(stats) + except Exception as e: + current_app.logger.error(f"Error running auto-deletion: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/auto-deletion/stats', methods=['GET']) +@login_required +def get_auto_deletion_stats(): + """Get statistics about recordings eligible for auto-deletion.""" + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + try: + stats = { + 'enabled': ENABLE_AUTO_DELETION, + 'global_retention_days': GLOBAL_RETENTION_DAYS, + 'deletion_mode': DELETION_MODE, + 'eligible_count': 0, + 'exempted_count': 0, + 'no_retention_count': 0, + 'archived_count': 0 + } + + if ENABLE_AUTO_DELETION: + # Check ALL completed recordings (per-recording retention) + all_recordings = Recording.query.filter( + Recording.status == 'COMPLETED' + ).all() + + eligible = 0 + exempted = 0 + no_retention = 0 + current_time = datetime.utcnow() + + for recording in all_recordings: + # Check if exempt from deletion entirely + if is_recording_exempt_from_deletion(recording): + exempted += 1 + continue + + # Get the effective retention period for this recording + retention_days = get_retention_days_for_recording(recording) + + if not retention_days: + no_retention += 1 + continue + + # Calculate cutoff for this specific recording + cutoff_date = current_time - timedelta(days=retention_days) + + # Check if past retention period + if recording.created_at < cutoff_date: + eligible += 1 + + stats['eligible_count'] = eligible + stats['exempted_count'] = exempted + stats['no_retention_count'] = no_retention + + # Count already archived recordings + stats['archived_count'] = Recording.query.filter( + Recording.audio_deleted_at.is_not(None) + ).count() + + return jsonify(stats) + except Exception as e: + current_app.logger.error(f"Error fetching auto-deletion stats: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/auto-deletion/preview', methods=['GET']) +@login_required +def preview_auto_deletion(): + """Preview what would be deleted without actually deleting (dry-run).""" + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + try: + if not ENABLE_AUTO_DELETION: + return jsonify({'error': 'Auto-deletion is not enabled'}), 400 + + # Check ALL completed recordings (per-recording retention) + all_recordings = Recording.query.filter( + Recording.status == 'COMPLETED' + ).all() + + preview_data = { + 'total_checked': len(all_recordings), + 'would_delete': [], + 'would_exempt': [], + 'no_retention': [], + 'deletion_mode': DELETION_MODE, + 'global_retention_days': GLOBAL_RETENTION_DAYS, + 'supports_per_recording_retention': True + } + + current_time = datetime.utcnow() + + for recording in all_recordings: + rec_data = { + 'id': recording.id, + 'title': recording.title, + 'created_at': recording.created_at.isoformat(), + 'age_days': (current_time - recording.created_at).days, + 'tags': [tag.tag.name for tag in recording.tag_associations] + } + + # Check if exempt from deletion entirely + if is_recording_exempt_from_deletion(recording): + rec_data['exempt_reason'] = [] + if recording.deletion_exempt: + rec_data['exempt_reason'].append('manually_exempted') + for tag_assoc in recording.tag_associations: + if tag_assoc.tag.protect_from_deletion: + rec_data['exempt_reason'].append(f'tag:{tag_assoc.tag.name}') + preview_data['would_exempt'].append(rec_data) + continue + + # Get the effective retention period for this recording + retention_days = get_retention_days_for_recording(recording) + + if not retention_days: + rec_data['reason'] = 'no_retention_policy' + preview_data['no_retention'].append(rec_data) + continue + + rec_data['retention_days'] = retention_days + + # Calculate cutoff for this specific recording + cutoff_date = current_time - timedelta(days=retention_days) + + # Check if past retention period + if recording.created_at < cutoff_date: + rec_data['days_past_retention'] = (current_time - cutoff_date).days + preview_data['would_delete'].append(rec_data) + + return jsonify(preview_data) + except Exception as e: + current_app.logger.error(f"Error previewing auto-deletion: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/api/admin/migrate_recordings', methods=['POST']) +@login_required +def migrate_existing_recordings_api(): + """API endpoint to migrate existing recordings for inquire mode (admin only).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized. Admin access required.'}), 403 + + try: + # Count recordings that need processing + completed_recordings = Recording.query.filter_by(status='COMPLETED').all() + recordings_needing_processing = [] + + for recording in completed_recordings: + if recording.transcription: # Has transcription + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count() + if chunk_count == 0: # No chunks yet + recordings_needing_processing.append(recording) + + if len(recordings_needing_processing) == 0: + return jsonify({ + 'success': True, + 'message': 'All recordings are already processed for inquire mode', + 'processed': 0, + 'total': len(completed_recordings) + }) + + # Process in small batches to avoid timeout + batch_size = min(5, len(recordings_needing_processing)) # Process max 5 at a time + processed = 0 + errors = 0 + + for i in range(min(batch_size, len(recordings_needing_processing))): + recording = recordings_needing_processing[i] + try: + success = process_recording_chunks(recording.id) + if success: + processed += 1 + else: + errors += 1 + except Exception as e: + current_app.logger.error(f"Error processing recording {recording.id} for migration: {e}") + errors += 1 + + remaining = max(0, len(recordings_needing_processing) - batch_size) + + return jsonify({ + 'success': True, + 'message': f'Processed {processed} recordings. {remaining} remaining.', + 'processed': processed, + 'errors': errors, + 'remaining': remaining, + 'total': len(recordings_needing_processing) + }) + + except Exception as e: + current_app.logger.error(f"Error in migration API: {e}") + return jsonify({'error': str(e)}), 500 + + +# --- Auto-Processing File Monitor Integration --- + + +@admin_bp.route('/admin/auto-process/status', methods=['GET']) +@login_required +def admin_get_auto_process_status(): + """Get the status of the automated file processing system.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + _, _, get_file_monitor_status = get_file_monitor_functions() + status = get_file_monitor_status() + + # Add configuration info + config = { + 'enabled': os.environ.get('ENABLE_AUTO_PROCESSING', 'false').lower() == 'true', + 'watch_directory': os.environ.get('AUTO_PROCESS_WATCH_DIR', '/data/auto-process'), + 'check_interval': int(os.environ.get('AUTO_PROCESS_CHECK_INTERVAL', '30')), + 'mode': os.environ.get('AUTO_PROCESS_MODE', 'admin_only'), + 'default_username': os.environ.get('AUTO_PROCESS_DEFAULT_USERNAME') + } + + return jsonify({ + 'status': status, + 'config': config + }) + + except Exception as e: + current_app.logger.error(f"Error getting auto-process status: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/auto-process/start', methods=['POST']) +@login_required +def admin_start_auto_process(): + """Start the automated file processing system.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + start_file_monitor, _, _ = get_file_monitor_functions() + start_file_monitor() + return jsonify({'success': True, 'message': 'Auto-processing started'}) + except Exception as e: + current_app.logger.error(f"Error starting auto-process: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/auto-process/stop', methods=['POST']) +@login_required +def admin_stop_auto_process(): + """Stop the automated file processing system.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + _, stop_file_monitor, _ = get_file_monitor_functions() + stop_file_monitor() + return jsonify({'success': True, 'message': 'Auto-processing stopped'}) + except Exception as e: + current_app.logger.error(f"Error stopping auto-process: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/auto-process/config', methods=['POST']) +@login_required +def admin_update_auto_process_config(): + """Update auto-processing configuration (requires restart).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + data = request.json + if not data: + return jsonify({'error': 'No configuration data provided'}), 400 + + # This endpoint would typically update environment variables or config files + # For now, we'll just return the current config and note that restart is required + return jsonify({ + 'success': True, + 'message': 'Configuration updated. Restart required to apply changes.', + 'note': 'Environment variables need to be updated manually and application restarted.' + }) + + except Exception as e: + current_app.logger.error(f"Error updating auto-process config: {e}") + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/inquire/process-recordings', methods=['POST']) +@login_required +def admin_process_recordings_for_inquire(): + """Process all remaining recordings for inquire mode (chunk and embed them).""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + # Get optional parameters from request + data = request.json or {} + batch_size = data.get('batch_size', 10) + max_recordings = data.get('max_recordings', None) + + # Find recordings that need processing + completed_recordings = Recording.query.filter_by(status='COMPLETED').all() + recordings_needing_processing = [] + + for recording in completed_recordings: + if recording.transcription: # Has transcription + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count() + if chunk_count == 0: # No chunks yet + recordings_needing_processing.append(recording) + if max_recordings and len(recordings_needing_processing) >= max_recordings: + break + + total_to_process = len(recordings_needing_processing) + + if total_to_process == 0: + return jsonify({ + 'success': True, + 'message': 'All recordings are already processed for inquire mode.', + 'processed': 0, + 'total': 0 + }) + + # Process recordings in batches + processed = 0 + failed = [] + + for recording in recordings_needing_processing: + try: + success = process_recording_chunks(recording.id) + if success: + processed += 1 + current_app.logger.info(f"Admin API: Processed chunks for recording: {recording.title} ({recording.id})") + else: + failed.append({'id': recording.id, 'title': recording.title, 'reason': 'Processing returned false'}) + except Exception as e: + current_app.logger.error(f"Admin API: Failed to process recording {recording.id}: {e}") + failed.append({'id': recording.id, 'title': recording.title, 'reason': str(e)}) + + # Commit after each batch + if processed % batch_size == 0: + db.session.commit() + + # Final commit + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'Processed {processed} out of {total_to_process} recordings.', + 'processed': processed, + 'total': total_to_process, + 'failed': failed + }) + + except Exception as e: + current_app.logger.error(f"Error in admin process recordings endpoint: {e}") + db.session.rollback() + return jsonify({'error': str(e)}), 500 + + + +@admin_bp.route('/admin/inquire/status', methods=['GET']) +@login_required +def admin_inquire_status(): + """Get the status of recordings for inquire mode.""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + # Count total completed recordings + total_completed = Recording.query.filter_by(status='COMPLETED').count() + + # Count recordings with transcriptions + recordings_with_transcriptions = Recording.query.filter( + Recording.status == 'COMPLETED', + Recording.transcription.isnot(None), + Recording.transcription != '' + ).count() + + # Count recordings that have been processed for inquire mode + processed_recordings = db.session.query(Recording.id).join( + TranscriptChunk, Recording.id == TranscriptChunk.recording_id + ).distinct().count() + + # Count recordings that still need processing + completed_recordings = Recording.query.filter_by(status='COMPLETED').all() + need_processing = 0 + + for recording in completed_recordings: + if recording.transcription: # Has transcription + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count() + if chunk_count == 0: # No chunks yet + need_processing += 1 + + # Get total chunks and embeddings count + total_chunks = TranscriptChunk.query.count() + + return jsonify({ + 'total_completed_recordings': total_completed, + 'recordings_with_transcriptions': recordings_with_transcriptions, + 'processed_for_inquire': processed_recordings, + 'need_processing': need_processing, + 'total_chunks': total_chunks, + 'embeddings_available': EMBEDDINGS_AVAILABLE + }) + + except Exception as e: + current_app.logger.error(f"Error getting inquire status: {e}") + return jsonify({'error': str(e)}), 500 + +# --- Group Management API (Admin Only) --- + + diff --git a/src/api/api_v1.py b/src/api/api_v1.py new file mode 100644 index 0000000..74cb081 --- /dev/null +++ b/src/api/api_v1.py @@ -0,0 +1,2259 @@ +""" +API v1 - RESTful API for external integrations. + +This blueprint provides a comprehensive REST API for: +- Dashboard widgets (gethomepage.dev, etc.) +- Automation tools (n8n, Zapier, etc.) +- Third-party integrations + +All endpoints require token authentication via: +- Authorization: Bearer +- X-API-Token: +- API-Token: +- ?token= query parameter +""" + +import os +import re +import json +from datetime import datetime, date, timedelta +from typing import Optional + +from flask import Blueprint, jsonify, request, current_app, send_file +from flask_login import login_required, current_user +from sqlalchemy import func, extract, or_, and_ + +from src.database import db +from src.models import Recording, User, Tag, RecordingTag, Speaker, Event +from src.models.processing_job import ProcessingJob +from src.models.token_usage import TokenUsage +from src.models.transcription_usage import TranscriptionUsage +from src.services.token_tracking import TokenTracker +from src.services.transcription_tracking import transcription_tracker +from src.file_exporter import format_transcription_with_template +from src.api.recordings import upload_file as _upload_file_ui + +# Create blueprint with /api/v1 prefix +api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') + +# Global helpers (will be injected from app) +has_recording_access = None +get_user_recording_status = None +set_user_recording_status = None +enrich_recording_dict_with_user_status = None +bcrypt = None +csrf = None +limiter = None +chunking_service = None + +# Token tracker instance +token_tracker = TokenTracker() + + +def init_api_v1_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, get_user_recording_status, set_user_recording_status + global enrich_recording_dict_with_user_status, bcrypt, csrf, limiter, chunking_service + has_recording_access = kwargs.get('has_recording_access') + get_user_recording_status = kwargs.get('get_user_recording_status') + set_user_recording_status = kwargs.get('set_user_recording_status') + enrich_recording_dict_with_user_status = kwargs.get('enrich_recording_dict_with_user_status') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + chunking_service = kwargs.get('chunking_service') + + +def format_bytes(bytes_value: int) -> str: + """Format bytes to human-readable string.""" + if bytes_value is None: + bytes_value = 0 + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_value < 1024: + return f"{bytes_value:.1f} {unit}" + bytes_value /= 1024 + return f"{bytes_value:.1f} PB" + + +# ============================================================================= +# OpenAPI Documentation +# ============================================================================= + +OPENAPI_SPEC = { + "openapi": "3.0.3", + "info": { + "title": "Speakr API", + "description": "REST API for Speakr - Audio transcription and note-taking application.\n\n## Authentication\nAll endpoints require token authentication via one of:\n- `Authorization: Bearer `\n- `X-API-Token: `\n- `API-Token: `\n- `?token=` query parameter\n\nGenerate tokens in Settings > API Tokens.", + "version": "1.0.0" + }, + "servers": [{"url": "/api/v1", "description": "API v1"}], + "components": { + "securitySchemes": { + "bearerAuth": {"type": "http", "scheme": "bearer"}, + "apiKeyHeader": {"type": "apiKey", "in": "header", "name": "X-API-Token"}, + "apiKeyQuery": {"type": "apiKey", "in": "query", "name": "token"} + }, + "schemas": { + "Recording": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "title": {"type": "string"}, + "status": {"type": "string", "enum": ["PENDING", "PROCESSING", "SUMMARIZING", "COMPLETED", "FAILED"]}, + "created_at": {"type": "string", "format": "date-time"}, + "meeting_date": {"type": "string", "format": "date-time"}, + "file_size": {"type": "integer"}, + "participants": {"type": "string"}, + "is_inbox": {"type": "boolean"}, + "is_highlighted": {"type": "boolean"}, + "tags": {"type": "array", "items": {"$ref": "#/components/schemas/Tag"}} + } + }, + "Tag": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "color": {"type": "string"}, + "custom_prompt": {"type": "string"}, + "default_language": {"type": "string"}, + "default_min_speakers": {"type": "integer"}, + "default_max_speakers": {"type": "integer"} + } + }, + "Speaker": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "use_count": {"type": "integer"}, + "has_voice_profile": {"type": "boolean"} + } + }, + "Error": { + "type": "object", + "properties": {"error": {"type": "string"}} + } + } + }, + "security": [{"bearerAuth": []}, {"apiKeyHeader": []}, {"apiKeyQuery": []}], + "paths": { + "/stats": { + "get": { + "tags": ["Stats"], + "summary": "Get system statistics", + "description": "Returns stats compatible with gethomepage.dev widgets", + "parameters": [{"name": "scope", "in": "query", "schema": {"type": "string", "enum": ["user", "all"], "default": "user"}, "description": "user=personal stats, all=global (admin only)"}], + "responses": {"200": {"description": "Stats object"}} + } + }, + "/recordings": { + "get": { + "tags": ["Recordings"], + "summary": "List recordings", + "parameters": [ + {"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}}, + {"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 25, "maximum": 100}}, + {"name": "status", "in": "query", "schema": {"type": "string", "enum": ["all", "pending", "processing", "completed", "failed"]}}, + {"name": "sort_by", "in": "query", "schema": {"type": "string", "enum": ["created_at", "meeting_date", "title", "file_size"]}}, + {"name": "sort_order", "in": "query", "schema": {"type": "string", "enum": ["asc", "desc"]}}, + {"name": "tag_id", "in": "query", "schema": {"type": "integer"}}, + {"name": "q", "in": "query", "schema": {"type": "string"}, "description": "Search query"} + ], + "responses": {"200": {"description": "Paginated list of recordings"}} + } + }, + "/recordings/{id}": { + "get": { + "tags": ["Recordings"], + "summary": "Get recording details", + "parameters": [ + {"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}, + {"name": "format", "in": "query", "schema": {"type": "string", "enum": ["full", "minimal"]}, "description": "minimal excludes large text fields"}, + {"name": "include", "in": "query", "schema": {"type": "string"}, "description": "Comma-separated: transcription,summary,notes"} + ], + "responses": {"200": {"description": "Recording details"}, "404": {"description": "Not found"}} + }, + "patch": { + "tags": ["Recordings"], + "summary": "Update recording", + "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"title": {"type": "string"}, "participants": {"type": "string"}, "notes": {"type": "string"}, "summary": {"type": "string"}, "meeting_date": {"type": "string"}, "is_inbox": {"type": "boolean"}, "is_highlighted": {"type": "boolean"}}}}}}, + "responses": {"200": {"description": "Updated recording"}} + }, + "delete": { + "tags": ["Recordings"], + "summary": "Delete recording", + "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], + "responses": {"200": {"description": "Deleted"}, "403": {"description": "Permission denied"}} + } + }, + "/recordings/{id}/transcript": { + "get": { + "tags": ["Recordings"], + "summary": "Get transcript", + "parameters": [ + {"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}, + {"name": "format", "in": "query", "schema": {"type": "string", "enum": ["json", "text", "srt", "vtt"], "default": "json"}} + ], + "responses": {"200": {"description": "Transcript in requested format"}} + } + }, + "/recordings/{id}/summary": { + "get": {"tags": ["Recordings"], "summary": "Get summary", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Summary markdown"}}}, + "put": {"tags": ["Recordings"], "summary": "Replace summary", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["summary"], "properties": {"summary": {"type": "string"}}}}}}, "responses": {"200": {"description": "Updated"}}} + }, + "/recordings/{id}/notes": { + "get": {"tags": ["Recordings"], "summary": "Get notes", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Notes markdown"}}}, + "put": {"tags": ["Recordings"], "summary": "Replace notes", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["notes"], "properties": {"notes": {"type": "string"}}}}}}, "responses": {"200": {"description": "Updated"}}} + }, + "/recordings/{id}/status": { + "get": {"tags": ["Recordings"], "summary": "Get processing status", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Status with queue position"}}} + }, + "/recordings/{id}/transcribe": { + "post": {"tags": ["Processing"], "summary": "Queue transcription", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"language": {"type": "string"}, "min_speakers": {"type": "integer"}, "max_speakers": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Job queued"}}} + }, + "/recordings/{id}/summarize": { + "post": {"tags": ["Processing"], "summary": "Queue summarization", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"custom_prompt": {"type": "string"}}}}}}, "responses": {"200": {"description": "Job queued"}}} + }, + "/recordings/{id}/chat": { + "post": {"tags": ["Chat"], "summary": "Chat about recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string"}, "conversation_history": {"type": "array"}}}}}}, "responses": {"200": {"description": "Chat response"}}} + }, + "/recordings/{id}/events": { + "get": {"tags": ["Events"], "summary": "Get calendar events", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "List of events"}}} + }, + "/recordings/{id}/events/ics": { + "get": {"tags": ["Events"], "summary": "Download events as ICS", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "ICS file", "content": {"text/calendar": {}}}}} + }, + "/recordings/{id}/audio": { + "get": {"tags": ["Audio"], "summary": "Download audio", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}, {"name": "download", "in": "query", "schema": {"type": "boolean"}}], "responses": {"200": {"description": "Audio file"}}} + }, + "/recordings/{id}/tags": { + "post": {"tags": ["Tags"], "summary": "Add tags to recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"tag_ids": {"type": "array", "items": {"type": "integer"}}}}}}}, "responses": {"200": {"description": "Tags added"}}} + }, + "/recordings/{id}/tags/{tag_id}": { + "delete": {"tags": ["Tags"], "summary": "Remove tag from recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}, {"name": "tag_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Tag removed"}}} + }, + "/recordings/{id}/speakers": { + "get": {"tags": ["Speakers"], "summary": "Get speakers in recording", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Speakers with suggestions"}}} + }, + "/recordings/{id}/speakers/assign": { + "put": { + "tags": ["Speakers"], + "summary": "Assign speaker names to transcription", + "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["speaker_map"], "properties": {"speaker_map": {"type": "object", "description": "Map of speaker labels to names. Values can be: string (name) or object {name, isMe}."}, "regenerate_summary": {"type": "boolean", "default": False}}}}}}, + "responses": {"200": {"description": "Speakers assigned"}, "404": {"description": "Recording not found"}, "403": {"description": "Permission denied"}} + } + }, + "/recordings/{id}/speakers/identify": { + "post": { + "tags": ["Speakers"], + "summary": "Auto-identify speakers via LLM", + "description": "Analyzes transcript context to suggest speaker names. Returns suggestions only - does not modify the recording.", + "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], + "responses": {"200": {"description": "Speaker identification suggestions"}, "400": {"description": "Transcription not available or unsupported format"}} + } + }, + "/recordings/batch": { + "patch": {"tags": ["Batch"], "summary": "Batch update recordings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["recording_ids", "updates"], "properties": {"recording_ids": {"type": "array", "items": {"type": "integer"}}, "updates": {"type": "object"}}}}}}, "responses": {"200": {"description": "Batch results"}}}, + "delete": {"tags": ["Batch"], "summary": "Batch delete recordings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["recording_ids"], "properties": {"recording_ids": {"type": "array", "items": {"type": "integer"}}}}}}}, "responses": {"200": {"description": "Batch results"}}} + }, + "/recordings/batch/transcribe": { + "post": {"tags": ["Batch"], "summary": "Batch queue transcriptions", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["recording_ids"], "properties": {"recording_ids": {"type": "array", "items": {"type": "integer"}}}}}}}, "responses": {"200": {"description": "Batch results"}}} + }, + "/recordings/upload": { + "post": { + "tags": ["Recordings"], + "summary": "Upload a recording (multipart form-data) and queue transcription", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": {"type": "string", "format": "binary"}, + "notes": {"type": "string"}, + "file_last_modified": {"type": "string"}, + "language": {"type": "string"}, + "min_speakers": {"type": "integer"}, + "max_speakers": {"type": "integer"}, + "tag_id": {"type": "integer"}, + "tag_ids[0]": {"type": "integer"}, + "tag_ids[1]": {"type": "integer"} + } + } + } + } + }, + "responses": {"202": {"description": "Upload accepted and queued"}} + } + }, + "/tags": { + "get": {"tags": ["Tags"], "summary": "List tags", "responses": {"200": {"description": "List of tags"}}}, + "post": {"tags": ["Tags"], "summary": "Create tag", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}, "color": {"type": "string"}, "custom_prompt": {"type": "string"}, "default_language": {"type": "string"}, "default_min_speakers": {"type": "integer"}, "default_max_speakers": {"type": "integer"}}}}}}, "responses": {"201": {"description": "Tag created"}}} + }, + "/tags/{id}": { + "put": {"tags": ["Tags"], "summary": "Update tag", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "color": {"type": "string"}, "custom_prompt": {"type": "string"}}}}}}, "responses": {"200": {"description": "Tag updated"}}}, + "delete": {"tags": ["Tags"], "summary": "Delete tag", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Tag deleted"}}} + }, + "/speakers": { + "get": {"tags": ["Speakers"], "summary": "List speakers", "responses": {"200": {"description": "List of speakers"}}}, + "post": {"tags": ["Speakers"], "summary": "Create speaker", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}}}}, "responses": {"201": {"description": "Speaker created"}}} + }, + "/speakers/{id}": { + "put": {"tags": ["Speakers"], "summary": "Update speaker", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}}}, "responses": {"200": {"description": "Speaker updated"}}}, + "delete": {"tags": ["Speakers"], "summary": "Delete speaker", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Speaker deleted"}}} + }, + "/settings/auto-summarization": { + "put": { + "tags": ["Settings"], + "summary": "Toggle auto-summarization", + "description": "Enable or disable auto-summarization for the current user.", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["enabled"], "properties": {"enabled": {"type": "boolean"}}}}}}, + "responses": {"200": {"description": "Setting updated"}} + } + } + }, + "tags": [ + {"name": "Stats", "description": "System statistics for dashboards"}, + {"name": "Recordings", "description": "Recording CRUD operations"}, + {"name": "Processing", "description": "Transcription and summarization"}, + {"name": "Chat", "description": "Chat with recordings"}, + {"name": "Events", "description": "Calendar events"}, + {"name": "Audio", "description": "Audio file operations"}, + {"name": "Tags", "description": "Tag management"}, + {"name": "Speakers", "description": "Speaker management"}, + {"name": "Batch", "description": "Batch operations"}, + {"name": "Settings", "description": "User settings"} + ] +} + + +@api_v1_bp.route('/openapi.json', methods=['GET']) +def get_openapi_spec(): + """Return OpenAPI specification.""" + return jsonify(OPENAPI_SPEC) + + +@api_v1_bp.route('/docs', methods=['GET']) +def get_docs(): + """Serve Swagger UI documentation.""" + from flask import Response + html = ''' + + + Speakr API v1 Documentation + + + +
+ + + +''' + return Response(html, mimetype='text/html') + + +# ============================================================================= +# Stats Endpoint (Homepage Widget Compatible) +# ============================================================================= + +@api_v1_bp.route('/stats', methods=['GET']) +@login_required +def get_stats(): + """ + Get system/user statistics for dashboard widgets. + + Query params: + scope: 'user' (default) or 'all' (admin only) + + Returns JSON compatible with gethomepage.dev custom API widget: + { + "recordings": {"total": N, "completed": N, "processing": N, "pending": N, "failed": N}, + "storage": {"used_bytes": N, "used_human": "X.X GB"}, + "queue": {"jobs_queued": N, "jobs_processing": N}, + "tokens": {"used_this_month": N, "budget": N, "percentage": N}, + "transcription": {"used_this_month_seconds": N, "used_this_month_minutes": N, "budget_seconds": N, "budget_minutes": N, "percentage": N, "estimated_cost": N}, + "activity": {"recordings_today": N, "last_transcription": "ISO datetime"} + } + """ + scope = request.args.get('scope', 'user') + + # Admin-only for global stats + if scope == 'all' and not current_user.is_admin: + return jsonify({'error': 'Admin access required for global stats'}), 403 + + # Build query filters based on scope + if scope == 'user': + recording_filter = Recording.user_id == current_user.id + job_filter = ProcessingJob.user_id == current_user.id + user_id_for_tokens = current_user.id + else: + recording_filter = True # No filter = all recordings + job_filter = True + user_id_for_tokens = None # Will aggregate all users + + # Recording counts by status + total = Recording.query.filter(recording_filter).count() + completed = Recording.query.filter(recording_filter, Recording.status == 'COMPLETED').count() + processing = Recording.query.filter( + recording_filter, + Recording.status.in_(['PROCESSING', 'SUMMARIZING']) + ).count() + pending = Recording.query.filter(recording_filter, Recording.status == 'PENDING').count() + failed = Recording.query.filter(recording_filter, Recording.status == 'FAILED').count() + + # Storage calculation + storage_query = db.session.query(func.sum(Recording.file_size)).filter(recording_filter) + storage_bytes = storage_query.scalar() or 0 + + # Queue status + jobs_queued = ProcessingJob.query.filter( + job_filter, + ProcessingJob.status == 'queued' + ).count() + jobs_processing = ProcessingJob.query.filter( + job_filter, + ProcessingJob.status == 'processing' + ).count() + + # Token usage + tokens_data = {} + if user_id_for_tokens: + # Single user stats + monthly_usage = token_tracker.get_monthly_usage(user_id_for_tokens) + user = db.session.get(User, user_id_for_tokens) + budget = user.monthly_token_budget if user else None + + tokens_data = { + 'used_this_month': monthly_usage, + 'budget': budget, + 'percentage': round((monthly_usage / budget * 100), 1) if budget else None + } + else: + # Aggregate all users (admin scope) + current_year = date.today().year + current_month = date.today().month + total_usage = db.session.query(func.sum(TokenUsage.total_tokens)).filter( + extract('year', TokenUsage.date) == current_year, + extract('month', TokenUsage.date) == current_month + ).scalar() or 0 + + tokens_data = { + 'used_this_month': total_usage, + 'budget': None, + 'percentage': None + } + + # Transcription usage + transcription_data = {} + if user_id_for_tokens: + # Single user stats + monthly_transcription = transcription_tracker.get_monthly_usage(user_id_for_tokens) + monthly_cost = transcription_tracker.get_monthly_cost(user_id_for_tokens) + user = db.session.get(User, user_id_for_tokens) + transcription_budget = user.monthly_transcription_budget if user else None + + transcription_data = { + 'used_this_month_seconds': monthly_transcription, + 'used_this_month_minutes': monthly_transcription // 60, + 'budget_seconds': transcription_budget, + 'budget_minutes': transcription_budget // 60 if transcription_budget else None, + 'percentage': round((monthly_transcription / transcription_budget * 100), 1) if transcription_budget else None, + 'estimated_cost': round(monthly_cost, 4) + } + else: + # Aggregate all users (admin scope) + current_year = date.today().year + current_month = date.today().month + total_seconds = db.session.query(func.sum(TranscriptionUsage.audio_duration_seconds)).filter( + extract('year', TranscriptionUsage.date) == current_year, + extract('month', TranscriptionUsage.date) == current_month + ).scalar() or 0 + total_cost = db.session.query(func.sum(TranscriptionUsage.estimated_cost)).filter( + extract('year', TranscriptionUsage.date) == current_year, + extract('month', TranscriptionUsage.date) == current_month + ).scalar() or 0 + + transcription_data = { + 'used_this_month_seconds': total_seconds, + 'used_this_month_minutes': total_seconds // 60, + 'budget_seconds': None, + 'budget_minutes': None, + 'percentage': None, + 'estimated_cost': round(total_cost, 4) + } + + # Recent activity + today_start = datetime.combine(date.today(), datetime.min.time()) + recordings_today = Recording.query.filter( + recording_filter, + Recording.created_at >= today_start + ).count() + + # Last completed transcription + last_completed = Recording.query.filter( + recording_filter, + Recording.status == 'COMPLETED', + Recording.completed_at.isnot(None) + ).order_by(Recording.completed_at.desc()).first() + + last_transcription = last_completed.completed_at.isoformat() if last_completed and last_completed.completed_at else None + + # Build response + response = { + 'recordings': { + 'total': total, + 'completed': completed, + 'processing': processing, + 'pending': pending, + 'failed': failed + }, + 'storage': { + 'used_bytes': storage_bytes, + 'used_human': format_bytes(storage_bytes) + }, + 'queue': { + 'jobs_queued': jobs_queued, + 'jobs_processing': jobs_processing + }, + 'tokens': tokens_data, + 'transcription': transcription_data, + 'activity': { + 'recordings_today': recordings_today, + 'last_transcription': last_transcription + } + } + + # Add user counts for admin scope + if scope == 'all' and current_user.is_admin: + total_users = User.query.count() + # Active = users with recordings in last 30 days + cutoff = datetime.utcnow() - timedelta(days=30) + active_users = db.session.query(func.count(func.distinct(Recording.user_id))).filter( + Recording.created_at >= cutoff + ).scalar() or 0 + + response['users'] = { + 'total': total_users, + 'active': active_users + } + + return jsonify(response) + + +# ============================================================================= +# Recordings List with Enhanced Filtering +# ============================================================================= + +@api_v1_bp.route('/recordings', methods=['GET']) +@login_required +def list_recordings(): + """ + List recordings with filtering and pagination. + + Query params: + page: Page number (default: 1) + per_page: Items per page (default: 25, max: 100) + status: Filter by status (pending, processing, completed, failed, all) + sort_by: Sort field (created_at, meeting_date, title, file_size, status) + sort_order: asc or desc (default: desc) + date_from: Filter from date (ISO format) + date_to: Filter to date (ISO format) + tag_id: Filter by tag ID + q: Search query (title, participants) + inbox: Filter by inbox status (true/false) + starred: Filter by starred status (true/false) + """ + # Parse query parameters + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 25, type=int), 100) + status_filter = request.args.get('status', 'all').lower() + sort_by = request.args.get('sort_by', 'created_at') + sort_order = request.args.get('sort_order', 'desc').lower() + date_from = request.args.get('date_from') + date_to = request.args.get('date_to') + tag_id = request.args.get('tag_id', type=int) + search_query = request.args.get('q', '').strip() + inbox_filter = request.args.get('inbox') + starred_filter = request.args.get('starred') + + # Base query - user's recordings + query = Recording.query.filter(Recording.user_id == current_user.id) + + # Status filter + if status_filter == 'pending': + query = query.filter(Recording.status == 'PENDING') + elif status_filter == 'processing': + query = query.filter(Recording.status.in_(['PROCESSING', 'SUMMARIZING'])) + elif status_filter == 'completed': + query = query.filter(Recording.status == 'COMPLETED') + elif status_filter == 'failed': + query = query.filter(Recording.status == 'FAILED') + # 'all' = no status filter + + # Date filters + if date_from: + try: + from_date = datetime.fromisoformat(date_from.replace('Z', '+00:00')) + query = query.filter(Recording.created_at >= from_date) + except ValueError: + pass + + if date_to: + try: + to_date = datetime.fromisoformat(date_to.replace('Z', '+00:00')) + query = query.filter(Recording.created_at <= to_date) + except ValueError: + pass + + # Tag filter + if tag_id: + query = query.join(RecordingTag).filter(RecordingTag.tag_id == tag_id) + + # Search filter + if search_query: + search_pattern = f'%{search_query}%' + query = query.filter( + or_( + Recording.title.ilike(search_pattern), + Recording.participants.ilike(search_pattern) + ) + ) + + # Inbox filter + if inbox_filter is not None: + is_inbox = inbox_filter.lower() == 'true' + query = query.filter(Recording.is_inbox == is_inbox) + + # Starred filter + if starred_filter is not None: + is_starred = starred_filter.lower() == 'true' + query = query.filter(Recording.is_highlighted == is_starred) + + # Sorting + sort_columns = { + 'created_at': Recording.created_at, + 'meeting_date': Recording.meeting_date, + 'title': Recording.title, + 'file_size': Recording.file_size, + 'status': Recording.status + } + sort_column = sort_columns.get(sort_by, Recording.created_at) + + if sort_order == 'asc': + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(sort_column.desc()) + + # Pagination + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + + # Build response + recordings = [] + for r in pagination.items: + recordings.append({ + 'id': r.id, + 'title': r.title, + 'status': r.status, + 'created_at': r.created_at.isoformat() if r.created_at else None, + 'meeting_date': r.meeting_date.isoformat() if r.meeting_date else None, + 'file_size': r.file_size, + 'original_filename': r.original_filename, + 'participants': r.participants, + 'is_inbox': r.is_inbox, + 'is_highlighted': r.is_highlighted, + 'audio_available': r.audio_deleted_at is None, + 'has_transcription': bool(r.transcription), + 'has_summary': bool(r.summary), + 'error_message': r.error_message if r.status == 'FAILED' else None, + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color} for t in r.tags] + }) + + return jsonify({ + 'recordings': recordings, + 'pagination': { + 'page': pagination.page, + 'per_page': pagination.per_page, + 'total': pagination.total, + 'total_pages': pagination.pages, + 'has_next': pagination.has_next, + 'has_prev': pagination.has_prev + } + }) + + +# ============================================================================= +# Recording Detail +# ============================================================================= + +@api_v1_bp.route('/recordings/', methods=['GET']) +@login_required +def get_recording(recording_id): + """ + Get full recording details. + + Query params: + include: Comma-separated fields to include (transcription, summary, notes) + Default: all fields + format: 'full' (default) or 'minimal' (excludes large text fields) + """ + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + include = request.args.get('include', 'transcription,summary,notes') + include_fields = [f.strip() for f in include.split(',')] + format_type = request.args.get('format', 'full') + + response = { + 'id': recording.id, + 'title': recording.title, + 'status': recording.status, + 'participants': recording.participants, + 'created_at': recording.created_at.isoformat() if recording.created_at else None, + 'meeting_date': recording.meeting_date.isoformat() if recording.meeting_date else None, + 'completed_at': recording.completed_at.isoformat() if recording.completed_at else None, + 'file_size': recording.file_size, + 'original_filename': recording.original_filename, + 'mime_type': recording.mime_type, + 'is_inbox': recording.is_inbox, + 'is_highlighted': recording.is_highlighted, + 'audio_available': recording.audio_deleted_at is None, + 'processing_time_seconds': recording.processing_time_seconds, + 'error_message': recording.error_message if recording.status == 'FAILED' else None, + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color} for t in recording.tags], + 'duplicate_info': recording.get_duplicate_info() + } + + # Include large text fields based on params + if format_type != 'minimal': + if 'transcription' in include_fields: + # Format transcription using user's default template + response['transcription'] = format_transcription_with_template( + recording.transcription, current_user + ) if recording.transcription else None + if 'summary' in include_fields: + response['summary'] = recording.summary + if 'notes' in include_fields: + response['notes'] = recording.notes + + return jsonify(response) + + +# ============================================================================= +# Recording Transcript/Summary/Notes Individual Endpoints +# ============================================================================= + +@api_v1_bp.route('/recordings//transcript', methods=['GET']) +@login_required +def get_transcript(recording_id): + """ + Get transcript in various formats. + + Query params: + format: json (default), text, srt, vtt + """ + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + if not recording.transcription: + return jsonify({'error': 'No transcription available'}), 404 + + format_type = request.args.get('format', 'json').lower() + + if format_type == 'json': + try: + segments = json.loads(recording.transcription) + return jsonify({ + 'format': 'json', + 'segments': segments + }) + except json.JSONDecodeError: + return jsonify({ + 'format': 'json', + 'segments': [], + 'raw': recording.transcription + }) + + elif format_type == 'text': + # Use user's default template for text format + formatted = format_transcription_with_template(recording.transcription, current_user) + return jsonify({ + 'format': 'text', + 'content': formatted + }) + + elif format_type in ['srt', 'vtt']: + try: + segments = json.loads(recording.transcription) + lines = [] + + if format_type == 'vtt': + lines.append('WEBVTT') + lines.append('') + + for i, seg in enumerate(segments, 1): + start = seg.get('start_time', seg.get('start', 0)) + end = seg.get('end_time', seg.get('end', start + 1)) + text = seg.get('sentence') or seg.get('text', '') + speaker = seg.get('speaker', '') + + # Format timestamps + def fmt_time(seconds, use_comma=False): + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) + ms = int((seconds - int(seconds)) * 1000) + sep = ',' if use_comma else '.' + return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}" + + if format_type == 'srt': + lines.append(str(i)) + lines.append(f"{fmt_time(start, True)} --> {fmt_time(end, True)}") + else: + lines.append(f"{fmt_time(start)} --> {fmt_time(end)}") + + if speaker: + lines.append(f"{text}") + else: + lines.append(text) + lines.append('') + + return jsonify({ + 'format': format_type, + 'content': '\n'.join(lines) + }) + except (json.JSONDecodeError, TypeError): + return jsonify({'error': 'Cannot generate subtitle format from transcript'}), 400 + + return jsonify({'error': f'Unknown format: {format_type}'}), 400 + + +@api_v1_bp.route('/recordings//summary', methods=['GET']) +@login_required +def get_summary(recording_id): + """Get summary markdown.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + return jsonify({ + 'summary': recording.summary, + 'has_summary': bool(recording.summary) + }) + + +@api_v1_bp.route('/recordings//notes', methods=['GET']) +@login_required +def get_notes(recording_id): + """Get notes markdown.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + return jsonify({ + 'notes': recording.notes, + 'has_notes': bool(recording.notes) + }) + + +# ============================================================================= +# Recording Update Operations +# ============================================================================= + +@api_v1_bp.route('/recordings/', methods=['PATCH']) +@login_required +def update_recording(recording_id): + """ + Update recording metadata, notes, or summary. + + Request body (all fields optional): + { + "title": "Updated Title", + "participants": "Alice, Bob", + "notes": "Updated notes...", + "summary": "Updated summary...", + "meeting_date": "2024-01-15T09:00:00Z", + "is_inbox": false, + "is_highlighted": true + } + """ + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Update fields if provided + if 'title' in data: + recording.title = data['title'] + if 'participants' in data: + recording.participants = data['participants'] + if 'notes' in data: + recording.notes = data['notes'] + if 'summary' in data: + recording.summary = data['summary'] + if 'meeting_date' in data: + try: + if data['meeting_date']: + recording.meeting_date = datetime.fromisoformat(data['meeting_date'].replace('Z', '+00:00')) + else: + recording.meeting_date = None + except ValueError: + return jsonify({'error': 'Invalid meeting_date format'}), 400 + if 'is_inbox' in data: + recording.is_inbox = bool(data['is_inbox']) + if 'is_highlighted' in data: + recording.is_highlighted = bool(data['is_highlighted']) + + db.session.commit() + + return jsonify({ + 'success': True, + 'recording': { + 'id': recording.id, + 'title': recording.title, + 'participants': recording.participants, + 'notes': recording.notes, + 'summary': recording.summary, + 'meeting_date': recording.meeting_date.isoformat() if recording.meeting_date else None, + 'is_inbox': recording.is_inbox, + 'is_highlighted': recording.is_highlighted + } + }) + + +@api_v1_bp.route('/recordings//notes', methods=['PUT']) +@login_required +def replace_notes(recording_id): + """Replace notes entirely.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + if not data or 'notes' not in data: + return jsonify({'error': 'notes field required'}), 400 + + recording.notes = data['notes'] + db.session.commit() + + return jsonify({'success': True, 'notes': recording.notes}) + + +@api_v1_bp.route('/recordings//summary', methods=['PUT']) +@login_required +def replace_summary(recording_id): + """Replace summary entirely.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + if not data or 'summary' not in data: + return jsonify({'error': 'summary field required'}), 400 + + recording.summary = data['summary'] + db.session.commit() + + return jsonify({'success': True, 'summary': recording.summary}) + + +# ============================================================================= +# Recording Delete +# ============================================================================= + +@api_v1_bp.route('/recordings/', methods=['DELETE']) +@login_required +def delete_recording(recording_id): + """Delete a recording.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check ownership (only owner can delete) + if recording.user_id != current_user.id: + return jsonify({'error': 'Permission denied - only owner can delete'}), 403 + + # Check if deletion is allowed + USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' + if not USERS_CAN_DELETE and not current_user.is_admin: + return jsonify({'error': 'Deletion not allowed'}), 403 + + # Delete associated files + if recording.audio_path: + try: + audio_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), recording.audio_path) + if os.path.exists(audio_path): + os.remove(audio_path) + except Exception: + pass # Continue with DB deletion even if file deletion fails + + # Delete from database + db.session.delete(recording) + db.session.commit() + + return jsonify({'success': True, 'message': 'Recording deleted'}) + + +# ============================================================================= +# Recording Status +# ============================================================================= + +@api_v1_bp.route('/recordings//status', methods=['GET']) +@login_required +def get_recording_status(recording_id): + """Get processing status of a recording.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + # Get queue position if pending/processing + queue_position = None + if recording.status in ['PENDING', 'PROCESSING', 'SUMMARIZING']: + # Count jobs ahead of this one + job = ProcessingJob.query.filter_by( + recording_id=recording_id, + status='queued' + ).first() + + if job: + queue_position = ProcessingJob.query.filter( + ProcessingJob.status == 'queued', + ProcessingJob.created_at < job.created_at + ).count() + 1 + + return jsonify({ + 'id': recording.id, + 'status': recording.status, + 'queue_position': queue_position, + 'error_message': recording.error_message if recording.status == 'FAILED' else None, + 'completed_at': recording.completed_at.isoformat() if recording.completed_at else None + }) + + +# ============================================================================= +# Tag Management +# ============================================================================= + +@api_v1_bp.route('/tags', methods=['GET']) +@login_required +def list_tags(): + """List available tags (personal + group tags user has access to).""" + from src.models.organization import GroupMembership + + # Get user's personal tags + user_tags = Tag.query.filter_by(user_id=current_user.id, group_id=None).order_by(Tag.name).all() + + # Get user's team memberships + memberships = GroupMembership.query.filter_by(user_id=current_user.id).all() + team_roles = {m.group_id: m.role for m in memberships} + team_ids = list(team_roles.keys()) + + # Get group tags + team_tags = [] + if team_ids: + team_tags = Tag.query.filter(Tag.group_id.in_(team_ids)).order_by(Tag.name).all() + + result = [] + + # Personal tags + for tag in user_tags: + result.append({ + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'is_group_tag': False, + 'group_id': None, + 'custom_prompt': tag.custom_prompt, + 'default_language': tag.default_language, + 'default_min_speakers': tag.default_min_speakers, + 'default_max_speakers': tag.default_max_speakers, + 'protect_from_deletion': tag.protect_from_deletion, + 'can_edit': True + }) + + # Group tags + for tag in team_tags: + user_role = team_roles.get(tag.group_id, 'member') + result.append({ + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'is_group_tag': True, + 'group_id': tag.group_id, + 'custom_prompt': tag.custom_prompt, + 'default_language': tag.default_language, + 'default_min_speakers': tag.default_min_speakers, + 'default_max_speakers': tag.default_max_speakers, + 'protect_from_deletion': tag.protect_from_deletion, + 'can_edit': (user_role == 'admin') + }) + + return jsonify({'tags': result}) + + +@api_v1_bp.route('/tags', methods=['POST']) +@login_required +def create_tag(): + """Create a new tag.""" + from src.models.organization import GroupMembership + + data = request.get_json() + if not data or not data.get('name'): + return jsonify({'error': 'Tag name is required'}), 400 + + group_id = data.get('group_id') + + # If group tag, verify admin permission + if group_id: + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can create group tags'}), 403 + + # Check for duplicate + existing = Tag.query.filter_by(name=data['name'], group_id=group_id).first() + if existing: + return jsonify({'error': 'Tag with this name already exists for this group'}), 400 + else: + # Check for duplicate personal tag + existing = Tag.query.filter_by(name=data['name'], user_id=current_user.id, group_id=None).first() + if existing: + return jsonify({'error': 'Tag with this name already exists'}), 400 + + tag = Tag( + name=data['name'], + user_id=current_user.id, + group_id=group_id, + color=data.get('color', '#3B82F6'), + custom_prompt=data.get('custom_prompt'), + default_language=data.get('default_language'), + default_min_speakers=data.get('default_min_speakers'), + default_max_speakers=data.get('default_max_speakers'), + protect_from_deletion=data.get('protect_from_deletion', False) + ) + + db.session.add(tag) + db.session.commit() + + return jsonify({ + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'is_group_tag': tag.group_id is not None, + 'group_id': tag.group_id, + 'custom_prompt': tag.custom_prompt, + 'default_language': tag.default_language, + 'default_min_speakers': tag.default_min_speakers, + 'default_max_speakers': tag.default_max_speakers, + 'protect_from_deletion': tag.protect_from_deletion + }), 201 + + +@api_v1_bp.route('/tags/', methods=['PUT']) +@login_required +def update_tag(tag_id): + """Update a tag.""" + from src.models.organization import GroupMembership + + tag = db.session.get(Tag, tag_id) + if not tag: + return jsonify({'error': 'Tag not found'}), 404 + + # Check permission + if tag.group_id: + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can edit group tags'}), 403 + else: + if tag.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + if 'name' in data: + tag.name = data['name'] + if 'color' in data: + tag.color = data['color'] + if 'custom_prompt' in data: + tag.custom_prompt = data['custom_prompt'] + if 'default_language' in data: + tag.default_language = data['default_language'] + if 'default_min_speakers' in data: + tag.default_min_speakers = data['default_min_speakers'] + if 'default_max_speakers' in data: + tag.default_max_speakers = data['default_max_speakers'] + if 'protect_from_deletion' in data: + tag.protect_from_deletion = data['protect_from_deletion'] + + db.session.commit() + + return jsonify({'success': True, 'tag': { + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'custom_prompt': tag.custom_prompt, + 'default_language': tag.default_language, + 'default_min_speakers': tag.default_min_speakers, + 'default_max_speakers': tag.default_max_speakers, + 'protect_from_deletion': tag.protect_from_deletion + }}) + + +@api_v1_bp.route('/tags/', methods=['DELETE']) +@login_required +def delete_tag(tag_id): + """Delete a tag.""" + from src.models.organization import GroupMembership + + tag = db.session.get(Tag, tag_id) + if not tag: + return jsonify({'error': 'Tag not found'}), 404 + + # Check permission + if tag.group_id: + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can delete group tags'}), 403 + else: + if tag.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + # Remove all recording associations + RecordingTag.query.filter_by(tag_id=tag_id).delete() + + db.session.delete(tag) + db.session.commit() + + return jsonify({'success': True, 'message': 'Tag deleted'}) + + +@api_v1_bp.route('/recordings//tags', methods=['POST']) +@login_required +def add_tags_to_recording(recording_id): + """Add tag(s) to a recording.""" + from src.models.organization import GroupMembership + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + tag_ids = data.get('tag_ids', []) + if not tag_ids: + # Support single tag_id for backward compatibility + tag_id = data.get('tag_id') + if tag_id: + tag_ids = [tag_id] + else: + return jsonify({'error': 'tag_ids or tag_id required'}), 400 + + added_tags = [] + errors = [] + + for tag_id in tag_ids: + tag = db.session.get(Tag, tag_id) + if not tag: + errors.append(f'Tag {tag_id} not found') + continue + + # Check permission for this tag + if tag.group_id: + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + if not membership: + errors.append(f'No access to tag {tag_id}') + continue + else: + if tag.user_id != current_user.id: + errors.append(f'No access to tag {tag_id}') + continue + + # Check if already exists + existing = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + if existing: + continue # Skip, already added + + # Get next order position + max_order = db.session.query(func.max(RecordingTag.order)).filter_by( + recording_id=recording_id + ).scalar() or 0 + + recording_tag = RecordingTag( + recording_id=recording_id, + tag_id=tag_id, + order=max_order + 1 + ) + db.session.add(recording_tag) + added_tags.append({'id': tag.id, 'name': tag.name}) + + db.session.commit() + + return jsonify({ + 'success': True, + 'added_tags': added_tags, + 'errors': errors if errors else None + }) + + +@api_v1_bp.route('/recordings//tags/', methods=['DELETE']) +@login_required +def remove_tag_from_recording(recording_id, tag_id): + """Remove a tag from a recording.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + recording_tag = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + + if not recording_tag: + return jsonify({'error': 'Tag not on this recording'}), 404 + + db.session.delete(recording_tag) + db.session.commit() + + return jsonify({'success': True, 'message': 'Tag removed'}) + + +# ============================================================================= +# Speaker Management +# ============================================================================= + +@api_v1_bp.route('/speakers', methods=['GET']) +@login_required +def list_speakers(): + """List all speakers for the current user.""" + speakers = Speaker.query.filter_by(user_id=current_user.id)\ + .order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\ + .all() + + return jsonify({ + 'speakers': [{ + 'id': s.id, + 'name': s.name, + 'use_count': s.use_count, + 'last_used': s.last_used.isoformat() if s.last_used else None, + 'confidence_score': s.confidence_score, + 'has_voice_profile': s.average_embedding is not None + } for s in speakers] + }) + + +@api_v1_bp.route('/speakers', methods=['POST']) +@login_required +def create_speaker(): + """Create a new speaker.""" + data = request.get_json() + if not data or not data.get('name'): + return jsonify({'error': 'Speaker name is required'}), 400 + + name = data['name'].strip() + + # Check if already exists + existing = Speaker.query.filter_by(user_id=current_user.id, name=name).first() + if existing: + return jsonify({'error': 'Speaker with this name already exists'}), 400 + + speaker = Speaker( + name=name, + user_id=current_user.id, + use_count=0, + created_at=datetime.utcnow() + ) + db.session.add(speaker) + db.session.commit() + + return jsonify({ + 'id': speaker.id, + 'name': speaker.name, + 'use_count': speaker.use_count, + 'created_at': speaker.created_at.isoformat() + }), 201 + + +@api_v1_bp.route('/speakers/', methods=['PUT']) +@login_required +def update_speaker(speaker_id): + """Update a speaker (cascades name changes to recordings).""" + speaker = db.session.get(Speaker, speaker_id) + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + if speaker.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + old_name = speaker.name + new_name = data.get('name', '').strip() + + if not new_name: + return jsonify({'error': 'Speaker name is required'}), 400 + + if new_name != old_name: + # Update speaker name + speaker.name = new_name + + # Update all recordings that have this speaker in their transcription + from src.services.speaker import update_speaker_in_recordings + try: + update_speaker_in_recordings(current_user.id, old_name, new_name) + except Exception as e: + current_app.logger.error(f"Error updating speaker in recordings: {e}") + + db.session.commit() + + return jsonify({ + 'success': True, + 'speaker': { + 'id': speaker.id, + 'name': speaker.name, + 'use_count': speaker.use_count + } + }) + + +@api_v1_bp.route('/speakers/', methods=['DELETE']) +@login_required +def delete_speaker(speaker_id): + """Delete a speaker.""" + speaker = db.session.get(Speaker, speaker_id) + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + if speaker.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + db.session.delete(speaker) + db.session.commit() + + return jsonify({'success': True, 'message': 'Speaker deleted'}) + + +@api_v1_bp.route('/recordings//speakers', methods=['GET']) +@login_required +def get_recording_speakers(recording_id): + """Get speakers in a recording with suggestions.""" + from src.services.speaker_embedding_matcher import find_matching_speakers + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + # Parse transcription to get speakers + speakers_in_recording = [] + speaker_counts = {} + + if recording.transcription: + try: + segments = json.loads(recording.transcription) + for seg in segments: + speaker = seg.get('speaker', 'Unknown') + speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1 + except (json.JSONDecodeError, TypeError): + pass + + # Build speaker list with identification info + for label, count in speaker_counts.items(): + # Check if this speaker label has been identified + identified_name = None + speaker_id = None + + # Look for speaker in user's speakers by checking recordings + # This is a simplified check - actual implementation would check speaker_embeddings + speakers_in_recording.append({ + 'label': label, + 'identified_name': identified_name, + 'speaker_id': speaker_id, + 'segment_count': count + }) + + # Get voice-based suggestions + suggestions = {} + if recording.speaker_embeddings: + try: + matches = find_matching_speakers(current_user.id, recording.speaker_embeddings) + for label, speaker_matches in matches.items(): + suggestions[label] = [{ + 'speaker_id': m['speaker_id'], + 'name': m['name'], + 'similarity': round(m['similarity'] * 100, 1) + } for m in speaker_matches[:3]] + except Exception as e: + current_app.logger.error(f"Error getting speaker suggestions: {e}") + + return jsonify({ + 'speakers': speakers_in_recording, + 'suggestions': suggestions + }) + + +@api_v1_bp.route('/recordings//speakers/assign', methods=['PUT']) +@login_required +def assign_speakers(recording_id): + """ + Assign speaker names to a recording's transcription segments. + + Accepts the same speaker_map format as the web UI, plus convenience + formats for API callers (plain strings). + + Request body: + { + "speaker_map": { + "SPEAKER_00": "Jane Doe", // string shorthand + "SPEAKER_01": {"name": "Bob", "isMe": false} // full object + }, + "regenerate_summary": false + } + """ + from src.services.speaker import update_speaker_usage + from src.services.speaker_embedding_matcher import update_speaker_embedding + from src.services.speaker_snippets import create_speaker_snippets + from src.services.job_queue import job_queue + + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + data = request.get_json() + if not data or 'speaker_map' not in data: + return jsonify({'error': 'speaker_map is required'}), 400 + + raw_speaker_map = data['speaker_map'] + regenerate_summary = data.get('regenerate_summary', False) + + if not isinstance(raw_speaker_map, dict): + return jsonify({'error': 'speaker_map must be an object'}), 400 + + # Normalize values to {name, isMe} format (same as web UI expects) + speaker_map = {} + for label, value in raw_speaker_map.items(): + if isinstance(value, str): + speaker_map[label] = {'name': value.strip(), 'isMe': False} + elif isinstance(value, dict): + speaker_map[label] = { + 'name': value.get('name', '').strip() if value.get('name') else '', + 'isMe': value.get('isMe', False) + } + else: + return jsonify({'error': f'Invalid value type for speaker "{label}"'}), 400 + + # --- Apply names to transcription (same logic as update_speakers in recordings.py) --- + transcription_text = recording.transcription or '' + is_json = False + try: + transcription_data = json.loads(transcription_text) + is_json = isinstance(transcription_data, list) + except (json.JSONDecodeError, TypeError): + is_json = False + + speaker_names_used = [] + + if is_json: + for segment in transcription_data: + original_speaker_label = segment.get('speaker') + if original_speaker_label in speaker_map: + new_name_info = speaker_map[original_speaker_label] + new_name = new_name_info.get('name', '').strip() + if new_name_info.get('isMe') and not new_name: + new_name = current_user.name or 'Me' + if new_name: + segment['speaker'] = new_name + if new_name not in speaker_names_used: + speaker_names_used.append(new_name) + + recording.transcription = json.dumps(transcription_data) + + # Update participants - exclude unresolved SPEAKER_XX labels + final_speakers = set() + for seg in transcription_data: + speaker = seg.get('speaker') + if speaker and str(speaker).strip(): + if not re.match(r'^SPEAKER_\d+$', str(speaker), re.IGNORECASE): + final_speakers.add(speaker) + recording.participants = ', '.join(sorted(list(final_speakers))) + else: + # Plain text transcript + new_participants = [] + for speaker_label, new_name_info in speaker_map.items(): + new_name = new_name_info.get('name', '').strip() + if new_name_info.get('isMe') and not new_name: + new_name = current_user.name or 'Me' + if new_name: + transcription_text = re.sub( + r'\[\s*' + re.escape(speaker_label) + r'\s*\]', + f'[{new_name}]', + transcription_text, + flags=re.IGNORECASE + ) + if new_name not in new_participants: + new_participants.append(new_name) + + recording.transcription = transcription_text + recording.participants = ', '.join(new_participants) + speaker_names_used = new_participants + + # Update speaker usage statistics + if speaker_names_used: + update_speaker_usage(speaker_names_used) + + # Update speaker voice embeddings if available + embeddings_updated = 0 + snippets_created = 0 + if recording.speaker_embeddings and speaker_map: + try: + embeddings_data = json.loads(recording.speaker_embeddings) if isinstance(recording.speaker_embeddings, str) else recording.speaker_embeddings + + speaker_label_to_name = {} + for speaker_label, speaker_info in speaker_map.items(): + name = speaker_info.get('name', '').strip() + if speaker_info.get('isMe') and not name: + name = current_user.name or 'Me' + if name and not re.match(r'^SPEAKER_\d+$', name, re.IGNORECASE): + speaker_label_to_name[speaker_label] = name + + for speaker_label, embedding in embeddings_data.items(): + if speaker_label in speaker_label_to_name and embedding and len(embedding) == 256: + speaker_name = speaker_label_to_name[speaker_label] + speaker_obj = Speaker.query.filter_by( + user_id=current_user.id, + name=speaker_name + ).first() + if speaker_obj: + update_speaker_embedding(speaker_obj, embedding, recording.id) + embeddings_updated += 1 + + if speaker_label_to_name: + snippets_created = create_speaker_snippets(recording.id, speaker_map) + except Exception as e: + current_app.logger.error(f"Error updating speaker embeddings: {e}", exc_info=True) + + db.session.commit() + + summary_queued = False + if regenerate_summary: + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='summarize', + params={'user_id': current_user.id} + ) + summary_queued = True + + return jsonify({ + 'success': True, + 'message': 'Speakers updated successfully.', + 'recording': { + 'id': recording.id, + 'title': recording.title, + 'participants': recording.participants, + 'status': recording.status + }, + 'summary_queued': summary_queued, + 'embeddings_updated': embeddings_updated, + 'snippets_created': snippets_created + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error assigning speakers for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + + +@api_v1_bp.route('/recordings//speakers/identify', methods=['POST']) +@login_required +def identify_speakers(recording_id): + """ + Trigger LLM-based auto-identification of speakers from transcript context. + Returns suggestions only - does not modify the recording. + + Uses the shared identification service with JSON schema support, + name sanitization, and fallback logic. + """ + from src.services.speaker_identification import identify_speakers_from_transcript + + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + if not recording.transcription: + return jsonify({'error': 'No transcription available for speaker identification'}), 400 + + try: + transcription_data = json.loads(recording.transcription) + except (json.JSONDecodeError, TypeError): + return jsonify({'error': 'Transcription format not supported for auto-identification'}), 400 + + if not isinstance(transcription_data, list): + return jsonify({'error': 'Transcription format not supported for auto-identification'}), 400 + + speaker_map = identify_speakers_from_transcript(transcription_data, current_user.id) + + if not speaker_map: + return jsonify({'error': 'No speakers found in transcription'}), 400 + + return jsonify({'success': True, 'speaker_map': speaker_map}) + + except ValueError as ve: + return jsonify({'error': str(ve)}), 503 + except Exception as e: + current_app.logger.error(f"Error during auto speaker identification for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': f'An unexpected error occurred: {str(e)}'}), 500 + + +# ============================================================================= +# Processing Operations +# ============================================================================= + +@api_v1_bp.route('/recordings//transcribe', methods=['POST']) +@login_required +def start_transcription(recording_id): + """Queue transcription for a recording.""" + from src.services.job_queue import job_queue + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + # Check if audio is available + if recording.audio_deleted_at: + return jsonify({'error': 'Audio has been deleted'}), 400 + + data = request.get_json() or {} + + params = { + 'language': data.get('language'), + 'min_speakers': data.get('min_speakers'), + 'max_speakers': data.get('max_speakers') + } + + # Queue the job + job_id = job_queue.enqueue( + user_id=current_user.id, + recording_id=recording_id, + job_type='reprocess_transcription', + params={k: v for k, v in params.items() if v is not None} + ) + + return jsonify({ + 'success': True, + 'job_id': job_id, + 'status': 'QUEUED', + 'message': 'Transcription queued' + }) + + +@api_v1_bp.route('/recordings//summarize', methods=['POST']) +@login_required +def start_summarization(recording_id): + """Queue summarization for a recording with optional custom prompt.""" + from src.services.job_queue import job_queue + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'Permission denied'}), 403 + + # Check if transcription exists + if not recording.transcription: + return jsonify({'error': 'No transcription available - transcribe first'}), 400 + + data = request.get_json() or {} + + params = { + 'custom_prompt': data.get('custom_prompt'), + 'user_id': current_user.id + } + + # Queue the job + job_id = job_queue.enqueue( + user_id=current_user.id, + recording_id=recording_id, + job_type='reprocess_summary', + params={k: v for k, v in params.items() if v is not None} + ) + + return jsonify({ + 'success': True, + 'job_id': job_id, + 'status': 'QUEUED', + 'message': 'Summarization queued' + }) + + +# ============================================================================= +# Chat with Recording +# ============================================================================= + +@api_v1_bp.route('/recordings//chat', methods=['POST']) +@login_required +def chat_with_recording(recording_id): + """Chat about a recording's content.""" + from src.services.llm import chat_client, call_chat_completion + from src.tasks.processing import format_transcription_for_llm + from src.models import SystemSetting + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + if not recording.transcription: + return jsonify({'error': 'No transcription available'}), 400 + + data = request.get_json() + if not data or not data.get('message'): + return jsonify({'error': 'message is required'}), 400 + + user_message = data['message'] + conversation_history = data.get('conversation_history', []) + + # Check if chat client is available + if chat_client is None: + return jsonify({'error': 'Chat service not available'}), 503 + + # Format transcription + formatted_transcription = format_transcription_for_llm(recording.transcription) + + # Get transcript limit + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit != -1: + formatted_transcription = formatted_transcription[:transcript_limit] + + # Build system prompt + system_prompt = f"""Tu es un assistant expert en analyse de transcriptions audio. Réponds de façon directe et professionnelle en français, en te basant sur la transcription ci-dessous. + +**Enregistrement :** {recording.title} +**Participants :** {recording.participants or 'Non précisé'} + +**Transcription :** +{formatted_transcription} + +**Notes :** {recording.notes or 'Aucune note.'} +""" + + # Build messages + messages = [{"role": "system", "content": system_prompt}] + messages.extend(conversation_history) + messages.append({"role": "user", "content": user_message}) + + try: + response = call_chat_completion(messages, user_id=current_user.id) + + return jsonify({ + 'response': response, + 'sources': [] # Could be enhanced to extract relevant segments + }) + except Exception as e: + current_app.logger.error(f"Chat error: {e}") + return jsonify({'error': 'Chat failed'}), 500 + + +# ============================================================================= +# Calendar Events +# ============================================================================= + +@api_v1_bp.route('/recordings//events', methods=['GET']) +@login_required +def get_recording_events(recording_id): + """Get calendar events extracted from a recording.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + events = Event.query.filter_by(recording_id=recording_id).all() + + return jsonify({ + 'events': [{ + 'id': e.id, + 'title': e.title, + 'start_datetime': e.start_datetime.isoformat() if e.start_datetime else None, + 'end_datetime': e.end_datetime.isoformat() if e.end_datetime else None, + 'description': e.description, + 'location': e.location + } for e in events] + }) + + +@api_v1_bp.route('/recordings//events/ics', methods=['GET']) +@login_required +def download_events_ics(recording_id): + """Download all events as ICS file.""" + from src.api.events import generate_ics_content + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + events = Event.query.filter_by(recording_id=recording_id).all() + if not events: + return jsonify({'error': 'No events found'}), 404 + + # Generate combined ICS + ics_lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Speakr//Events//EN'] + + for event in events: + ics_lines.append('BEGIN:VEVENT') + ics_lines.append(f'UID:{event.id}@speakr') + ics_lines.append(f'SUMMARY:{event.title}') + if event.start_datetime: + ics_lines.append(f'DTSTART:{event.start_datetime.strftime("%Y%m%dT%H%M%S")}') + if event.end_datetime: + ics_lines.append(f'DTEND:{event.end_datetime.strftime("%Y%m%dT%H%M%S")}') + if event.description: + ics_lines.append(f'DESCRIPTION:{event.description}') + if event.location: + ics_lines.append(f'LOCATION:{event.location}') + ics_lines.append('END:VEVENT') + + ics_lines.append('END:VCALENDAR') + + from flask import Response + return Response( + '\r\n'.join(ics_lines), + mimetype='text/calendar', + headers={'Content-Disposition': f'attachment; filename=events-{recording_id}.ics'} + ) + + +# ============================================================================= +# Audio Download +# ============================================================================= + +@api_v1_bp.route('/recordings//audio', methods=['GET']) +@login_required +def download_audio(recording_id): + """Download or stream audio file.""" + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Permission denied'}), 403 + + if recording.audio_deleted_at: + return jsonify({'error': 'Audio has been deleted'}), 404 + + if not recording.audio_path: + return jsonify({'error': 'No audio file'}), 404 + + audio_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), recording.audio_path) + if not os.path.exists(audio_path): + return jsonify({'error': 'Audio file not found'}), 404 + + download = request.args.get('download', 'false').lower() == 'true' + + return send_file( + audio_path, + mimetype=recording.mime_type or 'audio/mpeg', + as_attachment=download, + download_name=recording.original_filename or f'recording-{recording_id}.mp3' + ) + + +# ============================================================================= +# Batch Operations +# ============================================================================= + +@api_v1_bp.route('/recordings/batch', methods=['PATCH']) +@login_required +def batch_update_recordings(): + """Batch update multiple recordings.""" + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + recording_ids = data.get('recording_ids', []) + updates = data.get('updates', {}) + + if not recording_ids: + return jsonify({'error': 'recording_ids required'}), 400 + + results = [] + for recording_id in recording_ids: + recording = db.session.get(Recording, recording_id) + if not recording: + results.append({'id': recording_id, 'success': False, 'error': 'Not found'}) + continue + + if not has_recording_access(recording, current_user, require_edit=True): + results.append({'id': recording_id, 'success': False, 'error': 'Permission denied'}) + continue + + try: + if 'is_inbox' in updates: + recording.is_inbox = bool(updates['is_inbox']) + if 'is_highlighted' in updates: + recording.is_highlighted = bool(updates['is_highlighted']) + + # Handle tag additions + if 'add_tag_ids' in updates: + for tag_id in updates['add_tag_ids']: + existing = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + if not existing: + max_order = db.session.query(func.max(RecordingTag.order)).filter_by( + recording_id=recording_id + ).scalar() or 0 + recording_tag = RecordingTag( + recording_id=recording_id, + tag_id=tag_id, + order=max_order + 1 + ) + db.session.add(recording_tag) + + # Handle tag removals + if 'remove_tag_ids' in updates: + for tag_id in updates['remove_tag_ids']: + RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).delete() + + results.append({'id': recording_id, 'success': True}) + except Exception as e: + results.append({'id': recording_id, 'success': False, 'error': str(e)}) + + db.session.commit() + + success_count = sum(1 for r in results if r['success']) + return jsonify({ + 'success': True, + 'updated': success_count, + 'failed': len(results) - success_count, + 'results': results + }) + + +@api_v1_bp.route('/recordings/batch', methods=['DELETE']) +@login_required +def batch_delete_recordings(): + """Batch delete multiple recordings.""" + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + recording_ids = data.get('recording_ids', []) + if not recording_ids: + return jsonify({'error': 'recording_ids required'}), 400 + + USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' + if not USERS_CAN_DELETE and not current_user.is_admin: + return jsonify({'error': 'Deletion not allowed'}), 403 + + results = [] + for recording_id in recording_ids: + recording = db.session.get(Recording, recording_id) + if not recording: + results.append({'id': recording_id, 'success': False, 'error': 'Not found'}) + continue + + if recording.user_id != current_user.id and not current_user.is_admin: + results.append({'id': recording_id, 'success': False, 'error': 'Permission denied'}) + continue + + try: + # Delete audio file + if recording.audio_path: + audio_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), recording.audio_path) + if os.path.exists(audio_path): + os.remove(audio_path) + + db.session.delete(recording) + results.append({'id': recording_id, 'success': True}) + except Exception as e: + results.append({'id': recording_id, 'success': False, 'error': str(e)}) + + db.session.commit() + + success_count = sum(1 for r in results if r['success']) + return jsonify({ + 'success': True, + 'deleted': success_count, + 'failed': len(results) - success_count, + 'results': results + }) + + +@api_v1_bp.route('/recordings/batch/transcribe', methods=['POST']) +@login_required +def batch_transcribe_recordings(): + """Batch queue transcriptions for multiple recordings.""" + from src.services.job_queue import job_queue + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + recording_ids = data.get('recording_ids', []) + if not recording_ids: + return jsonify({'error': 'recording_ids required'}), 400 + + results = [] + for recording_id in recording_ids: + recording = db.session.get(Recording, recording_id) + if not recording: + results.append({'id': recording_id, 'success': False, 'error': 'Not found'}) + continue + + if not has_recording_access(recording, current_user, require_edit=True): + results.append({'id': recording_id, 'success': False, 'error': 'Permission denied'}) + continue + + if recording.audio_deleted_at: + results.append({'id': recording_id, 'success': False, 'error': 'Audio deleted'}) + continue + + try: + job_id = job_queue.enqueue( + user_id=current_user.id, + recording_id=recording_id, + job_type='reprocess_transcription', + params={} + ) + results.append({'id': recording_id, 'success': True, 'job_id': job_id}) + except Exception as e: + results.append({'id': recording_id, 'success': False, 'error': str(e)}) + + success_count = sum(1 for r in results if r['success']) + return jsonify({ + 'success': True, + 'queued': success_count, + 'failed': len(results) - success_count, + 'results': results + }) + + +# ============================================================================= +# Settings +# ============================================================================= + +@api_v1_bp.route('/settings/auto-summarization', methods=['PUT']) +@login_required +def update_auto_summarization(): + """Toggle auto-summarization for the current user.""" + data = request.get_json() + + if data is None: + return jsonify({'error': 'Invalid JSON'}), 400 + + if 'enabled' not in data: + return jsonify({'error': 'enabled field is required'}), 400 + + current_user.auto_summarization = bool(data['enabled']) + db.session.commit() + + return jsonify({ + 'success': True, + 'auto_summarization': current_user.auto_summarization + }) + + +@api_v1_bp.route('/recordings/upload', methods=['POST']) +@login_required +def upload_recording(): + """ + Upload a recording and queue transcription (API). + + Multipart form-data fields: + - file (required) + - notes (optional) + - file_last_modified (optional, ms epoch) + - language (optional) + - min_speakers (optional) + - max_speakers (optional) + - tag_ids[0], tag_ids[1], ... (optional) + - tag_id (optional, legacy) + """ + return _upload_file_ui() diff --git a/src/api/audit.py b/src/api/audit.py new file mode 100644 index 0000000..cd6c899 --- /dev/null +++ b/src/api/audit.py @@ -0,0 +1,107 @@ +""" +Admin API endpoints for audit log queries. + +Loi 25 compliance: allows administrators to review access and auth logs. +""" + +from flask import Blueprint, request, jsonify + +from src.services.audit import get_access_logs, get_auth_logs, is_audit_enabled +from src.utils import admin_required + +audit_bp = Blueprint('audit', __name__) + + +@audit_bp.route('/api/admin/audit/status', methods=['GET']) +@admin_required +def audit_status(): + """Check if audit logging is enabled.""" + return jsonify({'enabled': is_audit_enabled()}) + + +@audit_bp.route('/api/admin/audit/access', methods=['GET']) +@admin_required +def list_access_logs(): + """List access logs with pagination and filters.""" + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 50, type=int), 200) + user_id = request.args.get('user_id', type=int) + resource_type = request.args.get('resource_type') + resource_id = request.args.get('resource_id', type=int) + action = request.args.get('action') + + result = get_access_logs( + page=page, per_page=per_page, + user_id=user_id, resource_type=resource_type, + resource_id=resource_id, action=action, + ) + + return jsonify({ + 'logs': [log.to_dict() for log in result.items], + 'total': result.total, + 'page': result.page, + 'per_page': per_page, + 'pages': result.pages, + }) + + +@audit_bp.route('/api/admin/audit/auth', methods=['GET']) +@admin_required +def list_auth_logs(): + """List authentication logs with pagination and filters.""" + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 50, type=int), 200) + user_id = request.args.get('user_id', type=int) + action = request.args.get('action') + + result = get_auth_logs(page=page, per_page=per_page, user_id=user_id, action=action) + + return jsonify({ + 'logs': [log.to_dict() for log in result.items], + 'total': result.total, + 'page': result.page, + 'per_page': per_page, + 'pages': result.pages, + }) + + +@audit_bp.route('/api/admin/audit/recording/', methods=['GET']) +@admin_required +def recording_access_logs(recording_id): + """Get all access logs for a specific recording.""" + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 50, type=int), 200) + + result = get_access_logs(page=page, per_page=per_page, resource_type='recording', resource_id=recording_id) + + return jsonify({ + 'logs': [log.to_dict() for log in result.items], + 'total': result.total, + 'page': result.page, + 'per_page': per_page, + 'pages': result.pages, + }) + + +@audit_bp.route('/api/admin/audit/user/', methods=['GET']) +@admin_required +def user_audit_logs(user_id): + """Get all audit logs (access + auth) for a specific user.""" + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 50, type=int), 200) + + access_result = get_access_logs(page=page, per_page=per_page, user_id=user_id) + auth_result = get_auth_logs(page=page, per_page=per_page, user_id=user_id) + + return jsonify({ + 'access_logs': { + 'logs': [log.to_dict() for log in access_result.items], + 'total': access_result.total, + }, + 'auth_logs': { + 'logs': [log.to_dict() for log in auth_result.items], + 'total': auth_result.total, + }, + 'page': page, + 'per_page': per_page, + }) diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..8aa8e21 --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,886 @@ +""" +Authentication and user management routes. + +This blueprint handles user registration, login, logout, account management, +and password changes. +""" + +import os +import re +import hashlib +import mimetypes +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app +from flask_login import login_user, logout_user, login_required, current_user +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField, BooleanField +from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError +from werkzeug.security import generate_password_hash, check_password_hash +from urllib.parse import urlparse, urljoin +import markdown + +from src.database import db +from src.models import User, SystemSetting, GroupMembership +from src.utils import password_check +from src.auth.sso import ( + init_sso_client, + is_sso_enabled, + get_sso_config, + get_sso_client, + create_or_update_sso_user, + is_domain_allowed, + link_sso_to_existing_user, + update_user_profile_from_claims, +) +from src.services.audit import ( + audit_login, audit_logout, audit_failed_login, + audit_register, audit_password_change, audit_password_reset, audit_sso_login, +) +from src.services.email import ( + is_email_verification_enabled, + is_email_verification_required, + is_smtp_configured, + send_verification_email, + send_password_reset_email, + verify_email_token, + verify_reset_token, + can_resend_verification, + can_resend_password_reset, +) + +# Create blueprint +auth_bp = Blueprint('auth', __name__) + +# Import these from app after initialization +bcrypt = None +csrf = None +limiter = None + +def init_auth_extensions(_bcrypt, _csrf, _limiter): + """Initialize extensions after app creation.""" + global bcrypt, csrf, limiter + bcrypt = _bcrypt + csrf = _csrf + limiter = _limiter + + +def rate_limit(limit_string): + """Decorator that applies rate limiting if limiter is available.""" + def decorator(f): + from functools import wraps + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + # Store the limit string for later application + wrapper._rate_limit = limit_string + return wrapper + return decorator + + +def csrf_exempt(f): + """Decorator placeholder for CSRF exemption - applied after initialization.""" + from functools import wraps + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + wrapper._csrf_exempt = True + return wrapper + + +# --- Forms --- + +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), password_check]) + confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Sign Up') + + def validate_username(self, username): + user = User.query.filter_by(username=username.data).first() + if user: + raise ValidationError('That username is already taken. Please choose a different one.') + + def validate_email(self, email): + user = User.query.filter_by(email=email.data).first() + if user: + raise ValidationError('That email is already registered. Please use a different one.') + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Mot de passe', validators=[DataRequired()]) + remember = BooleanField('Se souvenir de moi') + submit = SubmitField('Se connecter') + + +# --- Helper Functions --- + +def is_safe_url(target): + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc + + +def is_registration_domain_allowed(email: str) -> bool: + """Check if email domain is allowed for registration. + + Returns True if no domain restrictions are configured or if the + email domain is in the allowed list. + """ + if not email: + return False + + domains_env = os.environ.get('REGISTRATION_ALLOWED_DOMAINS', '') + if not domains_env or not domains_env.strip(): + return True # No restriction configured + + allowed = [d.strip().lower() for d in domains_env.split(',') if d.strip()] + if not allowed: + return True # Empty after parsing + + parts = email.lower().rsplit('@', 1) + if len(parts) != 2: + return False # Invalid email format + + domain = parts[1] + return domain in allowed + + +# --- Routes --- + +@auth_bp.route('/register', methods=['GET', 'POST']) +@rate_limit("10 per minute") +def register(): + # Check if registration is allowed + allow_registration = os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true' + + if not allow_registration: + flash('Registration is currently disabled. Please contact the administrator.', 'danger') + return redirect(url_for('auth.login')) + + if current_user.is_authenticated: + return redirect(url_for('recordings.index')) + + form = RegistrationForm() + if form.validate_on_submit(): + # Check if email domain is allowed + if not is_registration_domain_allowed(form.email.data): + flash('Registration is restricted. Please contact the administrator.', 'danger') + return render_template('register.html', title='Register', form=form) + + hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8') + + # Set email_verified based on whether verification is enabled + # If verification is enabled, new users start unverified + # If disabled, new users are considered verified by default + email_verified = not is_email_verification_enabled() + + user = User( + username=form.username.data, + email=form.email.data, + password=hashed_password, + email_verified=email_verified + ) + db.session.add(user) + db.session.commit() + audit_register(user.id) + + # Send verification email if enabled + if is_email_verification_enabled() and is_smtp_configured(): + if send_verification_email(user): + return render_template('auth/check_email.html', + title='Check Your Email', + email=user.email, + action='verification') + else: + # Email failed to send, but account was created + flash('Your account has been created, but we could not send a verification email. Please contact support.', 'warning') + return redirect(url_for('auth.login')) + + flash('Your account has been created! You can now log in.', 'success') + return redirect(url_for('auth.login')) + + return render_template('register.html', title='Register', form=form) + + +@auth_bp.route('/login', methods=['GET', 'POST']) +@rate_limit("10 per minute") +def login(): + if current_user.is_authenticated: + return redirect(url_for('recordings.index')) + + sso_enabled = is_sso_enabled() + sso_config = get_sso_config() + if sso_enabled: + init_sso_client(current_app) + + password_login_disabled = sso_enabled and sso_config.get('disable_password_login', False) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user and user.password: + # Check if password login is disabled for non-admins + if password_login_disabled and not user.is_admin: + flash('Password login is disabled. Please sign in with SSO.', 'warning') + elif bcrypt.check_password_hash(user.password, form.password.data): + # Check email verification if required + if is_email_verification_required() and not user.email_verified: + # Store user email in session for resend functionality + session['unverified_email'] = user.email + return render_template('auth/check_email.html', + title='Email Verification Required', + email=user.email, + action='verification_required', + show_resend=True) + + login_user(user, remember=form.remember.data) + audit_login(user.id) + next_page = request.args.get('next') + if not is_safe_url(next_page): + return redirect(url_for('recordings.index')) + return redirect(next_page) if next_page else redirect(url_for('recordings.index')) + else: + _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] + audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'}) + flash('Login unsuccessful. Please check email and password.', 'danger') + elif user and not user.password: + _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] + audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'}) + flash('This account uses SSO login. Please sign in with SSO.', 'warning') + else: + _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] + audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'}) + flash('Login unsuccessful. Please check email and password.', 'danger') + + return render_template( + 'login.html', + title='Login', + form=form, + sso_enabled=sso_enabled, + sso_provider_name=sso_config.get('provider_name', 'SSO'), + password_login_disabled=password_login_disabled + ) + + +@auth_bp.route('/auth/sso/login') +@rate_limit("10 per minute") +def sso_login(): + if not is_sso_enabled(): + flash('SSO is not configured. Please contact the administrator.', 'danger') + return redirect(url_for('auth.login')) + + oauth = get_sso_client() or init_sso_client(current_app) + if not oauth: + flash('Failed to initialize SSO client. Check server logs.', 'danger') + return redirect(url_for('auth.login')) + + next_url = request.args.get('next') + if next_url and is_safe_url(next_url): + session['sso_next'] = next_url + else: + session.pop('sso_next', None) + + try: + return oauth.sso.authorize_redirect(redirect_uri=get_sso_config().get('redirect_uri')) + except Exception as e: + current_app.logger.error(f"SSO redirect failed: {e}") + flash('Impossible de joindre le fournisseur SSO. Vérifiez la connexion réseau du serveur.', 'danger') + return redirect(url_for('auth.login')) + + +@auth_bp.route('/auth/sso/callback') +@rate_limit("20 per minute") +def sso_callback(): + if not is_sso_enabled(): + flash('SSO is not configured. Please contact the administrator.', 'danger') + return redirect(url_for('auth.login')) + + oauth = get_sso_client() or init_sso_client(current_app) + if not oauth: + flash('Failed to initialize SSO client. Check server logs.', 'danger') + return redirect(url_for('auth.login')) + + try: + token = oauth.sso.authorize_access_token() + userinfo = token.get('userinfo') or oauth.sso.userinfo() + except Exception as e: + current_app.logger.warning(f"SSO callback error: {e}") + flash('SSO login failed. Please try again.', 'danger') + return redirect(url_for('auth.login')) + + subject = userinfo.get('sub') + if not subject: + flash('SSO response did not include a subject identifier.', 'danger') + return redirect(url_for('auth.login')) + + link_user_id = session.pop('sso_link_user_id', None) + next_url = session.pop('sso_next', None) + cfg = get_sso_config() + + if link_user_id: + target_user = db.session.get(User, int(link_user_id)) + if not target_user: + flash('Could not link account: user not found.', 'danger') + return redirect(url_for('auth.account')) + + existing = User.query.filter_by(sso_subject=subject).first() + if existing and existing.id != target_user.id: + flash('This SSO account is already linked to another user.', 'danger') + return redirect(url_for('auth.account')) + + update_user_profile_from_claims(target_user, userinfo) + target_user.sso_provider = cfg.get('provider_name', 'SSO') + target_user.sso_subject = subject + db.session.commit() + flash('SSO account linked successfully.', 'success') + return redirect(url_for('auth.account')) + + try: + user = create_or_update_sso_user(userinfo) + except PermissionError as e: + flash(str(e), 'danger') + return redirect(url_for('auth.login')) + except ValueError as e: + flash(str(e), 'danger') + return redirect(url_for('auth.login')) + except Exception as e: + current_app.logger.warning(f"SSO login error: {e}") + flash('Could not complete SSO login. Please try again.', 'danger') + return redirect(url_for('auth.login')) + + login_user(user, remember=True) + audit_sso_login(user.id, details={'provider': cfg.get('provider_name', 'SSO')}) + + if next_url and is_safe_url(next_url): + return redirect(next_url) + return redirect(url_for('recordings.index')) + + +@auth_bp.route('/auth/sso/link', methods=['POST']) +@login_required +def sso_link(): + if not is_sso_enabled(): + flash('SSO is not configured. Please contact the administrator.', 'danger') + return redirect(url_for('auth.account')) + + session['sso_link_user_id'] = current_user.id + session['sso_next'] = url_for('auth.account') + + return redirect(url_for('auth.sso_login')) + + +@auth_bp.route('/auth/sso/unlink', methods=['POST']) +@login_required +def sso_unlink(): + if not current_user.sso_subject: + flash('Your account is not linked to SSO.', 'warning') + return redirect(url_for('auth.account')) + + if not current_user.password: + flash('Cannot unlink SSO - you have no password set. Please set a password first.', 'danger') + return redirect(url_for('auth.account')) + + current_user.sso_provider = None + current_user.sso_subject = None + db.session.commit() + flash('SSO account unlinked successfully.', 'success') + return redirect(url_for('auth.account')) + + +@auth_bp.route('/logout') +@csrf_exempt +def logout(): + if current_user.is_authenticated: + audit_logout(current_user.id) + logout_user() + return redirect(url_for('auth.login')) + + +# --- Email Verification Routes --- + +@auth_bp.route('/verify-email/') +def verify_email(token): + """Verify email address using token from email link.""" + user_id = verify_email_token(token) + + if user_id is None: + flash('The verification link is invalid or has expired.', 'danger') + return redirect(url_for('auth.login')) + + user = db.session.get(User, user_id) + if not user: + flash('User not found.', 'danger') + return redirect(url_for('auth.login')) + + if user.email_verified: + flash('Your email has already been verified.', 'info') + return redirect(url_for('auth.login')) + + # Verify the email + user.email_verified = True + user.email_verification_token = None # Clear the token + db.session.commit() + + return render_template('auth/verify_success.html', title='Email Verified') + + +@auth_bp.route('/resend-verification', methods=['POST']) +@rate_limit("3 per minute") +def resend_verification(): + """Resend verification email.""" + if not is_email_verification_enabled(): + flash('Email verification is not enabled.', 'danger') + return redirect(url_for('auth.login')) + + if not is_smtp_configured(): + flash('Email service is not configured.', 'danger') + return redirect(url_for('auth.login')) + + # Get email from session (set during failed login) or form + email = session.get('unverified_email') or request.form.get('email') + + if not email: + flash('Email address is required.', 'danger') + return redirect(url_for('auth.login')) + + user = User.query.filter_by(email=email).first() + + if not user: + # Don't reveal if user exists + flash('If an account exists with this email, a verification link has been sent.', 'info') + return redirect(url_for('auth.login')) + + if user.email_verified: + flash('Your email has already been verified.', 'info') + return redirect(url_for('auth.login')) + + # Check cooldown + can_resend, remaining = can_resend_verification(user) + if not can_resend: + flash(f'Please wait {remaining} seconds before requesting another verification email.', 'warning') + return render_template('auth/check_email.html', + title='Check Your Email', + email=email, + action='verification_required', + show_resend=True) + + if send_verification_email(user): + flash('A new verification email has been sent.', 'success') + else: + flash('Failed to send verification email. Please try again later.', 'danger') + + return render_template('auth/check_email.html', + title='Check Your Email', + email=email, + action='verification', + show_resend=True) + + +# --- Password Reset Routes --- + +@auth_bp.route('/forgot-password', methods=['GET', 'POST']) +@rate_limit("5 per minute") +def forgot_password(): + """Show and handle forgot password form.""" + if current_user.is_authenticated: + return redirect(url_for('recordings.index')) + + if not is_smtp_configured(): + flash('Password reset is not available. Please contact the administrator.', 'warning') + return redirect(url_for('auth.login')) + + if request.method == 'POST': + email = request.form.get('email') + + if not email: + flash('Email address is required.', 'danger') + return render_template('auth/forgot_password.html', title='Forgot Password') + + user = User.query.filter_by(email=email).first() + + # Always show the same message to prevent email enumeration + if user: + # Check if user has a password (not SSO-only) + if user.password: + # Check cooldown + can_resend, remaining = can_resend_password_reset(user) + if not can_resend: + flash(f'Please wait {remaining} seconds before requesting another reset email.', 'warning') + else: + send_password_reset_email(user) + + flash('If an account exists with this email, a password reset link has been sent.', 'info') + return render_template('auth/check_email.html', + title='Check Your Email', + email=email, + action='password_reset') + + return render_template('auth/forgot_password.html', title='Forgot Password') + + +@auth_bp.route('/reset-password/', methods=['GET', 'POST']) +@rate_limit("10 per minute") +def reset_password(token): + """Handle password reset form.""" + if current_user.is_authenticated: + return redirect(url_for('recordings.index')) + + user_id = verify_reset_token(token) + + if user_id is None: + flash('The password reset link is invalid or has expired.', 'danger') + return redirect(url_for('auth.forgot_password')) + + user = db.session.get(User, user_id) + if not user: + flash('User not found.', 'danger') + return redirect(url_for('auth.forgot_password')) + + if request.method == 'POST': + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + + if not password or not confirm_password: + flash('Both password fields are required.', 'danger') + return render_template('auth/reset_password.html', title='Reset Password', token=token) + + if password != confirm_password: + flash('Passwords do not match.', 'danger') + return render_template('auth/reset_password.html', title='Reset Password', token=token) + + # Validate password + try: + password_check(None, type('obj', (object,), {'data': password})) + except ValidationError as e: + flash(str(e), 'danger') + return render_template('auth/reset_password.html', title='Reset Password', token=token) + + # Update password + hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') + user.password = hashed_password + user.password_reset_token = None # Clear the token + user.password_reset_sent_at = None + + # Also verify email if not already verified + if not user.email_verified: + user.email_verified = True + + db.session.commit() + audit_password_reset(user.id) + + flash('Your password has been reset. You can now log in with your new password.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/reset_password.html', title='Reset Password', token=token) + + +@auth_bp.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + # Import here to avoid circular imports + from flask import current_app + + if request.method == 'POST': + # Only update fields that are present in the form submission + # This prevents clearing data when switching between tabs + + # Check if this is the account information form (has user_name field) + if 'user_name' in request.form: + # Handle personal information updates + user_name = request.form.get('user_name') + user_job_title = request.form.get('user_job_title') + user_company = request.form.get('user_company') + ui_lang = request.form.get('ui_language') + transcription_lang = request.form.get('transcription_language') + output_lang = request.form.get('output_language') + + current_user.name = user_name if user_name else None + current_user.job_title = user_job_title if user_job_title else None + current_user.company = user_company if user_company else None + current_user.ui_language = ui_lang if ui_lang else 'en' + current_user.transcription_language = transcription_lang if transcription_lang else None + current_user.output_language = output_lang if output_lang else None + + # Check if this is the custom prompts form (has summary_prompt field) + elif 'summary_prompt' in request.form: + # Handle custom prompt updates + summary_prompt_text = request.form.get('summary_prompt') + current_user.summary_prompt = summary_prompt_text if summary_prompt_text else None + # Handle event extraction setting + current_user.extract_events = 'extract_events' in request.form + # Handle transcription hints + hotwords = request.form.get('transcription_hotwords') + current_user.transcription_hotwords = hotwords if hotwords else None + initial_prompt = request.form.get('transcription_initial_prompt') + current_user.transcription_initial_prompt = initial_prompt if initial_prompt else None + + # Only update diarize if it's not locked by env var + if 'ASR_DIARIZE' not in os.environ: + current_user.diarize = 'diarize' in request.form + + db.session.commit() + + # Return JSON response for AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.accept_mimetypes.best == 'application/json': + return jsonify({'success': True, 'message': 'Account details updated successfully!'}) + + # Regular form submission with redirect + flash('Account details updated successfully!', 'success') + + # Preserve the active tab when redirecting + if 'summary_prompt' in request.form: + return redirect(url_for('auth.account') + '#prompts') + else: + return redirect(url_for('auth.account')) + + # Get admin default prompt from system settings + admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None) + if admin_default_prompt: + default_summary_prompt_text = admin_default_prompt + else: + # Fallback to hardcoded default if admin hasn't set one + default_summary_prompt_text = """Generate a comprehensive summary that includes the following sections: +- **Key Issues Discussed**: A bulleted list of the main topics +- **Key Decisions Made**: A bulleted list of any decisions reached +- **Action Items**: A bulleted list of tasks assigned, including who is responsible if mentioned""" + + asr_diarize_locked = 'ASR_DIARIZE' in os.environ + ASR_DIARIZE = os.environ.get('ASR_DIARIZE', 'false').lower() == 'true' + USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + USE_NEW_TRANSCRIPTION_ARCHITECTURE = os.environ.get('USE_NEW_TRANSCRIPTION_ARCHITECTURE', 'true').lower() == 'true' + ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' + ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' + ASR_RETURN_SPEAKER_EMBEDDINGS = os.environ.get('ASR_RETURN_SPEAKER_EMBEDDINGS', 'false').lower() == 'true' + ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true' + + # Get connector diarization support (new architecture) + connector_supports_diarization = USE_ASR_ENDPOINT # Default to USE_ASR_ENDPOINT for backwards compat + if USE_NEW_TRANSCRIPTION_ARCHITECTURE: + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.get_active_connector() + if connector: + connector_supports_diarization = connector.supports_diarization + except Exception as e: + current_app.logger.warning(f"Could not get connector diarization support: {e}") + + # Check if user is a team admin and get their admin groups + admin_memberships = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).all() + + is_team_admin = len(admin_memberships) > 0 + + # Build list of groups where user is admin (for tag assignment) + user_admin_groups = [] + for membership in admin_memberships: + if membership.group: + user_admin_groups.append({ + 'id': membership.group.id, + 'name': membership.group.name + }) + + sso_config = get_sso_config() + sso_enabled = is_sso_enabled() + if sso_enabled: + init_sso_client(current_app) + sso_linked = bool(current_user.sso_subject) + + password_login_disabled = sso_enabled and sso_config.get('disable_password_login', False) + + # Check if admin has globally disabled auto-summarization + admin_setting = SystemSetting.get_setting('disable_auto_summarization', False) + admin_disabled_auto_summarization = admin_setting if isinstance(admin_setting, bool) else str(admin_setting).lower() == 'true' + + # Get user's UI language preference + user_language = current_user.ui_language if current_user.ui_language else 'en' + + return render_template('account.html', + title='Account', + default_summary_prompt_text=default_summary_prompt_text, + use_asr_endpoint=USE_ASR_ENDPOINT, + connector_supports_diarization=connector_supports_diarization, + enable_auto_deletion=ENABLE_AUTO_DELETION, + enable_internal_sharing=ENABLE_INTERNAL_SHARING, + user_admin_groups=user_admin_groups, + asr_diarize_locked=asr_diarize_locked, + asr_diarize_env_value=ASR_DIARIZE, + is_team_admin=is_team_admin, + sso_enabled=sso_enabled, + sso_provider_name=sso_config.get('provider_name', 'SSO'), + sso_linked=sso_linked, + sso_subject=current_user.sso_subject, + has_password=bool(current_user.password), + password_login_disabled=password_login_disabled, + speaker_embeddings_enabled=ASR_RETURN_SPEAKER_EMBEDDINGS, + auto_speaker_labelling=current_user.auto_speaker_labelling, + auto_speaker_labelling_threshold=current_user.auto_speaker_labelling_threshold or 'medium', + admin_disabled_auto_summarization=admin_disabled_auto_summarization, + auto_summarization=current_user.auto_summarization if current_user.auto_summarization is not None else True, + user_language=user_language, + enable_auto_export=ENABLE_AUTO_EXPORT) + + +@auth_bp.route('/api/user/auto-speaker-labelling', methods=['POST']) +@login_required +def update_auto_speaker_labelling(): + """Update user's auto speaker labelling settings.""" + data = request.get_json() + + if data is None: + return jsonify({'success': False, 'error': 'Invalid JSON'}), 400 + + # Update enabled state + if 'enabled' in data: + current_user.auto_speaker_labelling = bool(data['enabled']) + + # Update threshold (validate value) + if 'threshold' in data: + threshold = data['threshold'] + if threshold in ('low', 'medium', 'high'): + current_user.auto_speaker_labelling_threshold = threshold + else: + return jsonify({'success': False, 'error': 'Invalid threshold value'}), 400 + + db.session.commit() + + return jsonify({ + 'success': True, + 'auto_speaker_labelling': current_user.auto_speaker_labelling, + 'auto_speaker_labelling_threshold': current_user.auto_speaker_labelling_threshold + }) + + +@auth_bp.route('/api/user/auto-summarization', methods=['POST']) +@login_required +def update_auto_summarization(): + """Update user's auto summarization setting.""" + data = request.get_json() + + if data is None: + return jsonify({'success': False, 'error': 'Invalid JSON'}), 400 + + if 'enabled' in data: + current_user.auto_summarization = bool(data['enabled']) + db.session.commit() + + return jsonify({ + 'success': True, + 'auto_summarization': current_user.auto_summarization + }) + + +@auth_bp.route('/change_password', methods=['POST']) +@login_required +@rate_limit("10 per minute") +def change_password(): + # Check if password management is disabled for non-admins + sso_config = get_sso_config() + password_login_disabled = is_sso_enabled() and sso_config.get('disable_password_login', False) + if password_login_disabled and not current_user.is_admin: + flash('Password management is disabled. Please use SSO to sign in.', 'warning') + return redirect(url_for('auth.account')) + + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + # Check if user has an existing password + has_existing_password = bool(current_user.password) + + # Validate form data - current password only required if user has one + if has_existing_password and not current_password: + flash('Current password is required.', 'danger') + return redirect(url_for('auth.account')) + + if not new_password or not confirm_password: + flash('New password and confirmation are required.', 'danger') + return redirect(url_for('auth.account')) + + if new_password != confirm_password: + flash('New password and confirmation do not match.', 'danger') + return redirect(url_for('auth.account')) + + # Custom validation for new password + try: + password_check(None, type('obj', (object,), {'data': new_password})) + except ValidationError as e: + flash(str(e), 'danger') + return redirect(url_for('auth.account')) + + # Verify current password only if user has one + if has_existing_password: + if not bcrypt.check_password_hash(current_user.password, current_password): + flash('Current password is incorrect.', 'danger') + return redirect(url_for('auth.account')) + + # Update password + hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8') + current_user.password = hashed_password + db.session.commit() + audit_password_change(current_user.id) + + flash('Your password has been updated!', 'success') + return redirect(url_for('auth.account')) + + +@auth_bp.route('/docs/transcript-templates-guide') +def transcript_templates_guide(): + """Serve the transcript templates documentation.""" + from flask import current_app + + docs_path = os.path.join(current_app.root_path, '..', 'docs', 'transcript-templates-guide.md') + + if not os.path.exists(docs_path): + return "Documentation not found", 404 + + with open(docs_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Convert markdown to HTML + html_content = markdown.markdown(content, extensions=['tables', 'fenced_code', 'codehilite']) + + # Wrap in basic HTML template with Speakr styling + html_template = f''' + + + + + + Transcript Templates Guide - Speakr + + + + + +
+ ← Back to App + {html_content} +
+ + + ''' + + return html_template diff --git a/src/api/docs.py b/src/api/docs.py new file mode 100644 index 0000000..5ffae71 --- /dev/null +++ b/src/api/docs.py @@ -0,0 +1,201 @@ +"""Documentation API - serves client documentation pages.""" +import os +import markdown +from markupsafe import escape +from flask import Blueprint, jsonify +from flask_login import login_required +from src.utils import sanitize_html + +docs_bp = Blueprint('docs', __name__) + +# Path to client documentation files +DOCS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'client_docs') + +# Singleton Markdown instance (performance: avoid reinitializing extensions on every call) +_docs_md = markdown.Markdown( + extensions=[ + 'tables', + 'fenced_code', + 'toc', + 'admonition', + 'attr_list', + 'md_in_html', + 'sane_lists' + ], + extension_configs={ + 'toc': {'permalink': False, 'toc_depth': 3} + } +) + + +def _read_doc_file(filepath): + """Read and cache a documentation file. Returns content string.""" + mtime = os.path.getmtime(filepath) + cache = getattr(_read_doc_file, '_cache', {}) + cached = cache.get(filepath) + if cached and cached[0] == mtime: + return cached[1] + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + cache[filepath] = (mtime, content) + _read_doc_file._cache = cache + return content + + +def get_nav_structure(is_admin=False): + """Return navigation structure for documentation.""" + sections = [ + { + "title": "Guide Utilisateur", + "icon": "fa-book-open", + "slug": "guide-utilisateur", + "pages": [ + {"title": "Vue d'ensemble", "slug": "index"}, + {"title": "Premiers pas", "slug": "premiers-pas"}, + {"title": "Enregistrement", "slug": "enregistrement"}, + {"title": "Transcriptions", "slug": "transcriptions"}, + {"title": "Recherche IA", "slug": "recherche-ia"}, + {"title": "Partage", "slug": "partage"}, + {"title": "Dossiers", "slug": "dossiers"}, + {"title": "Groupes", "slug": "groupes"}, + {"title": "Paramètres", "slug": "parametres"}, + {"title": "Application mobile", "slug": "application-mobile"} + ] + }, + { + "title": "Dépannage", + "icon": "fa-life-ring", + "slug": "depannage", + "pages": [ + {"title": "Vue d'ensemble", "slug": "index"}, + {"title": "Transcription", "slug": "transcription"}, + {"title": "Performance", "slug": "performance"}, + {"title": "Fonctionnalités", "slug": "fonctionnalites"} + ] + } + ] + + if is_admin: + sections.insert(1, { + "title": "Guide Administrateur", + "icon": "fa-shield-alt", + "slug": "guide-admin", + "pages": [ + {"title": "Vue d'ensemble", "slug": "index"}, + {"title": "Gestion des utilisateurs", "slug": "gestion-utilisateurs"}, + {"title": "Gestion des groupes", "slug": "gestion-groupes"}, + {"title": "Statistiques", "slug": "statistiques"}, + {"title": "Paramètres système", "slug": "parametres-systeme"}, + {"title": "Modèles IA", "slug": "modeles-ia"}, + {"title": "Prompts", "slug": "prompts"}, + {"title": "Recherche sémantique", "slug": "recherche-semantique"}, + {"title": "Rétention", "slug": "retention"}, + {"title": "SSO", "slug": "sso"} + ] + }) + + return sections + + +def render_markdown_content(md_text): + """Render markdown to sanitized HTML with extensions for admonitions, tables, etc.""" + _docs_md.reset() + html = _docs_md.convert(md_text) + toc_html = getattr(_docs_md, 'toc', '') + # Sanitize rendered HTML to prevent XSS + html = sanitize_html(html) + return html, toc_html + + +@docs_bp.route('/api/docs/nav') +@login_required +def docs_nav(): + """Return navigation structure based on user role.""" + from flask_login import current_user + is_admin = getattr(current_user, 'is_admin', False) + sections = get_nav_structure(is_admin) + return jsonify({"sections": sections}) + + +@docs_bp.route('/api/docs/page/
/') +@login_required +def docs_page(section, page): + """Return rendered HTML for a documentation page.""" + # Sanitize path components to prevent directory traversal + safe_section = os.path.basename(section) + safe_page = os.path.basename(page) + + allowed_sections = ['guide-utilisateur', 'guide-admin', 'depannage'] + if safe_section not in allowed_sections: + return jsonify({"error": "Section invalide"}), 404 + + # Admin guide requires admin role + if safe_section == 'guide-admin': + from flask_login import current_user + if not getattr(current_user, 'is_admin', False): + return jsonify({"error": "Accès refusé"}), 403 + + filepath = os.path.join(DOCS_DIR, safe_section, f"{safe_page}.md") + + if not os.path.isfile(filepath): + return jsonify({"error": "Page non trouvée"}), 404 + + content = _read_doc_file(filepath) + html, toc = render_markdown_content(content) + + return jsonify({ + "html": html, + "toc": toc, + "section": safe_section, + "page": safe_page + }) + + +@docs_bp.route('/api/docs/search') +@login_required +def docs_search(): + """Simple text search across all documentation pages.""" + from flask import request + from flask_login import current_user + + query = request.args.get('q', '').strip().lower() + if not query or len(query) < 2: + return jsonify({"results": []}) + + is_admin = getattr(current_user, 'is_admin', False) + results = [] + + sections = get_nav_structure(is_admin) + + for section in sections: + section_dir = os.path.join(DOCS_DIR, section['slug']) + if not os.path.isdir(section_dir): + continue + + for page_info in section['pages']: + filepath = os.path.join(section_dir, f"{page_info['slug']}.md") + if not os.path.isfile(filepath): + continue + + content = _read_doc_file(filepath) + + content_lower = content.lower() + if query in content_lower: + # Find matching line for context + lines = content.split('\n') + snippet = '' + for line in lines: + if query in line.lower(): + # HTML-escape snippet to prevent XSS + snippet = str(escape(line.strip()[:150])) + break + + results.append({ + "section": section['slug'], + "section_title": section['title'], + "page": page_info['slug'], + "page_title": page_info['title'], + "snippet": snippet + }) + + return jsonify({"results": results}) diff --git a/src/api/events.py b/src/api/events.py new file mode 100644 index 0000000..5735995 --- /dev/null +++ b/src/api/events.py @@ -0,0 +1,173 @@ +""" +Calendar event extraction and export. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app, make_response +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * +from src.services.calendar import generate_ics_content + +# Create blueprint +events_bp = Blueprint('events', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_events_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@events_bp.route('/api/recording//events', methods=['GET']) +@login_required +def get_recording_events(recording_id): + """Get all events extracted from a recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Unauthorized'}), 403 + + events = Event.query.filter_by(recording_id=recording_id).all() + return jsonify({'events': [event.to_dict() for event in events]}) + + except Exception as e: + current_app.logger.error(f"Error fetching events for recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + + +@events_bp.route('/api/event//ics', methods=['GET']) +@login_required +def download_event_ics(event_id): + """Generate and download an ICS file for a single event.""" + try: + event = db.session.get(Event, event_id) + if not event: + return jsonify({'error': 'Event not found'}), 404 + + # Check permissions through recording access + if not has_recording_access(event.recording, current_user): + return jsonify({'error': 'Unauthorized'}), 403 + + # Generate ICS content + ics_content = generate_ics_content(event) + + # Create response with ICS file + response = make_response(ics_content) + response.headers['Content-Type'] = 'text/calendar; charset=utf-8' + response.headers['Content-Disposition'] = f'attachment; filename="{secure_filename(event.title)}.ics"' + + return response + + except Exception as e: + current_app.logger.error(f"Error generating ICS for event {event_id}: {e}") + return jsonify({'error': str(e)}), 500 + + +@events_bp.route('/api/recording//events/ics', methods=['GET']) +@login_required +def download_all_events_ics(recording_id): + """Generate and download an ICS file containing all events from a recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'Unauthorized'}), 403 + + # Get all events for this recording + events = Event.query.filter_by(recording_id=recording_id).all() + if not events: + return jsonify({'error': 'No events found for this recording'}), 404 + + # Generate combined ICS content + ics_lines = [] + ics_lines.append("BEGIN:VCALENDAR") + ics_lines.append("VERSION:2.0") + ics_lines.append("PRODID:-//Speakr//Event Export//EN") + ics_lines.append("CALSCALE:GREGORIAN") + ics_lines.append("METHOD:PUBLISH") + + # Add each event + for event in events: + # Get the individual event's ICS content and extract just the VEVENT portion + individual_ics = generate_ics_content(event) + # Extract VEVENT block from individual ICS + lines = individual_ics.split('\n') + in_event = False + for line in lines: + if line.startswith('BEGIN:VEVENT'): + in_event = True + if in_event: + ics_lines.append(line) + if line.startswith('END:VEVENT'): + in_event = False + + ics_lines.append("END:VCALENDAR") + ics_content = '\r\n'.join(ics_lines) + + # Create response with ICS file + response = make_response(ics_content) + response.headers['Content-Type'] = 'text/calendar; charset=utf-8' + safe_title = secure_filename(recording.title) if recording.title else f'recording-{recording_id}' + response.headers['Content-Disposition'] = f'attachment; filename="{safe_title}-events.ics"' + + return response + + except Exception as e: + current_app.logger.error(f"Error generating ICS for all events in recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + + +@events_bp.route('/api/event/', methods=['DELETE']) +@login_required +def delete_event(event_id): + """Delete a single event.""" + try: + event = db.session.get(Event, event_id) + if not event: + return jsonify({'error': 'Event not found'}), 404 + + # Check permissions through recording access + if not has_recording_access(event.recording, current_user): + return jsonify({'error': 'Unauthorized'}), 403 + + db.session.delete(event) + db.session.commit() + + return jsonify({'success': True}) + + except Exception as e: + current_app.logger.error(f"Error deleting event {event_id}: {e}") + db.session.rollback() + return jsonify({'error': str(e)}), 500 + + diff --git a/src/api/export_templates.py b/src/api/export_templates.py new file mode 100644 index 0000000..aa35374 --- /dev/null +++ b/src/api/export_templates.py @@ -0,0 +1,162 @@ +""" +Export template management API. + +This blueprint provides CRUD operations for export templates, +following the same pattern as transcript templates. +""" + +import os +from datetime import datetime +from flask import Blueprint, request, jsonify, current_app +from flask_login import login_required, current_user + +from src.database import db +from src.models import ExportTemplate + +# Create blueprint +export_templates_bp = Blueprint('export_templates', __name__) + +# Configuration from environment +ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true' + + +# --- Routes --- + +@export_templates_bp.route('/api/export-templates', methods=['GET']) +@login_required +def get_export_templates(): + """Get all export templates for the current user.""" + templates = ExportTemplate.query.filter_by(user_id=current_user.id).all() + return jsonify([template.to_dict() for template in templates]) + + +@export_templates_bp.route('/api/export-templates', methods=['POST']) +@login_required +def create_export_template(): + """Create a new export template.""" + data = request.json + if not data or not data.get('name') or not data.get('template'): + return jsonify({'error': 'Name and template are required'}), 400 + + # If this is set as default, unset other defaults + if data.get('is_default'): + ExportTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).update({'is_default': False}) + + template = ExportTemplate( + user_id=current_user.id, + name=data['name'], + template=data['template'], + description=data.get('description'), + is_default=data.get('is_default', False) + ) + + db.session.add(template) + db.session.commit() + + return jsonify(template.to_dict()), 201 + + +@export_templates_bp.route('/api/export-templates/', methods=['PUT']) +@login_required +def update_export_template(template_id): + """Update an existing export template.""" + template = ExportTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # If this is set as default, unset other defaults + if data.get('is_default'): + ExportTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).update({'is_default': False}) + + template.name = data.get('name', template.name) + template.template = data.get('template', template.template) + template.description = data.get('description', template.description) + template.is_default = data.get('is_default', template.is_default) + template.updated_at = datetime.utcnow() + + db.session.commit() + + return jsonify(template.to_dict()) + + +@export_templates_bp.route('/api/export-templates/', methods=['DELETE']) +@login_required +def delete_export_template(template_id): + """Delete an export template.""" + template = ExportTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + db.session.delete(template) + db.session.commit() + + return jsonify({'success': True}) + + +@export_templates_bp.route('/api/export-templates/create-defaults', methods=['POST']) +@login_required +def create_default_export_templates(): + """Create default export template for the user if they don't have any.""" + existing_templates = ExportTemplate.query.filter_by(user_id=current_user.id).count() + + if existing_templates > 0: + return jsonify({'message': 'User already has templates'}), 200 + + # Default template with localized labels + default_template = ExportTemplate( + user_id=current_user.id, + name="Standard Export", + template="""# {{title}} + +## {{label.metadata}} + +{{#if meeting_date}}- **{{label.date}}:** {{meeting_date}} +{{/if}}{{#if created_at}}- **{{label.created}}:** {{created_at}} +{{/if}}{{#if original_filename}}- **{{label.originalFile}}:** {{original_filename}} +{{/if}}{{#if file_size}}- **{{label.fileSize}}:** {{file_size}} +{{/if}}{{#if participants}}- **{{label.participants}}:** {{participants}} +{{/if}}{{#if tags}}- **{{label.tags}}:** {{tags}} +{{/if}} + +{{#if notes}}## {{label.notes}} + +{{notes}} + +{{/if}}{{#if summary}}## {{label.summary}} + +{{summary}} + +{{/if}}{{#if transcription}}## {{label.transcription}} + +{{transcription}} + +{{/if}}""", + description="Default export template with localized labels", + is_default=True + ) + + db.session.add(default_template) + db.session.commit() + + return jsonify({ + 'success': True, + 'templates': [default_template.to_dict()] + }), 201 diff --git a/src/api/folders.py b/src/api/folders.py new file mode 100644 index 0000000..71888fd --- /dev/null +++ b/src/api/folders.py @@ -0,0 +1,665 @@ +""" +Folder management and assignment. + +This blueprint handles folder CRUD operations and recording-folder assignments. +Folders are one-to-many (a recording can only belong to one folder). +""" + +import os +from datetime import datetime +from flask import Blueprint, request, jsonify, current_app +from flask_login import login_required, current_user + +from sqlalchemy.exc import IntegrityError + +from src.database import db +from src.services.audit import audit_access +from src.models import * + +# Create blueprint +folders_bp = Blueprint('folders', __name__) + +# Configuration from environment +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + + +def init_folders_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@folders_bp.route('/api/folders', methods=['GET']) +@login_required +def get_folders(): + """Get all folders for the current user, including group folders they have access to.""" + # Check if folders feature is enabled - return empty array if not + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify([]) + + # Get user's personal folders + user_folders = Folder.query.filter_by(user_id=current_user.id, group_id=None).order_by(Folder.name).all() + + # Get user's team memberships with roles + memberships = GroupMembership.query.filter_by(user_id=current_user.id).all() + team_roles = {m.group_id: m.role for m in memberships} + team_ids = list(team_roles.keys()) + + # Get group folders for all teams the user is a member of + team_folders = [] + if team_ids: + team_folders = Folder.query.filter(Folder.group_id.in_(team_ids)).order_by(Folder.name).all() + + # Build response with edit permissions + result = [] + + # Personal folders - user can always edit their own + for folder in user_folders: + folder_dict = folder.to_dict() + folder_dict['can_edit'] = True + folder_dict['user_role'] = None + result.append(folder_dict) + + # Group folders - only admins can edit + for folder in team_folders: + folder_dict = folder.to_dict() + user_role = team_roles.get(folder.group_id, 'member') + folder_dict['can_edit'] = (user_role == 'admin') + folder_dict['user_role'] = user_role + result.append(folder_dict) + + return jsonify(result) + + +@folders_bp.route('/api/folders', methods=['POST']) +@login_required +def create_folder(): + """Create a new folder (personal or group folder).""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + data = request.get_json() + + if not data or not data.get('name'): + return jsonify({'error': 'Folder name is required'}), 400 + + group_id = data.get('group_id') + + # If creating a group folder, verify user is admin of that group + if group_id: + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can create group folders'}), 403 + + # Check if group folder with same name already exists for this group + existing_folder = Folder.query.filter_by(name=data['name'], group_id=group_id).first() + if existing_folder: + return jsonify({'error': 'A folder with this name already exists for this group'}), 400 + else: + # Check if personal folder with same name already exists for this user + existing_folder = Folder.query.filter_by(name=data['name'], user_id=current_user.id, group_id=None).first() + if existing_folder: + return jsonify({'error': 'Folder with this name already exists'}), 400 + + # Handle retention_days: -1 means protected from deletion + retention_days = data.get('retention_days') + protect_from_deletion = False + + if retention_days == -1: + # -1 indicates infinite retention (protected from auto-deletion) + protect_from_deletion = True if ENABLE_AUTO_DELETION else False + + # Validate naming_template_id if provided + naming_template_id = data.get('naming_template_id') + if naming_template_id: + template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Naming template not found'}), 404 + + # Validate export_template_id if provided + export_template_id = data.get('export_template_id') + if export_template_id: + template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Export template not found'}), 404 + + folder = Folder( + name=data['name'], + user_id=current_user.id, + group_id=group_id, + color=data.get('color', '#10B981'), + custom_prompt=data.get('custom_prompt'), + default_language=data.get('default_language'), + default_min_speakers=data.get('default_min_speakers'), + default_max_speakers=data.get('default_max_speakers'), + default_hotwords=data.get('default_hotwords'), + default_initial_prompt=data.get('default_initial_prompt'), + protect_from_deletion=protect_from_deletion, + retention_days=retention_days, + auto_share_on_apply=data.get('auto_share_on_apply', True) if group_id else True, + share_with_group_lead=data.get('share_with_group_lead', True) if group_id else True, + naming_template_id=naming_template_id, + export_template_id=export_template_id + ) + + db.session.add(folder) + + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f"Folder creation failed due to integrity constraint: {str(e)}") + return jsonify({'error': 'A folder with this name already exists'}), 400 + + return jsonify(folder.to_dict()), 201 + + +@folders_bp.route('/api/folders/', methods=['PUT']) +@login_required +def update_folder(folder_id): + """Update a folder.""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + folder = db.session.get(Folder, folder_id) + if not folder: + return jsonify({'error': 'Folder not found'}), 404 + + # Check permissions + if folder.group_id: + # Group folder - user must be a team admin + membership = GroupMembership.query.filter_by( + group_id=folder.group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can edit group folders'}), 403 + else: + # Personal folder - must be the owner + if folder.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to edit this folder'}), 403 + + data = request.get_json() + + if 'name' in data: + # Check if new name conflicts with another folder + if folder.group_id: + existing_folder = Folder.query.filter_by(name=data['name'], group_id=folder.group_id).filter(Folder.id != folder_id).first() + else: + existing_folder = Folder.query.filter_by(name=data['name'], user_id=current_user.id).filter(Folder.id != folder_id).first() + + if existing_folder: + return jsonify({'error': 'Another folder with this name already exists'}), 400 + folder.name = data['name'] + + # Handle group_id changes (converting between personal and group folders) + if 'group_id' in data: + new_group_id = data['group_id'] if data['group_id'] else None + + # If changing to a group folder, verify user is admin of that group + if new_group_id: + membership = GroupMembership.query.filter_by( + group_id=new_group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can assign folders to groups'}), 403 + + folder.group_id = new_group_id + + if 'color' in data: + folder.color = data['color'] + if 'custom_prompt' in data: + folder.custom_prompt = data['custom_prompt'] + if 'default_language' in data: + folder.default_language = data['default_language'] + if 'default_min_speakers' in data: + folder.default_min_speakers = data['default_min_speakers'] + if 'default_max_speakers' in data: + folder.default_max_speakers = data['default_max_speakers'] + if 'default_hotwords' in data: + folder.default_hotwords = data['default_hotwords'] or None + if 'default_initial_prompt' in data: + folder.default_initial_prompt = data['default_initial_prompt'] or None + + # Handle retention_days: -1 means protected from deletion + if 'retention_days' in data: + retention_days = data['retention_days'] + + if retention_days == -1: + # -1 indicates infinite retention (protected from auto-deletion) + if ENABLE_AUTO_DELETION: + folder.protect_from_deletion = True + folder.retention_days = -1 + else: + # Regular retention period or null (use global) + folder.protect_from_deletion = False + folder.retention_days = retention_days if retention_days else None + if 'auto_share_on_apply' in data: + # Only applicable to group folders + if folder.group_id: + folder.auto_share_on_apply = bool(data['auto_share_on_apply']) + if 'share_with_group_lead' in data: + # Only applicable to group folders + if folder.group_id: + folder.share_with_group_lead = bool(data['share_with_group_lead']) + if 'naming_template_id' in data: + naming_template_id = data['naming_template_id'] + if naming_template_id: + template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Naming template not found'}), 404 + folder.naming_template_id = naming_template_id if naming_template_id else None + if 'export_template_id' in data: + export_template_id = data['export_template_id'] + if export_template_id: + template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Export template not found'}), 404 + folder.export_template_id = export_template_id if export_template_id else None + + folder.updated_at = datetime.utcnow() + + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f"Folder update failed due to integrity constraint: {str(e)}") + return jsonify({'error': 'A folder with this name already exists'}), 400 + + return jsonify(folder.to_dict()) + + +@folders_bp.route('/api/folders/', methods=['DELETE']) +@login_required +def delete_folder(folder_id): + """Delete a folder. Recordings in this folder will have folder_id set to NULL.""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + folder = db.session.get(Folder, folder_id) + if not folder: + return jsonify({'error': 'Folder not found'}), 404 + + # Check permissions + if folder.group_id: + # Group folder - user must be a team admin + membership = GroupMembership.query.filter_by( + group_id=folder.group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can delete group folders'}), 403 + else: + # Personal folder - must belong to the user + if folder.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to delete this folder'}), 403 + + # Recordings in this folder will have folder_id set to NULL via ondelete='SET NULL' + db.session.delete(folder) + db.session.commit() + return jsonify({'success': True}) + + +@folders_bp.route('/api/groups//folders', methods=['POST']) +@login_required +def create_group_folder(group_id): + """Create a group-scoped folder (group admins only).""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Group folders require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403 + + # Verify team exists + team = db.session.get(Group, group_id) + if not team: + return jsonify({'error': 'Group not found'}), 404 + + # Verify user is a team admin + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can create group folders'}), 403 + + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify({'error': 'Folder name is required'}), 400 + + # Check if a group folder with this name already exists for this team + existing_folder = Folder.query.filter_by( + name=name, + group_id=group_id + ).first() + + if existing_folder: + return jsonify({'error': 'A group folder with this name already exists'}), 400 + + # Validate naming_template_id if provided + naming_template_id = data.get('naming_template_id') + if naming_template_id: + template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Naming template not found'}), 404 + + # Validate export_template_id if provided + export_template_id = data.get('export_template_id') + if export_template_id: + template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Export template not found'}), 404 + + # Create the group folder with all supported parameters + folder = Folder( + name=name, + user_id=current_user.id, # Creator + group_id=group_id, + color=data.get('color', '#10B981'), + custom_prompt=data.get('custom_prompt'), + default_language=data.get('default_language'), + default_min_speakers=data.get('default_min_speakers'), + default_max_speakers=data.get('default_max_speakers'), + default_hotwords=data.get('default_hotwords'), + default_initial_prompt=data.get('default_initial_prompt'), + protect_from_deletion=data.get('protect_from_deletion', False), + retention_days=data.get('retention_days'), + auto_share_on_apply=data.get('auto_share_on_apply', True), # Default to True for group folders + share_with_group_lead=data.get('share_with_group_lead', True), # Default to True for group folders + naming_template_id=naming_template_id, + export_template_id=export_template_id + ) + + db.session.add(folder) + + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f"Folder creation failed due to integrity constraint: {str(e)}") + return jsonify({'error': 'A folder with this name already exists'}), 400 + + return jsonify(folder.to_dict()), 201 + + +@folders_bp.route('/api/groups//folders', methods=['GET']) +@login_required +def get_group_folders(group_id): + """Get all folders for a team (team members only).""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + # Verify team exists + team = db.session.get(Group, group_id) + if not team: + return jsonify({'error': 'Group not found'}), 404 + + # Verify user is a team member + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + + if not membership: + return jsonify({'error': 'You must be a team member to view group folders'}), 403 + + # Get all group folders + folders = Folder.query.filter_by(group_id=group_id).all() + + return jsonify({'folders': [folder.to_dict() for folder in folders]}) + + +@folders_bp.route('/api/recordings//folder', methods=['PUT']) +@login_required +def assign_recording_folder(recording_id): + """Assign a recording to a folder (or move to a different folder).""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check access to recording (require edit permission) + if has_recording_access: + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + else: + # Fallback: only owner can assign folder + if recording.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + + data = request.get_json() + folder_id = data.get('folder_id') + + if folder_id: + # Verify folder exists and user has access + folder = db.session.get(Folder, folder_id) + if not folder: + return jsonify({'error': 'Folder not found'}), 404 + + # Check if user can use this folder + if folder.group_id: + # Group folder - user must be a member + membership = GroupMembership.query.filter_by( + group_id=folder.group_id, + user_id=current_user.id + ).first() + if not membership: + return jsonify({'error': 'You do not have access to this folder'}), 403 + else: + # Personal folder - must be owner + if folder.user_id != current_user.id: + return jsonify({'error': 'You do not have access to this folder'}), 403 + + # Handle auto-sharing for group folders + old_folder_id = recording.folder_id + recording.folder_id = folder_id + + # Apply auto-shares if moving to a group folder + if folder.group_id and (folder.auto_share_on_apply or folder.share_with_group_lead): + _apply_folder_auto_shares(recording, folder) + + audit_access('move_folder', 'recording', recording_id, details={'folder_id': folder_id, 'old_folder_id': old_folder_id}) + db.session.commit() # commit folder + audit en une transaction atomique + current_app.logger.info(f"Recording {recording_id} moved to folder {folder_id} by user {current_user.id}") + else: + # Remove from folder + recording.folder_id = None + db.session.commit() + current_app.logger.info(f"Recording {recording_id} removed from folder by user {current_user.id}") + + return jsonify(recording.to_dict(include_html=False, viewer_user=current_user)) + + +@folders_bp.route('/api/recordings//folder', methods=['DELETE']) +@login_required +def remove_recording_folder(recording_id): + """Remove a recording from its folder.""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check access to recording (require edit permission) + if has_recording_access: + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + else: + # Fallback: only owner can remove folder + if recording.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + + recording.folder_id = None + db.session.commit() + current_app.logger.info(f"Recording {recording_id} removed from folder by user {current_user.id}") + + return jsonify({'success': True}) + + +@folders_bp.route('/api/recordings/bulk/folder', methods=['POST']) +@login_required +def bulk_assign_folder(): + """Assign multiple recordings to a folder.""" + # Check if folders feature is enabled + folders_enabled = SystemSetting.get_setting('enable_folders', False) + if not folders_enabled: + return jsonify({'error': 'Folders feature is not enabled'}), 403 + + data = request.get_json() + recording_ids = data.get('recording_ids', []) + folder_id = data.get('folder_id') # Can be None to remove from folder + + if not recording_ids: + return jsonify({'error': 'No recordings specified'}), 400 + + # Verify folder if specified + folder = None + if folder_id: + folder = db.session.get(Folder, folder_id) + if not folder: + return jsonify({'error': 'Folder not found'}), 404 + + # Check if user can use this folder + if folder.group_id: + membership = GroupMembership.query.filter_by( + group_id=folder.group_id, + user_id=current_user.id + ).first() + if not membership: + return jsonify({'error': 'You do not have access to this folder'}), 403 + else: + if folder.user_id != current_user.id: + return jsonify({'error': 'You do not have access to this folder'}), 403 + + updated_count = 0 + for rec_id in recording_ids: + recording = db.session.get(Recording, rec_id) + if not recording: + continue + + # Check access (require edit permission) + if has_recording_access: + if not has_recording_access(recording, current_user, require_edit=True): + continue + else: + if recording.user_id != current_user.id: + continue + + recording.folder_id = folder_id + + # Apply auto-shares if moving to a group folder + if folder and folder.group_id and (folder.auto_share_on_apply or folder.share_with_group_lead): + _apply_folder_auto_shares(recording, folder) + + updated_count += 1 + + db.session.commit() + action = f"moved to folder {folder_id}" if folder_id else "removed from folder" + current_app.logger.info(f"Bulk folder update: {updated_count} recordings {action} by user {current_user.id}") + + return jsonify({'success': True, 'updated_count': updated_count}) + + +def _apply_folder_auto_shares(recording, folder): + """ + Apply auto-shares for a group folder when a recording is assigned to it. + + Args: + recording: Recording being assigned to the folder + folder: Folder with auto-share settings + """ + if not ENABLE_INTERNAL_SHARING: + return + + if not folder.group_id: + return + + # Determine who to share with + if folder.auto_share_on_apply: + group_members = GroupMembership.query.filter_by(group_id=folder.group_id).all() + elif folder.share_with_group_lead: + group_members = GroupMembership.query.filter_by(group_id=folder.group_id, role='admin').all() + else: + return + + shares_created = 0 + + for membership in group_members: + # Skip the recording owner + if membership.user_id == recording.user_id: + continue + + # Check if already shared + existing_share = InternalShare.query.filter_by( + recording_id=recording.id, + shared_with_user_id=membership.user_id + ).first() + + if not existing_share: + # Create internal share with correct permissions + share = InternalShare( + recording_id=recording.id, + owner_id=recording.user_id, + shared_with_user_id=membership.user_id, + can_edit=(membership.role == 'admin'), + can_reshare=False, + source_type='group_folder', + source_tag_id=None # We don't use this field for folders + ) + db.session.add(share) + + # Create SharedRecordingState with default values for the recipient + state = SharedRecordingState( + recording_id=recording.id, + user_id=membership.user_id, + is_inbox=True, + is_highlighted=False + ) + db.session.add(state) + + shares_created += 1 + current_app.logger.info(f"Auto-shared recording {recording.id} with user {membership.user_id} via group folder '{folder.name}'") + + if shares_created > 0: + current_app.logger.info(f"Created {shares_created} auto-shares for recording {recording.id} via folder assignment") diff --git a/src/api/groups.py b/src/api/groups.py new file mode 100644 index 0000000..2eed567 --- /dev/null +++ b/src/api/groups.py @@ -0,0 +1,394 @@ +""" +Group management and collaboration. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import re +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * + +# Create blueprint +groups_bp = Blueprint('groups', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_groups_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@groups_bp.route('/api/groups//sync-shares', methods=['POST']) +@login_required +def sync_team_tag_shares(group_id): + """Retroactively share recordings with group members based on group tags with auto-sharing enabled.""" + # Verify group exists + group = db.session.get(Group, group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + + # Verify user is a group admin + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id, + role='admin' + ).first() + + if not membership: + return jsonify({'error': 'Only group admins can sync shares'}), 403 + + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Internal sharing is not enabled'}), 403 + + # Get all group tags with auto-sharing enabled + group_tags = Tag.query.filter( + Tag.group_id == group_id, + db.or_( + Tag.auto_share_on_apply == True, + Tag.share_with_group_lead == True + ) + ).all() + + shares_created = 0 + recordings_processed = 0 + + for tag in group_tags: + # Get all completed recordings with this tag + recordings = db.session.query(Recording).join(RecordingTag).filter( + RecordingTag.tag_id == tag.id, + Recording.status == 'COMPLETED' + ).all() + + for recording in recordings: + recordings_processed += 1 + + # Determine who to share with + if tag.auto_share_on_apply: + group_members = GroupMembership.query.filter_by(group_id=group_id).all() + elif tag.share_with_group_lead: + group_members = GroupMembership.query.filter_by(group_id=group_id, role='admin').all() + else: + continue + + for membership_to_share in group_members: + # Skip the recording owner + if membership_to_share.user_id == recording.user_id: + continue + + # Check if already shared + existing_share = InternalShare.query.filter_by( + recording_id=recording.id, + shared_with_user_id=membership_to_share.user_id + ).first() + + if not existing_share: + # Create internal share with correct permissions + # Group admins get edit permission, regular members get read-only + share = InternalShare( + recording_id=recording.id, + owner_id=recording.user_id, + shared_with_user_id=membership_to_share.user_id, + can_edit=(membership_to_share.role == 'admin'), + can_reshare=False, + source_type='group_tag', + source_tag_id=tag.id + ) + db.session.add(share) + + # Create SharedRecordingState with default values for the recipient + state = SharedRecordingState( + recording_id=recording.id, + user_id=membership_to_share.user_id, + is_inbox=True, # New shares appear in inbox by default + is_highlighted=False # Not favorited by default + ) + db.session.add(state) + + shares_created += 1 + current_app.logger.info(f"Synced share: Recording {recording.id} with user {membership_to_share.user_id} (role={membership_to_share.role}) via group tag '{tag.name}'") + + db.session.commit() + + return jsonify({ + 'success': True, + 'shares_created': shares_created, + 'recordings_processed': recordings_processed, + 'message': f'Created {shares_created} new shares across {recordings_processed} recordings' + }) + + + +@groups_bp.route('/api/admin/groups', methods=['GET']) +@login_required +def get_teams(): + """Get all groups (admin) or groups user is admin of (group admin).""" + # Check if user is admin OR group admin + is_group_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_group_admin: + return jsonify({'error': 'Admin access required'}), 403 + + # If full admin, return all groups; if group admin, return only their groups + if current_user.is_admin: + groups = Group.query.all() + else: + # Get groups where user is an admin + group_memberships = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).all() + groups = [m.group for m in group_memberships] + + return jsonify({'groups': [group.to_dict() for group in groups]}) + + + +@groups_bp.route('/api/admin/groups', methods=['POST']) +@login_required +def create_team(): + """Create a new group (admin only).""" + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Groups require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403 + + data = request.get_json() + name = data.get('name', '').strip() + description = data.get('description', '').strip() + + if not name: + return jsonify({'error': 'Group name is required'}), 400 + + # Check if group name already exists + existing = Group.query.filter_by(name=name).first() + if existing: + return jsonify({'error': 'A group with this name already exists'}), 400 + + group = Group(name=name, description=description) + db.session.add(group) + db.session.commit() + + current_app.logger.info(f"Admin {current_user.username} created group: {name}") + return jsonify(group.to_dict()), 201 + + + +@groups_bp.route('/api/admin/groups/', methods=['GET']) +@login_required +def get_team(group_id): + """Get group details (admin or group admin).""" + group = db.session.get(Group, group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + + # Check if user is admin OR admin of this specific group + is_group_admin = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_group_admin: + return jsonify({'error': 'Admin access required'}), 403 + + group_dict = group.to_dict() + group_dict['members'] = [m.to_dict() for m in group.memberships] + return jsonify(group_dict) + + + +@groups_bp.route('/api/admin/groups/', methods=['PUT']) +@login_required +def update_team(group_id): + """Update group (admin or group admin).""" + group = db.session.get(Group, group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + + # Check if user is admin OR admin of this specific group + is_group_admin = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_group_admin: + return jsonify({'error': 'Admin access required'}), 403 + + data = request.get_json() + name = data.get('name', '').strip() + description = data.get('description', '').strip() + + if name: + # Check if new name conflicts with another group + existing = Group.query.filter(Group.name == name, Group.id != group_id).first() + if existing: + return jsonify({'error': 'A group with this name already exists'}), 400 + group.name = name + + group.description = description + db.session.commit() + + current_app.logger.info(f"Admin {current_user.username} updated group: {group.name}") + return jsonify(group.to_dict()) + + + +@groups_bp.route('/api/admin/groups/', methods=['DELETE']) +@login_required +def delete_team(group_id): + """Delete group (admin only).""" + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + group = db.session.get(Group, group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + + group_name = group.name + db.session.delete(group) + db.session.commit() + + current_app.logger.info(f"Admin {current_user.username} deleted group: {group_name}") + return jsonify({'success': True}) + + + +@groups_bp.route('/api/admin/groups//members', methods=['POST']) +@login_required +def add_team_member(group_id): + """Add a member to a group (admin or group admin).""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Groups require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403 + + group = db.session.get(Group, group_id) + if not group: + return jsonify({'error': 'Group not found'}), 404 + + # Check if user is admin OR admin of this specific group + is_group_admin = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_group_admin: + return jsonify({'error': 'Admin access required'}), 403 + + data = request.get_json() + user_id = data.get('user_id') + role = data.get('role', 'member') + + if not user_id: + return jsonify({'error': 'User ID is required'}), 400 + + if role not in ['admin', 'member']: + return jsonify({'error': 'Role must be "admin" or "member"'}), 400 + + user = db.session.get(User, user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Check if already a member + existing = GroupMembership.query.filter_by(group_id=group_id, user_id=user_id).first() + if existing: + return jsonify({'error': 'User is already a member of this group'}), 400 + + membership = GroupMembership(group_id=group_id, user_id=user_id, role=role) + db.session.add(membership) + db.session.commit() + + current_app.logger.info(f"Admin {current_user.username} added {user.username} to group {group.name} as {role}") + return jsonify(membership.to_dict()), 201 + + + +@groups_bp.route('/api/admin/groups//members/', methods=['PUT']) +@login_required +def update_team_member(group_id, user_id): + """Update group member role (admin or group admin).""" + membership = GroupMembership.query.filter_by(group_id=group_id, user_id=user_id).first() + if not membership: + return jsonify({'error': 'Membership not found'}), 404 + + # Check if user is admin OR admin of this specific group + is_group_admin = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_group_admin: + return jsonify({'error': 'Admin access required'}), 403 + + data = request.get_json() + role = data.get('role') + + if role not in ['admin', 'member']: + return jsonify({'error': 'Role must be "admin" or "member"'}), 400 + + membership.role = role + db.session.commit() + + current_app.logger.info(f"Admin {current_user.username} updated {membership.user.username} role to {role} in group {membership.group.name}") + return jsonify(membership.to_dict()) + + + +@groups_bp.route('/api/admin/groups//members/', methods=['DELETE']) +@login_required +def remove_team_member(group_id, user_id): + """Remove a member from a group (admin or group admin).""" + membership = GroupMembership.query.filter_by(group_id=group_id, user_id=user_id).first() + if not membership: + return jsonify({'error': 'Membership not found'}), 404 + + # Check if user is admin OR admin of this specific group + is_group_admin = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id, + role='admin' + ).first() is not None + + if not current_user.is_admin and not is_group_admin: + return jsonify({'error': 'Admin access required'}), 403 + + username = membership.user.username + group_name = membership.group.name + db.session.delete(membership) + db.session.commit() + + current_app.logger.info(f"Admin {current_user.username} removed {username} from group {group_name}") + return jsonify({'success': True}) + + + diff --git a/src/api/inquire.py b/src/api/inquire.py new file mode 100644 index 0000000..6b00087 --- /dev/null +++ b/src/api/inquire.py @@ -0,0 +1,859 @@ +""" +Semantic search and chat functionality. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import re +import time +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * +from src.services.embeddings import get_accessible_recording_ids, semantic_search_chunks, EMBEDDINGS_AVAILABLE +from src.services.llm import call_llm_completion, call_chat_completion, process_streaming_with_thinking, client, chat_client, TokenBudgetExceeded + +# Create blueprint +inquire_bp = Blueprint('inquire', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_inquire_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@inquire_bp.route('/inquire') +@login_required +def inquire(): + # Check if inquire mode is enabled + if not ENABLE_INQUIRE_MODE: + flash('Inquire mode is not enabled on this server.', 'warning') + return redirect(url_for('recordings.index')) + + # Check if user is a group admin + is_team_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + # Render the inquire page with user context for theming + return render_template('inquire.html', + use_asr_endpoint=USE_ASR_ENDPOINT, + current_user=current_user, + is_team_admin=is_team_admin) + + + +@inquire_bp.route('/api/inquire/sessions', methods=['GET']) +@login_required +def get_inquire_sessions(): + """Get all inquire sessions for the current user.""" + if not ENABLE_INQUIRE_MODE: + return jsonify({'error': 'Inquire mode is not enabled'}), 403 + try: + sessions = InquireSession.query.filter_by(user_id=current_user.id).order_by(InquireSession.last_used.desc()).all() + return jsonify([session.to_dict() for session in sessions]) + except Exception as e: + current_app.logger.error(f"Error getting inquire sessions: {e}") + return jsonify({'error': str(e)}), 500 + + + +@inquire_bp.route('/api/inquire/sessions', methods=['POST']) +@login_required +def create_inquire_session(): + """Create a new inquire session with filters.""" + if not ENABLE_INQUIRE_MODE: + return jsonify({'error': 'Inquire mode is not enabled'}), 403 + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + session = InquireSession( + user_id=current_user.id, + session_name=data.get('session_name'), + filter_tags=json.dumps(data.get('filter_tags', [])), + filter_speakers=json.dumps(data.get('filter_speakers', [])), + filter_date_from=datetime.fromisoformat(data['filter_date_from']).date() if data.get('filter_date_from') else None, + filter_date_to=datetime.fromisoformat(data['filter_date_to']).date() if data.get('filter_date_to') else None, + filter_recording_ids=json.dumps(data.get('filter_recording_ids', [])) + ) + + db.session.add(session) + db.session.commit() + + return jsonify(session.to_dict()), 201 + + except Exception as e: + current_app.logger.error(f"Error creating inquire session: {e}") + return jsonify({'error': str(e)}), 500 + + + +@inquire_bp.route('/api/inquire/search', methods=['POST']) +@login_required +def inquire_search(): + """Perform semantic search within filtered transcriptions.""" + if not ENABLE_INQUIRE_MODE: + return jsonify({'error': 'Inquire mode is not enabled'}), 403 + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + query = data.get('query') + if not query: + return jsonify({'error': 'No query provided'}), 400 + + # Build filters from request + filters = {} + if data.get('filter_tags'): + filters['tag_ids'] = data['filter_tags'] + if data.get('filter_speakers'): + filters['speaker_names'] = data['filter_speakers'] + if data.get('filter_recording_ids'): + filters['recording_ids'] = data['filter_recording_ids'] + if data.get('filter_date_from'): + filters['date_from'] = datetime.fromisoformat(data['filter_date_from']).date() + if data.get('filter_date_to'): + filters['date_to'] = datetime.fromisoformat(data['filter_date_to']).date() + + # Perform semantic search + top_k = data.get('top_k', 5) + chunk_results = semantic_search_chunks(current_user.id, query, filters, top_k) + + # Format results + results = [] + for chunk, similarity in chunk_results: + result = chunk.to_dict() + result['similarity'] = similarity + result['recording_title'] = chunk.recording.title + result['recording_meeting_date'] = local_datetime_filter(chunk.recording.meeting_date) + results.append(result) + + return jsonify({'results': results}) + + except Exception as e: + current_app.logger.error(f"Error in inquire search: {e}") + return jsonify({'error': str(e)}), 500 + + + +@inquire_bp.route('/api/inquire/chat', methods=['POST']) +@login_required +def inquire_chat(): + """Chat with filtered transcriptions using RAG.""" + if not ENABLE_INQUIRE_MODE: + return jsonify({'error': 'Inquire mode is not enabled'}), 403 + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + user_message = data.get('message') + message_history = data.get('message_history', []) + + if not user_message: + return jsonify({'error': 'No message provided'}), 400 + + # Check if OpenRouter client is available + if client is None: + return jsonify({'error': 'Chat service is not available (OpenRouter client not configured)'}), 503 + + # Build filters from request + filters = {} + if data.get('filter_tags'): + filters['tag_ids'] = data['filter_tags'] + if data.get('filter_speakers'): + filters['speaker_names'] = data['filter_speakers'] + if data.get('filter_recording_ids'): + filters['recording_ids'] = data['filter_recording_ids'] + if data.get('filter_date_from'): + filters['date_from'] = datetime.fromisoformat(data['filter_date_from']).date() + if data.get('filter_date_to'): + filters['date_to'] = datetime.fromisoformat(data['filter_date_to']).date() + + # Debug logging + current_app.logger.info(f"Inquire chat - User: {current_user.username}, Query: '{user_message}', Filters: {filters}") + + # Capture user context and app before generator to avoid context issues + user_id = current_user.id + user_name = current_user.name if current_user.name else "the user" + user_title = current_user.job_title if current_user.job_title else "professional" + user_company = current_user.company if current_user.company else "their organization" + user_output_language = current_user.output_language if current_user.output_language else None + app = current_app._get_current_object() # Capture app for use in generator + + # Enhanced query processing with enrichment and debugging + def create_status_response(status, message): + """Helper to create SSE status updates""" + return f"data: {json.dumps({'status': status, 'message': message})}\n\n" + + def generate_enhanced_chat(): + # Explicitly reference outer scope variables + nonlocal user_id, user_name, user_title, user_company, user_output_language, data, filters + + # Push app context for entire generator execution + # This is needed because call_llm_completion uses current_app.logger internally + ctx = app.app_context() + ctx.push() + + try: + # Send initial status + yield create_status_response('processing', 'Analyzing your query...') + + # Step 1: Router - Determine if RAG lookup is needed + router_prompt = f"""Analyse cette requête pour déterminer si elle nécessite une recherche dans les transcriptions ou si c'est une demande de reformatage/clarification. + +Requête : "{user_message}" + +Réponds UNIQUEMENT avec "RAG" si la requête demande du contenu des transcriptions (informations, conversations, faits précis). +Réponds UNIQUEMENT avec "DIRECT" si c'est une demande de mise en forme, reformulation de la réponse précédente, ou sans besoin de recherche. + +Exemples : +- "Qu'est-ce que Marie a dit sur le budget ?" → RAG +- "Peux-tu reformater en titres séparés ?" → DIRECT +- "Qui a mentionné l'échéancier ?" → RAG +- "Rends ça plus structuré" → DIRECT""" + + try: + router_response = call_llm_completion( + messages=[ + {"role": "system", "content": "You are a query router. Respond with only 'RAG' or 'DIRECT'."}, + {"role": "user", "content": router_prompt} + ], + temperature=0.1, + max_tokens=10, + user_id=user_id, + operation_type='query_routing' + ) + + raw_decision = router_response.choices[0].message.content + if not raw_decision or not raw_decision.strip(): + app.logger.warning("Router returned empty response, defaulting to RAG") + raise ValueError("Empty router response") + + route_decision = raw_decision.strip().upper() + app.logger.info(f"Router decision: {route_decision}") + + if route_decision == "DIRECT": + # Direct response without RAG lookup + yield create_status_response('responding', 'Generating direct response...') + + direct_prompt = f"""Tu assistes {user_name}. Réponds directement à sa demande de façon professionnelle et concise. Utilise le formatage Markdown (## titres, **gras**, listes -). + +Demande : "{user_message}" + +Contexte de conversation (si pertinent) : +{json.dumps(message_history[-2:] if message_history else [])}""" + + stream = call_llm_completion( + messages=[ + {"role": "system", "content": direct_prompt}, + {"role": "user", "content": user_message} + ], + temperature=0.7, + max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")), + stream=True, + user_id=user_id, + operation_type='chat' + ) + + # Use helper function to process streaming with thinking tag support + for response in process_streaming_with_thinking(stream, user_id=user_id, operation_type='chat', model_name=os.environ.get('LLM_MODEL')): + yield response + return + + except Exception as e: + app.logger.warning(f"Router failed, defaulting to RAG: {e}") + + # Step 2: Query enrichment - generate better search terms based on user intent + yield create_status_response('enriching', 'Enriching search query...') + + # Use captured user context for personalized search terms + + if EMBEDDINGS_AVAILABLE: + enrichment_prompt = f"""Tu es un assistant spécialisé en recherche sémantique. Génère 3-5 termes ou phrases de recherche alternatifs pour retrouver du contenu pertinent dans des transcriptions. + +Contexte utilisateur : +- Nom : {user_name} +- Titre : {user_title} +- Organisation : {user_company} + +Question : "{user_message}" +Intervenants disponibles : {', '.join(data.get('filter_speakers', []))}. + +Génère des termes de recherche qui retrouveront le contenu pertinent. Priorités : +1. Concepts clés et sujets — utilise le nom réel de l'utilisateur au lieu de "moi" ou "je" +2. Terminologie spécifique au contexte professionnel +3. Reformulations avec les noms propres +4. Termes connexes susceptibles d'apparaître dans les transcriptions + +Exemples : +- Au lieu de "ce que Marie m'a dit" → "ce que Marie a dit à {user_name}" +- Au lieu de "ma dernière réunion" → "réunion de {user_name}" + +Réponds UNIQUEMENT avec un tableau JSON de chaînes : ["terme1", "terme2", "terme3", ...]""" + else: + enrichment_prompt = f"""Tu es un assistant spécialisé en extraction de mots-clés pour la recherche textuelle (SQL LIKE). Extrais 3-5 termes essentiels de la question. + +Contexte utilisateur : +- Nom : {user_name} + +Question : "{user_message}" + +Règles : +- Retourne UNIQUEMENT les termes qui apparaîtraient réellement dans une transcription +- Chaque terme : 1-3 mots maximum +- Remplace les pronoms "moi", "mon", "je" par le nom de l'utilisateur "{user_name}" +- Priorité aux noms propres, termes spécifiques, phrases distinctives +- N'inclus PAS de mots génériques comme "réunion", "discussion", "plan" sauf s'ils sont le sujet + +Exemples : +- "qu'est-ce qui se passe avec le Régime de retraite" → ["Régime de retraite", "retraite"] +- "quand Marie a-t-elle mentionné l'échéance" → ["Marie", "échéance", "délai"] + +Réponds UNIQUEMENT avec un tableau JSON : ["terme1", "terme2", ...]""" + + try: + enrichment_response = call_llm_completion( + messages=[ + {"role": "system", "content": "You are a query enhancement assistant. Respond only with valid JSON arrays of search terms."}, + {"role": "user", "content": enrichment_prompt} + ], + temperature=0.3, + max_tokens=200, + user_id=user_id, + operation_type='query_enrichment' + ) + + raw_content = enrichment_response.choices[0].message.content + if not raw_content or not raw_content.strip(): + app.logger.warning(f"Query enrichment returned empty response") + raise ValueError("Empty response from LLM") + + # Try to extract JSON array if wrapped in other text + content = raw_content.strip() + if content.startswith('['): + enriched_terms = json.loads(content) + else: + # Try to find JSON array in the response + match = re.search(r'\[.*?\]', content, re.DOTALL) + if match: + enriched_terms = json.loads(match.group()) + else: + app.logger.warning(f"Query enrichment response not JSON: {content[:200]}") + raise ValueError("No JSON array found in response") + + app.logger.info(f"Enriched search terms: {enriched_terms}") + + # Combine original query with enriched terms for search + search_queries = [user_message] + enriched_terms[:3] # Use original + top 3 enriched terms + + except Exception as e: + app.logger.warning(f"Query enrichment failed, using original query: {e}") + search_queries = [user_message] + + # Step 2: Semantic search with multiple queries + yield create_status_response('searching', 'Searching transcriptions...') + + all_chunks = [] + seen_chunk_ids = set() + + for query in search_queries: + with app.app_context(): + chunk_results = semantic_search_chunks(user_id, query, filters, 8) + app.logger.info(f"Search query '{query}' returned {len(chunk_results)} chunks") + + for chunk, similarity in chunk_results: + if chunk and chunk.id not in seen_chunk_ids: + all_chunks.append((chunk, similarity)) + seen_chunk_ids.add(chunk.id) + + # Sort by similarity and take top results + all_chunks.sort(key=lambda x: x[1], reverse=True) + chunk_results = all_chunks[:data.get('context_chunks', 8)] + + app.logger.info(f"Final chunk results: {len(chunk_results)} chunks with similarities: {[f'{s:.3f}' for _, s in chunk_results]}") + + # Step 2.5: Auto-detect mentioned speakers and apply filters if needed + with app.app_context(): + # Get available speakers + recordings_with_participants = Recording.query.filter_by(user_id=user_id).filter( + Recording.participants.isnot(None), + Recording.participants != '' + ).all() + + available_speakers = set() + for recording in recordings_with_participants: + if recording.participants: + participants = [p.strip() for p in recording.participants.split(',') if p.strip()] + available_speakers.update(participants) + + # Check if any speakers are mentioned in the user query but missing from results + mentioned_speakers = [] + for speaker in available_speakers: + if speaker.lower() in user_message.lower(): + # Check if this speaker appears in current chunk results + speaker_in_results = False + for chunk, _ in chunk_results: + if chunk and ( + (chunk.speaker_name and speaker.lower() in chunk.speaker_name.lower()) or + (chunk.recording and chunk.recording.participants and speaker.lower() in chunk.recording.participants.lower()) + ): + speaker_in_results = True + break + + if not speaker_in_results: + mentioned_speakers.append(speaker) + + # If we found mentioned speakers not in results, automatically apply speaker filter + if mentioned_speakers and not data.get('filter_speakers'): # Only if no speaker filter already applied + app.logger.info(f"Auto-detected mentioned speakers not in results: {mentioned_speakers}") + yield create_status_response('filtering', f'Detected mention of {", ".join(mentioned_speakers)}, applying speaker filter...') + + # Apply automatic speaker filter + auto_filters = filters.copy() + auto_filters['speaker_names'] = mentioned_speakers + + # Re-run semantic search with speaker filter + auto_filtered_chunks = [] + auto_filtered_seen_ids = set() + + for query in search_queries: + with app.app_context(): + auto_filtered_results = semantic_search_chunks(user_id, query, auto_filters, data.get('context_chunks', 8)) + app.logger.info(f"Auto-filtered search for '{query}' with speakers {mentioned_speakers} returned {len(auto_filtered_results)} chunks") + + for chunk, similarity in auto_filtered_results: + if chunk and chunk.id not in auto_filtered_seen_ids: + auto_filtered_chunks.append((chunk, similarity)) + auto_filtered_seen_ids.add(chunk.id) + + # If auto-filter found better results, use them + if len(auto_filtered_chunks) > 0: + auto_filtered_chunks.sort(key=lambda x: x[1], reverse=True) + chunk_results = auto_filtered_chunks[:data.get('context_chunks', 8)] + app.logger.info(f"Auto speaker filter found {len(chunk_results)} relevant chunks, using filtered results") + filters = auto_filters # Update filters for context building + + # Step 3: Evaluate results and re-query if needed + if len(chunk_results) < 2: # If we got very few results, try a broader search + yield create_status_response('requerying', 'Expanding search scope...') + + # Try without speaker filter if it was applied + broader_filters = filters.copy() + if 'speaker_names' in broader_filters: + del broader_filters['speaker_names'] + app.logger.info("Retrying search without speaker filter...") + + for query in search_queries: + with app.app_context(): + chunk_results_broader = semantic_search_chunks(user_id, query, broader_filters, 6) + for chunk, similarity in chunk_results_broader: + if chunk and chunk.id not in seen_chunk_ids: + all_chunks.append((chunk, similarity)) + seen_chunk_ids.add(chunk.id) + + # Re-sort and limit + all_chunks.sort(key=lambda x: x[1], reverse=True) + chunk_results = all_chunks[:data.get('context_chunks', 8)] + app.logger.info(f"Broader search returned {len(chunk_results)} total chunks") + + # Build context from retrieved chunks + yield create_status_response('contextualizing', 'Building context...') + + # Group chunks by recording and organize properly + recording_chunks = {} + recording_ids_in_context = set() + + for chunk, similarity in chunk_results: + if not chunk or not chunk.recording: + continue + recording_id = chunk.recording.id + recording_ids_in_context.add(recording_id) + + if recording_id not in recording_chunks: + recording_chunks[recording_id] = { + 'recording': chunk.recording, + 'chunks': [] + } + + recording_chunks[recording_id]['chunks'].append({ + 'chunk': chunk, + 'similarity': similarity + }) + + # Build organized context pieces + context_pieces = [] + + for recording_id, data in recording_chunks.items(): + recording = data['recording'] + chunks = data['chunks'] + + # Sort chunks by their index to maintain chronological order + chunks.sort(key=lambda x: x['chunk'].chunk_index) + + # Build recording header with complete metadata + header = f"=== {recording.title} [Recording ID: {recording_id}] ===" + if recording.meeting_date: + header += f" ({recording.meeting_date})" + + # Add participants information + if recording.participants: + participants_list = [p.strip() for p in recording.participants.split(',') if p.strip()] + header += f"\\nParticipants: {', '.join(participants_list)}" + + context_piece = header + "\\n\\n" + + # Process chunks and detect non-continuity + prev_chunk_index = None + for chunk_data in chunks: + chunk = chunk_data['chunk'] + similarity = chunk_data['similarity'] + + # Check for non-continuity + if prev_chunk_index is not None and chunk.chunk_index != prev_chunk_index + 1: + context_piece += "\\n[... gap in transcript - non-consecutive chunks ...]\\n\\n" + + # Add speaker information if available + speaker_info = "" + if chunk.speaker_name: + speaker_info = f"{chunk.speaker_name}: " + elif chunk.start_time is not None: + speaker_info = f"[{chunk.start_time:.1f}s]: " + + # Add timing info if available + timing_info = "" + if chunk.start_time is not None and chunk.end_time is not None: + timing_info = f" [{chunk.start_time:.1f}s-{chunk.end_time:.1f}s]" + + context_piece += f"{speaker_info}{chunk.content}{timing_info} (similarity: {similarity:.3f})\\n\\n" + prev_chunk_index = chunk.chunk_index + + context_pieces.append(context_piece) + + app.logger.info(f"Built context from {len(chunk_results)} chunks across {len(recording_chunks)} recordings") + + # Generate response + yield create_status_response('responding', 'Generating response...') + + # Prepare system prompt + language_instruction = f"Réponds en {user_output_language}." if user_output_language else "Réponds toujours en français." + + # Build filter description for context + filter_description = [] + with app.app_context(): + if data.get('filter_tags'): + tag_names = [tag.name for tag in Tag.query.filter(Tag.id.in_(data['filter_tags'])).all()] + filter_description.append(f"tags: {', '.join(tag_names)}") + if data.get('filter_speakers'): + filter_description.append(f"speakers: {', '.join(data['filter_speakers'])}") + if data.get('filter_date_from') or data.get('filter_date_to'): + date_range = [] + if data.get('filter_date_from'): + date_range.append(f"from {data['filter_date_from']}") + if data.get('filter_date_to'): + date_range.append(f"to {data['filter_date_to']}") + filter_description.append(f"dates: {' '.join(date_range)}") + + filter_text = f" (filtered by {'; '.join(filter_description)})" if filter_description else "" + + context_text = "\n\n".join(context_pieces) if context_pieces else "No relevant context found." + + # Get transcript length limit setting and available speakers + with app.app_context(): + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + + # Get all available speakers for this user + recordings_with_participants = Recording.query.filter_by(user_id=user_id).filter( + Recording.participants.isnot(None), + Recording.participants != '' + ).all() + + available_speakers = set() + for recording in recordings_with_participants: + if recording.participants: + participants = [p.strip() for p in recording.participants.split(',') if p.strip()] + available_speakers.update(participants) + + available_speakers = sorted(list(available_speakers)) + + user_context = f", {user_title} chez {user_company}" if user_title and user_company else "" + system_prompt = f"""Tu es un assistant expert en analyse de transcriptions audio et de réunions, qui assiste {user_name}{user_context}. {language_instruction} + +Tu analyses des transcriptions de plusieurs enregistrements{filter_text}. Le contexte suivant a été récupéré par recherche sémantique : + +<> +{context_text} +<> + +Recherche : {len(chunk_results)} extrait(s) de {len(recording_ids_in_context)} enregistrement(s). + +**Intervenants disponibles** : {', '.join(available_speakers) if available_speakers else 'Non précisé'} + +**IDs des enregistrements** : {list(recording_ids_in_context)} + +CONSIGNES DE FORMATAGE : +- Utilise le Markdown (## titres, **gras**, listes -) +- Commence par une synthèse concise si pertinent +- Organise par source avec le format : `## [Titre de l'enregistrement] - [Date]` +- Indique les intervenants en **gras** pour les citations directes +- Présente les enregistrements du plus récent au plus ancien + +**Exemple de structure :** + +## Réunion de planification — 2024-06-18 +- **Marie** a mentionné que "la mise en œuvre nécessite un soutien important" +- **Jean** a confirmé la prochaine rencontre avec l'équipe technique +- Points abordés : + - Planification budgétaire + - Coordination des échéanciers""" + + # Prepare messages array + messages = [{"role": "system", "content": system_prompt}] + if message_history: + messages.extend(message_history) + messages.append({"role": "user", "content": user_message}) + + # Enable streaming + stream = call_chat_completion( + messages=messages, + temperature=0.7, + max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")), + stream=True, + user_id=user_id, + operation_type='chat' + ) + + # Buffer content to detect full transcript requests + response_buffer = "" + + # Buffer content to detect full transcript requests + response_buffer = "" + content_buffer = "" + in_thinking = False + thinking_buffer = "" + + for chunk in stream: + content = chunk.choices[0].delta.content + if content: + response_buffer += content + content_buffer += content + + # Check if this is a full transcript request + if response_buffer.strip().startswith("REQUEST_FULL_TRANSCRIPT:"): + lines = response_buffer.split('\n') + request_line = lines[0].strip() + + if ':' in request_line: + try: + recording_id = int(request_line.split(':')[1]) + app.logger.info(f"Agent requested full transcript for recording {recording_id}") + + # Fetch full transcript + yield create_status_response('fetching', f'Retrieving full transcript for recording {recording_id}...') + + with app.app_context(): + recording = db.session.get(Recording, recording_id) + if recording and recording.user_id == user_id and recording.transcription: + # Apply transcript length limit + if transcript_limit == -1: + full_transcript = recording.transcription + else: + full_transcript = recording.transcription[:transcript_limit] + + # Add full transcript to context + full_context = f"{context_text}\n\n<>\n{full_transcript}\n<>" + + # Update system prompt with full transcript + updated_system_prompt = system_prompt.replace( + f"<>\n{context_text}\n<>", + f"<>\n{full_context}\n<>" + ) + + # Create new messages with updated context + updated_messages = [{"role": "system", "content": updated_system_prompt}] + if message_history: + updated_messages.extend(message_history) + updated_messages.append({"role": "user", "content": user_message}) + + # Generate new response with full context + yield create_status_response('responding', 'Analyzing full transcript...') + + new_stream = call_chat_completion( + messages=updated_messages, + temperature=0.7, + max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")), + stream=True, + user_id=user_id, + operation_type='chat' + ) + + # Use helper function to process streaming with thinking tag support + for response in process_streaming_with_thinking(new_stream, user_id=user_id, operation_type='chat', model_name=os.environ.get('CHAT_MODEL')): + yield response + return + else: + # Recording not found or no permission + error_msg = f"\n\nError: Unable to access full transcript for recording {recording_id}. Recording may not exist or you may not have permission." + yield f"data: {json.dumps({'delta': error_msg})}\n\n" + yield f"data: {json.dumps({'end_of_stream': True})}\n\n" + return + + except (ValueError, IndexError): + app.logger.warning(f"Invalid transcript request format: {request_line}") + # Continue with normal streaming + pass + + # Process the buffer to detect and handle thinking tags + while True: + if not in_thinking: + # Look for opening thinking tag + think_start = re.search(r'', content_buffer, re.IGNORECASE) + if think_start: + # Send any content before the thinking tag + before_thinking = content_buffer[:think_start.start()] + if before_thinking: + yield f"data: {json.dumps({'delta': before_thinking})}\n\n" + + # Start capturing thinking content + in_thinking = True + content_buffer = content_buffer[think_start.end():] + thinking_buffer = "" + else: + # No thinking tag found, send accumulated content + if content_buffer: + yield f"data: {json.dumps({'delta': content_buffer})}\n\n" + content_buffer = "" + break + else: + # We're inside a thinking tag, look for closing tag + think_end = re.search(r'', content_buffer, re.IGNORECASE) + if think_end: + # Capture thinking content up to the closing tag + thinking_buffer += content_buffer[:think_end.start()] + + # Send the thinking content as a special type + if thinking_buffer.strip(): + yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n" + + # Continue processing after the closing tag + in_thinking = False + content_buffer = content_buffer[think_end.end():] + thinking_buffer = "" + else: + # Still inside thinking tag, accumulate content + thinking_buffer += content_buffer + content_buffer = "" + break + + # Handle any remaining content + if in_thinking and thinking_buffer: + # Unclosed thinking tag - send as thinking content + yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n" + elif content_buffer: + # Regular content + yield f"data: {json.dumps({'delta': content_buffer})}\n\n" + + yield f"data: {json.dumps({'end_of_stream': True})}\n\n" + + except TokenBudgetExceeded as e: + app.logger.warning(f"Token budget exceeded for user {user_id}: {e}") + yield f"data: {json.dumps({'error': str(e), 'budget_exceeded': True})}\n\n" + except Exception as e: + app.logger.error(f"Error in enhanced chat generation: {e}") + yield f"data: {json.dumps({'error': str(e)})}\n\n" + finally: + ctx.pop() + + return Response(generate_enhanced_chat(), mimetype='text/event-stream') + + except Exception as e: + current_app.logger.error(f"Error in inquire chat endpoint: {str(e)}") + return jsonify({'error': str(e)}), 500 + + + +@inquire_bp.route('/api/inquire/available_filters', methods=['GET']) +@login_required +def get_available_filters(): + """Get available filter options for the user (includes shared recordings).""" + if not ENABLE_INQUIRE_MODE: + return jsonify({'error': 'Inquire mode is not enabled'}), 403 + try: + # Get user's personal tags + user_tags = Tag.query.filter_by(user_id=current_user.id, group_id=None).all() + + # Get group tags from user's teams + group_tags = [] + memberships = GroupMembership.query.filter_by(user_id=current_user.id).all() + group_ids = [m.group_id for m in memberships] + if group_ids: + group_tags = Tag.query.filter(Tag.group_id.in_(group_ids)).all() + + # Combine all tags + all_tags = user_tags + group_tags + + # Get all accessible recording IDs (own + shared) + accessible_recording_ids = get_accessible_recording_ids(current_user.id) + + # Get unique speakers from accessible recordings' participants field + recordings_with_participants = Recording.query.filter( + Recording.id.in_(accessible_recording_ids), + Recording.participants.isnot(None), + Recording.participants != '' + ).all() + + speaker_names = set() + for recording in recordings_with_participants: + if recording.participants: + # Split participants by comma and clean up + participants = [p.strip() for p in recording.participants.split(',') if p.strip()] + speaker_names.update(participants) + + speaker_names = sorted(list(speaker_names)) + + # Get accessible recordings for recording-specific filtering + recordings = Recording.query.filter( + Recording.id.in_(accessible_recording_ids), + Recording.status == 'COMPLETED' + ).order_by(Recording.created_at.desc()).all() + + return jsonify({ + 'tags': [tag.to_dict() for tag in all_tags], + 'speakers': speaker_names, + 'recordings': [{'id': r.id, 'title': r.title, 'meeting_date': f"{r.meeting_date.isoformat()}T00:00:00" if r.meeting_date else None} for r in recordings] + }) + + except Exception as e: + current_app.logger.error(f"Error getting available filters: {e}") + return jsonify({'error': str(e)}), 500 + + + diff --git a/src/api/naming_templates.py b/src/api/naming_templates.py new file mode 100644 index 0000000..0724d5b --- /dev/null +++ b/src/api/naming_templates.py @@ -0,0 +1,298 @@ +""" +Naming template management. + +This blueprint handles CRUD operations for naming templates, +which define how recording titles are generated from filenames, +metadata, and AI-generated content. +""" + +import json +from datetime import datetime +from flask import Blueprint, request, jsonify, current_app +from flask_login import login_required, current_user + +from src.database import db +from src.models import NamingTemplate + +# Create blueprint +naming_templates_bp = Blueprint('naming_templates', __name__) + + +# --- Routes --- + +@naming_templates_bp.route('/api/naming-templates', methods=['GET']) +@login_required +def get_naming_templates(): + """Get all naming templates for the current user.""" + templates = NamingTemplate.query.filter_by(user_id=current_user.id).all() + return jsonify([template.to_dict() for template in templates]) + + +@naming_templates_bp.route('/api/naming-templates', methods=['POST']) +@login_required +def create_naming_template(): + """Create a new naming template.""" + data = request.json + if not data or not data.get('name') or not data.get('template'): + return jsonify({'error': 'Name and template are required'}), 400 + + # Validate regex patterns if provided + regex_patterns = data.get('regex_patterns', {}) + if regex_patterns: + if not isinstance(regex_patterns, dict): + return jsonify({'error': 'regex_patterns must be a dictionary'}), 400 + # Validate each regex pattern + import re + for var_name, pattern in regex_patterns.items(): + try: + re.compile(pattern) + except re.error as e: + return jsonify({'error': f'Invalid regex pattern for "{var_name}": {str(e)}'}), 400 + + # If this is set as default, unset other defaults + if data.get('is_default'): + NamingTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).update({'is_default': False}) + + template = NamingTemplate( + user_id=current_user.id, + name=data['name'], + template=data['template'], + description=data.get('description'), + regex_patterns=json.dumps(regex_patterns) if regex_patterns else None, + is_default=data.get('is_default', False) + ) + + db.session.add(template) + db.session.commit() + + return jsonify(template.to_dict()), 201 + + +@naming_templates_bp.route('/api/naming-templates/', methods=['GET']) +@login_required +def get_naming_template(template_id): + """Get a specific naming template.""" + template = NamingTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + return jsonify(template.to_dict()) + + +@naming_templates_bp.route('/api/naming-templates/', methods=['PUT']) +@login_required +def update_naming_template(template_id): + """Update an existing naming template.""" + template = NamingTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Validate regex patterns if provided + if 'regex_patterns' in data: + regex_patterns = data['regex_patterns'] + if regex_patterns: + if not isinstance(regex_patterns, dict): + return jsonify({'error': 'regex_patterns must be a dictionary'}), 400 + import re + for var_name, pattern in regex_patterns.items(): + try: + re.compile(pattern) + except re.error as e: + return jsonify({'error': f'Invalid regex pattern for "{var_name}": {str(e)}'}), 400 + + # If this is set as default, unset other defaults + if data.get('is_default'): + NamingTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).update({'is_default': False}) + + template.name = data.get('name', template.name) + template.template = data.get('template', template.template) + template.description = data.get('description', template.description) + template.is_default = data.get('is_default', template.is_default) + + if 'regex_patterns' in data: + regex_patterns = data['regex_patterns'] + template.regex_patterns = json.dumps(regex_patterns) if regex_patterns else None + + template.updated_at = datetime.utcnow() + + db.session.commit() + + return jsonify(template.to_dict()) + + +@naming_templates_bp.route('/api/naming-templates/', methods=['DELETE']) +@login_required +def delete_naming_template(template_id): + """Delete a naming template.""" + template = NamingTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + # Check if any tags are using this template + from src.models import Tag + tags_using = Tag.query.filter_by(naming_template_id=template_id).count() + if tags_using > 0: + return jsonify({ + 'error': f'Cannot delete template: {tags_using} tag(s) are using this template' + }), 400 + + db.session.delete(template) + db.session.commit() + + return jsonify({'success': True}) + + +@naming_templates_bp.route('/api/naming-templates/create-defaults', methods=['POST']) +@login_required +def create_default_naming_templates(): + """Create default naming templates for the user if they don't have any.""" + existing_templates = NamingTemplate.query.filter_by(user_id=current_user.id).count() + + if existing_templates > 0: + return jsonify({'message': 'User already has naming templates'}), 200 + + templates = [] + + # Default template 1: Titre IA uniquement (default) + template1 = NamingTemplate( + user_id=current_user.id, + name="Titre IA uniquement", + template="{{ai_title}}", + description="Utilise le titre généré par l'IA depuis le contenu de la transcription", + is_default=True + ) + templates.append(template1) + + # Default template 2: Date + Titre IA + template2 = NamingTemplate( + user_id=current_user.id, + name="Date + Titre IA", + template="{{date}} - {{ai_title}}", + description="Date de l'enregistrement suivie du titre IA — format recommandé pour le classement", + is_default=False + ) + templates.append(template2) + + # Default template 3: Date, heure et titre + template3 = NamingTemplate( + user_id=current_user.id, + name="Date, heure et titre", + template="{{datetime}} {{ai_title}}", + description="Inclut la date et l'heure avant le titre IA — utile quand plusieurs enregistrements par jour", + is_default=False + ) + templates.append(template3) + + # Add all templates to database + for template in templates: + db.session.add(template) + + db.session.commit() + + return jsonify({ + 'success': True, + 'templates': [template.to_dict() for template in templates] + }), 201 + + +@naming_templates_bp.route('/api/naming-templates//test', methods=['POST']) +@login_required +def test_naming_template(template_id): + """Test a naming template with sample data.""" + template = NamingTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + data = request.json or {} + sample_filename = data.get('filename', 'sample-recording-2026-01-15.mp3') + sample_date = data.get('date') + sample_ai_title = data.get('ai_title', 'Meeting with Team') + + # Parse sample date + meeting_date = None + if sample_date: + try: + meeting_date = datetime.fromisoformat(sample_date) + except ValueError: + pass + + if not meeting_date: + meeting_date = datetime.now() + + # Apply template + result = template.apply( + original_filename=sample_filename, + meeting_date=meeting_date, + ai_title=sample_ai_title + ) + + return jsonify({ + 'result': result or '(empty - would fall back to AI title or filename)', + 'needs_ai_title': template.needs_ai_title(), + 'input': { + 'filename': sample_filename, + 'date': meeting_date.isoformat() if meeting_date else None, + 'ai_title': sample_ai_title + } + }) + + +@naming_templates_bp.route('/api/naming-templates/default', methods=['GET']) +@login_required +def get_default_naming_template(): + """Get the user's default naming template.""" + return jsonify({ + 'default_naming_template_id': current_user.default_naming_template_id + }) + + +@naming_templates_bp.route('/api/naming-templates/default', methods=['PUT']) +@login_required +def set_default_naming_template(): + """Set the user's default naming template.""" + data = request.json + template_id = data.get('template_id') if data else None + + if template_id: + # Verify template belongs to user + template = NamingTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + current_user.default_naming_template_id = template_id if template_id else None + db.session.commit() + + return jsonify({ + 'success': True, + 'default_naming_template_id': current_user.default_naming_template_id + }) diff --git a/src/api/push_notifications.py b/src/api/push_notifications.py new file mode 100644 index 0000000..f7b18ec --- /dev/null +++ b/src/api/push_notifications.py @@ -0,0 +1,232 @@ +""" +Push Notification API Endpoints +Handles push notification subscriptions and delivery +""" +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user +from src.database import db +from src.models.push_subscription import PushSubscription +import json + + +push_bp = Blueprint('push', __name__) + +# VAPID config is loaded lazily to avoid startup issues +_vapid_config = None + + +def _get_vapid_config(): + """Load VAPID configuration lazily""" + global _vapid_config + if _vapid_config is None: + try: + from src.utils.vapid_keys import VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_ENABLED + _vapid_config = { + 'enabled': VAPID_ENABLED, + 'public_key': VAPID_PUBLIC_KEY, + 'private_key': VAPID_PRIVATE_KEY + } + except Exception as e: + print(f"[Push] Failed to load VAPID config: {e}") + _vapid_config = { + 'enabled': False, + 'public_key': None, + 'private_key': None + } + return _vapid_config + + +@push_bp.route('/api/push/config', methods=['GET']) +def get_push_config(): + """Get push notification configuration for client""" + config = _get_vapid_config() + return jsonify({ + 'enabled': config['enabled'], + 'public_key': config['public_key'] if config['enabled'] else None + }) + + +@push_bp.route('/api/push/subscribe', methods=['POST']) +@login_required +def subscribe(): + """Store push subscription for current user""" + config = _get_vapid_config() + if not config['enabled']: + return jsonify({ + 'success': False, + 'error': 'Push notifications not available' + }), 503 + + try: + subscription_data = request.json + + if not subscription_data or 'endpoint' not in subscription_data: + return jsonify({ + 'success': False, + 'error': 'Invalid subscription data' + }), 400 + + # Check if subscription already exists + existing = PushSubscription.query.filter_by( + user_id=current_user.id, + endpoint=subscription_data['endpoint'] + ).first() + + if existing: + return jsonify({ + 'success': True, + 'message': 'Already subscribed', + 'subscription_id': existing.id + }) + + # Create new subscription + subscription = PushSubscription( + user_id=current_user.id, + endpoint=subscription_data['endpoint'], + p256dh_key=subscription_data.get('keys', {}).get('p256dh', ''), + auth_key=subscription_data.get('keys', {}).get('auth', '') + ) + + db.session.add(subscription) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Subscription saved', + 'subscription_id': subscription.id + }) + + except Exception as e: + db.session.rollback() + print(f"[Push] Subscription error: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@push_bp.route('/api/push/unsubscribe', methods=['POST']) +@login_required +def unsubscribe(): + """Remove push subscription for current user""" + config = _get_vapid_config() + if not config['enabled']: + return jsonify({'success': True, 'message': 'Push notifications not enabled'}) + + try: + subscription_data = request.json + + if not subscription_data or 'endpoint' not in subscription_data: + return jsonify({ + 'success': False, + 'error': 'Invalid subscription data' + }), 400 + + subscription = PushSubscription.query.filter_by( + user_id=current_user.id, + endpoint=subscription_data['endpoint'] + ).first() + + if subscription: + db.session.delete(subscription) + db.session.commit() + return jsonify({ + 'success': True, + 'message': 'Subscription removed' + }) + + return jsonify({ + 'success': False, + 'error': 'Subscription not found' + }), 404 + + except Exception as e: + db.session.rollback() + print(f"[Push] Unsubscribe error: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +def send_push_notification(user_id, title, body, data=None, url=None): + """ + Send push notification to all subscriptions for a user + + Args: + user_id: User ID to send notification to + title: Notification title + body: Notification body text + data: Optional dictionary of extra data + url: Optional URL to open when notification is clicked + """ + config = _get_vapid_config() + if not config['enabled']: + print("[Push] Push notifications not enabled, skipping") + return + + try: + from pywebpush import webpush, WebPushException + + subscriptions = PushSubscription.query.filter_by(user_id=user_id).all() + + if not subscriptions: + print(f"[Push] No subscriptions found for user {user_id}") + return + + notification_data = { + 'title': title, + 'body': body, + 'icon': '/static/img/icon-192x192.png', + 'badge': '/static/img/icon-192x192.png', + 'data': data or {} + } + + if url: + notification_data['data']['url'] = url + + sent_count = 0 + failed_count = 0 + + for subscription in subscriptions: + try: + webpush( + subscription_info={ + 'endpoint': subscription.endpoint, + 'keys': { + 'p256dh': subscription.p256dh_key, + 'auth': subscription.auth_key + } + }, + data=json.dumps(notification_data), + vapid_private_key=config['private_key'], + vapid_claims={ + 'sub': 'mailto:admin@speakr.app' + } + ) + sent_count += 1 + print(f'[Push] Sent notification to user {user_id} subscription {subscription.id}') + + except WebPushException as e: + failed_count += 1 + print(f'[Push] Failed to send to subscription {subscription.id}: {e}') + + # Remove expired subscriptions + if e.response and e.response.status_code in [404, 410]: + print(f'[Push] Removing expired subscription {subscription.id}') + db.session.delete(subscription) + + except Exception as e: + failed_count += 1 + print(f'[Push] Unexpected error sending to subscription {subscription.id}: {e}') + + # Commit any deletions + if failed_count > 0: + db.session.commit() + + print(f'[Push] Sent {sent_count} notifications, {failed_count} failed') + + except ImportError: + print("[Push] pywebpush not installed, cannot send notifications") + except Exception as e: + print(f"[Push] Error sending notifications: {e}") diff --git a/src/api/recordings.py b/src/api/recordings.py new file mode 100644 index 0000000..0793075 --- /dev/null +++ b/src/api/recordings.py @@ -0,0 +1,4080 @@ +""" +Recording upload, processing, and management. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import re +import mimetypes +import time +import subprocess +from datetime import datetime, timedelta +from src.services.job_queue import job_queue +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app, make_response +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename +from werkzeug.exceptions import RequestEntityTooLarge +from sqlalchemy import select +from email.utils import encode_rfc2231 + +from src.database import db +from src.models import * +from src.utils import * +from src.config.app_config import ASR_MIN_SPEAKERS, ASR_MAX_SPEAKERS, ASR_DIARIZE, USE_NEW_TRANSCRIPTION_ARCHITECTURE +from src.tasks.processing import format_transcription_for_llm +from src.utils.ffmpeg_utils import FFmpegError, FFmpegNotFoundError +from src.services.speaker import update_speaker_usage, identify_unidentified_speakers_from_text +from src.services.speaker_embedding_matcher import update_speaker_embedding +from src.services.speaker_snippets import create_speaker_snippets +from src.services.audit import audit_access, audit_view, audit_download, audit_edit, audit_delete, audit_export + +# Incognito mode - disabled by default, enable via environment variable +ENABLE_INCOGNITO_MODE = os.environ.get('ENABLE_INCOGNITO_MODE', 'false').lower() == 'true' +from src.services.document import process_markdown_to_docx +from src.services.llm import client, chat_client, call_llm_completion, call_chat_completion, process_streaming_with_thinking, TokenBudgetExceeded +from src.services.embeddings import process_recording_chunks +from src.file_exporter import export_recording, mark_export_as_deleted +from src.utils.ffprobe import get_codec_info, get_creation_date, FFProbeError +from src.utils.audio_conversion import convert_if_needed +from src.utils.file_hash import compute_file_sha256 + +# Create blueprint +recordings_bp = Blueprint('recordings', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +DELETION_MODE = os.environ.get('DELETION_MODE', 'full_recording') # 'audio_only' or 'full_recording' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +VIDEO_RETENTION = os.environ.get('VIDEO_RETENTION', 'false').lower() == 'true' +VIDEO_PASSTHROUGH_ASR = os.environ.get('VIDEO_PASSTHROUGH_ASR', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' +ENABLE_CHUNKING = os.environ.get('ENABLE_CHUNKING', 'true').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +get_user_recording_status = None +set_user_recording_status = None +enrich_recording_dict_with_user_status = None +bcrypt = None +csrf = None +limiter = None +chunking_service = None + +def init_recordings_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, get_user_recording_status, set_user_recording_status, enrich_recording_dict_with_user_status, bcrypt, csrf, limiter, chunking_service + has_recording_access = kwargs.get('has_recording_access') + get_user_recording_status = kwargs.get('get_user_recording_status') + set_user_recording_status = kwargs.get('set_user_recording_status') + enrich_recording_dict_with_user_status = kwargs.get('enrich_recording_dict_with_user_status') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + chunking_service = kwargs.get('chunking_service') + + +# --- Routes --- + +@recordings_bp.route('/recording//download/transcript') +@login_required +def download_transcript_with_template(recording_id): + """Download transcript with custom template formatting.""" + try: + import re + from datetime import timedelta + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + if not recording.transcription: + return jsonify({'error': 'No transcription available for this recording'}), 400 + + try: + audit_download('transcript', recording_id) + db.session.commit() + except Exception: + db.session.rollback() + current_app.logger.warning(f'Audit log failed for transcript {recording_id}', exc_info=True) + + # Get template ID from query params + template_id = request.args.get('template_id', type=int) + + # Get the template + if template_id: + template = TranscriptTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + else: + # Use default template + template = TranscriptTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).first() + + # If no template found, use a basic format + if not template: + template_format = "[{{speaker}}]: {{text}}" + else: + template_format = template.template + + # Helper functions for formatting + def format_time(seconds): + """Format seconds to HH:MM:SS""" + if seconds is None: + return "00:00:00" + td = timedelta(seconds=seconds) + hours = int(td.total_seconds() // 3600) + minutes = int((td.total_seconds() % 3600) // 60) + secs = int(td.total_seconds() % 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + def format_srt_time(seconds): + """Format seconds to SRT format HH:MM:SS,mmm""" + if seconds is None: + return "00:00:00,000" + td = timedelta(seconds=seconds) + hours = int(td.total_seconds() // 3600) + minutes = int((td.total_seconds() % 3600) // 60) + secs = int(td.total_seconds() % 60) + millis = int((td.total_seconds() % 1) * 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + # Parse transcription - handle both JSON (diarized) and plain text formats + is_diarized = False + transcription_data = None + try: + transcription_data = json.loads(recording.transcription) + if isinstance(transcription_data, list): + is_diarized = True + except (json.JSONDecodeError, TypeError): + # Not JSON, treat as plain text + pass + + # If plain text transcription, return it as-is (no template formatting applies) + if not is_diarized: + formatted_transcript = recording.transcription + else: + # Generate formatted transcript from diarized segments + output_lines = [] + for index, segment in enumerate(transcription_data, 1): + line = template_format + + # Replace variables + replacements = { + '{{index}}': str(index), + '{{speaker}}': segment.get('speaker', 'Unknown'), + '{{text}}': segment.get('sentence', ''), + '{{start_time}}': format_time(segment.get('start_time')), + '{{end_time}}': format_time(segment.get('end_time')), + } + + for key, value in replacements.items(): + line = line.replace(key, value) + + # Handle filters + # Upper case filter + line = re.sub(r'{{(.*?)\|upper}}', lambda m: replacements.get('{{' + m.group(1) + '}}', '').upper(), line) + # SRT time filter + line = re.sub(r'{{start_time\|srt}}', format_srt_time(segment.get('start_time')), line) + line = re.sub(r'{{end_time\|srt}}', format_srt_time(segment.get('end_time')), line) + + output_lines.append(line) + + # Join lines + formatted_transcript = '\n'.join(output_lines) + + # Create response + response = make_response(formatted_transcript) + if is_diarized and template: + filename = f"{recording.title or 'transcript'}_{template.name}.txt" + elif is_diarized: + filename = f"{recording.title or 'transcript'}_formatted.txt" + else: + # Plain text transcription + filename = f"{recording.title or 'transcript'}.txt" + filename = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', filename) + response.headers['Content-Type'] = 'text/plain; charset=utf-8' + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + + return response + + except Exception as e: + current_app.logger.error(f"Error downloading transcript: {e}") + return jsonify({'error': 'Failed to generate transcript download'}), 500 + + + + + +@recordings_bp.route('/recording//download/transcript/word') +@login_required +def download_transcript_word(recording_id): + """Download transcript as a formatted Word document with timestamps.""" + try: + from docx import Document + from docx.shared import Pt, RGBColor, Inches + from docx.enum.text import WD_ALIGN_PARAGRAPH + from io import BytesIO + from datetime import timedelta + import json as json_mod + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + if not recording.transcription: + return jsonify({'error': 'No transcription available for this recording'}), 400 + + try: + audit_download('transcript', recording_id) + db.session.commit() + except Exception: + db.session.rollback() + current_app.logger.warning(f'Audit log failed for transcript word {recording_id}', exc_info=True) + + def format_time(seconds): + if seconds is None: + return "00:00:00" + td = timedelta(seconds=float(seconds)) + h = int(td.total_seconds() // 3600) + m = int((td.total_seconds() % 3600) // 60) + s = int(td.total_seconds() % 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + # Parse transcription + is_diarized = False + segments = [] + try: + data = json_mod.loads(recording.transcription) + if isinstance(data, list): + is_diarized = True + segments = data + except (json_mod.JSONDecodeError, TypeError): + pass + + # Build Word document + doc = Document() + + # Page margins + for section in doc.sections: + section.top_margin = Inches(1) + section.bottom_margin = Inches(1) + section.left_margin = Inches(1.2) + section.right_margin = Inches(1.2) + + # Title + title_para = doc.add_heading(recording.title or 'Transcription', 0) + title_para.alignment = WD_ALIGN_PARAGRAPH.LEFT + + # Metadata block + meta_lines = [] + meta_lines.append(f"Date : {recording.created_at.strftime('%Y-%m-%d %H:%M')}") + if recording.meeting_date: + meta_lines.append(f"Date de l'enregistrement : {recording.meeting_date.strftime('%Y-%m-%d')}") + if recording.participants: + meta_lines.append(f"Participants : {recording.participants}") + visible_tags = recording.get_visible_tags(current_user) + if visible_tags: + meta_lines.append(f"Etiquettes : {', '.join(t.name for t in visible_tags)}") + + for line in meta_lines: + p = doc.add_paragraph(line) + p.runs[0].font.size = Pt(10) + p.runs[0].font.color.rgb = RGBColor(0x66, 0x66, 0x66) + + doc.add_paragraph() # spacer + + from docx.oxml.ns import qn + from docx.oxml import OxmlElement + + def add_horizontal_rule(doc): + p = doc.add_paragraph() + pPr = p._p.get_or_add_pPr() + pBdr = OxmlElement('w:pBdr') + bottom = OxmlElement('w:bottom') + bottom.set(qn('w:val'), 'single') + bottom.set(qn('w:sz'), '6') + bottom.set(qn('w:space'), '1') + bottom.set(qn('w:color'), 'CCCCCC') + pBdr.append(bottom) + pPr.append(pBdr) + return p + + add_horizontal_rule(doc) + doc.add_paragraph() + + # Transcript content + if not is_diarized: + for line in recording.transcription.split('\n'): + if line.strip(): + doc.add_paragraph(line.strip()) + else: + speaker_colors = {} + palette = [ + RGBColor(0x1a, 0x56, 0xdb), + RGBColor(0x05, 0x96, 0x69), + RGBColor(0x97, 0x16, 0x2d), + RGBColor(0x92, 0x40, 0xbd), + RGBColor(0xc2, 0x74, 0x03), + ] + + for segment in segments: + speaker = segment.get('speaker', 'Inconnu') + text = segment.get('sentence', '').strip() + start = segment.get('start_time') + end = segment.get('end_time') + + if not text: + continue + + if speaker not in speaker_colors: + speaker_colors[speaker] = palette[len(speaker_colors) % len(palette)] + color = speaker_colors[speaker] + + # Timestamp line + ts_para = doc.add_paragraph() + ts_run = ts_para.add_run(f"{format_time(start)} -> {format_time(end)}") + ts_run.font.size = Pt(8) + ts_run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + ts_para.paragraph_format.space_before = Pt(8) + ts_para.paragraph_format.space_after = Pt(0) + + # Speaker + text + seg_para = doc.add_paragraph() + seg_para.paragraph_format.space_before = Pt(0) + seg_para.paragraph_format.space_after = Pt(2) + + spk_run = seg_para.add_run(f"{speaker} ") + spk_run.bold = True + spk_run.font.size = Pt(10) + spk_run.font.color.rgb = color + + txt_run = seg_para.add_run(text) + txt_run.font.size = Pt(10) + + # Footer + doc.add_paragraph() + add_horizontal_rule(doc) + footer_para = doc.add_paragraph("Genere par DictIA — Confidentiel") + footer_para.runs[0].font.size = Pt(8) + footer_para.runs[0].font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA) + footer_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT + + doc_stream = BytesIO() + doc.save(doc_stream) + doc_stream.seek(0) + + safe_title = re.sub(r'[<>:"/\\|?*]', '', recording.title or 'Untitled') + safe_title = re.sub(r'[-\s]+', '-', safe_title).strip('-') + filename = f'transcript-{safe_title}.docx' if safe_title else f'transcript-{recording_id}.docx' + + response = send_file( + doc_stream, + as_attachment=False, + mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + try: + filename.encode('ascii') + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + except UnicodeEncodeError: + try: + encoded_value = encode_rfc2231(filename, charset='utf-8') + response.headers['Content-Disposition'] = f'attachment; filename*={encoded_value}' + except Exception: + response.headers['Content-Disposition'] = f'attachment; filename="transcript-{recording_id}.docx"' + + return response + + except Exception as e: + current_app.logger.error(f"Error generating transcript Word document: {e}") + return jsonify({'error': 'Failed to generate transcript Word document'}), 500 + + +@recordings_bp.route('/recording//download/summary') +@login_required +def download_summary_word(recording_id): + """Download recording summary as a Word document.""" + try: + from docx import Document + from docx.shared import Inches + import re + from io import BytesIO + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + if not recording.summary: + return jsonify({'error': 'No summary available for this recording'}), 400 + + # Create Word document + doc = Document() + + # Add title + title_text = f'Summary: {recording.title or "Untitled Recording"}' + title = doc.add_heading(title_text, 0) + # Check if title needs Unicode font support + try: + title_text.encode('ascii') + except UnicodeEncodeError: + # Title contains non-ASCII characters + from docx.oxml.ns import qn + for run in title.runs: + run.font.name = 'Arial' + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + + # Helper function to add paragraph with Unicode support + def add_unicode_paragraph(doc, text): + p = doc.add_paragraph(text) + try: + text.encode('ascii') + except UnicodeEncodeError: + from docx.oxml.ns import qn + for run in p.runs: + run.font.name = 'Arial' + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + return p + + # Add metadata + add_unicode_paragraph(doc, f'Uploaded: {recording.created_at.strftime("%Y-%m-%d %H:%M")}') + if recording.meeting_date: + add_unicode_paragraph(doc, f'Recording Date: {recording.meeting_date.strftime("%Y-%m-%d")}') + if recording.participants: + add_unicode_paragraph(doc, f'Participants: {recording.participants}') + visible_tags = recording.get_visible_tags(current_user) + if visible_tags: + tags_str = ', '.join([tag.name for tag in visible_tags]) + add_unicode_paragraph(doc, f'Tags: {tags_str}') + doc.add_paragraph('') # Empty line + + # Process markdown content using the helper function + process_markdown_to_docx(doc, recording.summary) + + # Save to BytesIO + doc_stream = BytesIO() + doc.save(doc_stream) + doc_stream.seek(0) + + # Create safe filename + safe_title = re.sub(r'[<>:"/\\|?*]', '', recording.title or 'Untitled') + safe_title = re.sub(r'[-\s]+', '-', safe_title).strip('-') + filename = f'summary-{safe_title}.docx' if safe_title else f'summary-recording-{recording_id}.docx' + + # Create ASCII fallback for send_file - if title has non-ASCII chars, use generic name with ID + ascii_filename = filename.encode('ascii', 'ignore').decode('ascii') + if not ascii_filename.strip() or ascii_filename.strip() in ['summary-.docx', 'summary-recording-.docx']: + ascii_filename = f'summary-recording-{recording_id}.docx' + + response = send_file( + doc_stream, + as_attachment=False, + mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + # Properly encode filename for international characters + # Check if filename contains non-ASCII characters + try: + # Try to encode as ASCII - if this works, use simple format + filename.encode('ascii') + # ASCII-only filename, use simple format + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + except UnicodeEncodeError: + # Contains non-ASCII characters, use proper RFC 2231 encoding + try: + # Use Python's built-in RFC 2231 encoder + encoded_value = encode_rfc2231(filename, charset='utf-8') + header_value = f'attachment; filename*={encoded_value}' + current_app.logger.info(f"DEBUG CHINESE FILENAME (RFC2231): Original='{filename}', Header='{header_value}'") + response.headers['Content-Disposition'] = header_value + except Exception as e: + # Fallback to simple attachment with generic name + current_app.logger.error(f"RFC2231 encoding failed: {e}, using fallback") + response.headers['Content-Disposition'] = f'attachment; filename="download-{recording_id}.docx"' + return response + + except Exception as e: + current_app.logger.error(f"Error generating summary Word document: {e}") + return jsonify({'error': 'Failed to generate Word document'}), 500 + + + +@recordings_bp.route('/recording//download/chat', methods=['POST']) +@login_required +def download_chat_word(recording_id): + """Download chat conversation as a Word document.""" + try: + from docx import Document + from docx.shared import Inches + import re + from io import BytesIO + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + # Get chat messages from request + data = request.json + if not data or 'messages' not in data: + return jsonify({'error': 'No messages provided'}), 400 + + messages = data['messages'] + if not messages: + return jsonify({'error': 'No messages to download'}), 400 + + # Create Word document + doc = Document() + + # Add title + title_text = f'Chat Conversation: {recording.title or "Untitled Recording"}' + title = doc.add_heading(title_text, 0) + # Check if title needs Unicode font support + try: + title_text.encode('ascii') + except UnicodeEncodeError: + from docx.oxml.ns import qn + for run in title.runs: + run.font.name = 'Arial' + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + + # Helper function to add paragraph with Unicode support + def add_unicode_paragraph(doc, text): + p = doc.add_paragraph(text) + try: + text.encode('ascii') + except UnicodeEncodeError: + from docx.oxml.ns import qn + for run in p.runs: + run.font.name = 'Arial' + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + return p + + # Add metadata + add_unicode_paragraph(doc, f'Recording Date: {recording.created_at.strftime("%Y-%m-%d %H:%M")}') + add_unicode_paragraph(doc, f'Chat Export Date: {datetime.utcnow().strftime("%Y-%m-%d %H:%M")}') + doc.add_paragraph('') # Empty line + + # Add chat messages + for message in messages: + role = message.get('role', 'unknown') + content = message.get('content', '') + thinking = message.get('thinking', '') + + # Add role header + if role == 'user': + p = doc.add_paragraph() + run = p.add_run('You: ') + run.bold = True + elif role == 'assistant': + p = doc.add_paragraph() + run = p.add_run('Assistant: ') + run.bold = True + else: + p = doc.add_paragraph() + run = p.add_run(f'{role.title()}: ') + run.bold = True + + # Add thinking content if present + if thinking and role == 'assistant': + p = doc.add_paragraph() + p.add_run('[Model Reasoning]\n').italic = True + p.add_run(thinking).italic = True + doc.add_paragraph('') # Empty line + + # Add message content with markdown formatting + process_markdown_to_docx(doc, content) + + doc.add_paragraph('') # Empty line between messages + + # Save to BytesIO + doc_stream = BytesIO() + doc.save(doc_stream) + doc_stream.seek(0) + + # Create safe filename + safe_title = re.sub(r'[<>:"/\\|?*]', '', recording.title or 'Untitled') + safe_title = re.sub(r'[-\s]+', '-', safe_title).strip('-') + filename = f'chat-{safe_title}.docx' if safe_title else f'chat-recording-{recording_id}.docx' + + # Create ASCII fallback for send_file - if title has non-ASCII chars, use generic name with ID + ascii_filename = filename.encode('ascii', 'ignore').decode('ascii') + if not ascii_filename.strip() or ascii_filename.strip() in ['chat-.docx', 'chat-recording-.docx']: + ascii_filename = f'chat-recording-{recording_id}.docx' + + response = send_file( + doc_stream, + as_attachment=False, + mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + + # Properly encode filename for international characters + # Check if filename contains non-ASCII characters + try: + # Try to encode as ASCII - if this works, use simple format + filename.encode('ascii') + # ASCII-only filename, use simple format + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + except UnicodeEncodeError: + # Contains non-ASCII characters, use proper RFC 2231 encoding + try: + # Use Python's built-in RFC 2231 encoder + encoded_value = encode_rfc2231(filename, charset='utf-8') + header_value = f'attachment; filename*={encoded_value}' + current_app.logger.info(f"DEBUG CHINESE FILENAME (RFC2231): Original='{filename}', Header='{header_value}'") + response.headers['Content-Disposition'] = header_value + except Exception as e: + # Fallback to simple attachment with generic name + current_app.logger.error(f"RFC2231 encoding failed: {e}, using fallback") + response.headers['Content-Disposition'] = f'attachment; filename="download-{recording_id}.docx"' + return response + + except Exception as e: + current_app.logger.error(f"Error generating chat Word document: {e}") + return jsonify({'error': 'Failed to generate Word document'}), 500 + + + +@recordings_bp.route('/recording//download/notes') +@login_required +def download_notes_word(recording_id): + """Download recording notes as a Word document.""" + try: + from docx import Document + from docx.shared import Inches + import re + from io import BytesIO + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + if not recording.notes: + return jsonify({'error': 'No notes available for this recording'}), 400 + + # Create Word document + doc = Document() + + # Add title + title_text = f'Notes: {recording.title or "Untitled Recording"}' + title = doc.add_heading(title_text, 0) + # Check if title needs Unicode font support + try: + title_text.encode('ascii') + except UnicodeEncodeError: + from docx.oxml.ns import qn + for run in title.runs: + run.font.name = 'Arial' + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + + # Helper function to add paragraph with Unicode support + def add_unicode_paragraph(doc, text): + p = doc.add_paragraph(text) + try: + text.encode('ascii') + except UnicodeEncodeError: + from docx.oxml.ns import qn + for run in p.runs: + run.font.name = 'Arial' + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + return p + + # Add metadata + add_unicode_paragraph(doc, f'Uploaded: {recording.created_at.strftime("%Y-%m-%d %H:%M")}') + if recording.meeting_date: + add_unicode_paragraph(doc, f'Recording Date: {recording.meeting_date.strftime("%Y-%m-%d")}') + if recording.participants: + add_unicode_paragraph(doc, f'Participants: {recording.participants}') + visible_tags = recording.get_visible_tags(current_user) + if visible_tags: + tags_str = ', '.join([tag.name for tag in visible_tags]) + add_unicode_paragraph(doc, f'Tags: {tags_str}') + doc.add_paragraph('') # Empty line + + # Process markdown content using the helper function + process_markdown_to_docx(doc, recording.notes) + + # Save to BytesIO + doc_stream = BytesIO() + doc.save(doc_stream) + doc_stream.seek(0) + + # Create safe filename + safe_title = re.sub(r'[<>:"/\\|?*]', '', recording.title or 'Untitled') + safe_title = re.sub(r'[-\s]+', '-', safe_title).strip('-') + filename = f'notes-{safe_title}.docx' if safe_title else f'notes-recording-{recording_id}.docx' + + # Create ASCII fallback for send_file - if title has non-ASCII chars, use generic name with ID + ascii_filename = filename.encode('ascii', 'ignore').decode('ascii') + if not ascii_filename.strip() or ascii_filename.strip() in ['notes-.docx', 'notes-recording-.docx']: + ascii_filename = f'notes-recording-{recording_id}.docx' + + response = send_file( + doc_stream, + as_attachment=False, + mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + # Properly encode filename for international characters + # Check if filename contains non-ASCII characters + try: + # Try to encode as ASCII - if this works, use simple format + filename.encode('ascii') + # ASCII-only filename, use simple format + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + except UnicodeEncodeError: + # Contains non-ASCII characters, use proper RFC 2231 encoding + try: + # Use Python's built-in RFC 2231 encoder + encoded_value = encode_rfc2231(filename, charset='utf-8') + header_value = f'attachment; filename*={encoded_value}' + current_app.logger.info(f"DEBUG CHINESE FILENAME (RFC2231): Original='{filename}', Header='{header_value}'") + response.headers['Content-Disposition'] = header_value + except Exception as e: + # Fallback to simple attachment with generic name + current_app.logger.error(f"RFC2231 encoding failed: {e}, using fallback") + response.headers['Content-Disposition'] = f'attachment; filename="download-{recording_id}.docx"' + return response + + except Exception as e: + current_app.logger.error(f"Error generating notes Word document: {e}") + return jsonify({'error': 'Failed to generate Word document'}), 500 + + + +@recordings_bp.route('/recording//generate_summary', methods=['POST']) +@login_required +def generate_summary_endpoint(recording_id): + """Generate summary for a recording that doesn't have one.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to generate summary for this recording'}), 403 + + # Check if transcription exists + if not recording.transcription or len(recording.transcription.strip()) < 10: + return jsonify({'error': 'No valid transcription available for summary generation'}), 400 + + # Check if transcription is an error message (not actual content) + if is_transcription_error(recording.transcription): + return jsonify({'error': 'Cannot generate summary: transcription failed. Please reprocess the transcription first.'}), 400 + + # Check if already processing + if recording.status in ['PROCESSING', 'SUMMARIZING']: + return jsonify({'error': 'Recording is already being processed'}), 400 + + # Check if OpenRouter client is available + if client is None: + return jsonify({'error': 'Summary service is not available (OpenRouter client not configured)'}), 503 + + current_app.logger.info(f"Queueing summary generation for recording {recording_id}") + + # Queue summary generation job + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='summarize', + params={'user_id': current_user.id} + ) + + return jsonify({ + 'success': True, + 'message': 'Summary generation queued' + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error starting summary generation for recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + + + +@recordings_bp.route('/recording//update_speakers', methods=['POST']) +@login_required +def update_speakers(recording_id): + """Updates speaker labels in a transcription with provided names.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to edit this recording'}), 403 + + data = request.json + speaker_map = data.get('speaker_map') + regenerate_summary = data.get('regenerate_summary', False) + + if speaker_map is None: + return jsonify({'error': 'No speaker map provided'}), 400 + + transcription_text = recording.transcription + is_json = False + try: + transcription_data = json.loads(transcription_text) + # Updated check for our new simplified JSON format (a list of segment objects) + is_json = isinstance(transcription_data, list) + except (json.JSONDecodeError, TypeError): + is_json = False + + speaker_names_used = [] + + if is_json: + # Handle new simplified JSON transcript (list of segments) + for segment in transcription_data: + original_speaker_label = segment.get('speaker') + if original_speaker_label in speaker_map: + new_name_info = speaker_map[original_speaker_label] + new_name = new_name_info.get('name', '').strip() + # If isMe is checked but no name provided, use current user's name + if new_name_info.get('isMe') and not new_name: + new_name = current_user.name or 'Me' + + if new_name: + segment['speaker'] = new_name + if new_name not in speaker_names_used: + speaker_names_used.append(new_name) + + recording.transcription = json.dumps(transcription_data) + + # Update participants only from speakers that were actually given names (not default labels) + final_speakers = set() + for seg in transcription_data: + speaker = seg.get('speaker') + if speaker and str(speaker).strip(): + # Only include speakers that have been given actual names (not default labels like "SPEAKER_01", "SPEAKER_09", etc.) + # Check if this speaker was updated with a real name (not a default label) + if not re.match(r'^SPEAKER_\d+$', str(speaker), re.IGNORECASE): + final_speakers.add(speaker) + recording.participants = ', '.join(sorted(list(final_speakers))) + + else: + # Handle plain text transcript + new_participants = [] + for speaker_label, new_name_info in speaker_map.items(): + new_name = new_name_info.get('name', '').strip() + # If isMe is checked but no name provided, use current user's name + if new_name_info.get('isMe') and not new_name: + new_name = current_user.name or 'Me' + + if new_name: + transcription_text = re.sub(r'\[\s*' + re.escape(speaker_label) + r'\s*\]', f'[{new_name}]', transcription_text, flags=re.IGNORECASE) + if new_name not in new_participants: + new_participants.append(new_name) + + recording.transcription = transcription_text + if new_participants: + recording.participants = ', '.join(new_participants) + speaker_names_used = new_participants + + # Update speaker usage statistics + if speaker_names_used: + update_speaker_usage(speaker_names_used) + + # Update speaker voice embeddings if available + embeddings_updated = 0 + snippets_created = 0 + if recording.speaker_embeddings and speaker_map: + try: + # Parse embeddings from recording + embeddings_data = json.loads(recording.speaker_embeddings) if isinstance(recording.speaker_embeddings, str) else recording.speaker_embeddings + + # Build reverse map: SPEAKER_XX -> actual name assigned + speaker_label_to_name = {} + for speaker_label, speaker_info in speaker_map.items(): + name = speaker_info.get('name', '').strip() + # Handle isMe checkbox + if speaker_info.get('isMe') and not name: + name = current_user.name or 'Me' + + # Only include speakers that were given real names (not SPEAKER_XX) + if name and not re.match(r'^SPEAKER_\d+$', name, re.IGNORECASE): + speaker_label_to_name[speaker_label] = name + + # Update embeddings for each identified speaker + for speaker_label, embedding in embeddings_data.items(): + if speaker_label in speaker_label_to_name and embedding and len(embedding) == 256: + speaker_name = speaker_label_to_name[speaker_label] + + # Find or create the speaker + speaker = Speaker.query.filter_by( + user_id=current_user.id, + name=speaker_name + ).first() + + if speaker: + # Update the speaker's voice embedding + similarity = update_speaker_embedding(speaker, embedding, recording.id) + embeddings_updated += 1 + + if similarity is not None: + current_app.logger.info( + f"Updated voice profile for '{speaker_name}' " + f"(similarity: {similarity*100:.1f}%)" + ) + else: + current_app.logger.info( + f"Created initial voice profile for '{speaker_name}'" + ) + + # Create snippets for identified speakers + if speaker_label_to_name: + snippets_created = create_speaker_snippets(recording.id, speaker_map) + if snippets_created > 0: + current_app.logger.info(f"Created {snippets_created} speaker snippets") + + except Exception as e: + current_app.logger.error(f"Error updating speaker embeddings: {e}", exc_info=True) + # Don't fail the whole request if embedding update fails + + db.session.commit() + + summary_queued = False + if regenerate_summary: + current_app.logger.info(f"Queueing summary regeneration for recording {recording_id} after speaker update.") + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='summarize', + params={'user_id': current_user.id} + ) + summary_queued = True + + # Return recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'message': 'Speakers updated successfully.', + 'recording': recording_dict, + 'summary_queued': summary_queued + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating speakers for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + + + +@recordings_bp.route('/recording//update_transcript', methods=['POST']) +@login_required +def update_transcript(recording_id): + """Updates the complete transcript data including text edits and speaker changes.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to edit this recording'}), 403 + + audit_edit('transcript', recording_id) + + data = request.json + transcript_data = data.get('transcript_data') + speaker_map = data.get('speaker_map', {}) + regenerate_summary = data.get('regenerate_summary', False) + + if not transcript_data or not isinstance(transcript_data, list): + return jsonify({'error': 'Invalid transcript data provided'}), 400 + + # Update speaker names in the transcript data + speaker_names_used = [] + for segment in transcript_data: + original_speaker_label = segment.get('speaker') + + # Apply speaker name mapping if provided + if original_speaker_label in speaker_map: + new_name_info = speaker_map[original_speaker_label] + new_name = new_name_info.get('name', '').strip() + if new_name_info.get('isMe'): + new_name = current_user.name or 'Me' + + if new_name: + segment['speaker'] = new_name + if new_name not in speaker_names_used: + speaker_names_used.append(new_name) + + # Save the updated transcript + recording.transcription = json.dumps(transcript_data) + + # Update participants + final_speakers = set() + for seg in transcript_data: + speaker = seg.get('speaker') + if speaker and str(speaker).strip(): + # Only include speakers with real names (not default labels) + if not re.match(r'^SPEAKER_\d+$', str(speaker), re.IGNORECASE): + final_speakers.add(speaker) + recording.participants = ', '.join(sorted(list(final_speakers))) + + # Update speaker usage statistics + if speaker_names_used: + update_speaker_usage(speaker_names_used) + + db.session.commit() + + summary_queued = False + if regenerate_summary: + current_app.logger.info(f"Queueing summary regeneration for recording {recording_id} after transcript update.") + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='summarize', + params={'user_id': current_user.id} + ) + summary_queued = True + # Export will happen after summary regenerates + else: + # Re-export the recording if auto-export is enabled + export_recording(recording_id) + + # Return recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'message': 'Transcript updated successfully.', + 'recording': recording_dict, + 'summary_queued': summary_queued + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating transcript for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + + + +@recordings_bp.route('/recording//auto_identify_speakers', methods=['POST']) +@login_required +def auto_identify_speakers(recording_id): + """ + Automatically identifies speakers in a transcription using an LLM. + Strips existing names and re-identifies all speakers from scratch. + """ + from src.services.speaker_identification import identify_speakers_from_transcript + + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + + if not recording.transcription: + return jsonify({'error': 'No transcription available for speaker identification'}), 400 + + try: + transcription_data = json.loads(recording.transcription) + except (json.JSONDecodeError, TypeError): + return jsonify({'error': 'Transcription format not supported for auto-identification'}), 400 + + if not isinstance(transcription_data, list): + return jsonify({'error': 'Transcription format not supported for auto-identification'}), 400 + + speaker_map = identify_speakers_from_transcript(transcription_data, current_user.id) + + if not speaker_map: + return jsonify({'error': 'No speakers found in transcription'}), 400 + + return jsonify({'success': True, 'speaker_map': speaker_map}) + + except ValueError as ve: + return jsonify({'error': str(ve)}), 503 + except Exception as e: + current_app.logger.error(f"Error during auto speaker identification for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': f'An unexpected error occurred: {str(e)}'}), 500 + +# --- Chat with Transcription --- + + +@recordings_bp.route('/recording//reprocess_transcription', methods=['POST']) +@login_required +def reprocess_transcription(recording_id): + """Reprocess transcription for a given recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to reprocess this recording'}), 403 + + if not recording.audio_path or not os.path.exists(recording.audio_path): + return jsonify({'error': 'Audio file not found for reprocessing'}), 404 + + if recording.status in ['QUEUED', 'PROCESSING', 'SUMMARIZING']: + return jsonify({'error': 'Recording is already being processed'}), 400 + + # File path and name for processing (conversion handled in background task if needed) + filepath = recording.audio_path + filename_for_asr = recording.original_filename or os.path.basename(filepath) + + # --- Proceed with reprocessing --- + recording.transcription = None + recording.summary = None + recording.status = 'QUEUED' # Will change to PROCESSING when job starts + + # Clear existing events since they depend on the transcription + Event.query.filter_by(recording_id=recording_id).delete() + + db.session.commit() + + current_app.logger.info(f"Queueing transcription reprocessing for recording {recording_id}") + + # Prepare job parameters + data = request.json or {} + start_time = datetime.utcnow() + app_context = current_app._get_current_object().app_context() + + # Build job parameters - language handling: + # - If 'language' key exists with a value (e.g., 'es'), use that language + # - If 'language' key exists but is empty string, keep as empty string (signals auto-detect) + # - If 'language' key doesn't exist at all, fall back to user's default (backwards compat) + if 'language' in data: + # User explicitly chose a language (or auto-detect with empty string) + language = data.get('language') # Could be 'es', '', or None + else: + # Language not provided - use user's default (backwards compatibility) + language = recording.owner.transcription_language if recording.owner else None + + min_speakers = data.get('min_speakers') or None + max_speakers = data.get('max_speakers') or None + + # Convert to int if provided + if min_speakers: + try: + min_speakers = int(min_speakers) + except (ValueError, TypeError): + min_speakers = None + if max_speakers: + try: + max_speakers = int(max_speakers) + except (ValueError, TypeError): + max_speakers = None + + # Apply tag defaults if no user input provided (for connectors that support speaker count) + if (min_speakers is None or max_speakers is None) and recording.tags: + for tag_association in sorted(recording.tag_associations, key=lambda x: x.order): + tag = tag_association.tag + if min_speakers is None and tag.default_min_speakers: + min_speakers = tag.default_min_speakers + if max_speakers is None and tag.default_max_speakers: + max_speakers = tag.default_max_speakers + if min_speakers is not None and max_speakers is not None: + break + + # Apply environment variable defaults + if min_speakers is None and ASR_MIN_SPEAKERS: + try: + min_speakers = int(ASR_MIN_SPEAKERS) + except (ValueError, TypeError): + min_speakers = None + if max_speakers is None and ASR_MAX_SPEAKERS: + try: + max_speakers = int(ASR_MAX_SPEAKERS) + except (ValueError, TypeError): + max_speakers = None + + # Enqueue the job with all parameters + job_params = { + 'language': language, + 'min_speakers': min_speakers, + 'max_speakers': max_speakers + } + + job_id = job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='reprocess_transcription', + params=job_params + ) + + # Get queue position for response + queue_position = job_queue.get_position_in_queue(recording.id) + queue_status = job_queue.get_queue_status() + + # Return recording with per-user status and queue info + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'message': 'Transcription reprocessing queued', + 'recording': recording_dict, + 'queue_position': queue_position, + 'queue_status': queue_status + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error reprocessing transcription for recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + + + + +@recordings_bp.route('/recording//reprocess_summary', methods=['POST']) +@login_required +def reprocess_summary(recording_id): + """Reprocess summary for a given recording (requires existing transcription).""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to reprocess this recording'}), 403 + + # Check if transcription exists + if not recording.transcription or len(recording.transcription.strip()) < 10: + return jsonify({'error': 'No valid transcription available for summary generation'}), 400 + + # Check if transcription is an error message (not actual content) + if is_transcription_error(recording.transcription): + return jsonify({'error': 'Cannot generate summary: transcription failed. Please reprocess the transcription first.'}), 400 + + # Check if already processing + if recording.status in ['PROCESSING', 'SUMMARIZING']: + return jsonify({'error': 'Recording is already being processed'}), 400 + + # Check if OpenRouter client is available + if client is None: + return jsonify({'error': 'Summary service is not available (OpenRouter client not configured)'}), 503 + + # Get custom prompt from request if provided + data = request.get_json() or {} + custom_prompt = data.get('custom_prompt', '').strip() if data.get('custom_prompt') else None + + # Debug logging + if custom_prompt: + current_app.logger.info(f"Received custom prompt override for recording {recording_id} (length: {len(custom_prompt)})") + else: + current_app.logger.info(f"No custom prompt override provided for recording {recording_id}, will use default priority") + + # Clear existing summary (status will be set to QUEUED by job_queue.enqueue) + recording.summary = None + + # Clear existing events since they might be re-extracted during summary generation + Event.query.filter_by(recording_id=recording_id).delete() + + db.session.commit() + + current_app.logger.info(f"Queueing summary reprocessing for recording {recording_id}" + + (f" with custom prompt (length: {len(custom_prompt)})" if custom_prompt else "")) + + # Queue summary generation job + job_params = { + 'custom_prompt': custom_prompt, + 'user_id': current_user.id + } + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='reprocess_summary', + params=job_params + ) + + # Refresh recording to get updated status + db.session.refresh(recording) + + # Return recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'message': 'Summary reprocessing started', + 'recording': recording_dict + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error reprocessing summary for recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + + + +@recordings_bp.route('/recording//reset_status', methods=['POST']) +@login_required +def reset_status(recording_id): + """Resets the status of a stuck or failed recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + + # Allow resetting if it's stuck or failed + if recording.status in ['PENDING', 'PROCESSING', 'SUMMARIZING', 'FAILED']: + recording.status = 'FAILED' + recording.error_message = "Manually reset from stuck or failed state." + db.session.commit() + current_app.logger.info(f"Manually reset status for recording {recording_id} to FAILED.") + + # Return recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({'success': True, 'message': 'Recording status has been reset.', 'recording': recording_dict}) + else: + return jsonify({'error': f'Recording is not in a state that can be reset. Current status: {recording.status}'}), 400 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error resetting status for recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + +# --- Authentication Routes --- + + +@recordings_bp.route('/') +@login_required +def index(): + # Check if user is a group admin + is_team_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + # Pass the ASR config, inquire mode config, and user language preference to the template + user_language = current_user.ui_language if current_user.is_authenticated and current_user.ui_language else 'en' + + # Calculate if archive toggle should be shown (only when audio-only deletion mode is active) + enable_archive_toggle = ENABLE_AUTO_DELETION and DELETION_MODE == 'audio_only' + + # Get connector capabilities (new architecture) + # Defaults to USE_ASR_ENDPOINT for backwards compatibility + connector_supports_diarization = USE_ASR_ENDPOINT + connector_supports_speaker_count = USE_ASR_ENDPOINT # ASR endpoint supports min/max speakers + if USE_NEW_TRANSCRIPTION_ARCHITECTURE: + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.get_active_connector() + if connector: + connector_supports_diarization = connector.supports_diarization + connector_supports_speaker_count = connector.supports_speaker_count_control + except Exception as e: + current_app.logger.warning(f"Could not get connector capabilities: {e}") + + return render_template('index.html', + use_asr_endpoint=USE_ASR_ENDPOINT, # Backwards compat + connector_supports_diarization=connector_supports_diarization, + connector_supports_speaker_count=connector_supports_speaker_count, + inquire_mode_enabled=ENABLE_INQUIRE_MODE, + enable_archive_toggle=enable_archive_toggle, + enable_internal_sharing=ENABLE_INTERNAL_SHARING, + user_language=user_language, + is_team_admin=is_team_admin) + + + +def get_accessible_recording_ids(user_id): + """ + Get all recording IDs that a user has access to. + + Includes: + - Recordings owned by the user + - Recordings shared with the user via InternalShare + - Recordings shared via group tags (if team membership exists) + + Args: + user_id (int): User ID to check access for + + Returns: + list: List of recording IDs the user can access + """ + accessible_ids = set() + + # 1. User's own recordings + own_recordings = db.session.query(Recording.id).filter_by(user_id=user_id).all() + accessible_ids.update([r.id for r in own_recordings]) + + # 2. Internally shared recordings + if ENABLE_INTERNAL_SHARING: + shared_recordings = db.session.query(InternalShare.recording_id).filter_by( + shared_with_user_id=user_id + ).all() + accessible_ids.update([r.recording_id for r in shared_recordings]) + + return list(accessible_ids) + + +@recordings_bp.route('/recordings', methods=['GET']) +def get_recordings(): + """Get all recordings for the current user (simple list).""" + try: + # Check if user is logged in + if not current_user.is_authenticated: + return jsonify([]) # Return empty array if not logged in + + # Filter recordings by the current user + stmt = select(Recording).where(Recording.user_id == current_user.id).order_by(Recording.created_at.desc()) + recordings = db.session.execute(stmt).scalars().all() + return jsonify([recording.to_dict(viewer_user=current_user) for recording in recordings]) + except Exception as e: + current_app.logger.error(f"Error fetching recordings: {e}") + return jsonify({'error': str(e)}), 500 + + +@recordings_bp.route('/api/recordings', methods=['GET']) +@login_required +def get_recordings_paginated(): + """Get recordings with pagination and server-side filtering (includes shared recordings).""" + import re + try: + # Parse query parameters + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 25, type=int), 100) # Cap at 100 per page + search_query = request.args.get('q', '').strip() + show_archived = request.args.get('archived', '').lower() == 'true' + show_shared = request.args.get('shared', '').lower() == 'true' + show_starred = request.args.get('starred', '').lower() == 'true' + show_inbox = request.args.get('inbox', '').lower() == 'true' + sort_by = request.args.get('sort_by', 'created_at') # 'created_at' or 'meeting_date' + folder_filter = request.args.get('folder', '').strip() # folder_id or 'none' for no folder + + # Get all accessible recording IDs (own + shared) + accessible_recording_ids = get_accessible_recording_ids(current_user.id) + + if not accessible_recording_ids: + return jsonify({ + 'recordings': [], + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': 0, + 'total_pages': 0, + 'has_next': False, + 'has_prev': False + } + }) + + # Build base query to include accessible recordings + stmt = select(Recording).where(Recording.id.in_(accessible_recording_ids)) + + # Apply archived filter (AND with other filters) + if show_archived: + # Only show recordings where audio has been deleted + stmt = stmt.where(Recording.audio_deleted_at.is_not(None)) + + # Apply shared filter (AND with other filters) + if show_shared: + # Only show recordings shared with current user (not owned by them) + stmt = stmt.where(Recording.user_id != current_user.id) + + # Apply starred filter (AND with other filters) + # For starred/inbox we need to consider both owned recordings and shared recordings + if show_starred: + from src.models.sharing import SharedRecordingState + # For owned recordings: check Recording.is_highlighted + # For shared recordings: check SharedRecordingState.is_highlighted + starred_subq = select(SharedRecordingState.recording_id).where( + db.and_( + SharedRecordingState.user_id == current_user.id, + SharedRecordingState.is_highlighted == True + ) + ).scalar_subquery() + stmt = stmt.where( + db.or_( + db.and_(Recording.user_id == current_user.id, Recording.is_highlighted == True), + Recording.id.in_(starred_subq) + ) + ) + + # Apply inbox filter (AND with other filters) + if show_inbox: + from src.models.sharing import SharedRecordingState + # For owned recordings: check Recording.is_inbox + # For shared recordings: check SharedRecordingState.is_inbox + inbox_subq = select(SharedRecordingState.recording_id).where( + db.and_( + SharedRecordingState.user_id == current_user.id, + SharedRecordingState.is_inbox == True + ) + ).scalar_subquery() + stmt = stmt.where( + db.or_( + db.and_(Recording.user_id == current_user.id, Recording.is_inbox == True), + Recording.id.in_(inbox_subq) + ) + ) + + # Apply folder filter (AND with other filters) + if folder_filter: + if folder_filter.lower() == 'none': + # Filter recordings with no folder + stmt = stmt.where(Recording.folder_id.is_(None)) + else: + # Filter by specific folder_id + try: + folder_id = int(folder_filter) + stmt = stmt.where(Recording.folder_id == folder_id) + except ValueError: + pass # Invalid folder_id, ignore filter + + # Apply search filters if provided + if search_query: + # Extract date filters + date_filters = re.findall(r'date:(\S+)', search_query.lower()) + date_from_filters = re.findall(r'date_from:(\S+)', search_query.lower()) + date_to_filters = re.findall(r'date_to:(\S+)', search_query.lower()) + tag_filters = re.findall(r'tag:(\S+)', search_query.lower()) + speaker_filters = re.findall(r'speaker:(\S+)', search_query.lower()) + + # Remove special syntax to get text search + text_query = re.sub(r'date:\S+', '', search_query, flags=re.IGNORECASE) + text_query = re.sub(r'date_from:\S+', '', text_query, flags=re.IGNORECASE) + text_query = re.sub(r'date_to:\S+', '', text_query, flags=re.IGNORECASE) + text_query = re.sub(r'tag:\S+', '', text_query, flags=re.IGNORECASE) + text_query = re.sub(r'speaker:\S+', '', text_query, flags=re.IGNORECASE).strip() + + # Apply date filters + for date_filter in date_filters: + if date_filter == 'today': + today = datetime.now().date() + stmt = stmt.where( + db.or_( + db.func.date(Recording.meeting_date) == today, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) == today + ) + ) + ) + elif date_filter == 'yesterday': + yesterday = datetime.now().date() - timedelta(days=1) + stmt = stmt.where( + db.or_( + db.func.date(Recording.meeting_date) == yesterday, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) == yesterday + ) + ) + ) + elif date_filter == 'thisweek': + today = datetime.now().date() + start_of_week = today - timedelta(days=today.weekday()) + stmt = stmt.where( + db.or_( + Recording.meeting_date >= start_of_week, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) >= start_of_week + ) + ) + ) + elif date_filter == 'lastweek': + today = datetime.now().date() + end_of_last_week = today - timedelta(days=today.weekday()) + start_of_last_week = end_of_last_week - timedelta(days=7) + stmt = stmt.where( + db.or_( + db.and_( + Recording.meeting_date >= start_of_last_week, + Recording.meeting_date < end_of_last_week + ), + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) >= start_of_last_week, + db.func.date(Recording.created_at) < end_of_last_week + ) + ) + ) + elif date_filter == 'thismonth': + today = datetime.now().date() + start_of_month = today.replace(day=1) + stmt = stmt.where( + db.or_( + Recording.meeting_date >= start_of_month, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) >= start_of_month + ) + ) + ) + elif date_filter == 'lastmonth': + today = datetime.now().date() + first_day_this_month = today.replace(day=1) + last_day_last_month = first_day_this_month - timedelta(days=1) + first_day_last_month = last_day_last_month.replace(day=1) + stmt = stmt.where( + db.or_( + db.and_( + Recording.meeting_date >= first_day_last_month, + Recording.meeting_date <= last_day_last_month + ), + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) >= first_day_last_month, + db.func.date(Recording.created_at) <= last_day_last_month + ) + ) + ) + elif re.match(r'^\d{4}-\d{2}-\d{2}$', date_filter): + # Specific date format YYYY-MM-DD + target_date = datetime.strptime(date_filter, '%Y-%m-%d').date() + stmt = stmt.where( + db.or_( + db.func.date(Recording.meeting_date) == target_date, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) == target_date + ) + ) + ) + elif re.match(r'^\d{4}-\d{2}$', date_filter): + # Month format YYYY-MM + year, month = map(int, date_filter.split('-')) + stmt = stmt.where( + db.or_( + db.and_( + db.extract('year', Recording.meeting_date) == year, + db.extract('month', Recording.meeting_date) == month + ), + db.and_( + Recording.meeting_date.is_(None), + db.extract('year', Recording.created_at) == year, + db.extract('month', Recording.created_at) == month + ) + ) + ) + elif re.match(r'^\d{4}$', date_filter): + # Year format YYYY + year = int(date_filter) + stmt = stmt.where( + db.or_( + db.extract('year', Recording.meeting_date) == year, + db.and_( + Recording.meeting_date.is_(None), + db.extract('year', Recording.created_at) == year + ) + ) + ) + + # Apply date range filters + if date_from_filters and date_from_filters[0]: + try: + date_from = datetime.strptime(date_from_filters[0], '%Y-%m-%d').date() + stmt = stmt.where( + db.or_( + Recording.meeting_date >= date_from, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) >= date_from + ) + ) + ) + except ValueError: + pass # Invalid date format, ignore + + if date_to_filters and date_to_filters[0]: + try: + date_to = datetime.strptime(date_to_filters[0], '%Y-%m-%d').date() + stmt = stmt.where( + db.or_( + Recording.meeting_date <= date_to, + db.and_( + Recording.meeting_date.is_(None), + db.func.date(Recording.created_at) <= date_to + ) + ) + ) + except ValueError: + pass # Invalid date format, ignore + + # Apply tag filters + if tag_filters: + # Join with tags table and filter by tag names + tag_conditions = [] + for tag_filter in tag_filters: + # Replace underscores back to spaces for matching + tag_name = tag_filter.replace('_', ' ') + tag_conditions.append(Tag.name.ilike(f'%{tag_name}%')) + + stmt = stmt.join(RecordingTag).join(Tag).where(db.or_(*tag_conditions)) + + # Apply speaker filters + if speaker_filters: + speaker_conditions = [] + for speaker_filter in speaker_filters: + # Replace underscores back to spaces for matching + speaker_name = speaker_filter.replace('_', ' ') + speaker_conditions.append(Recording.participants.ilike(f'%{speaker_name}%')) + stmt = stmt.where(db.or_(*speaker_conditions)) + + # Apply text search + if text_query: + from src.models.sharing import SharedRecordingState + + # Search in user-specific notes: + # - For owned recordings: search Recording.notes + # - For shared recordings: search SharedRecordingState.personal_notes + + text_conditions = [ + Recording.title.ilike(f'%{text_query}%'), + Recording.participants.ilike(f'%{text_query}%'), + Recording.transcription.ilike(f'%{text_query}%'), + # Search owner's notes for owned recordings + db.and_( + Recording.user_id == current_user.id, + Recording.notes.ilike(f'%{text_query}%') + ) + ] + + # Add search for personal notes in shared recordings + # Use a subquery to check if personal_notes match + shared_notes_subq = select(SharedRecordingState.recording_id).where( + db.and_( + SharedRecordingState.user_id == current_user.id, + SharedRecordingState.personal_notes.ilike(f'%{text_query}%') + ) + ).scalar_subquery() + + text_conditions.append(Recording.id.in_(shared_notes_subq)) + + stmt = stmt.where(db.or_(*text_conditions)) + + # Apply ordering based on sort_by parameter + if sort_by == 'meeting_date': + # Sort by meeting_date first, fall back to created_at if no meeting_date + stmt = stmt.order_by( + db.case( + (Recording.meeting_date.is_not(None), Recording.meeting_date), + else_=db.func.date(Recording.created_at) + ).desc(), + Recording.created_at.desc() + ) + else: + # Default: sort by created_at (upload/processing date) + stmt = stmt.order_by(Recording.created_at.desc()) + + # Get total count for pagination info + count_stmt = select(db.func.count()).select_from(stmt.subquery()) + total_count = db.session.execute(count_stmt).scalar() + + # Apply pagination + offset = (page - 1) * per_page + stmt = stmt.offset(offset).limit(per_page) + + # Execute query + recordings = db.session.execute(stmt).scalars().all() + + # Enrich recordings with sharing metadata + enriched_recordings = [] + for recording in recordings: + rec_dict = recording.to_list_dict(viewer_user=current_user) + + # Add sharing metadata + is_owner = recording.user_id == current_user.id + rec_dict['is_owner'] = is_owner + + # Get per-user status (owner uses Recording fields, recipients use SharedRecordingState) + user_inbox, user_highlighted = get_user_recording_status(recording, current_user) + rec_dict['is_inbox'] = user_inbox + rec_dict['is_highlighted'] = user_highlighted + + # Add edit permission info (uses has_recording_access which checks group admin status) + rec_dict['can_edit'] = has_recording_access(recording, current_user, require_edit=True) + + # Add delete permission info (only owner can delete) + rec_dict['can_delete'] = is_owner and (USERS_CAN_DELETE or current_user.is_admin) + + if not is_owner: + # This is a shared recording - get owner info and share permissions + owner = User.query.get(recording.user_id) + rec_dict['owner_username'] = owner.username if owner else "Unknown" + rec_dict['is_shared'] = True + # Don't show outgoing share count for recordings you don't own + rec_dict['shared_with_count'] = 0 + rec_dict['public_share_count'] = 0 + + # Get share permissions + share = InternalShare.query.filter_by( + recording_id=recording.id, + shared_with_user_id=current_user.id + ).first() + + if share: + rec_dict['share_info'] = { + 'share_id': share.id, + 'owner_username': owner.username if owner else "Unknown", + 'can_edit': share.can_edit, + 'can_reshare': share.can_reshare, + 'shared_at': share.created_at.isoformat() + } + else: + # Fallback if share record not found (shouldn't happen) + rec_dict['share_info'] = { + 'can_edit': False, + 'can_reshare': False + } + else: + rec_dict['is_shared'] = False + + # Check if recording has group tags (among visible tags) + visible_tags = recording.get_visible_tags(current_user) + has_group_tags = any(tag.is_group_tag for tag in visible_tags) + rec_dict['has_group_tags'] = has_group_tags + + enriched_recordings.append(rec_dict) + + # Calculate pagination metadata + total_pages = (total_count + per_page - 1) // per_page + has_next = page < total_pages + has_prev = page > 1 + + return jsonify({ + 'recordings': enriched_recordings, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total_count, + 'total_pages': total_pages, + 'has_next': has_next, + 'has_prev': has_prev + } + }) + + except Exception as e: + current_app.logger.error(f"Error fetching paginated recordings: {e}") + return jsonify({'error': str(e)}), 500 + + +@recordings_bp.route('/save', methods=['POST']) +@login_required +def save_metadata(): + """Save recording metadata (title, participants, notes, summary, etc.).""" + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + recording_id = data.get('id') + if not recording_id: + return jsonify({'error': 'No recording ID provided'}), 400 + + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check if user has at least view access + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + # Handle notes separately - no edit permission required (user-specific) + if 'notes' in data: + if recording.user_id == current_user.id: + # Owner saves to Recording.notes + recording.notes = sanitize_html(data['notes']) if data['notes'] else data['notes'] + else: + # Shared user saves to personal_notes (requires SharedRecordingState) + from src.models.sharing import SharedRecordingState + state = SharedRecordingState.query.filter_by( + recording_id=recording.id, + user_id=current_user.id + ).first() + + if not state: + # Create SharedRecordingState if it doesn't exist + state = SharedRecordingState( + recording_id=recording.id, + user_id=current_user.id, + is_inbox=True, + is_highlighted=False + ) + db.session.add(state) + + state.personal_notes = sanitize_html(data['notes']) if data['notes'] else data['notes'] + + # Determine if any fields requiring edit permission are being updated + edit_fields = ['title', 'participants', 'summary', 'meeting_date'] + requires_edit = any(field in data for field in edit_fields) + + # If edit fields are present, check for edit permission + if requires_edit and not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to edit this recording'}), 403 + + # Update fields requiring edit permission + if requires_edit: + if 'title' in data: + recording.title = data['title'] + if 'participants' in data: + recording.participants = data['participants'] + if 'summary' in data: + recording.summary = sanitize_html(data['summary']) if data['summary'] else data['summary'] + if 'meeting_date' in data: + try: + date_str = data['meeting_date'] + if date_str: + # Try to parse as full ISO datetime first + try: + recording.meeting_date = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except (ValueError, AttributeError): + # Fall back to date-only format, preserve existing time if available + parsed_date = datetime.strptime(date_str, '%Y-%m-%d') + if recording.meeting_date: + # Preserve existing time + existing_time = recording.meeting_date.time() + recording.meeting_date = datetime.combine(parsed_date.date(), existing_time) + else: + # No existing time, use the parsed date with midnight time + recording.meeting_date = parsed_date + else: + recording.meeting_date = None + except (ValueError, TypeError) as e: + current_app.logger.warning(f"Could not parse meeting_date '{data.get('meeting_date')}': {e}") + + # Handle per-user status fields (only requires view permission) + if 'is_inbox' in data or 'is_highlighted' in data: + set_user_recording_status( + recording, + current_user, + is_inbox=data.get('is_inbox'), + is_highlighted=data.get('is_highlighted') + ) + + db.session.commit() + + # Re-export the recording if auto-export is enabled and editable fields were changed + if requires_edit: + export_recording(recording_id) + + # Return recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({'success': True, 'recording': recording_dict}) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error saving metadata for recording {data.get('id')}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred while saving.'}), 500 + + +@recordings_bp.route('/recording//update_transcription', methods=['POST']) +@login_required +def update_transcription(recording_id): + """Updates the transcription content for a recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to edit this recording'}), 403 + + data = request.json + new_transcription = data.get('transcription') + + if new_transcription is None: + return jsonify({'error': 'No transcription data provided'}), 400 + + # The incoming data could be a JSON string (from ASR edit) or plain text + recording.transcription = new_transcription + + # Optional: If the transcription changes, we might want to indicate that the summary is outdated. + # For now, we'll just save the transcript. A "regenerate summary" button could be a good follow-up. + + db.session.commit() + current_app.logger.info(f"Transcription for recording {recording_id} was updated.") + + # Re-export the recording if auto-export is enabled + export_recording(recording_id) + + # Return recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({'success': True, 'message': 'Transcription updated successfully.', 'recording': recording_dict}) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating transcription for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred while updating the transcription.'}), 500 + +# Toggle inbox status endpoint + + +@recordings_bp.route('/recording//toggle_inbox', methods=['POST']) +@login_required +def toggle_inbox(recording_id): + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Only require view access (not edit) - users can manage their own inbox status + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to view this recording'}), 403 + + # Get current status and toggle it + current_inbox, current_highlighted = get_user_recording_status(recording, current_user) + new_inbox, new_highlighted = set_user_recording_status(recording, current_user, is_inbox=not current_inbox) + + return jsonify({'success': True, 'is_inbox': new_inbox}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error toggling inbox status for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + +# Toggle highlighted status endpoint + + +@recordings_bp.route('/recording//toggle_highlight', methods=['POST']) +@login_required +def toggle_highlight(recording_id): + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Only require view access (not edit) - users can manage their own highlight status + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to view this recording'}), 403 + + # Get current status and toggle it + current_inbox, current_highlighted = get_user_recording_status(recording, current_user) + new_inbox, new_highlighted = set_user_recording_status(recording, current_user, is_highlighted=not current_highlighted) + + return jsonify({'success': True, 'is_highlighted': new_highlighted}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error toggling highlighted status for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + + + +@recordings_bp.route('/upload', methods=['POST']) +@login_required +def upload_file(): + try: + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + original_filename = file.filename + safe_filename = secure_filename(original_filename) + filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{safe_filename}") + + # Get original file size + file.seek(0, os.SEEK_END) + original_file_size = file.tell() + file.seek(0) + + # Check size limit before saving - only enforce if chunking is disabled or connector handles it + max_content_length = current_app.config.get('MAX_CONTENT_LENGTH') + + # Get connector specifications for chunking decisions + connector_specs = None + if USE_NEW_TRANSCRIPTION_ARCHITECTURE: + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.get_active_connector() + if connector: + connector_specs = connector.specifications + except Exception as e: + current_app.logger.warning(f"Could not get connector specs for upload: {e}") + + # Skip size check if chunking is enabled (app-level or connector handles internally) + should_enforce_size_limit = True + if chunking_service: + from src.audio_chunking import get_effective_chunking_config + chunking_config = get_effective_chunking_config(connector_specs) + if chunking_config.enabled or chunking_config.source == 'connector_internal': + should_enforce_size_limit = False + if chunking_config.source == 'connector_internal': + current_app.logger.info(f"Connector handles chunking internally - skipping {original_file_size/1024/1024:.1f}MB size limit check") + elif chunking_config.mode == 'size': + current_app.logger.info(f"Size-based chunking enabled ({chunking_config.limit_value}MB, source={chunking_config.source}) - skipping {original_file_size/1024/1024:.1f}MB size limit check") + else: + current_app.logger.info(f"Duration-based chunking enabled ({chunking_config.limit_value}s, source={chunking_config.source}) - skipping {original_file_size/1024/1024:.1f}MB size limit check") + + if should_enforce_size_limit and max_content_length and original_file_size > max_content_length: + raise RequestEntityTooLarge() + + file.save(filepath) + current_app.logger.info(f"File saved to {filepath}") + + # Compute file hash on the ORIGINAL upload before any conversion/compression. + # Lossy re-encoding (e.g. FLAC→MP3) produces different bytes each run, + # so hashing after conversion would miss duplicates. + file_hash = None + duplicate_warning = None + try: + file_hash = compute_file_sha256(filepath) + existing = Recording.query.filter_by( + user_id=current_user.id, file_hash=file_hash + ).first() + if existing: + duplicate_warning = { + 'existing_recording_id': existing.id, + 'existing_title': existing.title, + 'existing_created_at': existing.created_at.isoformat() if existing.created_at else None + } + current_app.logger.info( + f"Duplicate file detected for user {current_user.id}: " + f"hash={file_hash[:12]}... matches recording {existing.id}" + ) + except Exception as e: + current_app.logger.warning(f"Could not compute file hash: {e}") + + # --- Convert files only when chunking is needed --- + filename_lower = original_filename.lower() + + # Check if chunking will be needed for this file (uses connector-aware logic) + needs_chunking_for_processing = bool( + chunking_service and + chunking_service.needs_chunking(filepath, USE_ASR_ENDPOINT, connector_specs) + ) + + # Probe once and use shared conversion utility + # Scale timeout based on file size — large files (especially MP4 with moov at end) need more time + file_size_mb = os.path.getsize(filepath) / (1024 * 1024) + probe_timeout = max(10, min(60, int(file_size_mb / 10))) # 10s min, scales ~1s per 10MB, 60s max + codec_info = None + try: + codec_info = get_codec_info(filepath, timeout=probe_timeout) + current_app.logger.info( + f"Detected codec for {original_filename}: " + f"audio_codec={codec_info.get('audio_codec')}, " + f"has_video={codec_info.get('has_video', False)}" + ) + except FFProbeError as e: + current_app.logger.warning(f"Failed to probe {original_filename} (timeout={probe_timeout}s): {e}. Will attempt conversion.") + codec_info = None + + # Video retention/passthrough: skip conversion for videos, processing pipeline handles extraction + has_video = codec_info.get('has_video', False) if codec_info else False + + # Fallback: if probe failed but VIDEO_RETENTION or VIDEO_PASSTHROUGH_ASR is on, check file extension + # to avoid silently discarding video from files we couldn't probe + if codec_info is None and (VIDEO_RETENTION or VIDEO_PASSTHROUGH_ASR) and not has_video: + video_extensions = {'.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v', '.wmv', '.flv', '.ts', '.mts'} + file_ext = os.path.splitext(original_filename)[1].lower() + if file_ext in video_extensions: + has_video = True + current_app.logger.info( + f"Probe failed but file extension '{file_ext}' indicates video — " + f"treating as video for {'VIDEO_PASSTHROUGH_ASR' if VIDEO_PASSTHROUGH_ASR else 'VIDEO_RETENTION'}" + ) + if (VIDEO_RETENTION or VIDEO_PASSTHROUGH_ASR) and has_video: + current_app.logger.info(f"Video {'passthrough' if VIDEO_PASSTHROUGH_ASR else 'retention'}: keeping original video, skipping conversion") + else: + # Use shared conversion utility - handles ALL conversion needs (codec conversion + compression) + try: + result = convert_if_needed( + filepath, + original_filename=original_filename, + codec_info=codec_info, + needs_chunking=needs_chunking_for_processing, + is_asr_endpoint=USE_ASR_ENDPOINT, + delete_original=True, + connector_specs=connector_specs # Pass connector specs for codec restrictions + ) + filepath = result.output_path + + # Log what happened + if result.was_converted: + current_app.logger.info(f"File converted: {result.original_codec} -> {result.final_codec}") + if result.was_compressed: + current_app.logger.info(f"File compressed: {result.size_reduction_percent:.1f}% size reduction") + + except FFmpegNotFoundError as e: + current_app.logger.error(f"FFmpeg not found: {e}") + return jsonify({'error': 'Audio conversion tool (FFmpeg) not found on server.'}), 500 + except FFmpegError as e: + current_app.logger.error(f"FFmpeg conversion failed for {filepath}: {e}") + return jsonify({'error': f'Failed to convert audio file: {str(e)}'}), 500 + + # Get final file size (of original or converted file) + final_file_size = os.path.getsize(filepath) + + # (file_hash and duplicate_warning already computed above, before conversion) + + # Determine MIME type of the final file + mime_type, _ = mimetypes.guess_type(filepath) + current_app.logger.info(f"Final MIME type: {mime_type} for file {filepath}") + + # Get notes from the form + notes = request.form.get('notes') + + # Get file's lastModified timestamp from client (milliseconds since epoch) + file_last_modified = request.form.get('file_last_modified') + + # Get selected tags if provided (multiple tags support) + selected_tags = [] + tag_index = 0 + while True: + tag_id_key = f'tag_ids[{tag_index}]' + tag_id = request.form.get(tag_id_key) + if not tag_id: + break + + # Check if tag belongs to user OR is a group tag where user is a member + tag = Tag.query.filter_by(id=tag_id).first() + if tag: + # Allow tag if it's user's own tag OR it's a group tag where user is a member + if tag.user_id == current_user.id or (tag.group_id and GroupMembership.query.filter_by(group_id=tag.group_id, user_id=current_user.id).first()): + selected_tags.append(tag) + tag_index += 1 + + # For backward compatibility with single tag uploads + if not selected_tags: + single_tag_id = request.form.get('tag_id') + if single_tag_id: + # Check if tag belongs to user OR is a group tag where user is a member + tag = Tag.query.filter_by(id=single_tag_id).first() + if tag and (tag.user_id == current_user.id or (tag.group_id and GroupMembership.query.filter_by(group_id=tag.group_id, user_id=current_user.id).first())): + selected_tags.append(tag) + + # Get folder_id if provided + selected_folder = None + folder_id = request.form.get('folder_id') + if folder_id: + folder = Folder.query.filter_by(id=folder_id).first() + if folder: + # Allow folder if it's user's own folder OR it's a group folder where user is a member + if folder.user_id == current_user.id or (folder.group_id and GroupMembership.query.filter_by(group_id=folder.group_id, user_id=current_user.id).first()): + selected_folder = folder + + # Get ASR advanced options if provided + language = request.form.get('language', '') + min_speakers = request.form.get('min_speakers') or None + max_speakers = request.form.get('max_speakers') or None + hotwords = request.form.get('hotwords', '').strip() or None + initial_prompt = request.form.get('initial_prompt', '').strip() or None + + # Convert to int if provided + if min_speakers: + try: + min_speakers = int(min_speakers) + except (ValueError, TypeError): + min_speakers = None + if max_speakers: + try: + max_speakers = int(max_speakers) + except (ValueError, TypeError): + max_speakers = None + + # Apply precedence hierarchy: user input > tag defaults > folder defaults > environment variables > user defaults > auto-detect + + # Apply folder defaults first (lower priority than tags) + if selected_folder and not selected_tags: + # Only apply folder defaults if no tags are selected (tags take priority) + if not language and selected_folder.default_language: + language = selected_folder.default_language + if min_speakers is None and selected_folder.default_min_speakers: + min_speakers = selected_folder.default_min_speakers + if max_speakers is None and selected_folder.default_max_speakers: + max_speakers = selected_folder.default_max_speakers + if not hotwords and selected_folder.default_hotwords: + hotwords = selected_folder.default_hotwords + if not initial_prompt and selected_folder.default_initial_prompt: + initial_prompt = selected_folder.default_initial_prompt + + # Apply tag defaults if tags are selected and values are not explicitly provided by user + # Use first tag's defaults (highest priority - overrides folder) + if selected_tags: + first_tag = selected_tags[0] + if not language and first_tag.default_language: + language = first_tag.default_language + if min_speakers is None and first_tag.default_min_speakers: + min_speakers = first_tag.default_min_speakers + if max_speakers is None and first_tag.default_max_speakers: + max_speakers = first_tag.default_max_speakers + if not hotwords and first_tag.default_hotwords: + hotwords = first_tag.default_hotwords + if not initial_prompt and first_tag.default_initial_prompt: + initial_prompt = first_tag.default_initial_prompt + + # Apply environment variable defaults if still no values are set + if min_speakers is None and ASR_MIN_SPEAKERS: + try: + min_speakers = int(ASR_MIN_SPEAKERS) + except (ValueError, TypeError): + min_speakers = None + if max_speakers is None and ASR_MAX_SPEAKERS: + try: + max_speakers = int(ASR_MAX_SPEAKERS) + except (ValueError, TypeError): + max_speakers = None + + # Fall back to user defaults if still not set + if not language and current_user.transcription_language: + language = current_user.transcription_language + current_app.logger.info(f"Using user's default transcription language: {language}") + if not hotwords and current_user.transcription_hotwords: + hotwords = current_user.transcription_hotwords + if not initial_prompt and current_user.transcription_initial_prompt: + initial_prompt = current_user.transcription_initial_prompt + + # Create initial database entry + now = datetime.utcnow() + + # Determine meeting_date: prefer client-provided lastModified, then file metadata, then current time + meeting_date = None + + # First try client-provided file lastModified (most reliable for uploads) + if file_last_modified: + try: + # JavaScript lastModified is in milliseconds since epoch + timestamp_ms = int(file_last_modified) + meeting_date = datetime.fromtimestamp(timestamp_ms / 1000) + current_app.logger.info(f"Using client file lastModified: {meeting_date}") + except (ValueError, TypeError, OSError) as e: + current_app.logger.warning(f"Could not parse file_last_modified '{file_last_modified}': {e}") + + # Fall back to file metadata (creation_time, date tags, etc.) + if not meeting_date: + meeting_date = get_creation_date(filepath, use_file_mtime=False) + if meeting_date: + current_app.logger.info(f"Using file metadata creation date: {meeting_date}") + + # Final fallback to current time + if not meeting_date: + meeting_date = now + current_app.logger.debug("No file date available, using current time") + + recording = Recording( + audio_path=filepath, + original_filename=original_filename, + title=f"Recording - {original_filename}", + file_size=final_file_size, + status='PENDING', + meeting_date=meeting_date, + user_id=current_user.id, + mime_type=mime_type, + notes=notes, + folder_id=selected_folder.id if selected_folder else None, + processing_source='upload', # Track that this was manually uploaded + file_hash=file_hash + ) + db.session.add(recording) + audit_access('upload', 'recording', recording.id, details={'filename': original_filename, 'size_bytes': original_file_size}) + db.session.commit() # commit recording + audit en une transaction atomique + + # Add tags to recording if selected (preserve order) + for order, tag in enumerate(selected_tags, 1): + new_association = RecordingTag( + recording_id=recording.id, + tag_id=tag.id, + order=order, + added_at=datetime.utcnow() + ) + db.session.add(new_association) + + if selected_tags: + db.session.commit() + tag_names = [tag.name for tag in selected_tags] + current_app.logger.info(f"Added {len(selected_tags)} tags to recording {recording.id}: {', '.join(tag_names)}") + + current_app.logger.info(f"Initial recording record created with ID: {recording.id}") + + # --- Queue transcription job --- + first_tag = selected_tags[0] if selected_tags else None + job_params = { + 'language': language, + 'min_speakers': min_speakers, + 'max_speakers': max_speakers, + 'tag_id': first_tag.id if first_tag else None, + 'hotwords': hotwords, + 'initial_prompt': initial_prompt, + } + + current_app.logger.info(f"Queueing transcription for recording {recording.id} with params: {job_params}") + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type='transcribe', + params=job_params, + is_new_upload=True + ) + current_app.logger.info(f"Transcription job queued for recording ID: {recording.id}") + + response_data = recording.to_dict(viewer_user=current_user) + if duplicate_warning: + response_data['duplicate_warning'] = duplicate_warning + return jsonify(response_data), 202 + + except RequestEntityTooLarge: + max_size_mb = current_app.config['MAX_CONTENT_LENGTH'] / (1024 * 1024) + current_app.logger.warning(f"Upload failed: File too large (>{max_size_mb}MB)") + return jsonify({ + 'error': f'File too large. Maximum size is {max_size_mb:.0f} MB.', + 'max_size_mb': max_size_mb + }), 413 + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error during file upload: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred during upload.'}), 500 + + +@recordings_bp.route('/api/recordings/incognito', methods=['POST']) +@login_required +def upload_incognito(): + """ + Process audio in incognito mode - no database storage. + Returns transcript/summary directly in response. + + This endpoint is designed for HIPAA-friendly transcription where + audio data is processed but never persisted to the database. + Results are returned directly and only stored client-side in sessionStorage. + """ + # Check if incognito mode is enabled + if not ENABLE_INCOGNITO_MODE: + return jsonify({'error': 'Incognito mode is not enabled on this server'}), 403 + + import tempfile + from datetime import datetime + from src.tasks.processing import transcribe_incognito, generate_incognito_summary + + temp_filepath = None + + try: + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + original_filename = file.filename + safe_filename = secure_filename(original_filename) + + # Get file size + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + # Check size limit + max_content_length = current_app.config.get('MAX_CONTENT_LENGTH') + if max_content_length and file_size > max_content_length: + max_size_mb = max_content_length / (1024 * 1024) + return jsonify({ + 'error': f'File too large. Maximum size is {max_size_mb:.0f} MB.', + 'max_size_mb': max_size_mb + }), 413 + + # Save to temp file - use secure temp directory + with tempfile.NamedTemporaryFile(delete=False, suffix=f'_{safe_filename}') as tmp: + temp_filepath = tmp.name + file.save(temp_filepath) + current_app.logger.info(f"[Incognito] Temp file saved: {temp_filepath}") + + # Get optional parameters + # Note: Empty string '' means auto-detect, don't convert to None + language = request.form.get('language', '') + min_speakers = request.form.get('min_speakers') + max_speakers = request.form.get('max_speakers') + auto_summarize = request.form.get('auto_summarize', 'false').lower() == 'true' + + # Convert to int if provided + if min_speakers: + try: + min_speakers = int(min_speakers) + except (ValueError, TypeError): + min_speakers = None + if max_speakers: + try: + max_speakers = int(max_speakers) + except (ValueError, TypeError): + max_speakers = None + + # Log only metadata - NEVER log content for HIPAA compliance + current_app.logger.info(f"[Incognito] Processing request from user {current_user.id}: " + f"filename={original_filename}, size={file_size/1024/1024:.2f}MB, " + f"language={language}, auto_summarize={auto_summarize}") + + # Perform transcription synchronously (no database operations) + result = transcribe_incognito( + filepath=temp_filepath, + original_filename=original_filename, + language=language, + min_speakers=min_speakers, + max_speakers=max_speakers, + user=current_user + ) + + if result.get('error'): + current_app.logger.error(f"[Incognito] Transcription failed: {result['error']}") + return jsonify({ + 'incognito': True, + 'error': result['error'] + }), 500 + + # Optionally generate summary + summary = None + if auto_summarize and result.get('transcription'): + current_app.logger.info(f"[Incognito] Auto-summarize requested, generating summary...") + summary = generate_incognito_summary(result['transcription'], current_user) + + # Build response + # Render markdown to HTML for summary display + summary_html = None + if summary: + summary_html = md_to_html(summary) + + response_data = { + 'incognito': True, + 'transcription': result.get('transcription'), + 'summary': summary, + 'summary_html': summary_html, + 'title': result.get('title', 'Incognito Recording'), + 'audio_duration_seconds': result.get('audio_duration_seconds'), + 'processing_time_seconds': result.get('processing_time_seconds'), + 'created_at': datetime.utcnow().isoformat() + 'Z', + 'original_filename': original_filename, + 'file_size': file_size + } + + current_app.logger.info(f"[Incognito] Request completed successfully for user {current_user.id}") + + return jsonify(response_data), 200 + + except RequestEntityTooLarge: + max_size_mb = current_app.config['MAX_CONTENT_LENGTH'] / (1024 * 1024) + current_app.logger.warning(f"[Incognito] Upload failed: File too large (>{max_size_mb}MB)") + return jsonify({ + 'incognito': True, + 'error': f'File too large. Maximum size is {max_size_mb:.0f} MB.', + 'max_size_mb': max_size_mb + }), 413 + + except Exception as e: + current_app.logger.error(f"[Incognito] Error during processing: {e}", exc_info=True) + return jsonify({ + 'incognito': True, + 'error': 'An unexpected error occurred during processing.' + }), 500 + + finally: + # CRITICAL: Always delete temp file for HIPAA compliance + if temp_filepath and os.path.exists(temp_filepath): + try: + os.remove(temp_filepath) + current_app.logger.info(f"[Incognito] Temp file deleted: {temp_filepath}") + except Exception as cleanup_error: + current_app.logger.error(f"[Incognito] Failed to delete temp file {temp_filepath}: {cleanup_error}") + + +@recordings_bp.route('/api/recordings/incognito/chat', methods=['POST']) +@login_required +def chat_incognito(): + """ + Chat with an incognito recording's transcription. + Since incognito recordings don't exist in the database, the transcription + is passed directly in the request. + """ + # Check if incognito mode is enabled + if not ENABLE_INCOGNITO_MODE: + return jsonify({'error': 'Incognito mode is not enabled on this server'}), 403 + + from src.tasks.processing import format_transcription_for_llm + + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + transcription = data.get('transcription') + user_message = data.get('message') + message_history = data.get('message_history', []) + participants = data.get('participants', '') + notes = data.get('notes', '') + + if not transcription: + return jsonify({'error': 'No transcription provided'}), 400 + if not user_message: + return jsonify({'error': 'No message provided'}), 400 + + # Check if chat client is available + if chat_client is None: + return jsonify({'error': 'Chat service is not available (chat client not configured)'}), 503 + + # Prepare the system prompt with the transcription + user_chat_output_language = current_user.output_language if current_user.is_authenticated else None + + language_instruction = "Réponds toujours en français." + if user_chat_output_language: + language_instruction = f"Réponds en {user_chat_output_language}." + + user_name = current_user.name if current_user.is_authenticated and current_user.name else "l'utilisateur" + user_title = current_user.job_title if current_user.is_authenticated and current_user.job_title else None + user_company = current_user.company if current_user.is_authenticated and current_user.company else None + + formatted_transcription = format_transcription_for_llm(transcription) + + # Get configurable transcript length limit for chat + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + chat_transcript = formatted_transcription + else: + chat_transcript = formatted_transcription[:transcript_limit] + + user_context = f", {user_title} chez {user_company}" if user_title and user_company else "" + + system_prompt = f"""Tu es un assistant expert en analyse de transcriptions audio et de réunions, qui assiste {user_name}{user_context}. {language_instruction} + +Réponds de façon directe et professionnelle. Utilise le formatage Markdown (## titres, **gras**, listes -). + +**Participants :** +{participants or "Non précisé"} + +**Transcription :** +<> +{chat_transcript or "Transcription non disponible."} +<> + +**Notes et contexte :** +{notes or "Aucune note."} + +Note : Cet enregistrement est en mode incognito — aucune donnée n'est conservée sur le serveur. +""" + + # Prepare messages array with system prompt and conversation history + messages = [{"role": "system", "content": system_prompt}] + if message_history: + messages.extend(message_history) + messages.append({"role": "user", "content": user_message}) + + # Get model info + chat_model = os.environ.get('TEXT_MODEL_NAME', os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')) + user_id = current_user.id + + current_app.logger.info(f"[Incognito Chat] User {user_id} sending message") + + def generate(): + """Stream the chat response.""" + try: + response = chat_client.chat.completions.create( + model=chat_model, + messages=messages, + temperature=0.7, + stream=True + ) + + for chunk in response: + if chunk.choices and len(chunk.choices) > 0: + delta = chunk.choices[0].delta + if hasattr(delta, 'content') and delta.content: + yield f"data: {json.dumps({'content': delta.content})}\n\n" + + yield "data: [DONE]\n\n" + + except Exception as e: + current_app.logger.error(f"[Incognito Chat] Error during streaming: {e}") + # Provide more helpful error message for connection issues + error_msg = str(e).lower() + if 'connection' in error_msg or 'connect' in error_msg or 'refused' in error_msg: + yield f"data: {json.dumps({'error': 'Could not connect to LLM server. Please check that your LLM service is running.'})}\n\n" + else: + yield f"data: {json.dumps({'error': str(e)})}\n\n" + + return Response(generate(), mimetype='text/event-stream') + + except Exception as e: + current_app.logger.error(f"[Incognito Chat] Error: {e}", exc_info=True) + # Provide more helpful error message for connection issues + error_msg = str(e).lower() + if 'connection' in error_msg or 'connect' in error_msg or 'refused' in error_msg: + return jsonify({'error': 'Could not connect to LLM server. Please check that your LLM service is running.'}), 503 + return jsonify({'error': 'An error occurred during chat'}), 500 + + +@recordings_bp.route('/api/recordings/incognito/summary', methods=['POST']) +@login_required +def generate_incognito_summary_endpoint(): + """ + Generate summary for an incognito recording on demand. + Since incognito recordings don't exist in the database, the transcription + is passed directly in the request. + """ + # Check if incognito mode is enabled + if not ENABLE_INCOGNITO_MODE: + return jsonify({'error': 'Incognito mode is not enabled on this server'}), 403 + + from src.tasks.processing import generate_incognito_summary + from src.utils import md_to_html + + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + transcription = data.get('transcription') + if not transcription: + return jsonify({'error': 'No transcription provided'}), 400 + + # Check if LLM client is available + if client is None: + return jsonify({'error': 'Summary service is not available (LLM client not configured)'}), 503 + + current_app.logger.info(f"[Incognito Summary] User {current_user.id} requesting summary generation") + + # Generate summary using existing function + summary = generate_incognito_summary(transcription, current_user) + + if summary: + summary_html = md_to_html(summary) + return jsonify({ + 'summary': summary, + 'summary_html': summary_html + }) + else: + return jsonify({'error': 'Failed to generate summary. Please check that your LLM service is running.'}), 503 + + except Exception as e: + current_app.logger.error(f"[Incognito Summary] Error: {e}", exc_info=True) + # Provide more helpful error message for connection issues + error_msg = str(e).lower() + if 'connection' in error_msg or 'connect' in error_msg or 'refused' in error_msg: + return jsonify({'error': 'Could not connect to LLM server. Please check that your LLM service is running.'}), 503 + return jsonify({'error': f'Failed to generate summary: {str(e)}'}), 500 + + +# Status Endpoint + + +@recordings_bp.route('/recording/', methods=['DELETE']) +@login_required +def delete_recording(recording_id): + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check if the recording belongs to the current user + if recording.user_id and recording.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to delete this recording'}), 403 + + # Check deletion permissions (admin-only if USERS_CAN_DELETE is false) + if not USERS_CAN_DELETE and not current_user.is_admin: + return jsonify({'error': 'Only administrators can delete recordings'}), 403 + + # Capture details before deletion for audit + _audit_details = {'title': recording.title} + + # Delete the audio file first + try: + if recording.audio_path and os.path.exists(recording.audio_path): + os.remove(recording.audio_path) + current_app.logger.info(f"Deleted audio file: {recording.audio_path}") + except Exception as e: + current_app.logger.error(f"Error deleting audio file {recording.audio_path}: {e}") + + # Log embeddings cleanup for Inquire Mode if enabled + if ENABLE_INQUIRE_MODE: + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording_id).count() + if chunk_count > 0: + current_app.logger.info(f"Deleting {chunk_count} transcript chunks with embeddings for recording {recording_id}") + + # Delete associated records with NOT NULL recording_id constraints + from src.models.speaker_snippet import SpeakerSnippet + deleted_snippets = SpeakerSnippet.query.filter_by(recording_id=recording_id).delete() + if deleted_snippets > 0: + current_app.logger.info(f"Deleted {deleted_snippets} speaker snippets for recording {recording_id}") + + from src.models.processing_job import ProcessingJob + deleted_jobs = ProcessingJob.query.filter_by(recording_id=recording_id).delete() + if deleted_jobs > 0: + current_app.logger.info(f"Deleted {deleted_jobs} processing jobs for recording {recording_id}") + + # Delete the database record (cascade will handle chunks/embeddings) + db.session.delete(recording) + db.session.commit() + current_app.logger.info(f"Deleted recording record ID: {recording_id}") + + # Audit AFTER successful deletion + audit_delete('recording', recording_id, details=_audit_details) + db.session.commit() + + if ENABLE_INQUIRE_MODE and chunk_count > 0: + current_app.logger.info(f"Successfully deleted embeddings and chunks for recording {recording_id}") + + # Mark the export file as deleted + mark_export_as_deleted(recording_id) + + # Clean up orphaned speakers (run after successful deletion) + # This is a best-effort cleanup; failures are logged but don't affect the delete operation + try: + from src.services.speaker_cleanup import cleanup_orphaned_speakers + speaker_stats = cleanup_orphaned_speakers() + if speaker_stats.get('speakers_deleted', 0) > 0: + current_app.logger.info( + f"Cleaned up {speaker_stats['speakers_deleted']} orphaned speakers after recording deletion" + ) + except Exception as cleanup_error: + # Log the error but don't fail the deletion + current_app.logger.warning(f"Speaker cleanup after recording deletion failed: {cleanup_error}") + + return jsonify({'success': True}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error deleting recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred while deleting.'}), 500 + + +# --- Inbox and Archive Endpoints --- + +@recordings_bp.route('/api/inbox_recordings', methods=['GET']) +@login_required +def get_inbox_recordings(): + """Get recordings that are in the inbox and currently processing.""" + from sqlalchemy import select + try: + stmt = select(Recording).where( + Recording.user_id == current_user.id, + Recording.is_inbox == True, + Recording.status.in_(['PENDING', 'PROCESSING', 'SUMMARIZING']) + ).order_by(Recording.created_at.desc()) + + recordings = db.session.execute(stmt).scalars().all() + return jsonify([recording.to_list_dict(viewer_user=current_user) for recording in recordings]) + except Exception as e: + current_app.logger.error(f"Error fetching inbox recordings: {e}") + return jsonify({'error': str(e)}), 500 + + +@recordings_bp.route('/api/recordings/archived', methods=['GET']) +@login_required +def get_archived_recordings(): + """Get recordings where audio has been deleted but transcription remains.""" + from sqlalchemy import select + try: + search_query = request.args.get('q', '').strip() + + # Find recordings owned by current user where audio_deleted_at is not null + stmt = select(Recording).where( + Recording.user_id == current_user.id, + Recording.audio_deleted_at.is_not(None) + ).order_by(Recording.audio_deleted_at.desc()) + + recordings = db.session.execute(stmt).scalars().all() + return jsonify([recording.to_list_dict(viewer_user=current_user) for recording in recordings]) + except Exception as e: + current_app.logger.error(f"Error fetching archived recordings: {e}") + return jsonify({'error': str(e)}), 500 + + +# --- Recording Detail and Audio Endpoints --- + +@recordings_bp.route('/api/recordings/', methods=['GET']) +@login_required +def get_recording_detail(recording_id): + """Get full details for a specific recording including markdown HTML.""" + try: + recording = db.session.get(Recording, recording_id) + + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check ownership or shared access + has_access = recording.user_id == current_user.id + + # Check if recording has been shared with current user (if internal sharing is enabled) + if not has_access and ENABLE_INTERNAL_SHARING: + share = InternalShare.query.filter_by( + recording_id=recording_id, + shared_with_user_id=current_user.id + ).first() + has_access = share is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + try: + audit_view('recording', recording_id) + db.session.commit() + except Exception: + db.session.rollback() + current_app.logger.warning(f'Audit log failed for recording {recording_id}', exc_info=True) + + # Return full detail with HTML conversion + rec_dict = recording.to_dict(include_html=True, viewer_user=current_user) + + # Add sharing metadata + is_owner = recording.user_id == current_user.id + rec_dict['is_owner'] = is_owner + + # Add edit permission info (uses has_recording_access which checks group admin status) + rec_dict['can_edit'] = has_recording_access(recording, current_user, require_edit=True) + + # Add delete permission info (only owner can delete) + rec_dict['can_delete'] = is_owner and (USERS_CAN_DELETE or current_user.is_admin) + + # Add sharing-related fields + if not is_owner: + # This is a shared recording - get owner info and share permissions + owner = User.query.get(recording.user_id) + rec_dict['owner_username'] = owner.username if owner else "Unknown" + rec_dict['is_shared'] = True + # Don't show outgoing share count for recordings you don't own + rec_dict['shared_with_count'] = 0 + rec_dict['public_share_count'] = 0 + + # Get share permissions + share = InternalShare.query.filter_by( + recording_id=recording.id, + shared_with_user_id=current_user.id + ).first() + + if share: + rec_dict['share_info'] = { + 'share_id': share.id, + 'owner_username': owner.username if owner else "Unknown", + 'can_edit': share.can_edit, + 'can_reshare': share.can_reshare, + 'shared_at': share.created_at.isoformat() + } + else: + # Fallback if share record not found (shouldn't happen) + rec_dict['share_info'] = { + 'can_edit': False, + 'can_reshare': False + } + else: + rec_dict['is_shared'] = False + + # Check if recording has group tags (among visible tags) + visible_tags = recording.get_visible_tags(current_user) + has_group_tags = any(tag.is_group_tag for tag in visible_tags) if visible_tags else False + rec_dict['has_group_tags'] = has_group_tags + + # Enrich with per-user status + enrich_recording_dict_with_user_status(rec_dict, recording, current_user) + + return jsonify(rec_dict) + except Exception as e: + current_app.logger.error(f"Error fetching recording detail: {e}") + return jsonify({'error': str(e)}), 500 + + +@recordings_bp.route('/recording//status', methods=['GET']) +@login_required +def get_recording_status_only(recording_id): + """ + Lightweight endpoint that returns only the status field. + Used for polling during processing/summarization. + Note: Rate limiting exemption is configured at app level. + """ + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to view this recording'}), 403 + + # Return only the status field + return jsonify({'status': recording.status}) + except Exception as e: + current_app.logger.error(f"Error fetching status for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings/batch-status', methods=['POST']) +@login_required +def get_batch_recording_status(): + """ + Batch endpoint to get status for multiple recordings at once. + More efficient than polling individual status endpoints. + + Request body: {"recording_ids": [1, 2, 3]} + Response: {"statuses": {"1": "COMPLETED", "2": "PROCESSING", "3": "FAILED"}} + """ + try: + data = request.get_json() + if not data or 'recording_ids' not in data: + return jsonify({'error': 'recording_ids is required'}), 400 + + recording_ids = data['recording_ids'] + if not isinstance(recording_ids, list): + return jsonify({'error': 'recording_ids must be a list'}), 400 + + # Limit batch size to prevent abuse + if len(recording_ids) > 50: + return jsonify({'error': 'Maximum 50 recordings per batch'}), 400 + + # Query all recordings at once + recordings = Recording.query.filter(Recording.id.in_(recording_ids)).all() + + # Build response with only accessible recordings + statuses = {} + for recording in recordings: + if has_recording_access(recording, current_user): + statuses[str(recording.id)] = recording.status + + return jsonify({'statuses': statuses}) + except Exception as e: + current_app.logger.error(f"Error fetching batch status: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings/job-queue-status', methods=['GET']) +@login_required +def get_job_queue_status(): + """ + Get detailed job queue status for all jobs (active, completed, and failed). + Returns status for the user's jobs within the last hour. + """ + try: + from src.models import ProcessingJob + from src.services.job_queue import TRANSCRIPTION_JOBS, SUMMARY_JOBS + from datetime import timedelta + + # Expire all cached objects to ensure we see latest data from worker threads + db.session.expire_all() + + # Get all jobs for the user (active + recent completed/failed within last hour) + cutoff_time = datetime.utcnow() - timedelta(hours=1) + all_jobs = ProcessingJob.query.filter( + ProcessingJob.user_id == current_user.id, + db.or_( + ProcessingJob.status.in_(['queued', 'processing']), + db.and_( + ProcessingJob.status.in_(['completed', 'failed']), + ProcessingJob.completed_at >= cutoff_time + ) + ) + ).order_by(ProcessingJob.created_at.desc()).all() + + job_details = [] + for job in all_jobs: + recording = db.session.get(Recording, job.recording_id) + recording_title = None + if recording: + recording_title = recording.title or recording.original_filename or 'Untitled' + + # Determine queue type + queue_type = 'summary' if job.job_type in SUMMARY_JOBS else 'transcription' + + # Calculate position if queued + position = None + if job.status == 'queued': + job_types = SUMMARY_JOBS if job.job_type in SUMMARY_JOBS else TRANSCRIPTION_JOBS + ahead_in_queue = ProcessingJob.query.filter( + ProcessingJob.status == 'queued', + ProcessingJob.job_type.in_(job_types), + ProcessingJob.created_at < job.created_at + ).count() + currently_processing = ProcessingJob.query.filter( + ProcessingJob.status == 'processing', + ProcessingJob.job_type.in_(job_types) + ).count() + position = ahead_in_queue + currently_processing + 1 + + job_details.append({ + 'id': job.id, + 'recording_id': job.recording_id, + 'recording_title': recording_title, + 'job_status': job.status, + 'job_type': job.job_type, + 'queue_type': queue_type, + 'position': position, + 'is_new_upload': job.is_new_upload, + 'error_message': job.error_message, + 'created_at': job.created_at.isoformat() if job.created_at else None, + 'started_at': job.started_at.isoformat() if job.started_at else None, + 'completed_at': job.completed_at.isoformat() if job.completed_at else None + }) + + return jsonify({'jobs': job_details}) + except Exception as e: + current_app.logger.error(f"Error fetching job queue status: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings/jobs//retry', methods=['POST']) +@login_required +def retry_failed_job(job_id): + """Retry a failed job.""" + try: + from src.models import ProcessingJob + + job = db.session.get(ProcessingJob, job_id) + if not job: + return jsonify({'error': 'Job not found'}), 404 + + if job.user_id != current_user.id: + return jsonify({'error': 'Access denied'}), 403 + + if job.status != 'failed': + return jsonify({'error': 'Only failed jobs can be retried'}), 400 + + # Reset job for retry + job.status = 'queued' + job.error_message = None + job.retry_count = 0 + job.started_at = None + job.completed_at = None + db.session.commit() + + current_app.logger.info(f"Job {job_id} queued for retry by user {current_user.id}") + return jsonify({'success': True, 'message': 'Job queued for retry'}) + + except Exception as e: + current_app.logger.error(f"Error retrying job {job_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings/jobs/', methods=['DELETE']) +@login_required +def delete_job(job_id): + """Delete a job (clear from queue or history).""" + try: + from src.models import ProcessingJob + import os + + job = db.session.get(ProcessingJob, job_id) + if not job: + return jsonify({'error': 'Job not found'}), 404 + + if job.user_id != current_user.id: + return jsonify({'error': 'Access denied'}), 403 + + # If it's a failed new upload, also delete the recording + if job.status == 'failed' and job.is_new_upload: + recording = db.session.get(Recording, job.recording_id) + if recording: + # Delete audio file + if recording.audio_path and os.path.exists(recording.audio_path): + try: + os.remove(recording.audio_path) + except Exception as e: + current_app.logger.error(f"Error deleting audio file: {e}") + # Delete ALL processing jobs for this recording first + ProcessingJob.query.filter_by(recording_id=recording.id).delete() + db.session.delete(recording) + else: + # Just delete this job + db.session.delete(job) + db.session.commit() + + current_app.logger.info(f"Job {job_id} deleted by user {current_user.id}") + return jsonify({'success': True, 'message': 'Job deleted'}) + + except Exception as e: + current_app.logger.error(f"Error deleting job {job_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings/jobs/clear-completed', methods=['POST']) +@login_required +def clear_completed_jobs(): + """Clear all completed jobs for the current user.""" + try: + from src.models import ProcessingJob + + deleted = ProcessingJob.query.filter( + ProcessingJob.user_id == current_user.id, + ProcessingJob.status == 'completed' + ).delete(synchronize_session=False) + + db.session.commit() + current_app.logger.info(f"Cleared {deleted} completed jobs for user {current_user.id}") + return jsonify({'success': True, 'deleted': deleted}) + + except Exception as e: + current_app.logger.error(f"Error clearing completed jobs: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/status/', methods=['GET']) +@login_required +def get_status(recording_id): + """Endpoint to check the transcription/summarization status (full recording data).""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to view this recording'}), 403 + + # Ensure events are loaded (refresh the recording to get latest relationships) + db.session.refresh(recording) + + # Get recording dict and enrich with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + + return jsonify(recording_dict) + except Exception as e: + current_app.logger.error(f"Error fetching status for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/audio/') +@login_required +def get_audio(recording_id): + """Serve audio file for a recording. + + Query parameters: + download: If 'true', serves file as attachment for download + """ + try: + recording = db.session.get(Recording, recording_id) + if not recording or not recording.audio_path: + return jsonify({'error': 'Recording or audio file not found'}), 404 + + # Check if the recording belongs to the current user or has been shared with them + has_access = recording.user_id == current_user.id + + # Check if recording has been shared with current user (if internal sharing is enabled) + if not has_access and ENABLE_INTERNAL_SHARING: + share = InternalShare.query.filter_by( + recording_id=recording_id, + shared_with_user_id=current_user.id + ).first() + has_access = share is not None + + if not has_access: + return jsonify({'error': 'You do not have permission to access this audio file'}), 403 + if not os.path.exists(recording.audio_path): + current_app.logger.error(f"Audio file missing from server: {recording.audio_path}") + return jsonify({'error': 'Audio file missing from server'}), 404 + + # Check if download is requested + download = request.args.get('download', 'false').lower() == 'true' + + # Audit log for audio access + try: + if download: + audit_download('audio', recording_id) + else: + audit_view('audio', recording_id) + db.session.commit() + except Exception: + db.session.rollback() + current_app.logger.warning(f'Audit log failed for audio {recording_id}', exc_info=True) + + if download: + # Generate filename from recording title or use default + filename = recording.title or f'recording_{recording_id}' + # Sanitize filename and add extension + filename = "".join(c for c in filename if c.isalnum() or c in (' ', '-', '_')).strip() + ext = os.path.splitext(recording.audio_path)[1] or '.mp3' + filename = f"{filename}{ext}" + return send_file(recording.audio_path, as_attachment=True, download_name=filename, mimetype=recording.mime_type, conditional=True) + + return send_file(recording.audio_path, mimetype=recording.mime_type, conditional=True) + except Exception as e: + current_app.logger.error(f"Error serving audio for recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +# --- Chat with Transcription --- + +@recordings_bp.route('/chat', methods=['POST']) +@login_required +def chat_with_transcription(): + """Chat with a specific recording's transcription.""" + try: + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + recording_id = data.get('recording_id') + user_message = data.get('message') + message_history = data.get('message_history', []) + + if not recording_id: + return jsonify({'error': 'No recording ID provided'}), 400 + if not user_message: + return jsonify({'error': 'No message provided'}), 400 + + # Get the recording + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user): + return jsonify({'error': 'You do not have permission to chat with this recording'}), 403 + + # Check if transcription exists + if not recording.transcription or len(recording.transcription.strip()) < 10: + return jsonify({'error': 'No transcription available for this recording'}), 400 + + # Check if transcription is an error message (not actual content) + if is_transcription_error(recording.transcription): + return jsonify({'error': 'Cannot chat: transcription failed. Please reprocess the transcription first.'}), 400 + + # Check if chat client is available + if chat_client is None: + return jsonify({'error': 'Chat service is not available (chat client not configured)'}), 503 + + # Prepare the system prompt with the transcription + user_chat_output_language = current_user.output_language if current_user.is_authenticated else None + + language_instruction = "Réponds toujours en français." + if user_chat_output_language: + language_instruction = f"Réponds en {user_chat_output_language}." + + user_name = current_user.name if current_user.is_authenticated and current_user.name else "l'utilisateur" + user_title = current_user.job_title if current_user.is_authenticated and current_user.job_title else None + user_company = current_user.company if current_user.is_authenticated and current_user.company else None + + formatted_transcription = format_transcription_for_llm(recording.transcription) + + # Get configurable transcript length limit for chat + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + chat_transcript = formatted_transcription + else: + chat_transcript = formatted_transcription[:transcript_limit] + + user_context = f", {user_title} chez {user_company}" if user_title and user_company else "" + + system_prompt = f"""Tu es un assistant expert en analyse de transcriptions audio et de réunions, qui assiste {user_name}{user_context}. {language_instruction} + +Réponds de façon directe et professionnelle. Utilise le formatage Markdown (## titres, **gras**, listes -). + +**Participants :** +{recording.participants or "Non précisé"} + +**Transcription :** +<> +{chat_transcript or "Transcription non disponible."} +<> + +**Notes et contexte :** +{recording.notes or "Aucune note."} +""" + + # Prepare messages array with system prompt and conversation history + messages = [{"role": "system", "content": system_prompt}] + if message_history: + messages.extend(message_history) + messages.append({"role": "user", "content": user_message}) + + # Capture context before generator starts (app context may not be available inside generator) + user_id = current_user.id + app = current_app._get_current_object() + + def generate(): + # Push app context for entire generator execution + # This is needed because call_chat_completion checks budget which requires db access + ctx = app.app_context() + ctx.push() + try: + # Enable streaming with user_id for budget enforcement + stream = call_chat_completion( + messages=messages, + temperature=0.7, + max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")), + stream=True, + user_id=user_id, + operation_type='chat' + ) + + # Use helper function to process streaming with thinking tag support + for response in process_streaming_with_thinking(stream, user_id=user_id, operation_type='chat', app=app): + yield response + + except TokenBudgetExceeded as e: + app.logger.warning(f"Token budget exceeded for user {user_id}: {e}") + yield f"data: {json.dumps({'error': str(e), 'budget_exceeded': True})}\n\n" + except Exception as e: + app.logger.error(f"Error during chat stream generation: {str(e)}") + # Provide more helpful error message for connection issues + error_msg = str(e).lower() + if 'connection' in error_msg or 'connect' in error_msg or 'refused' in error_msg: + yield f"data: {json.dumps({'error': 'Could not connect to LLM server. Please check that your LLM service is running.'})}\n\n" + else: + yield f"data: {json.dumps({'error': str(e)})}\n\n" + finally: + ctx.pop() + + return Response(generate(), mimetype='text/event-stream') + + except Exception as e: + current_app.logger.error(f"Error in chat endpoint: {str(e)}") + error_msg = str(e).lower() + if 'connection' in error_msg or 'connect' in error_msg or 'refused' in error_msg: + return jsonify({'error': 'Could not connect to LLM server. Please check that your LLM service is running.'}), 503 + return jsonify({'error': str(e)}), 500 + + +# --- Tag Management for Recordings --- + +@recordings_bp.route('/api/recordings//tags', methods=['POST']) +@login_required +def add_tag_to_recording(recording_id): + """Add a tag to a recording. Triggers auto-share for group tags.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check if user has view access to this recording + # (Edit permission will be checked for group tags specifically) + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + data = request.get_json() + tag_id = data.get('tag_id') + + if not tag_id: + return jsonify({'error': 'Tag ID is required'}), 400 + + tag = db.session.get(Tag, tag_id) + if not tag: + return jsonify({'error': 'Tag not found'}), 404 + + # Check if user has access to this tag and permission to apply it + if tag.group_id: + # Group tag - check membership first + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + if not membership: + return jsonify({'error': 'You do not have access to this tag'}), 403 + + # Only file owner or group admin can apply group tags + if recording.user_id != current_user.id and membership.role != 'admin': + return jsonify({'error': 'Only recording owner or group admin can apply group tags'}), 403 + + # Group tags require edit permission + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to apply group tags to this recording'}), 403 + else: + # Personal tag - only the tag owner can use it (view access is sufficient) + if tag.user_id != current_user.id: + return jsonify({'error': 'You can only apply your own personal tags'}), 403 + + # Check if tag is already on the recording + existing = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + + if existing: + return jsonify({'error': 'Tag is already on this recording'}), 400 + + # Get the next order position + max_order = db.session.query(db.func.max(RecordingTag.order)).filter_by( + recording_id=recording_id + ).scalar() or 0 + + # Add the tag + recording_tag = RecordingTag( + recording_id=recording_id, + tag_id=tag_id, + order=max_order + 1 + ) + db.session.add(recording_tag) + + # If this is a group tag with sharing enabled, automatically share the recording + # Only auto-share if recording is completed (not during processing) + if tag.group_id and ENABLE_INTERNAL_SHARING and recording.status == 'COMPLETED' and (tag.auto_share_on_apply or tag.share_with_group_lead): + # Determine who to share with + if tag.auto_share_on_apply: + group_members = GroupMembership.query.filter_by(group_id=tag.group_id).all() + elif tag.share_with_group_lead: + group_members = GroupMembership.query.filter_by(group_id=tag.group_id, role='admin').all() + else: + group_members = [] + + shares_created = 0 + for membership_to_share in group_members: + # Skip the recording owner + if membership_to_share.user_id == recording.user_id: + continue + + # Check if already shared + existing_share = InternalShare.query.filter_by( + recording_id=recording_id, + shared_with_user_id=membership_to_share.user_id + ).first() + + if not existing_share: + # Create internal share with correct permissions + # Group admins get edit permission, regular members get read-only + share = InternalShare( + recording_id=recording_id, + owner_id=recording.user_id, + shared_with_user_id=membership_to_share.user_id, + can_edit=(membership_to_share.role == 'admin'), + can_reshare=False, + source_type='group_tag', + source_tag_id=tag.id + ) + db.session.add(share) + + # Check if SharedRecordingState already exists (might exist from previous share) + existing_state = SharedRecordingState.query.filter_by( + recording_id=recording_id, + user_id=membership_to_share.user_id + ).first() + + if not existing_state: + # Create SharedRecordingState with default values for the recipient + state = SharedRecordingState( + recording_id=recording_id, + user_id=membership_to_share.user_id, + is_inbox=True, # New shares appear in inbox by default + is_highlighted=False # Not favorited by default + ) + db.session.add(state) + + shares_created += 1 + current_app.logger.info(f"Auto-shared recording {recording_id} with user {membership_to_share.user_id} (role={membership_to_share.role}) via group tag '{tag.name}'") + + if shares_created > 0: + current_app.logger.info(f"Created {shares_created} auto-shares for recording {recording_id} via group tag '{tag.name}'") + + db.session.commit() + + # Return updated recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'recording': recording_dict, + 'tag': tag.to_dict() + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error adding tag to recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings//tags/', methods=['DELETE']) +@login_required +def remove_tag_from_recording(recording_id, tag_id): + """Remove a tag from a recording. Cleans up auto-shares for group tags.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check if user has view access to this recording + # (Edit permission will be checked for group tags specifically) + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + # Find the recording-tag association + recording_tag = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + + if not recording_tag: + return jsonify({'error': 'Tag is not on this recording'}), 404 + + # Get the tag to check permissions and for cleanup + tag = db.session.get(Tag, tag_id) + if tag: + # Check permissions to remove this specific tag + if tag.group_id: + # Group tag - only file owner or group admin can remove + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + if recording.user_id != current_user.id: + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only recording owner or group admin can remove group tags'}), 403 + + # Group tags require edit permission + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to remove group tags from this recording'}), 403 + else: + # Personal tag - can be removed by tag owner (view access) or recording owner (edit access) + if tag.user_id != current_user.id: + # Not the tag owner, must be recording owner with edit permission + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You can only remove your own personal tags'}), 403 + + # Remove the association + db.session.delete(recording_tag) + + # Clean up shares created by this group tag + if tag and tag.group_id and ENABLE_INTERNAL_SHARING: + shares_to_check = InternalShare.query.filter_by( + recording_id=recording_id, + source_tag_id=tag_id + ).all() + + shares_removed = 0 + for share in shares_to_check: + # Check if user still has access via another group tag on this recording + other_team_tag_access = db.session.query(Tag).join( + RecordingTag, RecordingTag.tag_id == Tag.id + ).join( + GroupMembership, GroupMembership.group_id == Tag.group_id + ).filter( + RecordingTag.recording_id == recording_id, + GroupMembership.user_id == share.shared_with_user_id, + Tag.id != tag_id, # Exclude the tag being removed + Tag.group_id.isnot(None), + db.or_(Tag.auto_share_on_apply == True, Tag.share_with_group_lead == True) + ).first() + + # Only remove share if user has no other group tag access + if not other_team_tag_access: + db.session.delete(share) + shares_removed += 1 + current_app.logger.info(f"Removed auto-share for user {share.shared_with_user_id} from recording {recording_id} (group tag '{tag.name}' removed)") + + if shares_removed > 0: + current_app.logger.info(f"Cleaned up {shares_removed} auto-shares for recording {recording_id} after removing group tag '{tag.name}'") + + db.session.commit() + + # Return updated recording with per-user status + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'recording': recording_dict + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error removing tag from recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@recordings_bp.route('/api/recordings//tags/reorder', methods=['PUT']) +@login_required +def reorder_recording_tags(recording_id): + """Reorder tags on a recording. Updates the order field for each RecordingTag.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check if user has edit access to this recording + if not has_recording_access(recording, current_user, require_edit=True): + return jsonify({'error': 'You do not have permission to modify this recording'}), 403 + + data = request.get_json() + if not data or 'tag_ids' not in data: + return jsonify({'error': 'Missing tag_ids in request body'}), 400 + + tag_ids = data.get('tag_ids', []) + if not isinstance(tag_ids, list): + return jsonify({'error': 'tag_ids must be a list'}), 400 + + # Update order for each tag + for order, tag_id in enumerate(tag_ids, 1): + recording_tag = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + if recording_tag: + recording_tag.order = order + + db.session.commit() + + # Return updated recording + recording_dict = recording.to_dict(viewer_user=current_user) + enrich_recording_dict_with_user_status(recording_dict, recording, current_user) + return jsonify({ + 'success': True, + 'recording': recording_dict + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error reordering tags on recording {recording_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +# --- Bulk Operations --- + +@recordings_bp.route('/api/recordings/bulk', methods=['DELETE']) +@login_required +def bulk_delete_recordings(): + """Delete multiple recordings at once.""" + try: + data = request.get_json() + if not data or 'recording_ids' not in data: + return jsonify({'error': 'Missing recording_ids'}), 400 + + recording_ids = data.get('recording_ids', []) + if not isinstance(recording_ids, list) or len(recording_ids) == 0: + return jsonify({'error': 'recording_ids must be a non-empty list'}), 400 + + # Limit bulk operations to prevent abuse + if len(recording_ids) > 100: + return jsonify({'error': 'Cannot delete more than 100 recordings at once'}), 400 + + # Check deletion permissions + if not USERS_CAN_DELETE and not current_user.is_admin: + return jsonify({'error': 'Only administrators can delete recordings'}), 403 + + deleted_ids = [] + errors = [] + + for recording_id in recording_ids: + try: + recording = db.session.get(Recording, recording_id) + if not recording: + errors.append(f"Recording {recording_id} not found") + continue + + # Check ownership + if recording.user_id and recording.user_id != current_user.id: + errors.append(f"No permission for recording {recording_id}") + continue + + # Delete audio file + if recording.audio_path and os.path.exists(recording.audio_path): + try: + os.remove(recording.audio_path) + except Exception as e: + current_app.logger.error(f"Error deleting audio file {recording.audio_path}: {e}") + + # Delete associated records with NOT NULL recording_id constraints + from src.models import ProcessingJob + from src.models.speaker_snippet import SpeakerSnippet + SpeakerSnippet.query.filter_by(recording_id=recording_id).delete() + ProcessingJob.query.filter_by(recording_id=recording_id).delete() + + # Delete the recording (audit après commit réussi) + _audit_titles = getattr(current_app, '_bulk_delete_titles', {}) + _audit_titles[recording_id] = recording.title + current_app._bulk_delete_titles = _audit_titles + db.session.delete(recording) + deleted_ids.append(recording_id) + + except Exception as e: + current_app.logger.error(f"Error deleting recording {recording_id}: {e}") + errors.append(f"Error with recording {recording_id}") + + db.session.commit() + + # Audit APRÈS commit réussi (atomicité garantie) + try: + titles = getattr(current_app, '_bulk_delete_titles', {}) + for rid in deleted_ids: + audit_delete('recording', rid, details={'title': titles.get(rid)}) + if deleted_ids: + db.session.commit() + current_app._bulk_delete_titles = {} + except Exception: + current_app.logger.warning('Audit bulk delete failed', exc_info=True) + + return jsonify({ + 'success': True, + 'deleted_ids': deleted_ids, + 'deleted_count': len(deleted_ids), + 'errors': errors if errors else None + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error in bulk delete: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred'}), 500 + + +@recordings_bp.route('/api/recordings/bulk-tags', methods=['POST']) +@login_required +def bulk_update_tags(): + """Add or remove a tag from multiple recordings.""" + try: + data = request.get_json() + if not data: + return jsonify({'error': 'Missing request body'}), 400 + + recording_ids = data.get('recording_ids', []) + tag_id = data.get('tag_id') + action = data.get('action', 'add') # 'add' or 'remove' + + if not recording_ids or not tag_id: + return jsonify({'error': 'Missing recording_ids or tag_id'}), 400 + + if action not in ['add', 'remove']: + return jsonify({'error': 'Action must be "add" or "remove"'}), 400 + + if len(recording_ids) > 100: + return jsonify({'error': 'Cannot update more than 100 recordings at once'}), 400 + + # Verify tag exists and user has access + tag = db.session.get(Tag, tag_id) + if not tag: + return jsonify({'error': 'Tag not found'}), 404 + + if tag.user_id != current_user.id and not tag.group_id: + return jsonify({'error': 'No permission to use this tag'}), 403 + + affected_ids = [] + + for recording_id in recording_ids: + try: + recording = db.session.get(Recording, recording_id) + if not recording: + continue + + # Check ownership or edit access + if not has_recording_access(recording, current_user, require_edit=True): + continue + + if action == 'add': + # Check if tag already exists + existing = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + + if not existing: + # Get max order for this recording + max_order = db.session.query(db.func.max(RecordingTag.order)).filter_by( + recording_id=recording_id + ).scalar() or 0 + + new_tag = RecordingTag( + recording_id=recording_id, + tag_id=tag_id, + order=max_order + 1 + ) + db.session.add(new_tag) + affected_ids.append(recording_id) + + else: # remove + recording_tag = RecordingTag.query.filter_by( + recording_id=recording_id, + tag_id=tag_id + ).first() + + if recording_tag: + db.session.delete(recording_tag) + affected_ids.append(recording_id) + + except Exception as e: + current_app.logger.error(f"Error updating tag for recording {recording_id}: {e}") + + db.session.commit() + + return jsonify({ + 'success': True, + 'affected_ids': affected_ids, + 'affected_count': len(affected_ids) + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error in bulk tag update: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred'}), 500 + + +@recordings_bp.route('/api/recordings/bulk-reprocess', methods=['POST']) +@login_required +def bulk_reprocess(): + """Queue multiple recordings for reprocessing.""" + try: + data = request.get_json() + if not data: + return jsonify({'error': 'Missing request body'}), 400 + + recording_ids = data.get('recording_ids', []) + reprocess_type = data.get('type', 'summary') # 'transcription' or 'summary' + + if not recording_ids: + return jsonify({'error': 'Missing recording_ids'}), 400 + + if reprocess_type not in ['transcription', 'summary']: + return jsonify({'error': 'Type must be "transcription" or "summary"'}), 400 + + if len(recording_ids) > 50: + return jsonify({'error': 'Cannot reprocess more than 50 recordings at once'}), 400 + + queued_ids = [] + + for recording_id in recording_ids: + try: + recording = db.session.get(Recording, recording_id) + if not recording: + continue + + # Check ownership + if recording.user_id != current_user.id: + continue + + # Only reprocess completed or failed recordings + if recording.status not in ['COMPLETED', 'FAILED']: + continue + + # For transcription reprocess, need audio file + if reprocess_type == 'transcription': + if not recording.audio_path or not os.path.exists(recording.audio_path): + continue + job_type = 'reprocess_transcription' + else: + # For summary, need transcription + if not recording.transcription: + continue + job_type = 'reprocess_summary' + + # Queue the job + job_queue.enqueue( + user_id=current_user.id, + recording_id=recording.id, + job_type=job_type, + params={'user_id': current_user.id} + ) + + queued_ids.append(recording_id) + + except Exception as e: + current_app.logger.error(f"Error queueing reprocess for recording {recording_id}: {e}") + + db.session.commit() + + return jsonify({ + 'success': True, + 'queued_ids': queued_ids, + 'queued_count': len(queued_ids) + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error in bulk reprocess: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred'}), 500 + + +@recordings_bp.route('/api/recordings/bulk-toggle', methods=['POST']) +@login_required +def bulk_toggle(): + """Toggle inbox or highlight for multiple recordings.""" + try: + data = request.get_json() + if not data: + return jsonify({'error': 'Missing request body'}), 400 + + recording_ids = data.get('recording_ids', []) + field = data.get('field') # 'inbox' or 'highlight' + value = data.get('value') # True or False + + if not recording_ids or field is None or value is None: + return jsonify({'error': 'Missing recording_ids, field, or value'}), 400 + + if field not in ['inbox', 'highlight']: + return jsonify({'error': 'Field must be "inbox" or "highlight"'}), 400 + + if len(recording_ids) > 100: + return jsonify({'error': 'Cannot update more than 100 recordings at once'}), 400 + + affected_ids = [] + + for recording_id in recording_ids: + try: + recording = db.session.get(Recording, recording_id) + if not recording: + continue + + # Use set_user_recording_status which handles both owners and shared users + if field == 'inbox': + set_user_recording_status(recording, current_user, is_inbox=value) + else: + set_user_recording_status(recording, current_user, is_highlighted=value) + + affected_ids.append(recording_id) + + except Exception as e: + current_app.logger.error(f"Error toggling {field} for recording {recording_id}: {e}") + + db.session.commit() + + return jsonify({ + 'success': True, + 'affected_ids': affected_ids, + 'affected_count': len(affected_ids) + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error in bulk toggle: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred'}), 500 + + +# --- Auto-deletion and Chunks Processing --- + +@recordings_bp.route('/api/recordings//toggle_deletion_exempt', methods=['POST']) +@login_required +def toggle_deletion_exempt(recording_id): + """Toggle the deletion_exempt flag for a recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + # Check ownership + if recording.user_id != current_user.id and not current_user.is_admin: + return jsonify({'error': 'Permission denied'}), 403 + + # Toggle the flag + recording.deletion_exempt = not recording.deletion_exempt + db.session.commit() + + return jsonify({ + 'success': True, + 'deletion_exempt': recording.deletion_exempt + }) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error toggling deletion exempt for recording {recording_id}: {e}") + return jsonify({'error': str(e)}), 500 + + +@recordings_bp.route('/api/recording//process_chunks', methods=['POST']) +@login_required +def process_recording_chunks_endpoint(recording_id): + """Process chunks for a specific recording.""" + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if recording.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + success = process_recording_chunks(recording_id) + if success: + return jsonify({'message': 'Chunks processed successfully'}) + else: + return jsonify({'error': 'Failed to process chunks'}), 500 + + except Exception as e: + current_app.logger.error(f"Error in process chunks endpoint: {e}") + return jsonify({'error': str(e)}), 500 + + +# --- Inquire Mode API Endpoints --- diff --git a/src/api/shares.py b/src/api/shares.py new file mode 100644 index 0000000..ad0466d --- /dev/null +++ b/src/api/shares.py @@ -0,0 +1,641 @@ +""" +Sharing routes for public and internal recording shares. + +This blueprint handles: +- Public sharing (shareable links) +- Internal sharing (user-to-user sharing) +- Share management (CRUD operations) +""" + +import os +import re +import json +from flask import Blueprint, render_template, request, redirect, url_for, jsonify, send_file, current_app +from flask_login import login_required, current_user + +from src.database import db +from src.models import Recording, Share, InternalShare, SharedRecordingState, User, TranscriptChunk, ShareAuditLog +from src.utils import md_to_html +from src.services.audit import audit_access + +# Configuration from environment +ENABLE_PUBLIC_SHARING = os.environ.get('ENABLE_PUBLIC_SHARING', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +SHOW_USERNAMES_IN_UI = os.environ.get('SHOW_USERNAMES_IN_UI', 'false').lower() == 'true' +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +READABLE_PUBLIC_LINKS = os.environ.get('READABLE_PUBLIC_LINKS', 'false').lower() == 'true' + +# Create blueprint +shares_bp = Blueprint('shares', __name__) + +# Import has_recording_access from app context +has_recording_access = None + +def init_shares_helpers(_has_recording_access): + """Initialize helper functions from app.""" + global has_recording_access + has_recording_access = _has_recording_access + + +def process_transcription_for_template(transcription_str): + """ + Process transcription JSON into a format ready for server-side rendering. + + Returns a dict with: + - is_json: bool - whether transcription is valid JSON + - has_speakers: bool - whether diarization data exists + - segments: list - processed segments with speaker info and colors + - speakers: list - unique speakers with colors + - plain_text: str - plain text version for non-JSON or fallback + """ + if not transcription_str: + return {'is_json': False, 'has_speakers': False, 'segments': [], 'speakers': [], 'plain_text': ''} + + try: + data = json.loads(transcription_str) + except (json.JSONDecodeError, TypeError): + # Plain text transcription + return { + 'is_json': False, + 'has_speakers': False, + 'segments': [], + 'speakers': [], + 'plain_text': transcription_str + } + + if not isinstance(data, list): + return { + 'is_json': False, + 'has_speakers': False, + 'segments': [], + 'speakers': [], + 'plain_text': transcription_str + } + + # Check if diarized (has speaker info) + has_speakers = any(seg.get('speaker') for seg in data) + + # Get unique speakers and assign colors + speakers = [] + speaker_colors = {} + if has_speakers: + unique_speakers = list(dict.fromkeys(seg.get('speaker') for seg in data if seg.get('speaker'))) + for i, speaker in enumerate(unique_speakers): + color = f'speaker-color-{(i % 8) + 1}' + speaker_colors[speaker] = color + speakers.append({'name': speaker, 'color': color}) + + # Process segments + segments = [] + last_speaker = None + for seg in data: + speaker = seg.get('speaker', '') + segment = { + 'text': seg.get('sentence', ''), + 'speaker': speaker, + 'start_time': seg.get('start_time') or seg.get('startTime', ''), + 'end_time': seg.get('end_time') or seg.get('endTime', ''), + 'color': speaker_colors.get(speaker, 'speaker-color-1'), + 'show_speaker': speaker != last_speaker + } + segments.append(segment) + last_speaker = speaker + + # Build plain text version + if has_speakers: + plain_text = '\n'.join(f"[{seg['speaker']}]: {seg['text']}" for seg in segments) + else: + plain_text = '\n'.join(seg['text'] for seg in segments) + + return { + 'is_json': True, + 'has_speakers': has_speakers, + 'segments': segments, + 'speakers': speakers, + 'plain_text': plain_text + } + + +# --- Public Sharing Routes --- + +@shares_bp.route('/share/', methods=['GET']) +def view_shared_recording(public_id): + """View a publicly shared recording.""" + share = Share.query.filter_by(public_id=public_id).first_or_404() + recording = share.recording + + # Audit: log public share access avec dédup IP/1h pour éviter flood + try: + from datetime import datetime, timedelta + from src.models.access_log import AccessLog + _ip = request.remote_addr + _cutoff = datetime.utcnow() - timedelta(hours=1) + _recent = AccessLog.query.filter( + AccessLog.action == 'share_view', + AccessLog.resource_id == recording.id, + AccessLog.ip_address == _ip, + AccessLog.timestamp >= _cutoff + ).first() + if not _recent: + audit_access('share_view', 'recording', recording.id, details={'public_id': public_id, 'anonymous': True}) + db.session.commit() + except Exception: + db.session.rollback() + current_app.logger.warning('Audit share_view failed', exc_info=True) + + # Process transcription for server-side rendering (only if READABLE_PUBLIC_LINKS is enabled) + processed_transcript = None + if READABLE_PUBLIC_LINKS: + processed_transcript = process_transcription_for_template(recording.transcription) + + # Create a limited dictionary for the public view + recording_data = { + 'id': recording.id, + 'public_id': share.public_id, + 'title': recording.title, + 'participants': recording.participants, + 'transcription': recording.transcription, + 'summary': md_to_html(recording.summary) if share.share_summary else None, + 'summary_raw': recording.summary if share.share_summary else None, + 'notes': md_to_html(recording.notes) if share.share_notes else None, + 'notes_raw': recording.notes if share.share_notes else None, + 'meeting_date': f"{recording.meeting_date.isoformat()}T00:00:00" if recording.meeting_date else None, + 'mime_type': recording.mime_type, + 'audio_deleted_at': recording.audio_deleted_at.isoformat() if recording.audio_deleted_at else None, + 'audio_duration': recording.get_audio_duration() + } + + return render_template('share.html', recording=recording_data, transcript=processed_transcript, readable_mode=READABLE_PUBLIC_LINKS) + + +@shares_bp.route('/share/audio/') +def get_shared_audio(public_id): + """Serve audio file for a publicly shared recording.""" + try: + share = Share.query.filter_by(public_id=public_id).first_or_404() + recording = share.recording + if not recording or not recording.audio_path: + return jsonify({'error': 'Recording or audio file not found'}), 404 + if not os.path.exists(recording.audio_path): + current_app.logger.error(f"Audio file missing from server: {recording.audio_path}") + return jsonify({'error': 'Audio file missing from server'}), 404 + return send_file(recording.audio_path, conditional=True) + except Exception as e: + current_app.logger.error(f"Error serving shared audio for public_id {public_id}: {e}", exc_info=True) + return jsonify({'error': 'An unexpected error occurred.'}), 500 + + +@shares_bp.route('/api/recording//share', methods=['GET']) +@login_required +def get_existing_share(recording_id): + """Check if a share already exists for this recording.""" + recording = db.session.get(Recording, recording_id) + if not recording or recording.user_id != current_user.id: + return jsonify({'error': 'Recording not found or you do not have permission to view it.'}), 404 + + existing_share = Share.query.filter_by( + recording_id=recording.id, + user_id=current_user.id + ).order_by(Share.created_at.desc()).first() + + if existing_share: + share_url = url_for('shares.view_shared_recording', public_id=existing_share.public_id, _external=True) + return jsonify({ + 'success': True, + 'exists': True, + 'share_url': share_url, + 'share': existing_share.to_dict() + }), 200 + else: + return jsonify({ + 'success': True, + 'exists': False + }), 200 + + +@shares_bp.route('/api/recording//share', methods=['POST']) +@login_required +def create_share(recording_id): + """Create a public share link for a recording.""" + # Check if public sharing is globally enabled + if not ENABLE_PUBLIC_SHARING: + return jsonify({'error': 'Public sharing is not enabled on this server'}), 403 + + # Check if user has permission to create public shares + if not current_user.can_share_publicly: + return jsonify({'error': 'You do not have permission to create public share links. Contact your administrator.'}), 403 + + if not request.is_secure: + return jsonify({'error': 'Sharing is only available over a secure (HTTPS) connection.'}), 403 + + recording = db.session.get(Recording, recording_id) + if not recording or recording.user_id != current_user.id: + return jsonify({'error': 'Recording not found or you do not have permission to share it.'}), 404 + + data = request.json + share_summary = data.get('share_summary', True) + share_notes = data.get('share_notes', True) + force_new = data.get('force_new', False) + + # Check if ANY share already exists for this recording by this user + existing_share = Share.query.filter_by( + recording_id=recording.id, + user_id=current_user.id + ).order_by(Share.created_at.desc()).first() + + if existing_share and not force_new: + # Update the share permissions if they've changed + if existing_share.share_summary != share_summary or existing_share.share_notes != share_notes: + existing_share.share_summary = share_summary + existing_share.share_notes = share_notes + db.session.commit() + + # Return existing share info + share_url = url_for('shares.view_shared_recording', public_id=existing_share.public_id, _external=True) + return jsonify({ + 'success': True, + 'share_url': share_url, + 'share': existing_share.to_dict(), + 'existing': True, + 'message': 'Using existing share link for this recording' + }), 200 + + # Create new share + share = Share( + recording_id=recording.id, + user_id=current_user.id, + share_summary=share_summary, + share_notes=share_notes + ) + db.session.add(share) + db.session.commit() + + share_url = url_for('shares.view_shared_recording', public_id=share.public_id, _external=True) + + return jsonify({ + 'success': True, + 'share_url': share_url, + 'share': share.to_dict(), + 'existing': False + }), 201 + + +@shares_bp.route('/api/shares', methods=['GET']) +@login_required +def get_shares(): + """Get all public shares for the current user.""" + shares = Share.query.filter_by(user_id=current_user.id).order_by(Share.created_at.desc()).all() + return jsonify([share.to_dict() for share in shares]) + + +@shares_bp.route('/api/share/', methods=['PUT']) +@login_required +def update_share(share_id): + """Update a public share's settings.""" + share = Share.query.filter_by(id=share_id, user_id=current_user.id).first_or_404() + data = request.json + + if 'share_summary' in data: + share.share_summary = data['share_summary'] + if 'share_notes' in data: + share.share_notes = data['share_notes'] + + db.session.commit() + return jsonify({'success': True, 'share': share.to_dict()}) + + +@shares_bp.route('/api/share/', methods=['DELETE']) +@login_required +def delete_share(share_id): + """Delete a public share.""" + share = Share.query.filter_by(id=share_id, user_id=current_user.id).first_or_404() + db.session.delete(share) + db.session.commit() + return jsonify({'success': True}) + + +# --- Internal Sharing Routes --- + +@shares_bp.route('/api/users/search', methods=['GET']) +@login_required +def search_users(): + """Search for users by username (for internal sharing).""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Internal sharing is not enabled'}), 403 + + query = request.args.get('q', '').strip() + + # If SHOW_USERNAMES_IN_UI is enabled and no query, return all users for quick selection + if SHOW_USERNAMES_IN_UI and len(query) < 2: + users = User.query.filter(User.id != current_user.id).order_by(User.username).all() + elif len(query) < 2: + # If usernames are hidden and no query, return empty + return jsonify([]) + else: + if SHOW_USERNAMES_IN_UI: + # If usernames are shown, allow partial match (autocomplete) + users = User.query.filter( + User.id != current_user.id, + User.username.ilike(f'%{query}%') + ).limit(10).all() + else: + # If usernames are hidden (privacy mode), require exact match only + users = User.query.filter( + User.id != current_user.id, + User.username == query + ).all() + + return jsonify([{ + 'id': user.id, + 'username': user.username, + 'email': user.email if SHOW_USERNAMES_IN_UI else None + } for user in users]) + + +@shares_bp.route('/api/recordings//share-internal', methods=['POST']) +@login_required +def share_recording_internal(recording_id): + """Share a recording with another user internally.""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Internal sharing is not enabled'}), 403 + + try: + data = request.json + shared_with_user_id = data.get('user_id') + can_edit = data.get('can_edit', False) + can_reshare = data.get('can_reshare', False) + + if not shared_with_user_id: + return jsonify({'error': 'User ID is required'}), 400 + + # Check recording exists and user has permission to share it + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_reshare=True): + return jsonify({'error': 'You do not have permission to share this recording'}), 403 + + # Check target user exists + target_user = db.session.get(User, shared_with_user_id) + if not target_user: + return jsonify({'error': 'Target user not found'}), 404 + + # Prevent sharing back to owner (circular share) + if shared_with_user_id == recording.user_id: + return jsonify({'error': 'Cannot share a recording with its owner'}), 400 + + # Prevent sharing with self + if shared_with_user_id == current_user.id: + return jsonify({'error': 'Cannot share a recording with yourself'}), 400 + + # Check if already shared + existing_share = InternalShare.query.filter_by( + recording_id=recording_id, + shared_with_user_id=shared_with_user_id + ).first() + + if existing_share: + return jsonify({'error': 'Recording already shared with this user'}), 409 + + # PERMISSION VALIDATION: Validate that current user can grant the requested permissions + requested_permissions = {'can_edit': can_edit, 'can_reshare': can_reshare} + is_valid, error_message = InternalShare.validate_reshare_permissions( + recording, current_user, requested_permissions + ) + + if not is_valid: + return jsonify({'error': error_message}), 403 + + # Get current user's permissions for audit log + actor_permissions = InternalShare.get_user_max_permissions(recording, current_user) + + # Create share + share = InternalShare( + recording_id=recording_id, + owner_id=current_user.id, + shared_with_user_id=shared_with_user_id, + can_edit=can_edit, + can_reshare=can_reshare + ) + db.session.add(share) + + # Create or update SharedRecordingState for the recipient + state = SharedRecordingState.query.filter_by( + recording_id=recording_id, + user_id=shared_with_user_id + ).first() + + if not state: + # Create new state if it doesn't exist + state = SharedRecordingState( + recording_id=recording_id, + user_id=shared_with_user_id, + is_inbox=True, # New shares appear in inbox by default + is_highlighted=False # Not favorited by default + ) + db.session.add(state) + else: + # Reset to inbox if it already exists (e.g., from previous share that was deleted) + state.is_inbox = True + + db.session.commit() + + # AUDIT LOGGING: Log the share creation + try: + ShareAuditLog.log_share_created( + recording_id=recording_id, + actor_id=current_user.id, + target_user_id=shared_with_user_id, + permissions={'can_edit': can_edit, 'can_reshare': can_reshare}, + actor_permissions=actor_permissions, + notes=f"Shared by {'owner' if recording.user_id == current_user.id else 'delegated user'}", + ip_address=request.remote_addr + ) + db.session.commit() + except Exception as audit_error: + # Don't fail the share if audit logging fails + current_app.logger.error(f"Failed to log share creation: {audit_error}") + + return jsonify({ + 'success': True, + 'share': share.to_dict() + }), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error sharing recording internally: {e}") + return jsonify({'error': str(e)}), 500 + + +@shares_bp.route('/api/recordings//shares-internal', methods=['GET']) +@login_required +def get_internal_shares(recording_id): + """Get list of users a recording is shared with, including owner.""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Internal sharing is not enabled'}), 403 + + # Check recording exists and user has permission to view shares + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_reshare=True): + return jsonify({'error': 'You do not have permission to view shares for this recording'}), 403 + + # Get all internal shares + shares = InternalShare.query.filter_by(recording_id=recording_id).all() + shares_list = [share.to_dict() for share in shares] + + # Add owner as first entry (owner always has full permissions) + owner = db.session.get(User, recording.user_id) + if owner: + owner_entry = { + 'id': None, # No share ID for owner + 'recording_id': recording_id, + 'owner_id': owner.id, + 'owner_username': owner.username, + 'user_id': owner.id, + 'username': owner.username, + 'can_edit': True, + 'can_reshare': True, + 'is_owner': True, # Mark as owner + 'source_type': 'owner', + 'source_tag_id': None, + 'created_at': recording.created_at.isoformat() if recording.created_at else None + } + # Insert owner at the beginning + shares_list.insert(0, owner_entry) + + return jsonify({'shares': shares_list}) + + +@shares_bp.route('/api/internal-shares/', methods=['DELETE']) +@login_required +def revoke_internal_share(share_id): + """Revoke an internal share with cascade revocation.""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Internal sharing is not enabled'}), 403 + + share = db.session.get(InternalShare, share_id) + if not share: + return jsonify({'error': 'Share not found'}), 404 + + # Only owner can revoke + if share.owner_id != current_user.id: + return jsonify({'error': 'You do not have permission to revoke this share'}), 403 + + recording_id = share.recording_id + revoked_user_id = share.shared_with_user_id + revoked_count = 0 + + try: + # CASCADE REVOCATION: Find downstream shares created by the user losing access + downstream_shares = InternalShare.find_downstream_shares(recording_id, revoked_user_id) + + # Recursively revoke downstream shares that don't have alternate paths + for downstream in downstream_shares: + # Check for alternate access paths (diamond pattern protection) + has_alternate = InternalShare.has_alternate_access_path( + recording_id, + downstream.shared_with_user_id, + excluding_grantor_id=revoked_user_id + ) + + if not has_alternate: + # No alternate path - cascade revoke + # Audit log cascade revocation + try: + ShareAuditLog.log_share_revoked( + share_id=downstream.id, + recording_id=recording_id, + actor_id=current_user.id, + target_user_id=downstream.shared_with_user_id, + was_cascade=True, + notes=f"Cascaded from revoking user {revoked_user_id}", + ip_address=request.remote_addr + ) + except Exception as audit_error: + current_app.logger.error(f"Failed to log cascade revocation: {audit_error}") + + db.session.delete(downstream) + revoked_count += 1 + + # Audit log the primary revocation + try: + ShareAuditLog.log_share_revoked( + share_id=share.id, + recording_id=recording_id, + actor_id=current_user.id, + target_user_id=revoked_user_id, + was_cascade=False, + notes=f"Revoked by user {current_user.id}, cascaded to {revoked_count} downstream shares", + ip_address=request.remote_addr + ) + except Exception as audit_error: + current_app.logger.error(f"Failed to log revocation: {audit_error}") + + # Delete the primary share + db.session.delete(share) + db.session.commit() + + return jsonify({ + 'success': True, + 'revoked_count': revoked_count + 1, # Include primary share + 'cascaded': revoked_count + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error revoking internal share: {e}") + return jsonify({'error': str(e)}), 500 + + +@shares_bp.route('/api/recordings/shared-with-me', methods=['GET']) +@login_required +def get_shared_with_me(): + """Get recordings that have been shared with the current user.""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Internal sharing is not enabled'}), 403 + + try: + # Get shares where current user is the recipient + shares = InternalShare.query.filter_by(shared_with_user_id=current_user.id).all() + + result = [] + for share in shares: + recording = share.recording + if recording and recording.status == 'COMPLETED': + rec_data = recording.to_list_dict(viewer_user=current_user) + # Mark as shared recording with owner info + rec_data['is_shared'] = True + rec_data['owner_username'] = share.owner.username if SHOW_USERNAMES_IN_UI else None + # Don't show outgoing share counts for recordings you don't own + rec_data['shared_with_count'] = 0 + rec_data['public_share_count'] = 0 + # Check if recording has group tags (among visible tags) + visible_tags = recording.get_visible_tags(current_user) + rec_data['has_group_tags'] = any(tag.is_group_tag for tag in visible_tags) if visible_tags else False + rec_data['share_info'] = { + 'share_id': share.id, + 'owner_username': share.owner.username if SHOW_USERNAMES_IN_UI else None, + 'can_edit': share.can_edit, + 'can_reshare': share.can_reshare, + 'shared_at': share.created_at.isoformat() + } + result.append(rec_data) + + return jsonify(result) + + except Exception as e: + current_app.logger.error(f"Error fetching shared recordings: {e}") + return jsonify({'error': str(e)}), 500 + + +@shares_bp.route('/api/permissions/can-share-publicly', methods=['GET']) +@login_required +def can_share_publicly(): + """Check if the current user has permission to create public shares.""" + return jsonify({ + 'can_share_publicly': current_user.can_share_publicly and ENABLE_PUBLIC_SHARING + }) diff --git a/src/api/speakers.py b/src/api/speakers.py new file mode 100644 index 0000000..2151edf --- /dev/null +++ b/src/api/speakers.py @@ -0,0 +1,583 @@ +""" +Speaker identification and management. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import time +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * +from src.utils.ffmpeg_utils import extract_audio_segment, FFmpegError, FFmpegNotFoundError +from src.utils.ffprobe import get_codec_info, FFProbeError +from src.services.speaker_embedding_matcher import find_matching_speakers +from src.services.speaker_snippets import get_speaker_snippets, get_speaker_recordings_with_snippets +from src.services.speaker_merge import merge_speakers, preview_merge, can_merge_speakers + +# Create blueprint +speakers_bp = Blueprint('speakers', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_speakers_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@speakers_bp.route('/speakers', methods=['GET']) +@login_required +def get_speakers(): + """Get all speakers for the current user, ordered by usage frequency and recency.""" + try: + speakers = Speaker.query.filter_by(user_id=current_user.id)\ + .order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\ + .all() + return jsonify([speaker.to_dict() for speaker in speakers]) + except Exception as e: + current_app.logger.error(f"Error fetching speakers: {e}") + return jsonify({'error': str(e)}), 500 + + + +@speakers_bp.route('/speakers/search', methods=['GET']) +@login_required +def search_speakers(): + """Search speakers by name for autocomplete functionality.""" + try: + query = request.args.get('q', '').strip() + if not query: + return jsonify([]) + + speakers = Speaker.query.filter_by(user_id=current_user.id)\ + .filter(Speaker.name.ilike(f'%{query}%'))\ + .order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\ + .limit(10)\ + .all() + + return jsonify([speaker.to_dict() for speaker in speakers]) + except Exception as e: + current_app.logger.error(f"Error searching speakers: {e}") + return jsonify({'error': str(e)}), 500 + + + +@speakers_bp.route('/speakers', methods=['POST']) +@login_required +def create_speaker(): + """Create a new speaker or update existing one.""" + try: + data = request.json + name = data.get('name', '').strip() + + if not name: + return jsonify({'error': 'Speaker name is required'}), 400 + + # Check if speaker already exists for this user + existing_speaker = Speaker.query.filter_by(user_id=current_user.id, name=name).first() + + if existing_speaker: + # Update usage statistics + existing_speaker.use_count += 1 + existing_speaker.last_used = datetime.utcnow() + db.session.commit() + return jsonify(existing_speaker.to_dict()) + else: + # Create new speaker + speaker = Speaker( + name=name, + user_id=current_user.id, + use_count=1, + created_at=datetime.utcnow(), + last_used=datetime.utcnow() + ) + db.session.add(speaker) + db.session.commit() + return jsonify(speaker.to_dict()), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating speaker: {e}") + return jsonify({'error': str(e)}), 500 + + + +@speakers_bp.route('/speakers/', methods=['PUT']) +@login_required +def update_speaker(speaker_id): + """Update a speaker's name and cascade the change to all recordings.""" + try: + speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first() + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + data = request.json + new_name = data.get('name', '').strip() + + if not new_name: + return jsonify({'error': 'Speaker name cannot be empty'}), 400 + + # Check if another speaker with this name already exists for this user + existing_speaker = Speaker.query.filter_by(user_id=current_user.id, name=new_name).first() + if existing_speaker and existing_speaker.id != speaker_id: + return jsonify({'error': f'A speaker named "{new_name}" already exists'}), 400 + + # Store old name for updating transcript chunks and recordings + old_name = speaker.name + + # Update the speaker name + speaker.name = new_name + + # Update all transcript chunks that reference this speaker's old name + # This ensures the name change cascades to all recordings + from src.models import TranscriptChunk + chunks_updated = TranscriptChunk.query.filter_by( + user_id=current_user.id, + speaker_name=old_name + ).update({'speaker_name': new_name}) + + # Update Recording.participants field (comma-separated list of speakers) + # AND update speaker names in the transcription JSON + recordings_updated = 0 + user_recordings = Recording.query.filter_by(user_id=current_user.id).all() + + for recording in user_recordings: + updated = False + + # Update participants field if it contains the old name + if recording.participants and old_name in recording.participants: + # Replace exact speaker name matches in participants list + # Handle various formats: "Ross", "Ross, John", "John, Ross", etc. + participants_list = [p.strip() for p in recording.participants.split(',')] + if old_name in participants_list: + # Replace the old name with new name + participants_list = [new_name if p == old_name else p for p in participants_list] + recording.participants = ', '.join(participants_list) + updated = True + + # Update speaker names in the transcription JSON + # This is what displays in the transcript view speaker badges + if recording.transcription: + try: + transcription_data = json.loads(recording.transcription) + + # Handle JSON format (array of segments with speaker field) + if isinstance(transcription_data, list): + segments_updated = False + for segment in transcription_data: + if segment.get('speaker') == old_name: + segment['speaker'] = new_name + segments_updated = True + + if segments_updated: + recording.transcription = json.dumps(transcription_data) + updated = True + except (json.JSONDecodeError, TypeError): + # Not JSON or invalid format, skip + pass + + if updated: + recordings_updated += 1 + + db.session.commit() + + current_app.logger.info( + f"Updated speaker {speaker_id} from '{old_name}' to '{new_name}': " + f"{chunks_updated} transcript chunks, {recordings_updated} recordings" + ) + + return jsonify({ + 'success': True, + 'speaker': speaker.to_dict(), + 'chunks_updated': chunks_updated, + 'recordings_updated': recordings_updated + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating speaker: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers/', methods=['DELETE']) +@login_required +def delete_speaker(speaker_id): + """Delete a speaker.""" + try: + speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first() + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + db.session.delete(speaker) + db.session.commit() + return jsonify({'success': True}) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error deleting speaker: {e}") + return jsonify({'error': str(e)}), 500 + + + +@speakers_bp.route('/speakers/delete_all', methods=['DELETE']) +@login_required +def delete_all_speakers(): + """Delete all speakers for the current user.""" + try: + deleted_count = Speaker.query.filter_by(user_id=current_user.id).delete() + db.session.commit() + return jsonify({'success': True, 'deleted_count': deleted_count}) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error deleting all speakers: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers/suggestions/', methods=['GET']) +@login_required +def get_speaker_suggestions(recording_id): + """ + Get speaker suggestions based on voice embeddings from a recording. + + For each speaker in the recording, returns matching speakers from the user's + speaker database based on voice similarity. + + Returns: + { + 'SPEAKER_00': [ + {'speaker_id': 5, 'name': 'John', 'similarity': 85.3, 'confidence': 0.92}, + ... + ], + 'SPEAKER_01': [...], + ... + } + """ + try: + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + # Get speaker embeddings from recording + if not recording.speaker_embeddings: + return jsonify({'suggestions': {}, 'message': 'No speaker embeddings available'}), 200 + + try: + embeddings_data = json.loads(recording.speaker_embeddings) if isinstance(recording.speaker_embeddings, str) else recording.speaker_embeddings + except (json.JSONDecodeError, TypeError): + return jsonify({'error': 'Invalid speaker embeddings data'}), 500 + + # Get similarity threshold from query params (default 70%) + threshold = float(request.args.get('threshold', 0.70)) + + # Find matches for each speaker + suggestions = {} + for speaker_label, embedding in embeddings_data.items(): + if embedding and len(embedding) == 256: # Validate embedding dimension + matches = find_matching_speakers(embedding, current_user.id, threshold) + suggestions[speaker_label] = matches + else: + suggestions[speaker_label] = [] + + return jsonify({ + 'success': True, + 'suggestions': suggestions, + 'recording_id': recording_id + }) + + except Exception as e: + current_app.logger.error(f"Error getting speaker suggestions: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers//snippets', methods=['GET']) +@login_required +def get_snippets(speaker_id): + """ + Get representative speech snippets for a speaker. + + Returns recent quotes from recordings where this speaker appeared. + """ + try: + # Verify speaker belongs to user + speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first() + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + limit = int(request.args.get('limit', 5)) + snippets = get_speaker_snippets(speaker_id, limit) + + return jsonify({ + 'success': True, + 'speaker_id': speaker_id, + 'speaker_name': speaker.name, + 'snippets': snippets + }) + + except Exception as e: + current_app.logger.error(f"Error getting speaker snippets: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers//recordings', methods=['GET']) +@login_required +def get_speaker_recordings(speaker_id): + """ + Get list of recordings that contain snippets from this speaker. + + Returns recording metadata with snippet counts. + """ + try: + # Verify speaker belongs to user + speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first() + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + recordings = get_speaker_recordings_with_snippets(speaker_id) + + return jsonify({ + 'success': True, + 'speaker_id': speaker_id, + 'speaker_name': speaker.name, + 'recordings': recordings + }) + + except Exception as e: + current_app.logger.error(f"Error getting speaker recordings: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers//clear_embeddings', methods=['POST']) +@login_required +def clear_speaker_embeddings(speaker_id): + """ + Clear all voice embeddings for a speaker. + + This removes all voice recognition data but keeps the speaker name and metadata. + Useful for resetting voice profiles or removing outdated/incorrect voice data. + """ + try: + # Verify speaker belongs to user + speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first() + if not speaker: + return jsonify({'error': 'Speaker not found'}), 404 + + # Clear all embeddings + speaker.voice_embeddings = None + speaker.embedding_count = 0 + speaker.confidence_score = None + + db.session.commit() + + current_app.logger.info(f"Cleared voice embeddings for speaker {speaker_id} ({speaker.name})") + + return jsonify({ + 'success': True, + 'message': f'Voice profile cleared for {speaker.name}', + 'speaker': { + 'id': speaker.id, + 'name': speaker.name, + 'embedding_count': 0 + } + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error clearing speaker embeddings: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers/snippet-audio/', methods=['GET']) +@login_required +def get_snippet_audio(recording_id): + """ + Serve a short audio snippet from a recording. + + Query parameters: + start: Start time in seconds (float) + duration: Duration in seconds (float, max 5.0) + + Returns: + Audio file segment in the original format + """ + import tempfile + import os + from pathlib import Path + + try: + # Get query parameters + start_time = float(request.args.get('start', 0)) + duration = min(float(request.args.get('duration', 4.0)), 5.0) # Max 5 seconds + + # Get the recording + recording = db.session.get(Recording, recording_id) + if not recording: + return jsonify({'error': 'Recording not found'}), 404 + + if not has_recording_access(recording, current_user, require_edit=False): + return jsonify({'error': 'You do not have permission to access this recording'}), 403 + + if recording.audio_deleted_at: + return jsonify({'error': 'Audio file has been deleted'}), 410 + + if not recording.audio_path or not os.path.exists(recording.audio_path): + return jsonify({'error': 'Audio file not found'}), 404 + + # Detect audio codec to pick the right output container for stream copy + codec_to_container = { + 'mp3': ('.mp3', 'audio/mpeg'), + 'aac': ('.m4a', 'audio/mp4'), + 'opus': ('.ogg', 'audio/ogg'), + 'vorbis': ('.ogg', 'audio/ogg'), + 'flac': ('.flac', 'audio/flac'), + 'pcm_s16le': ('.wav', 'audio/wav'), + 'pcm_s24le': ('.wav', 'audio/wav'), + 'pcm_s32le': ('.wav', 'audio/wav'), + 'pcm_f32le': ('.wav', 'audio/wav'), + } + snippet_ext = '.mp3' + snippet_mime = 'audio/mpeg' + try: + codec_info = get_codec_info(recording.audio_path, timeout=10) + audio_codec = codec_info.get('audio_codec') + if audio_codec and audio_codec in codec_to_container: + snippet_ext, snippet_mime = codec_to_container[audio_codec] + except FFProbeError: + pass # Fall back to mp3 + + # Create temporary file for the snippet + with tempfile.NamedTemporaryFile(delete=False, suffix=snippet_ext) as tmp_file: + output_path = tmp_file.name + + try: + # Use centralized FFmpeg utility to extract the audio segment + extract_audio_segment( + recording.audio_path, + output_path, + start_time, + duration + ) + + # Send the file + response = send_file( + output_path, + mimetype=snippet_mime, + as_attachment=False, + download_name=f'snippet_{recording_id}_{start_time:.1f}s{snippet_ext}' + ) + + # Clean up temporary file after sending + @response.call_on_close + def cleanup(): + try: + os.unlink(output_path) + except: + pass + + return response + + except FFmpegNotFoundError as e: + current_app.logger.error(f"FFmpeg not found: {e}") + try: + os.unlink(output_path) + except: + pass + return jsonify({'error': 'FFmpeg not found on server'}), 500 + except FFmpegError as e: + current_app.logger.error(f"FFmpeg error extracting snippet: {e}") + try: + os.unlink(output_path) + except: + pass + return jsonify({'error': 'Failed to extract audio snippet'}), 500 + + except ValueError: + return jsonify({'error': 'Invalid start time or duration'}), 400 + except Exception as e: + current_app.logger.error(f"Error serving audio snippet: {e}") + return jsonify({'error': str(e)}), 500 + + +@speakers_bp.route('/speakers/merge', methods=['POST']) +@login_required +def merge_speaker_profiles(): + """ + Merge multiple speaker profiles into one. + + Request body: + { + 'target_id': 5, # Speaker to keep + 'source_ids': [6, 7, 8], # Speakers to merge into target + 'preview': false # Optional: if true, just preview without executing + } + + Returns merged speaker data or preview statistics. + """ + try: + data = request.json + target_id = data.get('target_id') + source_ids = data.get('source_ids', []) + preview = data.get('preview', False) + + if not target_id: + return jsonify({'error': 'target_id is required'}), 400 + + if not source_ids or not isinstance(source_ids, list): + return jsonify({'error': 'source_ids must be a non-empty list'}), 400 + + # Validate speakers can be merged + can_merge, error_msg = can_merge_speakers([target_id] + source_ids, current_user.id) + if not can_merge: + return jsonify({'error': error_msg}), 400 + + if preview: + # Just return preview statistics + preview_data = preview_merge(target_id, source_ids, current_user.id) + return jsonify({ + 'success': True, + 'preview': preview_data + }) + else: + # Execute the merge + merged_speaker = merge_speakers(target_id, source_ids, current_user.id) + return jsonify({ + 'success': True, + 'message': f'Successfully merged {len(source_ids)} speaker(s) into {merged_speaker.name}', + 'speaker': merged_speaker.to_dict() + }) + + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error merging speakers: {e}") + return jsonify({'error': str(e)}), 500 + + + diff --git a/src/api/system.py b/src/api/system.py new file mode 100644 index 0000000..046aa78 --- /dev/null +++ b/src/api/system.py @@ -0,0 +1,299 @@ +""" +System info and configuration. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * +from src.config.version import get_version +from src.services.llm import TEXT_MODEL_BASE_URL, TEXT_MODEL_NAME +from src.config.app_config import ASR_BASE_URL, USE_NEW_TRANSCRIPTION_ARCHITECTURE +from src.services.token_tracking import token_tracker +from src.services.transcription import TranscriptionCapability + +# Create blueprint +system_bp = Blueprint('system', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +DELETION_MODE = os.environ.get('DELETION_MODE', 'full_recording') # 'audio_only' or 'full_recording' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' +ENABLE_CHUNKING = os.environ.get('ENABLE_CHUNKING', 'true').lower() == 'true' +SHOW_USERNAMES_IN_UI = os.environ.get('SHOW_USERNAMES_IN_UI', 'false').lower() == 'true' +ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true' +ENABLE_INCOGNITO_MODE = os.environ.get('ENABLE_INCOGNITO_MODE', 'false').lower() == 'true' +INCOGNITO_MODE_DEFAULT = os.environ.get('INCOGNITO_MODE_DEFAULT', 'false').lower() == 'true' +VIDEO_RETENTION = os.environ.get('VIDEO_RETENTION', 'false').lower() == 'true' +MAX_CONCURRENT_UPLOADS = int(os.environ.get('MAX_CONCURRENT_UPLOADS', '3')) + +# Import chunking service (will be set from app) +chunking_service = None + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_system_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter, chunking_service + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + chunking_service = kwargs.get('chunking_service') + + +def csrf_exempt(f): + """Decorator placeholder for CSRF exemption - applied after initialization.""" + from functools import wraps + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + wrapper._csrf_exempt = True + return wrapper + + +# --- Routes --- + +@system_bp.route('/api/user/preferences', methods=['POST']) +@login_required +def save_user_preferences(): + """Save user preferences including UI language""" + data = request.json + + if 'language' in data: + current_user.ui_language = data['language'] + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Preferences saved successfully', + 'ui_language': current_user.ui_language + }) + + +@system_bp.route('/api/user/token-budget', methods=['GET']) +@login_required +def get_user_token_budget(): + """Get current user's token budget status.""" + try: + user = current_user + + # If user has no budget, return null to indicate unlimited + if not user.monthly_token_budget: + return jsonify({ + 'has_budget': False, + 'budget': None, + 'usage': 0, + 'percentage': 0 + }) + + # Get current usage + current_usage = token_tracker.get_monthly_usage(user.id) + percentage = (current_usage / user.monthly_token_budget) * 100 + + return jsonify({ + 'has_budget': True, + 'budget': user.monthly_token_budget, + 'usage': current_usage, + 'percentage': round(percentage, 1) + }) + except Exception as e: + current_app.logger.error(f"Error getting token budget for user {current_user.id}: {e}") + return jsonify({'error': str(e)}), 500 + + +# --- System Info API Endpoint --- + + +@system_bp.route('/api/system/info', methods=['GET']) +def get_system_info(): + """Get system information including version and model details.""" + try: + # Use the same version detection logic as startup + version = get_version() + + # Get transcription connector info + transcription_info = { + 'connector': 'unknown', + 'model': None, + 'supports_diarization': USE_ASR_ENDPOINT, # Backwards compatible default + 'supports_speaker_embeddings': False, + } + + if USE_NEW_TRANSCRIPTION_ARCHITECTURE: + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.get_active_connector() + if connector: + transcription_info = { + 'connector': registry.get_active_connector_name(), + 'model': getattr(connector, 'model', None), # Model name if available + 'supports_diarization': connector.supports_diarization, + 'supports_speaker_embeddings': connector.supports(TranscriptionCapability.SPEAKER_EMBEDDINGS), + } + except Exception as e: + current_app.logger.warning(f"Could not get connector info: {e}") + + # Determine ASR status from connector (new arch) or env var (legacy) + is_asr_connector = transcription_info.get('connector') == 'asr_endpoint' + asr_enabled = is_asr_connector or USE_ASR_ENDPOINT + + # Determine the active transcription endpoint based on which connector is in use + if asr_enabled: + active_endpoint = ASR_BASE_URL + else: + active_endpoint = os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1') + + return jsonify({ + 'version': version, + 'llm_endpoint': TEXT_MODEL_BASE_URL, + 'llm_model': TEXT_MODEL_NAME, + 'transcription_endpoint': active_endpoint, # The actual endpoint being used + 'asr_enabled': asr_enabled, + # Legacy fields for backwards compatibility + 'whisper_endpoint': os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1'), + 'asr_endpoint': ASR_BASE_URL if asr_enabled else None, + 'transcription': transcription_info, + }) + except Exception as e: + current_app.logger.error(f"Error getting system info: {e}") + return jsonify({'error': 'Unable to retrieve system information'}), 500 + +# --- Tag API Endpoints --- + + +@system_bp.route('/api/config', methods=['GET']) +def get_config(): + """Get application configuration settings for the frontend.""" + try: + # Get configurable file size limit + max_file_size_mb = SystemSetting.get_setting('max_file_size_mb', 250) + + # Get chunking configuration (supports both legacy and new formats) + chunking_info = {} + if ENABLE_CHUNKING and chunking_service: + mode, limit_value = chunking_service.parse_chunk_limit() + chunking_info = { + 'chunking_enabled': True, + 'chunking_mode': mode, # 'size' or 'duration' + 'chunking_limit': limit_value, # Value in MB or seconds + 'chunking_limit_display': f"{limit_value}{'MB' if mode == 'size' else 's'}" + } + else: + chunking_info = { + 'chunking_enabled': False, + 'chunking_mode': 'size', + 'chunking_limit': 20, + 'chunking_limit_display': '20MB' + } + + # Check if current user can delete (for authenticated requests) + can_delete = True # Default to true for unauthenticated config requests + try: + from flask_login import current_user + if current_user and current_user.is_authenticated: + can_delete = USERS_CAN_DELETE or current_user.is_admin + except: + pass # If not authenticated, use default + + # Calculate if archive toggle should be shown (only when audio-only deletion mode is active) + enable_archive_toggle = ENABLE_AUTO_DELETION and DELETION_MODE == 'audio_only' + + # Get connector capabilities (new architecture) + # Defaults to USE_ASR_ENDPOINT for backwards compatibility + connector_supports_diarization = USE_ASR_ENDPOINT + connector_supports_speaker_count = USE_ASR_ENDPOINT # ASR endpoint supports min/max speakers + is_asr_connector = False + if USE_NEW_TRANSCRIPTION_ARCHITECTURE: + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.get_active_connector() + if connector: + connector_supports_diarization = connector.supports_diarization + connector_supports_speaker_count = connector.supports_speaker_count_control + is_asr_connector = registry.get_active_connector_name() == 'asr_endpoint' + except Exception as e: + current_app.logger.warning(f"Could not get connector capabilities: {e}") + + # Derive ASR status from connector or legacy env var + asr_enabled = is_asr_connector or USE_ASR_ENDPOINT + + return jsonify({ + 'max_file_size_mb': max_file_size_mb, + 'recording_disclaimer': SystemSetting.get_setting('recording_disclaimer', ''), + 'upload_disclaimer': SystemSetting.get_setting('upload_disclaimer', ''), + 'custom_banner': SystemSetting.get_setting('custom_banner', ''), + 'use_asr_endpoint': asr_enabled, # Derived from connector or legacy env var + 'connector_supports_diarization': connector_supports_diarization, # Connector capability + 'connector_supports_speaker_count': connector_supports_speaker_count, # Min/max speakers + 'enable_internal_sharing': ENABLE_INTERNAL_SHARING, + 'enable_archive_toggle': enable_archive_toggle, + 'show_usernames_in_ui': SHOW_USERNAMES_IN_UI, + 'can_delete_recordings': can_delete, + 'users_can_delete_enabled': USERS_CAN_DELETE, + 'enable_incognito_mode': ENABLE_INCOGNITO_MODE, + 'incognito_mode_default': INCOGNITO_MODE_DEFAULT, + 'enable_folders': SystemSetting.get_setting('enable_folders', False) == True, + 'enable_auto_export': ENABLE_AUTO_EXPORT, + 'video_retention': VIDEO_RETENTION, + 'max_concurrent_uploads': MAX_CONCURRENT_UPLOADS, + **chunking_info + }) + except Exception as e: + current_app.logger.error(f"Error fetching configuration: {e}") + return jsonify({'error': str(e)}), 500 + + + + +@system_bp.route('/api/csrf-token', methods=['GET']) +@csrf_exempt # Exempt this endpoint from CSRF protection since it's providing tokens +def get_csrf_token(): + """Get a fresh CSRF token for the frontend.""" + try: + from flask_wtf.csrf import generate_csrf + token = generate_csrf() + current_app.logger.info("Fresh CSRF token generated successfully") + return jsonify({'csrf_token': token}) + except Exception as e: + current_app.logger.error(f"Error generating CSRF token: {e}") + return jsonify({'error': str(e)}), 500 + +# --- Flask Routes --- + + +@system_bp.route('/api/permissions/can-delete', methods=['GET']) +@login_required +def check_deletion_permission(): + """Check if the current user can delete recordings.""" + try: + can_delete = USERS_CAN_DELETE or current_user.is_admin + return jsonify({ + 'can_delete': can_delete, + 'is_admin': current_user.is_admin, + 'users_can_delete_enabled': USERS_CAN_DELETE + }) + except Exception as e: + current_app.logger.error(f"Error checking deletion permissions: {e}") + return jsonify({'error': str(e)}), 500 + + + diff --git a/src/api/tags.py b/src/api/tags.py new file mode 100644 index 0000000..084fb61 --- /dev/null +++ b/src/api/tags.py @@ -0,0 +1,430 @@ +""" +Tag management and assignment. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import time +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from sqlalchemy.exc import IntegrityError + +from src.database import db +from src.models import * +from src.utils import * + +# Create blueprint +tags_bp = Blueprint('tags', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_tags_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@tags_bp.route('/api/tags', methods=['GET']) +@login_required +def get_tags(): + """Get all tags for the current user, including group tags they have access to.""" + # Get user's personal tags + user_tags = Tag.query.filter_by(user_id=current_user.id, group_id=None).order_by(Tag.name).all() + + # Get user's team memberships with roles + memberships = GroupMembership.query.filter_by(user_id=current_user.id).all() + team_roles = {m.group_id: m.role for m in memberships} + team_ids = list(team_roles.keys()) + + # Get group tags for all teams the user is a member of + team_tags = [] + if team_ids: + team_tags = Tag.query.filter(Tag.group_id.in_(team_ids)).order_by(Tag.name).all() + + # Build response with edit permissions + result = [] + + # Personal tags - user can always edit their own + for tag in user_tags: + tag_dict = tag.to_dict() + tag_dict['can_edit'] = True + tag_dict['user_role'] = None + result.append(tag_dict) + + # Group tags - only admins can edit + for tag in team_tags: + tag_dict = tag.to_dict() + user_role = team_roles.get(tag.group_id, 'member') + tag_dict['can_edit'] = (user_role == 'admin') + tag_dict['user_role'] = user_role + result.append(tag_dict) + + return jsonify(result) + + + +@tags_bp.route('/api/tags', methods=['POST']) +@login_required +def create_tag(): + """Create a new tag (personal or group tag).""" + data = request.get_json() + + if not data or not data.get('name'): + return jsonify({'error': 'Tag name is required'}), 400 + + group_id = data.get('group_id') + + # If creating a group tag, verify user is admin of that group + if group_id: + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can create group tags'}), 403 + + # Check if group tag with same name already exists for this group + existing_tag = Tag.query.filter_by(name=data['name'], group_id=group_id).first() + if existing_tag: + return jsonify({'error': 'A tag with this name already exists for this group'}), 400 + else: + # Check if personal tag with same name already exists for this user + existing_tag = Tag.query.filter_by(name=data['name'], user_id=current_user.id, group_id=None).first() + if existing_tag: + return jsonify({'error': 'Tag with this name already exists'}), 400 + + # Handle retention_days: -1 means protected from deletion + retention_days = data.get('retention_days') + protect_from_deletion = False + + if retention_days == -1: + # -1 indicates infinite retention (protected from auto-deletion) + protect_from_deletion = True if ENABLE_AUTO_DELETION else False + + # Validate naming_template_id if provided + naming_template_id = data.get('naming_template_id') + if naming_template_id: + from src.models import NamingTemplate + template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Naming template not found'}), 404 + + # Validate export_template_id if provided + export_template_id = data.get('export_template_id') + if export_template_id: + from src.models import ExportTemplate + template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Export template not found'}), 404 + + tag = Tag( + name=data['name'], + user_id=current_user.id, + group_id=group_id, + color=data.get('color', '#3B82F6'), + custom_prompt=data.get('custom_prompt'), + default_language=data.get('default_language'), + default_min_speakers=data.get('default_min_speakers'), + default_max_speakers=data.get('default_max_speakers'), + default_hotwords=data.get('default_hotwords'), + default_initial_prompt=data.get('default_initial_prompt'), + protect_from_deletion=protect_from_deletion, + retention_days=retention_days, + auto_share_on_apply=data.get('auto_share_on_apply', True) if group_id else True, + share_with_group_lead=data.get('share_with_group_lead', True) if group_id else True, + naming_template_id=naming_template_id, + export_template_id=export_template_id + ) + + db.session.add(tag) + + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f"Tag creation failed due to integrity constraint: {str(e)}") + return jsonify({'error': 'A tag with this name already exists'}), 400 + + return jsonify(tag.to_dict()), 201 + + + +@tags_bp.route('/api/tags/', methods=['PUT']) +@login_required +def update_tag(tag_id): + """Update a tag.""" + tag = db.session.get(Tag, tag_id) + if not tag: + return jsonify({'error': 'Tag not found'}), 404 + + # Check permissions + if tag.group_id: + # Group tag - user must be a team admin + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can edit group tags'}), 403 + else: + # Personal tag - must be the owner + if tag.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to edit this tag'}), 403 + + data = request.get_json() + + if 'name' in data: + # Check if new name conflicts with another tag + if tag.group_id: + existing_tag = Tag.query.filter_by(name=data['name'], group_id=tag.group_id).filter(Tag.id != tag_id).first() + else: + existing_tag = Tag.query.filter_by(name=data['name'], user_id=current_user.id).filter(Tag.id != tag_id).first() + + if existing_tag: + return jsonify({'error': 'Another tag with this name already exists'}), 400 + tag.name = data['name'] + + # Handle group_id changes (converting between personal and group tags) + if 'group_id' in data: + new_group_id = data['group_id'] if data['group_id'] else None + + # If changing to a group tag, verify user is admin of that group + if new_group_id: + membership = GroupMembership.query.filter_by( + group_id=new_group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can assign tags to groups'}), 403 + + tag.group_id = new_group_id + + if 'color' in data: + tag.color = data['color'] + if 'custom_prompt' in data: + tag.custom_prompt = data['custom_prompt'] + if 'default_language' in data: + tag.default_language = data['default_language'] + if 'default_min_speakers' in data: + tag.default_min_speakers = data['default_min_speakers'] + if 'default_max_speakers' in data: + tag.default_max_speakers = data['default_max_speakers'] + if 'default_hotwords' in data: + tag.default_hotwords = data['default_hotwords'] or None + if 'default_initial_prompt' in data: + tag.default_initial_prompt = data['default_initial_prompt'] or None + + # Handle retention_days: -1 means protected from deletion + if 'retention_days' in data: + retention_days = data['retention_days'] + + if retention_days == -1: + # -1 indicates infinite retention (protected from auto-deletion) + if ENABLE_AUTO_DELETION: + tag.protect_from_deletion = True + tag.retention_days = -1 + else: + # Regular retention period or null (use global) + tag.protect_from_deletion = False + tag.retention_days = retention_days if retention_days else None + if 'auto_share_on_apply' in data: + # Only applicable to group tags + if tag.group_id: + tag.auto_share_on_apply = bool(data['auto_share_on_apply']) + if 'share_with_group_lead' in data: + # Only applicable to group tags + if tag.group_id: + tag.share_with_group_lead = bool(data['share_with_group_lead']) + if 'naming_template_id' in data: + naming_template_id = data['naming_template_id'] + if naming_template_id: + from src.models import NamingTemplate + template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Naming template not found'}), 404 + tag.naming_template_id = naming_template_id if naming_template_id else None + if 'export_template_id' in data: + export_template_id = data['export_template_id'] + if export_template_id: + from src.models import ExportTemplate + template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Export template not found'}), 404 + tag.export_template_id = export_template_id if export_template_id else None + + tag.updated_at = datetime.utcnow() + + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f"Tag update failed due to integrity constraint: {str(e)}") + return jsonify({'error': 'A tag with this name already exists'}), 400 + + return jsonify(tag.to_dict()) + + + +@tags_bp.route('/api/tags/', methods=['DELETE']) +@login_required +def delete_tag(tag_id): + """Delete a tag.""" + tag = db.session.get(Tag, tag_id) + if not tag: + return jsonify({'error': 'Tag not found'}), 404 + + # Check permissions + if tag.group_id: + # Group tag - user must be a team admin + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can delete group tags'}), 403 + else: + # Personal tag - must belong to the user + if tag.user_id != current_user.id: + return jsonify({'error': 'You do not have permission to delete this tag'}), 403 + + db.session.delete(tag) + db.session.commit() + return jsonify({'success': True}) + + + +@tags_bp.route('/api/groups//tags', methods=['POST']) +@login_required +def create_group_tag(group_id): + """Create a group-scoped tag (group admins only).""" + if not ENABLE_INTERNAL_SHARING: + return jsonify({'error': 'Group tags require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403 + + # Verify team exists + team = db.session.get(Group, group_id) + if not team: + return jsonify({'error': 'Group not found'}), 404 + + # Verify user is a team admin + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + + if not membership or membership.role != 'admin': + return jsonify({'error': 'Only group admins can create group tags'}), 403 + + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify({'error': 'Tag name is required'}), 400 + + # Check if a group tag with this name already exists for this team + existing_tag = Tag.query.filter_by( + name=name, + group_id=group_id + ).first() + + if existing_tag: + return jsonify({'error': 'A group tag with this name already exists'}), 400 + + # Validate naming_template_id if provided + naming_template_id = data.get('naming_template_id') + if naming_template_id: + from src.models import NamingTemplate + template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Naming template not found'}), 404 + + # Validate export_template_id if provided + export_template_id = data.get('export_template_id') + if export_template_id: + from src.models import ExportTemplate + template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first() + if not template: + return jsonify({'error': 'Export template not found'}), 404 + + # Create the group tag with all supported parameters + tag = Tag( + name=name, + user_id=current_user.id, # Creator + group_id=group_id, + color=data.get('color', '#3B82F6'), + custom_prompt=data.get('custom_prompt'), + default_language=data.get('default_language'), + default_min_speakers=data.get('default_min_speakers'), + default_max_speakers=data.get('default_max_speakers'), + default_hotwords=data.get('default_hotwords'), + default_initial_prompt=data.get('default_initial_prompt'), + protect_from_deletion=data.get('protect_from_deletion', False), + retention_days=data.get('retention_days'), + auto_share_on_apply=data.get('auto_share_on_apply', True), # Default to True for group tags + share_with_group_lead=data.get('share_with_group_lead', True), # Default to True for group tags + naming_template_id=naming_template_id, + export_template_id=export_template_id + ) + + db.session.add(tag) + + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f"Tag creation failed due to integrity constraint: {str(e)}") + return jsonify({'error': 'A tag with this name already exists'}), 400 + + return jsonify(tag.to_dict()), 201 + + + +@tags_bp.route('/api/groups//tags', methods=['GET']) +@login_required +def get_group_tags(group_id): + """Get all tags for a team (team members only).""" + # Verify team exists + team = db.session.get(Group, group_id) + if not team: + return jsonify({'error': 'Group not found'}), 404 + + # Verify user is a team member + membership = GroupMembership.query.filter_by( + group_id=group_id, + user_id=current_user.id + ).first() + + if not membership: + return jsonify({'error': 'You must be a team member to view group tags'}), 403 + + # Get all group tags + tags = Tag.query.filter_by(group_id=group_id).all() + + return jsonify({'tags': [tag.to_dict() for tag in tags]}) + + + diff --git a/src/api/templates.py b/src/api/templates.py new file mode 100644 index 0000000..34ef834 --- /dev/null +++ b/src/api/templates.py @@ -0,0 +1,232 @@ +""" +Transcript template management. + +This blueprint was auto-generated from app.py route extraction. +""" + +import os +import json +import time +from datetime import datetime, timedelta +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.database import db +from src.models import * +from src.utils import * + +# Create blueprint +templates_bp = Blueprint('templates', __name__) + +# Configuration from environment +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + +# Global helpers (will be injected from app) +has_recording_access = None +bcrypt = None +csrf = None +limiter = None + +def init_templates_helpers(**kwargs): + """Initialize helper functions and extensions from app.""" + global has_recording_access, bcrypt, csrf, limiter + has_recording_access = kwargs.get('has_recording_access') + bcrypt = kwargs.get('bcrypt') + csrf = kwargs.get('csrf') + limiter = kwargs.get('limiter') + + +# --- Routes --- + +@templates_bp.route('/api/transcript-templates', methods=['GET']) +@login_required +def get_transcript_templates(): + """Get all transcript templates for the current user.""" + templates = TranscriptTemplate.query.filter_by(user_id=current_user.id).all() + return jsonify([template.to_dict() for template in templates]) + + + +@templates_bp.route('/api/transcript-templates', methods=['POST']) +@login_required +def create_transcript_template(): + """Create a new transcript template.""" + data = request.json + if not data or not data.get('name') or not data.get('template'): + return jsonify({'error': 'Name and template are required'}), 400 + + # If this is set as default, unset other defaults + if data.get('is_default'): + TranscriptTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).update({'is_default': False}) + + template = TranscriptTemplate( + user_id=current_user.id, + name=data['name'], + template=data['template'], + description=data.get('description'), + is_default=data.get('is_default', False) + ) + + db.session.add(template) + db.session.commit() + + return jsonify(template.to_dict()), 201 + + + +@templates_bp.route('/api/transcript-templates/', methods=['PUT']) +@login_required +def update_transcript_template(template_id): + """Update an existing transcript template.""" + template = TranscriptTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + data = request.json + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # If this is set as default, unset other defaults + if data.get('is_default'): + TranscriptTemplate.query.filter_by( + user_id=current_user.id, + is_default=True + ).update({'is_default': False}) + + template.name = data.get('name', template.name) + template.template = data.get('template', template.template) + template.description = data.get('description', template.description) + template.is_default = data.get('is_default', template.is_default) + template.updated_at = datetime.utcnow() + + db.session.commit() + + return jsonify(template.to_dict()) + + + +@templates_bp.route('/api/transcript-templates/', methods=['DELETE']) +@login_required +def delete_transcript_template(template_id): + """Delete a transcript template.""" + template = TranscriptTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + + if not template: + return jsonify({'error': 'Template not found'}), 404 + + db.session.delete(template) + db.session.commit() + + return jsonify({'success': True}) + + + +@templates_bp.route('/api/transcript-templates/create-defaults', methods=['POST']) +@login_required +def create_default_templates(): + """Create default templates for the user if they don't have any.""" + existing_templates = TranscriptTemplate.query.filter_by(user_id=current_user.id).count() + + if existing_templates > 0: + return jsonify({'message': 'User already has templates'}), 200 + + templates = [] + + # Default template 1: Conversation simple + template1 = TranscriptTemplate( + user_id=current_user.id, + name="Conversation simple", + template="{{speaker}}: {{text}}", + description="Format épuré avec noms des intervenants et texte", + is_default=True + ) + templates.append(template1) + + # Default template 2: Horodaté + template2 = TranscriptTemplate( + user_id=current_user.id, + name="Horodaté", + template="[{{start_time}} - {{end_time}}] {{speaker}}: {{text}}", + description="Format avec horodatage et noms des intervenants", + is_default=False + ) + templates.append(template2) + + # Default template 3: Entrevue / Interrogatoire + template3 = TranscriptTemplate( + user_id=current_user.id, + name="Entrevue / Interrogatoire", + template="{{speaker|upper}}:\n{{text}}\n", + description="Format questions-réponses avec noms en majuscules — idéal pour interrogatoires et entrevues", + is_default=False + ) + templates.append(template3) + + # Default template 4: Consultation client + template4 = TranscriptTemplate( + user_id=current_user.id, + name="Consultation client", + template="[{{start_time}}] {{speaker}}: {{text}}", + description="Rencontres client avec horodatage — consultation juridique, prise de notes", + is_default=False + ) + templates.append(template4) + + # Default template 5: Verbatim numéroté + template5 = TranscriptTemplate( + user_id=current_user.id, + name="Verbatim numéroté", + template="{{index}} [{{start_time}}] {{speaker|upper}}: {{text}}", + description="Transcription certifiable avec numéros de ligne et horodatage — dépositions, audiences", + is_default=False + ) + templates.append(template5) + + # Default template 6: Dictée juridique + template6 = TranscriptTemplate( + user_id=current_user.id, + name="Dictée juridique", + template="{{text}}", + description="Texte continu sans locuteur — pour dictées de lettres, mises en demeure, procédures", + is_default=False + ) + templates.append(template6) + + # Default template 7: Procès-verbal formel + template7 = TranscriptTemplate( + user_id=current_user.id, + name="Procès-verbal formel", + template="• [{{start_time}}] {{speaker}}: {{text}}", + description="Format à puces horodatées — PV de réunion, assemblées, conseil d'administration", + is_default=False + ) + templates.append(template7) + + # Add all templates to database + for template in templates: + db.session.add(template) + + db.session.commit() + + return jsonify({ + 'success': True, + 'templates': [template.to_dict() for template in templates] + }), 201 + + + diff --git a/src/api/tokens.py b/src/api/tokens.py new file mode 100644 index 0000000..1c19339 --- /dev/null +++ b/src/api/tokens.py @@ -0,0 +1,187 @@ +""" +API Token management routes. + +This blueprint handles creating, listing, and revoking API tokens +for user authentication. +""" + +import secrets +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user + +from src.database import db +from src.models import APIToken +from src.utils.token_auth import hash_token + +# Create blueprint +tokens_bp = Blueprint('tokens', __name__, url_prefix='/api/tokens') + +# Extensions (injected after app initialization) +bcrypt = None +csrf = None +limiter = None + + +def init_tokens_helpers(_bcrypt, _csrf, _limiter): + """Initialize extensions after app creation.""" + global bcrypt, csrf, limiter + bcrypt = _bcrypt + csrf = _csrf + limiter = _limiter + + +def rate_limit(limit_string): + """Decorator that applies rate limiting if limiter is available.""" + def decorator(f): + from functools import wraps + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + wrapper._rate_limit = limit_string + return wrapper + return decorator + + +def generate_token(): + """ + Generate a secure random API token. + + Returns: + str: A cryptographically secure random token + """ + return secrets.token_urlsafe(32) + + +@tokens_bp.route('', methods=['GET']) +@login_required +def list_tokens(): + """ + List all API tokens for the current user. + + Returns: + JSON: List of token objects (without the actual token values) + """ + tokens = APIToken.query.filter_by(user_id=current_user.id).all() + return jsonify({ + 'tokens': [token.to_dict() for token in tokens] + }) + + +@tokens_bp.route('', methods=['POST']) +@login_required +@rate_limit("10 per hour") +def create_token(): + """ + Create a new API token for the current user. + + Request JSON: + name (str, optional): A friendly name for the token + expires_in_days (int, optional): Number of days until expiration (0 = no expiration) + + Returns: + JSON: The new token object including the plaintext token (shown only once) + """ + data = request.get_json() + + # Validate input + name = data.get('name', 'Unnamed Token') + expires_in_days = data.get('expires_in_days', 0) + + # Validate expiration + if not isinstance(expires_in_days, int) or expires_in_days < 0: + return jsonify({'error': 'expires_in_days must be a non-negative integer'}), 400 + + # Generate the token + plaintext_token = generate_token() + token_hash = hash_token(plaintext_token) + + # Calculate expiration date + expires_at = None + if expires_in_days > 0: + expires_at = datetime.utcnow() + timedelta(days=expires_in_days) + + # Create the token record + api_token = APIToken( + user_id=current_user.id, + token_hash=token_hash, + name=name, + expires_at=expires_at + ) + + db.session.add(api_token) + db.session.commit() + + # Return the token data INCLUDING the plaintext token + # This is the only time the plaintext token will be shown + response = api_token.to_dict() + response['token'] = plaintext_token + + return jsonify(response), 201 + + +@tokens_bp.route('/', methods=['DELETE']) +@login_required +@rate_limit("20 per hour") +def revoke_token(token_id): + """ + Revoke (delete) an API token. + + Args: + token_id (int): The ID of the token to revoke + + Returns: + JSON: Success message + """ + # Find the token + api_token = APIToken.query.filter_by( + id=token_id, + user_id=current_user.id + ).first() + + if not api_token: + return jsonify({'error': 'Token not found'}), 404 + + # Delete the token + db.session.delete(api_token) + db.session.commit() + + return jsonify({'message': 'Token revoked successfully'}), 200 + + +@tokens_bp.route('/', methods=['PATCH']) +@login_required +@rate_limit("20 per hour") +def update_token(token_id): + """ + Update an API token's metadata (name only). + + Args: + token_id (int): The ID of the token to update + + Request JSON: + name (str): The new name for the token + + Returns: + JSON: Updated token object + """ + # Find the token + api_token = APIToken.query.filter_by( + id=token_id, + user_id=current_user.id + ).first() + + if not api_token: + return jsonify({'error': 'Token not found'}), 404 + + # Update the name + data = request.get_json() + new_name = data.get('name') + + if not new_name: + return jsonify({'error': 'name is required'}), 400 + + api_token.name = new_name + db.session.commit() + + return jsonify(api_token.to_dict()), 200 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..3f78311 --- /dev/null +++ b/src/app.py @@ -0,0 +1,668 @@ +# Speakr - Audio Transcription and Summarization App +import os +import sys +from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for, flash, Response, make_response +from urllib.parse import urlparse, urljoin, quote +from email.utils import encode_rfc2231 +from markupsafe import Markup +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime, timedelta +from openai import OpenAI # Keep using the OpenAI library +import json +from werkzeug.utils import secure_filename +from werkzeug.exceptions import RequestEntityTooLarge +from werkzeug.middleware.proxy_fix import ProxyFix +from sqlalchemy import select +from sqlalchemy.orm import joinedload +import threading +from dotenv import load_dotenv # Import load_dotenv +import httpx +import re +import subprocess +import mimetypes +import markdown +import bleach + +# Add common audio MIME type mappings that might be missing +mimetypes.add_type('audio/mp4', '.m4a') +mimetypes.add_type('audio/aac', '.aac') +mimetypes.add_type('audio/x-m4a', '.m4a') +mimetypes.add_type('audio/webm', '.webm') +mimetypes.add_type('audio/flac', '.flac') +mimetypes.add_type('audio/ogg', '.ogg') +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +from flask_bcrypt import Bcrypt +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from wtforms import StringField, PasswordField, SubmitField, BooleanField +from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +import pytz +from babel.dates import format_datetime +import ast +import logging +import secrets +import time +from src.audio_chunking import AudioChunkingService, ChunkProcessingError, ChunkingNotSupportedError + +# Optional imports for embedding functionality +try: + import numpy as np + from sentence_transformers import SentenceTransformer + from sklearn.metrics.pairwise import cosine_similarity + EMBEDDINGS_AVAILABLE = True +except ImportError as e: + EMBEDDINGS_AVAILABLE = False + # Create dummy classes to prevent import errors + class SentenceTransformer: + def __init__(self, *args, **kwargs): + pass + def encode(self, *args, **kwargs): + return [] + + np = None + cosine_similarity = None + +# Load environment variables from .env file +load_dotenv() + +# Early check for Inquire Mode configuration (needed for startup message) +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' + +# Auto-deletion and retention configuration +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +GLOBAL_RETENTION_DAYS = int(os.environ.get('GLOBAL_RETENTION_DAYS', '0')) # 0 = disabled +DELETION_MODE = os.environ.get('DELETION_MODE', 'full_recording') # 'audio_only' or 'full_recording' + +# Permission-based deletion control +USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true' # true = all users can delete, false = admin only + +# Internal sharing configuration +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' +SHOW_USERNAMES_IN_UI = os.environ.get('SHOW_USERNAMES_IN_UI', 'false').lower() == 'true' + +# Public sharing configuration +ENABLE_PUBLIC_SHARING = os.environ.get('ENABLE_PUBLIC_SHARING', 'true').lower() == 'true' + +# Video retention - when enabled, video files keep their video stream for playback +VIDEO_RETENTION = os.environ.get('VIDEO_RETENTION', 'false').lower() == 'true' + +# Audit logging for Loi 25 compliance +ENABLE_AUDIT_LOG = os.environ.get('ENABLE_AUDIT_LOG', 'false').lower() == 'true' + +# Log embedding status on startup +if ENABLE_INQUIRE_MODE and EMBEDDINGS_AVAILABLE: + print("✅ Inquire Mode: Full semantic search enabled (embeddings available)") +elif ENABLE_INQUIRE_MODE and not EMBEDDINGS_AVAILABLE: + print("⚠️ Inquire Mode: Basic text search only (embedding dependencies not available)") + print(" To enable semantic search, install: pip install sentence-transformers==2.7.0 huggingface-hub>=0.19.0") +elif not ENABLE_INQUIRE_MODE: + print("ℹ️ Inquire Mode: Disabled (set ENABLE_INQUIRE_MODE=true to enable)") + +# Log auto-deletion status +if ENABLE_AUTO_DELETION: + if GLOBAL_RETENTION_DAYS > 0: + print(f"✅ Auto-deletion: Enabled (global retention: {GLOBAL_RETENTION_DAYS} days, mode: {DELETION_MODE})") + else: + print("⚠️ Auto-deletion: Enabled but no global retention period set (configure GLOBAL_RETENTION_DAYS)") +else: + print("ℹ️ Auto-deletion: Disabled (set ENABLE_AUTO_DELETION=true to enable)") + +# Log deletion permissions +if USERS_CAN_DELETE: + print("ℹ️ User deletion: Enabled (all users can delete their recordings)") +else: + print("🔒 User deletion: Restricted (only admins can delete recordings)") + +# Log internal sharing status +if ENABLE_INTERNAL_SHARING: + username_visibility = "visible" if SHOW_USERNAMES_IN_UI else "hidden" + print(f"✅ Internal sharing: Enabled (usernames {username_visibility})") +else: + print("ℹ️ Internal sharing: Disabled (set ENABLE_INTERNAL_SHARING=true to enable)") + +# Log public sharing status +if ENABLE_PUBLIC_SHARING: + print("✅ Public sharing: Enabled (users can create public share links)") +else: + print("🔒 Public sharing: Disabled (public share links are not allowed)") + +# Log video retention status +if VIDEO_RETENTION: + print("✅ Video retention: Enabled (video files preserve video stream for playback)") +else: + print("ℹ️ Video retention: Disabled (video uploads extract audio only)") + +# Log audit status +if ENABLE_AUDIT_LOG: + print("✅ Audit logging: Enabled (Loi 25 compliance - access and auth events tracked)") +else: + print("ℹ️ Audit logging: Disabled (set ENABLE_AUDIT_LOG=true for Loi 25 compliance)") + +# Configure logging +log_level = os.environ.get('LOG_LEVEL', 'INFO').upper() +handler = logging.StreamHandler(sys.stdout) +handler.setLevel(log_level) +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) + +# Get the root logger and clear any existing handlers to avoid duplicates +root_logger = logging.getLogger() +root_logger.handlers.clear() +root_logger.setLevel(log_level) +root_logger.addHandler(handler) + +# Silence noisy markdown extension debug logs +markdown_logger = logging.getLogger('MARKDOWN') +markdown_logger.setLevel(logging.WARNING) + +# --- Initialize Markdown Once (Performance Optimization) --- +# Create a single reusable Markdown instance to avoid reinitializing extensions on every call +_markdown_instance = markdown.Markdown(extensions=[ + 'fenced_code', # Fenced code blocks + 'tables', # Table support + 'attr_list', # Attribute lists + 'def_list', # Definition lists + 'footnotes', # Footnotes + 'abbr', # Abbreviations + 'codehilite', # Syntax highlighting for code blocks + 'smarty' # Smart quotes, dashes, etc. +]) + +# --- Rate Limiting Setup (will be configured after app creation) --- +# TEMPORARILY INCREASED FOR TESTING - REVERT FOR PRODUCTION! +limiter = Limiter( + get_remote_address, + app=None, # Defer initialization + default_limits=["5000 per day", "1000 per hour"] # Increased from 200/day, 50/hour for testing +) + +# --- Utility Functions --- +# Utility functions (JSON parsing, markdown, datetime, security) have been extracted +# to src/utils/ and imported at the top of this file + +def has_recording_access(recording, user, require_edit=False, require_reshare=False): + """ + Check if a user has access to a recording. + + Args: + recording: Recording object to check access for + user: User object (typically current_user) + require_edit: If True, check for edit permission (default: False for view-only) + require_reshare: If True, check for reshare permission (default: False) + + Returns: + Boolean indicating if user has the required access level + """ + # Owner always has full access + if recording.user_id == user.id: + return True + + # If internal sharing is not enabled, only owner has access + if not ENABLE_INTERNAL_SHARING: + return False + + # Check for shared access + share = InternalShare.query.filter_by( + recording_id=recording.id, + shared_with_user_id=user.id + ).first() + + if not share: + return False + + # If edit permission is required, check for it + if require_edit: + # First check if share directly grants edit permission + if share.can_edit: + pass # Has direct edit permission + else: + # Check if user is a group admin for any group tag on this recording + # This grants edit permission even if share.can_edit is False + is_group_admin_for_recording = db.session.query(GroupMembership).join( + Tag, Tag.group_id == GroupMembership.group_id + ).join( + RecordingTag, RecordingTag.tag_id == Tag.id + ).filter( + RecordingTag.recording_id == recording.id, + GroupMembership.user_id == user.id, + GroupMembership.role == 'admin', + Tag.group_id.isnot(None), + db.or_(Tag.auto_share_on_apply == True, Tag.share_with_group_lead == True) + ).first() + + if not is_group_admin_for_recording: + return False + + # If reshare permission is required, check for it + if require_reshare and not share.can_reshare: + return False + + # User has at least view access + return True + + +def get_user_recording_status(recording, user): + """ + Get the inbox and highlighted status for a recording from a user's perspective. + + For owners: Returns status from Recording model + For shared recipients: Returns status from SharedRecordingState (creates default if not exists) + + Args: + recording: Recording object + user: User object (typically current_user) + + Returns: + Tuple of (is_inbox, is_highlighted) + """ + # Owner uses the Recording model's global fields + if recording.user_id == user.id: + return (recording.is_inbox, recording.is_highlighted) + + # Shared recipient uses SharedRecordingState + state = SharedRecordingState.query.filter_by( + recording_id=recording.id, + user_id=user.id + ).first() + + if state: + return (state.is_inbox, state.is_highlighted) + else: + # Return defaults if no state exists yet (inbox=True, highlighted=False) + return (True, False) + + +def set_user_recording_status(recording, user, is_inbox=None, is_highlighted=None): + """ + Set the inbox and/or highlighted status for a recording from a user's perspective. + + For owners: Updates Recording model + For shared recipients: Updates or creates SharedRecordingState + + Args: + recording: Recording object + user: User object (typically current_user) + is_inbox: Boolean or None (None means don't change) + is_highlighted: Boolean or None (None means don't change) + + Returns: + Tuple of (is_inbox, is_highlighted) after update + """ + # Owner updates the Recording model's global fields + if recording.user_id == user.id: + if is_inbox is not None: + recording.is_inbox = is_inbox + if is_highlighted is not None: + recording.is_highlighted = is_highlighted + db.session.commit() + return (recording.is_inbox, recording.is_highlighted) + + # Shared recipient uses SharedRecordingState + state = SharedRecordingState.query.filter_by( + recording_id=recording.id, + user_id=user.id + ).first() + + if not state: + # Create new state with defaults + state = SharedRecordingState( + recording_id=recording.id, + user_id=user.id, + is_inbox=True, + is_highlighted=False + ) + db.session.add(state) + + # Update the requested fields + if is_inbox is not None: + state.is_inbox = is_inbox + if is_highlighted is not None: + state.is_highlighted = is_highlighted + + db.session.commit() + return (state.is_inbox, state.is_highlighted) + + +def enrich_recording_dict_with_user_status(recording_dict, recording, user): + """ + Enrich a recording dictionary with per-user status (inbox, highlighted). + + This should be called after recording.to_dict() or recording.to_list_dict() + to replace the owner's status with the current user's per-user status. + + Args: + recording_dict: Dictionary from recording.to_dict() or recording.to_list_dict() + recording: Recording object + user: User object (typically current_user) + + Returns: + The enriched recording_dict (modified in place, but also returned for convenience) + """ + user_inbox, user_highlighted = get_user_recording_status(recording, user) + recording_dict['is_inbox'] = user_inbox + recording_dict['is_highlighted'] = user_highlighted + return recording_dict + + +app = Flask(__name__, + template_folder='../templates', + static_folder='../static') +# Use environment variables or default paths for Docker compatibility +app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI', 'sqlite:////data/instance/transcriptions.db') +app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', '/data/uploads') + +# SQLite concurrency settings for multi-worker job queue +if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']: + app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'connect_args': { + 'timeout': 30, # Wait up to 30 seconds for locked database + 'check_same_thread': False # Allow multi-threaded access + }, + 'pool_pre_ping': True # Verify connections before use + } +# MAX_CONTENT_LENGTH will be set dynamically after database initialization +# Set a secret key for session management and CSRF protection +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'default-dev-key-change-in-production') + +# Apply ProxyFix to handle headers from a reverse proxy (like Nginx or Caddy) +# This is crucial for request.is_secure to work correctly behind an SSL-terminating proxy. +trusted_proxy_hops = int(os.environ.get('TRUSTED_PROXY_HOPS', '1')) +app.wsgi_app = ProxyFix( + app.wsgi_app, + x_for=trusted_proxy_hops, + x_proto=trusted_proxy_hops, + x_host=trusted_proxy_hops, + x_prefix=trusted_proxy_hops +) + +# --- Secure Session Cookie Configuration --- +# For local network usage, disable secure cookies to allow HTTP connections +# Only enable secure cookies in production when HTTPS is actually being used +app.config['SESSION_COOKIE_SECURE'] = False # Allow HTTP for local network usage +app.config['SESSION_COOKIE_HTTPONLY'] = True # Still protect against XSS +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF protection + +# Import database instance from extracted module +from src.database import db +db.init_app(app) + +# Import all models from extracted modules +from src.models import ( + User, Speaker, Recording, TranscriptChunk, Share, InternalShare, + SharedRecordingState, Group, GroupMembership, Tag, RecordingTag, + Event, TranscriptTemplate, InquireSession, SystemSetting, PushSubscription, + APIToken, NamingTemplate, Folder, SpeakerSnippet, ShareAuditLog, + ProcessingJob, TokenUsage, TranscriptionUsage, + AccessLog, AuthLog, +) + +# Import utility functions from extracted modules +from src.utils import ( + auto_close_json, safe_json_loads, preprocess_json_escapes, extract_json_object, + md_to_html, sanitize_html, local_datetime_filter, password_check, + add_column_if_not_exists, is_safe_url +) + +# Import service layer functions +from src.services.embeddings import ( + get_embedding_model, chunk_transcription, generate_embeddings, + serialize_embedding, deserialize_embedding, get_accessible_recording_ids, + process_recording_chunks, basic_text_search_chunks, semantic_search_chunks +) +from src.services.llm import ( + is_gpt5_model, is_using_openai_api, call_llm_completion, format_api_error_message +) +from src.services.document import process_markdown_to_docx +from src.services.retention import ( + is_recording_exempt_from_deletion, get_retention_days_for_recording, process_auto_deletion +) +from src.services.calendar import generate_ics_content, escape_ical_text +from src.services.speaker import ( + update_speaker_usage, identify_speakers_from_text, identify_unidentified_speakers_from_text +) + +# Import background task functions +from src.tasks.processing import ( + generate_title_task, generate_summary_only_task, extract_events_from_transcript, + extract_audio_from_video, transcribe_audio_task, transcribe_with_connector, + transcribe_chunks_with_connector, transcribe_incognito +) + +# Import configuration helpers +from src.config.version import get_version + +# Initialize Flask-Login and other extensions +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'auth.login' +login_manager.login_message_category = 'info' +bcrypt = Bcrypt() +bcrypt.init_app(app) +limiter.init_app(app) # Initialize the limiter (uses in-memory storage by default) + +# Exempt frequently-polled status endpoints from rate limiting +@limiter.request_filter +def exempt_status_endpoints(): + """Exempt status polling endpoints from rate limiting.""" + from flask import request + # Exempt status endpoints that are polled frequently during processing + if '/status' in request.path and request.method == 'GET': + return True + if request.path.endswith('/batch-status') and request.method == 'POST': + return True + # Exempt job queue status polling (polled every 5-30 seconds during processing) + if request.path == '/api/recordings/job-queue-status' and request.method == 'GET': + return True + return False + +csrf = CSRFProtect(app) + +# Return JSON (not HTML) for CSRF errors so frontend can handle them +from flask_wtf.csrf import CSRFError + +@app.errorhandler(CSRFError) +def handle_csrf_error(e): + return jsonify({ + 'error': 'Session expirée, veuillez rafraîchir la page.', + 'csrf_error': True + }), 400 + + +# Exempt token-authenticated requests from CSRF protection +@csrf.exempt +@app.before_request +def csrf_exempt_for_api_tokens(): + """ + Exempt API token-authenticated requests from CSRF validation. + + This allows automation tools (n8n, Zapier, curl, etc.) to make + authenticated requests without needing CSRF tokens. + """ + from src.utils.token_auth import is_token_authenticated + + # If request has a valid token, skip CSRF check + if is_token_authenticated(): + # Mark this view as CSRF exempt + if hasattr(request, 'endpoint') and request.endpoint: + view_func = app.view_functions.get(request.endpoint) + if view_func: + csrf.exempt(view_func) + + +# Add context processor to make 'now' available to all templates +@app.context_processor +def inject_now(): + return {'now': datetime.now()} + +@app.context_processor +def inject_group_admin_status(): + """Inject is_group_admin flag into all templates.""" + from flask_login import current_user + from src.models.organization import GroupMembership + + is_group_admin = False + if current_user.is_authenticated: + is_group_admin = GroupMembership.query.filter_by( + user_id=current_user.id, + role='admin' + ).first() is not None + + return {'is_group_admin': is_group_admin} + +# --- Timezone Formatting Filter --- +@app.template_filter('localdatetime') +def local_datetime_filter(dt): + """Format a UTC datetime object to the user's local timezone.""" + if dt is None: + return "" + + # Get timezone from .env, default to UTC + user_tz_name = os.environ.get('TIMEZONE', 'UTC') + try: + user_tz = pytz.timezone(user_tz_name) + except pytz.UnknownTimeZoneError: + user_tz = pytz.utc + app.logger.warning(f"Invalid TIMEZONE '{user_tz_name}' in .env. Defaulting to UTC.") + + # If the datetime object is naive, assume it's UTC + if dt.tzinfo is None: + dt = pytz.utc.localize(dt) + + # Convert to the user's timezone + local_dt = dt.astimezone(user_tz) + + # Format it nicely + return format_datetime(local_dt, format='medium', locale='en_US') + +# Ensure upload and instance directories exist +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +# Ensure upload and instance directories exist +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) +# Assuming the instance folder is handled correctly by Flask or created by setup.sh +# os.makedirs(os.path.dirname(app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '/')), exist_ok=True) + + +# --- User loader for Flask-Login --- +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +@login_manager.request_loader +def load_user_from_request(request): + """ + Load user from API token in the request. + + This enables token-based authentication for API access + (e.g., from curl, n8n, Zapier, etc.). + """ + from src.utils.token_auth import load_user_from_token + return load_user_from_token() + + +# --- Embedding and Chunking Utilities --- + +from src.api.auth import auth_bp, init_auth_extensions +from src.api.tokens import tokens_bp, init_tokens_helpers +from src.api.shares import shares_bp, init_shares_helpers +from src.api.recordings import recordings_bp, init_recordings_helpers +from src.api.tags import tags_bp, init_tags_helpers +from src.api.folders import folders_bp, init_folders_helpers +from src.api.groups import groups_bp, init_groups_helpers +from src.api.admin import admin_bp, init_admin_helpers +from src.api.speakers import speakers_bp, init_speakers_helpers +from src.api.inquire import inquire_bp, init_inquire_helpers +from src.api.templates import templates_bp, init_templates_helpers +from src.api.naming_templates import naming_templates_bp +from src.api.export_templates import export_templates_bp +from src.api.events import events_bp, init_events_helpers +from src.api.system import system_bp, init_system_helpers +from src.api.push_notifications import push_bp +from src.api.api_v1 import api_v1_bp, init_api_v1_helpers +from src.api.audit import audit_bp +from src.api.docs import docs_bp + +# Database initialization (extracted to src/init_db.py) +from src.init_db import initialize_database +with app.app_context(): + initialize_database(app) + +# Application configuration (extracted to src/config/app_config.py) +from src.config.app_config import initialize_config +client, chunking_service, version = initialize_config(app) + +# Initialize blueprint helpers (inject extensions and utility functions) +init_auth_extensions(bcrypt, csrf, limiter) +init_tokens_helpers(bcrypt, csrf, limiter) +init_shares_helpers(has_recording_access) +init_recordings_helpers(has_recording_access=has_recording_access, get_user_recording_status=get_user_recording_status, set_user_recording_status=set_user_recording_status, enrich_recording_dict_with_user_status=enrich_recording_dict_with_user_status, bcrypt=bcrypt, csrf=csrf, limiter=limiter, chunking_service=chunking_service) +init_tags_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_folders_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_groups_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_admin_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_speakers_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_inquire_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_templates_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_events_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter) +init_system_helpers(has_recording_access=has_recording_access, bcrypt=bcrypt, csrf=csrf, limiter=limiter, chunking_service=chunking_service) +init_api_v1_helpers(has_recording_access=has_recording_access, get_user_recording_status=get_user_recording_status, set_user_recording_status=set_user_recording_status, enrich_recording_dict_with_user_status=enrich_recording_dict_with_user_status, bcrypt=bcrypt, csrf=csrf, limiter=limiter, chunking_service=chunking_service) + +# Register blueprints +app.register_blueprint(auth_bp) +app.register_blueprint(tokens_bp) +app.register_blueprint(shares_bp) +app.register_blueprint(recordings_bp) +app.register_blueprint(tags_bp) +app.register_blueprint(folders_bp) +app.register_blueprint(groups_bp) +app.register_blueprint(admin_bp) +app.register_blueprint(speakers_bp) +app.register_blueprint(inquire_bp) +app.register_blueprint(templates_bp) +app.register_blueprint(naming_templates_bp) +app.register_blueprint(export_templates_bp) +app.register_blueprint(events_bp) +app.register_blueprint(system_bp) +app.register_blueprint(push_bp) +app.register_blueprint(api_v1_bp) +csrf.exempt(api_v1_bp) # API v1 uses token auth, not CSRF +app.register_blueprint(audit_bp) +app.register_blueprint(docs_bp) + +# File monitor and scheduler initialization functions below + +# Startup functions (extracted to src/config/startup.py) +from src.config.startup import initialize_file_monitor, get_file_monitor_functions, initialize_auto_deletion_scheduler, run_startup_tasks + +# Run startup tasks +run_startup_tasks(app) + +# --- No-Crawl System: HTTP Headers --- +@app.after_request +def add_no_crawl_headers(response): + """ + Add HTTP headers to discourage search engine crawling and indexing. + This provides defense-in-depth alongside robots.txt and meta tags. + """ + response.headers['X-Robots-Tag'] = 'noindex, nofollow, noarchive, nosnippet, noimageindex' + return response + +# --- No-Crawl System: Serve robots.txt --- +@app.route('/robots.txt') +def robots_txt(): + """Serve robots.txt to instruct crawlers not to index the site.""" + return send_file(os.path.join(app.static_folder, 'robots.txt'), mimetype='text/plain') + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true', help='Run in debug mode') + args = parser.parse_args() + + # Consider using waitress or gunicorn for production + # waitress-serve --host 0.0.0.0 --port 8899 app:app + # For development: + app.run(host='0.0.0.0', port=8899, debug=args.debug) diff --git a/src/audio_chunking.py b/src/audio_chunking.py new file mode 100644 index 0000000..4992816 --- /dev/null +++ b/src/audio_chunking.py @@ -0,0 +1,1203 @@ +""" +Audio Chunking Service for Large File Processing with OpenAI Whisper API + +This module provides functionality to split large audio files into smaller chunks +that comply with OpenAI's 25MB file size limit, process them individually, +and reassemble the transcriptions while maintaining accuracy and speaker continuity. +""" + +import os +import json +import subprocess +import tempfile +import logging +import math +import re +from dataclasses import dataclass +from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING +from datetime import datetime +import mimetypes + +from src.utils.ffmpeg_utils import convert_to_mp3, FFmpegError, FFmpegNotFoundError + +if TYPE_CHECKING: + from src.services.transcription.base import ConnectorSpecifications + +# Configure logging +logger = logging.getLogger(__name__) + + +@dataclass +class EffectiveChunkingConfig: + """Effective chunking configuration after resolving connector specs and ENV settings.""" + enabled: bool + mode: str # 'size' or 'duration' + limit_value: float # MB for size, seconds for duration + overlap_seconds: int + source: str # 'disabled', 'connector_internal', 'env', 'connector_default', 'app_default' + + +def get_effective_chunking_config( + connector_specs: Optional['ConnectorSpecifications'] = None +) -> EffectiveChunkingConfig: + """ + Determine effective chunking configuration based on connector specs and ENV settings. + + Logic: + 1. Gather connector constraints (max_duration_seconds, max_file_size_bytes) + 2. Gather user settings (CHUNK_LIMIT, CHUNK_SIZE_MB, ENABLE_CHUNKING) + 3. If connector has hard limits: + - Chunking is REQUIRED (can't disable) + - Use MIN(connector_limit, user_limit) - user can go smaller but not larger + 4. If connector has no hard limits: + - If handles_chunking_internally=True → no app chunking + - If ENABLE_CHUNKING=false → no chunking + - Otherwise use user settings or app defaults + + Args: + connector_specs: Optional ConnectorSpecifications from the active connector + + Returns: + EffectiveChunkingConfig with resolved settings + """ + overlap_seconds = int(os.environ.get('CHUNK_OVERLAP_SECONDS', '3')) + enable_chunking_env = os.environ.get('ENABLE_CHUNKING', '').lower() + + # --- Step 1: Determine connector's hard limits --- + connector_duration_limit = None + connector_size_limit_mb = None + + if connector_specs: + if connector_specs.max_duration_seconds: + # Use recommended if available, otherwise 85% of max for safety + if connector_specs.recommended_chunk_seconds: + connector_duration_limit = connector_specs.recommended_chunk_seconds + else: + connector_duration_limit = int(connector_specs.max_duration_seconds * 0.85) + + if connector_specs.max_file_size_bytes: + # Use 80% of max for safety margin + connector_size_limit_mb = (connector_specs.max_file_size_bytes / (1024 * 1024)) * 0.8 + + has_hard_limits = connector_duration_limit is not None or connector_size_limit_mb is not None + + # --- Step 2: Parse user settings --- + user_duration_limit = None + user_size_limit_mb = None + + chunk_limit = os.environ.get('CHUNK_LIMIT', '').strip() + chunk_size_mb_env = os.environ.get('CHUNK_SIZE_MB', '').strip() + + if chunk_limit: + chunk_limit_upper = chunk_limit.upper() + try: + if chunk_limit_upper.endswith('MB'): + user_size_limit_mb = float(re.sub(r'[^0-9.]', '', chunk_limit_upper)) + elif chunk_limit_upper.endswith('S'): + user_duration_limit = float(re.sub(r'[^0-9.]', '', chunk_limit_upper)) + elif chunk_limit_upper.endswith('M') and not chunk_limit_upper.endswith('MB'): + user_duration_limit = float(re.sub(r'[^0-9.]', '', chunk_limit_upper)) * 60 + except ValueError: + logger.warning(f"Invalid CHUNK_LIMIT format: {chunk_limit}") + elif chunk_size_mb_env: + try: + user_size_limit_mb = float(chunk_size_mb_env) + except ValueError: + logger.warning(f"Invalid CHUNK_SIZE_MB format: {chunk_size_mb_env}") + + # --- Step 3: If connector has hard limits, chunking is REQUIRED --- + if has_hard_limits: + # Prefer duration-based if connector has duration limit + if connector_duration_limit is not None: + # Use minimum of connector limit and user limit (if user set one) + if user_duration_limit is not None: + effective_limit = min(connector_duration_limit, user_duration_limit) + source = 'user_and_connector' + logger.info(f"Chunking: Using MIN(connector={connector_duration_limit}s, user={user_duration_limit}s) = {effective_limit}s") + else: + effective_limit = connector_duration_limit + source = 'connector_limit' + logger.info(f"Chunking: Connector requires duration limit {effective_limit}s (max_duration={connector_specs.max_duration_seconds}s)") + + return EffectiveChunkingConfig( + enabled=True, + mode='duration', + limit_value=effective_limit, + overlap_seconds=overlap_seconds, + source=source + ) + + # Fall back to size-based if only size limit exists + elif connector_size_limit_mb is not None: + if user_size_limit_mb is not None: + effective_limit = min(connector_size_limit_mb, user_size_limit_mb) + source = 'user_and_connector' + logger.info(f"Chunking: Using MIN(connector={connector_size_limit_mb:.1f}MB, user={user_size_limit_mb}MB) = {effective_limit:.1f}MB") + else: + effective_limit = connector_size_limit_mb + source = 'connector_limit' + logger.info(f"Chunking: Connector requires size limit {effective_limit:.1f}MB (max_size={connector_specs.max_file_size_bytes/(1024*1024):.1f}MB)") + + return EffectiveChunkingConfig( + enabled=True, + mode='size', + limit_value=effective_limit, + overlap_seconds=overlap_seconds, + source=source + ) + + # --- Step 4: No hard limits - chunking is optional --- + + # Connector handles chunking internally + if connector_specs and connector_specs.handles_chunking_internally: + logger.info("Chunking: Connector handles chunking internally, no app-level chunking needed") + return EffectiveChunkingConfig( + enabled=False, + mode='none', + limit_value=0, + overlap_seconds=overlap_seconds, + source='connector_internal' + ) + + # User explicitly disabled chunking + if enable_chunking_env == 'false': + logger.info("Chunking: Disabled via ENABLE_CHUNKING=false") + return EffectiveChunkingConfig( + enabled=False, + mode='none', + limit_value=0, + overlap_seconds=overlap_seconds, + source='disabled' + ) + + # User set explicit limits - use them + if user_duration_limit is not None: + logger.info(f"Chunking: Using user CHUNK_LIMIT={user_duration_limit}s") + return EffectiveChunkingConfig( + enabled=True, + mode='duration', + limit_value=user_duration_limit, + overlap_seconds=overlap_seconds, + source='env' + ) + + if user_size_limit_mb is not None: + logger.info(f"Chunking: Using user CHUNK_LIMIT={user_size_limit_mb}MB") + return EffectiveChunkingConfig( + enabled=True, + mode='size', + limit_value=user_size_limit_mb, + overlap_seconds=overlap_seconds, + source='env' + ) + + # Connector has recommended settings (but no hard limits) + if connector_specs and connector_specs.recommended_chunk_seconds: + logger.info(f"Chunking: Using connector recommended={connector_specs.recommended_chunk_seconds}s") + return EffectiveChunkingConfig( + enabled=True, + mode='duration', + limit_value=connector_specs.recommended_chunk_seconds, + overlap_seconds=overlap_seconds, + source='connector_recommended' + ) + + # App defaults + if enable_chunking_env != 'false': + logger.info("Chunking: Using app defaults (20MB size-based)") + return EffectiveChunkingConfig( + enabled=True, + mode='size', + limit_value=20.0, + overlap_seconds=overlap_seconds, + source='app_default' + ) + + # Final fallback: disabled + return EffectiveChunkingConfig( + enabled=False, + mode='none', + limit_value=0, + overlap_seconds=overlap_seconds, + source='disabled' + ) + +class AudioChunkingService: + """Service for chunking large audio files and processing them with OpenAI Whisper API.""" + + def __init__(self, max_chunk_size_mb: int = 20, overlap_seconds: int = 3, max_chunk_duration_seconds: int = None): + """ + Initialize the chunking service. + + Args: + max_chunk_size_mb: Maximum size for each chunk in MB (default 20MB for safety margin) + overlap_seconds: Overlap between chunks in seconds for context continuity + max_chunk_duration_seconds: Maximum duration for each chunk in seconds (optional) + """ + self.max_chunk_size_mb = max_chunk_size_mb + self.overlap_seconds = overlap_seconds + self.max_chunk_size_bytes = max_chunk_size_mb * 1024 * 1024 + self.max_chunk_duration_seconds = max_chunk_duration_seconds + self.chunk_stats = [] # Track processing statistics + + def needs_chunking( + self, + file_path: str, + use_asr_endpoint: bool = False, + connector_specs: Optional['ConnectorSpecifications'] = None + ) -> bool: + """ + Check if a file needs to be chunked based on connector specs, ENV settings, and file size. + + Priority order for chunking configuration: + 1. If connector handles_chunking_internally=True → no app-level chunking + 2. If ENABLE_CHUNKING=false → no chunking + 3. If CHUNK_LIMIT or CHUNK_SIZE_MB is explicitly set → use ENV settings + 4. If connector has specs → use connector defaults + 5. Fallback: 20MB size-based chunking + + NOTE: For duration-based limits, this may return True even if chunking isn't needed, + because we need to convert the file first to check duration. The actual chunking + decision is made after conversion in calculate_optimal_chunking(). + + Args: + file_path: Path to the audio file + use_asr_endpoint: DEPRECATED - use connector_specs instead + connector_specs: Optional ConnectorSpecifications from the active connector + + Returns: + True if file might need chunking, False otherwise + """ + # Get effective chunking configuration + chunking_config = get_effective_chunking_config(connector_specs) + + # If chunking is disabled (by connector or user), return False + if not chunking_config.enabled: + logger.info(f"Chunking disabled (source: {chunking_config.source})") + return False + + # Legacy fallback: if no connector_specs provided, check use_asr_endpoint + if connector_specs is None and use_asr_endpoint: + logger.info("Chunking: ASR endpoint detected (legacy), no chunking needed") + return False + + try: + file_size = os.path.getsize(file_path) + + if chunking_config.mode == 'size': + # For size-based limits, we can determine immediately + chunk_size_bytes = chunking_config.limit_value * 1024 * 1024 + needs_it = file_size > chunk_size_bytes + logger.info(f"Chunking check (size, source={chunking_config.source}): " + f"{file_size/1024/1024:.1f}MB vs limit {chunking_config.limit_value}MB - needs chunking: {needs_it}") + return needs_it + elif chunking_config.mode == 'duration': + # For duration-based limits, we need to check the actual duration + # Try to get duration without conversion first (fast check) + duration = self.get_audio_duration(file_path) + if duration: + needs_it = duration > chunking_config.limit_value + logger.info(f"Chunking check (duration, source={chunking_config.source}): " + f"{duration:.1f}s vs limit {chunking_config.limit_value}s - needs chunking: {needs_it}") + return needs_it + else: + # Can't determine duration without conversion, assume might need chunking + logger.info(f"Duration-based limit set ({chunking_config.limit_value}s) but can't check duration yet - will check after conversion") + return True # Proceed to conversion and check + else: + # Mode is 'none', shouldn't reach here but handle gracefully + return False + + except OSError: + logger.error(f"Could not get file size for {file_path}") + return False + + def needs_chunking_with_config( + self, + file_path: str, + connector_specs: Optional['ConnectorSpecifications'] = None + ) -> Tuple[bool, EffectiveChunkingConfig]: + """ + Check if a file needs chunking and return the effective configuration. + + This is useful when you need both the decision and the configuration + for subsequent processing. + + Args: + file_path: Path to the audio file + connector_specs: Optional ConnectorSpecifications from the active connector + + Returns: + Tuple of (needs_chunking, EffectiveChunkingConfig) + """ + chunking_config = get_effective_chunking_config(connector_specs) + needs_it = self.needs_chunking(file_path, connector_specs=connector_specs) + return needs_it, chunking_config + + def get_audio_duration(self, file_path: str) -> Optional[float]: + """ + Get the duration of an audio file in seconds using ffprobe. + + Args: + file_path: Path to the audio file + + Returns: + Duration in seconds, or None if unable to determine + """ + try: + result = subprocess.run([ + 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', file_path + ], capture_output=True, text=True, check=True) + + duration = float(result.stdout.strip()) + return duration + except (subprocess.CalledProcessError, ValueError, FileNotFoundError) as e: + logger.error(f"Error getting audio duration for {file_path}: {e}") + return None + + def convert_to_mp3_and_get_info(self, file_path: str, temp_dir: str) -> Tuple[str, float, float]: + """ + Convert the input file to MP3 format for consistency and get its size and duration info. + + If the input is already MP3, skips conversion and just copies it. + + Args: + file_path: Path to the source audio file + temp_dir: Directory to store the temporary MP3 file + + Returns: + Tuple of (mp3_file_path, duration_seconds, size_bytes) + """ + try: + import shutil + + # Generate MP3 filename + base_name = os.path.splitext(os.path.basename(file_path))[0] + mp3_filename = f"{base_name}_converted.mp3" + mp3_path = os.path.join(temp_dir, mp3_filename) + + # Check if input is already MP3 - skip conversion + file_ext = os.path.splitext(file_path)[1].lower() + if file_ext == '.mp3': + logger.info(f"Input {file_path} is already MP3, skipping conversion") + shutil.copy2(file_path, mp3_path) + else: + logger.info(f"Converting {file_path} to 128kbps MP3 format for chunking...") + # Use centralized FFmpeg utility for conversion + convert_to_mp3(file_path, mp3_path) + + if not os.path.exists(mp3_path): + raise ValueError("MP3 file was not created") + + # Get the size and duration of the MP3 file + mp3_size = os.path.getsize(mp3_path) + mp3_duration = self.get_audio_duration(mp3_path) + + if not mp3_duration: + raise ValueError("Could not determine MP3 file duration") + + logger.info(f"MP3 ready for chunking: {mp3_size/1024/1024:.1f}MB, {mp3_duration:.1f}s") + + # Optionally preserve converted file for debugging (set PRESERVE_CHUNK_DEBUG=true in env) + if os.getenv('PRESERVE_CHUNK_DEBUG', 'false').lower() == 'true': + # Save debug files in /data/uploads/debug/ directory + debug_dir = '/data/uploads/debug' + os.makedirs(debug_dir, exist_ok=True) + debug_filename = os.path.basename(mp3_path).replace('_converted', '_converted_debug') + debug_path = os.path.join(debug_dir, debug_filename) + shutil.copy2(mp3_path, debug_path) + logger.info(f"Debug: Preserved converted file as {debug_path}") + + return mp3_path, mp3_duration, mp3_size + + except (FFmpegError, FFmpegNotFoundError) as e: + logger.error(f"Error converting file to MP3: {e}") + raise + except Exception as e: + logger.error(f"Error converting file to MP3: {e}") + raise + + def parse_chunk_limit(self) -> Tuple[str, float]: + """ + Parse the CHUNK_LIMIT environment variable to determine chunking mode and value. + + Supports formats: + - Size-based: "20MB", "10MB" + - Duration-based: "1200s", "20m" + - Legacy: CHUNK_SIZE_MB environment variable (for backwards compatibility) + + Returns: + Tuple of (mode, value) where mode is 'size' or 'duration' + """ + chunk_limit = os.environ.get('CHUNK_LIMIT', '').strip().upper() + + # Check for new CHUNK_LIMIT format + if chunk_limit: + # Size-based: ends with MB + if chunk_limit.endswith('MB'): + try: + size_mb = float(re.sub(r'[^0-9.]', '', chunk_limit)) + return 'size', size_mb + except ValueError: + logger.warning(f"Invalid CHUNK_LIMIT format: {chunk_limit}") + + # Duration-based: ends with s or m + elif chunk_limit.endswith('S'): + try: + seconds = float(re.sub(r'[^0-9.]', '', chunk_limit)) + return 'duration', seconds + except ValueError: + logger.warning(f"Invalid CHUNK_LIMIT format: {chunk_limit}") + + elif chunk_limit.endswith('M'): + try: + minutes = float(re.sub(r'[^0-9.]', '', chunk_limit)) + return 'duration', minutes * 60 + except ValueError: + logger.warning(f"Invalid CHUNK_LIMIT format: {chunk_limit}") + + # Fallback to legacy CHUNK_SIZE_MB for backwards compatibility + legacy_size = os.environ.get('CHUNK_SIZE_MB', '20') + try: + size_mb = float(legacy_size) + logger.info(f"Using legacy CHUNK_SIZE_MB: {size_mb}MB") + return 'size', size_mb + except ValueError: + logger.warning(f"Invalid CHUNK_SIZE_MB format: {legacy_size}") + return 'size', 20.0 # Ultimate fallback + + def calculate_optimal_chunking(self, converted_size: float, total_duration: float, connector_specs=None) -> Tuple[int, float]: + """ + Calculate optimal number of chunks and chunk duration based on the configured limit. + + Args: + converted_size: Size of the converted audio file in bytes + total_duration: Total duration of the audio file in seconds + connector_specs: Optional ConnectorSpecifications with hard limits + + Returns: + Tuple of (num_chunks, chunk_duration_seconds) + """ + try: + # Use effective chunking config which respects connector hard limits + chunking_config = get_effective_chunking_config(connector_specs) + mode = chunking_config.mode + limit_value = chunking_config.limit_value + + logger.info(f"Chunking config: mode={mode}, limit={limit_value}, source={chunking_config.source}") + + if mode == 'size': + # Size-based chunking + max_size_bytes = limit_value * 1024 * 1024 * 0.95 # 95% safety factor + num_chunks = max(1, math.ceil(converted_size / max_size_bytes)) + + logger.info(f"Size-based chunking: {limit_value}MB limit") + logger.info(f"File size {converted_size/1024/1024:.1f}MB requires {num_chunks} chunks") + + else: # duration-based + # Duration-based chunking with API safety limit + effective_limit = min(limit_value, 1400) # Cap at OpenAI safe limit + num_chunks = max(1, math.ceil(total_duration / effective_limit)) + + logger.info(f"Duration-based chunking: {limit_value}s limit (effective: {effective_limit}s)") + logger.info(f"File duration {total_duration:.1f}s requires {num_chunks} chunks") + + # Calculate chunk duration + chunk_duration = total_duration / num_chunks + + # Apply minimum duration (5 minutes) but don't exceed file duration + chunk_duration = min(max(300, chunk_duration), total_duration) + + # Log final chunking plan + expected_chunk_size_mb = (converted_size / num_chunks) / (1024 * 1024) + logger.info(f"Chunking plan: {num_chunks} chunks of ~{chunk_duration:.1f}s each (~{expected_chunk_size_mb:.1f}MB each)") + + return num_chunks, chunk_duration + + except Exception as e: + logger.error(f"Error calculating optimal chunking: {e}") + # Conservative fallback + fallback_chunks = max(2, math.ceil(total_duration / 600)) # 10-minute chunks + fallback_duration = total_duration / fallback_chunks + return fallback_chunks, fallback_duration + + def create_chunks(self, file_path: str, temp_dir: str, connector_specs=None) -> List[Dict[str, Any]]: + """ + Split audio file into overlapping chunks. + + First converts the file to MP3 format to get accurate size information, + then calculates optimal chunk duration based on the actual MP3 file size. + + Args: + file_path: Path to the source audio file + temp_dir: Directory to store temporary chunk files + connector_specs: Optional ConnectorSpecifications with hard limits + + Returns: + List of chunk information dictionaries + """ + chunks = [] + wav_path = None + + try: + # Step 1: Convert to MP3 and get accurate size/duration info + mp3_path, mp3_duration, mp3_size = self.convert_to_mp3_and_get_info(file_path, temp_dir) + + # Step 2: Calculate optimal chunking strategy (respects connector hard limits) + num_chunks, chunk_duration = self.calculate_optimal_chunking(mp3_size, mp3_duration, connector_specs) + + # If only 1 chunk needed, no actual chunking required + if num_chunks == 1: + logger.info(f"File duration {mp3_duration:.1f}s is within limit - no chunking needed") + # Return the single "chunk" as the whole file + base_name = os.path.splitext(os.path.basename(file_path))[0] + chunk_filename = f"{base_name}_chunk_000.mp3" + chunk_path = os.path.join(temp_dir, chunk_filename) + + # Copy the converted file as the single chunk + import shutil + shutil.copy2(mp3_path, chunk_path) + + chunk_info = { + 'index': 0, + 'path': chunk_path, + 'filename': chunk_filename, + 'start_time': 0, + 'end_time': mp3_duration, + 'duration': mp3_duration, + 'size_bytes': mp3_size, + 'size_mb': mp3_size / (1024 * 1024) + } + chunks.append(chunk_info) + logger.info(f"Created single chunk for entire file: {mp3_duration:.1f}s") + return chunks + + # Calculate step size to create exactly num_chunks with overlap + # Total coverage needed: mp3_duration + (overlap * (num_chunks - 1)) + # Each chunk covers: chunk_duration + # Step between chunks to get exactly num_chunks + if num_chunks > 1: + step_duration = (mp3_duration - chunk_duration) / (num_chunks - 1) + else: + step_duration = mp3_duration + + current_start = 0 + chunk_index = 0 + + logger.info(f"Splitting {file_path} into {num_chunks} chunks of ~{chunk_duration:.1f}s with {self.overlap_seconds}s overlap") + + for chunk_index in range(num_chunks): + # Calculate start position for this chunk + if chunk_index > 0: + current_start = chunk_index * step_duration + + # Calculate end time for this chunk + chunk_end = min(current_start + chunk_duration, mp3_duration) + actual_duration = chunk_end - current_start + + # Skip very short chunks at the end (shouldn't happen with proper calculation) + if actual_duration < 10: # Less than 10 seconds + logger.warning(f"Skipping short chunk {chunk_index}: {actual_duration:.1f}s") + break + + # Generate chunk filename + base_name = os.path.splitext(os.path.basename(file_path))[0] + chunk_filename = f"{base_name}_chunk_{chunk_index:03d}.mp3" + chunk_path = os.path.join(temp_dir, chunk_filename) + + # Extract chunk from the converted MP3 file (more efficient than re-converting) + cmd = [ + 'ffmpeg', '-i', mp3_path, + '-ss', str(current_start), + '-t', str(actual_duration), + '-acodec', 'copy', # Copy codec since it's already in the right format + '-y', # Overwrite output file + chunk_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + logger.error(f"ffmpeg failed for chunk {chunk_index}: {result.stderr}") + continue + + # Verify chunk was created and get its size + if os.path.exists(chunk_path): + chunk_size = os.path.getsize(chunk_path) + + # Verify chunk size is within limits + if chunk_size > self.max_chunk_size_bytes: + logger.warning(f"Chunk {chunk_index} is {chunk_size/1024/1024:.1f}MB, exceeds {self.max_chunk_size_mb}MB limit") + + chunk_info = { + 'index': chunk_index, + 'path': chunk_path, + 'filename': chunk_filename, + 'start_time': current_start, + 'end_time': chunk_end, + 'duration': actual_duration, + 'size_bytes': chunk_size, + 'size_mb': chunk_size / (1024 * 1024) + } + + chunks.append(chunk_info) + logger.info(f"Created chunk {chunk_index}: {current_start:.1f}s-{chunk_end:.1f}s ({chunk_size/1024/1024:.1f}MB)") + + # Optionally preserve chunks for debugging (set PRESERVE_CHUNK_DEBUG=true in env) + if os.getenv('PRESERVE_CHUNK_DEBUG', 'false').lower() == 'true': + import shutil + # Save debug chunks in /data/uploads/debug/ directory + debug_dir = '/data/uploads/debug' + os.makedirs(debug_dir, exist_ok=True) + debug_filename = os.path.basename(chunk_path).replace('.mp3', '_debug.mp3') + debug_path = os.path.join(debug_dir, debug_filename) + shutil.copy2(chunk_path, debug_path) + logger.info(f"Debug: Preserved chunk as {debug_path}") + else: + logger.error(f"Chunk file not created: {chunk_path}") + + logger.info(f"Created {len(chunks)} chunks for {file_path}") + return chunks + + except Exception as e: + logger.error(f"Error creating chunks for {file_path}: {e}") + # Clean up any partial chunks + for chunk in chunks: + try: + if os.path.exists(chunk['path']): + os.remove(chunk['path']) + except Exception: + pass + raise + finally: + # Clean up the temporary WAV file + if wav_path and os.path.exists(wav_path): + try: + os.remove(wav_path) + logger.debug(f"Cleaned up temporary WAV file: {wav_path}") + except Exception as e: + logger.warning(f"Error cleaning up temporary WAV file: {e}") + + def merge_transcriptions(self, chunk_results: List[Dict[str, Any]]) -> str: + """ + Merge transcription results from multiple chunks, handling overlaps. + + Args: + chunk_results: List of transcription results from chunks + + Returns: + Merged transcription text + """ + if not chunk_results: + return "" + + if len(chunk_results) == 1: + return chunk_results[0].get('transcription', '') + + # Sort chunks by start time to ensure correct order + sorted_chunks = sorted(chunk_results, key=lambda x: x.get('start_time', 0)) + + merged_text = "" + + for i, chunk in enumerate(sorted_chunks): + chunk_text = chunk.get('transcription', '').strip() + + if not chunk_text: + continue + + if i == 0: + # First chunk: use entire transcription + merged_text = chunk_text + else: + # Subsequent chunks: try to handle overlap + merged_text = self._merge_overlapping_text( + merged_text, + chunk_text, + chunk.get('start_time', 0), + sorted_chunks[i-1].get('end_time', 0) + ) + + return merged_text + + def _merge_overlapping_text(self, existing_text: str, new_text: str, + new_start_time: float, prev_end_time: float) -> str: + """ + Merge overlapping transcription text, attempting to remove duplicates. + + Args: + existing_text: Previously merged text + new_text: New chunk text to merge + new_start_time: Start time of new chunk + prev_end_time: End time of previous chunk + + Returns: + Merged text with overlaps handled + """ + # If there's no overlap, just concatenate + overlap_duration = prev_end_time - new_start_time + if overlap_duration <= 0: + return f"{existing_text}\n{new_text}" + + # For overlapping chunks, try to find common text and merge intelligently + # This is a simplified approach - in practice, you might want more sophisticated + # text similarity matching + + # Split texts into sentences/phrases + existing_sentences = self._split_into_sentences(existing_text) + new_sentences = self._split_into_sentences(new_text) + + if not existing_sentences or not new_sentences: + return f"{existing_text}\n{new_text}" + + # Try to find overlap by comparing last few sentences of existing text + # with first few sentences of new text + overlap_found = False + merge_point = len(existing_sentences) + + # Look for common sentences (simple approach) + for i in range(min(3, len(existing_sentences))): # Check last 3 sentences + last_sentence = existing_sentences[-(i+1)].strip().lower() + + for j in range(min(3, len(new_sentences))): # Check first 3 sentences + first_sentence = new_sentences[j].strip().lower() + + # If sentences are similar enough, consider it an overlap + if last_sentence and first_sentence and self._sentences_similar(last_sentence, first_sentence): + merge_point = len(existing_sentences) - i + new_start_index = j + 1 + overlap_found = True + break + + if overlap_found: + break + + if overlap_found: + # Merge at the found overlap point + merged_sentences = existing_sentences[:merge_point] + new_sentences[new_start_index:] + return ' '.join(merged_sentences) + else: + # No clear overlap found, concatenate with a separator + return f"{existing_text}\n{new_text}" + + def _split_into_sentences(self, text: str) -> List[str]: + """Split text into sentences for overlap detection.""" + import re + # Simple sentence splitting - could be improved with more sophisticated NLP + sentences = re.split(r'[.!?]+', text) + return [s.strip() for s in sentences if s.strip()] + + def _sentences_similar(self, sent1: str, sent2: str, threshold: float = 0.8) -> bool: + """Check if two sentences are similar enough to be considered the same.""" + # Simple similarity check based on common words + words1 = set(sent1.split()) + words2 = set(sent2.split()) + + if not words1 or not words2: + return False + + intersection = len(words1.intersection(words2)) + union = len(words1.union(words2)) + + similarity = intersection / union if union > 0 else 0 + return similarity >= threshold + + def analyze_chunk_audio_properties(self, chunk_path: str) -> Dict[str, Any]: + """ + Analyze audio properties of a chunk that might affect processing time. + + Args: + chunk_path: Path to the chunk file + + Returns: + Dictionary with audio analysis results + """ + try: + # Get detailed audio information using ffprobe + cmd = [ + 'ffprobe', '-v', 'quiet', '-print_format', 'json', + '-show_format', '-show_streams', chunk_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + probe_data = json.loads(result.stdout) + + audio_stream = None + for stream in probe_data.get('streams', []): + if stream.get('codec_type') == 'audio': + audio_stream = stream + break + + if not audio_stream: + return {'error': 'No audio stream found'} + + format_info = probe_data.get('format', {}) + + analysis = { + 'duration': float(format_info.get('duration', 0)), + 'size_bytes': int(format_info.get('size', 0)), + 'bitrate': int(format_info.get('bit_rate', 0)), + 'sample_rate': int(audio_stream.get('sample_rate', 0)), + 'channels': int(audio_stream.get('channels', 0)), + 'codec': audio_stream.get('codec_name', 'unknown'), + 'bits_per_sample': int(audio_stream.get('bits_per_raw_sample', 0)), + } + + # Calculate some derived metrics + if analysis['duration'] > 0: + analysis['effective_bitrate'] = (analysis['size_bytes'] * 8) / analysis['duration'] + analysis['compression_ratio'] = analysis['bitrate'] / analysis['effective_bitrate'] if analysis['effective_bitrate'] > 0 else 0 + + return analysis + + except Exception as e: + logger.warning(f"Error analyzing chunk audio properties: {e}") + return {'error': str(e)} + + def log_processing_statistics(self, chunk_results: List[Dict[str, Any]]) -> None: + """ + Log detailed statistics about chunk processing performance. + + Args: + chunk_results: List of chunk processing results with timing info + """ + if not chunk_results: + return + + logger.info("=== CHUNK PROCESSING STATISTICS ===") + + total_chunks = len(chunk_results) + processing_times = [] + sizes = [] + durations = [] + + for i, result in enumerate(chunk_results): + processing_time = result.get('processing_time', 0) + chunk_size = result.get('size_mb', 0) + chunk_duration = result.get('duration', 0) + + processing_times.append(processing_time) + sizes.append(chunk_size) + durations.append(chunk_duration) + + # Log individual chunk stats + rate = chunk_duration / processing_time if processing_time > 0 else 0 + logger.info(f"Chunk {i+1}: {processing_time:.1f}s processing, {chunk_size:.1f}MB, {chunk_duration:.1f}s audio (rate: {rate:.2f}x)") + + # Calculate summary statistics + if processing_times: + avg_time = sum(processing_times) / len(processing_times) + min_time = min(processing_times) + max_time = max(processing_times) + + avg_size = sum(sizes) / len(sizes) + avg_duration = sum(durations) / len(durations) + + total_audio_time = sum(durations) + total_processing_time = sum(processing_times) + overall_rate = total_audio_time / total_processing_time if total_processing_time > 0 else 0 + + logger.info(f"Summary: {total_chunks} chunks, {total_audio_time:.1f}s audio in {total_processing_time:.1f}s") + logger.info(f"Average: {avg_time:.1f}s processing, {avg_size:.1f}MB, {avg_duration:.1f}s audio") + logger.info(f"Range: {min_time:.1f}s - {max_time:.1f}s processing time") + logger.info(f"Overall rate: {overall_rate:.2f}x realtime") + + # Identify performance outliers + if max_time > avg_time * 2: + slow_chunks = [i for i, t in enumerate(processing_times) if t > avg_time * 1.5] + logger.warning(f"Performance outliers detected: chunks {[i+1 for i in slow_chunks]} took significantly longer") + + # Suggest possible causes + logger.info("Possible causes for slow processing:") + logger.info("- OpenAI API server load/performance variations") + logger.info("- Network latency or connection issues") + logger.info("- Audio content complexity (silence, noise, multiple speakers)") + logger.info("- Temporary API rate limiting or throttling") + + logger.info("=== END STATISTICS ===") + + def get_performance_recommendations(self, chunk_results: List[Dict[str, Any]]) -> List[str]: + """ + Generate performance recommendations based on processing results. + + Args: + chunk_results: List of chunk processing results + + Returns: + List of recommendation strings + """ + recommendations = [] + + if not chunk_results: + return recommendations + + processing_times = [r.get('processing_time', 0) for r in chunk_results] + + if processing_times: + avg_time = sum(processing_times) / len(processing_times) + max_time = max(processing_times) + + # Check for high variance in processing times + if max_time > avg_time * 3: + recommendations.append("High variance in processing times detected. Consider implementing retry logic with exponential backoff.") + + # Check for overall slow processing + total_audio = sum(r.get('duration', 0) for r in chunk_results) + total_processing = sum(processing_times) + rate = total_audio / total_processing if total_processing > 0 else 0 + + if rate < 0.5: # Less than 0.5x realtime + recommendations.append("Overall processing is slow. Consider using smaller chunks or a different transcription service.") + + # Check for timeout issues + if any(t > 300 for t in processing_times): # 5+ minutes + recommendations.append("Some chunks took over 5 minutes. Consider implementing timeout handling and chunk retry logic.") + + # Check chunk size optimization + avg_size = sum(r.get('size_mb', 0) for r in chunk_results) / len(chunk_results) + if avg_size < 10: + recommendations.append("Chunks are relatively small. Consider increasing chunk size for better efficiency.") + elif avg_size > 22: + recommendations.append("Chunks are close to size limit. Consider reducing chunk size for more reliable processing.") + + return recommendations + + def cleanup_chunks(self, chunks: List[Dict[str, Any]], temp_mp3_path: str = None) -> None: + """ + Clean up temporary chunk files and MP3 file. + + Args: + chunks: List of chunk information dictionaries + temp_mp3_path: Optional path to temporary MP3 file to clean up + """ + for chunk in chunks: + try: + chunk_path = chunk.get('path') + if chunk_path and os.path.exists(chunk_path): + os.remove(chunk_path) + logger.debug(f"Cleaned up chunk file: {chunk_path}") + except Exception as e: + logger.warning(f"Error cleaning up chunk {chunk.get('filename', 'unknown')}: {e}") + + # Clean up temporary MP3 file if provided + if temp_mp3_path and os.path.exists(temp_mp3_path): + try: + os.remove(temp_mp3_path) + logger.debug(f"Cleaned up temporary MP3 file: {temp_mp3_path}") + except Exception as e: + logger.warning(f"Error cleaning up temporary MP3 file: {e}") + +def get_audio_duration_ffprobe(file_path: str) -> Optional[float]: + """Get actual audio duration using ffprobe.""" + try: + result = subprocess.run([ + 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', file_path + ], capture_output=True, text=True, check=True) + return float(result.stdout.strip()) + except Exception: + return None + + +def extract_speaker_samples( + audio_path: str, + segments: List[Dict[str, Any]], + output_dir: str, + min_duration: float = 1.5, # OpenAI minimum is 1.2s, use 1.5s for safety + max_duration: float = 9.0, # OpenAI maximum is 10.0s, use 9.0s for safety + max_speakers: int = 4 +) -> Dict[str, str]: + """ + Extract audio samples for each unique speaker from diarized segments. + + This is used to maintain speaker identity across chunks when processing + long audio files with the gpt-4o-transcribe-diarize model. + + Args: + audio_path: Path to the source audio file (should be the converted chunk MP3) + segments: List of diarized segments with speaker, start_time, end_time + output_dir: Directory to store extracted speaker samples + min_duration: Minimum duration for a speaker sample (OpenAI requires 1.2-10s) + max_duration: Maximum duration for a speaker sample + max_speakers: Maximum number of speakers to extract (OpenAI supports up to 4) + + Returns: + Dict mapping speaker label (e.g., "A", "B") to path of extracted audio sample + """ + # OpenAI's actual limits + OPENAI_MIN_DURATION = 1.2 + OPENAI_MAX_DURATION = 10.0 + + # Group segments by speaker + speaker_segments: Dict[str, List[Dict]] = {} + for seg in segments: + # Handle both dict and object segments + if isinstance(seg, dict): + speaker = seg.get('speaker', 'Unknown') + start = seg.get('start_time') or seg.get('start') + end = seg.get('end_time') or seg.get('end') + else: + speaker = getattr(seg, 'speaker', 'Unknown') + start = getattr(seg, 'start_time', None) or getattr(seg, 'start', None) + end = getattr(seg, 'end_time', None) or getattr(seg, 'end', None) + + if speaker == 'Unknown' or start is None or end is None: + continue + + if speaker not in speaker_segments: + speaker_segments[speaker] = [] + speaker_segments[speaker].append({'start': start, 'end': end}) + + if not speaker_segments: + logger.warning("No valid speaker segments found for sample extraction") + return {} + + # Sort speakers to get consistent ordering (A, B, C, D...) + sorted_speakers = sorted(speaker_segments.keys())[:max_speakers] + logger.info(f"Extracting samples for {len(sorted_speakers)} speakers: {sorted_speakers}") + + speaker_samples = {} + + for speaker in sorted_speakers: + segs = speaker_segments[speaker] + + # Find the best segment for this speaker (ideally 1.5-9 seconds) + best_segment = None + best_duration = 0 + + for seg in segs: + duration = seg['end'] - seg['start'] + + # Prefer segments in the ideal range + if min_duration <= duration <= max_duration: + if duration > best_duration: + best_segment = seg + best_duration = duration + + # If no segment in ideal range, try to find one we can trim + if not best_segment: + for seg in segs: + duration = seg['end'] - seg['start'] + if duration >= min_duration: + # Trim to max_duration if needed + best_segment = { + 'start': seg['start'], + 'end': min(seg['end'], seg['start'] + max_duration) + } + best_duration = best_segment['end'] - best_segment['start'] + break + + # Still no segment? Try combining multiple short segments + if not best_segment and len(segs) > 1: + # Sort by start time and try to find consecutive segments + sorted_segs = sorted(segs, key=lambda x: x['start']) + combined_start = sorted_segs[0]['start'] + combined_end = sorted_segs[0]['end'] + + for i in range(1, len(sorted_segs)): + # If segments are close (within 1 second), combine them + if sorted_segs[i]['start'] - combined_end < 1.0: + combined_end = sorted_segs[i]['end'] + if combined_end - combined_start >= min_duration: + break + + combined_duration = combined_end - combined_start + if combined_duration >= min_duration: + best_segment = { + 'start': combined_start, + 'end': min(combined_end, combined_start + max_duration) + } + best_duration = best_segment['end'] - best_segment['start'] + + if not best_segment: + logger.warning(f"Could not find suitable segment for speaker {speaker}") + continue + + # Extract the audio sample using ffmpeg + sample_filename = f"speaker_{speaker}_sample.mp3" + sample_path = os.path.join(output_dir, sample_filename) + + try: + cmd = [ + 'ffmpeg', '-i', audio_path, + '-ss', str(best_segment['start']), + '-t', str(best_duration), + '-acodec', 'libmp3lame', + '-b:a', '128k', + '-y', + sample_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + logger.error(f"Failed to extract sample for speaker {speaker}: {result.stderr}") + continue + + if os.path.exists(sample_path) and os.path.getsize(sample_path) > 0: + # Verify actual duration meets OpenAI requirements + actual_duration = get_audio_duration_ffprobe(sample_path) + if actual_duration: + logger.info(f"Speaker {speaker} sample: expected {best_duration:.2f}s, actual {actual_duration:.2f}s") + + if actual_duration < OPENAI_MIN_DURATION: + logger.warning(f"Sample for speaker {speaker} too short ({actual_duration:.2f}s < {OPENAI_MIN_DURATION}s), skipping") + os.remove(sample_path) + continue + elif actual_duration > OPENAI_MAX_DURATION: + logger.warning(f"Sample for speaker {speaker} too long ({actual_duration:.2f}s > {OPENAI_MAX_DURATION}s), skipping") + os.remove(sample_path) + continue + + speaker_samples[speaker] = sample_path + logger.info(f"Extracted {actual_duration:.1f}s sample for speaker {speaker} " + f"(from {best_segment['start']:.1f}s to {best_segment['end']:.1f}s)") + else: + logger.warning(f"Sample file not created for speaker {speaker}") + + except Exception as e: + logger.error(f"Error extracting sample for speaker {speaker}: {e}") + + return speaker_samples + + +def samples_to_data_urls(speaker_samples: Dict[str, str]) -> Dict[str, str]: + """ + Convert speaker sample file paths to base64-encoded data URLs. + + OpenAI's known_speaker_references requires audio samples as data URLs + when using multipart form data. + + Args: + speaker_samples: Dict mapping speaker label to file path + + Returns: + Dict mapping speaker label to data URL + """ + import base64 + + data_urls = {} + + for speaker, path in speaker_samples.items(): + try: + with open(path, 'rb') as f: + audio_data = f.read() + + # Encode as base64 data URL + b64_data = base64.b64encode(audio_data).decode('utf-8') + data_url = f"data:audio/mpeg;base64,{b64_data}" + data_urls[speaker] = data_url + + logger.debug(f"Converted speaker {speaker} sample to data URL ({len(b64_data)} bytes)") + + except Exception as e: + logger.error(f"Error converting speaker {speaker} sample to data URL: {e}") + + return data_urls + + +class ChunkProcessingError(Exception): + """Exception raised when chunk processing fails.""" + pass + +class ChunkingNotSupportedError(Exception): + """Exception raised when chunking is not supported for the current configuration.""" + pass diff --git a/src/auth/sso.py b/src/auth/sso.py new file mode 100644 index 0000000..36e5601 --- /dev/null +++ b/src/auth/sso.py @@ -0,0 +1,188 @@ +import os +import re +from typing import Dict, Optional + +from authlib.integrations.flask_client import OAuth +from flask import current_app + +from src.database import db +from src.models import User + +# Keep a single OAuth client instance +_oauth: Optional[OAuth] = None + + +def _str_to_bool(value: str) -> bool: + return str(value or "").lower() == "true" + + +def get_sso_config() -> Dict[str, Optional[str]]: + """Load SSO configuration from environment variables.""" + return { + "enabled": _str_to_bool(os.environ.get("ENABLE_SSO", "false")), + "provider_name": os.environ.get("SSO_PROVIDER_NAME", "SSO"), + "client_id": os.environ.get("SSO_CLIENT_ID"), + "client_secret": os.environ.get("SSO_CLIENT_SECRET"), + "discovery_url": os.environ.get("SSO_DISCOVERY_URL"), + "redirect_uri": os.environ.get("SSO_REDIRECT_URI"), + "auto_register": _str_to_bool(os.environ.get("SSO_AUTO_REGISTER", "true")), + "allowed_domains": os.environ.get("SSO_ALLOWED_DOMAINS"), + "username_claim": os.environ.get("SSO_DEFAULT_USERNAME_CLAIM", "preferred_username"), + "name_claim": os.environ.get("SSO_DEFAULT_NAME_CLAIM", "name"), + "disable_password_login": _str_to_bool(os.environ.get("SSO_DISABLE_PASSWORD_LOGIN", "false")), + } + + +def is_sso_enabled() -> bool: + cfg = get_sso_config() + return bool( + cfg["enabled"] + and cfg["client_id"] + and cfg["client_secret"] + and cfg["discovery_url"] + and cfg["redirect_uri"] + ) + + +def init_sso_client(app) -> Optional[OAuth]: + """Initialize OAuth client if SSO is enabled.""" + global _oauth + if not is_sso_enabled(): + return None + + if _oauth: + return _oauth + + cfg = get_sso_config() + oauth = OAuth(app) + oauth.register( + name="sso", + server_metadata_url=cfg["discovery_url"], + client_id=cfg["client_id"], + client_secret=cfg["client_secret"], + client_kwargs={"scope": "openid email profile"}, + ) + _oauth = oauth + app.logger.info("SSO client initialized with discovery URL %s", cfg["discovery_url"]) + return _oauth + + +def get_sso_client() -> Optional[OAuth]: + """Return initialized OAuth client or None.""" + return _oauth + + +def is_domain_allowed(email: Optional[str]) -> bool: + """Check if email domain is allowed for auto-registration.""" + if not email: + return False + cfg = get_sso_config() + domains_env = cfg["allowed_domains"] + if not domains_env: + return True # no restriction + + allowed = [d.strip().lower() for d in domains_env.split(",") if d.strip()] + if not allowed: + return True + + parts = email.lower().rsplit("@", 1) + if len(parts) != 2: + return False + domain = parts[1] + return domain in allowed + + +def _sanitize_username(candidate: str) -> str: + sanitized = re.sub(r"[^a-zA-Z0-9._-]", "", candidate or "") + return sanitized or "user" + + +def generate_unique_username(preferred: Optional[str]) -> str: + """Generate a unique username based on preferred value.""" + base = _sanitize_username(preferred or "user") + base = base[:20] + + suffix = 0 + candidate = base + while User.query.filter_by(username=candidate).first(): + suffix += 1 + candidate = f"{base[:18]}{suffix:02d}" + return candidate + + +def create_or_update_sso_user(userinfo: Dict[str, str]) -> User: + """Create or update a user from SSO (OIDC) claims.""" + cfg = get_sso_config() + subject = userinfo.get("sub") + email = userinfo.get("email") + username_claim = cfg["username_claim"] + name_claim = cfg["name_claim"] + + if not subject: + raise ValueError("SSO userinfo does not include 'sub'") + + if not cfg["auto_register"] and not User.query.filter_by(sso_subject=subject).first(): + raise PermissionError("SSO auto-registration is disabled") + + if email and not is_domain_allowed(email): + raise PermissionError("Email domain is not allowed for SSO sign-up") + + # Existing by subject + user = User.query.filter_by(sso_subject=subject).first() + if user: + _update_profile_fields(user, userinfo, name_claim) + db.session.commit() + return user + + # Existing by email: attach SSO + if email: + existing_email_user = User.query.filter_by(email=email).first() + if existing_email_user: + existing_email_user.sso_provider = cfg["provider_name"] + existing_email_user.sso_subject = subject + _update_profile_fields(existing_email_user, userinfo, name_claim) + db.session.commit() + return existing_email_user + + # Create new user + preferred_username = userinfo.get(username_claim) or (email.split("@")[0] if email else None) + username = generate_unique_username(preferred_username) + name_value = userinfo.get(name_claim) if name_claim else userinfo.get("name") + + user = User( + username=username, + email=email or f"{subject}@placeholder.local", + password=None, + sso_provider=cfg["provider_name"], + sso_subject=subject, + name=name_value, + ) + db.session.add(user) + db.session.commit() + return user + + +def _update_profile_fields(user: User, userinfo: Dict[str, str], name_claim: Optional[str]) -> None: + """Update optional profile fields from SSO claims.""" + if not user.email and userinfo.get("email"): + user.email = userinfo["email"] + if name_claim and userinfo.get(name_claim): + user.name = userinfo[name_claim] + + +def update_user_profile_from_claims(user: User, userinfo: Dict[str, str]) -> None: + """Expose profile update for external callers (e.g., account linking).""" + cfg = get_sso_config() + _update_profile_fields(user, userinfo, cfg["name_claim"]) + + +def link_sso_to_existing_user(user: User, provider: str, subject: str) -> User: + """Link SSO identity to an existing user account.""" + if user.sso_subject and user.sso_subject != subject: + raise ValueError("Account already linked to another SSO identity") + + user.sso_provider = provider + user.sso_subject = subject + db.session.commit() + return user + diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config/app_config.py b/src/config/app_config.py new file mode 100644 index 0000000..1c665cd --- /dev/null +++ b/src/config/app_config.py @@ -0,0 +1,164 @@ +""" +Application configuration and initialization. +""" + +import os +import sys +import httpx +from openai import OpenAI + +from src.audio_chunking import AudioChunkingService +from src.config.version import get_version + +# Configuration from environment +TEXT_MODEL_API_KEY = os.environ.get("TEXT_MODEL_API_KEY") +TEXT_MODEL_BASE_URL = os.environ.get("TEXT_MODEL_BASE_URL", "https://openrouter.ai/api/v1") +if TEXT_MODEL_BASE_URL: + TEXT_MODEL_BASE_URL = TEXT_MODEL_BASE_URL.split('#')[0].strip() +TEXT_MODEL_NAME = os.environ.get("TEXT_MODEL_NAME", "openai/gpt-3.5-turbo") + +transcription_api_key = os.environ.get("TRANSCRIPTION_API_KEY", "") +transcription_base_url = os.environ.get("TRANSCRIPTION_BASE_URL", "") +if transcription_base_url: + transcription_base_url = transcription_base_url.split('#')[0].strip() + +# New transcription connector configuration +# TRANSCRIPTION_CONNECTOR: explicit connector name (openai_whisper, openai_transcribe, asr_endpoint) +# TRANSCRIPTION_MODEL: model to use (e.g., gpt-4o-transcribe-diarize for diarization) +TRANSCRIPTION_CONNECTOR = os.environ.get('TRANSCRIPTION_CONNECTOR', '').lower().strip() +TRANSCRIPTION_MODEL = os.environ.get('TRANSCRIPTION_MODEL', '') +if TRANSCRIPTION_MODEL: + TRANSCRIPTION_MODEL = TRANSCRIPTION_MODEL.split('#')[0].strip() + +# Feature flag for new transcription architecture (default: enabled) +USE_NEW_TRANSCRIPTION_ARCHITECTURE = os.environ.get( + 'USE_NEW_TRANSCRIPTION_ARCHITECTURE', 'true' +).lower() == 'true' + +USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' +ASR_BASE_URL = os.environ.get('ASR_BASE_URL') +if ASR_BASE_URL: + ASR_BASE_URL = ASR_BASE_URL.split('#')[0].strip() + +if USE_ASR_ENDPOINT: + ASR_DIARIZE = os.environ.get('ASR_DIARIZE', 'true').lower() == 'true' + ASR_MIN_SPEAKERS = os.environ.get('ASR_MIN_SPEAKERS') + ASR_MAX_SPEAKERS = os.environ.get('ASR_MAX_SPEAKERS') + # Speaker embeddings are only supported by WhisperX ASR service, not the basic whisper-asr-webservice + ASR_RETURN_SPEAKER_EMBEDDINGS = os.environ.get('ASR_RETURN_SPEAKER_EMBEDDINGS', 'false').lower() == 'true' +else: + ASR_DIARIZE = False + ASR_MIN_SPEAKERS = None + ASR_MAX_SPEAKERS = None + ASR_RETURN_SPEAKER_EMBEDDINGS = False + +# ASR chunking configuration - enables app-level chunking for self-hosted ASR services +# that may crash on long files due to GPU memory exhaustion +ASR_ENABLE_CHUNKING = os.environ.get('ASR_ENABLE_CHUNKING', 'false').lower() == 'true' +ASR_MAX_DURATION_SECONDS = int(os.environ.get('ASR_MAX_DURATION_SECONDS', '7200')) # 2 hours default + +ENABLE_CHUNKING = os.environ.get('ENABLE_CHUNKING', 'true').lower() == 'true' +CHUNK_SIZE_MB = int(os.environ.get('CHUNK_SIZE_MB', '20')) +CHUNK_OVERLAP_SECONDS = int(os.environ.get('CHUNK_OVERLAP_SECONDS', '3')) + +# Audio compression settings - compress lossless uploads (WAV, AIFF) to save storage +AUDIO_COMPRESS_UPLOADS = os.environ.get('AUDIO_COMPRESS_UPLOADS', 'true').lower() == 'true' +AUDIO_CODEC = os.environ.get('AUDIO_CODEC', 'mp3').lower() # mp3, flac, opus +AUDIO_BITRATE = os.environ.get('AUDIO_BITRATE', '128k') # For lossy codecs + +# Video passthrough - send original video files directly to ASR without extracting audio +# Useful for custom ASR backends that handle video/multi-track audio internally +VIDEO_PASSTHROUGH_ASR = os.environ.get('VIDEO_PASSTHROUGH_ASR', 'false').lower() == 'true' + +# Unsupported codecs - comma-separated list of codecs to exclude from the default supported list +# Useful when your transcription service doesn't support certain codecs (e.g., vllm doesn't support opus) +# Example: AUDIO_UNSUPPORTED_CODECS=opus,vorbis +_unsupported_codecs_str = os.environ.get('AUDIO_UNSUPPORTED_CODECS', '') +AUDIO_UNSUPPORTED_CODECS = {c.strip().lower() for c in _unsupported_codecs_str.split(',') if c.strip()} + +# Email verification configuration +ENABLE_EMAIL_VERIFICATION = os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true' +REQUIRE_EMAIL_VERIFICATION = os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true' +SMTP_HOST = os.environ.get('SMTP_HOST', '') +SMTP_PORT = int(os.environ.get('SMTP_PORT', '587')) +SMTP_USERNAME = os.environ.get('SMTP_USERNAME', '') +SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '') +SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true' +SMTP_USE_SSL = os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true' +SMTP_FROM_ADDRESS = os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com') +SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'Speakr') + +# Create chunking service at module level so it can be imported by processing.py +# Always initialize the service - the needs_chunking() method will check ENABLE_CHUNKING +# and return False when appropriate. This allows connectors with hard limits (e.g., +# max_duration_seconds) to still enforce chunking even when ENABLE_CHUNKING=false. +chunking_service = AudioChunkingService(CHUNK_SIZE_MB, CHUNK_OVERLAP_SECONDS) + + +def initialize_config(app): + """Initialize application configuration.""" + app_headers = { + "HTTP-Referer": "https://github.com/murtaza-nasir/speakr", + "X-Title": "Speakr - AI Audio Transcription", + "User-Agent": "Speakr/1.0 (https://github.com/murtaza-nasir/speakr)" + } + + http_client_no_proxy = httpx.Client(verify=True, headers=app_headers) + + client = None + try: + api_key = TEXT_MODEL_API_KEY or "not-needed" + client = OpenAI(api_key=api_key, base_url=TEXT_MODEL_BASE_URL, http_client=http_client_no_proxy) + app.logger.info(f"LLM client initialized: {TEXT_MODEL_BASE_URL} / {TEXT_MODEL_NAME}") + except Exception as e: + app.logger.error(f"Failed to initialize LLM client: {e}") + + # Use module-level chunking_service (already created above) + version = get_version() + + app.logger.info(f"=== DictIA {version} Starting Up ===") + + # Initialize transcription connector + if USE_NEW_TRANSCRIPTION_ARCHITECTURE: + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.initialize_from_env() + connector_name = registry.get_active_connector_name() + capabilities = [c.name for c in connector.get_capabilities()] + app.logger.info(f"Transcription connector initialized: {connector_name}") + app.logger.info(f"Connector capabilities: {capabilities}") + + # Log diarization support prominently + diarize_default = getattr(connector, 'default_diarize', connector.supports_diarization) + if not connector.supports_diarization: + app.logger.info("Speaker diarization: NOT AVAILABLE (connector does not support it)") + elif not diarize_default: + app.logger.info("Speaker diarization: DISABLED (ASR_DIARIZE=false)") + else: + app.logger.info("Speaker diarization: ENABLED") + + except Exception as e: + app.logger.error(f"Failed to initialize transcription connector: {e}") + app.logger.error("Falling back to legacy transcription configuration validation") + # Fall through to legacy validation + _validate_legacy_transcription_config(app) + else: + # Legacy configuration validation + _validate_legacy_transcription_config(app) + + return client, chunking_service, version + + +def _validate_legacy_transcription_config(app): + """Validate legacy transcription configuration (backwards compatibility).""" + if USE_ASR_ENDPOINT: + if not ASR_BASE_URL: + app.logger.error("ERROR: ASR enabled but ASR_BASE_URL not configured!") + sys.exit(1) + app.logger.info(f"Using ASR endpoint: {ASR_BASE_URL}") + else: + if not transcription_base_url or not transcription_api_key: + app.logger.error("ERROR: No transcription service configured!") + sys.exit(1) + app.logger.info(f"Using Whisper API: {transcription_base_url}") diff --git a/src/config/startup.py b/src/config/startup.py new file mode 100644 index 0000000..2d4f072 --- /dev/null +++ b/src/config/startup.py @@ -0,0 +1,158 @@ +""" +Application startup functions. +""" + +import os +import time +import threading +from datetime import datetime, timedelta +from flask import current_app + +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +GLOBAL_RETENTION_DAYS = int(os.environ.get('GLOBAL_RETENTION_DAYS', '0')) + + +def initialize_file_monitor(app): + """Initialize file monitor after app is fully loaded to avoid circular imports.""" + try: + # Import here to avoid circular imports + import src.file_monitor as file_monitor + file_monitor.start_file_monitor() + app.logger.info("File monitor initialization completed") + except Exception as e: + app.logger.warning(f"File monitor initialization failed: {e}") + +def get_file_monitor_functions(app): + """Get file monitor functions, handling import errors gracefully.""" + try: + import src.file_monitor as file_monitor + return file_monitor.start_file_monitor, file_monitor.stop_file_monitor, file_monitor.get_file_monitor_status + except ImportError as e: + app.logger.warning(f"File monitor not available: {e}") + + # Create stub functions if file_monitor is not available + def start_file_monitor(): + pass + def stop_file_monitor(): + pass + def get_file_monitor_status(): + return {'running': False, 'error': 'File monitor module not available'} + + return start_file_monitor, stop_file_monitor, get_file_monitor_status + +# --- Auto-Processing API Endpoints --- +def initialize_auto_deletion_scheduler(app): + """Initialize the daily auto-deletion scheduler if enabled.""" + from src.services.retention import process_auto_deletion + + if not ENABLE_AUTO_DELETION: + app.logger.info("Auto-deletion scheduler not started (ENABLE_AUTO_DELETION=false)") + return + + if GLOBAL_RETENTION_DAYS <= 0: + app.logger.info("Auto-deletion scheduler not started (GLOBAL_RETENTION_DAYS not set)") + return + + def run_daily_deletion(): + """Background thread that runs auto-deletion daily at 2 AM.""" + import time + from datetime import datetime, timedelta + + app.logger.info("Auto-deletion scheduler started - will run daily at 2:00 AM") + + while True: + try: + # Calculate time until next 2 AM + now = datetime.now() + next_run = now.replace(hour=2, minute=0, second=0, microsecond=0) + + # If it's past 2 AM today, schedule for tomorrow + if now.hour >= 2: + next_run += timedelta(days=1) + + sleep_seconds = (next_run - now).total_seconds() + + app.logger.info(f"Next auto-deletion scheduled for: {next_run.strftime('%Y-%m-%d %H:%M:%S')} (in {sleep_seconds/3600:.1f} hours)") + + # Sleep until next run time + time.sleep(sleep_seconds) + + # Run auto-deletion + app.logger.info("Running scheduled auto-deletion...") + with app.app_context(): + stats = process_auto_deletion() + app.logger.info(f"Scheduled auto-deletion completed: {stats}") + + except Exception as e: + app.logger.error(f"Error in auto-deletion scheduler: {e}", exc_info=True) + # Sleep for 1 hour before retrying on error + time.sleep(3600) + + # Start the scheduler thread + import threading + scheduler_thread = threading.Thread(target=run_daily_deletion, daemon=True, name="AutoDeletionScheduler") + scheduler_thread.start() + app.logger.info("✅ Auto-deletion scheduler initialized - running daily at 2:00 AM") + + +def initialize_file_exporter(app): + """Initialize file exporter after app is fully loaded.""" + try: + from src.file_exporter import initialize_export_directory, ENABLE_AUTO_EXPORT + if ENABLE_AUTO_EXPORT: + initialize_export_directory() + app.logger.info("✅ Auto-export initialized") + else: + app.logger.info("ℹ️ Auto-export: Disabled (set ENABLE_AUTO_EXPORT=true to enable)") + except Exception as e: + app.logger.warning(f"File exporter initialization failed: {e}") + + +def initialize_job_queue(app): + """Initialize and start the background job queue with orphan recovery.""" + try: + from src.services.job_queue import job_queue + + # Initialize job queue with app context + job_queue.init_app(app) + + # Recover any jobs that were processing when the app crashed + job_queue.recover_orphaned_jobs() + + # Start worker threads + job_queue.start() + + # Get queue status + status = job_queue.get_queue_status() + t_queue = status['transcription_queue'] + s_queue = status['summary_queue'] + app.logger.info( + f"Job queues started: " + f"transcription ({t_queue['workers']} workers, {t_queue['queued']} queued), " + f"summary ({s_queue['workers']} workers, {s_queue['queued']} queued)" + ) + except Exception as e: + app.logger.error(f"Failed to start job queue: {e}", exc_info=True) + + +def run_startup_tasks(app): + """Run all startup tasks that need to happen after app creation.""" + from src.models import SystemSetting + + with app.app_context(): + # Set dynamic MAX_CONTENT_LENGTH based on database setting + max_file_size_mb = SystemSetting.get_setting('max_file_size_mb', 250) + app.config['MAX_CONTENT_LENGTH'] = max_file_size_mb * 1024 * 1024 + app.logger.info(f"Set MAX_CONTENT_LENGTH to {max_file_size_mb}MB from database setting") + + # Initialize job queue for background processing + initialize_job_queue(app) + + # Initialize file monitor after app setup + initialize_file_monitor(app) + + # Initialize file exporter + initialize_file_exporter(app) + + # Initialize auto-deletion scheduler + initialize_auto_deletion_scheduler(app) diff --git a/src/config/version.py b/src/config/version.py new file mode 100644 index 0000000..19d66be --- /dev/null +++ b/src/config/version.py @@ -0,0 +1,29 @@ +""" +Version information helper. +""" + +import os + + + +def get_version(): + # Try reading VERSION file first (works in Docker) + try: + with open('VERSION', 'r') as f: + return f.read().strip() + except FileNotFoundError: + pass + + # Fall back to git tags (works in development) + try: + import subprocess + return subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], + stderr=subprocess.DEVNULL).decode().strip() + except: + pass + + # Final fallback + return "unknown" + + + diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..2b14697 --- /dev/null +++ b/src/database.py @@ -0,0 +1,12 @@ +""" +Database initialization module. + +This module creates and exports the SQLAlchemy database instance +that is used across all models. +""" + +from flask_sqlalchemy import SQLAlchemy + +# Create the SQLAlchemy database instance +# This will be initialized with the Flask app using db.init_app(app) +db = SQLAlchemy() diff --git a/src/file_exporter.py b/src/file_exporter.py new file mode 100644 index 0000000..deff627 --- /dev/null +++ b/src/file_exporter.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +""" +File Exporter for Automated Recording Export + +Exports transcriptions and summaries as markdown files to a configured directory. +Supports per-user subdirectories based on username. +Supports customizable export templates with localized labels. +""" + +import os +import re +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path +from werkzeug.utils import secure_filename + +# Configuration from environment +ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true' +AUTO_EXPORT_DIR = os.environ.get('AUTO_EXPORT_DIR', '/data/exports') +AUTO_EXPORT_TRANSCRIPTION = os.environ.get('AUTO_EXPORT_TRANSCRIPTION', 'true').lower() == 'true' +AUTO_EXPORT_SUMMARY = os.environ.get('AUTO_EXPORT_SUMMARY', 'true').lower() == 'true' + +# Setup logging +logger = logging.getLogger('file_exporter') +logger.setLevel(logging.INFO) + + +def format_transcription_with_template(transcription_text, user): + """ + Format transcription using the user's default template. + + Args: + transcription_text: Raw transcription (JSON or plain text) + user: User object to get template from + + Returns: + Formatted transcription string + """ + # Import here to avoid circular imports + from src.models import TranscriptTemplate + + # Try to parse as JSON + try: + transcription_data = json.loads(transcription_text) + if not isinstance(transcription_data, list): + # Not our expected format, return as-is + return transcription_text + except (json.JSONDecodeError, TypeError): + # Not JSON, return as-is + return transcription_text + + # Get user's default template + template = TranscriptTemplate.query.filter_by( + user_id=user.id, + is_default=True + ).first() + + # Default format if no template set + if not template: + template_format = "[{{speaker}}]: {{text}}" + else: + template_format = template.template + + # Helper functions for formatting + def format_time(seconds): + """Format seconds to HH:MM:SS""" + if seconds is None: + return "00:00:00" + td = timedelta(seconds=seconds) + hours = int(td.total_seconds() // 3600) + minutes = int((td.total_seconds() % 3600) // 60) + secs = int(td.total_seconds() % 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + def format_srt_time(seconds): + """Format seconds to SRT format HH:MM:SS,mmm""" + if seconds is None: + return "00:00:00,000" + td = timedelta(seconds=seconds) + hours = int(td.total_seconds() // 3600) + minutes = int((td.total_seconds() % 3600) // 60) + secs = int(td.total_seconds() % 60) + millis = int((td.total_seconds() % 1) * 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + # Generate formatted transcript + output_lines = [] + for index, segment in enumerate(transcription_data, 1): + line = template_format + + # Replace variables + replacements = { + '{{index}}': str(index), + '{{speaker}}': segment.get('speaker', 'Unknown'), + '{{text}}': segment.get('sentence', ''), + '{{start_time}}': format_time(segment.get('start_time')), + '{{end_time}}': format_time(segment.get('end_time')), + } + + for key, value in replacements.items(): + line = line.replace(key, value) + + # Handle filters + # Upper case filter + line = re.sub(r'{{(.*?)\|upper}}', lambda m: replacements.get('{{' + m.group(1) + '}}', '').upper(), line) + # SRT time filter + line = re.sub(r'{{start_time\|srt}}', format_srt_time(segment.get('start_time')), line) + line = re.sub(r'{{end_time\|srt}}', format_srt_time(segment.get('end_time')), line) + + output_lines.append(line) + + return '\n'.join(output_lines) + + +def get_export_directory(user): + """Get the export directory for a user, creating if needed.""" + base_dir = Path(AUTO_EXPORT_DIR) + + # Create per-user subdirectory based on username + user_dir = base_dir / secure_filename(user.username) + user_dir.mkdir(parents=True, exist_ok=True) + + return user_dir + + +def generate_safe_filename(recording): + """Generate a safe filename for the export based on recording ID only.""" + # Use only recording ID for consistent filename that doesn't change + return f"recording_{recording.id}" + + +def get_export_filepath(user, recording): + """Get the full export filepath for a recording.""" + export_dir = get_export_directory(user) + filename = generate_safe_filename(recording) + return export_dir / f"{filename}.md" + + +def mark_export_as_deleted(recording_id): + """ + Rename the export file to indicate the recording was deleted. + + Args: + recording_id: ID of the deleted recording + + Returns: + New filepath if renamed, None otherwise + """ + if not ENABLE_AUTO_EXPORT: + return None + + # Import here to avoid circular imports + from src.app import app, db + from src.models import Recording, User + + with app.app_context(): + try: + # We need to find the file - check all user directories + base_dir = Path(AUTO_EXPORT_DIR) + if not base_dir.exists(): + return None + + # Look for the file in all user subdirectories + for user_dir in base_dir.iterdir(): + if user_dir.is_dir(): + old_filepath = user_dir / f"recording_{recording_id}.md" + if old_filepath.exists(): + new_filepath = user_dir / f"[deleted]_recording_{recording_id}.md" + old_filepath.rename(new_filepath) + logger.info(f"Marked export as deleted: {new_filepath}") + return str(new_filepath) + + return None + + except Exception as e: + logger.error(f"Failed to mark export as deleted for recording {recording_id}: {e}") + return None + + +def format_duration(seconds): + """Format duration in seconds to human-readable string.""" + if not seconds: + return "" + + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + + if hours > 0: + return f"{hours}h {minutes}m {secs}s" + elif minutes > 0: + return f"{minutes}m {secs}s" + else: + return f"{secs}s" + + +def format_file_size(bytes_size): + """Format file size in bytes to human-readable string.""" + if not bytes_size: + return "" + + for unit in ['B', 'KB', 'MB', 'GB']: + if bytes_size < 1024: + return f"{bytes_size:.1f} {unit}" + bytes_size /= 1024 + return f"{bytes_size:.1f} TB" + + +def get_user_export_template(user, recording=None): + """ + Get the export template to use for a recording. + + Resolution order: + 1. Folder's export_template_id (if recording is in a folder) + 2. Tag's export_template_id (first matching tag with an export template) + 3. User's default export template (is_default=True) + + Args: + user: User object + recording: Optional Recording object (for folder/tag lookup) + + Returns: + ExportTemplate object or None + """ + from src.models import ExportTemplate + + # 1. Check folder's export template + if recording and recording.folder and recording.folder.export_template_id: + template = ExportTemplate.query.get(recording.folder.export_template_id) + if template: + return template + + # 2. Check tags' export templates + if recording and recording.tags: + for tag in recording.tags: + if tag.export_template_id: + template = ExportTemplate.query.get(tag.export_template_id) + if template: + return template + + # 3. Fall back to user's default + return ExportTemplate.query.filter_by( + user_id=user.id, + is_default=True + ).first() + + +def render_export_template(template_str, context, labels): + """ + Render an export template with variable substitution and conditionals. + + Args: + template_str: Template string with {{variables}} and {{#if var}}...{{/if}} blocks + context: Dictionary of variable values + labels: Dictionary of localized labels + + Returns: + Rendered string + """ + result = template_str + + # Process conditionals first: {{#if variable}}content{{/if}} + def replace_conditional(match): + var_name = match.group(1) + content = match.group(2) + # Check if the variable exists and is truthy + value = context.get(var_name, '') + if value: + return content + return '' + + # Match {{#if var}}...{{/if}} blocks (non-greedy) + conditional_pattern = r'\{\{#if\s+(\w+)\}\}(.*?)\{\{/if\}\}' + result = re.sub(conditional_pattern, replace_conditional, result, flags=re.DOTALL) + + # Replace label variables: {{label.key}} + def replace_label(match): + key = match.group(1) + return labels.get(key, key) + + result = re.sub(r'\{\{label\.(\w+)\}\}', replace_label, result) + + # Replace context variables: {{variable}} + for key, value in context.items(): + placeholder = '{{' + key + '}}' + result = result.replace(placeholder, str(value) if value else '') + + return result + + +def generate_markdown_content(recording, user, include_transcription=True, include_summary=True): + """Generate markdown content for a recording export. + + Args: + recording: Recording object to export + user: User object for getting template preferences + include_transcription: Whether to include transcription + include_summary: Whether to include summary + """ + from src.utils.localization import get_export_labels, format_date_localized, format_datetime_localized + + # Get user's language preference (default to English) + user_language = getattr(user, 'ui_language', 'en') or 'en' + + # Get localized labels + labels = get_export_labels(user_language) + + # Get export template (checks folder, tags, then user default) + export_template = get_user_export_template(user, recording) + + if export_template: + # Use custom template + return generate_from_template( + recording, user, export_template.template, labels, user_language, + include_transcription, include_summary + ) + else: + # Use default (backwards compatible) behavior + return generate_default_markdown( + recording, user, labels, user_language, + include_transcription, include_summary + ) + + +def generate_from_template(recording, user, template_str, labels, user_language, + include_transcription=True, include_summary=True): + """ + Generate markdown content using a custom template. + + Args: + recording: Recording object + user: User object + template_str: Template string + labels: Localized labels dictionary + user_language: User's language code + include_transcription: Whether to include transcription + include_summary: Whether to include summary + + Returns: + Rendered markdown string + """ + from src.utils.localization import format_date_localized, format_datetime_localized + + # Build context with all available variables + context = { + 'title': recording.title or f"Recording {recording.id}", + 'meeting_date': format_date_localized(recording.meeting_date, user_language) if recording.meeting_date else '', + 'created_at': format_datetime_localized(recording.created_at, user_language) if recording.created_at else '', + 'original_filename': recording.original_filename or '', + 'file_size': format_file_size(recording.file_size) if recording.file_size else '', + 'participants': recording.participants or '', + 'tags': ', '.join([tag.name for tag in recording.tags]) if recording.tags else '', + 'transcription_duration': format_duration(recording.transcription_duration_seconds) if recording.transcription_duration_seconds else '', + 'summarization_duration': format_duration(recording.summarization_duration_seconds) if recording.summarization_duration_seconds else '', + 'notes': recording.notes or '' if include_summary else '', # Notes included with summary setting + 'summary': recording.summary or '' if include_summary else '', + 'transcription': '', # Will be set below + } + + # Format transcription if included + if include_transcription and recording.transcription: + context['transcription'] = format_transcription_with_template(recording.transcription, user) + + # Render template + rendered = render_export_template(template_str, context, labels) + + # Always append hardcoded footer + footer = labels.get('footer', 'Generated with [Speakr](https://github.com/learnedmachine/speakr)') + rendered += f"\n\n---\n\n*{footer}*\n" + + return rendered + + +def generate_default_markdown(recording, user, labels, user_language, + include_transcription=True, include_summary=True): + """ + Generate markdown using the default (backwards compatible) format. + + Args: + recording: Recording object + user: User object + labels: Localized labels dictionary + user_language: User's language code + include_transcription: Whether to include transcription + include_summary: Whether to include summary + + Returns: + Rendered markdown string + """ + from src.utils.localization import format_date_localized, format_datetime_localized + + lines = [] + + # Header with title + title = recording.title or f"Recording {recording.id}" + lines.append(f"# {title}") + lines.append("") + + # Metadata section + lines.append(f"## {labels.get('metadata', 'Metadata')}") + lines.append("") + + if recording.meeting_date: + date_str = format_date_localized(recording.meeting_date, user_language) + lines.append(f"- **{labels.get('date', 'Date')}:** {date_str}") + + if recording.created_at: + created_str = format_datetime_localized(recording.created_at, user_language) + lines.append(f"- **{labels.get('created', 'Created')}:** {created_str}") + + if recording.original_filename: + lines.append(f"- **{labels.get('originalFile', 'Original File')}:** {recording.original_filename}") + + if recording.file_size: + lines.append(f"- **{labels.get('fileSize', 'File Size')}:** {format_file_size(recording.file_size)}") + + if recording.participants: + lines.append(f"- **{labels.get('participants', 'Participants')}:** {recording.participants}") + + if recording.tags: + tag_names = [tag.name for tag in recording.tags] + lines.append(f"- **{labels.get('tags', 'Tags')}:** {', '.join(tag_names)}") + + if recording.transcription_duration_seconds: + lines.append(f"- **{labels.get('transcriptionTime', 'Transcription Time')}:** {format_duration(recording.transcription_duration_seconds)}") + + if recording.summarization_duration_seconds: + lines.append(f"- **{labels.get('summarizationTime', 'Summarization Time')}:** {format_duration(recording.summarization_duration_seconds)}") + + lines.append("") + + # Notes section (if available) + if recording.notes: + lines.append(f"## {labels.get('notes', 'Notes')}") + lines.append("") + lines.append(recording.notes) + lines.append("") + + # Summary section + if include_summary and recording.summary: + lines.append(f"## {labels.get('summary', 'Summary')}") + lines.append("") + lines.append(recording.summary) + lines.append("") + + # Transcription section + if include_transcription and recording.transcription: + lines.append(f"## {labels.get('transcription', 'Transcription')}") + lines.append("") + # Format transcription using user's template + formatted_transcription = format_transcription_with_template(recording.transcription, user) + lines.append(formatted_transcription) + lines.append("") + + # Footer + lines.append("---") + lines.append("") + footer = labels.get('footer', 'Generated with [Speakr](https://github.com/learnedmachine/speakr)') + lines.append(f"*{footer}*") + lines.append("") + + return "\n".join(lines) + + +def export_recording(recording_id): + """ + Export a recording to markdown file. + + Args: + recording_id: ID of the recording to export + + Returns: + Path to the exported file, or None if export failed/disabled + """ + if not ENABLE_AUTO_EXPORT: + return None + + # Check if we should export anything + if not AUTO_EXPORT_TRANSCRIPTION and not AUTO_EXPORT_SUMMARY: + logger.warning("Auto-export is enabled but both transcription and summary export are disabled") + return None + + # Import here to avoid circular imports + from src.app import app, db + from src.models import Recording, User + + with app.app_context(): + try: + recording = db.session.get(Recording, recording_id) + if not recording: + logger.error(f"Recording {recording_id} not found for export") + return None + + # Get the owner + user = db.session.get(User, recording.user_id) + if not user: + logger.error(f"User not found for recording {recording_id}") + return None + + # Check if we have content to export + has_transcription = bool(recording.transcription) and AUTO_EXPORT_TRANSCRIPTION + has_summary = bool(recording.summary) and AUTO_EXPORT_SUMMARY + + if not has_transcription and not has_summary: + logger.debug(f"Recording {recording_id} has no content to export") + return None + + # Get export directory for user + export_dir = get_export_directory(user) + + # Generate filename and path + filename = generate_safe_filename(recording) + filepath = export_dir / f"{filename}.md" + + # Generate content + content = generate_markdown_content( + recording, + user, + include_transcription=AUTO_EXPORT_TRANSCRIPTION, + include_summary=AUTO_EXPORT_SUMMARY + ) + + # Write to file (overwrites if exists) + filepath.write_text(content, encoding='utf-8') + + logger.info(f"Exported recording {recording_id} to {filepath}") + return str(filepath) + + except Exception as e: + logger.error(f"Failed to export recording {recording_id}: {e}") + return None + + +def initialize_export_directory(): + """Initialize the export directory on startup.""" + if not ENABLE_AUTO_EXPORT: + return + + try: + export_dir = Path(AUTO_EXPORT_DIR) + export_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Auto-export enabled, directory: {AUTO_EXPORT_DIR}") + + if AUTO_EXPORT_TRANSCRIPTION and AUTO_EXPORT_SUMMARY: + logger.info("Exporting: transcription and summary") + elif AUTO_EXPORT_TRANSCRIPTION: + logger.info("Exporting: transcription only") + elif AUTO_EXPORT_SUMMARY: + logger.info("Exporting: summary only") + else: + logger.warning("Auto-export enabled but no content types selected") + + except Exception as e: + logger.error(f"Failed to initialize export directory: {e}") diff --git a/src/file_monitor.py b/src/file_monitor.py new file mode 100644 index 0000000..f7755fb --- /dev/null +++ b/src/file_monitor.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +""" +File Monitor for Automated Audio Processing +Monitors directories for new audio files and automatically processes them. +Supports multiple user modes: +1. Admin-only: Files go to admin user only +2. User-specific directories: Each user has their own folder (e.g., /auto-process/user123/) +3. Single default user: All files go to one specified user +""" + +import os +import time +import threading +import logging +from datetime import datetime +from pathlib import Path +import mimetypes +from werkzeug.utils import secure_filename +from src.utils.ffprobe import get_codec_info, get_creation_date, FFProbeError +from src.utils.ffmpeg_utils import FFmpegError, FFmpegNotFoundError +from src.utils.audio_conversion import convert_if_needed + +# Video retention - when enabled, video files keep their video stream for playback +VIDEO_RETENTION = os.environ.get('VIDEO_RETENTION', 'false').lower() == 'true' + +# Video passthrough - send original video files directly to ASR without extracting audio +VIDEO_PASSTHROUGH_ASR = os.environ.get('VIDEO_PASSTHROUGH_ASR', 'false').lower() == 'true' + +# Flask app components will be imported inside functions to avoid circular imports + +class FileMonitor: + def __init__(self, base_watch_directory, check_interval=30, mode='admin_only'): + """ + Initialize the file monitor. + + Args: + base_watch_directory (str): Base directory to monitor for new files + check_interval (int): How often to check for new files (seconds) + mode (str): Processing mode - 'admin_only', 'user_directories', or 'single_user' + """ + self.base_watch_directory = Path(base_watch_directory) + self.check_interval = check_interval + self.mode = mode + self.running = False + self.thread = None + + # Ensure base directory exists + self.base_watch_directory.mkdir(parents=True, exist_ok=True) + + # Setup logging + self.logger = logging.getLogger('file_monitor') + self.logger.setLevel(logging.INFO) + + # Supported audio file extensions + # We'll use ffprobe to detect audio files instead of extensions + # Keep a basic list for initial filtering to avoid probing every file + self.potential_audio_extensions = { + '.wav', '.mp3', '.flac', '.amr', '.3gp', '.3gpp', + '.m4a', '.aac', '.ogg', '.wma', '.webm', '.mp4', '.mov', + '.opus', '.caf', '.aiff', '.ts', '.mts', '.mkv', '.avi', + '.m4v', '.wmv', '.flv', '.mpeg', '.mpg', '.ogv', '.vob', '.asf' + } + + # Cache for admin user and valid users + self._admin_user_id = None + self._valid_users = {} # Maps user_id to username + self._username_to_id = {} # Maps username to user_id + self._last_user_cache_update = 0 + + def start(self): + """Start the file monitoring in a background thread.""" + if self.running: + self.logger.warning("File monitor is already running") + return + + self.running = True + self.thread = threading.Thread(target=self._monitor_loop, daemon=True) + self.thread.start() + self.logger.info(f"File monitor started in '{self.mode}' mode, watching: {self.base_watch_directory}") + + def stop(self): + """Stop the file monitoring.""" + self.running = False + if self.thread: + self.thread.join(timeout=5) + self.logger.info("File monitor stopped") + + def _update_user_cache(self): + """Update the cache of valid users and admin user.""" + current_time = time.time() + # Update cache every 5 minutes + if current_time - self._last_user_cache_update < 300: + return + + # Import Flask components inside function to avoid circular imports + from src.app import app, db, User + + with app.app_context(): + try: + # Find admin user + admin_user = User.query.filter_by(is_admin=True).first() + self._admin_user_id = admin_user.id if admin_user else None + + # Cache all valid users + users = User.query.all() + self._valid_users = {user.id: user.username for user in users} + self._username_to_id = {user.username: user.id for user in users} + + self._last_user_cache_update = current_time + self.logger.debug(f"Updated user cache: {len(self._valid_users)} users, admin: {self._admin_user_id}") + + except Exception as e: + self.logger.error(f"Error updating user cache: {e}") + + def _monitor_loop(self): + """Main monitoring loop.""" + while self.running: + try: + self._update_user_cache() + + if self.mode == 'admin_only': + self._scan_admin_directory() + elif self.mode == 'user_directories': + self._scan_user_directories() + elif self.mode == 'single_user': + self._scan_single_user_directory() + + except Exception as e: + self.logger.error(f"Error during directory scan: {e}", exc_info=True) + + # Wait for next check + time.sleep(self.check_interval) + + def _scan_admin_directory(self): + """Scan the main directory for files to process as admin user.""" + if not self._admin_user_id: + self.logger.warning("No admin user found, skipping admin directory scan") + return + + self._scan_directory_for_user(self.base_watch_directory, self._admin_user_id) + + def _scan_user_directories(self): + """Scan user-specific subdirectories.""" + if not self.base_watch_directory.exists(): + return + + # Look for user directories (e.g., user123, user456) + for item in self.base_watch_directory.iterdir(): + if not item.is_dir(): + continue + + # Extract user ID from directory name + user_id = self._extract_user_id_from_dirname(item.name) + if user_id and user_id in self._valid_users: + self._scan_directory_for_user(item, user_id) + elif item.name.startswith('user'): + self.logger.warning(f"Found user directory '{item.name}' but user ID {user_id} is not valid") + + def _scan_single_user_directory(self): + """Scan directory for a single configured user.""" + default_username = os.environ.get('AUTO_PROCESS_DEFAULT_USERNAME') + if not default_username: + self.logger.warning("AUTO_PROCESS_DEFAULT_USERNAME not configured for single_user mode") + return + + user_id = self._username_to_id.get(default_username) + if user_id: + self._scan_directory_for_user(self.base_watch_directory, user_id) + else: + self.logger.warning(f"Configured default username '{default_username}' is not valid") + + def _scan_directory_for_user(self, directory, user_id): + """Scan a specific directory for files to process for a specific user.""" + if not directory.exists(): + return + + for file_path in directory.iterdir(): + if not file_path.is_file(): + continue + + # Skip hidden files, processing files, or non-supported files + if file_path.name.startswith('.') or file_path.suffix == '.processing': + continue + + if file_path.suffix.lower() not in self.potential_audio_extensions: + continue + + # Check if file is still being written (size stability check) + stability_time = int(os.environ.get('AUTO_PROCESS_STABILITY_TIME', '5')) + try: + if not self._is_file_stable(file_path, stability_time): + continue + except FileNotFoundError: + # File might have been picked up by another worker after iterdir() + continue + + self.logger.info(f"Found potential audio file for user {user_id}: {file_path}") + + # --- Atomic Lock via Rename --- + processing_path = file_path.with_suffix(file_path.suffix + '.processing') + + try: + file_path.rename(processing_path) + self.logger.info(f"Acquired lock for {file_path}, renamed to {processing_path}") + except FileNotFoundError: + self.logger.debug(f"Could not acquire lock for {file_path}, already processed by another worker.") + continue + except Exception as e: + self.logger.error(f"Error acquiring lock for {file_path}: {e}") + continue + + # --- Process the locked file --- + try: + self._process_file(processing_path, user_id) + except Exception as e: + self.logger.error(f"Error processing file {processing_path}: {e}", exc_info=True) + # If processing fails, unlock the file by renaming it back + try: + original_path = processing_path.with_suffix(processing_path.suffix.replace('.processing', '')) + processing_path.rename(original_path) + self.logger.info(f"Unlocked file {processing_path} back to {original_path} after processing error.") + except Exception as rename_err: + self.logger.error(f"CRITICAL: Failed to unlock file {processing_path} after error: {rename_err}") + + def _extract_user_id_from_dirname(self, dirname): + """ + Extract user ID from directory name. + + Expected formats: user123, 123 + + Args: + dirname (str): Directory name + + Returns: + int or None: User ID if found, None otherwise + """ + import re + + # Pattern: user123 or just 123 + patterns = [ + r'^user(\d+)$', # user123 + r'^(\d+)$' # 123 + ] + + for pattern in patterns: + match = re.match(pattern, dirname, re.IGNORECASE) + if match: + try: + return int(match.group(1)) + except ValueError: + continue + + return None + + def _is_file_stable(self, file_path, stability_time=5): + """ + Check if a file is stable (not being written to). + + Args: + file_path (Path): Path to the file + stability_time (int): Time in seconds to wait for size stability + + Returns: + bool: True if file appears stable + """ + try: + initial_size = file_path.stat().st_size + initial_mtime = file_path.stat().st_mtime + + # Wait a bit and check again + time.sleep(stability_time) + + current_size = file_path.stat().st_size + current_mtime = file_path.stat().st_mtime + + # File is stable if size and modification time haven't changed + return initial_size == current_size and initial_mtime == current_mtime + + except (OSError, FileNotFoundError): + return False + + def _process_file(self, processing_path, user_id): + """ + Process a single locked audio file for a specific user. + + Args: + processing_path (Path): Path to the locked audio file (e.g., file.mp3.processing) + user_id (int): ID of the user to assign the recording to + """ + # Import Flask components inside function to avoid circular imports + from src.app import app, db, Recording, User, transcribe_audio_task + + with app.app_context(): + try: + # Verify user exists + user = db.session.get(User, user_id) + if not user: + self.logger.error(f"User ID {user_id} not found in database for file {processing_path}") + # We must raise an exception to trigger the unlock mechanism + raise ValueError(f"User ID {user_id} not found") + + # Derive original filename by removing .processing suffix + original_filename = processing_path.name.replace('.processing', '') + safe_filename = secure_filename(original_filename) + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + new_filename = f"auto_{timestamp}_{safe_filename}" + + uploads_dir = Path(app.config['UPLOAD_FOLDER']) + uploads_dir.mkdir(parents=True, exist_ok=True) + destination_path = uploads_dir / new_filename + + # Copy locked file to uploads directory + import shutil + shutil.copy(str(processing_path), str(destination_path)) + self.logger.info(f"Copied {processing_path} to {destination_path}") + + # Delete the locked file from watch directory after successful copy + try: + processing_path.unlink() + self.logger.info(f"Deleted locked file: {processing_path}") + except FileNotFoundError: + # This should not happen if the lock is held, but good to log + self.logger.warning(f"Locked file {processing_path} was already deleted.") + + # Compute file hash on the ORIGINAL file before any conversion/compression. + # Lossy re-encoding produces different bytes each run, so hashing after + # conversion would miss duplicates. + file_hash = None + try: + from src.utils.file_hash import compute_file_sha256 + file_hash = compute_file_sha256(str(destination_path)) + except Exception as e: + self.logger.warning(f"Could not compute file hash: {e}") + + # Probe once to get codec info, then pass through pipeline to avoid redundant calls + codec_info = None + try: + codec_info = get_codec_info(str(destination_path), timeout=10) + self.logger.info(f"Detected codec for {original_filename}: audio_codec={codec_info.get('audio_codec')}, has_video={codec_info.get('has_video', False)}") + except FFProbeError as e: + self.logger.warning(f"Failed to probe {original_filename}: {e}. Will attempt conversion.") + + # Get connector specs for codec restrictions + connector_specs = None + try: + from src.services.transcription import get_registry + registry = get_registry() + connector = registry.get_active_connector() + if connector: + connector_specs = connector.specifications + except Exception as e: + self.logger.warning(f"Could not get connector specs: {e}") + + # Check if this is a video file (for video retention logic) + has_video = codec_info.get('has_video', False) if codec_info else False + + # Video passthrough or retention: skip conversion for videos + if (VIDEO_PASSTHROUGH_ASR or VIDEO_RETENTION) and has_video: + self.logger.info(f"Video {'passthrough' if VIDEO_PASSTHROUGH_ASR else 'retention'}: keeping original video, skipping conversion") + final_path = destination_path + else: + # Convert/compress file if necessary - convert_if_needed handles ALL conversion needs + try: + result = convert_if_needed( + str(destination_path), + original_filename=original_filename, + codec_info=codec_info, + needs_chunking=False, + is_asr_endpoint=False, + delete_original=True, # Clean up original after conversion + connector_specs=connector_specs # Pass connector specs for codec restrictions + ) + final_path = Path(result.output_path) + + # Log what happened + if result.was_converted: + self.logger.info(f"File converted: {result.original_codec} -> {result.final_codec}") + if result.was_compressed: + self.logger.info(f"File compressed: {result.size_reduction_percent:.1f}% size reduction") + + except FFmpegNotFoundError as e: + self.logger.error(f"FFmpeg not found: {e}") + raise + except FFmpegError as e: + self.logger.error(f"FFmpeg conversion failed: {e}") + raise + + # (file_hash already computed above, before conversion) + + # Get file size and MIME type + file_size = final_path.stat().st_size + mime_type, _ = mimetypes.guess_type(str(final_path)) + + # Create database record + now = datetime.utcnow() + + # Try to extract creation date from file metadata, fall back to current time + meeting_date = get_creation_date(str(final_path)) + if meeting_date: + self.logger.info(f"Using file metadata creation date: {meeting_date}") + else: + meeting_date = now + self.logger.debug("No metadata creation date found, using current time") + + # Check for duplicate + if file_hash: + existing = Recording.query.filter_by(user_id=user_id, file_hash=file_hash).first() + if existing: + self.logger.warning( + f"Duplicate file detected for user {user_id}: " + f"hash={file_hash[:12]}... matches recording {existing.id} " + f"({existing.title}). Processing anyway." + ) + + recording = Recording( + audio_path=str(final_path), + original_filename=original_filename, + title=f"Auto-processed - {original_filename}", + file_size=file_size, + status='PENDING', + meeting_date=meeting_date, + user_id=user_id, + mime_type=mime_type, + is_inbox=True, # Auto-processed files go to inbox + processing_source='auto_process', # Track that this was auto-processed + file_hash=file_hash + ) + + db.session.add(recording) + db.session.commit() + + self.logger.info(f"Created recording record with ID: {recording.id} for user: {user.username}") + + # Queue for background processing + from src.services.job_queue import job_queue + job_queue.enqueue( + user_id=user.id, + recording_id=recording.id, + job_type='transcribe', + params={}, + is_new_upload=True + ) + + self.logger.info(f"Queued background processing for recording ID: {recording.id}") + self.logger.info(f"Successfully processed and moved file from: {processing_path}") + + except Exception as e: + self.logger.error(f"Error processing file {processing_path} for user {user_id}: {e}", exc_info=True) + # Re-raise the exception to be caught by the calling method, which will handle unlocking. + raise + + + + +# Global file monitor instance +file_monitor = None + +def start_file_monitor(): + """Start the file monitor with configuration from environment variables.""" + global file_monitor + + if file_monitor and file_monitor.running: + return + + # Import Flask app inside function to avoid circular imports + from src.app import app + + # Get configuration from environment + watch_dir = os.environ.get('AUTO_PROCESS_WATCH_DIR', '/data/auto-process') + check_interval = int(os.environ.get('AUTO_PROCESS_CHECK_INTERVAL', '30')) + mode = os.environ.get('AUTO_PROCESS_MODE', 'admin_only') # admin_only, user_directories, single_user + + # Validate mode + valid_modes = ['admin_only', 'user_directories', 'single_user'] + if mode not in valid_modes: + app.logger.error(f"Invalid AUTO_PROCESS_MODE: {mode}. Must be one of: {valid_modes}") + return + + # Only start if auto-processing is enabled + if os.environ.get('ENABLE_AUTO_PROCESSING', 'false').lower() == 'true': + file_monitor = FileMonitor( + base_watch_directory=watch_dir, + check_interval=check_interval, + mode=mode + ) + file_monitor.start() + app.logger.info(f"Automated file processing started in '{mode}' mode") + else: + app.logger.info("Automated file processing is disabled") + +def stop_file_monitor(): + """Stop the file monitor.""" + global file_monitor + if file_monitor: + file_monitor.stop() + file_monitor = None + +def get_file_monitor_status(): + """Get the current status of the file monitor.""" + global file_monitor + if file_monitor and file_monitor.running: + return { + 'running': True, + 'mode': file_monitor.mode, + 'watch_directory': str(file_monitor.base_watch_directory), + 'check_interval': file_monitor.check_interval + } + else: + return {'running': False} diff --git a/src/init_db.py b/src/init_db.py new file mode 100644 index 0000000..857224a --- /dev/null +++ b/src/init_db.py @@ -0,0 +1,748 @@ +""" +Database initialization and migration logic. + +This module handles: +- Database schema creation +- Column migrations (adding missing columns to existing tables) +- Default system settings initialization +- Existing recordings migration for inquire mode +""" + +import os +import fcntl +import tempfile +from sqlalchemy import text, inspect + +from src.database import db +from src.models import Recording, TranscriptChunk, SystemSetting +from src.services.embeddings import process_recording_chunks +from src.utils import add_column_if_not_exists, migrate_column_type, create_index_if_not_exists + +# Configuration +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' + + +def initialize_database(app): + """ + Initialize database schema and run migrations. + + This function should be called within an app context. + """ + db.create_all() + + # Check and add new columns if they don't exist + engine = db.engine + + # Enable WAL mode for SQLite (better concurrent write performance) + if engine.name == 'sqlite': + try: + with engine.connect() as conn: + conn.execute(text('PRAGMA journal_mode=WAL')) + conn.commit() + app.logger.info("SQLite WAL mode enabled for better concurrency") + except Exception as e: + app.logger.warning(f"Could not enable WAL mode: {e}") + + try: + # Add is_inbox column with default value of 1 (True) + if add_column_if_not_exists(engine, 'recording', 'is_inbox', 'BOOLEAN DEFAULT 1'): + app.logger.info("Added is_inbox column to recording table") + + # Add is_highlighted column with default value of 0 (False) + if add_column_if_not_exists(engine, 'recording', 'is_highlighted', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added is_highlighted column to recording table") + + # Add language preference columns to User table + if add_column_if_not_exists(engine, 'user', 'transcription_language', 'VARCHAR(10)'): + app.logger.info("Added transcription_language column to user table") + + # Add extract_events column to User table + if add_column_if_not_exists(engine, 'user', 'extract_events', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added extract_events column to user table") + if add_column_if_not_exists(engine, 'user', 'output_language', 'VARCHAR(50)'): + app.logger.info("Added output_language column to user table") + if add_column_if_not_exists(engine, 'user', 'summary_prompt', 'TEXT'): + app.logger.info("Added summary_prompt column to user table") + if add_column_if_not_exists(engine, 'user', 'name', 'VARCHAR(100)'): + app.logger.info("Added name column to user table") + if add_column_if_not_exists(engine, 'user', 'job_title', 'VARCHAR(100)'): + app.logger.info("Added job_title column to user table") + if add_column_if_not_exists(engine, 'user', 'company', 'VARCHAR(100)'): + app.logger.info("Added company column to user table") + if add_column_if_not_exists(engine, 'user', 'diarize', 'BOOLEAN'): + app.logger.info("Added diarize column to user table") + if add_column_if_not_exists(engine, 'user', 'ui_language', "VARCHAR(10) DEFAULT 'en'"): + app.logger.info("Added ui_language column to user table") + if add_column_if_not_exists(engine, 'user', 'sso_provider', 'VARCHAR(100)'): + app.logger.info("Added sso_provider column to user table") + if add_column_if_not_exists(engine, 'user', 'sso_subject', 'VARCHAR(255)'): + app.logger.info("Added sso_subject column to user table") + + # Make password column nullable for SSO users + try: + inspector = inspect(engine) + if 'user' in inspector.get_table_names(): + if engine.name == 'sqlite': + # SQLite doesn't support ALTER COLUMN, so we need to check and recreate + with engine.connect() as conn: + result = conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='user'")) + schema = result.scalar() + + if schema and 'password VARCHAR(60) NOT NULL' in schema: + app.logger.info("Migrating user table to make password nullable for SSO support...") + + conn.execute(text(""" + CREATE TABLE user_new ( + id INTEGER NOT NULL, + username VARCHAR(20) NOT NULL, + email VARCHAR(120) NOT NULL, + password VARCHAR(60), + is_admin BOOLEAN, + can_share_publicly BOOLEAN, + transcription_language VARCHAR(10), + output_language VARCHAR(50), + ui_language VARCHAR(10), + summary_prompt TEXT, + extract_events BOOLEAN, + name VARCHAR(100), + job_title VARCHAR(100), + company VARCHAR(100), + diarize BOOLEAN, + sso_provider VARCHAR(100), + sso_subject VARCHAR(255), + PRIMARY KEY (id), + UNIQUE (username), + UNIQUE (email) + ) + """)) + conn.execute(text(""" + INSERT INTO user_new + SELECT id, username, email, password, is_admin, can_share_publicly, + transcription_language, output_language, ui_language, + summary_prompt, extract_events, name, job_title, company, + diarize, sso_provider, sso_subject + FROM user + """)) + conn.execute(text("DROP TABLE user")) + conn.execute(text("ALTER TABLE user_new RENAME TO user")) + conn.execute(text('CREATE UNIQUE INDEX IF NOT EXISTS ix_user_sso_subject ON "user" (sso_subject)')) + conn.commit() + app.logger.info("Successfully made password column nullable for SSO support") + else: + app.logger.info("Password column is already nullable, skipping migration") + + elif engine.name == 'postgresql': + # PostgreSQL supports ALTER COLUMN directly + with engine.connect() as conn: + result = conn.execute(text(""" + SELECT is_nullable FROM information_schema.columns + WHERE table_name = 'user' AND column_name = 'password' + """)) + row = result.fetchone() + if row and row[0] == 'NO': + conn.execute(text('ALTER TABLE "user" ALTER COLUMN password DROP NOT NULL')) + conn.commit() + app.logger.info("Made password column nullable for SSO support (PostgreSQL)") + else: + app.logger.info("Password column is already nullable, skipping migration") + except Exception as e: + app.logger.warning(f"Could not migrate password column to nullable (may cause issues with SSO): {e}") + + if add_column_if_not_exists(engine, 'recording', 'mime_type', 'VARCHAR(100)'): + app.logger.info("Added mime_type column to recording table") + if add_column_if_not_exists(engine, 'recording', 'completed_at', 'DATETIME'): + app.logger.info("Added completed_at column to recording table") + if add_column_if_not_exists(engine, 'recording', 'processing_time_seconds', 'INTEGER'): + app.logger.info("Added processing_time_seconds column to recording table") + if add_column_if_not_exists(engine, 'recording', 'transcription_duration_seconds', 'INTEGER'): + app.logger.info("Added transcription_duration_seconds column to recording table") + if add_column_if_not_exists(engine, 'recording', 'summarization_duration_seconds', 'INTEGER'): + app.logger.info("Added summarization_duration_seconds column to recording table") + if add_column_if_not_exists(engine, 'recording', 'processing_source', "VARCHAR(50) DEFAULT 'upload'"): + app.logger.info("Added processing_source column to recording table") + if add_column_if_not_exists(engine, 'recording', 'error_message', 'TEXT'): + app.logger.info("Added error_message column to recording table") + + # Add columns to recording_tags for order tracking + if add_column_if_not_exists(engine, 'recording_tags', 'added_at', 'DATETIME'): + app.logger.info("Added added_at column to recording_tags table") + if add_column_if_not_exists(engine, 'recording_tags', 'order', '"order" INTEGER DEFAULT 0'): + app.logger.info("Added order column to recording_tags table") + + # Add auto-deletion and retention columns + if add_column_if_not_exists(engine, 'recording', 'audio_deleted_at', 'DATETIME'): + app.logger.info("Added audio_deleted_at column to recording table") + if add_column_if_not_exists(engine, 'recording', 'deletion_exempt', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added deletion_exempt column to recording table") + if add_column_if_not_exists(engine, 'tag', 'protect_from_deletion', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added protect_from_deletion column to tag table") + + # Add speaker embeddings column for storing voice embeddings from diarization + if add_column_if_not_exists(engine, 'recording', 'speaker_embeddings', 'JSON'): + app.logger.info("Added speaker_embeddings column to recording table") + + # Add speaker voice profile embedding fields + if add_column_if_not_exists(engine, 'speaker', 'average_embedding', 'BLOB'): + app.logger.info("Added average_embedding column to speaker table") + if add_column_if_not_exists(engine, 'speaker', 'embeddings_history', 'JSON'): + app.logger.info("Added embeddings_history column to speaker table") + if add_column_if_not_exists(engine, 'speaker', 'embedding_count', 'INTEGER DEFAULT 0'): + app.logger.info("Added embedding_count column to speaker table") + if add_column_if_not_exists(engine, 'speaker', 'confidence_score', 'REAL'): + app.logger.info("Added confidence_score column to speaker table") + + # Add is_new_upload column to processing_job table for tracking upload vs reprocessing jobs + if add_column_if_not_exists(engine, 'processing_job', 'is_new_upload', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added is_new_upload column to processing_job table") + + if add_column_if_not_exists(engine, 'tag', 'group_id', 'INTEGER'): + app.logger.info("Added group_id column to tag table") + + if add_column_if_not_exists(engine, 'tag', 'retention_days', 'INTEGER'): + app.logger.info("Added retention_days column to tag table") + + # Migrate existing protected tags to use retention_days = -1 for consistency + # This standardizes the protection mechanism: retention_days = -1 means protected/infinite retention + try: + with engine.connect() as conn: + # Find tags with protect_from_deletion=True but retention_days != -1 + result = conn.execute(text(""" + SELECT COUNT(*) FROM tag + WHERE protect_from_deletion = TRUE + AND (retention_days IS NULL OR retention_days != -1) + """)) + count = result.scalar() + + if count and count > 0: + # Migrate these tags to use retention_days = -1 + conn.execute(text(""" + UPDATE tag + SET retention_days = -1 + WHERE protect_from_deletion = TRUE + AND (retention_days IS NULL OR retention_days != -1) + """)) + conn.commit() + app.logger.info(f"Migrated {count} protected tags to use retention_days=-1 (standardized protection format)") + except Exception as e: + app.logger.warning(f"Could not migrate protected tags to retention_days=-1: {e}") + + if add_column_if_not_exists(engine, 'tag', 'auto_share_on_apply', 'BOOLEAN DEFAULT 1'): + app.logger.info("Added auto_share_on_apply column to tag table") + + if add_column_if_not_exists(engine, 'tag', 'share_with_group_lead', 'BOOLEAN DEFAULT 1'): + app.logger.info("Added share_with_group_lead column to tag table") + + if add_column_if_not_exists(engine, 'user', 'can_share_publicly', 'BOOLEAN DEFAULT 1'): + app.logger.info("Added can_share_publicly column to user table") + + # Token budget for rate limiting + if add_column_if_not_exists(engine, 'user', 'monthly_token_budget', 'INTEGER'): + app.logger.info("Added monthly_token_budget column to user table") + + # Transcription budget for rate limiting (in seconds) + if add_column_if_not_exists(engine, 'user', 'monthly_transcription_budget', 'INTEGER'): + app.logger.info("Added monthly_transcription_budget column to user table") + + # Naming templates feature + if add_column_if_not_exists(engine, 'user', 'default_naming_template_id', 'INTEGER'): + app.logger.info("Added default_naming_template_id column to user table") + + # Email verification fields + email_verified_added = add_column_if_not_exists(engine, 'user', 'email_verified', 'BOOLEAN DEFAULT 0') + if email_verified_added: + app.logger.info("Added email_verified column to user table") + # Set all existing users to email_verified=True (grandfathered) + try: + with engine.connect() as conn: + conn.execute(text('UPDATE "user" SET email_verified = TRUE WHERE email_verified = FALSE OR email_verified IS NULL')) + conn.commit() + app.logger.info("Set email_verified=True for all existing users (grandfathered)") + except Exception as e: + app.logger.warning(f"Could not update existing users email_verified status: {e}") + + if add_column_if_not_exists(engine, 'user', 'email_verification_token', 'VARCHAR(200)'): + app.logger.info("Added email_verification_token column to user table") + if add_column_if_not_exists(engine, 'user', 'email_verification_sent_at', 'DATETIME'): + app.logger.info("Added email_verification_sent_at column to user table") + if add_column_if_not_exists(engine, 'user', 'password_reset_token', 'VARCHAR(200)'): + app.logger.info("Added password_reset_token column to user table") + if add_column_if_not_exists(engine, 'user', 'password_reset_sent_at', 'DATETIME'): + app.logger.info("Added password_reset_sent_at column to user table") + + # Auto speaker labelling settings + if add_column_if_not_exists(engine, 'user', 'auto_speaker_labelling', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added auto_speaker_labelling column to user table") + if add_column_if_not_exists(engine, 'user', 'auto_speaker_labelling_threshold', "VARCHAR(10) DEFAULT 'medium'"): + app.logger.info("Added auto_speaker_labelling_threshold column to user table") + + # Auto summarization setting (per-user, default enabled) + if add_column_if_not_exists(engine, 'user', 'auto_summarization', 'BOOLEAN DEFAULT 1'): + app.logger.info("Added auto_summarization column to user table") + + # Transcription hints (hotwords and initial prompt for improving ASR accuracy) + if add_column_if_not_exists(engine, 'user', 'transcription_hotwords', 'TEXT'): + app.logger.info("Added transcription_hotwords column to user table") + if add_column_if_not_exists(engine, 'user', 'transcription_initial_prompt', 'TEXT'): + app.logger.info("Added transcription_initial_prompt column to user table") + if add_column_if_not_exists(engine, 'tag', 'default_hotwords', 'TEXT'): + app.logger.info("Added default_hotwords column to tag table") + if add_column_if_not_exists(engine, 'tag', 'default_initial_prompt', 'TEXT'): + app.logger.info("Added default_initial_prompt column to tag table") + if add_column_if_not_exists(engine, 'folder', 'default_hotwords', 'TEXT'): + app.logger.info("Added default_hotwords column to folder table") + if add_column_if_not_exists(engine, 'folder', 'default_initial_prompt', 'TEXT'): + app.logger.info("Added default_initial_prompt column to folder table") + + # Create indexes for token lookups (for faster token verification) + try: + if create_index_if_not_exists(engine, 'ix_user_email_verification_token', 'user', 'email_verification_token'): + app.logger.info("Created index ix_user_email_verification_token on user.email_verification_token") + if create_index_if_not_exists(engine, 'ix_user_password_reset_token', 'user', 'password_reset_token'): + app.logger.info("Created index ix_user_password_reset_token on user.password_reset_token") + except Exception as e: + app.logger.warning(f"Could not create token indexes: {e}") + if add_column_if_not_exists(engine, 'tag', 'naming_template_id', 'INTEGER'): + app.logger.info("Added naming_template_id column to tag table") + + # Export template assignments for tags and folders + if add_column_if_not_exists(engine, 'tag', 'export_template_id', 'INTEGER'): + app.logger.info("Added export_template_id column to tag table") + if add_column_if_not_exists(engine, 'folder', 'export_template_id', 'INTEGER'): + app.logger.info("Added export_template_id column to folder table") + + # Add source tracking columns to internal_share table + if add_column_if_not_exists(engine, 'internal_share', 'source_type', "VARCHAR(20) DEFAULT 'manual'"): + app.logger.info("Added source_type column to internal_share table") + + if add_column_if_not_exists(engine, 'internal_share', 'source_tag_id', 'INTEGER'): + app.logger.info("Added source_tag_id column to internal_share table") + + # Migrate existing shares: infer source based on group tag presence + try: + with engine.connect() as conn: + # For each existing share, check if it was likely created by a group tag + # by looking for group tags on the recording where the shared user is a group member + result = conn.execute(text(''' + UPDATE internal_share + SET source_type = 'group_tag', + source_tag_id = ( + SELECT t.id FROM tag t + INNER JOIN recording_tags rt ON rt.tag_id = t.id + INNER JOIN group_membership gm ON gm.group_id = t.group_id + WHERE rt.recording_id = internal_share.recording_id + AND gm.user_id = internal_share.shared_with_user_id + AND t.group_id IS NOT NULL + AND (t.auto_share_on_apply = TRUE OR t.share_with_group_lead = TRUE) + LIMIT 1 + ) + WHERE source_type = 'manual' + AND EXISTS ( + SELECT 1 FROM tag t + INNER JOIN recording_tags rt ON rt.tag_id = t.id + INNER JOIN group_membership gm ON gm.group_id = t.group_id + WHERE rt.recording_id = internal_share.recording_id + AND gm.user_id = internal_share.shared_with_user_id + AND t.group_id IS NOT NULL + AND (t.auto_share_on_apply = TRUE OR t.share_with_group_lead = TRUE) + ) + ''')) + conn.commit() + app.logger.info("Inferred source tracking for existing shares based on group tag presence") + except Exception as e: + app.logger.warning(f"Could not infer source tracking for existing shares: {e}") + + # Update existing records to have proper order values (approximate by tag_id) + try: + with engine.connect() as conn: + # Get existing associations without order values and assign them + existing_associations = conn.execute(text(''' + SELECT recording_id, tag_id, + ROW_NUMBER() OVER (PARTITION BY recording_id ORDER BY tag_id) as row_num + FROM recording_tags + WHERE "order" = 0 + ''')).fetchall() + + for assoc in existing_associations: + conn.execute(text(''' + UPDATE recording_tags + SET "order" = :order_num + WHERE recording_id = :rec_id AND tag_id = :tag_id + '''), {"order_num": assoc.row_num, "rec_id": assoc.recording_id, "tag_id": assoc.tag_id}) + + conn.commit() + app.logger.info(f"Updated order values for {len(existing_associations)} existing tag associations") + except Exception as e: + app.logger.warning(f"Could not update existing tag order values: {e}") + + # Add per-user status columns to shared_recording_state table + if add_column_if_not_exists(engine, 'shared_recording_state', 'is_inbox', 'BOOLEAN DEFAULT 1'): + app.logger.info("Added is_inbox column to shared_recording_state table") + + # Handle is_starred -> is_highlighted migration + inspector = inspect(engine) + if 'shared_recording_state' in inspector.get_table_names(): + columns = [col['name'] for col in inspector.get_columns('shared_recording_state')] + has_is_starred = 'is_starred' in columns + has_is_highlighted = 'is_highlighted' in columns + + if has_is_starred and not has_is_highlighted: + # Rename is_starred to is_highlighted by copying data + try: + # Add is_highlighted column using utility (handles PostgreSQL boolean defaults) + add_column_if_not_exists(engine, 'shared_recording_state', 'is_highlighted', 'BOOLEAN DEFAULT 0') + # Copy data from is_starred to is_highlighted + with engine.connect() as conn: + conn.execute(text('UPDATE shared_recording_state SET is_highlighted = is_starred')) + conn.commit() + app.logger.info("Migrated is_starred to is_highlighted in shared_recording_state table") + # Note: We keep is_starred for now to avoid breaking existing code during transition + except Exception as e: + app.logger.warning(f"Could not migrate is_starred to is_highlighted: {e}") + elif not has_is_highlighted: + # Neither column exists, add is_highlighted + if add_column_if_not_exists(engine, 'shared_recording_state', 'is_highlighted', 'BOOLEAN DEFAULT 0'): + app.logger.info("Added is_highlighted column to shared_recording_state table") + + # Migrate meeting_date from DATE to DATETIME format + # This migration handles both: + # 1. Converting existing DATE columns to DATETIME (for fresh pulls) + # 2. Restoring NULL dates from created_at (for failed migrations) + try: + inspector = inspect(engine) + columns_info = {col['name']: col for col in inspector.get_columns('recording')} + + if 'meeting_date' in columns_info: + col_type = str(columns_info['meeting_date']['type']).upper() + + # Check if column needs migration from DATE to DATETIME + needs_migration = False + + # For SQLite: Both DATE and DATETIME are TEXT, check data format + if engine.name == 'sqlite': + with engine.connect() as conn: + # Check if we have date-only format (no time component) + result = conn.execute(text(""" + SELECT meeting_date FROM recording + WHERE meeting_date IS NOT NULL + AND meeting_date NOT LIKE '%:%' + LIMIT 1 + """)) + has_date_only = result.fetchone() is not None + needs_migration = has_date_only + + # For PostgreSQL/MySQL: Check actual column type + elif 'DATE' in col_type and 'DATETIME' not in col_type and 'TIMESTAMP' not in col_type: + needs_migration = True + + if needs_migration: + app.logger.info(f"Migrating meeting_date from DATE to DATETIME format (engine: {engine.name})") + + with engine.connect() as conn: + if engine.name == 'sqlite': + # SQLite: Add time component to date-only values + conn.execute(text(""" + UPDATE recording + SET meeting_date = datetime(date(meeting_date) || ' 12:00:00') + WHERE meeting_date IS NOT NULL + AND meeting_date NOT LIKE '%:%' + """)) + conn.commit() + app.logger.info("Migrated SQLite meeting_date to include time") + + elif engine.name == 'postgresql': + # PostgreSQL: Change column type + conn.execute(text(""" + ALTER TABLE recording + ALTER COLUMN meeting_date TYPE TIMESTAMP + USING (meeting_date + TIME '12:00:00') + """)) + conn.commit() + app.logger.info("Migrated PostgreSQL meeting_date to TIMESTAMP") + + elif engine.name == 'mysql': + # MySQL: Change column type + conn.execute(text(""" + ALTER TABLE recording + MODIFY COLUMN meeting_date DATETIME + """)) + # Add time component to existing date values + conn.execute(text(""" + UPDATE recording + SET meeting_date = TIMESTAMP(meeting_date, '12:00:00') + WHERE meeting_date IS NOT NULL + """)) + conn.commit() + app.logger.info("Migrated MySQL meeting_date to DATETIME") + else: + app.logger.info("meeting_date already in DATETIME format, skipping migration") + + # Safety net: Restore any NULL meeting_dates from created_at + with engine.connect() as conn: + result = conn.execute(text(""" + SELECT COUNT(*) FROM recording + WHERE meeting_date IS NULL AND created_at IS NOT NULL + """)) + null_count = result.scalar() + + if null_count and null_count > 0: + conn.execute(text(""" + UPDATE recording + SET meeting_date = created_at + WHERE meeting_date IS NULL AND created_at IS NOT NULL + """)) + conn.commit() + app.logger.info(f"Restored {null_count} NULL meeting dates from created_at") + + except Exception as e: + app.logger.warning(f"Error during meeting_date migration: {e}") + app.logger.warning("New recordings will work correctly, but existing dates may need manual migration") + + # Add index on TranscriptChunk.speaker_name for performance + # This improves speaker rename operations which update all chunks + try: + inspector = inspect(engine) + if 'transcript_chunk' in inspector.get_table_names(): + existing_indexes = [idx['name'] for idx in inspector.get_indexes('transcript_chunk')] + + # Create composite index on (user_id, speaker_name) if it doesn't exist + if 'idx_user_speaker_name' not in existing_indexes: + with engine.connect() as conn: + conn.execute(text( + 'CREATE INDEX IF NOT EXISTS idx_user_speaker_name ON transcript_chunk (user_id, speaker_name)' + )) + conn.commit() + app.logger.info("Created index idx_user_speaker_name on transcript_chunk (user_id, speaker_name) for speaker rename performance") + + # Create single-column index on speaker_name if it doesn't exist + if 'ix_transcript_chunk_speaker_name' not in existing_indexes: + with engine.connect() as conn: + conn.execute(text( + 'CREATE INDEX IF NOT EXISTS ix_transcript_chunk_speaker_name ON transcript_chunk (speaker_name)' + )) + conn.commit() + app.logger.info("Created index ix_transcript_chunk_speaker_name on transcript_chunk (speaker_name)") + except Exception as e: + app.logger.warning(f"Could not create speaker_name indexes: {e}") + + # Add unique index for SSO subject to prevent duplicate linking + try: + if create_index_if_not_exists(engine, 'ix_user_sso_subject', 'user', 'sso_subject', unique=True): + app.logger.info("Created unique index ix_user_sso_subject on user.sso_subject") + except Exception as e: + app.logger.warning(f"Could not create unique index on user.sso_subject: {e}") + + # Add file_hash column for duplicate detection + if add_column_if_not_exists(engine, 'recording', 'file_hash', 'VARCHAR(64)'): + app.logger.info("Added file_hash column to recording table") + try: + if create_index_if_not_exists(engine, 'ix_recording_user_file_hash', 'recording', 'user_id, file_hash'): + app.logger.info("Created index ix_recording_user_file_hash on recording (user_id, file_hash)") + except Exception as e: + app.logger.warning(f"Could not create index on recording (user_id, file_hash): {e}") + + # Add folder_id column to recording table for folders feature + if add_column_if_not_exists(engine, 'recording', 'folder_id', 'INTEGER'): + app.logger.info("Added folder_id column to recording table") + # Create index for folder_id + try: + if create_index_if_not_exists(engine, 'ix_recording_folder_id', 'recording', 'folder_id'): + app.logger.info("Created index ix_recording_folder_id on recording.folder_id") + except Exception as e: + app.logger.warning(f"Could not create index on recording.folder_id: {e}") + + # Add indexes for audit log tables (Loi 25 compliance) + try: + inspector = inspect(engine) + if 'access_log' in inspector.get_table_names(): + if create_index_if_not_exists(engine, 'ix_access_log_user_id', 'access_log', 'user_id'): + app.logger.info("Created index ix_access_log_user_id") + if create_index_if_not_exists(engine, 'ix_access_log_resource', 'access_log', 'resource_type, resource_id'): + app.logger.info("Created index ix_access_log_resource") + if 'auth_log' in inspector.get_table_names(): + if create_index_if_not_exists(engine, 'ix_auth_log_user_id', 'auth_log', 'user_id'): + app.logger.info("Created index ix_auth_log_user_id") + except Exception as e: + app.logger.warning(f"Could not create audit log indexes: {e}") + + # Initialize default system settings + if not SystemSetting.query.filter_by(key='transcript_length_limit').first(): + SystemSetting.set_setting( + key='transcript_length_limit', + value='50000', + description='Maximum number of characters to send from transcript to LLM for summarization and chat. Use -1 for no limit.', + setting_type='integer' + ) + app.logger.info("Initialized default transcript_length_limit setting") + + if not SystemSetting.query.filter_by(key='max_file_size_mb').first(): + SystemSetting.set_setting( + key='max_file_size_mb', + value='10000', + description='Maximum file size allowed for audio uploads in megabytes (MB).', + setting_type='integer' + ) + app.logger.info("Initialized default max_file_size_mb setting") + + if not SystemSetting.query.filter_by(key='asr_timeout_seconds').first(): + SystemSetting.set_setting( + key='asr_timeout_seconds', + value='1800', + description='Maximum time in seconds to wait for ASR transcription to complete. Default is 1800 seconds (30 minutes).', + setting_type='integer' + ) + app.logger.info("Initialized default asr_timeout_seconds setting") + + if not SystemSetting.query.filter_by(key='admin_default_summary_prompt').first(): + default_prompt = """Tu es un assistant expert en prise de notes. Analyse cette transcription et extrais toutes les informations importantes en français. + + ## 📝 RÉSUMÉ + Synthèse claire et concise de la conversation en 4-6 phrases. + + ## 🔑 POINTS CLÉS + • Les informations essentielles à retenir + • Les faits importants mentionnés + • Les opinions ou positions exprimées + + ## 📊 DONNÉES & CHIFFRES + • Montants, dates, délais, pourcentages + • Noms de personnes, entreprises, lieux + • Références techniques ou spécifiques + + ## 💬 CITATIONS MARQUANTES + > Phrases importantes ou révélatrices (entre guillemets) + + ## ⚠️ PROBLÈMES & PRÉOCCUPATIONS + • Difficultés ou obstacles mentionnés + • Risques identifiés + • Points de friction ou désaccords + + ## 💡 IDÉES & SUGGESTIONS + • Propositions faites durant la conversation + • Solutions envisagées + • Opportunités mentionnelles + + ## ✅ DÉCISIONS & PROCHAINES ÉTAPES + • Ce qui a été décidé ou convenu + • Actions à entreprendre + • Suivis nécessaires + + --- + Instructions : Sois exhaustif et n'omets aucun détail pertinent. Utilise un langage clair et professionnel. Adapte les sections selon le contenu — si une section ne s'applique pas, omets-la.""" + SystemSetting.set_setting( + key='admin_default_summary_prompt', + value=default_prompt, + description='Default summarization prompt used when users have not set their own prompt. This serves as the base prompt for all users.', + setting_type='string' + ) + app.logger.info("Initialized admin_default_summary_prompt setting") + + if not SystemSetting.query.filter_by(key='recording_disclaimer').first(): + SystemSetting.set_setting( + key='recording_disclaimer', + value='', + description='Legal disclaimer shown to users before recording starts. Supports Markdown formatting. Leave empty to disable.', + setting_type='string' + ) + app.logger.info("Initialized recording_disclaimer setting") + + if not SystemSetting.query.filter_by(key='upload_disclaimer').first(): + SystemSetting.set_setting( + key='upload_disclaimer', + value='', + description='Legal disclaimer shown before file uploads. Supports Markdown. Leave empty to disable.', + setting_type='string' + ) + app.logger.info("Initialized upload_disclaimer setting") + + if not SystemSetting.query.filter_by(key='custom_banner').first(): + SystemSetting.set_setting( + key='custom_banner', + value='', + description='Custom banner shown at the top of the page. Supports Markdown. Leave empty to disable.', + setting_type='string' + ) + app.logger.info("Initialized custom_banner setting") + + if not SystemSetting.query.filter_by(key='disable_auto_summarization').first(): + SystemSetting.set_setting( + key='disable_auto_summarization', + value='false', + description='Disable automatic summarization after transcription completes. When enabled, recordings will only be transcribed and users must manually trigger summarization.', + setting_type='boolean' + ) + app.logger.info("Initialized disable_auto_summarization setting") + + if not SystemSetting.query.filter_by(key='enable_folders').first(): + SystemSetting.set_setting( + key='enable_folders', + value='true', + description='Enable the Folders feature, allowing users to organize recordings into folders with custom prompts and ASR settings.', + setting_type='boolean' + ) + app.logger.info("Initialized enable_folders setting") + + # Process existing recordings for inquire mode (chunk and embed them) + # Only run if inquire mode is enabled + if ENABLE_INQUIRE_MODE: + # Use a file lock to prevent multiple workers from running this simultaneously + lock_file_path = os.path.join(tempfile.gettempdir(), 'inquire_migration.lock') + + try: + with open(lock_file_path, 'w') as lock_file: + # Try to acquire exclusive lock (non-blocking) + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + app.logger.info("Acquired migration lock, checking for existing recordings that need chunking for inquire mode...") + + completed_recordings = Recording.query.filter_by(status='COMPLETED').all() + recordings_needing_processing = [] + + for recording in completed_recordings: + if recording.transcription: # Has transcription + chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count() + if chunk_count == 0: # No chunks yet + recordings_needing_processing.append(recording) + + if recordings_needing_processing: + app.logger.info(f"Found {len(recordings_needing_processing)} recordings that need chunking for inquire mode") + app.logger.info("Processing first 10 recordings automatically. Use admin API or migration script for remaining recordings.") + + # Process first 10 recordings automatically to avoid long startup times + batch_size = min(10, len(recordings_needing_processing)) + processed = 0 + + for i in range(batch_size): + recording = recordings_needing_processing[i] + try: + success = process_recording_chunks(recording.id) + if success: + processed += 1 + app.logger.info(f"Processed chunks for recording: {recording.title} ({recording.id})") + except Exception as e: + app.logger.warning(f"Failed to process chunks for recording {recording.id}: {e}") + + remaining = len(recordings_needing_processing) - processed + if remaining > 0: + app.logger.info(f"Successfully processed {processed} recordings. {remaining} recordings remaining.") + app.logger.info("Use the admin migration API or run 'python migrate_existing_recordings.py' to process remaining recordings.") + else: + app.logger.info(f"Successfully processed all {processed} recordings for inquire mode.") + else: + app.logger.info("All existing recordings are already processed for inquire mode.") + + except BlockingIOError: + app.logger.info("Migration already running in another worker, skipping...") + + except Exception as e: + app.logger.warning(f"Error during existing recordings migration: {e}") + app.logger.info("Existing recordings can be migrated later using the admin API or migration script.") + + except Exception as e: + app.logger.error(f"Error during database migration: {e}") + + +if __name__ == '__main__': + # For standalone migration script + from src.app import app + with app.app_context(): + initialize_database(app) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..17551cc --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,73 @@ +""" +Database models package for the Speakr application. + +This package contains all database models organized by domain: +- User and authentication models +- Recording and transcript models +- Sharing models (public and internal) +- Organization models (groups and tags) +- Event, template, and search session models +- System configuration models +""" + +# Import database instance +from src.database import db + +# Import all models +from .user import User, Speaker +from .api_token import APIToken +from .speaker_snippet import SpeakerSnippet +from .recording import Recording, TranscriptChunk +from .sharing import Share, InternalShare, SharedRecordingState +from .organization import Group, GroupMembership, Tag, RecordingTag, Folder +from .events import Event +from .templates import TranscriptTemplate +from .naming_template import NamingTemplate +from .export_template import ExportTemplate +from .inquire import InquireSession +from .system import SystemSetting +from .audit import ShareAuditLog +from .access_log import AccessLog +from .auth_log import AuthLog +from .push_subscription import PushSubscription +from .processing_job import ProcessingJob +from .token_usage import TokenUsage +from .transcription_usage import TranscriptionUsage + +# Export all models +__all__ = [ + # Database instance + 'db', + # User models + 'User', + 'Speaker', + 'APIToken', + 'SpeakerSnippet', + # Recording models + 'Recording', + 'TranscriptChunk', + # Sharing models + 'Share', + 'InternalShare', + 'SharedRecordingState', + 'ShareAuditLog', + 'AccessLog', + 'AuthLog', + # Organization models + 'Group', + 'GroupMembership', + 'Tag', + 'RecordingTag', + 'Folder', + # Other models + 'Event', + 'TranscriptTemplate', + 'NamingTemplate', + 'ExportTemplate', + 'InquireSession', + 'SystemSetting', + 'PushSubscription', + 'ProcessingJob', + 'TokenUsage', + 'TranscriptionUsage', +] diff --git a/src/models/access_log.py b/src/models/access_log.py new file mode 100644 index 0000000..5919e47 --- /dev/null +++ b/src/models/access_log.py @@ -0,0 +1,97 @@ +""" +Access audit log model for tracking data access operations. + +Provides traceability for Loi 25 compliance: who accessed what, when, from where. +""" + +from datetime import datetime +from src.database import db + + +class AccessLog(db.Model): + """Audit trail for data access operations (view, download, edit, delete).""" + + __tablename__ = 'access_log' + + id = db.Column(db.Integer, primary_key=True) + + # Who + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) # nullable for anonymous/public access + user = db.relationship('User', backref=db.backref('access_logs', lazy='dynamic')) + + # What action + action = db.Column(db.String(30), nullable=False) # 'view', 'download', 'edit', 'delete', 'export', 'share' + + # What resource + resource_type = db.Column(db.String(50), nullable=False) # 'recording', 'audio', 'transcript', 'user', 'summary' + resource_id = db.Column(db.Integer, nullable=True) + + # When + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Where from + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.String(500), nullable=True) + + # Result + status = db.Column(db.String(20), default='success', nullable=False) # 'success', 'denied', 'error' + + # Extra context (JSON) + details = db.Column(db.JSON, nullable=True) + + def to_dict(self): + """Convert to dictionary for API responses.""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'username': self.user.username if self.user else None, + 'action': self.action, + 'resource_type': self.resource_type, + 'resource_id': self.resource_id, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'ip_address': self.ip_address, + 'user_agent': self.user_agent, + 'status': self.status, + 'details': self.details, + } + + @staticmethod + def log_access(action, resource_type, resource_id=None, user_id=None, status='success', details=None, ip_address=None, user_agent=None): + """Log a data access event.""" + log = AccessLog( + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + status=status, + details=details, + ip_address=ip_address, + user_agent=user_agent, + ) + db.session.add(log) + return log + + @staticmethod + def log_view(resource_type, resource_id, user_id=None, ip_address=None, user_agent=None, details=None): + """Log a view/read access.""" + return AccessLog.log_access('view', resource_type, resource_id, user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_download(resource_type, resource_id, user_id=None, ip_address=None, user_agent=None, details=None): + """Log a download access.""" + return AccessLog.log_access('download', resource_type, resource_id, user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_edit(resource_type, resource_id, user_id=None, ip_address=None, user_agent=None, details=None): + """Log an edit/modification.""" + return AccessLog.log_access('edit', resource_type, resource_id, user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_delete(resource_type, resource_id, user_id=None, ip_address=None, user_agent=None, details=None): + """Log a deletion.""" + return AccessLog.log_access('delete', resource_type, resource_id, user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_export(resource_type, resource_id, user_id=None, ip_address=None, user_agent=None, details=None): + """Log a data export.""" + return AccessLog.log_access('export', resource_type, resource_id, user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) diff --git a/src/models/api_token.py b/src/models/api_token.py new file mode 100644 index 0000000..37a6a04 --- /dev/null +++ b/src/models/api_token.py @@ -0,0 +1,51 @@ +""" +API Token database model. + +This module defines the APIToken model for managing user API tokens +that allow authentication via Bearer tokens for automation tools. +""" + +from datetime import datetime +from src.database import db + + +class APIToken(db.Model): + """API Token model for token-based authentication.""" + + __tablename__ = 'api_token' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True) + name = db.Column(db.String(100), nullable=True) # User-friendly label (e.g., "n8n", "CLI") + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + last_used_at = db.Column(db.DateTime, nullable=True) + expires_at = db.Column(db.DateTime, nullable=True) + revoked = db.Column(db.Boolean, default=False, nullable=False, index=True) + + # Relationship to User + user = db.relationship('User', backref=db.backref('api_tokens', lazy=True, cascade='all, delete-orphan')) + + def __repr__(self): + return f"APIToken(name='{self.name}', user_id={self.user_id}, revoked={self.revoked})" + + def to_dict(self): + """Convert token to dictionary for API responses.""" + return { + 'id': self.id, + 'name': self.name, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'revoked': self.revoked + } + + def is_expired(self): + """Check if token has expired.""" + if not self.expires_at: + return False + return self.expires_at < datetime.utcnow() + + def is_valid(self): + """Check if token is valid (not revoked and not expired).""" + return not self.revoked and not self.is_expired() diff --git a/src/models/audit.py b/src/models/audit.py new file mode 100644 index 0000000..beecedd --- /dev/null +++ b/src/models/audit.py @@ -0,0 +1,109 @@ +""" +Audit logging models for tracking share operations. + +Provides comprehensive audit trail for security and compliance. +""" + +from datetime import datetime +from src.database import db + + +class ShareAuditLog(db.Model): + """Audit trail for share operations.""" + + __tablename__ = 'share_audit_log' + + id = db.Column(db.Integer, primary_key=True) + + # Action details + action = db.Column(db.String(20), nullable=False) # 'created', 'modified', 'revoked', 'cascade_revoked' + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id', ondelete='CASCADE'), nullable=False) + + # Actor (who performed the action) + actor_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + actor = db.relationship('User', foreign_keys=[actor_id], backref='audit_actions_performed') + + # Target (who was affected - optional for some actions) + target_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + target_user = db.relationship('User', foreign_keys=[target_user_id]) + + # Permission snapshot at time of action + permissions_granted = db.Column(db.JSON, nullable=True) # What was granted/revoked + actor_permissions = db.Column(db.JSON, nullable=True) # What actor had at time + + # Metadata + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + share_id = db.Column(db.Integer, nullable=True) # Reference to share if applicable + + # Context and notes + notes = db.Column(db.Text, nullable=True) # System-generated notes (e.g., "Permission constrained", "Cascade revocation") + ip_address = db.Column(db.String(45), nullable=True) # Actor's IP address + + # Recording relationship + recording = db.relationship('Recording', backref=db.backref('share_audit_logs', cascade='all, delete-orphan')) + + def to_dict(self): + """Convert to dictionary for API responses.""" + return { + 'id': self.id, + 'action': self.action, + 'recording_id': self.recording_id, + 'actor_id': self.actor_id, + 'actor_username': self.actor.username if self.actor else None, + 'target_user_id': self.target_user_id, + 'target_username': self.target_user.username if self.target_user else None, + 'permissions_granted': self.permissions_granted, + 'actor_permissions': self.actor_permissions, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'share_id': self.share_id, + 'notes': self.notes, + 'ip_address': self.ip_address + } + + @staticmethod + def log_share_created(recording_id, actor_id, target_user_id, permissions, actor_permissions=None, notes=None, ip_address=None): + """Log share creation.""" + log = ShareAuditLog( + action='created', + recording_id=recording_id, + actor_id=actor_id, + target_user_id=target_user_id, + permissions_granted=permissions, + actor_permissions=actor_permissions, + notes=notes, + ip_address=ip_address + ) + db.session.add(log) + return log + + @staticmethod + def log_share_modified(share_id, recording_id, actor_id, target_user_id, old_permissions, new_permissions, notes=None, ip_address=None): + """Log share modification.""" + log = ShareAuditLog( + action='modified', + recording_id=recording_id, + actor_id=actor_id, + target_user_id=target_user_id, + permissions_granted={'old': old_permissions, 'new': new_permissions}, + share_id=share_id, + notes=notes, + ip_address=ip_address + ) + db.session.add(log) + return log + + @staticmethod + def log_share_revoked(share_id, recording_id, actor_id, target_user_id, was_cascade=False, notes=None, ip_address=None): + """Log share revocation.""" + action = 'cascade_revoked' if was_cascade else 'revoked' + log = ShareAuditLog( + action=action, + recording_id=recording_id, + actor_id=actor_id, + target_user_id=target_user_id, + share_id=share_id, + notes=notes, + ip_address=ip_address + ) + db.session.add(log) + return log diff --git a/src/models/auth_log.py b/src/models/auth_log.py new file mode 100644 index 0000000..33fe2dc --- /dev/null +++ b/src/models/auth_log.py @@ -0,0 +1,94 @@ +""" +Authentication audit log model for tracking auth events. + +Provides traceability for Loi 25 compliance: login/logout history, failed attempts. +""" + +from datetime import datetime +from src.database import db + + +class AuthLog(db.Model): + """Audit trail for authentication events.""" + + __tablename__ = 'auth_log' + + id = db.Column(db.Integer, primary_key=True) + + # Who (nullable for failed logins where user doesn't exist) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + user = db.relationship('User', backref=db.backref('auth_logs', lazy='dynamic')) + + # What + action = db.Column(db.String(30), nullable=False) # 'login', 'logout', 'failed_login', 'register', 'password_change', 'password_reset', 'sso_login' + + # When + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Where from + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.String(500), nullable=True) + + # Extra context (JSON) — e.g. email attempted, SSO provider, reason for failure + details = db.Column(db.JSON, nullable=True) + + def to_dict(self): + """Convert to dictionary for API responses.""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'username': self.user.username if self.user else None, + 'action': self.action, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'ip_address': self.ip_address, + 'user_agent': self.user_agent, + 'details': self.details, + } + + @staticmethod + def log_auth(action, user_id=None, ip_address=None, user_agent=None, details=None): + """Log an authentication event.""" + log = AuthLog( + user_id=user_id, + action=action, + ip_address=ip_address, + user_agent=user_agent, + details=details, + ) + db.session.add(log) + return log + + @staticmethod + def log_login(user_id, ip_address=None, user_agent=None, details=None): + """Log a successful login.""" + return AuthLog.log_auth('login', user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_logout(user_id, ip_address=None, user_agent=None): + """Log a logout.""" + return AuthLog.log_auth('logout', user_id=user_id, ip_address=ip_address, user_agent=user_agent) + + @staticmethod + def log_failed_login(ip_address=None, user_agent=None, details=None): + """Log a failed login attempt.""" + return AuthLog.log_auth('failed_login', ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_register(user_id, ip_address=None, user_agent=None): + """Log a new user registration.""" + return AuthLog.log_auth('register', user_id=user_id, ip_address=ip_address, user_agent=user_agent) + + @staticmethod + def log_password_change(user_id, ip_address=None, user_agent=None, details=None): + """Log a password change.""" + return AuthLog.log_auth('password_change', user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) + + @staticmethod + def log_password_reset(user_id, ip_address=None, user_agent=None): + """Log a password reset.""" + return AuthLog.log_auth('password_reset', user_id=user_id, ip_address=ip_address, user_agent=user_agent) + + @staticmethod + def log_sso_login(user_id, ip_address=None, user_agent=None, details=None): + """Log an SSO login.""" + return AuthLog.log_auth('sso_login', user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=details) diff --git a/src/models/events.py b/src/models/events.py new file mode 100644 index 0000000..d967b68 --- /dev/null +++ b/src/models/events.py @@ -0,0 +1,43 @@ +""" +Event model for calendar events extracted from transcripts. + +This module defines the Event model for storing calendar events +that are extracted from transcriptions. +""" + +import json +from datetime import datetime +from src.database import db + + +class Event(db.Model): + """Calendar events extracted from transcripts.""" + + id = db.Column(db.Integer, primary_key=True) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id'), nullable=False) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + start_datetime = db.Column(db.DateTime, nullable=False) + end_datetime = db.Column(db.DateTime, nullable=True) + location = db.Column(db.String(500), nullable=True) + attendees = db.Column(db.Text, nullable=True) # JSON list of attendees + reminder_minutes = db.Column(db.Integer, nullable=True, default=15) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationship + recording = db.relationship('Recording', backref=db.backref('events', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'recording_id': self.recording_id, + 'title': self.title, + 'description': self.description, + 'start_datetime': self.start_datetime.isoformat() if self.start_datetime else None, + 'end_datetime': self.end_datetime.isoformat() if self.end_datetime else None, + 'location': self.location, + 'attendees': json.loads(self.attendees) if self.attendees else [], + 'reminder_minutes': self.reminder_minutes, + 'created_at': self.created_at.isoformat() if self.created_at else None + } diff --git a/src/models/export_template.py b/src/models/export_template.py new file mode 100644 index 0000000..dc13278 --- /dev/null +++ b/src/models/export_template.py @@ -0,0 +1,37 @@ +""" +ExportTemplate model for user-defined export formatting. + +This module defines the ExportTemplate model for storing +custom templates for markdown export formatting. +""" + +from datetime import datetime +from src.database import db + + +class ExportTemplate(db.Model): + """Stores user-defined templates for markdown export formatting.""" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + template = db.Column(db.Text, nullable=False) + description = db.Column(db.String(500), nullable=True) + is_default = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('export_templates', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'template': self.template, + 'description': self.description, + 'is_default': self.is_default, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } diff --git a/src/models/inquire.py b/src/models/inquire.py new file mode 100644 index 0000000..eb84186 --- /dev/null +++ b/src/models/inquire.py @@ -0,0 +1,45 @@ +""" +InquireSession model for semantic search sessions. + +This module defines the InquireSession model for tracking +inquire mode sessions and their filtering criteria. +""" + +import json +from datetime import datetime +from src.database import db + + +class InquireSession(db.Model): + """Tracks inquire mode sessions and their filtering criteria.""" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + session_name = db.Column(db.String(200), nullable=True) # Optional user-defined name + + # Filter criteria (JSON stored as text) + filter_tags = db.Column(db.Text, nullable=True) # JSON array of tag IDs + filter_speakers = db.Column(db.Text, nullable=True) # JSON array of speaker names + filter_date_from = db.Column(db.Date, nullable=True) + filter_date_to = db.Column(db.Date, nullable=True) + filter_recording_ids = db.Column(db.Text, nullable=True) # JSON array of specific recording IDs + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_used = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('inquire_sessions', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'session_name': self.session_name, + 'filter_tags': json.loads(self.filter_tags) if self.filter_tags else [], + 'filter_speakers': json.loads(self.filter_speakers) if self.filter_speakers else [], + 'filter_date_from': self.filter_date_from.isoformat() if self.filter_date_from else None, + 'filter_date_to': self.filter_date_to.isoformat() if self.filter_date_to else None, + 'filter_recording_ids': json.loads(self.filter_recording_ids) if self.filter_recording_ids else [], + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_used': self.last_used.isoformat() if self.last_used else None + } diff --git a/src/models/naming_template.py b/src/models/naming_template.py new file mode 100644 index 0000000..f09dc89 --- /dev/null +++ b/src/models/naming_template.py @@ -0,0 +1,114 @@ +""" +NamingTemplate model for user-defined recording title formatting. + +This module defines the NamingTemplate model for storing +custom templates for generating recording titles from filenames, +metadata, and AI-generated content. +""" + +import json +import re +import os +from datetime import datetime +from src.database import db + + +class NamingTemplate(db.Model): + """Stores user-defined templates for recording title generation.""" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + template = db.Column(db.Text, nullable=False) # e.g., "{{phone}} - {{date}} {{ai_title}}" + description = db.Column(db.String(500), nullable=True) + regex_patterns = db.Column(db.Text, nullable=True) # JSON: {"phone": "\\d{10}", "caller": "^([^-]+)"} + is_default = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('naming_templates', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'template': self.template, + 'description': self.description, + 'regex_patterns': json.loads(self.regex_patterns) if self.regex_patterns else {}, + 'is_default': self.is_default, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + def get_regex_patterns(self): + """Parse and return regex patterns as dictionary.""" + if not self.regex_patterns: + return {} + try: + return json.loads(self.regex_patterns) + except json.JSONDecodeError: + return {} + + def needs_ai_title(self): + """Check if template requires AI-generated title.""" + return '{{ai_title}}' in self.template + + def apply(self, original_filename, meeting_date=None, ai_title=None): + """ + Apply this template to generate a recording title. + + Args: + original_filename: The original filename of the recording + meeting_date: Optional datetime of the recording + ai_title: Optional AI-generated title + + Returns: + Generated title string, or None if template produces empty result + """ + # Start with template + result = self.template + + # Get filename without extension for {{filename}} + filename_no_ext = os.path.splitext(original_filename)[0] if original_filename else '' + + # Build built-in variables + variables = { + 'ai_title': ai_title or '', + 'filename': filename_no_ext, + 'filename_full': original_filename or '', + 'date': meeting_date.strftime('%Y-%m-%d') if meeting_date else '', + 'datetime': meeting_date.strftime('%Y-%m-%d %H:%M') if meeting_date else '', + 'time': meeting_date.strftime('%H:%M') if meeting_date else '', + 'year': meeting_date.strftime('%Y') if meeting_date else '', + 'month': meeting_date.strftime('%m') if meeting_date else '', + 'day': meeting_date.strftime('%d') if meeting_date else '', + } + + # Extract custom variables from filename using regex patterns + regex_patterns = self.get_regex_patterns() + for var_name, pattern in regex_patterns.items(): + try: + match = re.search(pattern, filename_no_ext) + if match: + # Use first capture group if exists, else full match + variables[var_name] = match.group(1) if match.groups() else match.group(0) + else: + variables[var_name] = '' + except re.error as e: + # Invalid regex - log and treat as empty + variables[var_name] = '' + + # Replace all variables in template + for var_name, value in variables.items(): + result = result.replace('{{' + var_name + '}}', value) + + # Clean up result + result = result.strip() + + # If result is empty or only whitespace, return None + if not result: + return None + + return result diff --git a/src/models/organization.py b/src/models/organization.py new file mode 100644 index 0000000..1e536ed --- /dev/null +++ b/src/models/organization.py @@ -0,0 +1,240 @@ +""" +Organization models for groups, tags, and related structures. + +This module defines models for organizing users into groups and tagging recordings. +""" + +from datetime import datetime +from src.database import db + + +class Group(db.Model): + """Groups for organizing users and sharing recordings.""" + + __tablename__ = 'group' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + description = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + memberships = db.relationship('GroupMembership', back_populates='group', cascade='all, delete-orphan') + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'member_count': len(self.memberships), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + +class GroupMembership(db.Model): + """Tracks user membership in groups with roles.""" + + __tablename__ = 'group_membership' + + id = db.Column(db.Integer, primary_key=True) + group_id = db.Column(db.Integer, db.ForeignKey('group.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + role = db.Column(db.String(20), default='member') # 'admin' or 'member' + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + group = db.relationship('Group', back_populates='memberships') + user = db.relationship('User', backref=db.backref('group_memberships', lazy=True)) + + # Unique constraint: user can only be in a group once + __table_args__ = (db.UniqueConstraint('group_id', 'user_id', name='unique_group_membership'),) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'group_id': self.group_id, + 'group_name': self.group.name if self.group else None, + 'user_id': self.user_id, + 'username': self.user.username if self.user else None, + 'role': self.role, + 'joined_at': self.joined_at.isoformat() if self.joined_at else None + } + + +class RecordingTag(db.Model): + """Many-to-many relationship table for recordings and tags.""" + + __tablename__ = 'recording_tags' + + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id'), primary_key=True) + tag_id = db.Column(db.Integer, db.ForeignKey('tag.id'), primary_key=True) + added_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=True) + order = db.Column(db.Integer, nullable=False, default=0) + + # Relationships + recording = db.relationship('Recording', back_populates='tag_associations') + tag = db.relationship('Tag', back_populates='recording_associations') + + +class Folder(db.Model): + """Folders for organizing recordings (one-to-many relationship).""" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + group_id = db.Column(db.Integer, db.ForeignKey('group.id', ondelete='CASCADE'), nullable=True) # Group-scoped folder + color = db.Column(db.String(7), default='#10B981') # Hex color for UI (green to differentiate from tags) + + # Custom settings for this folder + custom_prompt = db.Column(db.Text, nullable=True) # Custom summarization prompt + default_language = db.Column(db.String(10), nullable=True) # Default transcription language + default_min_speakers = db.Column(db.Integer, nullable=True) # Default min speakers for ASR + default_max_speakers = db.Column(db.Integer, nullable=True) # Default max speakers for ASR + + # Transcription hints + default_hotwords = db.Column(db.Text, nullable=True) # Comma-separated words to bias recognition + default_initial_prompt = db.Column(db.Text, nullable=True) # Initial prompt to steer transcription + + # Retention and deletion settings + protect_from_deletion = db.Column(db.Boolean, default=False) # Exempt recordings in folder from auto-deletion + retention_days = db.Column(db.Integer, nullable=True) # Folder-specific retention override + + # Group folder settings + auto_share_on_apply = db.Column(db.Boolean, default=True) # Auto-share recording with group when moved to folder + share_with_group_lead = db.Column(db.Boolean, default=True) # Share with group admins when moved to folder + + # Naming template for recordings in this folder + naming_template_id = db.Column(db.Integer, db.ForeignKey('naming_template.id', ondelete='SET NULL'), nullable=True) + + # Export template for recordings in this folder + export_template_id = db.Column(db.Integer, db.ForeignKey('export_template.id', ondelete='SET NULL'), nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('folders', lazy=True, cascade='all, delete-orphan')) + group = db.relationship('Group', backref=db.backref('folders', lazy=True)) + naming_template = db.relationship('NamingTemplate', foreign_keys=[naming_template_id]) + export_template = db.relationship('ExportTemplate', foreign_keys=[export_template_id]) + # One-to-many relationship with recordings + recordings = db.relationship('Recording', back_populates='folder', lazy=True) + + # Unique constraint: folder name must be unique per user + __table_args__ = (db.UniqueConstraint('name', 'user_id', name='_user_folder_uc'),) + + @property + def is_group_folder(self): + """Check if this is a group-scoped folder.""" + return self.group_id is not None + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'color': self.color, + 'group_id': self.group_id, + 'is_group_folder': self.is_group_folder, + 'group_name': self.group.name if self.group else None, + 'custom_prompt': self.custom_prompt, + 'default_language': self.default_language, + 'default_min_speakers': self.default_min_speakers, + 'default_max_speakers': self.default_max_speakers, + 'default_hotwords': self.default_hotwords, + 'default_initial_prompt': self.default_initial_prompt, + 'protect_from_deletion': self.protect_from_deletion, + 'retention_days': self.retention_days, + 'auto_share_on_apply': self.auto_share_on_apply, + 'share_with_group_lead': self.share_with_group_lead, + 'naming_template_id': self.naming_template_id, + 'naming_template_name': self.naming_template.name if self.naming_template else None, + 'export_template_id': self.export_template_id, + 'export_template_name': self.export_template.name if self.export_template else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'recording_count': len(self.recordings) if self.recordings else 0 + } + + +class Tag(db.Model): + """Tags for organizing and categorizing recordings.""" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + group_id = db.Column(db.Integer, db.ForeignKey('group.id', ondelete='CASCADE'), nullable=True) # Group-scoped tag + color = db.Column(db.String(7), default='#3B82F6') # Hex color for UI + + # Custom settings for this tag + custom_prompt = db.Column(db.Text, nullable=True) # Custom summarization prompt + default_language = db.Column(db.String(10), nullable=True) # Default transcription language + default_min_speakers = db.Column(db.Integer, nullable=True) # Default min speakers for ASR + default_max_speakers = db.Column(db.Integer, nullable=True) # Default max speakers for ASR + + # Transcription hints + default_hotwords = db.Column(db.Text, nullable=True) # Comma-separated words to bias recognition + default_initial_prompt = db.Column(db.Text, nullable=True) # Initial prompt to steer transcription + + # Retention and deletion settings + protect_from_deletion = db.Column(db.Boolean, default=False) # Exempt tagged recordings from auto-deletion + retention_days = db.Column(db.Integer, nullable=True) # Group-specific retention override (overrides global) + + # Group tag settings + auto_share_on_apply = db.Column(db.Boolean, default=True) # Auto-share recording with group when this tag is applied + share_with_group_lead = db.Column(db.Boolean, default=True) # Share with group admins when this tag is applied + + # Naming template for recordings with this tag + naming_template_id = db.Column(db.Integer, db.ForeignKey('naming_template.id', ondelete='SET NULL'), nullable=True) + + # Export template for recordings with this tag + export_template_id = db.Column(db.Integer, db.ForeignKey('export_template.id', ondelete='SET NULL'), nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('tags', lazy=True, cascade='all, delete-orphan')) + group = db.relationship('Group', backref=db.backref('tags', lazy=True)) + naming_template = db.relationship('NamingTemplate', foreign_keys=[naming_template_id]) + export_template = db.relationship('ExportTemplate', foreign_keys=[export_template_id]) + # Use association object for many-to-many with order tracking + recording_associations = db.relationship('RecordingTag', back_populates='tag', cascade='all, delete-orphan') + + # Unique constraint: tag name must be unique per user (or per group if group_id is set) + __table_args__ = (db.UniqueConstraint('name', 'user_id', name='_user_tag_uc'),) + + @property + def is_group_tag(self): + """Check if this is a group-scoped tag.""" + return self.group_id is not None + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'color': self.color, + 'group_id': self.group_id, + 'is_group_tag': self.is_group_tag, + 'group_name': self.group.name if self.group else None, + 'custom_prompt': self.custom_prompt, + 'default_language': self.default_language, + 'default_min_speakers': self.default_min_speakers, + 'default_max_speakers': self.default_max_speakers, + 'default_hotwords': self.default_hotwords, + 'default_initial_prompt': self.default_initial_prompt, + 'protect_from_deletion': self.protect_from_deletion, + 'retention_days': self.retention_days, + 'auto_share_on_apply': self.auto_share_on_apply, + 'share_with_group_lead': self.share_with_group_lead, + 'naming_template_id': self.naming_template_id, + 'naming_template_name': self.naming_template.name if self.naming_template else None, + 'export_template_id': self.export_template_id, + 'export_template_name': self.export_template.name if self.export_template else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'recording_count': len(self.recording_associations) + } diff --git a/src/models/processing_job.py b/src/models/processing_job.py new file mode 100644 index 0000000..375e1cd --- /dev/null +++ b/src/models/processing_job.py @@ -0,0 +1,63 @@ +""" +ProcessingJob database model for persistent job queue. + +This model stores background processing jobs in the database to ensure +they survive application restarts and support fair scheduling across users. +""" + +from datetime import datetime +from src.database import db + + +class ProcessingJob(db.Model): + """Database model for tracking background processing jobs.""" + + __tablename__ = 'processing_job' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id', ondelete='CASCADE'), nullable=False, index=True) + + # Job type: transcribe, summarize, reprocess_transcription, reprocess_summary + job_type = db.Column(db.String(50), nullable=False) + + # Status: queued, processing, completed, failed + status = db.Column(db.String(20), default='queued', nullable=False, index=True) + + # JSON blob for job-specific parameters (language, min_speakers, custom_prompt, etc.) + params = db.Column(db.Text, nullable=True) + + # Error tracking + error_message = db.Column(db.Text, nullable=True) + retry_count = db.Column(db.Integer, default=0, nullable=False) + + # Track if this is a new upload (vs reprocessing) - for cleanup on failure + is_new_upload = db.Column(db.Boolean, default=False, nullable=False) + + # Timestamps + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + started_at = db.Column(db.DateTime, nullable=True) + completed_at = db.Column(db.DateTime, nullable=True) + + # Relationships + user = db.relationship('User', backref=db.backref('processing_jobs', lazy='dynamic')) + recording = db.relationship('Recording', backref=db.backref('processing_jobs', lazy='dynamic', cascade='all, delete-orphan')) + + def __repr__(self): + return f'' + + def to_dict(self): + """Convert job to dictionary for API responses.""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'recording_id': self.recording_id, + 'job_type': self.job_type, + 'status': self.status, + 'retry_count': self.retry_count, + 'is_new_upload': self.is_new_upload, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'started_at': self.started_at.isoformat() if self.started_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + 'error_message': self.error_message + } diff --git a/src/models/push_subscription.py b/src/models/push_subscription.py new file mode 100644 index 0000000..d44662d --- /dev/null +++ b/src/models/push_subscription.py @@ -0,0 +1,37 @@ +""" +Push Subscription Model +Stores web push notification subscriptions for users +""" +from datetime import datetime +from src.database import db + + +class PushSubscription(db.Model): + """Web Push notification subscription""" + __tablename__ = 'push_subscriptions' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # Push subscription endpoint (unique per browser/device) + endpoint = db.Column(db.String(500), nullable=False, unique=True) + + # Encryption keys for sending push messages + p256dh_key = db.Column(db.String(200), nullable=False) + auth_key = db.Column(db.String(100), nullable=False) + + # Timestamps + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + + def to_dict(self): + """Convert to dictionary""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'endpoint': self.endpoint, + 'created_at': self.created_at.isoformat() if self.created_at else None + } diff --git a/src/models/recording.py b/src/models/recording.py new file mode 100644 index 0000000..ec3ece5 --- /dev/null +++ b/src/models/recording.py @@ -0,0 +1,338 @@ +""" +Recording and TranscriptChunk database models. + +This module defines models for audio recordings and their chunked transcriptions. +""" + +import logging +import os +from datetime import datetime +from sqlalchemy import func +from src.database import db +from src.utils import local_datetime_filter, md_to_html + +logger = logging.getLogger(__name__) + + +class Recording(db.Model): + """Main recording model storing audio files and their metadata.""" + + # Add user_id foreign key to associate recordings with users + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + id = db.Column(db.Integer, primary_key=True) + # Title will now often be AI-generated, maybe start with filename? + title = db.Column(db.String(200), nullable=True) # Allow Null initially + participants = db.Column(db.String(500)) + notes = db.Column(db.Text) + transcription = db.Column(db.Text, nullable=True) + summary = db.Column(db.Text, nullable=True) + status = db.Column(db.String(50), default='PENDING') # PENDING, PROCESSING, SUMMARIZING, COMPLETED, FAILED + audio_path = db.Column(db.String(500)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + meeting_date = db.Column(db.DateTime, nullable=True) + file_size = db.Column(db.Integer) # Store file size in bytes + original_filename = db.Column(db.String(500), nullable=True) # Store the original uploaded filename + is_inbox = db.Column(db.Boolean, default=True) # New recordings are marked as inbox by default + is_highlighted = db.Column(db.Boolean, default=False) # Recordings can be highlighted by the user + mime_type = db.Column(db.String(100), nullable=True) + completed_at = db.Column(db.DateTime, nullable=True) + processing_time_seconds = db.Column(db.Integer, nullable=True) + transcription_duration_seconds = db.Column(db.Integer, nullable=True) # Time taken for transcription + summarization_duration_seconds = db.Column(db.Integer, nullable=True) # Time taken for summarization + processing_source = db.Column(db.String(50), default='upload') # upload, auto_process, recording + error_message = db.Column(db.Text, nullable=True) # Store detailed error messages + file_hash = db.Column(db.String(64), nullable=True) # SHA-256 hash for duplicate detection + + # Auto-deletion and archival fields + audio_deleted_at = db.Column(db.DateTime, nullable=True) # When audio file was deleted (null = not deleted) + deletion_exempt = db.Column(db.Boolean, default=False) # Manual exemption from auto-deletion + + # Speaker embeddings from diarization (JSON dict mapping speaker IDs to 256-dimensional vectors) + speaker_embeddings = db.Column(db.JSON, nullable=True) + + # Folder relationship (one-to-many: a recording belongs to at most one folder) + folder_id = db.Column(db.Integer, db.ForeignKey('folder.id', ondelete='SET NULL'), nullable=True, index=True) + + # Relationships + folder = db.relationship('Folder', back_populates='recordings') + tag_associations = db.relationship('RecordingTag', back_populates='recording', cascade='all, delete-orphan', order_by='RecordingTag.order') + + @property + def tags(self): + """Get tags ordered by the order they were added to this recording.""" + return [assoc.tag for assoc in sorted(self.tag_associations, key=lambda x: x.order)] + + def get_visible_tags(self, viewer_user): + """ + Get tags that are visible to a specific user viewing this recording. + + Visibility rules: + - Group tags: visible if viewer is a member of the tag's group + - Personal tags: visible only to the tag creator + + Note: These rules apply to ALL users, including the recording owner. + Personal tags are private to their creator regardless of recording ownership. + + Args: + viewer_user: User object viewing the recording (or None for backward compatibility) + + Returns: + List of Tag objects visible to the viewer + """ + # If no viewer specified, return all tags (backward compatibility) + if viewer_user is None: + return self.tags + + if not self.tags: + return [] + + # Import here to avoid circular dependencies + from src.models.organization import GroupMembership + + visible_tags = [] + for tag in self.tags: + # Group tags: visible if viewer is a member of the group + if tag.group_id: + membership = GroupMembership.query.filter_by( + group_id=tag.group_id, + user_id=viewer_user.id + ).first() + if membership: + visible_tags.append(tag) + # Personal tags: visible only to tag creator + else: + if tag.user_id == viewer_user.id: + visible_tags.append(tag) + + return visible_tags + + def get_user_notes(self, user): + """ + Get notes from user's perspective (owner or shared recipient). + + - Recording owner sees Recording.notes + - Shared users see their personal_notes from SharedRecordingState + + Args: + user: User object viewing the recording + + Returns: + String notes content or None + """ + if user is None: + return self.notes + + if self.user_id == user.id: + return self.notes # Owner sees Recording.notes + else: + # Shared user sees their personal notes + from src.models.sharing import SharedRecordingState + state = SharedRecordingState.query.filter_by( + recording_id=self.id, + user_id=user.id + ).first() + return state.personal_notes if state else None + + def get_audio_duration(self): + """ + Get the audio duration in seconds using ffprobe. + + Returns: + Float duration in seconds, or None if unavailable + """ + if self.audio_deleted_at is not None: + return None + + if not self.audio_path or not os.path.exists(self.audio_path): + return None + + try: + from src.utils.ffprobe import get_duration + # Allow longer timeout for packet scanning fallback on files without duration metadata + duration = get_duration(self.audio_path, timeout=30) + return duration + except Exception as e: + logger.warning(f"Failed to get duration for recording {self.id}: {e}") + return None + + def get_duplicate_info(self): + """Check if other recordings share the same file_hash for this user. + + Returns: + Dict with total copy count and list of copies, or None. + """ + if not self.file_hash: + return None + dupes = Recording.query.filter( + Recording.user_id == self.user_id, + Recording.file_hash == self.file_hash, + ).with_entities( + Recording.id, Recording.title, Recording.created_at + ).order_by(Recording.created_at).all() + if len(dupes) > 1: + return { + 'total_copies': len(dupes), + 'copies': [ + { + 'id': d.id, + 'title': d.title or f'#{d.id}', + 'created_at': local_datetime_filter(d.created_at), + 'is_self': d.id == self.id + } + for d in dupes + ] + } + return None + + def to_list_dict(self, viewer_user=None): + """ + Lightweight dict for list views - excludes expensive HTML conversions. + + Args: + viewer_user: User viewing the recording (for tag visibility filtering) + """ + # Import here to avoid circular dependencies + from src.models.sharing import InternalShare, Share + + # Count internal shares for this recording + shared_with_count = db.session.query(func.count(InternalShare.id)).filter( + InternalShare.recording_id == self.id + ).scalar() or 0 + + # Count public shares (link shares) for this recording + public_share_count = db.session.query(func.count(Share.id)).filter( + Share.recording_id == self.id + ).scalar() or 0 + + # Get visible tags for this viewer + visible_tags = self.get_visible_tags(viewer_user) + + return { + 'id': self.id, + 'title': self.title, + 'participants': self.participants, + 'status': self.status, + 'created_at': local_datetime_filter(self.created_at), + 'completed_at': local_datetime_filter(self.completed_at), + 'meeting_date': local_datetime_filter(self.meeting_date), + 'file_size': self.file_size, + 'original_filename': self.original_filename, + 'is_inbox': self.is_inbox, + 'is_highlighted': self.is_highlighted, + 'audio_deleted_at': local_datetime_filter(self.audio_deleted_at), + 'audio_available': self.audio_deleted_at is None, + 'deletion_exempt': self.deletion_exempt, + 'folder_id': self.folder_id, + 'folder': self.folder.to_dict() if self.folder else None, + 'tags': [tag.to_dict() for tag in visible_tags] if visible_tags else [], + 'duplicate_info': self.get_duplicate_info(), + 'shared_with_count': shared_with_count, + 'public_share_count': public_share_count + } + + def to_dict(self, include_html=True, viewer_user=None): + """ + Full dict with optional HTML conversion for notes/summary. + + Args: + include_html: Whether to include HTML-rendered markdown fields + viewer_user: User viewing the recording (for tag visibility filtering) + """ + # Import here to avoid circular dependencies + from src.models.sharing import InternalShare, Share + + # Count internal shares for this recording + shared_with_count = db.session.query(func.count(InternalShare.id)).filter( + InternalShare.recording_id == self.id + ).scalar() or 0 + + # Count public shares (link shares) for this recording + public_share_count = db.session.query(func.count(Share.id)).filter( + Share.recording_id == self.id + ).scalar() or 0 + + # Get visible tags for this viewer + visible_tags = self.get_visible_tags(viewer_user) + + # Get user-specific notes + user_notes = self.get_user_notes(viewer_user) + + data = { + 'id': self.id, + 'title': self.title, + 'participants': self.participants, + 'notes': user_notes, + 'transcription': self.transcription, + 'summary': self.summary, + 'status': self.status, + 'created_at': local_datetime_filter(self.created_at), + 'completed_at': local_datetime_filter(self.completed_at), + 'processing_time_seconds': self.processing_time_seconds, + 'transcription_duration_seconds': self.transcription_duration_seconds, + 'summarization_duration_seconds': self.summarization_duration_seconds, + 'meeting_date': local_datetime_filter(self.meeting_date), + 'file_size': self.file_size, + 'original_filename': self.original_filename, + 'user_id': self.user_id, + 'is_inbox': self.is_inbox, + 'is_highlighted': self.is_highlighted, + 'mime_type': self.mime_type, + 'audio_deleted_at': local_datetime_filter(self.audio_deleted_at), + 'audio_available': self.audio_deleted_at is None, + 'audio_duration': self.get_audio_duration(), + 'deletion_exempt': self.deletion_exempt, + 'folder_id': self.folder_id, + 'folder': self.folder.to_dict() if self.folder else None, + 'tags': [tag.to_dict() for tag in visible_tags] if visible_tags else [], + 'events': [event.to_dict() for event in self.events] if self.events else [], + 'duplicate_info': self.get_duplicate_info(), + 'shared_with_count': shared_with_count, + 'public_share_count': public_share_count + } + + # Only compute expensive HTML conversions when explicitly requested + if include_html: + data['notes_html'] = md_to_html(user_notes) if user_notes else "" + data['summary_html'] = md_to_html(self.summary) if self.summary else "" + else: + data['notes_html'] = "" + data['summary_html'] = "" + + return data + + +class TranscriptChunk(db.Model): + """Stores chunked transcription segments for efficient retrieval and embedding.""" + + id = db.Column(db.Integer, primary_key=True) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + chunk_index = db.Column(db.Integer, nullable=False) # Order within the recording + content = db.Column(db.Text, nullable=False) # The actual text chunk + start_time = db.Column(db.Float, nullable=True) # Start time in seconds (if available) + end_time = db.Column(db.Float, nullable=True) # End time in seconds (if available) + speaker_name = db.Column(db.String(100), nullable=True, index=True) # Speaker for this chunk (indexed for speaker rename operations) + embedding = db.Column(db.LargeBinary, nullable=True) # Stored as binary vector + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Composite index for efficient speaker name lookups scoped to user + __table_args__ = ( + db.Index('idx_user_speaker_name', 'user_id', 'speaker_name'), + ) + + # Relationships + recording = db.relationship('Recording', backref=db.backref('chunks', lazy=True, cascade='all, delete-orphan')) + user = db.relationship('User', backref=db.backref('transcript_chunks', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'recording_id': self.recording_id, + 'chunk_index': self.chunk_index, + 'content': self.content, + 'start_time': self.start_time, + 'end_time': self.end_time, + 'speaker_name': self.speaker_name, + 'created_at': self.created_at.isoformat() if self.created_at else None + } diff --git a/src/models/sharing.py b/src/models/sharing.py new file mode 100644 index 0000000..83bf3a6 --- /dev/null +++ b/src/models/sharing.py @@ -0,0 +1,236 @@ +""" +Sharing models for public and internal recording shares. + +This module defines models for sharing recordings both publicly (via links) +and internally (between users). +""" + +import os +import secrets +from datetime import datetime +from src.database import db +from src.utils import local_datetime_filter + + +# Get sharing configuration from environment +SHOW_USERNAMES_IN_UI = os.environ.get('SHOW_USERNAMES_IN_UI', 'false').lower() == 'true' + + +class Share(db.Model): + """Public sharing via shareable links.""" + + id = db.Column(db.Integer, primary_key=True) + public_id = db.Column(db.String(32), unique=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + share_summary = db.Column(db.Boolean, default=True) + share_notes = db.Column(db.Boolean, default=True) + + user = db.relationship('User', backref=db.backref('shares', lazy=True, cascade='all, delete-orphan')) + recording = db.relationship('Recording', backref=db.backref('shares', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'public_id': self.public_id, + 'recording_id': self.recording_id, + 'created_at': local_datetime_filter(self.created_at), + 'share_summary': self.share_summary, + 'share_notes': self.share_notes, + 'recording_title': self.recording.title if self.recording else "N/A", + 'audio_available': self.recording.audio_deleted_at is None if self.recording else True + } + + +class InternalShare(db.Model): + """Tracks internal sharing of recordings between users (independent of teams).""" + + __tablename__ = 'internal_share' + + id = db.Column(db.Integer, primary_key=True) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id', ondelete='CASCADE'), nullable=False) + owner_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) # User who shared + shared_with_user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) # User it was shared with + + # Permissions + can_edit = db.Column(db.Boolean, default=False) # Can edit notes/metadata + can_reshare = db.Column(db.Boolean, default=False) # Can share with others + + # Source tracking for share cleanup + source_type = db.Column(db.String(20), default='manual') # 'manual' or 'group_tag' + source_tag_id = db.Column(db.Integer, db.ForeignKey('tag.id', ondelete='SET NULL'), nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationship for source tag + source_tag = db.relationship('Tag', foreign_keys=[source_tag_id], backref=db.backref('created_shares', lazy=True)) + + # Relationships + recording = db.relationship('Recording', backref=db.backref('internal_shares', lazy=True, cascade='all, delete-orphan')) + owner = db.relationship('User', foreign_keys=[owner_id], backref=db.backref('shared_recordings', lazy=True)) + shared_with = db.relationship('User', foreign_keys=[shared_with_user_id], backref=db.backref('received_shares', lazy=True)) + + # Unique constraint: can't share same recording with same user twice + __table_args__ = (db.UniqueConstraint('recording_id', 'shared_with_user_id', name='unique_recording_share'),) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'recording_id': self.recording_id, + 'owner_id': self.owner_id, + 'owner_username': self.owner.username if SHOW_USERNAMES_IN_UI else None, + 'user_id': self.shared_with_user_id, # For frontend compatibility + 'username': self.shared_with.username, # Always include username + 'can_edit': self.can_edit, + 'can_reshare': self.can_reshare, + 'source_type': self.source_type, + 'source_tag_id': self.source_tag_id, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + @staticmethod + def get_user_max_permissions(recording, user): + """ + Get the maximum permissions a user can grant for a recording. + + Args: + recording: Recording object + user: User object attempting to share + + Returns: + Dict with 'can_edit' and 'can_reshare' boolean flags + """ + # Owner has unlimited permissions + if recording.user_id == user.id: + return {'can_edit': True, 'can_reshare': True} + + # Get user's share for this recording + user_share = InternalShare.query.filter_by( + recording_id=recording.id, + shared_with_user_id=user.id + ).first() + + if not user_share: + # User has no access + return {'can_edit': False, 'can_reshare': False} + + # User can only grant what they have + return { + 'can_edit': user_share.can_edit, + 'can_reshare': user_share.can_reshare + } + + @staticmethod + def validate_reshare_permissions(recording, grantor_user, requested_permissions): + """ + Validate that a user can grant the requested permissions. + + Args: + recording: Recording object being shared + grantor_user: User attempting to share (current_user) + requested_permissions: Dict with 'can_edit' and 'can_reshare' flags + + Returns: + Tuple of (is_valid: bool, error_message: str or None) + """ + # Owner can grant anything + if recording.user_id == grantor_user.id: + return True, None + + # Get grantor's permissions + max_permissions = InternalShare.get_user_max_permissions(recording, grantor_user) + + # Validate edit permission + if requested_permissions.get('can_edit', False) and not max_permissions['can_edit']: + return False, "You cannot grant edit permission because you do not have edit access" + + # Validate reshare permission + if requested_permissions.get('can_reshare', False) and not max_permissions['can_reshare']: + return False, "You cannot grant reshare permission because you do not have reshare access" + + return True, None + + @staticmethod + def find_downstream_shares(recording_id, user_id): + """ + Find all shares created by a specific user for a recording. + Used for cascade revocation. + + Args: + recording_id: ID of the recording + user_id: ID of the user whose downstream shares to find + + Returns: + List of InternalShare objects + """ + return InternalShare.query.filter_by( + recording_id=recording_id, + owner_id=user_id + ).all() + + @staticmethod + def has_alternate_access_path(recording_id, user_id, excluding_grantor_id=None): + """ + Check if a user has alternate access to a recording through other shares. + Used to prevent cascade revocation when user has multiple access paths (diamond pattern). + + Args: + recording_id: ID of the recording + user_id: ID of the user to check + excluding_grantor_id: Exclude shares from this grantor (the one being revoked) + + Returns: + Boolean - True if user has alternate access path + """ + query = InternalShare.query.filter( + InternalShare.recording_id == recording_id, + InternalShare.shared_with_user_id == user_id + ) + + if excluding_grantor_id is not None: + query = query.filter(InternalShare.owner_id != excluding_grantor_id) + + return query.count() > 0 + + +class SharedRecordingState(db.Model): + """Tracks per-user state for shared recordings (notes, highlights, etc).""" + + __tablename__ = 'shared_recording_state' + + id = db.Column(db.Integer, primary_key=True) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + + # User-specific state + personal_notes = db.Column(db.Text, nullable=True) # Private notes only this user can see + is_inbox = db.Column(db.Boolean, default=True) # User's personal inbox status + is_highlighted = db.Column(db.Boolean, default=False) # User's personal highlight/favorite status + last_viewed = db.Column(db.DateTime, default=datetime.utcnow) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + recording = db.relationship('Recording', backref=db.backref('user_states', lazy=True, cascade='all, delete-orphan')) + user = db.relationship('User', backref=db.backref('recording_states', lazy=True)) + + # Unique constraint: one state per user per recording + __table_args__ = (db.UniqueConstraint('recording_id', 'user_id', name='unique_user_recording_state'),) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'recording_id': self.recording_id, + 'user_id': self.user_id, + 'personal_notes': self.personal_notes, + 'is_inbox': self.is_inbox, + 'is_highlighted': self.is_highlighted, + 'last_viewed': self.last_viewed.isoformat() if self.last_viewed else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } diff --git a/src/models/speaker_snippet.py b/src/models/speaker_snippet.py new file mode 100644 index 0000000..53d7779 --- /dev/null +++ b/src/models/speaker_snippet.py @@ -0,0 +1,43 @@ +""" +SpeakerSnippet database model. + +This module defines the SpeakerSnippet model for storing example quotes/snippets +from recordings that feature specific speakers. These snippets provide context +when viewing speaker profiles and help users verify speaker identifications. +""" + +from datetime import datetime +from src.database import db + + +class SpeakerSnippet(db.Model): + """Model for storing representative speech snippets from speakers.""" + + __tablename__ = 'speaker_snippet' + + id = db.Column(db.Integer, primary_key=True) + speaker_id = db.Column(db.Integer, db.ForeignKey('speaker.id', ondelete='CASCADE'), nullable=False) + recording_id = db.Column(db.Integer, db.ForeignKey('recording.id', ondelete='CASCADE'), nullable=False) + segment_index = db.Column(db.Integer, nullable=False) # Index in the transcript + text_snippet = db.Column(db.String(200), nullable=False) # The actual quote + timestamp = db.Column(db.Float, nullable=True) # Seconds into the recording + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + speaker = db.relationship('Speaker', backref=db.backref('snippets', lazy=True, cascade='all, delete-orphan')) + recording = db.relationship('Recording', backref=db.backref('speaker_snippets', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'speaker_id': self.speaker_id, + 'recording_id': self.recording_id, + 'text': self.text_snippet, + 'timestamp': self.timestamp, + 'recording_title': self.recording.title if self.recording else 'Unknown', + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + def __repr__(self): + return f"SpeakerSnippet(speaker_id={self.speaker_id}, recording_id={self.recording_id}, text='{self.text_snippet[:30]}...')" diff --git a/src/models/system.py b/src/models/system.py new file mode 100644 index 0000000..a3aae98 --- /dev/null +++ b/src/models/system.py @@ -0,0 +1,77 @@ +""" +SystemSetting model for application configuration. + +This module defines the SystemSetting model for storing +dynamic system configuration in the database. +""" + +from datetime import datetime +from src.database import db + + +class SystemSetting(db.Model): + """Stores system-wide configuration settings.""" + + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(100), unique=True, nullable=False) + value = db.Column(db.Text, nullable=True) + description = db.Column(db.Text, nullable=True) + setting_type = db.Column(db.String(50), nullable=False, default='string') # string, integer, boolean, float + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'key': self.key, + 'value': self.value, + 'description': self.description, + 'setting_type': self.setting_type, + 'created_at': self.created_at, + 'updated_at': self.updated_at + } + + @staticmethod + def get_setting(key, default_value=None): + """Get a system setting value by key, with optional default.""" + setting = SystemSetting.query.filter_by(key=key).first() + if setting: + # Convert value based on type + if setting.setting_type == 'integer': + try: + return int(setting.value) if setting.value is not None else default_value + except (ValueError, TypeError): + return default_value + elif setting.setting_type == 'boolean': + return setting.value.lower() in ('true', '1', 'yes') if setting.value else default_value + elif setting.setting_type == 'float': + try: + return float(setting.value) if setting.value is not None else default_value + except (ValueError, TypeError): + return default_value + else: # string + return setting.value if setting.value is not None else default_value + return default_value + + @staticmethod + def set_setting(key, value, description=None, setting_type='string'): + """Set a system setting value.""" + setting = SystemSetting.query.filter_by(key=key).first() + if setting: + setting.value = str(value) if value is not None else None + setting.updated_at = datetime.utcnow() + if description: + setting.description = description + if setting_type: + setting.setting_type = setting_type + else: + setting = SystemSetting( + key=key, + value=str(value) if value is not None else None, + description=description, + setting_type=setting_type + ) + db.session.add(setting) + db.session.commit() + return setting diff --git a/src/models/templates.py b/src/models/templates.py new file mode 100644 index 0000000..1d98da1 --- /dev/null +++ b/src/models/templates.py @@ -0,0 +1,37 @@ +""" +TranscriptTemplate model for user-defined transcript formatting. + +This module defines the TranscriptTemplate model for storing +custom templates for transcript formatting. +""" + +from datetime import datetime +from src.database import db + + +class TranscriptTemplate(db.Model): + """Stores user-defined templates for transcript formatting.""" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + template = db.Column(db.Text, nullable=False) + description = db.Column(db.String(500), nullable=True) + is_default = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('transcript_templates', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'template': self.template, + 'description': self.description, + 'is_default': self.is_default, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } diff --git a/src/models/token_usage.py b/src/models/token_usage.py new file mode 100644 index 0000000..0868fff --- /dev/null +++ b/src/models/token_usage.py @@ -0,0 +1,44 @@ +""" +Token usage tracking model for monitoring LLM API consumption. +""" + +from datetime import datetime, date +from src.database import db + + +class TokenUsage(db.Model): + """Daily token usage aggregates per user per operation type.""" + __tablename__ = 'token_usage' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + date = db.Column(db.Date, nullable=False, default=date.today) + operation_type = db.Column(db.String(50), nullable=False) + + # Token counts (from API response.usage) + prompt_tokens = db.Column(db.Integer, default=0) + completion_tokens = db.Column(db.Integer, default=0) + total_tokens = db.Column(db.Integer, default=0) + + # Cost tracking (OpenRouter provides this) + cost = db.Column(db.Float, default=0.0) + + # Request count for this day/operation + request_count = db.Column(db.Integer, default=0) + + # Model info + model_name = db.Column(db.String(100), nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('token_usage', lazy='dynamic')) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'date', 'operation_type', name='uq_user_date_op'), + db.Index('idx_token_user_date', 'user_id', 'date'), + ) + + def __repr__(self): + return f'' diff --git a/src/models/transcription_usage.py b/src/models/transcription_usage.py new file mode 100644 index 0000000..6697dc2 --- /dev/null +++ b/src/models/transcription_usage.py @@ -0,0 +1,42 @@ +""" +Transcription usage tracking model for monitoring audio transcription consumption. +""" + +from datetime import datetime, date +from src.database import db + + +class TranscriptionUsage(db.Model): + """Daily transcription usage aggregates per user per connector type.""" + __tablename__ = 'transcription_usage' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + date = db.Column(db.Date, nullable=False, default=date.today) + connector_type = db.Column(db.String(50), nullable=False) # 'openai_whisper', 'openai_transcribe', 'asr_endpoint' + + # Audio duration tracking (in seconds for precision) + audio_duration_seconds = db.Column(db.Integer, default=0) + + # Cost tracking ($0 for self-hosted ASR) + estimated_cost = db.Column(db.Float, default=0.0) + + # Request count for this day/connector + request_count = db.Column(db.Integer, default=0) + + # Model info (e.g., 'whisper-1', 'gpt-4o-transcribe', 'asr-endpoint') + model_name = db.Column(db.String(100), nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = db.relationship('User', backref=db.backref('transcription_usage', lazy='dynamic')) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'date', 'connector_type', name='uq_user_date_connector'), + db.Index('idx_transcription_user_date', 'user_id', 'date'), + ) + + def __repr__(self): + return f'' diff --git a/src/models/user.py b/src/models/user.py new file mode 100644 index 0000000..364334a --- /dev/null +++ b/src/models/user.py @@ -0,0 +1,98 @@ +""" +User and Speaker database models. + +This module defines the User model for authentication and user profiles, +and the Speaker model for tracking speaker profiles used in diarization. +""" + +from datetime import datetime +from flask_login import UserMixin +from src.database import db + + +class User(db.Model, UserMixin): + """User model for authentication and profile management.""" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(20), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password = db.Column(db.String(60), nullable=True) + sso_provider = db.Column(db.String(100), nullable=True) + sso_subject = db.Column(db.String(255), unique=True, nullable=True) + is_admin = db.Column(db.Boolean, default=False) + can_share_publicly = db.Column(db.Boolean, default=True) # Permission to create public share links + recordings = db.relationship('Recording', backref='owner', lazy=True) + transcription_language = db.Column(db.String(10), nullable=True) # For ISO 639-1 codes + output_language = db.Column(db.String(50), nullable=True) # For full language names like "Spanish" + ui_language = db.Column(db.String(10), nullable=True, default='en') # For UI language preference (en, es, fr, zh) + summary_prompt = db.Column(db.Text, nullable=True) + extract_events = db.Column(db.Boolean, default=False) # Enable event extraction from transcripts + name = db.Column(db.String(100), nullable=True) + job_title = db.Column(db.String(100), nullable=True) + company = db.Column(db.String(100), nullable=True) + diarize = db.Column(db.Boolean, default=False) + + # Default naming template for title generation + default_naming_template_id = db.Column(db.Integer, db.ForeignKey('naming_template.id', ondelete='SET NULL'), nullable=True) + default_naming_template = db.relationship('NamingTemplate', foreign_keys=[default_naming_template_id]) + + # Token budget (None = unlimited) + monthly_token_budget = db.Column(db.Integer, nullable=True) + + # Transcription budget in seconds (None = unlimited) + monthly_transcription_budget = db.Column(db.Integer, nullable=True) + + # Email verification fields + email_verified = db.Column(db.Boolean, default=False) + email_verification_token = db.Column(db.String(200), nullable=True, index=True) + email_verification_sent_at = db.Column(db.DateTime, nullable=True) + + # Password reset fields + password_reset_token = db.Column(db.String(200), nullable=True, index=True) + password_reset_sent_at = db.Column(db.DateTime, nullable=True) + + # Auto speaker labelling settings + auto_speaker_labelling = db.Column(db.Boolean, default=False) # Enable auto-labelling when voice confidence exceeds threshold + auto_speaker_labelling_threshold = db.Column(db.String(10), nullable=True, default='medium') # 'low', 'medium', 'high' + + # Auto summarization setting (user can disable if admin hasn't globally disabled) + auto_summarization = db.Column(db.Boolean, default=True) + + # Transcription hints (hotwords and initial prompt for improving ASR accuracy) + transcription_hotwords = db.Column(db.Text, nullable=True) + transcription_initial_prompt = db.Column(db.Text, nullable=True) + + def __repr__(self): + return f"User('{self.username}', '{self.email}')" + + +class Speaker(db.Model): + """Speaker model for tracking voice profiles used in diarization.""" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_used = db.Column(db.DateTime, default=datetime.utcnow) + use_count = db.Column(db.Integer, default=1) + + # Voice embedding fields (256 dimensions from WhisperX) + average_embedding = db.Column(db.LargeBinary, nullable=True) # Binary numpy array (256 × 4 bytes = 1024 bytes) + embeddings_history = db.Column(db.JSON, nullable=True) # List of metadata: [{recording_id, timestamp, similarity}, ...] + embedding_count = db.Column(db.Integer, default=0) # Number of embeddings collected + confidence_score = db.Column(db.Float, nullable=True) # 0-1 score based on embedding consistency + + # Relationship to user + user = db.relationship('User', backref=db.backref('speakers', lazy=True, cascade='all, delete-orphan')) + + def to_dict(self): + """Convert model to dictionary representation.""" + return { + 'id': self.id, + 'name': self.name, + 'created_at': self.created_at, + 'last_used': self.last_used, + 'use_count': self.use_count, + 'embedding_count': self.embedding_count, + 'confidence_score': self.confidence_score + } diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..4aea0de --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,34 @@ +""" +Service layer for business logic. +""" + +from .embeddings import * +from .llm import * +from .document import * +from .retention import * + +__all__ = [ + # Embedding services + 'get_embedding_model', + 'chunk_transcription', + 'generate_embeddings', + 'serialize_embedding', + 'deserialize_embedding', + 'get_accessible_recording_ids', + 'process_recording_chunks', + 'basic_text_search_chunks', + 'semantic_search_chunks', + # LLM services + 'is_gpt5_model', + 'is_using_openai_api', + 'call_llm_completion', + 'call_chat_completion', + 'chat_client', + 'format_api_error_message', + # Document services + 'process_markdown_to_docx', + # Retention services + 'is_recording_exempt_from_deletion', + 'get_retention_days_for_recording', + 'process_auto_deletion', +] diff --git a/src/services/audit.py b/src/services/audit.py new file mode 100644 index 0000000..7c673d5 --- /dev/null +++ b/src/services/audit.py @@ -0,0 +1,211 @@ +""" +Central audit service for Loi 25 compliance. + +Wraps AccessLog and AuthLog with request context helpers. +All logging is gated behind ENABLE_AUDIT_LOG env var. + +NOTE: ENABLE_AUDIT_LOG is read once at import time. Changing the env var +requires an application restart to take effect. + +NOTE: Audit helpers do NOT commit the session — the caller's transaction +will persist the log entry when it commits. This avoids interfering with +ongoing transactions (e.g. a delete operation that audits then deletes). +""" + +import os +import logging +from datetime import datetime, timedelta +from flask import request, has_request_context +from flask_login import current_user + +from src.database import db +from src.models.access_log import AccessLog +from src.models.auth_log import AuthLog + +logger = logging.getLogger(__name__) + +# Read once at import — requires restart to change. +ENABLE_AUDIT_LOG = os.environ.get('ENABLE_AUDIT_LOG', 'false').lower() == 'true' + + +def is_audit_enabled(): + """Check if audit logging is enabled.""" + return ENABLE_AUDIT_LOG + + +def _get_request_context(): + """Extract IP address and user agent from current request.""" + if not has_request_context(): + return None, None + ip = request.remote_addr + ua = request.headers.get('User-Agent', '')[:500] + return ip, ua + + +def _get_current_user_id(): + """Get current user ID if authenticated.""" + if has_request_context() and current_user and current_user.is_authenticated: + return current_user.id + return None + + +# --- Access logging helpers --- + +_DEDUP_WINDOW_SECONDS = 300 # 5 minutes + + +def _is_recent_duplicate(action, resource_type, resource_id, user_id): + """Return True if the same user already logged this action on this resource in the last 5 min. + + Prevents unbounded log growth for high-frequency read operations (e.g. view on every GET). + """ + if user_id is None or resource_id is None: + return False + cutoff = datetime.utcnow() - timedelta(seconds=_DEDUP_WINDOW_SECONDS) + return AccessLog.query.filter( + AccessLog.user_id == user_id, + AccessLog.action == action, + AccessLog.resource_type == resource_type, + AccessLog.resource_id == resource_id, + AccessLog.timestamp >= cutoff, + ).first() is not None + + +def audit_access(action, resource_type, resource_id=None, user_id=None, status='success', details=None): + """Log a data access event if audit is enabled. + + Does NOT commit — the log is added to the current session and will be + persisted when the caller (or Flask teardown) commits. + + View events are deduplicated: the same user viewing the same resource within + 5 minutes is logged only once to avoid unbounded log growth. + """ + if not ENABLE_AUDIT_LOG: + return None + try: + ip, ua = _get_request_context() + if user_id is None: + user_id = _get_current_user_id() + if action == 'view' and _is_recent_duplicate(action, resource_type, resource_id, user_id): + return None + log = AccessLog.log_access( + action=action, + resource_type=resource_type, + resource_id=resource_id, + user_id=user_id, + status=status, + details=details, + ip_address=ip, + user_agent=ua, + ) + return log + except Exception as e: + logger.warning(f"Failed to write access audit log: {e}") + return None + + +def audit_view(resource_type, resource_id, **kwargs): + """Log a view access.""" + return audit_access('view', resource_type, resource_id, **kwargs) + + +def audit_download(resource_type, resource_id, **kwargs): + """Log a download access.""" + return audit_access('download', resource_type, resource_id, **kwargs) + + +def audit_edit(resource_type, resource_id, **kwargs): + """Log an edit.""" + return audit_access('edit', resource_type, resource_id, **kwargs) + + +def audit_delete(resource_type, resource_id, **kwargs): + """Log a deletion.""" + return audit_access('delete', resource_type, resource_id, **kwargs) + + +def audit_export(resource_type, resource_id, **kwargs): + """Log a data export.""" + return audit_access('export', resource_type, resource_id, **kwargs) + + +# --- Auth logging helpers --- +# Auth events (login/logout/failed) are standalone operations, so they +# commit their own transaction since the caller typically redirects after. + +def _audit_auth(func, *args, **kwargs): + """Wrapper for auth audit helpers that commits independently.""" + if not ENABLE_AUDIT_LOG: + return None + try: + ip, ua = _get_request_context() + log = func(*args, ip_address=ip, user_agent=ua, **kwargs) + db.session.commit() + return log + except Exception as e: + logger.warning(f"Failed to write auth audit log: {e}") + db.session.rollback() + return None + + +def audit_login(user_id, details=None): + """Log a successful login.""" + return _audit_auth(AuthLog.log_login, user_id, details=details) + + +def audit_logout(user_id=None): + """Log a logout.""" + if user_id is None: + user_id = _get_current_user_id() + return _audit_auth(AuthLog.log_logout, user_id) + + +def audit_failed_login(details=None): + """Log a failed login.""" + return _audit_auth(AuthLog.log_failed_login, details=details) + + +def audit_register(user_id): + """Log a registration.""" + return _audit_auth(AuthLog.log_register, user_id) + + +def audit_password_change(user_id, details=None): + """Log a password change.""" + return _audit_auth(AuthLog.log_password_change, user_id, details=details) + + +def audit_password_reset(user_id): + """Log a password reset.""" + return _audit_auth(AuthLog.log_password_reset, user_id) + + +def audit_sso_login(user_id, details=None): + """Log an SSO login.""" + return _audit_auth(AuthLog.log_sso_login, user_id, details=details) + + +# --- Query helpers for admin --- + +def get_access_logs(page=1, per_page=50, user_id=None, resource_type=None, resource_id=None, action=None): + """Query access logs with pagination and filters.""" + query = AccessLog.query.order_by(AccessLog.timestamp.desc()) + if user_id is not None: + query = query.filter_by(user_id=user_id) + if resource_type is not None: + query = query.filter_by(resource_type=resource_type) + if resource_id is not None: + query = query.filter_by(resource_id=resource_id) + if action is not None: + query = query.filter_by(action=action) + return query.paginate(page=page, per_page=per_page, error_out=False) + + +def get_auth_logs(page=1, per_page=50, user_id=None, action=None): + """Query auth logs with pagination and filters.""" + query = AuthLog.query.order_by(AuthLog.timestamp.desc()) + if user_id is not None: + query = query.filter_by(user_id=user_id) + if action is not None: + query = query.filter_by(action=action) + return query.paginate(page=page, per_page=per_page, error_out=False) diff --git a/src/services/calendar.py b/src/services/calendar.py new file mode 100644 index 0000000..9266b43 --- /dev/null +++ b/src/services/calendar.py @@ -0,0 +1,102 @@ +""" +Calendar/ICS file generation services. +""" + +import json +import uuid +from datetime import datetime, timedelta + + +def generate_ics_content(event): + """Generate ICS calendar file content for an event.""" + import uuid + from datetime import datetime, timedelta + + # Generate unique ID for the event + uid = f"{event.id}-{uuid.uuid4()}@speakr.app" + + # Format dates in iCalendar format (YYYYMMDDTHHMMSS) + def format_ical_date(dt): + if dt: + return dt.strftime('%Y%m%dT%H%M%S') + return None + + # Start building ICS content + lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Speakr//Event Export//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + f'UID:{uid}', + f'DTSTAMP:{format_ical_date(datetime.utcnow())}', + ] + + # Add event details + if event.start_datetime: + lines.append(f'DTSTART:{format_ical_date(event.start_datetime)}') + + if event.end_datetime: + lines.append(f'DTEND:{format_ical_date(event.end_datetime)}') + elif event.start_datetime: + # If no end time, default to 1 hour after start + end_time = event.start_datetime + timedelta(hours=1) + lines.append(f'DTEND:{format_ical_date(end_time)}') + + # Add title and description + lines.append(f'SUMMARY:{escape_ical_text(event.title)}') + + if event.description: + lines.append(f'DESCRIPTION:{escape_ical_text(event.description)}') + + # Add location if available + if event.location: + lines.append(f'LOCATION:{escape_ical_text(event.location)}') + + # Add attendees if available + if event.attendees: + try: + attendees_list = json.loads(event.attendees) + for attendee in attendees_list: + if attendee: + lines.append(f'ATTENDEE:CN={escape_ical_text(attendee)}:mailto:{attendee.replace(" ", ".").lower()}@example.com') + except: + pass + + # Add reminder/alarm if specified + if event.reminder_minutes and event.reminder_minutes > 0: + lines.extend([ + 'BEGIN:VALARM', + 'TRIGGER:-PT{}M'.format(event.reminder_minutes), + 'ACTION:DISPLAY', + f'DESCRIPTION:Reminder: {escape_ical_text(event.title)}', + 'END:VALARM' + ]) + + # Close event and calendar + lines.extend([ + 'STATUS:CONFIRMED', + 'TRANSP:OPAQUE', + 'END:VEVENT', + 'END:VCALENDAR' + ]) + + return '\r\n'.join(lines) + + + +def escape_ical_text(text): + """Escape special characters for iCalendar format.""" + if not text: + return '' + # Escape special characters + text = str(text) + text = text.replace('\\', '\\\\') + text = text.replace(',', '\\,') + text = text.replace(';', '\\;') + text = text.replace('\n', '\\n') + return text + + + diff --git a/src/services/document.py b/src/services/document.py new file mode 100644 index 0000000..aae988a --- /dev/null +++ b/src/services/document.py @@ -0,0 +1,296 @@ +""" +Document processing and conversion services. +""" + +import re +from docx import Document +from docx.shared import Pt, RGBColor + + + +def process_markdown_to_docx(doc, content): + """Convert markdown content to properly formatted Word document elements. + + Supports: + - Tables (markdown pipe tables) + - Headings (# ## ###) + - Bold text (**text**) + - Italic text (*text* or _text_) + - Bold italic (***text***) + - Inline code (`code`) + - Code blocks (```code```) + - Strikethrough (~~text~~) + - Links ([text](url)) + - Bullet lists (- or *) + - Numbered lists (1. 2. 3.) + - Horizontal rules (--- or ***) + """ + from docx.shared import RGBColor, Pt + from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + from docx.oxml.ns import qn + import re + + def ensure_unicode_font(run, text): + """Ensure the run uses a font that supports the characters in the text.""" + # Check if text contains non-ASCII characters + try: + text.encode('ascii') + # Text is pure ASCII, no special font needed + except UnicodeEncodeError: + # Text contains non-ASCII characters, use a font with better Unicode support + # Use Arial for broad compatibility - it has good Unicode support on most systems + run.font.name = 'Arial' + # Set the East Asian font for CJK (Chinese, Japanese, Korean) text + # This ensures proper rendering in Word + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial') + return run + + def add_formatted_run(paragraph, text): + """Add a run with inline formatting to a paragraph.""" + if not text: + return + + # Pattern for all inline formatting + # Order matters: check triple asterisk before double/single + patterns = [ + (r'\*\*\*(.*?)\*\*\*', lambda p, t: (lambda r: (setattr(r, 'bold', True), setattr(r, 'italic', True), ensure_unicode_font(r, t)))(p.add_run(t))), # Bold italic + (r'\*\*(.*?)\*\*', lambda p, t: (lambda r: (setattr(r, 'bold', True), ensure_unicode_font(r, t)))(p.add_run(t))), # Bold + (r'(? 0: + run = paragraph.add_run(remaining_text[:earliest_pos]) + ensure_unicode_font(run, remaining_text[:earliest_pos]) + + # Apply formatting for the matched text + if '[' in earliest_match.group(0) and '](' in earliest_match.group(0): + # Special handling for links (two groups) + matched_pattern(paragraph, earliest_match.group(1), earliest_match.group(2)) + else: + matched_pattern(paragraph, earliest_match.group(1)) + + # Continue with remaining text + remaining_text = remaining_text[earliest_match.end():] + else: + # No more patterns, add the rest as plain text + run = paragraph.add_run(remaining_text) + ensure_unicode_font(run, remaining_text) + break + + def parse_table(lines, start_idx): + """Parse a markdown table starting at the given index.""" + if start_idx >= len(lines): + return None, start_idx + + # Check if this looks like a table + if '|' not in lines[start_idx]: + return None, start_idx + + table_data = [] + idx = start_idx + + while idx < len(lines) and '|' in lines[idx]: + # Skip separator lines + if re.match(r'^[\s\|\-:]+$', lines[idx]): + idx += 1 + continue + + # Parse cells + cells = [cell.strip() for cell in lines[idx].split('|')] + # Remove empty cells at start and end + if cells and not cells[0]: + cells = cells[1:] + if cells and not cells[-1]: + cells = cells[:-1] + + if cells: + table_data.append(cells) + idx += 1 + + if table_data: + return table_data, idx + return None, start_idx + + # Split content into lines + lines = content.split('\n') + i = 0 + in_code_block = False + code_block_content = [] + + while i < len(lines): + line = lines[i] + + # Handle code blocks + if line.strip().startswith('```'): + if not in_code_block: + in_code_block = True + code_block_content = [] + else: + # End of code block - add it as preformatted text + in_code_block = False + if code_block_content: + p = doc.add_paragraph() + p.style = 'Normal' + code_text = '\n'.join(code_block_content) + run = p.add_run(code_text) + run.font.name = 'Courier New' + run.font.size = Pt(10) + run.font.color.rgb = RGBColor(64, 64, 64) + # Check if we need Unicode support for code blocks + try: + code_text.encode('ascii') + except UnicodeEncodeError: + r = run._element + r.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas') + i += 1 + continue + + if in_code_block: + code_block_content.append(line) + i += 1 + continue + + # Check for table + table_data, end_idx = parse_table(lines, i) + if table_data: + # Create Word table + table = doc.add_table(rows=len(table_data), cols=len(table_data[0])) + table.style = 'Table Grid' + + # Populate table + for row_idx, row_data in enumerate(table_data): + for col_idx, cell_text in enumerate(row_data): + if col_idx < len(table.rows[row_idx].cells): + cell = table.rows[row_idx].cells[col_idx] + # Clear existing paragraphs and add new one + cell.text = "" + p = cell.add_paragraph() + add_formatted_run(p, cell_text) + # Make header row bold + if row_idx == 0: + for run in p.runs: + run.bold = True + + doc.add_paragraph('') # Space after table + i = end_idx + continue + + line = line.rstrip() + + # Skip empty lines + if not line: + doc.add_paragraph('') + i += 1 + continue + + # Horizontal rule + if re.match(r'^(\*{3,}|-{3,}|_{3,})$', line.strip()): + p = doc.add_paragraph('─' * 50) + p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + i += 1 + continue + + # Headings + if line.startswith('# '): + doc.add_heading(line[2:], 1) + elif line.startswith('## '): + doc.add_heading(line[3:], 2) + elif line.startswith('### '): + doc.add_heading(line[4:], 3) + elif line.startswith('#### '): + doc.add_heading(line[5:], 4) + # Bullet points + elif line.lstrip().startswith('- ') or line.lstrip().startswith('* '): + # Get the indentation level + indent = len(line) - len(line.lstrip()) + bullet_text = line.lstrip()[2:] + p = doc.add_paragraph(style='List Bullet') + # Add indentation if nested + if indent > 0: + p.paragraph_format.left_indent = Pt(indent * 10) + add_formatted_run(p, bullet_text) + # Numbered lists + elif re.match(r'^\s*\d+\.', line): + match = re.match(r'^(\s*)(\d+)\.\s*(.*)', line) + if match: + indent = len(match.group(1)) + list_text = match.group(3) + p = doc.add_paragraph(style='List Number') + if indent > 0: + p.paragraph_format.left_indent = Pt(indent * 10) + add_formatted_run(p, list_text) + # Blockquote + elif line.startswith('> '): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Pt(30) + add_formatted_run(p, line[2:]) + # Add a gray color to indicate quote + for run in p.runs: + run.font.color.rgb = RGBColor(100, 100, 100) + else: + # Regular paragraph + p = doc.add_paragraph() + add_formatted_run(p, line) + + i += 1 + +# --- Database Models --- +# --- Database Models --- +# Models have been extracted to src/models/ and imported at the top of this file + +# --- Forms for Authentication --- +# --- Custom Password Validator --- +# password_check utility has been extracted to src/utils/security.py + + +# --- Blueprint Registration --- +# Import and register all blueprints for modular route organization + + + diff --git a/src/services/email.py b/src/services/email.py new file mode 100644 index 0000000..a5f84d0 --- /dev/null +++ b/src/services/email.py @@ -0,0 +1,443 @@ +""" +Email service for verification and password reset. + +This module provides email functionality using Python's built-in smtplib. +All email features are opt-in via environment variables. +""" + +import os +import smtplib +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime, timedelta +from typing import Optional + +from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature +from flask import current_app, url_for + +logger = logging.getLogger(__name__) + +# Token expiry times +EMAIL_VERIFICATION_EXPIRY = 24 * 60 * 60 # 24 hours in seconds +PASSWORD_RESET_EXPIRY = 1 * 60 * 60 # 1 hour in seconds + + +def get_email_config(): + """Get email configuration from environment variables.""" + return { + 'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true', + 'required': os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true', + 'smtp_host': os.environ.get('SMTP_HOST', ''), + 'smtp_port': int(os.environ.get('SMTP_PORT', '587')), + 'smtp_username': os.environ.get('SMTP_USERNAME', ''), + 'smtp_password': os.environ.get('SMTP_PASSWORD', ''), + 'smtp_use_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true', + 'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true', + 'from_address': os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com'), + 'from_name': os.environ.get('SMTP_FROM_NAME', 'Speakr'), + } + + +def is_email_verification_enabled() -> bool: + """Check if email verification is enabled.""" + return get_email_config()['enabled'] + + +def is_email_verification_required() -> bool: + """Check if email verification is required for login.""" + config = get_email_config() + return config['enabled'] and config['required'] + + +def is_smtp_configured() -> bool: + """Check if SMTP settings are properly configured.""" + config = get_email_config() + return bool(config['smtp_host'] and config['smtp_username'] and config['smtp_password']) + + +def get_serializer(salt: str) -> URLSafeTimedSerializer: + """Get a URL-safe timed serializer for token generation.""" + secret_key = current_app.config.get('SECRET_KEY', 'default-dev-key') + return URLSafeTimedSerializer(secret_key, salt=salt) + + +def generate_verification_token(user_id: int) -> str: + """Generate an email verification token.""" + serializer = get_serializer('email-verification') + return serializer.dumps(user_id) + + +def generate_password_reset_token(user_id: int) -> str: + """Generate a password reset token.""" + serializer = get_serializer('password-reset') + return serializer.dumps(user_id) + + +def verify_email_token(token: str) -> Optional[int]: + """ + Verify an email verification token. + + Returns the user_id if valid, None otherwise. + """ + serializer = get_serializer('email-verification') + try: + user_id = serializer.loads(token, max_age=EMAIL_VERIFICATION_EXPIRY) + return user_id + except SignatureExpired: + logger.warning("Email verification token expired") + return None + except BadSignature: + logger.warning("Invalid email verification token") + return None + + +def verify_reset_token(token: str) -> Optional[int]: + """ + Verify a password reset token. + + Returns the user_id if valid, None otherwise. + """ + serializer = get_serializer('password-reset') + try: + user_id = serializer.loads(token, max_age=PASSWORD_RESET_EXPIRY) + return user_id + except SignatureExpired: + logger.warning("Password reset token expired") + return None + except BadSignature: + logger.warning("Invalid password reset token") + return None + + +def _send_email(to_email: str, subject: str, html_body: str, text_body: str = None) -> bool: + """ + Send an email using SMTP. + + Returns True if successful, False otherwise. + """ + config = get_email_config() + + if not is_smtp_configured(): + logger.error("SMTP is not configured. Cannot send email.") + return False + + try: + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{config['from_name']} <{config['from_address']}>" + msg['To'] = to_email + + # Add plain text version + if text_body: + part1 = MIMEText(text_body, 'plain') + msg.attach(part1) + + # Add HTML version + part2 = MIMEText(html_body, 'html') + msg.attach(part2) + + # Connect to SMTP server + if config['smtp_use_ssl']: + server = smtplib.SMTP_SSL(config['smtp_host'], config['smtp_port']) + else: + server = smtplib.SMTP(config['smtp_host'], config['smtp_port']) + if config['smtp_use_tls']: + server.starttls() + + server.login(config['smtp_username'], config['smtp_password']) + server.sendmail(config['from_address'], to_email, msg.as_string()) + server.quit() + + logger.info(f"Email sent successfully to {to_email}") + return True + + except smtplib.SMTPAuthenticationError as e: + logger.error(f"SMTP authentication failed: {e}") + return False + except smtplib.SMTPException as e: + logger.error(f"SMTP error sending email: {e}") + return False + except Exception as e: + logger.error(f"Error sending email: {e}") + return False + + +def _get_email_template(content_html: str, content_text: str, subject: str) -> tuple[str, str]: + """ + Wrap content in the Speakr email template. + + Returns (html_body, text_body) + """ + # Get the base URL for the logo + try: + logo_url = url_for('static', filename='img/icon-192x192.png', _external=True) + except RuntimeError: + # Outside of request context, use a placeholder + logo_url = "" + + html_body = f""" + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + +
+ Speakr + +

Speakr

+
+
+

AI-Powered Audio Transcription

+
+
+ {content_html} +
+ + + + +
+

+ This email was sent by Speakr. If you have questions, please contact your administrator. +

+

+ © {datetime.utcnow().year} Speakr · AI-Powered Audio Transcription +

+
+
+
+ + +""" + + text_body = f""" +{subject} +{'=' * len(subject)} + +{content_text} + +--- +This email was sent by Speakr - AI-Powered Audio Transcription. +If you have questions, please contact your administrator. +""" + + return html_body, text_body + + +def send_verification_email(user) -> bool: + """ + Send a verification email to a user. + + Args: + user: User model instance + + Returns True if email was sent successfully, False otherwise. + """ + from src.database import db + + if not is_email_verification_enabled(): + logger.debug("Email verification is disabled") + return False + + if not is_smtp_configured(): + logger.warning("Cannot send verification email: SMTP not configured") + return False + + # Generate token and store it + token = generate_verification_token(user.id) + user.email_verification_token = token + user.email_verification_sent_at = datetime.utcnow() + db.session.commit() + + # Build verification URL + verify_url = url_for('auth.verify_email', token=token, _external=True) + + subject = "Verify your email address - Speakr" + + content_html = f""" +

Verify Your Email Address

+ +

Hi {user.username},

+ +

+ Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address. +

+ + + +

Or copy and paste this link into your browser:

+

{verify_url}

+ +
+

+ This link will expire in 24 hours.
+ If you didn't create an account on Speakr, you can safely ignore this email. +

+
+""" + + content_text = f"""Hi {user.username}, + +Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address. + +Click here to verify: {verify_url} + +This link will expire in 24 hours. + +If you didn't create an account on Speakr, you can safely ignore this email.""" + + html_body, text_body = _get_email_template(content_html, content_text, subject) + return _send_email(user.email, subject, html_body, text_body) + + +def send_password_reset_email(user) -> bool: + """ + Send a password reset email to a user. + + Args: + user: User model instance + + Returns True if email was sent successfully, False otherwise. + """ + from src.database import db + + if not is_smtp_configured(): + logger.warning("Cannot send password reset email: SMTP not configured") + return False + + # Generate token and store it + token = generate_password_reset_token(user.id) + user.password_reset_token = token + user.password_reset_sent_at = datetime.utcnow() + db.session.commit() + + # Build reset URL + reset_url = url_for('auth.reset_password', token=token, _external=True) + + subject = "Reset your password - Speakr" + + content_html = f""" +

Reset Your Password

+ +

Hi {user.username},

+ +

+ We received a request to reset your Speakr account password. Click the button below to create a new password. +

+ + + +

Or copy and paste this link into your browser:

+

{reset_url}

+ +
+ + + + + +
+ ⚠️ + +

+ This link will expire in 1 hour.
+ If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. +

+
+
+""" + + content_text = f"""Hi {user.username}, + +We received a request to reset your Speakr account password. Click the link below to create a new password: + +{reset_url} + +This link will expire in 1 hour. + +If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.""" + + html_body, text_body = _get_email_template(content_html, content_text, subject) + return _send_email(user.email, subject, html_body, text_body) + + +def can_resend_verification(user) -> tuple[bool, Optional[int]]: + """ + Check if a verification email can be resent. + + Returns (can_resend, seconds_until_can_resend) + """ + if not user.email_verification_sent_at: + return True, None + + # Allow resend after 60 seconds + cooldown = timedelta(seconds=60) + time_since_last = datetime.utcnow() - user.email_verification_sent_at + + if time_since_last >= cooldown: + return True, None + + remaining = (cooldown - time_since_last).seconds + return False, remaining + + +def can_resend_password_reset(user) -> tuple[bool, Optional[int]]: + """ + Check if a password reset email can be resent. + + Returns (can_resend, seconds_until_can_resend) + """ + if not user.password_reset_sent_at: + return True, None + + # Allow resend after 60 seconds + cooldown = timedelta(seconds=60) + time_since_last = datetime.utcnow() - user.password_reset_sent_at + + if time_since_last >= cooldown: + return True, None + + remaining = (cooldown - time_since_last).seconds + return False, remaining diff --git a/src/services/embeddings.py b/src/services/embeddings.py new file mode 100644 index 0000000..fb42a37 --- /dev/null +++ b/src/services/embeddings.py @@ -0,0 +1,422 @@ +""" +Embedding generation and semantic search services. +""" + +import os +import numpy as np +from flask import current_app +from sqlalchemy.orm import joinedload + +try: + from sentence_transformers import SentenceTransformer + from sklearn.metrics.pairwise import cosine_similarity + EMBEDDINGS_AVAILABLE = True +except ImportError: + EMBEDDINGS_AVAILABLE = False + cosine_similarity = None + +from src.database import db +from src.models import Recording, TranscriptChunk, InternalShare, RecordingTag + +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' + +# Initialize embedding model (lazy loading) +_embedding_model = None + + + +def get_embedding_model(): + """Get or initialize the sentence transformer model.""" + global _embedding_model + + if not EMBEDDINGS_AVAILABLE: + return None + + if _embedding_model is None: + try: + _embedding_model = SentenceTransformer('all-MiniLM-L6-v2') + current_app.logger.info("Embedding model loaded successfully") + except Exception as e: + current_app.logger.error(f"Failed to load embedding model: {e}") + return None + return _embedding_model + + + +def chunk_transcription(transcription, max_chunk_length=500, overlap=50): + """ + Split transcription into overlapping chunks for better context retrieval. + + Args: + transcription (str): The full transcription text + max_chunk_length (int): Maximum characters per chunk + overlap (int): Character overlap between chunks + + Returns: + list: List of text chunks + """ + if not transcription or len(transcription) <= max_chunk_length: + return [transcription] if transcription else [] + + chunks = [] + start = 0 + + while start < len(transcription): + end = start + max_chunk_length + + # Try to break at sentence boundaries + if end < len(transcription): + # Look for sentence endings within the last 100 characters + sentence_end = -1 + for i in range(max(0, end - 100), end): + if transcription[i] in '.!?': + # Check if it's not an abbreviation + if i + 1 < len(transcription) and transcription[i + 1].isspace(): + sentence_end = i + 1 + + if sentence_end > start: + end = sentence_end + + chunk = transcription[start:end].strip() + if chunk: + chunks.append(chunk) + + # Move start position with overlap + start = max(start + 1, end - overlap) + + # Prevent infinite loop + if start >= len(transcription): + break + + return chunks + + + +def generate_embeddings(texts): + """ + Generate embeddings for a list of texts. + + Args: + texts (list): List of text strings + + Returns: + list: List of embedding vectors as numpy arrays, or empty list if embeddings unavailable + """ + if not EMBEDDINGS_AVAILABLE: + current_app.logger.warning("Embeddings not available - skipping embedding generation") + return [] + + model = get_embedding_model() + if not model or not texts: + return [] + + try: + embeddings = model.encode(texts) + return [embedding.astype(np.float32) for embedding in embeddings] + except Exception as e: + current_app.logger.error(f"Error generating embeddings: {e}") + return [] + + + +def serialize_embedding(embedding): + """Convert numpy array to binary for database storage.""" + if embedding is None or not EMBEDDINGS_AVAILABLE: + return None + return embedding.tobytes() + + + +def deserialize_embedding(binary_data): + """Convert binary data back to numpy array.""" + if binary_data is None or not EMBEDDINGS_AVAILABLE: + return None + return np.frombuffer(binary_data, dtype=np.float32) + + + +def get_accessible_recording_ids(user_id): + """ + Get all recording IDs that a user has access to. + + Includes: + - Recordings owned by the user + - Recordings shared with the user via InternalShare + - Recordings shared via group tags (if team membership exists) + + Args: + user_id (int): User ID to check access for + + Returns: + list: List of recording IDs the user can access + """ + accessible_ids = set() + + # 1. User's own recordings + own_recordings = db.session.query(Recording.id).filter_by(user_id=user_id).all() + accessible_ids.update([r.id for r in own_recordings]) + + # 2. Internally shared recordings + if ENABLE_INTERNAL_SHARING: + shared_recordings = db.session.query(InternalShare.recording_id).filter_by( + shared_with_user_id=user_id + ).all() + accessible_ids.update([r.recording_id for r in shared_recordings]) + + return list(accessible_ids) + + + +def process_recording_chunks(recording_id): + """ + Process a recording by creating chunks and generating embeddings. + This should be called after a recording is transcribed. + """ + try: + recording = db.session.get(Recording, recording_id) + if not recording or not recording.transcription: + return False + + # Delete existing chunks for this recording + TranscriptChunk.query.filter_by(recording_id=recording_id).delete() + + # Create chunks + chunks = chunk_transcription(recording.transcription) + + if not chunks: + return True + + # Generate embeddings + embeddings = generate_embeddings(chunks) + + # Store chunks in database + for i, (chunk_text, embedding) in enumerate(zip(chunks, embeddings)): + chunk = TranscriptChunk( + recording_id=recording_id, + user_id=recording.user_id, + chunk_index=i, + content=chunk_text, + embedding=serialize_embedding(embedding) if embedding is not None else None + ) + db.session.add(chunk) + + db.session.commit() + current_app.logger.info(f"Created {len(chunks)} chunks for recording {recording_id}") + return True + + except Exception as e: + current_app.logger.error(f"Error processing chunks for recording {recording_id}: {e}") + db.session.rollback() + return False + + + +def basic_text_search_chunks(user_id, query, filters=None, top_k=5): + """ + Basic text search fallback when embeddings are not available. + Uses simple text matching instead of semantic search. + Searches across user's own recordings and recordings shared with them. + """ + try: + # Get all accessible recording IDs (own + shared) + accessible_recording_ids = get_accessible_recording_ids(user_id) + + if not accessible_recording_ids: + return [] + + # Build base query for chunks from accessible recordings with eager loading + chunks_query = TranscriptChunk.query.options(joinedload(TranscriptChunk.recording)).filter( + TranscriptChunk.recording_id.in_(accessible_recording_ids) + ) + + # Apply filters if provided + if filters: + if filters.get('tag_ids'): + chunks_query = chunks_query.join(Recording).join( + RecordingTag, Recording.id == RecordingTag.recording_id + ).filter(RecordingTag.tag_id.in_(filters['tag_ids'])) + + if filters.get('speaker_names'): + # Filter by participants field in recordings instead of chunk speaker_name + if not any(hasattr(desc, 'name') and desc.name == 'recording' for desc in chunks_query.column_descriptions): + chunks_query = chunks_query.join(Recording) + + # Build OR conditions for each speaker name in participants + speaker_conditions = [] + for speaker_name in filters['speaker_names']: + speaker_conditions.append( + Recording.participants.ilike(f'%{speaker_name}%') + ) + + chunks_query = chunks_query.filter(db.or_(*speaker_conditions)) + current_app.logger.info(f"Applied speaker filter for: {filters['speaker_names']}") + + if filters.get('recording_ids'): + chunks_query = chunks_query.filter( + TranscriptChunk.recording_id.in_(filters['recording_ids']) + ) + + if filters.get('date_from') or filters.get('date_to'): + chunks_query = chunks_query.join(Recording) + if filters.get('date_from'): + chunks_query = chunks_query.filter(Recording.meeting_date >= filters['date_from']) + if filters.get('date_to'): + chunks_query = chunks_query.filter(Recording.meeting_date <= filters['date_to']) + + # Text search - filter stop words and rank by match count + stop_words = {'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', + 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', + 'would', 'could', 'should', 'may', 'might', 'shall', 'can', + 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', + 'up', 'about', 'into', 'through', 'during', 'before', 'after', + 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', + 'it', 'its', 'this', 'that', 'these', 'those', 'what', 'which', + 'who', 'whom', 'how', 'when', 'where', 'why', + 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', + 'his', 'her', 'they', 'them', 'their'} + + query_words = [w for w in query.lower().split() if w not in stop_words and len(w) > 1] + + if not query_words: + # If all words were stop words, fall back to using original query words + query_words = [w for w in query.lower().split() if len(w) > 1] + + if query_words: + from sqlalchemy import or_, func, case, literal + + # Filter: match ANY keyword (OR) to get candidates + text_conditions = [] + for word in query_words: + text_conditions.append(TranscriptChunk.content.ilike(f'%{word}%')) + chunks_query = chunks_query.filter(or_(*text_conditions)) + + # Fetch more candidates than needed so we can rank them + chunks = chunks_query.limit(top_k * 5).all() + + # Rank by how many query words each chunk matches + scored_chunks = [] + for chunk in chunks: + content_lower = chunk.content.lower() + match_count = sum(1 for word in query_words if word in content_lower) + score = match_count / len(query_words) # 0.0 to 1.0 + scored_chunks.append((chunk, score)) + + # Sort by score descending, take top_k + scored_chunks.sort(key=lambda x: x[1], reverse=True) + return scored_chunks[:top_k] + + # No usable query words + return [] + + except Exception as e: + current_app.logger.error(f"Error in basic text search: {e}") + return [] + + + +def semantic_search_chunks(user_id, query, filters=None, top_k=5): + """ + Perform semantic search on transcript chunks with filtering. + Searches across user's own recordings and recordings shared with them. + + Args: + user_id (int): User ID for permission filtering + query (str): Search query + filters (dict): Optional filters for tags, speakers, dates, recording_ids + top_k (int): Number of top chunks to return + + Returns: + list: List of relevant chunks with similarity scores + """ + try: + # If embeddings are not available, fall back to basic text search + if not EMBEDDINGS_AVAILABLE: + current_app.logger.info("Embeddings not available - using basic text search as fallback") + return basic_text_search_chunks(user_id, query, filters, top_k) + + # Generate embedding for the query + model = get_embedding_model() + if not model: + return basic_text_search_chunks(user_id, query, filters, top_k) + + query_embedding = model.encode([query])[0] + + # Get all accessible recording IDs (own + shared) + accessible_recording_ids = get_accessible_recording_ids(user_id) + + if not accessible_recording_ids: + return [] + + # Build base query for chunks from accessible recordings with eager loading + chunks_query = TranscriptChunk.query.options(joinedload(TranscriptChunk.recording)).filter( + TranscriptChunk.recording_id.in_(accessible_recording_ids) + ) + + # Apply filters if provided + if filters: + if filters.get('tag_ids'): + # Join with recordings that have specified tags + chunks_query = chunks_query.join(Recording).join( + RecordingTag, Recording.id == RecordingTag.recording_id + ).filter(RecordingTag.tag_id.in_(filters['tag_ids'])) + + if filters.get('speaker_names'): + # Filter by participants field in recordings instead of chunk speaker_name + if not any(hasattr(desc, 'name') and desc.name == 'recording' for desc in chunks_query.column_descriptions): + chunks_query = chunks_query.join(Recording) + + # Build OR conditions for each speaker name in participants + speaker_conditions = [] + for speaker_name in filters['speaker_names']: + speaker_conditions.append( + Recording.participants.ilike(f'%{speaker_name}%') + ) + + chunks_query = chunks_query.filter(db.or_(*speaker_conditions)) + current_app.logger.info(f"Applied speaker filter for: {filters['speaker_names']}") + + if filters.get('recording_ids'): + chunks_query = chunks_query.filter( + TranscriptChunk.recording_id.in_(filters['recording_ids']) + ) + + if filters.get('date_from') or filters.get('date_to'): + chunks_query = chunks_query.join(Recording) + if filters.get('date_from'): + chunks_query = chunks_query.filter(Recording.meeting_date >= filters['date_from']) + if filters.get('date_to'): + chunks_query = chunks_query.filter(Recording.meeting_date <= filters['date_to']) + + # Get chunks that have embeddings + chunks = chunks_query.filter(TranscriptChunk.embedding.isnot(None)).all() + + if not chunks: + return [] + + # Calculate similarities + chunk_similarities = [] + for chunk in chunks: + try: + chunk_embedding = deserialize_embedding(chunk.embedding) + if chunk_embedding is not None: + similarity = cosine_similarity( + query_embedding.reshape(1, -1), + chunk_embedding.reshape(1, -1) + )[0][0] + chunk_similarities.append((chunk, float(similarity))) + except Exception as e: + current_app.logger.warning(f"Error calculating similarity for chunk {chunk.id}: {e}") + continue + + # Sort by similarity and return top k + chunk_similarities.sort(key=lambda x: x[1], reverse=True) + return chunk_similarities[:top_k] + + except Exception as e: + current_app.logger.error(f"Error in semantic search: {e}") + return [] + +# --- Helper Functions for Document Processing --- + + + diff --git a/src/services/job_queue.py b/src/services/job_queue.py new file mode 100644 index 0000000..d3a0d28 --- /dev/null +++ b/src/services/job_queue.py @@ -0,0 +1,631 @@ +""" +Fair database-backed job queue for background processing tasks. + +This queue ensures: +- Jobs persist across application restarts +- Fair round-robin scheduling between users +- Separate queues for transcription (slow) and summary (fast) jobs +- Limited concurrency to prevent overwhelming external services +- Automatic recovery of orphaned jobs +""" + +import os +import json +import threading +import time +import logging +from datetime import datetime +from typing import Optional, Dict, Any, List +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + +# Configuration +TRANSCRIPTION_WORKERS = int(os.environ.get('JOB_QUEUE_WORKERS', '2')) +SUMMARY_WORKERS = int(os.environ.get('SUMMARY_QUEUE_WORKERS', '2')) +MAX_RETRIES = int(os.environ.get('JOB_MAX_RETRIES', '3')) +POLL_INTERVAL = 1.0 # seconds between checking for new jobs + +# Job type categories +TRANSCRIPTION_JOBS = ['transcribe', 'reprocess_transcription'] +SUMMARY_JOBS = ['summarize', 'reprocess_summary'] + + +class FairJobQueue: + """ + A database-backed job queue with fair scheduling across users. + + Uses separate queues for transcription and summary jobs to prevent + slow transcription jobs from blocking fast summary jobs. + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + """Singleton pattern to ensure only one queue exists.""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + """Initialize the job queue.""" + if self._initialized: + return + + self._transcription_workers = [] + self._summary_workers = [] + self._running = False + self._app = None + # Separate round-robin tracking for each queue + self._last_user_id_transcription = None + self._last_user_id_summary = None + # Lock for claiming jobs (SQLite doesn't support row-level locking) + self._claim_lock = threading.Lock() + self._initialized = True + + logger.info(f"FairJobQueue initialized: {TRANSCRIPTION_WORKERS} transcription workers, {SUMMARY_WORKERS} summary workers") + + def init_app(self, app): + """Initialize with Flask app for context management.""" + self._app = app + + @contextmanager + def _app_context(self): + """Get application context for database operations.""" + if self._app: + with self._app.app_context(): + yield + else: + yield + + def start(self): + """Start the worker threads for both queues.""" + if self._running: + return + + self._running = True + + # Start transcription workers + for i in range(TRANSCRIPTION_WORKERS): + worker = threading.Thread( + target=self._worker_loop, + args=(TRANSCRIPTION_JOBS, 'transcription'), + name=f"TranscriptionWorker-{i}", + daemon=True + ) + worker.start() + self._transcription_workers.append(worker) + + # Start summary workers + for i in range(SUMMARY_WORKERS): + worker = threading.Thread( + target=self._worker_loop, + args=(SUMMARY_JOBS, 'summary'), + name=f"SummaryWorker-{i}", + daemon=True + ) + worker.start() + self._summary_workers.append(worker) + + logger.info(f"Started {TRANSCRIPTION_WORKERS} transcription workers and {SUMMARY_WORKERS} summary workers") + + def stop(self): + """Stop the worker threads gracefully.""" + self._running = False + for worker in self._transcription_workers + self._summary_workers: + worker.join(timeout=5) + self._transcription_workers.clear() + self._summary_workers.clear() + logger.info("Job queue workers stopped") + + def _worker_loop(self, job_types: List[str], queue_name: str): + """Main worker loop that processes jobs of specific types.""" + while self._running: + try: + job = self._claim_next_job(job_types, queue_name) + if job: + self._process_job(job) + else: + # No jobs available, sleep briefly + time.sleep(POLL_INTERVAL) + except Exception as e: + logger.error(f"{queue_name.capitalize()} worker error: {e}", exc_info=True) + time.sleep(POLL_INTERVAL) + + def _claim_next_job(self, job_types: List[str], queue_name: str): + """ + Claim the next job of specified types using fair round-robin scheduling. + + Args: + job_types: List of job types this worker handles + queue_name: Name of the queue ('transcription' or 'summary') + + Returns the claimed job or None if no jobs available. + """ + # Use lock to prevent race conditions (SQLite doesn't support row-level locking) + with self._claim_lock: + with self._app_context(): + from src.database import db + from src.models import ProcessingJob + + try: + # Get list of users with queued jobs of our types + users_with_jobs = db.session.query( + ProcessingJob.user_id + ).filter( + ProcessingJob.status == 'queued', + ProcessingJob.job_type.in_(job_types) + ).group_by( + ProcessingJob.user_id + ).order_by( + db.func.min(ProcessingJob.created_at) + ).all() + + if not users_with_jobs: + return None + + user_ids = [u[0] for u in users_with_jobs] + + # Get last user ID for this queue type + last_user_id = (self._last_user_id_transcription + if queue_name == 'transcription' + else self._last_user_id_summary) + + # Round-robin: pick next user after last processed + next_user_id = None + if last_user_id is not None and last_user_id in user_ids: + idx = user_ids.index(last_user_id) + next_user_id = user_ids[(idx + 1) % len(user_ids)] + else: + next_user_id = user_ids[0] + + # Get oldest queued job of our types for this user + candidate_job = ProcessingJob.query.filter( + ProcessingJob.user_id == next_user_id, + ProcessingJob.status == 'queued', + ProcessingJob.job_type.in_(job_types) + ).order_by( + ProcessingJob.created_at + ).first() + + if candidate_job: + # Atomically claim the job - only succeeds if status is still 'queued' + # This prevents race conditions when multiple workers try to claim the same job + from sqlalchemy import update + claim_time = datetime.utcnow() + result = db.session.execute( + update(ProcessingJob) + .where( + ProcessingJob.id == candidate_job.id, + ProcessingJob.status == 'queued' # Critical: only claim if still queued + ) + .values(status='processing', started_at=claim_time) + ) + + if result.rowcount == 0: + # Job was already claimed by another worker - this is expected with multiple workers + logger.debug(f"[{queue_name.upper()}] Job {candidate_job.id} already claimed by another worker") + db.session.rollback() + return None + + # Also update Recording.status to reflect active processing + from src.models import Recording + recording = db.session.get(Recording, candidate_job.recording_id) + if recording and recording.status == 'QUEUED': + recording.status = 'PROCESSING' + + db.session.commit() + + # Refresh the job object to get updated values + db.session.refresh(candidate_job) + + # Update last user ID for this queue + if queue_name == 'transcription': + self._last_user_id_transcription = next_user_id + else: + self._last_user_id_summary = next_user_id + + wait_time = (claim_time - candidate_job.created_at).total_seconds() + logger.info(f"[{queue_name.upper()}] Claimed job {candidate_job.id} (type={candidate_job.job_type}) for user {candidate_job.user_id}, recording {candidate_job.recording_id} (waited {wait_time:.1f}s)") + return candidate_job + + return None + + except Exception as e: + logger.error(f"Error claiming {queue_name} job: {e}", exc_info=True) + db.session.rollback() + return None + + def _is_permanent_error(self, error_str: str) -> bool: + """ + Detect if an error is permanent and should not be retried. + + Permanent errors include: + - 400: Bad request (invalid format, invalid parameters) + - 413: File too large (user needs to enable chunking or compress file) + - 401/403: Authentication/authorization errors (credentials issue) + - 402: Payment required (billing issue) + - 404: Resource not found (model doesn't exist) + - Invalid format errors (file needs to be converted) + """ + error_lower = error_str.lower() + + # HTTP status codes that indicate permanent errors + permanent_codes = ['400', '413', '401', '402', '403', '404'] + for code in permanent_codes: + if f'error code: {code}' in error_lower or f'status {code}' in error_lower: + return True + + # Specific error patterns that are permanent (simple substring matching) + permanent_patterns = [ + 'maximum content size limit', + 'file too large', + 'payload too large', + 'invalid api key', + 'incorrect api key', + 'authentication failed', + 'unauthorized', + 'permission denied', + 'access denied', + 'billing', + 'payment required', + 'quota exceeded', + 'insufficient funds', + 'model not found', + 'invalid model', + 'unsupported format', + 'invalid file format', + 'invalid_request_error', + 'bad request', + ] + + for pattern in permanent_patterns: + if pattern in error_lower: + return True + + return False + + def _process_job(self, job): + """Process a single job by dispatching to the appropriate task function.""" + job_id = job.id + job_type = job.job_type + recording_id = job.recording_id + params_str = job.params + is_new_upload = job.is_new_upload + + with self._app_context(): + from src.database import db + from src.models import ProcessingJob, Recording + from flask import current_app + + try: + # Parse job parameters + params = json.loads(params_str) if params_str else {} + + # Re-fetch the job in this session context to ensure it's attached + job = db.session.get(ProcessingJob, job_id) + if not job: + logger.error(f"Job {job_id} not found when trying to process") + return + + # Get recording + recording = db.session.get(Recording, recording_id) + if not recording: + raise ValueError(f"Recording {recording_id} not found") + + # Dispatch based on job type + if job_type == 'transcribe': + self._run_transcription(job, recording, params) + elif job_type == 'summarize': + self._run_summarization(job, recording, params) + elif job_type == 'reprocess_transcription': + self._run_reprocess_transcription(job, recording, params) + elif job_type == 'reprocess_summary': + self._run_reprocess_summary(job, recording, params) + else: + raise ValueError(f"Unknown job type: {job_type}") + + # Mark as completed - re-fetch to ensure we have latest state + job = db.session.get(ProcessingJob, job_id) + if job: + job.status = 'completed' + job.completed_at = datetime.utcnow() + db.session.commit() + logger.info(f"Job {job_id} completed successfully") + + except Exception as e: + error_str = str(e) + logger.error(f"Job {job_id} failed: {e}", exc_info=True) + + # Check if this is a permanent error that shouldn't be retried + is_permanent_error = self._is_permanent_error(error_str) + + # Re-fetch job to update it + job = db.session.get(ProcessingJob, job_id) + if job: + job.error_message = error_str + job.retry_count += 1 + + # Only retry if: not a permanent error AND under retry limit + if not is_permanent_error and job.retry_count < MAX_RETRIES: + # Re-queue for retry + job.status = 'queued' + job.started_at = None + logger.info(f"Job {job_id} re-queued for retry ({job.retry_count}/{MAX_RETRIES})") + else: + job.status = 'failed' + job.completed_at = datetime.utcnow() + recording = db.session.get(Recording, recording_id) + + if is_permanent_error: + logger.info(f"Job {job_id} failed with permanent error (no retry): {error_str[:100]}") + + # Always keep recordings with FAILED status so users can see the error + # and reprocess later (e.g., when ASR server recovers) + if recording: + # Keep the recording with FAILED status so user can see the error and fix settings + recording.status = 'FAILED' + # Format the error for nice display + from src.utils.error_formatting import format_error_for_storage + recording.transcription = format_error_for_storage(error_str) + + if is_permanent_error: + logger.error(f"Job {job_id} failed permanently (non-retryable error)") + else: + logger.error(f"Job {job_id} failed permanently after {MAX_RETRIES} retries") + + db.session.commit() + + def _run_transcription(self, job, recording, params): + """Run transcription task. Status updates handled by task function.""" + from src.tasks.processing import transcribe_audio_task + from flask import current_app + + filepath = recording.audio_path + filename_for_asr = recording.original_filename or os.path.basename(filepath) + + transcribe_audio_task( + current_app._get_current_object().app_context(), + recording.id, + filepath, + filename_for_asr, + datetime.utcnow(), + language=params.get('language'), + min_speakers=params.get('min_speakers'), + max_speakers=params.get('max_speakers'), + tag_id=params.get('tag_id'), + hotwords=params.get('hotwords'), + initial_prompt=params.get('initial_prompt'), + ) + + def _run_summarization(self, job, recording, params): + """Run summarization-only task. Status updates handled by task function.""" + from src.tasks.processing import generate_summary_only_task + from flask import current_app + + generate_summary_only_task( + current_app._get_current_object().app_context(), + recording.id, + custom_prompt_override=params.get('custom_prompt'), + user_id=params.get('user_id') + ) + + def _run_reprocess_transcription(self, job, recording, params): + """Run transcription reprocessing task. Status updates handled by task function.""" + from src.tasks.processing import transcribe_audio_task + from flask import current_app + + filepath = recording.audio_path + filename_for_asr = recording.original_filename or os.path.basename(filepath) + + transcribe_audio_task( + current_app._get_current_object().app_context(), + recording.id, + filepath, + filename_for_asr, + datetime.utcnow(), + language=params.get('language'), + min_speakers=params.get('min_speakers'), + max_speakers=params.get('max_speakers'), + tag_id=params.get('tag_id'), + hotwords=params.get('hotwords'), + initial_prompt=params.get('initial_prompt'), + ) + + def _run_reprocess_summary(self, job, recording, params): + """Run summary reprocessing task. Status updates handled by task function.""" + from src.tasks.processing import generate_summary_only_task + from flask import current_app + + generate_summary_only_task( + current_app._get_current_object().app_context(), + recording.id, + custom_prompt_override=params.get('custom_prompt'), + user_id=params.get('user_id') + ) + + def enqueue( + self, + user_id: int, + recording_id: int, + job_type: str, + params: Dict[str, Any] = None, + is_new_upload: bool = False + ) -> int: + """ + Add a job to the database queue. + + Args: + user_id: ID of the user who owns this job + recording_id: ID of the recording to process + job_type: Type of job (transcribe, summarize, reprocess_transcription, reprocess_summary) + params: Optional parameters for the job + is_new_upload: True if this is a new file upload (for cleanup on failure) + + Returns: + The created job ID + """ + with self._app_context(): + from src.database import db + from src.models import ProcessingJob, Recording + + # Check for existing active job of the SAME TYPE for this recording + # Allow different job types to coexist (e.g., transcribe and summarize) + existing = ProcessingJob.query.filter( + ProcessingJob.recording_id == recording_id, + ProcessingJob.job_type == job_type, + ProcessingJob.status.in_(['queued', 'processing']) + ).first() + + if existing: + logger.warning(f"Job of type {job_type} already exists for recording {recording_id}: {existing.id}") + return existing.id + + # Create new job + job = ProcessingJob( + user_id=user_id, + recording_id=recording_id, + job_type=job_type, + params=json.dumps(params) if params else None, + is_new_upload=is_new_upload + ) + db.session.add(job) + + # Update recording status based on job type + recording = db.session.get(Recording, recording_id) + if recording: + if job_type in SUMMARY_JOBS: + recording.status = 'SUMMARIZING' + else: + recording.status = 'QUEUED' + + db.session.commit() + + # Auto-start workers if not running + if not self._running: + self.start() + + queue_name = 'summary' if job_type in SUMMARY_JOBS else 'transcription' + logger.info(f"Enqueued {queue_name} job {job.id} (type={job_type}) for user {user_id}, recording {recording_id}") + return job.id + + def recover_orphaned_jobs(self): + """ + Recover jobs that were processing when the app crashed. + Call this on startup to reset orphaned jobs back to queued. + """ + with self._app_context(): + from src.database import db + from src.models import ProcessingJob + + orphaned = ProcessingJob.query.filter( + ProcessingJob.status == 'processing' + ).all() + + for job in orphaned: + job.status = 'queued' + job.started_at = None + queue_name = 'summary' if job.job_type in SUMMARY_JOBS else 'transcription' + logger.info(f"Recovered orphaned {queue_name} job {job.id} for recording {job.recording_id}") + + if orphaned: + db.session.commit() + logger.info(f"Recovered {len(orphaned)} orphaned jobs") + + def get_queue_status(self) -> Dict[str, Any]: + """Get the current queue status for both queues.""" + with self._app_context(): + from src.models import ProcessingJob + + transcription_queued = ProcessingJob.query.filter( + ProcessingJob.status == 'queued', + ProcessingJob.job_type.in_(TRANSCRIPTION_JOBS) + ).count() + transcription_processing = ProcessingJob.query.filter( + ProcessingJob.status == 'processing', + ProcessingJob.job_type.in_(TRANSCRIPTION_JOBS) + ).count() + + summary_queued = ProcessingJob.query.filter( + ProcessingJob.status == 'queued', + ProcessingJob.job_type.in_(SUMMARY_JOBS) + ).count() + summary_processing = ProcessingJob.query.filter( + ProcessingJob.status == 'processing', + ProcessingJob.job_type.in_(SUMMARY_JOBS) + ).count() + + return { + "transcription_queue": { + "queued": transcription_queued, + "processing": transcription_processing, + "workers": TRANSCRIPTION_WORKERS + }, + "summary_queue": { + "queued": summary_queued, + "processing": summary_processing, + "workers": SUMMARY_WORKERS + }, + "is_running": self._running + } + + def get_position_in_queue(self, recording_id: int) -> Optional[int]: + """Get the position of a recording's job in its respective queue (1-indexed).""" + with self._app_context(): + from src.models import ProcessingJob + + job = ProcessingJob.query.filter( + ProcessingJob.recording_id == recording_id, + ProcessingJob.status == 'queued' + ).first() + + if not job: + return None + + # Determine which queue this job is in + job_types = SUMMARY_JOBS if job.job_type in SUMMARY_JOBS else TRANSCRIPTION_JOBS + + # Count jobs of the same type created before this one + position = ProcessingJob.query.filter( + ProcessingJob.status == 'queued', + ProcessingJob.job_type.in_(job_types), + ProcessingJob.created_at < job.created_at + ).count() + 1 + + return position + + def get_job_for_recording(self, recording_id: int): + """Get the active job for a recording.""" + with self._app_context(): + from src.models import ProcessingJob + + return ProcessingJob.query.filter( + ProcessingJob.recording_id == recording_id, + ProcessingJob.status.in_(['queued', 'processing']) + ).first() + + def cleanup_old_jobs(self, max_age_hours: int = 24): + """Remove completed/failed jobs older than max_age_hours.""" + with self._app_context(): + from src.database import db + from src.models import ProcessingJob + from datetime import timedelta + + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + deleted = ProcessingJob.query.filter( + ProcessingJob.status.in_(['completed', 'failed']), + ProcessingJob.completed_at < cutoff + ).delete(synchronize_session=False) + + if deleted: + db.session.commit() + logger.info(f"Cleaned up {deleted} old jobs") + + +# Global job queue instance +job_queue = FairJobQueue() diff --git a/src/services/llm.py b/src/services/llm.py new file mode 100644 index 0000000..c8e9f6b --- /dev/null +++ b/src/services/llm.py @@ -0,0 +1,498 @@ +""" +LLM API integration services (OpenAI/OpenRouter). +""" + +import os +import re +import json +import logging +import httpx +from openai import OpenAI + +# Use standard logging instead of current_app.logger for context independence +logger = logging.getLogger(__name__) + + +class TokenBudgetExceeded(Exception): + """Raised when user exceeds their token budget.""" + def __init__(self, message, usage_percentage=100): + self.message = message + self.usage_percentage = usage_percentage + super().__init__(message) + +from src.utils import safe_json_loads, extract_json_object + +# Configuration - use TEXT_MODEL_* variables for LLM +TEXT_MODEL_API_KEY = os.environ.get("TEXT_MODEL_API_KEY") +TEXT_MODEL_BASE_URL = os.environ.get("TEXT_MODEL_BASE_URL", "https://openrouter.ai/api/v1") +if TEXT_MODEL_BASE_URL: + TEXT_MODEL_BASE_URL = TEXT_MODEL_BASE_URL.split('#')[0].strip() +TEXT_MODEL_NAME = os.environ.get("TEXT_MODEL_NAME", "openai/gpt-3.5-turbo") + +# Chat model configuration (optional - falls back to TEXT_MODEL_* if not set) +CHAT_MODEL_API_KEY = os.environ.get("CHAT_MODEL_API_KEY") +CHAT_MODEL_BASE_URL = os.environ.get("CHAT_MODEL_BASE_URL") +if CHAT_MODEL_BASE_URL: + CHAT_MODEL_BASE_URL = CHAT_MODEL_BASE_URL.split('#')[0].strip() +CHAT_MODEL_NAME = os.environ.get("CHAT_MODEL_NAME") + +# Chat-specific GPT-5 settings (optional - falls back to main GPT5_* settings) +CHAT_GPT5_REASONING_EFFORT = os.environ.get("CHAT_GPT5_REASONING_EFFORT") +CHAT_GPT5_VERBOSITY = os.environ.get("CHAT_GPT5_VERBOSITY") + +# Streaming options - disable for LLM servers that don't support OpenAI's stream_options +ENABLE_STREAM_OPTIONS = os.environ.get("ENABLE_STREAM_OPTIONS", "true").lower() == "true" + + +def get_chat_config(): + """ + Get chat model configuration, falling back to TEXT_MODEL if not set. + + Returns a dict with api_key, base_url, model_name, and GPT-5 settings. + """ + if CHAT_MODEL_API_KEY and CHAT_MODEL_NAME: + return { + 'api_key': CHAT_MODEL_API_KEY, + 'base_url': CHAT_MODEL_BASE_URL or TEXT_MODEL_BASE_URL, + 'model_name': CHAT_MODEL_NAME, + 'gpt5_reasoning_effort': CHAT_GPT5_REASONING_EFFORT or os.environ.get("GPT5_REASONING_EFFORT", "medium"), + 'gpt5_verbosity': CHAT_GPT5_VERBOSITY or os.environ.get("GPT5_VERBOSITY", "medium") + } + return { + 'api_key': TEXT_MODEL_API_KEY, + 'base_url': TEXT_MODEL_BASE_URL, + 'model_name': TEXT_MODEL_NAME, + 'gpt5_reasoning_effort': os.environ.get("GPT5_REASONING_EFFORT", "medium"), + 'gpt5_verbosity': os.environ.get("GPT5_VERBOSITY", "medium") + } + + +# Set up HTTP client with custom headers for OpenRouter app identification +app_headers = { + "HTTP-Referer": "https://github.com/murtaza-nasir/speakr", + "X-Title": "Speakr - AI Audio Transcription", + "User-Agent": "Speakr/1.0 (https://github.com/murtaza-nasir/speakr)" +} + +http_client_no_proxy = httpx.Client( + verify=True, + headers=app_headers +) + +# Create client with placeholder key if not provided (allows app to start) +try: + api_key = TEXT_MODEL_API_KEY or "not-needed" + client = OpenAI( + api_key=api_key, + base_url=TEXT_MODEL_BASE_URL, + http_client=http_client_no_proxy + ) +except Exception as client_init_e: + client = None + +# Create chat client (may be same as main client if no separate config) +chat_client = None +try: + chat_config = get_chat_config() + if chat_config['api_key']: + if CHAT_MODEL_API_KEY and CHAT_MODEL_API_KEY != TEXT_MODEL_API_KEY: + # Separate chat configuration - create dedicated client + chat_client = OpenAI( + api_key=chat_config['api_key'], + base_url=chat_config['base_url'], + http_client=http_client_no_proxy + ) + logger.info(f"Separate chat client initialized: {chat_config['base_url']} / {chat_config['model_name']}") + else: + # Use same client as main LLM + chat_client = client +except Exception as chat_client_init_e: + logger.warning(f"Failed to initialize chat client, falling back to main client: {chat_client_init_e}") + chat_client = client + + +def is_gpt5_model(model_name): + """ + Check if the model is a GPT-5 series model that requires special API parameters. + + Args: + model_name: The model name string + + Returns: + Boolean indicating if this is a GPT-5 model + """ + if not model_name: + return False + model_lower = model_name.lower() + return model_lower.startswith('gpt-5') or model_lower in ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-chat-latest'] + + + +def is_using_openai_api(): + """ + Check if we're using the official OpenAI API (not OpenRouter or other providers). + + Returns: + Boolean indicating if this is the OpenAI API + """ + return TEXT_MODEL_BASE_URL and 'api.openai.com' in TEXT_MODEL_BASE_URL + + + +def call_llm_completion(messages, temperature=0.7, response_format=None, stream=False, max_tokens=None, + user_id=None, operation_type=None): + """ + Centralized function for LLM API calls with proper error handling and logging. + + Args: + messages: List of message dicts with 'role' and 'content' + temperature: Sampling temperature (0-1) - ignored for GPT-5 models + response_format: Optional response format dict (e.g., {"type": "json_object"}) + stream: Whether to stream the response + max_tokens: Optional maximum tokens to generate + user_id: Optional user ID for token tracking and budget enforcement + operation_type: Optional operation type for token tracking (e.g., 'summarization', 'chat') + + Returns: + OpenAI completion object or generator (if streaming) + """ + if not client: + raise ValueError("LLM client not initialized") + + if not TEXT_MODEL_API_KEY: + raise ValueError("TEXT_MODEL_API_KEY not configured") + + # Check budget before making the call + if user_id and operation_type: + try: + from src.services.token_tracking import token_tracker + can_proceed, usage_pct, msg = token_tracker.check_budget(user_id) + if not can_proceed: + raise TokenBudgetExceeded(msg, usage_pct) + if usage_pct >= 80: + logger.warning(f"User {user_id} at {usage_pct:.1f}% of token budget") + except TokenBudgetExceeded: + raise + except Exception as e: + # Log but don't block on budget check errors + logger.warning(f"Budget check failed for user {user_id}: {e}") + + try: + # Check if we're using GPT-5 with OpenAI API + using_gpt5 = is_gpt5_model(TEXT_MODEL_NAME) and is_using_openai_api() + + completion_args = { + "model": TEXT_MODEL_NAME, + "messages": messages, + "stream": stream + } + + # Add stream_options to get usage in final chunk for streaming + # Some LLM servers don't support this OpenAI-specific option + if stream and ENABLE_STREAM_OPTIONS: + completion_args["stream_options"] = {"include_usage": True} + + if using_gpt5: + # GPT-5 models don't support temperature, top_p, or logprobs + # They use reasoning_effort and verbosity instead + logger.debug(f"Using GPT-5 model: {TEXT_MODEL_NAME} - applying GPT-5 specific parameters") + + # Get GPT-5 specific parameters from environment variables + reasoning_effort = os.environ.get("GPT5_REASONING_EFFORT", "medium") # minimal, low, medium, high + verbosity = os.environ.get("GPT5_VERBOSITY", "medium") # low, medium, high + + # Add GPT-5 specific parameters + completion_args["reasoning_effort"] = reasoning_effort + completion_args["verbosity"] = verbosity + + # Use max_completion_tokens instead of max_tokens for GPT-5 + if max_tokens: + completion_args["max_completion_tokens"] = max_tokens + else: + # Non-GPT-5 models use standard parameters + completion_args["temperature"] = temperature + + if max_tokens: + completion_args["max_tokens"] = max_tokens + + if response_format: + completion_args["response_format"] = response_format + + response = client.chat.completions.create(**completion_args) + + # Track usage for non-streaming calls + if user_id and operation_type and not stream and response.usage: + try: + from src.services.token_tracking import token_tracker + token_tracker.record_usage( + user_id=user_id, + operation_type=operation_type, + prompt_tokens=response.usage.prompt_tokens, + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + model_name=TEXT_MODEL_NAME, + cost=getattr(response.usage, 'cost', None) + ) + except Exception as e: + logger.warning(f"Failed to record token usage: {e}") + + # Debug log for empty responses + if not stream and response.choices: + content = response.choices[0].message.content + if not content: + logger.warning(f"LLM returned empty content. Model: {TEXT_MODEL_NAME}, finish_reason: {response.choices[0].finish_reason}") + # Log more details if available + if hasattr(response.choices[0].message, 'refusal'): + logger.warning(f"Refusal: {response.choices[0].message.refusal}") + if hasattr(response.choices[0].message, 'tool_calls') and response.choices[0].message.tool_calls: + logger.warning(f"Tool calls present: {response.choices[0].message.tool_calls}") + + return response + + except TokenBudgetExceeded: + raise + except Exception as e: + logger.error(f"LLM API call failed: {e}") + raise + + +def call_chat_completion(messages, temperature=0.7, response_format=None, stream=False, max_tokens=None, + user_id=None, operation_type=None): + """ + Chat-specific LLM completion function. Uses dedicated chat model if configured, + otherwise falls back to standard TEXT_MODEL configuration. + + Args: + messages: List of message dicts with 'role' and 'content' + temperature: Sampling temperature (0-1) - ignored for GPT-5 models + response_format: Optional response format dict (e.g., {"type": "json_object"}) + stream: Whether to stream the response + max_tokens: Optional maximum tokens to generate + user_id: Optional user ID for token tracking and budget enforcement + operation_type: Optional operation type for token tracking (e.g., 'chat') + + Returns: + OpenAI completion object or generator (if streaming) + """ + effective_client = chat_client if chat_client else client + chat_config = get_chat_config() + + if not effective_client: + raise ValueError("Chat LLM client not initialized") + + if not chat_config['api_key']: + raise ValueError("Chat model API key not configured") + + # Check budget before making the call + if user_id and operation_type: + try: + from src.services.token_tracking import token_tracker + can_proceed, usage_pct, msg = token_tracker.check_budget(user_id) + if not can_proceed: + raise TokenBudgetExceeded(msg, usage_pct) + if usage_pct >= 80: + logger.warning(f"User {user_id} at {usage_pct:.1f}% of token budget") + except TokenBudgetExceeded: + raise + except Exception as e: + # Log but don't block on budget check errors + logger.warning(f"Budget check failed for user {user_id}: {e}") + + try: + model_name = chat_config['model_name'] + base_url = chat_config['base_url'] or '' + + # Check if we're using GPT-5 with OpenAI API + using_gpt5 = is_gpt5_model(model_name) and 'api.openai.com' in base_url + + completion_args = { + "model": model_name, + "messages": messages, + "stream": stream + } + + # Add stream_options to get usage in final chunk for streaming + # Some LLM servers don't support this OpenAI-specific option + if stream and ENABLE_STREAM_OPTIONS: + completion_args["stream_options"] = {"include_usage": True} + + if using_gpt5: + logger.debug(f"Using GPT-5 chat model: {model_name}") + # Use chat-specific GPT-5 settings from config + completion_args["reasoning_effort"] = chat_config['gpt5_reasoning_effort'] + completion_args["verbosity"] = chat_config['gpt5_verbosity'] + + if max_tokens: + completion_args["max_completion_tokens"] = max_tokens + else: + completion_args["temperature"] = temperature + if max_tokens: + completion_args["max_tokens"] = max_tokens + + if response_format: + completion_args["response_format"] = response_format + + response = effective_client.chat.completions.create(**completion_args) + + # Track usage for non-streaming calls + if user_id and operation_type and not stream and response.usage: + try: + from src.services.token_tracking import token_tracker + token_tracker.record_usage( + user_id=user_id, + operation_type=operation_type, + prompt_tokens=response.usage.prompt_tokens, + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + model_name=model_name, + cost=getattr(response.usage, 'cost', None) + ) + except Exception as e: + logger.warning(f"Failed to record token usage: {e}") + + # Debug log for empty responses + if not stream and response.choices: + content = response.choices[0].message.content + if not content: + logger.warning(f"Chat LLM returned empty content. Model: {model_name}, finish_reason: {response.choices[0].finish_reason}") + + return response + + except TokenBudgetExceeded: + raise + except Exception as e: + logger.error(f"Chat LLM API call failed: {e}") + raise + + +def format_api_error_message(error_str): + """ + Formats API error messages to be more user-friendly. + Specifically handles token limit errors with helpful suggestions. + """ + error_lower = error_str.lower() + + # Check for token limit errors + if 'maximum context length' in error_lower and 'tokens' in error_lower: + return "[Summary generation failed: The transcription is too long for AI processing. Request your admin to try using a different LLM with a larger context size, or set a limit for the transcript_length_limit in the system settings.]" + + # Check for other common API errors + if 'rate limit' in error_lower: + return "[Summary generation failed: API rate limit exceeded. Please try again in a few minutes.]" + + if 'insufficient funds' in error_lower or 'quota exceeded' in error_lower: + return "[Summary generation failed: API quota exceeded. Please contact support.]" + + if 'timeout' in error_lower: + return "[Summary generation failed: Request timed out. Please try again.]" + + # For other errors, show a generic message + return f"[Summary generation failed: {error_str}]" + + +def process_streaming_with_thinking(stream, user_id=None, operation_type=None, model_name=None, app=None): + """ + Generator that processes a streaming response and separates thinking content. + Yields SSE-formatted data with 'delta' for regular content and 'thinking' for thinking content. + + Args: + stream: The streaming response from the LLM API + user_id: Optional user ID for token tracking + operation_type: Optional operation type for token tracking + model_name: Optional model name for token tracking + app: Optional Flask app instance for database context in generators + """ + content_buffer = "" + in_thinking = False + thinking_buffer = "" + + for chunk in stream: + # Check for usage in final chunk (from stream_options={'include_usage': True}) + if hasattr(chunk, 'usage') and chunk.usage and user_id and operation_type: + try: + from src.services.token_tracking import token_tracker + # Use app context if provided (needed for generators where context may be lost) + if app: + with app.app_context(): + token_tracker.record_usage( + user_id=user_id, + operation_type=operation_type, + prompt_tokens=chunk.usage.prompt_tokens, + completion_tokens=chunk.usage.completion_tokens, + total_tokens=chunk.usage.total_tokens, + model_name=model_name or TEXT_MODEL_NAME, + cost=getattr(chunk.usage, 'cost', None) + ) + else: + token_tracker.record_usage( + user_id=user_id, + operation_type=operation_type, + prompt_tokens=chunk.usage.prompt_tokens, + completion_tokens=chunk.usage.completion_tokens, + total_tokens=chunk.usage.total_tokens, + model_name=model_name or TEXT_MODEL_NAME, + cost=getattr(chunk.usage, 'cost', None) + ) + except Exception as e: + logger.warning(f"Failed to record streaming token usage: {e}") + + # Process content delta + if chunk.choices and chunk.choices[0].delta.content: + content = chunk.choices[0].delta.content + content_buffer += content + + # Process the buffer to detect and handle thinking tags + while True: + if not in_thinking: + # Look for opening thinking tag + think_start = re.search(r'', content_buffer, re.IGNORECASE) + if think_start: + # Send any content before the thinking tag + before_thinking = content_buffer[:think_start.start()] + if before_thinking: + yield f"data: {json.dumps({'delta': before_thinking})}\n\n" + + # Start capturing thinking content + in_thinking = True + content_buffer = content_buffer[think_start.end():] + thinking_buffer = "" + else: + # No thinking tag found, send accumulated content + if content_buffer: + yield f"data: {json.dumps({'delta': content_buffer})}\n\n" + content_buffer = "" + break + else: + # We're inside a thinking tag, look for closing tag + think_end = re.search(r'', content_buffer, re.IGNORECASE) + if think_end: + # Capture thinking content up to the closing tag + thinking_buffer += content_buffer[:think_end.start()] + + # Send the thinking content as a special type + if thinking_buffer.strip(): + yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n" + + # Continue processing after the closing tag + in_thinking = False + content_buffer = content_buffer[think_end.end():] + thinking_buffer = "" + else: + # Still inside thinking tag, accumulate content + thinking_buffer += content_buffer + content_buffer = "" + break + + # Handle any remaining content + if in_thinking and thinking_buffer: + # Unclosed thinking tag - send as thinking content + yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n" + elif content_buffer: + # Regular content + yield f"data: {json.dumps({'delta': content_buffer})}\n\n" + + # Signal the end of the stream + yield f"data: {json.dumps({'end_of_stream': True})}\n\n" + + + diff --git a/src/services/retention.py b/src/services/retention.py new file mode 100644 index 0000000..35af4d7 --- /dev/null +++ b/src/services/retention.py @@ -0,0 +1,219 @@ +""" +Recording retention and auto-deletion services. +""" + +import os +from datetime import datetime, timedelta +from flask import current_app + +from src.database import db +from src.models import Recording, RecordingTag, Tag + +ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true' +GLOBAL_RETENTION_DAYS = int(os.environ.get('GLOBAL_RETENTION_DAYS', '0')) +DELETION_MODE = os.environ.get('DELETION_MODE', 'full_recording') + + + +def is_recording_exempt_from_deletion(recording): + """ + Check if a recording is exempt from auto-deletion. + + Args: + recording: Recording object to check + + Returns: + Boolean indicating if the recording should be kept + """ + # Manual exemption flag + if recording.deletion_exempt: + return True + + # Check if any of the recording's tags protect it from deletion + # Protection can be indicated by either protect_from_deletion flag OR retention_days == -1 + for tag_assoc in recording.tag_associations: + if tag_assoc.tag.protect_from_deletion: + return True + if tag_assoc.tag.retention_days == -1: + return True + + return False + + + +def get_retention_days_for_recording(recording): + """ + Get the effective retention period for a recording. + Multi-tier system: tag retention (shortest) → global retention + + Tags with retention_days set override the global retention policy. + If multiple tags have retention_days, the SHORTEST period is used (most conservative). + Note: retention_days == -1 indicates infinite retention (protected), which is handled separately. + + Args: + recording: Recording object + + Returns: + Integer days for retention period, or None if no retention applies + """ + # Collect all tag-level retention periods + # Skip -1 (infinite retention/protected) as that's handled in is_recording_exempt_from_deletion + tag_retention_periods = [] + for tag_assoc in recording.tag_associations: + if tag_assoc.tag.retention_days and tag_assoc.tag.retention_days > 0: + tag_retention_periods.append(tag_assoc.tag.retention_days) + + # If any tags have retention periods, use the shortest one (most conservative) + if tag_retention_periods: + return min(tag_retention_periods) + + # Fall back to global retention + if GLOBAL_RETENTION_DAYS > 0: + return GLOBAL_RETENTION_DAYS + + return None + + + +def process_auto_deletion(): + """ + Process auto-deletion of recordings based on retention policies. + This can be called by a scheduled job or admin endpoint. + + Supports per-recording retention via tag-level retention_days overrides. + Tags with retention_days set take precedence over global retention. + + Returns: + Dictionary with deletion statistics + """ + if not ENABLE_AUTO_DELETION: + return {'error': 'Auto-deletion is not enabled'} + + # Check if any retention policy exists (global or tag-level) + has_global_retention = GLOBAL_RETENTION_DAYS > 0 + # We'll check for tag-level retention on a per-recording basis + + if not has_global_retention: + # Still check recordings in case they have tag-level retention + current_app.logger.info("No global retention configured, checking for tag-level retention policies") + + stats = { + 'checked': 0, + 'deleted_audio_only': 0, + 'deleted_full': 0, + 'exempted': 0, + 'skipped_no_retention': 0, + 'errors': 0 + } + + try: + # Get completed recordings to check + # In audio_only mode: Skip recordings where audio was already deleted + # In full_recording mode: Include all (to catch audio-only deletions for full cleanup) + if DELETION_MODE == 'audio_only': + all_recordings = Recording.query.filter( + Recording.status == 'COMPLETED', + Recording.audio_deleted_at.is_(None) # Skip already-deleted audio + ).all() + else: # full_recording mode + all_recordings = Recording.query.filter( + Recording.status == 'COMPLETED' + ).all() + + stats['checked'] = len(all_recordings) + current_time = datetime.utcnow() + + for recording in all_recordings: + try: + # Check if exempt from deletion entirely + if is_recording_exempt_from_deletion(recording): + stats['exempted'] += 1 + continue + + # Get the effective retention period for this specific recording + retention_days = get_retention_days_for_recording(recording) + + if not retention_days: + # No retention policy applies to this recording + stats['skipped_no_retention'] += 1 + continue + + # Calculate the cutoff date for this specific recording + cutoff_date = current_time - timedelta(days=retention_days) + + # Check if recording is past its retention period + if recording.created_at >= cutoff_date: + # Recording is still within retention period + continue + + # Recording is past retention period - process deletion + + # Determine deletion mode + if DELETION_MODE == 'audio_only': + # Delete only the audio file, keep transcription + if recording.audio_path and os.path.exists(recording.audio_path): + current_app.logger.info(f"Recording {recording.id} is past retention ({retention_days} days), deleting audio") + os.remove(recording.audio_path) + current_app.logger.info(f"Auto-deleted audio file: {recording.audio_path}") + recording.audio_deleted_at = datetime.utcnow() + db.session.commit() + stats['deleted_audio_only'] += 1 + else: + # Audio already deleted or doesn't exist - just mark timestamp + if not recording.audio_deleted_at: + recording.audio_deleted_at = datetime.utcnow() + db.session.commit() + current_app.logger.debug(f"Recording {recording.id} audio file not found, marked as deleted") + + else: # full_recording mode + # Check if this is completing a previous audio_only deletion + if recording.audio_deleted_at: + current_app.logger.info(f"Recording {recording.id} has deleted audio (mode changed), completing full deletion") + else: + current_app.logger.info(f"Recording {recording.id} is past retention ({retention_days} days), deleting fully") + + # Delete audio file if it exists + if recording.audio_path and os.path.exists(recording.audio_path): + os.remove(recording.audio_path) + + # Delete associated processing jobs (required due to NOT NULL constraint) + from src.models.processing_job import ProcessingJob + ProcessingJob.query.filter_by(recording_id=recording.id).delete() + + # Delete the database record (cascades to chunks, shares, etc.) + db.session.delete(recording) + db.session.commit() + stats['deleted_full'] += 1 + current_app.logger.info(f"Auto-deleted full recording ID: {recording.id}") + + except Exception as e: + stats['errors'] += 1 + current_app.logger.error(f"Error auto-deleting recording {recording.id}: {e}") + db.session.rollback() + + # After processing recording deletions, clean up orphaned speaker profiles + try: + from src.services.speaker_cleanup import cleanup_orphaned_speakers + speaker_stats = cleanup_orphaned_speakers() + stats['speakers_deleted'] = speaker_stats['speakers_deleted'] + stats['embeddings_cleaned'] = speaker_stats['embeddings_removed'] + stats['speakers_evaluated'] = speaker_stats['speakers_evaluated'] + current_app.logger.info( + f"Speaker cleanup completed: {speaker_stats['speakers_deleted']} speakers deleted, " + f"{speaker_stats['embeddings_removed']} embedding references removed" + ) + except Exception as e: + current_app.logger.error(f"Error during speaker cleanup: {e}", exc_info=True) + stats['speaker_cleanup_error'] = str(e) + + current_app.logger.info(f"Auto-deletion completed: {stats}") + return stats + + except Exception as e: + current_app.logger.error(f"Error during auto-deletion process: {e}", exc_info=True) + return {'error': str(e)} + +# --- API client setup for OpenRouter --- +# Use environment variables from .env + + diff --git a/src/services/speaker.py b/src/services/speaker.py new file mode 100644 index 0000000..7c0bff5 --- /dev/null +++ b/src/services/speaker.py @@ -0,0 +1,217 @@ +""" +Speaker identification and management services. +""" + +import os +import re +from datetime import datetime +from flask import current_app +from flask_login import current_user + +from src.database import db +from src.models import Speaker, SystemSetting +from src.services.llm import call_llm_completion +from src.utils import safe_json_loads + +# NOTE: format_transcription_for_llm is referenced but not defined - needs to be implemented +def format_transcription_for_llm(transcription): + """ + Format transcription for LLM processing. + + TODO: This function needs proper implementation. + If transcription is JSON, extract and format the text. + Otherwise return as-is. + """ + if isinstance(transcription, str): + try: + import json + data = json.loads(transcription) + # If it's JSON diarized format, extract text + if isinstance(data, list): + return '\n'.join([f"[{seg.get('speaker', 'UNKNOWN')}] {seg.get('text', '')}" + for seg in data if 'text' in seg]) + except: + pass + return str(transcription) + +# Import TEXT_MODEL_API_KEY from llm service +from src.services.llm import TEXT_MODEL_API_KEY + + +def update_speaker_usage(speaker_names): + """Helper function to update speaker usage statistics.""" + if not speaker_names or not current_user.is_authenticated: + return + + try: + for name in speaker_names: + name = name.strip() + if not name: + continue + + speaker = Speaker.query.filter_by(user_id=current_user.id, name=name).first() + if speaker: + speaker.use_count += 1 + speaker.last_used = datetime.utcnow() + else: + # Create new speaker + speaker = Speaker( + name=name, + user_id=current_user.id, + use_count=1, + created_at=datetime.utcnow(), + last_used=datetime.utcnow() + ) + db.session.add(speaker) + + db.session.commit() + except Exception as e: + current_app.logger.error(f"Error updating speaker usage: {e}") + db.session.rollback() + + + +def identify_speakers_from_text(transcription): + """ + Uses an LLM to identify speakers from a transcription. + """ + if not TEXT_MODEL_API_KEY: + raise ValueError("TEXT_MODEL_API_KEY not configured.") + + # The transcription passed here could be JSON, so we format it. + formatted_transcription = format_transcription_for_llm(transcription) + + # Extract existing speaker labels (e.g., SPEAKER_00, SPEAKER_01) in order of appearance + all_labels = re.findall(r'\[(SPEAKER_\d+)\]', formatted_transcription) + seen = set() + speaker_labels = [x for x in all_labels if not (x in seen or seen.add(x))] + + if not speaker_labels: + return {} + + # Get configurable transcript length limit + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + # No limit + transcript_text = formatted_transcription + else: + transcript_text = formatted_transcription[:transcript_limit] + + prompt = f"""Analyze the following transcription and identify the names of the speakers. The speakers are labeled as {', '.join(speaker_labels)}. Based on the context of the conversation, determine the most likely name for each speaker label. + +Transcription: +--- +{transcript_text} +--- + +Respond with a single JSON object where keys are the speaker labels (e.g., "SPEAKER_00") and values are the identified full names. If a name cannot be determined, use the value "Unknown". + +Example: +{{ + "SPEAKER_00": "John Doe", + "SPEAKER_01": "Jane Smith", + "SPEAKER_02": "Unknown" +}} + +JSON Response: +""" + + try: + completion = call_llm_completion( + messages=[ + {"role": "system", "content": "You are an expert in analyzing conversation transcripts to identify speakers. Your response must be a single, valid JSON object."}, + {"role": "user", "content": prompt} + ], + temperature=0.2 + ) + response_content = completion.choices[0].message.content + speaker_map = safe_json_loads(response_content, {}) + + # Post-process the map to replace "Unknown" with an empty string + for speaker_label, identified_name in speaker_map.items(): + if identified_name.strip().lower() == "unknown": + speaker_map[speaker_label] = "" + + return speaker_map + except Exception as e: + current_app.logger.error(f"Error calling LLM for speaker identification: {e}") + raise + + +def identify_unidentified_speakers_from_text(transcription, unidentified_speakers): + """ + Uses an LLM to identify only the unidentified speakers from a transcription. + """ + if not TEXT_MODEL_API_KEY: + raise ValueError("TEXT_MODEL_API_KEY not configured.") + + # The transcription passed here could be JSON, so we format it. + formatted_transcription = format_transcription_for_llm(transcription) + + if not unidentified_speakers: + return {} + + # Get configurable transcript length limit + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + # No limit + transcript_text = formatted_transcription + else: + transcript_text = formatted_transcription[:transcript_limit] + + prompt = f"""Analyze the following conversation transcript and identify the names of the UNIDENTIFIED speakers based on the context and content of their dialogue. + +The speakers that need to be identified are: {', '.join(unidentified_speakers)} + +Look for clues in the conversation such as: +- Names mentioned by other speakers when addressing someone +- Self-introductions or references to their own name +- Context clues about roles, relationships, or positions +- Any direct mentions of names in the dialogue + +Here is the complete conversation transcript: + +{transcript_text} + +Based on the conversation above, identify the most likely real names for the unidentified speakers. Pay close attention to how speakers address each other and any names that are mentioned in the dialogue. + +Respond with a single JSON object where keys are the speaker labels (e.g., "SPEAKER_01") and values are the identified full names. If a name cannot be determined from the conversation context, use an empty string "". + +Example format: +{{ + "SPEAKER_01": "Jane Smith", + "SPEAKER_03": "Bob Johnson", + "SPEAKER_05": "" +}} + +JSON Response: +""" + + try: + current_app.logger.info(f"[Auto-Identify] Calling LLM to identify speakers: {unidentified_speakers}") + current_app.logger.info(f"[Auto-Identify] Transcript excerpt (first 500 chars): {transcript_text[:500]}") + + completion = call_llm_completion( + messages=[ + {"role": "system", "content": "You are an expert in analyzing conversation transcripts to identify speakers based on contextual clues in the dialogue. Analyze the conversation carefully to find names mentioned when speakers address each other or introduce themselves. Your response must be a single, valid JSON object containing only the requested speaker identifications."}, + {"role": "user", "content": prompt} + ], + temperature=0.2 + ) + response_content = completion.choices[0].message.content + current_app.logger.info(f"[Auto-Identify] LLM Raw Response: {response_content}") + + speaker_map = safe_json_loads(response_content, {}) + current_app.logger.info(f"[Auto-Identify] Parsed speaker_map: {speaker_map}") + + # Post-process the map to replace "Unknown" with an empty string + for speaker_label, identified_name in speaker_map.items(): + if identified_name and identified_name.strip().lower() in ["unknown", "n/a", "not available", "unclear"]: + speaker_map[speaker_label] = "" + + current_app.logger.info(f"[Auto-Identify] Final speaker_map after post-processing: {speaker_map}") + return speaker_map + except Exception as e: + current_app.logger.error(f"Error calling LLM for speaker identification: {e}", exc_info=True) + raise + diff --git a/src/services/speaker_cleanup.py b/src/services/speaker_cleanup.py new file mode 100644 index 0000000..80c147a --- /dev/null +++ b/src/services/speaker_cleanup.py @@ -0,0 +1,295 @@ +""" +Speaker cleanup service for managing orphaned speaker voice profiles. + +This module provides automatic cleanup of speaker records when their associated +recordings are deleted through auto-deletion or manual deletion processes. + +By default, speaker profiles (including voice embeddings) are preserved even +when all their recordings are deleted, since embeddings are aggregated and +represent hours of manual identification work. Set DELETE_ORPHANED_SPEAKERS=true +to enable automatic cleanup of speakers with no remaining recordings. +""" + +import os +import logging +import json +from datetime import datetime +from sqlalchemy import exists +from src.database import db +from src.models import Speaker, SpeakerSnippet, Recording + +logger = logging.getLogger(__name__) + + +def cleanup_orphaned_speakers(dry_run=False): + """ + Clean up speaker records that no longer have any associated recordings. + + Only runs if DELETE_ORPHANED_SPEAKERS=true is set. By default, speaker + profiles are preserved because voice embeddings are aggregated values + that can't be reconstructed from recordings alone. + + A speaker is considered orphaned when: + - It has no SpeakerSnippet records + - Its embeddings_history contains no valid recording references + + Args: + dry_run (bool): If True, only report what would be deleted without actually deleting + + Returns: + dict: Statistics about cleanup operation + { + 'speakers_deleted': int, + 'embeddings_removed': int, + 'speakers_evaluated': int, + 'orphaned_speakers': list of dict (if dry_run=True) + } + """ + delete_orphans = os.environ.get('DELETE_ORPHANED_SPEAKERS', 'false').lower() in ('true', '1', 'yes') + + if not delete_orphans: + logger.debug("Speaker cleanup skipped (DELETE_ORPHANED_SPEAKERS is not enabled)") + return { + 'speakers_deleted': 0, + 'embeddings_removed': 0, + 'speakers_evaluated': 0, + 'orphaned_speakers': [] + } + + logger.info("Starting speaker cleanup process (dry_run=%s)", dry_run) + + stats = { + 'speakers_deleted': 0, + 'embeddings_removed': 0, + 'speakers_evaluated': 0, + 'orphaned_speakers': [] + } + + try: + # Clean embeddings_history references first + embeddings_cleaned = clean_embeddings_history_references(dry_run=dry_run) + stats['embeddings_removed'] = embeddings_cleaned + + # Find and process orphaned speakers + orphaned_speaker_ids = get_orphaned_speakers() + stats['speakers_evaluated'] = Speaker.query.count() + + if not orphaned_speaker_ids: + logger.info("No orphaned speakers found") + return stats + + logger.info("Found %d orphaned speaker(s)", len(orphaned_speaker_ids)) + + if dry_run: + # Report what would be deleted + for speaker_id in orphaned_speaker_ids: + speaker = Speaker.query.get(speaker_id) + if speaker: + stats['orphaned_speakers'].append({ + 'id': speaker.id, + 'name': speaker.name, + 'user_id': speaker.user_id, + 'embedding_count': speaker.embedding_count + }) + logger.info("Dry run: Would delete %d speakers", len(orphaned_speaker_ids)) + else: + # Actually delete orphaned speakers + for speaker_id in orphaned_speaker_ids: + speaker = Speaker.query.get(speaker_id) + if speaker: + logger.debug( + "Deleting orphaned speaker: id=%d, name='%s', user_id=%d, embedding_count=%d", + speaker.id, speaker.name, speaker.user_id, speaker.embedding_count or 0 + ) + db.session.delete(speaker) + stats['speakers_deleted'] += 1 + + # Commit all deletions + db.session.commit() + logger.info("Speaker cleanup completed: %d speakers deleted", stats['speakers_deleted']) + + # Warning if large number deleted + if stats['speakers_deleted'] >= 50: + logger.warning( + "Large number of speakers deleted (%d). Review cleanup logic if unexpected.", + stats['speakers_deleted'] + ) + + return stats + + except Exception as e: + db.session.rollback() + logger.error("Error during speaker cleanup: %s", str(e), exc_info=True) + raise + + +def clean_embeddings_history_references(dry_run=False): + """ + Clean embeddings_history JSON fields to remove references to deleted recordings. + + Scans all speakers' embeddings_history and removes entries where the + recording_id no longer exists in the database. + + Args: + dry_run (bool): If True, only count what would be cleaned + + Returns: + int: Number of embedding references removed + """ + logger.debug("Cleaning embeddings_history references (dry_run=%s)", dry_run) + + references_removed = 0 + + try: + # Get all speakers with embeddings_history + speakers = Speaker.query.filter(Speaker.embeddings_history.isnot(None)).all() + + for speaker in speakers: + try: + # Parse embeddings_history JSON + if not speaker.embeddings_history: + continue + + history = speaker.embeddings_history if isinstance(speaker.embeddings_history, list) else json.loads(speaker.embeddings_history) + + if not history or not isinstance(history, list): + continue + + # Filter out entries with deleted recording_ids + cleaned_history = [] + for entry in history: + if not isinstance(entry, dict) or 'recording_id' not in entry: + continue + + recording_id = entry['recording_id'] + + # Check if recording still exists + recording_exists = db.session.query( + exists().where(Recording.id == recording_id) + ).scalar() + + if recording_exists: + cleaned_history.append(entry) + else: + references_removed += 1 + logger.debug( + "Removing deleted recording reference: speaker_id=%d, recording_id=%d", + speaker.id, recording_id + ) + + # Update speaker if history changed + if len(cleaned_history) < len(history): + if not dry_run: + speaker.embeddings_history = cleaned_history + logger.debug( + "Updated speaker %d embeddings_history: %d -> %d entries", + speaker.id, len(history), len(cleaned_history) + ) + + except (json.JSONDecodeError, TypeError, KeyError) as e: + logger.warning( + "Error processing embeddings_history for speaker %d: %s", + speaker.id, str(e) + ) + continue + + if not dry_run and references_removed > 0: + db.session.commit() + logger.debug("Cleaned %d embedding references", references_removed) + + return references_removed + + except Exception as e: + db.session.rollback() + logger.error("Error cleaning embeddings_history: %s", str(e), exc_info=True) + raise + + +def get_orphaned_speakers(user_id=None): + """ + Get list of speaker IDs that are orphaned (no associated recordings). + + A speaker is orphaned when: + - It has no SpeakerSnippet records + - After cleaning embeddings_history, it has no valid recording references + + Args: + user_id (int, optional): Filter to specific user's speakers + + Returns: + list: List of speaker IDs that are orphaned + """ + logger.debug("Finding orphaned speakers (user_id=%s)", user_id) + + # Query for speakers with no snippets + query = Speaker.query.filter( + ~exists().where(SpeakerSnippet.speaker_id == Speaker.id) + ) + + if user_id is not None: + query = query.filter(Speaker.user_id == user_id) + + speakers_without_snippets = query.all() + + orphaned_ids = [] + + for speaker in speakers_without_snippets: + # Check if embeddings_history has any valid recording references + has_valid_recordings = False + + if speaker.embeddings_history: + try: + history = speaker.embeddings_history if isinstance(speaker.embeddings_history, list) else json.loads(speaker.embeddings_history) + + if history and isinstance(history, list): + for entry in history: + if isinstance(entry, dict) and 'recording_id' in entry: + recording_id = entry['recording_id'] + + # Check if this recording exists + recording_exists = db.session.query( + exists().where(Recording.id == recording_id) + ).scalar() + + if recording_exists: + has_valid_recordings = True + break + except (json.JSONDecodeError, TypeError, KeyError): + pass + + # If no snippets AND no valid recording references, it's orphaned + if not has_valid_recordings: + orphaned_ids.append(speaker.id) + logger.debug( + "Speaker %d ('%s') is orphaned: no snippets, no valid recordings", + speaker.id, speaker.name + ) + + return orphaned_ids + + +def get_speaker_cleanup_statistics(): + """ + Get statistics about speaker data for monitoring. + + Returns: + dict: Statistics about speakers + { + 'total_speakers': int, + 'speakers_with_snippets': int, + 'speakers_with_embeddings': int, + 'potential_orphans': int + } + """ + stats = { + 'total_speakers': Speaker.query.count(), + 'speakers_with_snippets': db.session.query(Speaker.id).join( + SpeakerSnippet, Speaker.id == SpeakerSnippet.speaker_id + ).distinct().count(), + 'speakers_with_embeddings': Speaker.query.filter( + Speaker.average_embedding.isnot(None) + ).count(), + 'potential_orphans': len(get_orphaned_speakers()) + } + + return stats diff --git a/src/services/speaker_embedding_matcher.py b/src/services/speaker_embedding_matcher.py new file mode 100644 index 0000000..eab91bc --- /dev/null +++ b/src/services/speaker_embedding_matcher.py @@ -0,0 +1,453 @@ +""" +Speaker Embedding Matcher Service. + +This service handles voice embedding comparison and matching for speaker identification. +It provides functions to: +- Serialize/deserialize speaker embeddings for database storage +- Calculate cosine similarity between voice embeddings +- Find matching speakers based on voice similarity +- Update speaker profiles with new embeddings +- Calculate confidence scores for speaker profiles + +Uses 256-dimensional embeddings from WhisperX diarization. +""" + +import json +import numpy as np +from datetime import datetime +try: + from sklearn.metrics.pairwise import cosine_similarity +except ImportError: + cosine_similarity = None +from src.database import db +from src.models import Speaker + + +def serialize_embedding(embedding_array): + """ + Convert numpy array or list to binary for database storage. + + Args: + embedding_array: numpy array or list of floats (256 dimensions) + + Returns: + bytes: Binary representation (1,024 bytes for 256 × float32) + """ + return np.array(embedding_array, dtype=np.float32).tobytes() + + +def deserialize_embedding(binary_data): + """ + Convert binary data back to numpy array. + + Args: + binary_data: bytes from database (1,024 bytes) + + Returns: + numpy.ndarray: 256-dimensional float32 array + """ + return np.frombuffer(binary_data, dtype=np.float32) + + +def calculate_similarity(embedding1, embedding2): + """ + Compute cosine similarity between two 256-dimensional voice embeddings. + + Args: + embedding1: numpy array, list, or binary data + embedding2: numpy array, list, or binary data + + Returns: + float: Similarity score (0-1, where 1 is identical) + """ + # Convert to numpy arrays if needed + e1 = np.array(embedding1, dtype=np.float32).reshape(1, -1) + e2 = np.array(embedding2, dtype=np.float32).reshape(1, -1) + + # Cosine similarity returns values from -1 to 1 + # For voice embeddings, we typically see 0.6-0.99 range + return float(cosine_similarity(e1, e2)[0][0]) + + +def find_matching_speakers(target_embedding, user_id, threshold=0.70): + """ + Find speakers matching a target voice embedding for a specific user. + + Args: + target_embedding: The voice embedding to match against (256-dim array/list) + user_id: User ID to search within + threshold: Minimum similarity score (0-1, default 0.70 = 70%) + + Returns: + list: Sorted list of matching speakers with scores + [{'speaker_id': 5, 'name': 'John', 'similarity': 85.3, 'confidence': 0.92}, ...] + """ + # Get all speakers with embeddings for this user + speakers = Speaker.query.filter_by(user_id=user_id).filter( + Speaker.average_embedding.isnot(None) + ).all() + + if not speakers: + return [] + + matches = [] + for speaker in speakers: + try: + # Deserialize and compare + speaker_emb = deserialize_embedding(speaker.average_embedding) + similarity = calculate_similarity(target_embedding, speaker_emb) + + if similarity >= threshold: + matches.append({ + 'speaker_id': speaker.id, + 'name': speaker.name, + 'similarity': round(similarity * 100, 1), # Convert to percentage + 'confidence': speaker.confidence_score or 0.5, + 'embedding_count': speaker.embedding_count or 0 + }) + except Exception as e: + # Skip speakers with corrupted embeddings + continue + + # Sort by similarity (highest first) + return sorted(matches, key=lambda x: x['similarity'], reverse=True) + + +def update_speaker_embedding(speaker, new_embedding, recording_id): + """ + Update a speaker's average embedding and history with a new sample. + + Uses weighted moving average to update the profile: + - New embeddings get 30% weight + - Existing average gets 70% weight + + Args: + speaker: Speaker model instance + new_embedding: New voice embedding (256-dim array/list) + recording_id: ID of the recording this embedding came from + + Returns: + float: Similarity between new embedding and previous average (None if first) + """ + new_emb_array = np.array(new_embedding, dtype=np.float32) + similarity_to_avg = None + + if speaker.average_embedding is None: + # First embedding for this speaker + speaker.average_embedding = serialize_embedding(new_emb_array) + speaker.embedding_count = 1 + speaker.embeddings_history = [{ + 'recording_id': recording_id, + 'timestamp': datetime.utcnow().isoformat(), + 'similarity': 100.0 # Perfect match to itself + }] + else: + # Update existing average + current_avg = deserialize_embedding(speaker.average_embedding) + similarity_to_avg = calculate_similarity(new_emb_array, current_avg) + + # Weighted average: 30% new, 70% existing + # This prevents sudden shifts while still adapting to voice changes + weight = 0.3 + updated_avg = (1 - weight) * current_avg + weight * new_emb_array + + speaker.average_embedding = serialize_embedding(updated_avg) + speaker.embedding_count += 1 + + # Add to history (keep last 10 entries) + history = speaker.embeddings_history or [] + history.append({ + 'recording_id': recording_id, + 'timestamp': datetime.utcnow().isoformat(), + 'similarity': round(similarity_to_avg * 100, 1) + }) + speaker.embeddings_history = history[-10:] # Keep most recent 10 + + # Recalculate confidence score + speaker.confidence_score = calculate_confidence(speaker) + + # Commit changes + db.session.commit() + + return similarity_to_avg + + +def calculate_confidence(speaker): + """ + Calculate confidence score based on embedding consistency. + + Confidence is based on: + - Number of samples (more is better) + - Consistency of embeddings (high similarity scores = high confidence) + + Args: + speaker: Speaker model instance with embeddings_history + + Returns: + float: Confidence score (0-1) + """ + if speaker.embedding_count is None or speaker.embedding_count < 1: + return 0.0 + + if speaker.embedding_count == 1: + return 0.5 # Medium confidence with single sample + + # Get recent similarity scores from history + history = speaker.embeddings_history or [] + if len(history) < 2: + return 0.5 + + # Use last 5 samples + recent_history = history[-5:] + similarities = [h.get('similarity', 0) / 100.0 for h in recent_history] + + # Average similarity to the profile + avg_similarity = sum(similarities) / len(similarities) + + # Penalize if we have very few samples + sample_factor = min(1.0, speaker.embedding_count / 5.0) + + # Confidence = average similarity × sample factor + confidence = avg_similarity * sample_factor + + return min(1.0, max(0.0, confidence)) + + +def get_speaker_voice_profile_summary(speaker): + """ + Get a human-readable summary of a speaker's voice profile. + + Args: + speaker: Speaker model instance + + Returns: + dict: Profile summary with statistics and status + """ + if not speaker.average_embedding: + return { + 'has_profile': False, + 'message': 'No voice profile yet' + } + + return { + 'has_profile': True, + 'embedding_count': speaker.embedding_count or 0, + 'confidence_score': speaker.confidence_score or 0.0, + 'confidence_level': _get_confidence_level(speaker.confidence_score), + 'last_updated': speaker.embeddings_history[-1]['timestamp'] if speaker.embeddings_history else None, + 'recordings': len(speaker.embeddings_history or []) + } + + +def _get_confidence_level(score): + """ + Convert numeric confidence score to human-readable level. + + Args: + score: float (0-1) + + Returns: + str: 'low', 'medium', or 'high' + """ + if score is None or score < 0.6: + return 'low' + elif score < 0.8: + return 'medium' + else: + return 'high' + + +# Threshold mapping for auto-labelling +AUTO_LABEL_THRESHOLDS = { + 'low': 0.3, # Aggressive, may have more false positives + 'medium': 0.6, # Default, balanced approach + 'high': 0.8 # Only auto-label well-established speakers +} + +# Base similarity threshold for finding matches (70%) +BASE_SIMILARITY_THRESHOLD = 0.70 + +# Ambiguity threshold: if top 2 matches are within 5% similarity, skip +AMBIGUITY_MARGIN = 0.05 + + +def apply_auto_speaker_labels(recording, user): + """ + Automatically label speakers in a recording based on voice profile matching. + + This function matches speaker embeddings from the recording against the user's + saved speaker profiles and returns a mapping of generic labels to speaker names. + + Args: + recording: Recording model instance with speaker_embeddings + user: User model instance with auto_speaker_labelling settings + + Returns: + dict: Mapping of {SPEAKER_XX: speaker_name} for matched speakers, + or empty dict if auto-labelling is disabled or no matches found + """ + # Check if user has auto-labelling enabled + if not user.auto_speaker_labelling: + return {} + + # Check if recording has speaker embeddings + if not recording.speaker_embeddings: + return {} + + # Get the user's threshold setting + threshold_setting = user.auto_speaker_labelling_threshold or 'medium' + confidence_threshold = AUTO_LABEL_THRESHOLDS.get(threshold_setting, AUTO_LABEL_THRESHOLDS['medium']) + + speaker_map = {} + embeddings = recording.speaker_embeddings + + for speaker_label, embedding_data in embeddings.items(): + # embedding_data should be a list of floats (256 dimensions) + if not embedding_data or not isinstance(embedding_data, list): + continue + + # Find matching speakers with base similarity threshold + matches = find_matching_speakers( + target_embedding=embedding_data, + user_id=user.id, + threshold=BASE_SIMILARITY_THRESHOLD + ) + + if not matches: + continue + + # Check if the best match exceeds the user's confidence threshold + best_match = matches[0] + best_similarity = best_match['similarity'] / 100.0 # Convert from percentage + + if best_similarity < confidence_threshold: + continue + + # Check for ambiguity: if top 2 matches are within 5% similarity, skip + if len(matches) >= 2: + second_similarity = matches[1]['similarity'] / 100.0 + if (best_similarity - second_similarity) <= AMBIGUITY_MARGIN: + # Ambiguous - top 2 matches too close + continue + + # We have a clear winner - add to speaker map + speaker_map[speaker_label] = best_match['name'] + + return speaker_map + + +def apply_speaker_names_to_transcription(recording, speaker_map): + """ + Apply speaker name mappings to a recording's transcription. + + This function updates the transcription JSON by replacing generic speaker + labels (SPEAKER_00, SPEAKER_01, etc.) with actual speaker names, and + updates the recording's participants list. + + Args: + recording: Recording model instance with transcription + speaker_map: Dict mapping {SPEAKER_XX: speaker_name} + + Returns: + bool: True if changes were made, False otherwise + """ + import logging + logger = logging.getLogger(__name__) + + if not speaker_map or not recording.transcription: + logger.warning(f"Auto-label: No speaker_map or transcription (map={bool(speaker_map)}, trans={bool(recording.transcription)})") + return False + + try: + # Parse transcription as JSON array: [{speaker, sentence, start_time, end_time}, ...] + segments = json.loads(recording.transcription) + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"Auto-label: Failed to parse transcription as JSON: {e}") + return False + + if not isinstance(segments, list) or not segments: + logger.warning(f"Auto-label: Transcription not in expected array format") + return False + + # Track which speakers were renamed + renamed_speakers = set() + + # Update speaker labels in segments + for segment in segments: + if 'speaker' in segment and segment['speaker'] in speaker_map: + segment['speaker'] = speaker_map[segment['speaker']] + renamed_speakers.add(segment['speaker']) + + if not renamed_speakers: + logger.warning(f"Auto-label: No speakers matched in segments") + return False + + logger.info(f"Auto-label: Applied names to {len(renamed_speakers)} speakers: {renamed_speakers}") + + # Update participants field + all_speakers = set(s.get('speaker') for s in segments if 'speaker' in s) + if all_speakers: + recording.participants = ', '.join(sorted(all_speakers)) + + # Save updated transcription + recording.transcription = json.dumps(segments) + db.session.commit() + + return True + + +def update_speaker_profiles_from_recording(recording, speaker_map, user): + """ + Update speaker voice profiles with new embeddings from a recording. + + For each successfully matched speaker, this function updates their + average embedding and increments their usage count. + + Args: + recording: Recording model instance with speaker_embeddings + speaker_map: Dict mapping {SPEAKER_XX: speaker_name} that was applied + user: User model instance + + Returns: + int: Number of speaker profiles updated + """ + if not speaker_map or not recording.speaker_embeddings: + return 0 + + updated_count = 0 + embeddings = recording.speaker_embeddings + + for speaker_label, speaker_name in speaker_map.items(): + if speaker_label not in embeddings: + continue + + embedding_data = embeddings[speaker_label] + if not embedding_data or not isinstance(embedding_data, list): + continue + + # Find the speaker profile + speaker = Speaker.query.filter_by( + user_id=user.id, + name=speaker_name + ).first() + + if not speaker: + continue + + try: + # Update the speaker's embedding with the new sample + update_speaker_embedding(speaker, embedding_data, recording.id) + + # Update usage tracking + speaker.use_count = (speaker.use_count or 0) + 1 + speaker.last_used = datetime.utcnow() + + updated_count += 1 + except Exception: + # Skip if embedding update fails + continue + + if updated_count > 0: + db.session.commit() + + return updated_count diff --git a/src/services/speaker_identification.py b/src/services/speaker_identification.py new file mode 100644 index 0000000..728c534 --- /dev/null +++ b/src/services/speaker_identification.py @@ -0,0 +1,228 @@ +""" +Shared speaker identification service. + +Provides LLM-based speaker identification from transcript context, +used by both the web UI (recordings.py) and REST API (api_v1.py). +""" + +import os +import re +import json +from flask import current_app + + +def identify_speakers_from_transcript(transcription_data, user_id): + """ + Identify speakers in a transcription using an LLM. + + Args: + transcription_data: List of transcript segments (already parsed JSON). + user_id: Current user's ID (for token tracking). + + Returns: + dict mapping original speaker labels to identified names. + Values are empty string "" for unidentified speakers. + + Raises: + ValueError: If LLM API key is not configured. + Exception: On LLM call failure. + """ + from src.services.llm import call_llm_completion + from src.utils import safe_json_loads + from src.models import SystemSetting + + # Extract unique speakers in order of appearance + seen_speakers = set() + unique_speakers = [] + for segment in transcription_data: + speaker = segment.get('speaker') + if speaker and speaker not in seen_speakers: + seen_speakers.add(speaker) + unique_speakers.append(speaker) + + if not unique_speakers: + return {} + + # Normalize all labels to SPEAKER_XX format for the LLM + speaker_to_label = {} + for idx, speaker in enumerate(unique_speakers): + speaker_to_label[speaker] = f'SPEAKER_{str(idx).zfill(2)}' + + # Create temporary transcript with normalized labels + formatted_lines = [] + for segment in transcription_data: + original_speaker = segment.get('speaker') + label = speaker_to_label.get(original_speaker, 'Unknown Speaker') + sentence = segment.get('sentence', '') + formatted_lines.append(f"[{label}]: {sentence}") + formatted_transcription = "\n".join(formatted_lines) + + speaker_labels = list(speaker_to_label.values()) + + current_app.logger.info(f"[Auto-Identify] Formatted transcript (first 500 chars): {formatted_transcription[:500]}") + current_app.logger.info(f"[Auto-Identify] Speaker labels: {speaker_labels}") + + # Apply configurable transcript length limit + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + transcript_text = formatted_transcription + else: + transcript_text = formatted_transcription[:transcript_limit] + + prompt = f"""Analyse cette transcription de conversation et identifie les noms des locuteurs à partir du contexte et du contenu de leurs dialogues. + +Les locuteurs à identifier sont : {', '.join(speaker_labels)} + +Indices à chercher : +- Noms mentionnés par d'autres locuteurs quand ils s'adressent à quelqu'un +- Présentations ou références à son propre nom +- Indices contextuels sur les rôles, relations ou postes +- Toute mention directe de noms dans le dialogue + +Transcription complète : + +{transcript_text} + +À partir de cette conversation, identifie les noms les plus probables pour chaque locuteur. Porte une attention particulière à la façon dont les locuteurs s'adressent les uns aux autres. + +Réponds avec un seul objet JSON où les clés sont les étiquettes de locuteurs (ex. "SPEAKER_01") et les valeurs sont les noms complets identifiés. Si un nom ne peut pas être déterminé, utilise une chaîne vide "". + +Exemple : +{{ + "SPEAKER_01": "Marie Lavoie", + "SPEAKER_03": "Jean Tremblay", + "SPEAKER_05": "" +}} + +Réponse JSON : +""" + + current_app.logger.info("[Auto-Identify] Calling LLM") + + use_schema = os.environ.get('AUTO_IDENTIFY_RESPONSE_SCHEMA', '').strip() in ('1', 'true', 'yes') + system_msg = ( + "You are an expert in analyzing conversation transcripts to identify speakers " + "based on contextual clues in the dialogue. Analyze the conversation carefully " + "to find names mentioned when speakers address each other or introduce themselves. " + "Your response must be a single, valid JSON object containing only the requested " + "speaker identifications." + ) + + response_content = None + if use_schema: + # Build JSON schema response format with constrained keys + schema_properties = {label: {"type": "string"} for label in speaker_labels} + schema_response_format = { + "type": "json_schema", + "json_schema": { + "name": "speaker_identification", + "strict": True, + "schema": { + "type": "object", + "properties": schema_properties, + "required": speaker_labels, + "additionalProperties": False + } + } + } + schema_prompt = prompt + f"\n\nIMPORTANT: Your JSON response must contain exactly these keys: {', '.join(speaker_labels)}" + try: + current_app.logger.info("[Auto-Identify] Trying json_schema response format") + completion = call_llm_completion( + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": schema_prompt} + ], + temperature=0.2, + response_format=schema_response_format, + user_id=user_id, + operation_type='speaker_identification' + ) + response_content = completion.choices[0].message.content + current_app.logger.info(f"[Auto-Identify] LLM Raw Response (schema mode): {response_content}") + except Exception as schema_err: + current_app.logger.warning(f"[Auto-Identify] json_schema mode failed, falling back to json_object: {schema_err}") + response_content = None + + if response_content is None: + completion = call_llm_completion( + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": prompt} + ], + temperature=0.2, + user_id=user_id, + operation_type='speaker_identification' + ) + response_content = completion.choices[0].message.content + current_app.logger.info(f"[Auto-Identify] LLM Raw Response: {response_content}") + + identified_map = safe_json_loads(response_content, {}) + current_app.logger.info(f"[Auto-Identify] Parsed identified_map: {identified_map}") + + # --- Sanitize identified_map --- + identified_map = _sanitize_identified_map(identified_map, speaker_labels) + current_app.logger.info(f"[Auto-Identify] Sanitized identified_map: {identified_map}") + + # Map back to original speaker labels + final_speaker_map = {} + for original_speaker, temp_label in speaker_to_label.items(): + if temp_label in identified_map: + final_speaker_map[original_speaker] = identified_map[temp_label] + + current_app.logger.info(f"[Auto-Identify] Final speaker_map: {final_speaker_map}") + return final_speaker_map + + +def _sanitize_identified_map(identified_map, speaker_labels): + """ + Clean up LLM output: handle inverted maps, strip commentary, + clear placeholders, etc. + """ + speaker_label_re = re.compile(r'^SPEAKER_\d{2}$') + + # Detect inverted map ({name: "SPEAKER_XX"}) and flip it + if identified_map and all( + speaker_label_re.match(str(v)) for v in identified_map.values() if v + ) and not any(speaker_label_re.match(str(k)) for k in identified_map.keys()): + current_app.logger.warning("[Auto-Identify] Detected inverted map, flipping keys/values") + identified_map = {v: k for k, v in identified_map.items() if v} + + sanitized = {} + for speaker_label, identified_name in identified_map.items(): + # Skip entries whose key isn't a valid SPEAKER_XX label + if not speaker_label_re.match(str(speaker_label)): + continue + if not identified_name or not isinstance(identified_name, str): + sanitized[speaker_label] = "" + continue + + name = identified_name.strip() + + # Clear generic placeholders + if name.lower() in ["unknown", "n/a", "not available", "unclear", "unidentified", ""]: + sanitized[speaker_label] = "" + continue + + # Clear label-to-label entries (e.g. "SPEAKER_01": "SPEAKER_02") + if speaker_label_re.match(name): + sanitized[speaker_label] = "" + continue + + # Strip parenthetical content: "John (the host)" -> "John" + name = re.sub(r'\s*\([^)]*\)', '', name).strip() + + # Take first name segment before comma, semicolon, or slash + name = re.split(r'[,;/]', name)[0].strip() + + # Collapse whitespace + name = re.sub(r'\s+', ' ', name) + + # Final check: if result still matches SPEAKER_XX, clear it + if speaker_label_re.match(name) or not name: + sanitized[speaker_label] = "" + continue + + sanitized[speaker_label] = name + + return sanitized diff --git a/src/services/speaker_merge.py b/src/services/speaker_merge.py new file mode 100644 index 0000000..c5300bc --- /dev/null +++ b/src/services/speaker_merge.py @@ -0,0 +1,226 @@ +""" +Speaker Merge Service. + +This service handles merging multiple speaker profiles into one. +Useful when users accidentally create duplicate speakers for the same person. + +When speakers are merged: +- Voice embeddings are combined using weighted average +- All snippets are transferred to the target speaker +- Usage statistics are combined +- Source speakers are deleted +- Confidence score is recalculated +""" + +import numpy as np +from src.database import db +from src.models import Speaker, SpeakerSnippet +from src.services.speaker_embedding_matcher import ( + serialize_embedding, + deserialize_embedding, + calculate_confidence +) + + +def merge_speakers(target_id, source_ids, user_id): + """ + Merge multiple speaker profiles into one target speaker. + + All embeddings, snippets, and usage data from source speakers are + combined into the target speaker. Source speakers are then deleted. + + Args: + target_id: ID of the speaker to keep (receives all merged data) + source_ids: List of speaker IDs to merge into target + user_id: ID of the user (for security check) + + Returns: + Speaker: The updated target speaker + + Raises: + ValueError: If speakers don't exist or don't belong to user + """ + # Validate target speaker + target = Speaker.query.filter_by(id=target_id, user_id=user_id).first() + if not target: + raise ValueError(f"Target speaker {target_id} not found or doesn't belong to user") + + # Validate source speakers + sources = Speaker.query.filter( + Speaker.id.in_(source_ids), + Speaker.user_id == user_id + ).all() + + if len(sources) == 0: + raise ValueError("No valid source speakers found") + + if len(sources) != len(source_ids): + raise ValueError("Some source speakers don't exist or don't belong to user") + + # Can't merge a speaker with itself + if target_id in source_ids: + raise ValueError("Cannot merge a speaker with itself") + + # Combine embeddings + _combine_embeddings(target, sources) + + # Transfer snippets + for source in sources: + SpeakerSnippet.query.filter_by(speaker_id=source.id).update( + {'speaker_id': target_id} + ) + + # Combine usage statistics + for source in sources: + target.use_count += source.use_count + + # Update last_used to most recent + if source.last_used and (not target.last_used or source.last_used > target.last_used): + target.last_used = source.last_used + + # Combine embedding histories + if source.embeddings_history: + target_history = target.embeddings_history or [] + source_history = source.embeddings_history or [] + combined_history = target_history + source_history + + # Sort by timestamp (most recent last) and keep last 10 + try: + combined_history.sort(key=lambda x: x.get('timestamp', '')) + target.embeddings_history = combined_history[-10:] + except: + # If sorting fails, just concatenate and truncate + target.embeddings_history = (target_history + source_history)[-10:] + + # Recalculate confidence score + target.confidence_score = calculate_confidence(target) + + # Delete source speakers + for source in sources: + db.session.delete(source) + + # Commit all changes + db.session.commit() + + return target + + +def _combine_embeddings(target, sources): + """ + Combine embeddings from multiple speakers using weighted average. + + Weight is based on embedding_count (more samples = more weight). + + Args: + target: Target Speaker instance + sources: List of source Speaker instances + """ + all_embeddings = [] + all_counts = [] + + # Add target's embedding if it exists + if target.average_embedding: + all_embeddings.append(deserialize_embedding(target.average_embedding)) + all_counts.append(target.embedding_count or 1) + + # Add all source embeddings + for source in sources: + if source.average_embedding: + all_embeddings.append(deserialize_embedding(source.average_embedding)) + all_counts.append(source.embedding_count or 1) + + if not all_embeddings: + # No embeddings to combine + return + + # Calculate weighted average + total_count = sum(all_counts) + weights = [c / total_count for c in all_counts] + + combined_emb = np.average(all_embeddings, axis=0, weights=weights) + + # Update target + target.average_embedding = serialize_embedding(combined_emb) + target.embedding_count = total_count + + +def preview_merge(target_id, source_ids, user_id): + """ + Preview what a merge would look like without executing it. + + Args: + target_id: ID of the target speaker + source_ids: List of source speaker IDs + user_id: ID of the user + + Returns: + dict: Preview of the merge results + { + 'target_name': '...', + 'source_names': [...], + 'combined_use_count': 123, + 'combined_embedding_count': 45, + 'total_snippets': 67 + } + """ + # Validate speakers + target = Speaker.query.filter_by(id=target_id, user_id=user_id).first() + if not target: + raise ValueError("Target speaker not found") + + sources = Speaker.query.filter( + Speaker.id.in_(source_ids), + Speaker.user_id == user_id + ).all() + + if len(sources) == 0: + raise ValueError("No valid source speakers found") + + # Calculate combined statistics + combined_use_count = target.use_count + combined_embedding_count = target.embedding_count or 0 + total_snippets = SpeakerSnippet.query.filter_by(speaker_id=target_id).count() + + source_names = [] + for source in sources: + combined_use_count += source.use_count + combined_embedding_count += (source.embedding_count or 0) + total_snippets += SpeakerSnippet.query.filter_by(speaker_id=source.id).count() + source_names.append(source.name) + + return { + 'target_name': target.name, + 'source_names': source_names, + 'combined_use_count': combined_use_count, + 'combined_embedding_count': combined_embedding_count, + 'total_snippets': total_snippets, + 'has_embeddings': target.average_embedding is not None or any(s.average_embedding for s in sources) + } + + +def can_merge_speakers(speaker_ids, user_id): + """ + Check if speakers can be merged (all belong to same user, no duplicates). + + Args: + speaker_ids: List of speaker IDs + user_id: ID of the user + + Returns: + tuple: (bool, str) - (can_merge, error_message) + """ + if len(speaker_ids) < 2: + return False, "Need at least 2 speakers to merge" + + if len(speaker_ids) != len(set(speaker_ids)): + return False, "Duplicate speaker IDs provided" + + speakers = Speaker.query.filter( + Speaker.id.in_(speaker_ids), + Speaker.user_id == user_id + ).all() + + if len(speakers) != len(speaker_ids): + return False, "Some speakers don't exist or don't belong to user" + + return True, "" diff --git a/src/services/speaker_snippets.py b/src/services/speaker_snippets.py new file mode 100644 index 0000000..3263a54 --- /dev/null +++ b/src/services/speaker_snippets.py @@ -0,0 +1,371 @@ +""" +Speaker Snippets Service. + +This service handles the extraction and management of representative speech snippets +from recordings. Snippets provide context when viewing speaker profiles and help +users verify speaker identifications. + +Key functions: +- Extract snippets when speakers are identified in recordings +- Retrieve snippets for display in speaker profiles +- Clean up old snippets to prevent database bloat +""" + +import json +from src.database import db +from src.models import Speaker, SpeakerSnippet, Recording + +MAX_SNIPPETS_PER_SPEAKER = 7 +MAX_SNIPPETS_PER_RECORDING = 2 + + +def create_speaker_snippets(recording_id, speaker_map): + """ + Extract and store representative snippets for each identified speaker. + + This function is called after a user saves speaker identifications in a recording. + It extracts up to MAX_SNIPPETS_PER_RECORDING quotes per speaker from this recording, + and enforces a global cap of MAX_SNIPPETS_PER_SPEAKER by evicting the oldest. + + Args: + recording_id: ID of the recording + speaker_map: Dict mapping SPEAKER_XX to speaker info + {'SPEAKER_00': {'name': 'John Doe', 'isMe': False}, ...} + + Returns: + int: Number of snippets created + """ + recording = Recording.query.get(recording_id) + if not recording or not recording.transcription: + return 0 + + try: + transcript = json.loads(recording.transcription) + except (json.JSONDecodeError, TypeError): + return 0 + + # Build a reverse map: assigned name -> speaker_info + # After transcript is saved, segment['speaker'] contains the real name, + # not the original SPEAKER_XX label. We need to match by name too. + name_to_info = {} + for label, info in speaker_map.items(): + name = info.get('name', '').strip() + if name and not name.startswith('SPEAKER_'): + name_to_info[name] = info + + # Collect candidates per speaker: (speaker_obj, segment_idx, text, timestamp) + candidates = {} # speaker_id -> list of (segment_idx, text, timestamp) + + for segment_idx, segment in enumerate(transcript): + speaker_field = segment.get('speaker') + + if not speaker_field: + continue + + # Try matching by original label first, then by assigned name + if speaker_field in speaker_map: + speaker_info = speaker_map[speaker_field] + speaker_name = speaker_info.get('name') + elif speaker_field in name_to_info: + speaker_name = speaker_field + else: + continue + + if not speaker_name or speaker_name.startswith('SPEAKER_'): + continue + + # Find the speaker in database + speaker = Speaker.query.filter_by( + user_id=recording.user_id, + name=speaker_name + ).first() + + if not speaker: + continue + + text = segment.get('sentence', '').strip() + if len(text) < 10: + continue + + if speaker.id not in candidates: + candidates[speaker.id] = [] + candidates[speaker.id].append((segment_idx, text[:200], segment.get('start_time'))) + + # Delete existing snippets for this recording (re-save replaces them) + SpeakerSnippet.query.filter_by(recording_id=recording_id).delete() + + snippets_created = 0 + + for speaker_id, segs in candidates.items(): + # Pick up to MAX_SNIPPETS_PER_RECORDING spread across the transcript + if len(segs) <= MAX_SNIPPETS_PER_RECORDING: + chosen = segs + else: + # Evenly sample from the segments + step = len(segs) / MAX_SNIPPETS_PER_RECORDING + chosen = [segs[int(i * step)] for i in range(MAX_SNIPPETS_PER_RECORDING)] + + for segment_idx, text_snippet, timestamp in chosen: + # Evict oldest if at global cap + global_count = SpeakerSnippet.query.filter_by(speaker_id=speaker_id).count() + if global_count >= MAX_SNIPPETS_PER_SPEAKER: + oldest = SpeakerSnippet.query.filter_by(speaker_id=speaker_id)\ + .order_by(SpeakerSnippet.created_at.asc()).first() + if oldest: + db.session.delete(oldest) + db.session.flush() + + snippet = SpeakerSnippet( + speaker_id=speaker_id, + recording_id=recording_id, + segment_index=segment_idx, + text_snippet=text_snippet, + timestamp=timestamp, + ) + db.session.add(snippet) + snippets_created += 1 + + # Flush after each speaker batch to keep counts accurate + db.session.flush() + + if snippets_created > 0: + db.session.commit() + + return snippets_created + + +def _generate_dynamic_snippets(speaker_id, limit=3): + """ + Dynamically generate audio snippets from a speaker's recent recordings. + + This function finds short audio segments (3-4 seconds) from recent recordings + where the speaker appears. These can be played back to verify speaker identity. + + Args: + speaker_id: ID of the speaker + limit: Maximum number of snippets to return (default 3) + + Returns: + list: List of snippet dictionaries with audio segment information + [{'recording_id': 123, 'start_time': 45.2, 'duration': 3.5, ...}, ...] + """ + # Get the speaker + speaker = Speaker.query.get(speaker_id) + if not speaker: + return [] + + # Find recordings that have this speaker's name in transcription + # We'll look at the last 10 recordings and extract snippets from them + recordings = Recording.query.filter_by(user_id=speaker.user_id)\ + .filter(Recording.transcription.isnot(None))\ + .filter(Recording.transcription != '')\ + .filter(Recording.audio_deleted_at.is_(None))\ + .order_by(Recording.created_at.desc())\ + .limit(10).all() + + snippets = [] + + for recording in recordings: + if len(snippets) >= limit: + break + + try: + # Parse transcription JSON + transcript = json.loads(recording.transcription) + + if not isinstance(transcript, list): + continue + + # Find segments where this speaker appears + speaker_segments = [] + for idx, segment in enumerate(transcript): + # Check if segment has speaker identification matching our speaker's name + speaker_label = segment.get('speaker') + + # In identified transcripts, the speaker field contains the actual name + if speaker_label != speaker.name: + continue + + start_time = segment.get('start_time') + end_time = segment.get('end_time') + + if start_time is None or end_time is None: + continue + + duration = end_time - start_time + + # Skip very short segments (less than 2 seconds) + if duration < 2.0: + continue + + speaker_segments.append({ + 'index': idx, + 'start_time': start_time, + 'end_time': end_time, + 'duration': duration, + 'text': segment.get('sentence', '').strip()[:100] # Preview text + }) + + if not speaker_segments: + continue + + # Take snippets from middle portions (skip first and last 10%) + total_segments = len(speaker_segments) + if total_segments > 4: + # Skip first and last 10% + start_idx = max(1, int(total_segments * 0.1)) + end_idx = min(total_segments - 1, int(total_segments * 0.9)) + middle_segments = speaker_segments[start_idx:end_idx] + else: + middle_segments = speaker_segments + + # Take 1 snippet per recording from the middle + if middle_segments: + # Pick a segment from the middle + middle_idx = len(middle_segments) // 2 + segment = middle_segments[middle_idx] + + # Limit audio snippet to 3-4 seconds + snippet_duration = min(4.0, segment['duration']) + + snippets.append({ + 'id': None, # Dynamic snippet, no database ID + 'speaker_id': speaker_id, + 'recording_id': recording.id, + 'start_time': segment['start_time'], + 'duration': snippet_duration, + 'text': segment['text'], # Preview text for context + 'recording_title': recording.title or 'Untitled Recording', + 'created_at': recording.created_at.isoformat() if recording.created_at else None + }) + + except (json.JSONDecodeError, TypeError, KeyError) as e: + # Skip recordings with invalid transcription format + continue + + return snippets + + +def get_speaker_snippets(speaker_id, limit=3): + """ + Get recent audio snippets for a speaker. + + Returns short audio segments (3-4 seconds) from recent recordings where this + speaker appears. These audio snippets can be played to verify speaker identity. + + Args: + speaker_id: ID of the speaker + limit: Maximum number of snippets to return (default 3) + + Returns: + list: List of snippet dictionaries with audio segment information + [{'recording_id': 123, 'start_time': 45.2, 'duration': 3.5, ...}, ...] + """ + # Always dynamically generate audio snippets from recent recordings + return _generate_dynamic_snippets(speaker_id, limit) + + +def get_snippets_by_recording(recording_id, speaker_id): + """ + Get all snippets for a specific speaker in a specific recording. + + Args: + recording_id: ID of the recording + speaker_id: ID of the speaker + + Returns: + list: List of snippet dictionaries + """ + snippets = SpeakerSnippet.query.filter_by( + recording_id=recording_id, + speaker_id=speaker_id + ).order_by(SpeakerSnippet.segment_index).all() + + return [snippet.to_dict() for snippet in snippets] + + +def cleanup_old_snippets(speaker_id, keep=10): + """ + Clean up old snippets for a speaker, keeping only the most recent ones. + + Args: + speaker_id: ID of the speaker + keep: Number of snippets to keep (default 10) + + Returns: + int: Number of snippets deleted + """ + # Get all snippets for this speaker, ordered by creation date + all_snippets = SpeakerSnippet.query.filter_by(speaker_id=speaker_id)\ + .order_by(SpeakerSnippet.created_at.desc()).all() + + if len(all_snippets) <= keep: + return 0 + + # Delete old snippets beyond the keep limit + snippets_to_delete = all_snippets[keep:] + deleted_count = 0 + + for snippet in snippets_to_delete: + db.session.delete(snippet) + deleted_count += 1 + + if deleted_count > 0: + db.session.commit() + + return deleted_count + + +def delete_snippets_for_recording(recording_id): + """ + Delete all snippets associated with a recording. + + This is typically called when a recording is deleted or reprocessed. + + Args: + recording_id: ID of the recording + + Returns: + int: Number of snippets deleted + """ + deleted_count = SpeakerSnippet.query.filter_by(recording_id=recording_id).delete() + db.session.commit() + return deleted_count + + +def get_speaker_recordings_with_snippets(speaker_id): + """ + Get a list of recordings that have snippets for this speaker. + + Args: + speaker_id: ID of the speaker + + Returns: + list: List of recording dictionaries with snippet counts + [{'id': 123, 'title': '...', 'snippet_count': 3, 'date': '...'}, ...] + """ + # Get distinct recordings with snippet counts + from sqlalchemy import func + + recordings_with_counts = db.session.query( + Recording.id, + Recording.title, + Recording.created_at, + func.count(SpeakerSnippet.id).label('snippet_count') + ).join( + SpeakerSnippet, + Recording.id == SpeakerSnippet.recording_id + ).filter( + SpeakerSnippet.speaker_id == speaker_id + ).group_by( + Recording.id + ).order_by( + Recording.created_at.desc() + ).all() + + return [{ + 'id': r.id, + 'title': r.title, + 'snippet_count': r.snippet_count, + 'created_at': r.created_at.isoformat() if r.created_at else None + } for r in recordings_with_counts] diff --git a/src/services/token_tracking.py b/src/services/token_tracking.py new file mode 100644 index 0000000..d05390d --- /dev/null +++ b/src/services/token_tracking.py @@ -0,0 +1,270 @@ +""" +Token usage tracking service for monitoring LLM API consumption and budget enforcement. +""" + +import logging +from datetime import date, datetime, timedelta +from typing import Tuple, Optional, Dict, List + +from sqlalchemy import func, extract + +from src.database import db +from src.models.token_usage import TokenUsage +from src.models.user import User + +logger = logging.getLogger(__name__) + + +class TokenTracker: + """Service for recording and checking token usage.""" + + OPERATION_TYPES = [ + 'summarization', + 'chat', + 'title_generation', + 'event_extraction', + 'query_routing', + 'query_enrichment' + ] + + def record_usage( + self, + user_id: int, + operation_type: str, + prompt_tokens: int, + completion_tokens: int, + total_tokens: int, + model_name: str = None, + cost: float = None + ): + """ + Record token usage - upserts into daily aggregate. + + Args: + user_id: User ID who made the request + operation_type: Type of operation (summarization, chat, etc.) + prompt_tokens: Number of input tokens + completion_tokens: Number of output tokens + total_tokens: Total tokens (prompt + completion) + model_name: Name of the model used + cost: API cost if available (e.g., from OpenRouter) + """ + try: + today = date.today() + + # Find or create today's record for this user/operation + usage = TokenUsage.query.filter_by( + user_id=user_id, + date=today, + operation_type=operation_type + ).first() + + if usage: + # Update existing record + usage.prompt_tokens += prompt_tokens + usage.completion_tokens += completion_tokens + usage.total_tokens += total_tokens + usage.request_count += 1 + if cost: + usage.cost += cost + if model_name: + usage.model_name = model_name # Update to latest model used + else: + # Create new record + usage = TokenUsage( + user_id=user_id, + date=today, + operation_type=operation_type, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + request_count=1, + model_name=model_name, + cost=cost or 0.0 + ) + db.session.add(usage) + + db.session.commit() + logger.debug(f"Recorded {total_tokens} tokens for user {user_id}, operation {operation_type}") + return usage + + except Exception as e: + logger.error(f"Failed to record token usage: {e}") + db.session.rollback() + return None + + def get_monthly_usage(self, user_id: int, year: int = None, month: int = None) -> int: + """Get total tokens used by a user in a given month.""" + if year is None: + year = date.today().year + if month is None: + month = date.today().month + + result = db.session.query(func.sum(TokenUsage.total_tokens)).filter( + TokenUsage.user_id == user_id, + extract('year', TokenUsage.date) == year, + extract('month', TokenUsage.date) == month + ).scalar() + + return result or 0 + + def get_monthly_cost(self, user_id: int, year: int = None, month: int = None) -> float: + """Get total cost for a user in a given month.""" + if year is None: + year = date.today().year + if month is None: + month = date.today().month + + result = db.session.query(func.sum(TokenUsage.cost)).filter( + TokenUsage.user_id == user_id, + extract('year', TokenUsage.date) == year, + extract('month', TokenUsage.date) == month + ).scalar() + + return result or 0.0 + + def check_budget(self, user_id: int) -> Tuple[bool, float, Optional[str]]: + """ + Check if user is within budget. + + Returns: + (can_proceed, usage_percentage, message) + - can_proceed: False if hard cap (100%) reached + - usage_percentage: 0-100+ + - message: Warning/error message if applicable + """ + try: + user = db.session.get(User, user_id) + if not user or not user.monthly_token_budget: + return (True, 0, None) # No budget = unlimited + + current_usage = self.get_monthly_usage(user_id) + budget = user.monthly_token_budget + percentage = (current_usage / budget) * 100 + + if percentage >= 100: + return (False, percentage, + f"Monthly token budget exceeded ({percentage:.1f}%). Contact admin for more tokens.") + elif percentage >= 80: + return (True, percentage, + f"Warning: {percentage:.1f}% of monthly token budget used.") + else: + return (True, percentage, None) + + except Exception as e: + logger.error(f"Failed to check budget for user {user_id}: {e}") + # Fail open - allow the request if we can't check + return (True, 0, None) + + def get_daily_stats(self, days: int = 30, user_id: int = None) -> List[Dict]: + """Get daily token usage for charts.""" + start_date = date.today() - timedelta(days=days - 1) + + query = db.session.query( + TokenUsage.date, + TokenUsage.operation_type, + func.sum(TokenUsage.total_tokens).label('tokens'), + func.sum(TokenUsage.cost).label('cost') + ).filter(TokenUsage.date >= start_date) + + if user_id: + query = query.filter(TokenUsage.user_id == user_id) + + results = query.group_by(TokenUsage.date, TokenUsage.operation_type).all() + + # Organize by date + stats = {} + for r in results: + date_str = r.date.isoformat() + if date_str not in stats: + stats[date_str] = {'date': date_str, 'total': 0, 'cost': 0.0, 'by_operation': {}} + stats[date_str]['total'] += r.tokens or 0 + stats[date_str]['cost'] += r.cost or 0 + stats[date_str]['by_operation'][r.operation_type] = r.tokens or 0 + + # Fill in missing dates with zeros + all_dates = [] + current = start_date + while current <= date.today(): + date_str = current.isoformat() + if date_str not in stats: + stats[date_str] = {'date': date_str, 'total': 0, 'cost': 0.0, 'by_operation': {}} + all_dates.append(date_str) + current += timedelta(days=1) + + return [stats[d] for d in sorted(all_dates)] + + def get_monthly_stats(self, months: int = 12) -> List[Dict]: + """Get monthly token usage for charts.""" + results = db.session.query( + extract('year', TokenUsage.date).label('year'), + extract('month', TokenUsage.date).label('month'), + func.sum(TokenUsage.total_tokens).label('tokens'), + func.sum(TokenUsage.cost).label('cost') + ).group_by('year', 'month').order_by('year', 'month').all() + + # Get last N months + monthly_data = [ + { + 'year': int(r.year), + 'month': int(r.month), + 'tokens': r.tokens or 0, + 'cost': r.cost or 0 + } + for r in results + ] + + return monthly_data[-months:] if len(monthly_data) > months else monthly_data + + def get_user_stats(self) -> List[Dict]: + """Get per-user token usage breakdown for current month.""" + today = date.today() + + results = db.session.query( + User.id, + User.username, + User.monthly_token_budget, + func.sum(TokenUsage.total_tokens).label('usage'), + func.sum(TokenUsage.cost).label('cost') + ).outerjoin( + TokenUsage, + (User.id == TokenUsage.user_id) & + (extract('year', TokenUsage.date) == today.year) & + (extract('month', TokenUsage.date) == today.month) + ).group_by(User.id).all() + + return [ + { + 'user_id': r.id, + 'username': r.username, + 'monthly_budget': r.monthly_token_budget, + 'current_usage': r.usage or 0, + 'cost': r.cost or 0, + 'percentage': ((r.usage or 0) / r.monthly_token_budget * 100) + if r.monthly_token_budget else 0 + } + for r in results + ] + + def get_today_usage(self, user_id: int = None) -> Dict: + """Get today's token usage.""" + today = date.today() + + query = db.session.query( + func.sum(TokenUsage.total_tokens).label('tokens'), + func.sum(TokenUsage.cost).label('cost') + ).filter(TokenUsage.date == today) + + if user_id: + query = query.filter(TokenUsage.user_id == user_id) + + result = query.first() + + return { + 'tokens': result.tokens or 0, + 'cost': result.cost or 0 + } + + +# Singleton instance +token_tracker = TokenTracker() diff --git a/src/services/transcription/__init__.py b/src/services/transcription/__init__.py new file mode 100644 index 0000000..4b409f4 --- /dev/null +++ b/src/services/transcription/__init__.py @@ -0,0 +1,98 @@ +""" +Transcription service package. + +Provides a connector-based architecture for speech-to-text transcription +with support for multiple providers: + +- OpenAI Whisper (whisper-1) +- OpenAI GPT-4o Transcribe (gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize) +- Custom ASR endpoints (whisper-asr-webservice, WhisperX, etc.) + +Usage: + from src.services.transcription import ( + transcribe, + get_connector, + supports_diarization, + TranscriptionRequest, + TranscriptionResponse, + ) + + # Simple transcription using active connector + with open('audio.mp3', 'rb') as f: + request = TranscriptionRequest( + audio_file=f, + filename='audio.mp3', + diarize=True + ) + response = transcribe(request) + print(response.text) + if response.segments: + for seg in response.segments: + print(f"[{seg.speaker}]: {seg.text}") +""" + +from .base import ( + TranscriptionCapability, + TranscriptionRequest, + TranscriptionResponse, + TranscriptionSegment, + BaseTranscriptionConnector, + ConnectorSpecifications, + DEFAULT_SPECIFICATIONS, +) + +from .exceptions import ( + TranscriptionError, + ConfigurationError, + ProviderError, + AudioFormatError, + ChunkingError, +) + +from .registry import ( + ConnectorRegistry, + get_registry, + connector_registry, + transcribe, + get_connector, + supports_diarization, +) + +from .connectors import ( + OpenAIWhisperConnector, + OpenAITranscribeConnector, + ASREndpointConnector, +) + +__all__ = [ + # Base types + 'TranscriptionCapability', + 'TranscriptionRequest', + 'TranscriptionResponse', + 'TranscriptionSegment', + 'BaseTranscriptionConnector', + 'ConnectorSpecifications', + 'DEFAULT_SPECIFICATIONS', + + # Exceptions + 'TranscriptionError', + 'ConfigurationError', + 'ProviderError', + 'AudioFormatError', + 'ChunkingError', + + # Registry + 'ConnectorRegistry', + 'get_registry', + 'connector_registry', + + # Convenience functions + 'transcribe', + 'get_connector', + 'supports_diarization', + + # Connectors + 'OpenAIWhisperConnector', + 'OpenAITranscribeConnector', + 'ASREndpointConnector', +] diff --git a/src/services/transcription/base.py b/src/services/transcription/base.py new file mode 100644 index 0000000..6ce0aa0 --- /dev/null +++ b/src/services/transcription/base.py @@ -0,0 +1,243 @@ +""" +Base classes and data types for transcription connectors. +""" + +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Optional, List, Dict, Any, BinaryIO, Set, Type, FrozenSet + + +class TranscriptionCapability(Enum): + """Capabilities that connectors can declare support for.""" + DIARIZATION = auto() # Speaker diarization + CHUNKING = auto() # Automatic file chunking for large files + TIMESTAMPS = auto() # Word/segment timestamps + LANGUAGE_DETECTION = auto() # Auto language detection + KNOWN_SPEAKERS = auto() # Support for known speaker references (future) + SPEAKER_EMBEDDINGS = auto() # Return speaker embeddings + SPEAKER_COUNT_CONTROL = auto() # Support for min/max speaker count parameters + STREAMING = auto() # Real-time streaming transcription + + +@dataclass +class ConnectorSpecifications: + """ + Provider-specific constraints and requirements. + + Each connector declares its constraints so the application can automatically + handle chunking, format conversion, and other preprocessing as needed. + """ + # Size constraints + max_file_size_bytes: Optional[int] = None # None = unlimited + + # Duration constraints + max_duration_seconds: Optional[int] = None # None = unlimited + min_duration_for_chunking: Optional[int] = None # Provider's internal chunking threshold + + # Chunking behavior + handles_chunking_internally: bool = False # Provider handles large files + requires_chunking_param: bool = False # Must send chunking_strategy param + recommended_chunk_seconds: int = 600 # 10 minutes default + + # Audio format support - connector-specific codec restrictions + # None = use system defaults from get_supported_codecs() + # Set = only allow these codecs (overrides defaults) + supported_codecs: Optional[FrozenSet[str]] = None + # Codecs this connector doesn't support (removed from defaults) + # Merged with AUDIO_UNSUPPORTED_CODECS env var + unsupported_codecs: Optional[FrozenSet[str]] = None + + +# Default specifications for connectors that don't define their own +DEFAULT_SPECIFICATIONS = ConnectorSpecifications() + + +@dataclass +class TranscriptionRequest: + """Standardized transcription request.""" + audio_file: BinaryIO + filename: str + mime_type: Optional[str] = None + language: Optional[str] = None + + # Diarization options + diarize: bool = False + min_speakers: Optional[int] = None + max_speakers: Optional[int] = None + known_speaker_names: Optional[List[str]] = None + # known_speaker_references: Dict mapping speaker label to either BinaryIO or data URL string + known_speaker_references: Optional[Dict[str, Any]] = None + + # Advanced options + prompt: Optional[str] = None + hotwords: Optional[str] = None # Comma-separated words to bias recognition + temperature: Optional[float] = None + + # Provider-specific options (passthrough) + extra_options: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class TranscriptionSegment: + """Single segment of transcription with optional metadata.""" + text: str + speaker: Optional[str] = None + start_time: Optional[float] = None + end_time: Optional[float] = None + confidence: Optional[float] = None + words: Optional[List[Dict[str, Any]]] = None + + +@dataclass +class TranscriptionResponse: + """Standardized transcription response.""" + # Core content + text: str # Plain text transcription + segments: Optional[List[TranscriptionSegment]] = None # Detailed segments + + # Metadata + language: Optional[str] = None # Detected language + duration: Optional[float] = None # Audio duration in seconds + + # Speaker information + speakers: Optional[List[str]] = None # List of speakers found + speaker_embeddings: Optional[Dict[str, List[float]]] = None + + # Provider info + provider: str = "" + model: str = "" + + # Raw response for debugging + raw_response: Optional[Dict[str, Any]] = None + + def to_storage_format(self) -> str: + """ + Convert to the JSON format used for storage in database. + + Returns a JSON string in the format expected by the existing codebase: + [ + { + "speaker": "SPEAKER_00", + "sentence": "Text here", + "start_time": 0.0, + "end_time": 5.5 + }, + ... + ] + """ + if self.segments: + return json.dumps([ + { + 'speaker': seg.speaker or 'Unknown Speaker', + 'sentence': seg.text, + 'start_time': seg.start_time, + 'end_time': seg.end_time + } + for seg in self.segments + ]) + # If no segments, return plain text (for non-diarized transcriptions) + return self.text + + def has_diarization(self) -> bool: + """Check if this response contains diarization data.""" + if not self.segments: + return False + return any(seg.speaker for seg in self.segments) + + +class BaseTranscriptionConnector(ABC): + """Abstract base class for transcription connectors.""" + + # Class-level capability declarations - subclasses should override + CAPABILITIES: Set[TranscriptionCapability] = set() + PROVIDER_NAME: str = "unknown" + + # Provider-specific constraints - subclasses should override + SPECIFICATIONS: ConnectorSpecifications = DEFAULT_SPECIFICATIONS + + def __init__(self, config: Dict[str, Any]): + """ + Initialize connector with configuration. + + Args: + config: Provider-specific configuration dict + """ + self.config = config + self._validate_config() + + @abstractmethod + def _validate_config(self) -> None: + """ + Validate required configuration is present. + + Raises: + ConfigurationError: If required config is missing or invalid + """ + pass + + @abstractmethod + def transcribe(self, request: TranscriptionRequest) -> TranscriptionResponse: + """ + Perform transcription. + + Args: + request: Standardized transcription request + + Returns: + Standardized transcription response + + Raises: + TranscriptionError: On transcription failure + ConfigurationError: On configuration issues + """ + pass + + def supports(self, capability: TranscriptionCapability) -> bool: + """Check if connector supports a capability.""" + return capability in self.CAPABILITIES + + def get_capabilities(self) -> Set[TranscriptionCapability]: + """Get all supported capabilities.""" + return self.CAPABILITIES.copy() + + @property + def supports_diarization(self) -> bool: + """Check if connector supports speaker diarization.""" + return TranscriptionCapability.DIARIZATION in self.CAPABILITIES + + @property + def supports_chunking(self) -> bool: + """Check if connector supports automatic file chunking.""" + return TranscriptionCapability.CHUNKING in self.CAPABILITIES + + @property + def supports_speaker_count_control(self) -> bool: + """Check if connector supports min/max speaker count parameters.""" + return TranscriptionCapability.SPEAKER_COUNT_CONTROL in self.CAPABILITIES + + @property + def specifications(self) -> ConnectorSpecifications: + """Get connector specifications.""" + return self.SPECIFICATIONS + + @classmethod + def get_config_schema(cls) -> Dict[str, Any]: + """ + Return JSON schema for this connector's configuration. + Useful for admin UI and validation. + + Returns: + JSON schema dict describing required and optional config + """ + return {} + + def health_check(self) -> bool: + """ + Check if the connector is properly configured and reachable. + + Returns: + True if the connector is healthy, False otherwise + """ + return True diff --git a/src/services/transcription/connectors/__init__.py b/src/services/transcription/connectors/__init__.py new file mode 100644 index 0000000..2266fd7 --- /dev/null +++ b/src/services/transcription/connectors/__init__.py @@ -0,0 +1,15 @@ +""" +Transcription connector implementations. +""" + +from .openai_whisper import OpenAIWhisperConnector +from .openai_transcribe import OpenAITranscribeConnector +from .asr_endpoint import ASREndpointConnector +from .azure_openai_transcribe import AzureOpenAITranscribeConnector + +__all__ = [ + 'OpenAIWhisperConnector', + 'OpenAITranscribeConnector', + 'ASREndpointConnector', + 'AzureOpenAITranscribeConnector', +] diff --git a/src/services/transcription/connectors/asr_endpoint.py b/src/services/transcription/connectors/asr_endpoint.py new file mode 100644 index 0000000..d3f9266 --- /dev/null +++ b/src/services/transcription/connectors/asr_endpoint.py @@ -0,0 +1,337 @@ +""" +ASR Endpoint connector for custom self-hosted ASR services. + +Supports whisper-asr-webservice, WhisperX, and other compatible ASR services +that expose a /asr endpoint. +""" + +import logging +import os +import httpx +from typing import Dict, Any, Set, Optional + +from ..base import ( + BaseTranscriptionConnector, + TranscriptionCapability, + TranscriptionRequest, + TranscriptionResponse, + TranscriptionSegment, + ConnectorSpecifications, +) +from ..exceptions import TranscriptionError, ConfigurationError, ProviderError +from src.config.app_config import ASR_ENABLE_CHUNKING, ASR_MAX_DURATION_SECONDS + +logger = logging.getLogger(__name__) + + +class ASREndpointConnector(BaseTranscriptionConnector): + """Connector for custom ASR webservice (whisper-asr-webservice, WhisperX, etc.).""" + + CAPABILITIES: Set[TranscriptionCapability] = { + TranscriptionCapability.DIARIZATION, + TranscriptionCapability.TIMESTAMPS, + TranscriptionCapability.LANGUAGE_DETECTION, + TranscriptionCapability.SPEAKER_COUNT_CONTROL, # Supports min/max speakers + } + PROVIDER_NAME = "asr_endpoint" + + # SPECIFICATIONS is set dynamically in __init__ based on ASR_ENABLE_CHUNKING config + # Default values here for class-level reference (overridden per-instance) + SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=None, + max_duration_seconds=None, + handles_chunking_internally=True, + ) + + def __init__(self, config: Dict[str, Any]): + """ + Initialize the ASR Endpoint connector. + + Args: + config: Configuration dict with keys: + - base_url: ASR service base URL (required) + - timeout: Request timeout in seconds (default: 1800) + - return_speaker_embeddings: Whether to request embeddings (default: False) + - diarize: Whether to enable diarization by default (default: True) + """ + super().__init__(config) + + self.base_url = config['base_url'].rstrip('/') + self._config_timeout = config.get('timeout', 1800) # 30 minutes default + self.return_embeddings = config.get('return_speaker_embeddings', False) + self.default_diarize = config.get('diarize', True) + + # Configure chunking behavior based on environment variables + # ASR_ENABLE_CHUNKING=true enables app-level chunking for self-hosted ASR services + # that may crash on long files due to GPU memory exhaustion + if ASR_ENABLE_CHUNKING: + # Calculate recommended chunk size (80% of max for safety margin) + recommended_chunk = int(ASR_MAX_DURATION_SECONDS * 0.8) + self.SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=None, # No file size limit + max_duration_seconds=ASR_MAX_DURATION_SECONDS, + handles_chunking_internally=False, # App handles chunking + recommended_chunk_seconds=recommended_chunk, + ) + logger.info( + f"ASR chunking enabled: max_duration={ASR_MAX_DURATION_SECONDS}s, " + f"recommended_chunk={recommended_chunk}s" + ) + else: + # Default behavior: ASR service handles everything internally + self.SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=None, + max_duration_seconds=None, + handles_chunking_internally=True, + ) + + # Add speaker embeddings capability if enabled + if self.return_embeddings: + self.CAPABILITIES = self.CAPABILITIES | {TranscriptionCapability.SPEAKER_EMBEDDINGS} + + @property + def timeout(self): + """Get ASR timeout, reading fresh from env/DB each time to respect runtime changes.""" + # Environment variables take priority + env_timeout = os.environ.get('ASR_TIMEOUT') or os.environ.get('asr_timeout_seconds') + if env_timeout: + try: + return int(env_timeout) + except (ValueError, TypeError): + pass + + # Try database setting (Admin UI) + try: + from src.models import SystemSetting + db_timeout = SystemSetting.get_setting('asr_timeout_seconds', None) + if db_timeout is not None: + return int(db_timeout) + except Exception: + pass + + # Fall back to config value from initialization + return self._config_timeout + + def _validate_config(self) -> None: + """Validate required configuration.""" + if not self.config.get('base_url'): + raise ConfigurationError("base_url is required for ASR endpoint connector") + + def transcribe(self, request: TranscriptionRequest) -> TranscriptionResponse: + """ + Transcribe audio using ASR webservice. + + Args: + request: Standardized transcription request + + Returns: + TranscriptionResponse with segments and speaker information + """ + try: + url = f"{self.base_url}/asr" + + params = { + 'encode': True, + 'task': 'transcribe', + 'output': 'json' + } + + if request.language: + params['language'] = request.language + logger.info(f"Using transcription language: {request.language}") + + # Determine if we should diarize + should_diarize = request.diarize if request.diarize is not None else self.default_diarize + + # Send both parameter names for compatibility: + # - 'diarize' is used by whisper-asr-webservice + # - 'enable_diarization' is used by WhisperX + params['diarize'] = should_diarize + params['enable_diarization'] = should_diarize + + if should_diarize and self.return_embeddings: + params['return_speaker_embeddings'] = True + + if request.min_speakers: + params['min_speakers'] = request.min_speakers + if request.max_speakers: + params['max_speakers'] = request.max_speakers + + if request.prompt: + params['initial_prompt'] = request.prompt + if request.hotwords: + params['hotwords'] = request.hotwords + + content_type = request.mime_type or 'application/octet-stream' + files = { + 'audio_file': (request.filename, request.audio_file, content_type) + } + + # Configure timeout: generous values for large file uploads + # Write timeout needs to be high too - large files take time to upload + timeout = httpx.Timeout( + None, + connect=60.0, + read=float(self.timeout), + write=float(self.timeout), + pool=None + ) + + logger.info(f"Sending ASR request to {url} with params: {params} (timeout: {self.timeout}s)") + + with httpx.Client() as client: + response = client.post(url, params=params, files=files, timeout=timeout) + logger.info(f"ASR request completed with status: {response.status_code}") + response.raise_for_status() + + # Parse the JSON response + response_text = response.text + try: + data = response.json() + except Exception as json_err: + if response_text.strip().startswith('<'): + logger.error(f"ASR returned HTML error page (status {response.status_code})") + raise ProviderError( + f"ASR service returned HTML error page", + provider=self.PROVIDER_NAME, + status_code=response.status_code + ) + else: + raise ProviderError( + f"ASR service returned invalid response: {json_err}", + provider=self.PROVIDER_NAME, + status_code=response.status_code + ) + + return self._parse_response(data) + + except httpx.HTTPStatusError as e: + logger.error(f"ASR request failed with status {e.response.status_code}") + raise ProviderError( + f"ASR request failed with status {e.response.status_code}", + provider=self.PROVIDER_NAME, + status_code=e.response.status_code + ) from e + + except httpx.TimeoutException as e: + logger.error(f"ASR request timed out after {self.timeout}s") + raise TranscriptionError(f"ASR request timed out after {self.timeout}s") from e + + except Exception as e: + error_msg = str(e) + logger.error(f"ASR transcription failed: {error_msg}") + raise TranscriptionError(f"ASR transcription failed: {error_msg}") from e + + def _parse_response(self, data: Dict[str, Any]) -> TranscriptionResponse: + """ + Parse ASR webservice response into standardized format. + + The ASR response contains: + - text: Full transcription text + - language: Detected language + - segments: Array of segments with speaker, text, start, end + - speaker_embeddings: Optional speaker embeddings (WhisperX only) + """ + segments = [] + speakers = set() + full_text_parts = [] + last_speaker = None + + logger.info(f"ASR response keys: {list(data.keys())}") + + if 'segments' in data and isinstance(data['segments'], list): + logger.info(f"Number of segments: {len(data['segments'])}") + + for seg in data['segments']: + speaker = seg.get('speaker') + + # Handle missing speakers by carrying forward from previous segment + if speaker is None: + if last_speaker is not None: + speaker = last_speaker + else: + speaker = 'UNKNOWN_SPEAKER' + else: + last_speaker = speaker + + text = seg.get('text', '').strip() + speakers.add(speaker) + full_text_parts.append(f"[{speaker}]: {text}") + + segments.append(TranscriptionSegment( + text=text, + speaker=speaker, + start_time=seg.get('start'), + end_time=seg.get('end') + )) + + # Get the full text + if 'text' in data and isinstance(data['text'], str): + full_text = data['text'] + elif full_text_parts: + full_text = '\n'.join(full_text_parts) + else: + full_text = '' + + # Extract speaker embeddings if present + speaker_embeddings = data.get('speaker_embeddings') + if speaker_embeddings: + logger.info(f"Received speaker embeddings for speakers: {list(speaker_embeddings.keys())}") + + logger.info(f"Parsed {len(segments)} segments with {len(speakers)} unique speakers: {sorted(speakers)}") + + return TranscriptionResponse( + text=full_text, + segments=segments, + speakers=sorted(list(speakers)), + speaker_embeddings=speaker_embeddings, + language=data.get('language'), + provider=self.PROVIDER_NAME, + model="asr-endpoint", + raw_response=data + ) + + def health_check(self) -> bool: + """Check if ASR endpoint is reachable.""" + try: + with httpx.Client(timeout=10.0) as client: + # Try common health check endpoints + for endpoint in ['/health', '/']: + try: + response = client.get(f"{self.base_url}{endpoint}") + if response.status_code < 500: + return True + except Exception: + continue + return False + except Exception: + return False + + @classmethod + def get_config_schema(cls) -> Dict[str, Any]: + """Return JSON schema for configuration.""" + return { + "type": "object", + "required": ["base_url"], + "properties": { + "base_url": { + "type": "string", + "description": "ASR service base URL (e.g., http://whisper-asr:9000)" + }, + "timeout": { + "type": "integer", + "default": 1800, + "description": "Request timeout in seconds" + }, + "diarize": { + "type": "boolean", + "default": True, + "description": "Enable speaker diarization by default" + }, + "return_speaker_embeddings": { + "type": "boolean", + "default": False, + "description": "Request speaker embeddings (WhisperX only)" + } + } + } diff --git a/src/services/transcription/connectors/azure_openai_transcribe.py b/src/services/transcription/connectors/azure_openai_transcribe.py new file mode 100644 index 0000000..c4e9241 --- /dev/null +++ b/src/services/transcription/connectors/azure_openai_transcribe.py @@ -0,0 +1,370 @@ +""" +Azure OpenAI Transcribe connector. + +Supports Azure OpenAI audio transcription models: +- whisper-1: Basic transcription (no diarization) +- gpt-4o-transcribe: High quality transcription +- gpt-4o-mini-transcribe: Cost-effective transcription +- gpt-4o-transcribe-diarize: Speaker diarization with labels A, B, C, D + +Azure OpenAI uses a different API format than standard OpenAI: +- Endpoint: https://{resource}.openai.azure.com/openai/deployments/{deployment}/audio/transcriptions +- Requires api-version query parameter +- Uses api-key header for authentication +""" + +import logging +import httpx +from typing import Dict, Any, Set, Optional + +from ..base import ( + BaseTranscriptionConnector, + TranscriptionCapability, + TranscriptionRequest, + TranscriptionResponse, + TranscriptionSegment, + ConnectorSpecifications, +) +from ..exceptions import TranscriptionError, ConfigurationError + +logger = logging.getLogger(__name__) + + +class AzureOpenAITranscribeConnector(BaseTranscriptionConnector): + """Connector for Azure OpenAI audio transcription models.""" + + # Base capabilities - diarization added dynamically based on model + CAPABILITIES: Set[TranscriptionCapability] = { + TranscriptionCapability.TIMESTAMPS, + TranscriptionCapability.LANGUAGE_DETECTION, + } + PROVIDER_NAME = "azure_openai_transcribe" + + # Default specifications (will be overridden per-model in __init__) + SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=25 * 1024 * 1024, # 25MB + max_duration_seconds=1400, # Default to most restrictive (diarize model) + min_duration_for_chunking=30, + handles_chunking_internally=False, + requires_chunking_param=True, + recommended_chunk_seconds=1200, + unsupported_codecs=frozenset({'opus'}), + ) + + # Models and their capabilities + MODELS = { + 'whisper-1': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, + 'recommended_chunk_seconds': 1200, + 'description': 'OpenAI Whisper model on Azure' + }, + 'gpt-4o-transcribe': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, + 'recommended_chunk_seconds': 1200, + 'description': 'High quality transcription' + }, + 'gpt-4o-mini-transcribe': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, + 'recommended_chunk_seconds': 1200, + 'description': 'Cost-effective transcription' + }, + 'gpt-4o-mini-transcribe-2025-12-15': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, + 'recommended_chunk_seconds': 1200, + 'description': 'Cost-effective transcription (dated version)' + }, + 'gpt-4o-transcribe-diarize': { + 'supports_diarization': True, + 'max_duration_seconds': 1400, + 'recommended_chunk_seconds': 1200, + 'description': 'Speaker diarization with labels A, B, C, D' + } + } + + # Default API version - can be overridden in config + DEFAULT_API_VERSION = "2025-04-01-preview" + + def __init__(self, config: Dict[str, Any]): + """ + Initialize the Azure OpenAI Transcribe connector. + + Args: + config: Configuration dict with keys: + - api_key: Azure OpenAI API key (required) + - endpoint: Azure OpenAI endpoint URL (required) + e.g., https://your-resource.openai.azure.com + - deployment_name: The deployment name for the model (required) + - api_version: API version (default: 2025-04-01-preview) + - model: Model name for validation (optional, defaults to deployment_name) + """ + # Store model/deployment before calling super().__init__ + self.deployment_name = config.get('deployment_name', '') + self.model = config.get('model', self.deployment_name) + self.api_version = config.get('api_version', self.DEFAULT_API_VERSION) + + # Set model-specific specifications + model_info = self.MODELS.get(self.model, {}) + if model_info: + self.SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=25 * 1024 * 1024, + max_duration_seconds=model_info.get('max_duration_seconds', 1400), + min_duration_for_chunking=30, + handles_chunking_internally=False, + requires_chunking_param=True, + recommended_chunk_seconds=model_info.get('recommended_chunk_seconds', 1200), + unsupported_codecs=frozenset({'opus'}), + ) + + super().__init__(config) + + # Parse endpoint URL + self.endpoint = config['endpoint'].rstrip('/') + + # Set up HTTP client + self.http_client = httpx.Client( + timeout=httpx.Timeout( + connect=60.0, + read=1800.0, # 30 minutes for long transcriptions + write=1800.0, + pool=None + ), + headers={ + "api-key": config['api_key'], + "User-Agent": "Speakr/1.0 (https://github.com/murtaza-nasir/speakr)" + } + ) + + # Dynamically update capabilities based on model + if self._model_supports_diarization(): + self.CAPABILITIES = self.CAPABILITIES | { + TranscriptionCapability.DIARIZATION, + TranscriptionCapability.KNOWN_SPEAKERS + } + + def _validate_config(self) -> None: + """Validate required configuration.""" + if not self.config.get('api_key'): + raise ConfigurationError("api_key is required for Azure OpenAI Transcribe connector") + if not self.config.get('endpoint'): + raise ConfigurationError("endpoint is required for Azure OpenAI Transcribe connector") + if not self.config.get('deployment_name'): + raise ConfigurationError("deployment_name is required for Azure OpenAI Transcribe connector") + + def _model_supports_diarization(self) -> bool: + """Check if the current model supports diarization.""" + model_info = self.MODELS.get(self.model, {}) + return model_info.get('supports_diarization', False) + + def _build_url(self) -> str: + """Build the Azure OpenAI transcription API URL.""" + return f"{self.endpoint}/openai/deployments/{self.deployment_name}/audio/transcriptions?api-version={self.api_version}" + + def transcribe(self, request: TranscriptionRequest) -> TranscriptionResponse: + """ + Transcribe audio using Azure OpenAI API. + + Args: + request: Standardized transcription request + + Returns: + TranscriptionResponse, with segments if using diarization model + """ + try: + url = self._build_url() + + # Build form data + data = {} + + if request.language: + data["language"] = request.language + logger.info(f"Using transcription language: {request.language}") + + # Handle diarization model specifics + is_diarize_model = 'diarize' in self.model.lower() + + if is_diarize_model: + # Required: chunking_strategy for audio > 30 seconds + data["chunking_strategy"] = "auto" + + if request.diarize: + data["response_format"] = "diarized_json" + logger.info("Using diarized_json response format for speaker diarization") + + # Known speaker support + if request.known_speaker_names and request.known_speaker_references: + for i, name in enumerate(request.known_speaker_names): + if name in request.known_speaker_references: + data[f"known_speaker_names[{i}]"] = name + data[f"known_speaker_references[{i}]"] = request.known_speaker_references[name] + logger.info(f"Using known speaker references for {len(request.known_speaker_names)} speakers") + else: + # Non-diarization models - request verbose_json for timestamps + data["response_format"] = "verbose_json" + # Combine initial prompt and hotwords into a single prompt + prompt_parts = [] + if request.prompt: + prompt_parts.append(request.prompt) + if request.hotwords: + prompt_parts.append(request.hotwords) + if prompt_parts: + data["prompt"] = ". ".join(prompt_parts) + + # Prepare file for upload + content_type = request.mime_type or 'application/octet-stream' + files = { + "file": (request.filename, request.audio_file, content_type) + } + + logger.info(f"Sending request to Azure OpenAI: {url}") + logger.info(f"Model: {self.model}, Deployment: {self.deployment_name}") + + response = self.http_client.post(url, data=data, files=files) + + if response.status_code != 200: + error_detail = response.text + try: + error_json = response.json() + if 'error' in error_json: + error_detail = error_json['error'].get('message', error_detail) + except: + pass + logger.error(f"Azure OpenAI transcription failed: {response.status_code} - {error_detail}") + raise TranscriptionError(f"Azure OpenAI transcription failed: {response.status_code} - {error_detail}") + + result = response.json() + + # Parse response based on format + if is_diarize_model and request.diarize: + return self._parse_diarized_response(result) + else: + return self._parse_response(result) + + except TranscriptionError: + raise + except Exception as e: + error_msg = str(e) + logger.error(f"Azure OpenAI transcription failed: {error_msg}") + raise TranscriptionError(f"Azure OpenAI transcription failed: {error_msg}") from e + + def _parse_response(self, response: Dict) -> TranscriptionResponse: + """Parse a standard (non-diarized) response.""" + text = response.get('text', '') + + # Check for segments (verbose_json format) + segments = [] + if 'segments' in response: + for seg in response['segments']: + segments.append(TranscriptionSegment( + text=seg.get('text', ''), + start_time=seg.get('start'), + end_time=seg.get('end') + )) + + return TranscriptionResponse( + text=text, + segments=segments if segments else None, + language=response.get('language'), + provider=self.PROVIDER_NAME, + model=self.model, + raw_response=response + ) + + def _parse_diarized_response(self, response: Dict) -> TranscriptionResponse: + """ + Parse diarized JSON response into standardized format. + + The diarized_json response contains segments with: + - speaker: "A", "B", "C", "D" etc. + - text: The transcribed text + - start: Segment start time + - end: Segment end time + """ + segments = [] + speakers = set() + full_text_parts = [] + + raw_segments = response.get('segments', []) + + if not raw_segments: + # Fallback to text-only response + logger.warning("No segments found in diarized response, falling back to text") + return self._parse_response(response) + + for seg in raw_segments: + speaker = seg.get('speaker', 'Unknown') + text = seg.get('text', '') + start = seg.get('start') + end = seg.get('end') + + # Skip empty segments + if not text or not text.strip(): + continue + + speakers.add(speaker) + full_text_parts.append(f"[{speaker}]: {text}") + + segments.append(TranscriptionSegment( + text=text, + speaker=speaker, + start_time=start, + end_time=end + )) + + # Build full text with speaker labels + full_text = '\n'.join(full_text_parts) + + logger.info(f"Parsed {len(segments)} segments with {len(speakers)} unique speakers: {sorted(speakers)}") + + return TranscriptionResponse( + text=full_text, + segments=segments, + speakers=sorted(list(speakers)), + language=response.get('language'), + provider=self.PROVIDER_NAME, + model=self.model, + raw_response=response + ) + + def health_check(self) -> bool: + """Check if the connector is properly configured.""" + return bool( + self.config.get('api_key') and + self.config.get('endpoint') and + self.config.get('deployment_name') + ) + + @classmethod + def get_config_schema(cls) -> Dict[str, Any]: + """Return JSON schema for configuration.""" + return { + "type": "object", + "required": ["api_key", "endpoint", "deployment_name"], + "properties": { + "api_key": { + "type": "string", + "description": "Azure OpenAI API key" + }, + "endpoint": { + "type": "string", + "description": "Azure OpenAI endpoint URL (e.g., https://your-resource.openai.azure.com)" + }, + "deployment_name": { + "type": "string", + "description": "The deployment name for your transcription model" + }, + "api_version": { + "type": "string", + "default": cls.DEFAULT_API_VERSION, + "description": "Azure OpenAI API version" + }, + "model": { + "type": "string", + "enum": list(cls.MODELS.keys()), + "description": "Model type (for capability detection, defaults to deployment_name)" + } + } + } diff --git a/src/services/transcription/connectors/openai_transcribe.py b/src/services/transcription/connectors/openai_transcribe.py new file mode 100644 index 0000000..f682803 --- /dev/null +++ b/src/services/transcription/connectors/openai_transcribe.py @@ -0,0 +1,329 @@ +""" +OpenAI GPT-4o Transcribe connector. + +Supports the newer GPT-4o based transcription models: +- gpt-4o-transcribe: High quality transcription +- gpt-4o-mini-transcribe: Cost-effective transcription +- gpt-4o-transcribe-diarize: Speaker diarization with labels A, B, C, D +""" + +import logging +import httpx +from openai import OpenAI +from typing import Dict, Any, Set, Optional + +from ..base import ( + BaseTranscriptionConnector, + TranscriptionCapability, + TranscriptionRequest, + TranscriptionResponse, + TranscriptionSegment, + ConnectorSpecifications, +) +from ..exceptions import TranscriptionError, ConfigurationError + +logger = logging.getLogger(__name__) + + +class OpenAITranscribeConnector(BaseTranscriptionConnector): + """Connector for GPT-4o Transcribe models with optional diarization support.""" + + # Base capabilities - diarization added dynamically based on model + CAPABILITIES: Set[TranscriptionCapability] = { + TranscriptionCapability.TIMESTAMPS, + TranscriptionCapability.LANGUAGE_DETECTION, + } + PROVIDER_NAME = "openai_transcribe" + + # GPT-4o Transcribe models have specific constraints + # - 25MB file size limit (all models) + # - Duration limits vary by model: + # - gpt-4o-transcribe / gpt-4o-mini-transcribe: 1500 seconds (25 min) + # - gpt-4o-transcribe-diarize: 1400 seconds (~23 min) + # - chunking_strategy="auto" handles files internally up to the duration limit + # Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, flac, ogg, oga + # NOT supported: opus (used by WhatsApp voice notes, Discord) + + # Default specifications (will be overridden per-model in __init__) + SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=25 * 1024 * 1024, # 25MB + max_duration_seconds=1400, # Default to most restrictive (diarize model) + min_duration_for_chunking=30, # >30s needs chunking_strategy param + handles_chunking_internally=False, # App must chunk files > max_duration_seconds + requires_chunking_param=True, # Must send chunking_strategy for >30s + recommended_chunk_seconds=1200, # 20 minutes - safe margin + unsupported_codecs=frozenset({'opus'}), # OpenAI API doesn't support opus + ) + + # Models and their capabilities with duration limits + MODELS = { + 'gpt-4o-transcribe': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, # 25 minutes + 'recommended_chunk_seconds': 1200, # 20 minutes + 'description': 'High quality transcription' + }, + 'gpt-4o-mini-transcribe': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, # 25 minutes + 'recommended_chunk_seconds': 1200, # 20 minutes + 'description': 'Cost-effective transcription' + }, + 'gpt-4o-mini-transcribe-2025-12-15': { + 'supports_diarization': False, + 'max_duration_seconds': 1500, # 25 minutes + 'recommended_chunk_seconds': 1200, # 20 minutes + 'description': 'Cost-effective transcription (dated version)' + }, + 'gpt-4o-transcribe-diarize': { + 'supports_diarization': True, + 'max_duration_seconds': 1400, # ~23 minutes (more restrictive) + 'recommended_chunk_seconds': 1200, # 20 minutes + 'description': 'Speaker diarization with labels A, B, C, D' + } + } + + def __init__(self, config: Dict[str, Any]): + """ + Initialize the GPT-4o Transcribe connector. + + Args: + config: Configuration dict with keys: + - api_key: OpenAI API key (required) + - base_url: API base URL (default: https://api.openai.com/v1) + - model: Model name (required, one of MODELS) + - http_client: Optional httpx.Client instance + """ + # Store model before calling super().__init__ since _validate_config needs it + self.model = config.get('model', 'gpt-4o-transcribe') + + # Set model-specific specifications (override class defaults) + # Use SPECIFICATIONS (uppercase) to shadow the class attribute + model_info = self.MODELS.get(self.model, {}) + self.SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=25 * 1024 * 1024, # 25MB (same for all) + max_duration_seconds=model_info.get('max_duration_seconds', 1400), + min_duration_for_chunking=30, + handles_chunking_internally=False, + requires_chunking_param=True, + recommended_chunk_seconds=model_info.get('recommended_chunk_seconds', 1200), + unsupported_codecs=frozenset({'opus'}), + ) + + super().__init__(config) + + # Set up HTTP client with custom headers + http_client = config.get('http_client') + if not http_client: + app_headers = { + "HTTP-Referer": "https://github.com/murtaza-nasir/speakr", + "X-Title": "Speakr - AI Audio Transcription", + "User-Agent": "Speakr/1.0 (https://github.com/murtaza-nasir/speakr)" + } + http_client = httpx.Client(verify=True, headers=app_headers) + + self.client = OpenAI( + api_key=config['api_key'], + base_url=config.get('base_url', 'https://api.openai.com/v1'), + http_client=http_client + ) + + # Dynamically update capabilities based on model + if self._model_supports_diarization(): + self.CAPABILITIES = self.CAPABILITIES | { + TranscriptionCapability.DIARIZATION, + TranscriptionCapability.KNOWN_SPEAKERS + } + + def _validate_config(self) -> None: + """Validate required configuration.""" + if not self.config.get('api_key'): + raise ConfigurationError("api_key is required for OpenAI Transcribe connector") + + model = self.config.get('model', 'gpt-4o-transcribe') + if model not in self.MODELS: + raise ConfigurationError( + f"Unknown model: {model}. Valid models: {list(self.MODELS.keys())}" + ) + + def _model_supports_diarization(self) -> bool: + """Check if the current model supports diarization.""" + model_info = self.MODELS.get(self.model, {}) + return model_info.get('supports_diarization', False) + + def transcribe(self, request: TranscriptionRequest) -> TranscriptionResponse: + """ + Transcribe audio using GPT-4o Transcribe API. + + Args: + request: Standardized transcription request + + Returns: + TranscriptionResponse, with segments if using diarization model + """ + try: + params = { + "model": self.model, + "file": request.audio_file, + } + + if request.language: + params["language"] = request.language + logger.info(f"Using transcription language: {request.language}") + + # Handle diarization model specifics + if self.model == 'gpt-4o-transcribe-diarize': + # Required: chunking_strategy for audio > 30 seconds + params["chunking_strategy"] = "auto" + + if request.diarize: + params["response_format"] = "diarized_json" + logger.info("Using diarized_json response format for speaker diarization") + + # Known speaker support for maintaining speaker identity across chunks + # known_speaker_names is a list of speaker labels (e.g., ["A", "B"]) + # known_speaker_references is a dict mapping label to data URL + if request.known_speaker_names and request.known_speaker_references: + # OpenAI expects lists for both parameters + speaker_names = [] + speaker_refs = [] + + for name in request.known_speaker_names: + if name in request.known_speaker_references: + speaker_names.append(name) + speaker_refs.append(request.known_speaker_references[name]) + + if speaker_names: + # Use extra_body to pass the known speaker parameters + params["extra_body"] = { + "known_speaker_names": speaker_names, + "known_speaker_references": speaker_refs + } + logger.info(f"Using known speaker references for {len(speaker_names)} speakers: {speaker_names}") + else: + # Non-diarization models - combine initial prompt and hotwords + prompt_parts = [] + if request.prompt: + prompt_parts.append(request.prompt) + if request.hotwords: + prompt_parts.append(request.hotwords) + if prompt_parts: + params["prompt"] = ". ".join(prompt_parts) + + logger.info(f"Sending request to GPT-4o Transcribe API with model: {self.model}") + response = self.client.audio.transcriptions.create(**params) + + # Parse response based on format + if self.model == 'gpt-4o-transcribe-diarize' and request.diarize: + return self._parse_diarized_response(response) + else: + return self._parse_text_response(response) + + except Exception as e: + error_msg = str(e) + logger.error(f"GPT-4o transcription failed: {error_msg}") + raise TranscriptionError(f"GPT-4o transcription failed: {error_msg}") from e + + def _parse_text_response(self, response) -> TranscriptionResponse: + """Parse a plain text response.""" + text = response.text if hasattr(response, 'text') else str(response) + return TranscriptionResponse( + text=text, + provider=self.PROVIDER_NAME, + model=self.model + ) + + def _parse_diarized_response(self, response) -> TranscriptionResponse: + """ + Parse diarized JSON response into standardized format. + + The diarized_json response contains segments with: + - speaker: "A", "B", "C", "D" etc. + - text: The transcribed text + - start: Segment start time + - end: Segment end time + """ + segments = [] + speakers = set() + full_text_parts = [] + + # Handle response object - could be dict or object with attributes + if hasattr(response, 'segments'): + raw_segments = response.segments + elif isinstance(response, dict) and 'segments' in response: + raw_segments = response['segments'] + else: + # Fallback to text-only response + logger.warning("No segments found in diarized response, falling back to text") + return self._parse_text_response(response) + + for seg in raw_segments: + # Handle both dict and object segments + if isinstance(seg, dict): + speaker = seg.get('speaker', 'Unknown') + text = seg.get('text', '') + start = seg.get('start') + end = seg.get('end') + else: + speaker = getattr(seg, 'speaker', 'Unknown') + text = getattr(seg, 'text', '') + start = getattr(seg, 'start', None) + end = getattr(seg, 'end', None) + + # Skip empty segments + if not text or not text.strip(): + continue + + speakers.add(speaker) + full_text_parts.append(f"[{speaker}]: {text}") + + segments.append(TranscriptionSegment( + text=text, + speaker=speaker, + start_time=start, + end_time=end + )) + + # Always use our formatted text with speaker labels for diarized responses + # OpenAI's response.text is plain text WITHOUT speaker labels + full_text = '\n'.join(full_text_parts) + + logger.info(f"Parsed {len(segments)} segments with {len(speakers)} unique speakers: {sorted(speakers)}") + + return TranscriptionResponse( + text=full_text, + segments=segments, + speakers=sorted(list(speakers)), + provider=self.PROVIDER_NAME, + model=self.model, + raw_response=response if isinstance(response, dict) else None + ) + + def health_check(self) -> bool: + """Check if the connector is properly configured.""" + return bool(self.config.get('api_key')) + + @classmethod + def get_config_schema(cls) -> Dict[str, Any]: + """Return JSON schema for configuration.""" + return { + "type": "object", + "required": ["api_key"], + "properties": { + "api_key": { + "type": "string", + "description": "OpenAI API key" + }, + "base_url": { + "type": "string", + "default": "https://api.openai.com/v1", + "description": "API base URL" + }, + "model": { + "type": "string", + "enum": list(cls.MODELS.keys()), + "default": "gpt-4o-transcribe", + "description": "GPT-4o transcription model to use" + } + } + } diff --git a/src/services/transcription/connectors/openai_whisper.py b/src/services/transcription/connectors/openai_whisper.py new file mode 100644 index 0000000..b52ad0a --- /dev/null +++ b/src/services/transcription/connectors/openai_whisper.py @@ -0,0 +1,153 @@ +""" +OpenAI Whisper API connector (whisper-1 model). + +This is the legacy Whisper API connector that supports the whisper-1 model. +It returns plain text transcriptions without speaker diarization. +""" + +import logging +import os +import httpx +from openai import OpenAI +from typing import Dict, Any, Set + +from ..base import ( + BaseTranscriptionConnector, + TranscriptionCapability, + TranscriptionRequest, + TranscriptionResponse, + ConnectorSpecifications, +) +from ..exceptions import TranscriptionError, ConfigurationError + +logger = logging.getLogger(__name__) + + +class OpenAIWhisperConnector(BaseTranscriptionConnector): + """Connector for OpenAI Whisper API (whisper-1 model).""" + + CAPABILITIES: Set[TranscriptionCapability] = { + TranscriptionCapability.CHUNKING, + TranscriptionCapability.TIMESTAMPS, + TranscriptionCapability.LANGUAGE_DETECTION, + } + PROVIDER_NAME = "openai_whisper" + + # OpenAI Whisper has a 25MB file limit and doesn't handle chunking internally + # Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, flac, ogg, oga + # NOT supported: opus (used by WhatsApp voice notes, Discord) + SPECIFICATIONS = ConnectorSpecifications( + max_file_size_bytes=25 * 1024 * 1024, # 25MB + handles_chunking_internally=False, + recommended_chunk_seconds=600, # 10 minutes + unsupported_codecs=frozenset({'opus'}), # OpenAI API doesn't support opus + ) + + def __init__(self, config: Dict[str, Any]): + """ + Initialize the Whisper connector. + + Args: + config: Configuration dict with keys: + - api_key: OpenAI API key (required) + - base_url: API base URL (optional) + - model: Model name (default: whisper-1) + - http_client: Optional httpx.Client instance + """ + super().__init__(config) + + # Set up HTTP client with custom headers + http_client = config.get('http_client') + if not http_client: + app_headers = { + "HTTP-Referer": "https://github.com/murtaza-nasir/speakr", + "X-Title": "Speakr - AI Audio Transcription", + "User-Agent": "Speakr/1.0 (https://github.com/murtaza-nasir/speakr)" + } + http_client = httpx.Client(verify=True, headers=app_headers) + + self.client = OpenAI( + api_key=config['api_key'], + base_url=config.get('base_url') or None, + http_client=http_client + ) + self.model = config.get('model', 'whisper-1') + + def _validate_config(self) -> None: + """Validate required configuration.""" + if not self.config.get('api_key'): + raise ConfigurationError("api_key is required for OpenAI Whisper connector") + + def transcribe(self, request: TranscriptionRequest) -> TranscriptionResponse: + """ + Transcribe audio using OpenAI Whisper API. + + Args: + request: Standardized transcription request + + Returns: + TranscriptionResponse with plain text (no diarization) + """ + try: + params = { + "model": self.model, + "file": request.audio_file, + } + + if request.language: + params["language"] = request.language + logger.info(f"Using transcription language: {request.language}") + + # Combine initial prompt and hotwords into a single prompt + # OpenAI Whisper uses prompt for both steering and vocabulary hints + prompt_parts = [] + if request.prompt: + prompt_parts.append(request.prompt) + if request.hotwords: + prompt_parts.append(request.hotwords) + if prompt_parts: + params["prompt"] = ". ".join(prompt_parts) + + if request.temperature is not None: + params["temperature"] = request.temperature + + logger.info(f"Sending request to Whisper API with model: {self.model}") + transcript = self.client.audio.transcriptions.create(**params) + + return TranscriptionResponse( + text=transcript.text, + provider=self.PROVIDER_NAME, + model=self.model + ) + + except Exception as e: + error_msg = str(e) + logger.error(f"Whisper transcription failed: {error_msg}") + raise TranscriptionError(f"Whisper transcription failed: {error_msg}") from e + + def health_check(self) -> bool: + """Check if the connector is properly configured.""" + return bool(self.config.get('api_key')) + + @classmethod + def get_config_schema(cls) -> Dict[str, Any]: + """Return JSON schema for configuration.""" + return { + "type": "object", + "required": ["api_key"], + "properties": { + "api_key": { + "type": "string", + "description": "OpenAI API key" + }, + "base_url": { + "type": "string", + "description": "API base URL (optional, for OpenAI-compatible endpoints)" + }, + "model": { + "type": "string", + "default": "whisper-1", + "description": "Whisper model to use" + } + } + } diff --git a/src/services/transcription/exceptions.py b/src/services/transcription/exceptions.py new file mode 100644 index 0000000..d9de52d --- /dev/null +++ b/src/services/transcription/exceptions.py @@ -0,0 +1,32 @@ +""" +Custom exceptions for transcription services. +""" + + +class TranscriptionError(Exception): + """Base exception for transcription errors.""" + pass + + +class ConfigurationError(TranscriptionError): + """Configuration-related errors (missing or invalid config).""" + pass + + +class ProviderError(TranscriptionError): + """Provider/API errors.""" + + def __init__(self, message: str, provider: str = None, status_code: int = None): + super().__init__(message) + self.provider = provider + self.status_code = status_code + + +class AudioFormatError(TranscriptionError): + """Unsupported audio format errors.""" + pass + + +class ChunkingError(TranscriptionError): + """Errors during file chunking.""" + pass diff --git a/src/services/transcription/registry.py b/src/services/transcription/registry.py new file mode 100644 index 0000000..b413644 --- /dev/null +++ b/src/services/transcription/registry.py @@ -0,0 +1,353 @@ +""" +Connector registry for managing transcription connectors. + +Provides factory pattern for creating and managing transcription connectors, +with auto-detection from environment variables for backwards compatibility. +""" + +import os +import logging +from typing import Dict, Any, Optional, Type, List + +from .base import BaseTranscriptionConnector, TranscriptionCapability, TranscriptionRequest, TranscriptionResponse +from .exceptions import ConfigurationError + +logger = logging.getLogger(__name__) + + +class ConnectorRegistry: + """ + Registry for managing transcription connectors. + + Singleton pattern - use get_registry() to get the shared instance. + """ + + _instance = None + _connectors: Dict[str, Type[BaseTranscriptionConnector]] = {} + _active_connector: Optional[BaseTranscriptionConnector] = None + _connector_name: str = "" + _initialized: bool = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if self._initialized: + return + self._register_builtin_connectors() + self._initialized = True + + def _register_builtin_connectors(self): + """Register all built-in connectors.""" + from .connectors.openai_whisper import OpenAIWhisperConnector + from .connectors.openai_transcribe import OpenAITranscribeConnector + from .connectors.asr_endpoint import ASREndpointConnector + from .connectors.azure_openai_transcribe import AzureOpenAITranscribeConnector + + self.register('openai_whisper', OpenAIWhisperConnector) + self.register('openai_transcribe', OpenAITranscribeConnector) + self.register('asr_endpoint', ASREndpointConnector) + self.register('azure_openai_transcribe', AzureOpenAITranscribeConnector) + + def register(self, name: str, connector_class: Type[BaseTranscriptionConnector]): + """ + Register a connector class. + + Args: + name: Unique name for the connector + connector_class: The connector class to register + """ + self._connectors[name] = connector_class + logger.debug(f"Registered transcription connector: {name}") + + def get_connector_class(self, name: str) -> Type[BaseTranscriptionConnector]: + """ + Get a connector class by name. + + Args: + name: The connector name + + Returns: + The connector class + + Raises: + ConfigurationError: If connector not found + """ + if name not in self._connectors: + raise ConfigurationError( + f"Unknown connector: {name}. Available: {list(self._connectors.keys())}" + ) + return self._connectors[name] + + def create_connector(self, name: str, config: Dict[str, Any]) -> BaseTranscriptionConnector: + """ + Create a connector instance. + + Args: + name: The connector name + config: Configuration dict for the connector + + Returns: + Configured connector instance + """ + connector_class = self.get_connector_class(name) + return connector_class(config) + + def list_connectors(self) -> List[Dict[str, Any]]: + """ + List all registered connectors with their capabilities. + + Returns: + List of connector info dicts + """ + result = [] + for name, cls in self._connectors.items(): + result.append({ + 'name': name, + 'provider_name': cls.PROVIDER_NAME, + 'capabilities': [c.name for c in cls.CAPABILITIES], + 'config_schema': cls.get_config_schema() + }) + return result + + def initialize_from_env(self) -> BaseTranscriptionConnector: + """ + Initialize the active connector from environment variables. + + Auto-detection priority: + 1. TRANSCRIPTION_CONNECTOR - explicit connector name + 2. ASR_BASE_URL is set - use ASR endpoint (smarter detection) + - USE_ASR_ENDPOINT=true also works (backwards compat, with deprecation warning) + 3. TRANSCRIPTION_MODEL contains 'gpt-4o' - use OpenAI Transcribe + 4. TRANSCRIPTION_MODEL is set - use OpenAI Whisper with that model + 5. Default to OpenAI Whisper (whisper-1) + + Returns: + The initialized connector + """ + connector_name = os.environ.get('TRANSCRIPTION_CONNECTOR', '').lower().strip() + + if not connector_name: + # Auto-detect based on existing config for backwards compatibility + asr_base_url = os.environ.get('ASR_BASE_URL', '').strip() + use_asr_flag = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true' + transcription_model = os.environ.get('TRANSCRIPTION_MODEL', '').lower() + whisper_model = os.environ.get('WHISPER_MODEL', '').lower() + + # Deprecation warning for legacy USE_ASR_ENDPOINT flag + if use_asr_flag: + logger.warning( + "USE_ASR_ENDPOINT=true is deprecated. " + "Set ASR_BASE_URL instead for auto-detection, or use TRANSCRIPTION_CONNECTOR=asr_endpoint" + ) + + # Priority 2: ASR endpoint - check ASR_BASE_URL or legacy flag + if asr_base_url or use_asr_flag: + connector_name = 'asr_endpoint' + if asr_base_url: + logger.info("Auto-detected ASR endpoint from ASR_BASE_URL") + # Priority 2.5: Azure OpenAI - check for Azure endpoint URL + elif self._is_azure_endpoint(): + connector_name = 'azure_openai_transcribe' + logger.info("Auto-detected Azure OpenAI from TRANSCRIPTION_BASE_URL") + # Priority 3: Model-based detection + elif transcription_model and 'gpt-4o' in transcription_model: + connector_name = 'openai_transcribe' + logger.info(f"Auto-detected OpenAI Transcribe from TRANSCRIPTION_MODEL={transcription_model}") + # Priority 4 & 5: OpenAI Whisper (with custom or default model) + else: + connector_name = 'openai_whisper' + model = transcription_model or whisper_model or 'whisper-1' + logger.info(f"Using OpenAI Whisper connector with model: {model}") + + config = self._build_config_from_env(connector_name) + + try: + self._active_connector = self.create_connector(connector_name, config) + self._connector_name = connector_name + + logger.info(f"Initialized transcription connector: {connector_name}") + logger.info(f"Capabilities: {[c.name for c in self._active_connector.get_capabilities()]}") + + return self._active_connector + + except Exception as e: + logger.error(f"Failed to initialize connector '{connector_name}': {e}") + raise ConfigurationError(f"Failed to initialize connector '{connector_name}': {e}") from e + + def _get_asr_timeout(self) -> int: + """ + Get ASR timeout with fallback chain: ENV -> Admin UI -> default. + + Priority: + 1. ASR_TIMEOUT environment variable + 2. asr_timeout_seconds environment variable (legacy) + 3. SystemSetting from Admin UI (database) + 4. Default: 1800 seconds (30 minutes) + """ + # Check environment variables first + env_timeout = os.environ.get('ASR_TIMEOUT') or os.environ.get('asr_timeout_seconds') + if env_timeout: + return int(env_timeout) + + # Fall back to Admin UI setting (SystemSetting in database) + try: + from src.models import SystemSetting + db_timeout = SystemSetting.get_setting('asr_timeout_seconds', None) + if db_timeout is not None: + return int(db_timeout) + except Exception as e: + # May fail if no app context or during initialization + logger.debug(f"Could not read ASR timeout from database: {e}") + + # Default: 30 minutes + return 1800 + + def _is_azure_endpoint(self) -> bool: + """Check if the TRANSCRIPTION_BASE_URL points to an Azure OpenAI endpoint.""" + base_url = os.environ.get('TRANSCRIPTION_BASE_URL', '').lower() + return '.openai.azure.com' in base_url or '.cognitiveservices.azure.com' in base_url + + def _build_config_from_env(self, connector_name: str) -> Dict[str, Any]: + """ + Build connector config from environment variables. + + Args: + connector_name: The connector to build config for + + Returns: + Configuration dict + """ + if connector_name == 'asr_endpoint': + base_url = os.environ.get('ASR_BASE_URL', '') + if base_url: + base_url = base_url.split('#')[0].strip() + + return { + 'base_url': base_url, + 'timeout': self._get_asr_timeout(), + 'diarize': os.environ.get('ASR_DIARIZE', 'true').lower() == 'true', + 'return_speaker_embeddings': os.environ.get('ASR_RETURN_SPEAKER_EMBEDDINGS', 'false').lower() == 'true' + } + + elif connector_name == 'openai_transcribe': + base_url = os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1') + if base_url: + base_url = base_url.split('#')[0].strip() + + return { + 'api_key': os.environ.get('TRANSCRIPTION_API_KEY', ''), + 'base_url': base_url, + 'model': os.environ.get('TRANSCRIPTION_MODEL', 'gpt-4o-transcribe') + } + + elif connector_name == 'azure_openai_transcribe': + # Azure OpenAI requires endpoint and deployment_name + # TRANSCRIPTION_BASE_URL should be the Azure endpoint (e.g., https://your-resource.openai.azure.com) + endpoint = os.environ.get('TRANSCRIPTION_BASE_URL', '') + if endpoint: + endpoint = endpoint.split('#')[0].strip() + # Remove any trailing /openai or /v1 paths - we build the full URL ourselves + endpoint = endpoint.rstrip('/') + for suffix in ['/openai/v1', '/openai', '/v1']: + if endpoint.lower().endswith(suffix): + endpoint = endpoint[:-len(suffix)] + + return { + 'api_key': os.environ.get('TRANSCRIPTION_API_KEY', ''), + 'endpoint': endpoint, + 'deployment_name': os.environ.get('AZURE_DEPLOYMENT_NAME', os.environ.get('TRANSCRIPTION_MODEL', 'gpt-4o-transcribe')), + 'api_version': os.environ.get('AZURE_API_VERSION', '2025-04-01-preview'), + 'model': os.environ.get('TRANSCRIPTION_MODEL', '') # For capability detection + } + + else: # openai_whisper (default) + base_url = os.environ.get('TRANSCRIPTION_BASE_URL', '') + if base_url: + base_url = base_url.split('#')[0].strip() + + # Support both TRANSCRIPTION_MODEL and legacy WHISPER_MODEL + # TRANSCRIPTION_MODEL takes priority for custom Whisper variants + model = os.environ.get('TRANSCRIPTION_MODEL', '') or os.environ.get('WHISPER_MODEL', 'whisper-1') + + return { + 'api_key': os.environ.get('TRANSCRIPTION_API_KEY', ''), + 'base_url': base_url or None, + 'model': model + } + + def get_active_connector(self) -> BaseTranscriptionConnector: + """ + Get the currently active connector. + + Initializes from environment if not already initialized. + + Returns: + The active connector + """ + if not self._active_connector: + self.initialize_from_env() + return self._active_connector + + def get_active_connector_name(self) -> str: + """Get the name of the currently active connector.""" + if not self._active_connector: + self.initialize_from_env() + return self._connector_name + + def reinitialize(self) -> BaseTranscriptionConnector: + """ + Force re-initialization of the connector. + + Useful when environment variables have changed. + + Returns: + The newly initialized connector + """ + self._active_connector = None + self._connector_name = "" + return self.initialize_from_env() + + +# Global registry instance +_registry: Optional[ConnectorRegistry] = None + + +def get_registry() -> ConnectorRegistry: + """Get the global connector registry.""" + global _registry + if _registry is None: + _registry = ConnectorRegistry() + return _registry + + +# Convenience aliases +connector_registry = get_registry() + + +def transcribe(request: TranscriptionRequest) -> TranscriptionResponse: + """ + Transcribe audio using the active connector. + + This is a convenience function that uses the global registry. + + Args: + request: The transcription request + + Returns: + Transcription response + """ + connector = get_registry().get_active_connector() + return connector.transcribe(request) + + +def get_connector() -> BaseTranscriptionConnector: + """Get the active transcription connector.""" + return get_registry().get_active_connector() + + +def supports_diarization() -> bool: + """Check if the active connector supports diarization.""" + return get_registry().get_active_connector().supports_diarization diff --git a/src/services/transcription_tracking.py b/src/services/transcription_tracking.py new file mode 100644 index 0000000..ca63f13 --- /dev/null +++ b/src/services/transcription_tracking.py @@ -0,0 +1,312 @@ +""" +Transcription usage tracking service for monitoring audio transcription consumption and budget enforcement. +""" + +import logging +from datetime import date, datetime, timedelta +from typing import Tuple, Optional, Dict, List + +from sqlalchemy import func, extract + +from src.database import db +from src.models.transcription_usage import TranscriptionUsage +from src.models.user import User + +logger = logging.getLogger(__name__) + + +# Pricing configuration per connector/model (dollars per minute) +TRANSCRIPTION_PRICING = { + 'openai_whisper': { + 'whisper-1': 0.006, # $0.006/min + 'default': 0.006, + }, + 'openai_transcribe': { + 'gpt-4o-transcribe': 0.006, # $0.006/min + 'gpt-4o-mini-transcribe': 0.003, # $0.003/min + 'gpt-4o-mini-transcribe-2025-12-15': 0.003, + 'gpt-4o-transcribe-diarize': 0.006, + 'default': 0.006, + }, + 'asr_endpoint': { + 'default': 0.0, # Self-hosted = free + }, +} + + +def get_transcription_cost_per_minute(connector_type: str, model_name: str = None) -> float: + """ + Get the cost per minute for a given connector and model. + + Args: + connector_type: The connector provider name + model_name: The specific model (optional) + + Returns: + Cost per minute in dollars + """ + connector_pricing = TRANSCRIPTION_PRICING.get(connector_type, {}) + + if model_name and model_name in connector_pricing: + return connector_pricing[model_name] + + # Fall back to 'default' pricing for the connector + return connector_pricing.get('default', 0.0) + + +class TranscriptionTracker: + """Service for recording and checking transcription usage.""" + + CONNECTOR_TYPES = [ + 'openai_whisper', + 'openai_transcribe', + 'asr_endpoint', + ] + + def record_usage( + self, + user_id: int, + connector_type: str, + audio_duration_seconds: int, + model_name: str = None, + estimated_cost: float = None + ): + """ + Record transcription usage - upserts into daily aggregate. + + Args: + user_id: User ID who made the request + connector_type: Type of connector (openai_whisper, openai_transcribe, asr_endpoint) + audio_duration_seconds: Duration of audio transcribed in seconds + model_name: Name of the model used + estimated_cost: Pre-calculated cost (if None, calculated from pricing config) + """ + try: + today = date.today() + + # Calculate cost if not provided + if estimated_cost is None: + cost_per_minute = get_transcription_cost_per_minute(connector_type, model_name) + estimated_cost = (audio_duration_seconds / 60.0) * cost_per_minute + + # Find or create today's record for this user/connector + usage = TranscriptionUsage.query.filter_by( + user_id=user_id, + date=today, + connector_type=connector_type + ).first() + + if usage: + # Update existing record + usage.audio_duration_seconds += audio_duration_seconds + usage.estimated_cost += estimated_cost + usage.request_count += 1 + if model_name: + usage.model_name = model_name # Update to latest model used + else: + # Create new record + usage = TranscriptionUsage( + user_id=user_id, + date=today, + connector_type=connector_type, + audio_duration_seconds=audio_duration_seconds, + request_count=1, + model_name=model_name, + estimated_cost=estimated_cost or 0.0 + ) + db.session.add(usage) + + db.session.commit() + logger.debug(f"Recorded {audio_duration_seconds}s transcription for user {user_id}, connector {connector_type}") + return usage + + except Exception as e: + logger.error(f"Failed to record transcription usage: {e}") + db.session.rollback() + return None + + def get_monthly_usage(self, user_id: int, year: int = None, month: int = None) -> int: + """Get total seconds transcribed by a user in a given month.""" + if year is None: + year = date.today().year + if month is None: + month = date.today().month + + result = db.session.query(func.sum(TranscriptionUsage.audio_duration_seconds)).filter( + TranscriptionUsage.user_id == user_id, + extract('year', TranscriptionUsage.date) == year, + extract('month', TranscriptionUsage.date) == month + ).scalar() + + return result or 0 + + def get_monthly_cost(self, user_id: int, year: int = None, month: int = None) -> float: + """Get total estimated cost for a user in a given month.""" + if year is None: + year = date.today().year + if month is None: + month = date.today().month + + result = db.session.query(func.sum(TranscriptionUsage.estimated_cost)).filter( + TranscriptionUsage.user_id == user_id, + extract('year', TranscriptionUsage.date) == year, + extract('month', TranscriptionUsage.date) == month + ).scalar() + + return result or 0.0 + + def check_budget(self, user_id: int) -> Tuple[bool, float, Optional[str]]: + """ + Check if user is within transcription budget. + + Returns: + (can_proceed, usage_percentage, message) + - can_proceed: False if hard cap (100%) reached + - usage_percentage: 0-100+ + - message: Warning/error message if applicable + """ + try: + user = db.session.get(User, user_id) + if not user or not user.monthly_transcription_budget: + return (True, 0, None) # No budget = unlimited + + current_usage = self.get_monthly_usage(user_id) + budget = user.monthly_transcription_budget + percentage = (current_usage / budget) * 100 + + if percentage >= 100: + minutes_used = current_usage // 60 + minutes_budget = budget // 60 + return (False, percentage, + f"Monthly transcription budget exceeded ({minutes_used}/{minutes_budget} minutes). Contact admin for more time.") + elif percentage >= 80: + return (True, percentage, + f"Warning: {percentage:.1f}% of monthly transcription budget used.") + else: + return (True, percentage, None) + + except Exception as e: + logger.error(f"Failed to check transcription budget for user {user_id}: {e}") + # Fail open - allow the request if we can't check + return (True, 0, None) + + def get_daily_stats(self, days: int = 30, user_id: int = None) -> List[Dict]: + """Get daily transcription usage for charts.""" + start_date = date.today() - timedelta(days=days - 1) + + query = db.session.query( + TranscriptionUsage.date, + TranscriptionUsage.connector_type, + func.sum(TranscriptionUsage.audio_duration_seconds).label('seconds'), + func.sum(TranscriptionUsage.estimated_cost).label('cost') + ).filter(TranscriptionUsage.date >= start_date) + + if user_id: + query = query.filter(TranscriptionUsage.user_id == user_id) + + results = query.group_by(TranscriptionUsage.date, TranscriptionUsage.connector_type).all() + + # Organize by date + stats = {} + for r in results: + date_str = r.date.isoformat() + if date_str not in stats: + stats[date_str] = {'date': date_str, 'total_seconds': 0, 'total_minutes': 0, 'cost': 0.0, 'by_connector': {}} + stats[date_str]['total_seconds'] += r.seconds or 0 + stats[date_str]['total_minutes'] = stats[date_str]['total_seconds'] // 60 + stats[date_str]['cost'] += r.cost or 0 + stats[date_str]['by_connector'][r.connector_type] = { + 'seconds': r.seconds or 0, + 'minutes': (r.seconds or 0) // 60 + } + + # Fill in missing dates with zeros + all_dates = [] + current = start_date + while current <= date.today(): + date_str = current.isoformat() + if date_str not in stats: + stats[date_str] = {'date': date_str, 'total_seconds': 0, 'total_minutes': 0, 'cost': 0.0, 'by_connector': {}} + all_dates.append(date_str) + current += timedelta(days=1) + + return [stats[d] for d in sorted(all_dates)] + + def get_monthly_stats(self, months: int = 12) -> List[Dict]: + """Get monthly transcription usage for charts.""" + results = db.session.query( + extract('year', TranscriptionUsage.date).label('year'), + extract('month', TranscriptionUsage.date).label('month'), + func.sum(TranscriptionUsage.audio_duration_seconds).label('seconds'), + func.sum(TranscriptionUsage.estimated_cost).label('cost') + ).group_by('year', 'month').order_by('year', 'month').all() + + # Get last N months + monthly_data = [ + { + 'year': int(r.year), + 'month': int(r.month), + 'seconds': r.seconds or 0, + 'minutes': (r.seconds or 0) // 60, + 'cost': r.cost or 0 + } + for r in results + ] + + return monthly_data[-months:] if len(monthly_data) > months else monthly_data + + def get_user_stats(self) -> List[Dict]: + """Get per-user transcription usage breakdown for current month.""" + today = date.today() + + results = db.session.query( + User.id, + User.username, + User.monthly_transcription_budget, + func.sum(TranscriptionUsage.audio_duration_seconds).label('usage'), + func.sum(TranscriptionUsage.estimated_cost).label('cost') + ).outerjoin( + TranscriptionUsage, + (User.id == TranscriptionUsage.user_id) & + (extract('year', TranscriptionUsage.date) == today.year) & + (extract('month', TranscriptionUsage.date) == today.month) + ).group_by(User.id).all() + + return [ + { + 'user_id': r.id, + 'username': r.username, + 'monthly_budget_seconds': r.monthly_transcription_budget, + 'monthly_budget_minutes': (r.monthly_transcription_budget // 60) if r.monthly_transcription_budget else None, + 'current_usage_seconds': r.usage or 0, + 'current_usage_minutes': (r.usage or 0) // 60, + 'cost': r.cost or 0, + 'percentage': ((r.usage or 0) / r.monthly_transcription_budget * 100) + if r.monthly_transcription_budget else 0 + } + for r in results + ] + + def get_today_usage(self, user_id: int = None) -> Dict: + """Get today's transcription usage.""" + today = date.today() + + query = db.session.query( + func.sum(TranscriptionUsage.audio_duration_seconds).label('seconds'), + func.sum(TranscriptionUsage.estimated_cost).label('cost') + ).filter(TranscriptionUsage.date == today) + + if user_id: + query = query.filter(TranscriptionUsage.user_id == user_id) + + result = query.first() + + return { + 'seconds': result.seconds or 0, + 'minutes': (result.seconds or 0) // 60, + 'cost': result.cost or 0 + } + + +# Singleton instance +transcription_tracker = TranscriptionTracker() diff --git a/src/tasks/__init__.py b/src/tasks/__init__.py new file mode 100644 index 0000000..408c773 --- /dev/null +++ b/src/tasks/__init__.py @@ -0,0 +1,30 @@ +""" +Background task functions for asynchronous processing. + +Note: Legacy functions (transcribe_audio_asr, transcribe_single_file, transcribe_with_chunking) +were removed. All transcription now uses the connector architecture via transcribe_audio_task. +""" + +from .processing import ( + generate_title_task, + generate_summary_only_task, + extract_events_from_transcript, + extract_audio_from_video, + compress_lossless_audio, + transcribe_audio_task, + transcribe_with_connector, + transcribe_chunks_with_connector, + transcribe_incognito, +) + +__all__ = [ + 'generate_title_task', + 'generate_summary_only_task', + 'extract_events_from_transcript', + 'extract_audio_from_video', + 'compress_lossless_audio', + 'transcribe_audio_task', + 'transcribe_with_connector', + 'transcribe_chunks_with_connector', + 'transcribe_incognito', +] diff --git a/src/tasks/processing.py b/src/tasks/processing.py new file mode 100644 index 0000000..14edcb8 --- /dev/null +++ b/src/tasks/processing.py @@ -0,0 +1,2232 @@ +""" +Background task functions for audio processing, transcription, and summarization. + +These functions handle asynchronous processing tasks: +- Audio transcription (Whisper API and custom ASR endpoints) +- Title and summary generation +- Event extraction from transcripts +- Audio/video format conversion +""" + +import os +import re +import json +import time +import mimetypes +import tempfile +import subprocess +import httpx +from datetime import datetime +from flask import current_app +from openai import OpenAI + +from src.database import db +from src.models import Recording, Tag, Event, TranscriptChunk, SystemSetting, GroupMembership, RecordingTag, InternalShare, SharedRecordingState, User, NamingTemplate +from src.services.embeddings import process_recording_chunks +from src.services.llm import is_using_openai_api, call_llm_completion, format_api_error_message, TEXT_MODEL_NAME, client, http_client_no_proxy +from src.utils import extract_json_object, safe_json_loads +from src.utils.ffprobe import get_codec_info, is_video_file, is_lossless_audio, FFProbeError +from src.utils.ffmpeg_utils import convert_to_mp3, extract_audio_from_video as ffmpeg_extract_audio, compress_audio, FFmpegError, FFmpegNotFoundError +from src.utils.audio_conversion import convert_if_needed, ConversionResult +from src.utils.error_formatting import format_error_for_storage +from src.config.app_config import AUDIO_COMPRESS_UPLOADS, AUDIO_CODEC, AUDIO_BITRATE, VIDEO_PASSTHROUGH_ASR +from src.audio_chunking import AudioChunkingService, ChunkProcessingError, ChunkingNotSupportedError +from src.config.app_config import ( + ASR_DIARIZE, ASR_BASE_URL, ASR_RETURN_SPEAKER_EMBEDDINGS, + transcription_api_key, transcription_base_url, chunking_service, ENABLE_CHUNKING +) +from src.file_exporter import export_recording, ENABLE_AUTO_EXPORT +from src.services.transcription_tracking import transcription_tracker + +# Configuration for internal sharing +ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true' + +# Video retention - when enabled, video files keep their video stream for playback +VIDEO_RETENTION = os.environ.get('VIDEO_RETENTION', 'false').lower() == 'true' + + +def apply_team_tag_auto_shares(recording_id): + """ + Apply auto-shares for all group tags on a recording after processing completes. + + This function should be called after a recording status changes to COMPLETED. + It creates InternalShare records for team members based on group tag settings. + + Args: + recording_id: ID of the recording to apply auto-shares for + """ + if not ENABLE_INTERNAL_SHARING: + return + + recording = db.session.get(Recording, recording_id) + if not recording: + return + + # Get all group tags on this recording with auto-share enabled + group_tags = db.session.query(Tag).join( + RecordingTag, RecordingTag.tag_id == Tag.id + ).filter( + RecordingTag.recording_id == recording_id, + Tag.group_id.isnot(None), + db.or_(Tag.auto_share_on_apply == True, Tag.share_with_group_lead == True) + ).all() + + if not group_tags: + return + + shares_created = 0 + + for tag in group_tags: + # Determine who to share with + if tag.auto_share_on_apply: + group_members = GroupMembership.query.filter_by(group_id=tag.group_id).all() + elif tag.share_with_group_lead: + group_members = GroupMembership.query.filter_by(group_id=tag.group_id, role='admin').all() + else: + continue + + for membership in group_members: + # Skip the recording owner + if membership.user_id == recording.user_id: + continue + + # Check if already shared + existing_share = InternalShare.query.filter_by( + recording_id=recording_id, + shared_with_user_id=membership.user_id + ).first() + + if not existing_share: + # Create internal share with correct permissions + # Group admins get edit permission, regular members get read-only + share = InternalShare( + recording_id=recording_id, + owner_id=recording.user_id, + shared_with_user_id=membership.user_id, + can_edit=(membership.role == 'admin'), + can_reshare=False, + source_type='group_tag', + source_tag_id=tag.id + ) + db.session.add(share) + + # Create SharedRecordingState with default values for the recipient + state = SharedRecordingState( + recording_id=recording_id, + user_id=membership.user_id, + is_inbox=True, # New shares appear in inbox by default + is_highlighted=False # Not favorited by default + ) + db.session.add(state) + + shares_created += 1 + current_app.logger.info(f"Auto-shared recording {recording_id} with user {membership.user_id} (role={membership.role}) via group tag '{tag.name}'") + + if shares_created > 0: + db.session.commit() + current_app.logger.info(f"Created {shares_created} auto-shares for recording {recording_id} after processing completed") + + +def format_transcription_for_llm(transcription_text): + """ + Formats transcription for LLM. If it's our simplified JSON, convert it to plain text. + Otherwise, return as is. + """ + try: + transcription_data = json.loads(transcription_text) + if isinstance(transcription_data, list): + # It's our simplified JSON format + formatted_lines = [] + for segment in transcription_data: + speaker = segment.get('speaker', 'Unknown Speaker') + sentence = segment.get('sentence', '') + formatted_lines.append(f"[{speaker}]: {sentence}") + return "\n".join(formatted_lines) + except (json.JSONDecodeError, TypeError): + # Not a JSON, or not the format we expect, so return as is. + pass + return transcription_text + + +def clean_llm_response(text): + """ + Clean LLM responses by removing thinking tags and excessive whitespace. + This handles responses from reasoning models that include tags. + """ + if not text: + return "" + + # Remove thinking tags and their content + # Handle both and tags with various closing formats + cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + + # Also handle unclosed thinking tags (in case the model doesn't close them) + cleaned = re.sub(r'.*$', '', cleaned, flags=re.DOTALL | re.IGNORECASE) + + # Remove any remaining XML-like tags that might be related to thinking + # but preserve markdown formatting + cleaned = re.sub(r'<(?!/?(?:code|pre|blockquote|p|br|hr|ul|ol|li|h[1-6]|em|strong|b|i|a|img)(?:\s|>|/))[^>]+>', '', cleaned) + + # Clean up excessive whitespace while preserving intentional formatting + # Handle lines individually to preserve Markdown hard line breaks (two spaces at end) + lines = cleaned.split('\n') + cleaned_lines = [] + + for line in lines: + # If a line consists only of whitespace (e.g., after tag removal), + # make it completely empty. This is necessary for the \n{3,} regex to work later. + if not line.strip(): + cleaned_lines.append("") + else: + # Preserve lines containing text exactly as they are + # This keeps trailing spaces needed for Markdown hard line breaks intact + cleaned_lines.append(line) + + # Join lines and collapse 3+ consecutive newlines into exactly 2 (one blank line) + cleaned = '\n'.join(cleaned_lines) + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned) + + # Final strip to remove leading/trailing whitespace + return cleaned.strip() + +# Configuration from environment +# Note: Legacy ASR paths (USE_ASR_ENDPOINT, transcribe_audio_asr, etc.) were removed. +# All transcription now uses the connector architecture via transcribe_with_connector(). +ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true' + +# chunking_service, ENABLE_CHUNKING, transcription_api_key, and transcription_base_url +# are imported from src.config.app_config + +# Note: OpenAI clients are created inside each transcription function as needed, +# not at module level (matching original pre-refactor behavior) + + +def generate_title_task(app_context, recording_id, will_auto_summarize=False): + """Generates only a title for a recording based on transcription. + + Args: + app_context: Flask app context + recording_id: ID of the recording + will_auto_summarize: If True, don't set status to COMPLETED (summary task will do it) + """ + with app_context: + recording = db.session.get(Recording, recording_id) + if not recording: + current_app.logger.error(f"Error: Recording {recording_id} not found for title generation.") + return + + # Resolve naming template: first tag with template → user default → None + naming_template = None + for tag in recording.tags: + if tag.naming_template_id: + naming_template = tag.naming_template + current_app.logger.info(f"Using naming template '{naming_template.name}' from tag '{tag.name}' for recording {recording_id}") + break + + if not naming_template and recording.owner and recording.owner.default_naming_template_id: + naming_template = recording.owner.default_naming_template + if naming_template: + current_app.logger.info(f"Using user's default naming template '{naming_template.name}' for recording {recording_id}") + + # Check if we need to generate AI title + needs_ai_title = naming_template is None or naming_template.needs_ai_title() + + # Early exit conditions + if not needs_ai_title: + # Template doesn't need AI - we can skip LLM call entirely + current_app.logger.info(f"Naming template doesn't require AI title for recording {recording_id}, skipping LLM call") + ai_title = None + elif client is None: + current_app.logger.warning(f"Skipping AI title generation for {recording_id}: OpenRouter client not configured.") + ai_title = None + elif not recording.transcription or len(recording.transcription.strip()) < 10: + current_app.logger.warning(f"Transcription for recording {recording_id} is too short or empty. Skipping AI title generation.") + ai_title = None + else: + # Generate AI title via LLM + ai_title = _generate_ai_title(recording) + + # Apply naming template if we have one + final_title = None + if naming_template: + final_title = naming_template.apply( + original_filename=recording.original_filename, + meeting_date=recording.meeting_date, + ai_title=ai_title + ) + if final_title: + current_app.logger.info(f"Applied naming template for recording {recording_id}: '{final_title}'") + + # Fallback chain: template result → AI title → filename + if not final_title: + if ai_title: + final_title = ai_title + elif recording.original_filename: + # Use filename without extension as last resort + import os + final_title = os.path.splitext(recording.original_filename)[0] + current_app.logger.info(f"Using filename as title for recording {recording_id}: '{final_title}'") + + if final_title: + recording.title = final_title + current_app.logger.info(f"Title set for recording {recording_id}: {final_title}") + else: + current_app.logger.warning(f"Could not generate title for recording {recording_id}") + + # Only set status to COMPLETED if auto-summarization won't happen next + # If auto-summarization is enabled, the summary task will set COMPLETED + if not will_auto_summarize: + recording.status = 'COMPLETED' + recording.completed_at = datetime.utcnow() + db.session.commit() + current_app.logger.info(f"Title generation complete, status set to COMPLETED for recording {recording_id}") + + # Process chunks for semantic search after completion (if inquire mode is enabled) + if ENABLE_INQUIRE_MODE: + try: + process_recording_chunks(recording_id) + except Exception as e: + current_app.logger.error(f"Error processing chunks for completed recording {recording_id}: {e}") + else: + # Just commit the title without changing status + db.session.commit() + current_app.logger.info(f"Title generation complete, leaving status unchanged (auto-summarization will follow) for recording {recording_id}") + + +def _generate_ai_title(recording): + """Generate an AI title for a recording using LLM. + + Args: + recording: Recording model instance + + Returns: + Generated title string, or None if generation fails + """ + # Get configurable transcript length limit and format transcription for LLM + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + raw_transcription = recording.transcription + else: + raw_transcription = recording.transcription[:transcript_limit] + + # Convert ASR JSON to clean text format + transcript_text = format_transcription_for_llm(raw_transcription) + + # Get user language preference + user_output_language = None + if recording.owner: + user_output_language = recording.owner.output_language + + language_directive = f"Réponds en {user_output_language}." if user_output_language else "Réponds en français." + + prompt_text = f"""Génère un titre court pour cette transcription : + +{transcript_text} + +Consignes : +- Maximum 8 mots +- Pas de formules génériques comme "Discussion sur" ou "Réunion concernant" +- Le sujet principal uniquement, direct et précis +- {language_directive} + +Titre :""" + + system_message_content = "Tu es un assistant qui génère des titres concis pour des transcriptions audio. Réponds uniquement avec le titre, sans ponctuation finale." + if user_output_language: + system_message_content += f" Réponds en {user_output_language}." + else: + system_message_content += " Réponds en français." + + try: + completion = call_llm_completion( + messages=[ + {"role": "system", "content": system_message_content}, + {"role": "user", "content": prompt_text} + ], + temperature=0.7, + max_tokens=5000, + user_id=recording.user_id, + operation_type='title_generation' + ) + + raw_response = completion.choices[0].message.content + reasoning = getattr(completion.choices[0].message, 'reasoning', None) + + # Use reasoning content if main content is empty (fallback for reasoning models) + if not raw_response and reasoning: + current_app.logger.info(f"Title generation for recording {recording.id}: Using reasoning field as fallback") + # Try to extract a title from the reasoning field + lines = reasoning.strip().split('\n') + # Look for the last line that might be the title + for line in reversed(lines): + line = line.strip() + if line and not line.startswith('I') and len(line.split()) <= 8: + raw_response = line + break + + title = clean_llm_response(raw_response) if raw_response else None + + if title: + current_app.logger.info(f"AI title generated for recording {recording.id}: {title}") + else: + current_app.logger.warning(f"Empty AI title generated for recording {recording.id}") + + return title + + except Exception as e: + current_app.logger.error(f"Error generating AI title for recording {recording.id}: {str(e)}") + current_app.logger.error(f"Exception details:", exc_info=True) + return None + + +def generate_summary_only_task(app_context, recording_id, custom_prompt_override=None, user_id=None): + """Generates only a summary for a recording (no title, no JSON response). + + Args: + app_context: Flask app context + recording_id: ID of the recording + custom_prompt_override: Optional custom prompt that overrides all other prompts (for reprocessing) + user_id: Optional user ID to filter tag visibility (defaults to recording owner) + """ + with app_context: + recording = db.session.get(Recording, recording_id) + if not recording: + current_app.logger.error(f"Error: Recording {recording_id} not found for summary generation.") + return + + if client is None: + current_app.logger.warning(f"Skipping summary generation for {recording_id}: OpenRouter client not configured.") + recording.summary = "[Summary skipped: OpenRouter client not configured]" + db.session.commit() + return + + recording.status = 'SUMMARIZING' + summarization_start_time = time.time() + db.session.commit() + + current_app.logger.info(f"Requesting summary from OpenRouter for recording {recording_id} using model {TEXT_MODEL_NAME}...") + + if not recording.transcription or len(recording.transcription.strip()) < 10: + current_app.logger.warning(f"Transcription for recording {recording_id} is too short or empty. Skipping summarization.") + recording.summary = "[Summary skipped due to short transcription]" + recording.status = 'COMPLETED' + db.session.commit() + return + + # Get user preferences and tag custom prompts + user_summary_prompt = None + user_output_language = None + tag_custom_prompt = None + + # Determine which user's perspective to use for tag visibility + # If user_id is provided (e.g., from reprocess), use that user + # Otherwise default to the recording owner + viewer_user = None + if user_id: + viewer_user = db.session.get(User, user_id) + if viewer_user: + current_app.logger.info(f"Using user {viewer_user.username} (ID: {user_id}) for tag visibility filtering") + else: + current_app.logger.warning(f"User ID {user_id} not found, falling back to recording owner") + viewer_user = recording.owner + else: + viewer_user = recording.owner + if viewer_user: + current_app.logger.info(f"Using recording owner {viewer_user.username} for tag visibility filtering") + + # Collect custom prompts from tags visible to the viewer user + tag_custom_prompts = [] + if viewer_user: + visible_tags = recording.get_visible_tags(viewer_user) + if visible_tags: + current_app.logger.info(f"Found {len(visible_tags)} visible tags for user {viewer_user.username} on recording {recording_id}") + # Tags are ordered by the order they were added to this recording + for tag in visible_tags: + if tag.custom_prompt and tag.custom_prompt.strip(): + tag_custom_prompts.append({ + 'name': tag.name, + 'prompt': tag.custom_prompt.strip() + }) + current_app.logger.info(f"Found custom prompt from tag '{tag.name}' for recording {recording_id}") + else: + current_app.logger.warning(f"No viewer user available for tag filtering on recording {recording_id}") + + # Create merged prompt if we have multiple tag prompts + if tag_custom_prompts: + if len(tag_custom_prompts) == 1: + tag_custom_prompt = tag_custom_prompts[0]['prompt'] + current_app.logger.info(f"Using single custom prompt from tag '{tag_custom_prompts[0]['name']}' for recording {recording_id}") + else: + # Merge multiple prompts seamlessly as unified instructions + merged_parts = [] + for tag_prompt in tag_custom_prompts: + merged_parts.append(tag_prompt['prompt']) + tag_custom_prompt = "\n\n".join(merged_parts) + tag_names = [tp['name'] for tp in tag_custom_prompts] + current_app.logger.info(f"Combined custom prompts from {len(tag_custom_prompts)} tags in order added ({', '.join(tag_names)}) for recording {recording_id}") + else: + tag_custom_prompt = None + + # Get folder custom prompt (if recording has a folder) + # Folder prompt has lower priority than tag prompts (tags override folders) + folder_custom_prompt = None + if recording.folder and recording.folder.custom_prompt and recording.folder.custom_prompt.strip(): + folder_custom_prompt = recording.folder.custom_prompt.strip() + current_app.logger.info(f"Found custom prompt from folder '{recording.folder.name}' for recording {recording_id}") + + if recording.owner: + user_summary_prompt = recording.owner.summary_prompt + user_output_language = recording.owner.output_language + + # Format transcription for LLM (convert JSON to clean text format like clipboard copy) + formatted_transcription = format_transcription_for_llm(recording.transcription) + + # Get configurable transcript length limit + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + transcript_text = formatted_transcription + else: + transcript_text = formatted_transcription[:transcript_limit] + + language_directive = f"IMPORTANT: You MUST provide the summary in {user_output_language}. The entire response must be in {user_output_language}." if user_output_language else "" + + # Determine which summarization instructions to use + # Priority order: custom_prompt_override > tag custom prompt > folder custom prompt > user summary prompt > admin default prompt > hardcoded fallback + summarization_instructions = "" + if custom_prompt_override: + current_app.logger.info(f"Using custom prompt override for recording {recording_id} (length: {len(custom_prompt_override)})") + summarization_instructions = custom_prompt_override + elif tag_custom_prompt: + current_app.logger.info(f"Using tag custom prompt for recording {recording_id}") + summarization_instructions = tag_custom_prompt + elif folder_custom_prompt: + current_app.logger.info(f"Using folder custom prompt for recording {recording_id}") + summarization_instructions = folder_custom_prompt + elif user_summary_prompt: + current_app.logger.info(f"Using user custom prompt for recording {recording_id}") + summarization_instructions = user_summary_prompt + else: + # Get admin default prompt from system settings + admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None) + if admin_default_prompt: + current_app.logger.info(f"Using admin default prompt for recording {recording_id}") + summarization_instructions = admin_default_prompt + else: + # Fallback to hardcoded default if admin hasn't set one + summarization_instructions = """Génère un résumé structuré incluant les sections suivantes : +- **Sujets principaux** : Liste à puces des thèmes abordés +- **Décisions prises** : Liste à puces des décisions convenues +- **Actions à faire** : Liste à puces des tâches assignées, avec le responsable si mentionné""" + current_app.logger.info(f"Using hardcoded default prompt for recording {recording_id}") + + # Build context information + current_date = datetime.now().strftime("%B %d, %Y") + context_parts = [] + context_parts.append(f"Date courante : {current_date}") + + # Add folder information if recording is in a folder + if recording.folder: + context_parts.append(f"Dossier : {recording.folder.name}") + + # Add selected tags information (only visible tags) + if viewer_user: + visible_tags = recording.get_visible_tags(viewer_user) + if visible_tags: + tag_names = [tag.name for tag in visible_tags] + context_parts.append(f"Étiquettes appliquées à cette transcription : {', '.join(tag_names)}") + + # Add user profile information if available + if recording.owner: + user_context_parts = [] + if recording.owner.name: + user_context_parts.append(f"Nom : {recording.owner.name}") + if recording.owner.job_title: + user_context_parts.append(f"Poste : {recording.owner.job_title}") + if recording.owner.company: + user_context_parts.append(f"Entreprise : {recording.owner.company}") + + if user_context_parts: + context_parts.append(f"Informations sur l'utilisateur : {', '.join(user_context_parts)}") + + context_section = "Contexte :\n" + "\n".join(f"- {part}" for part in context_parts) + + # Build SYSTEM message: Initial instructions + Context + Language + system_message_content = "Tu es un assistant expert en rédaction de comptes rendus. Génère des résumés structurés de transcriptions audio. Réponds uniquement en Markdown brut — pas de blocs de code (```markdown), pas d'explications." + system_message_content += f"\n\n{context_section}" + if user_output_language: + system_message_content += f"\n\nExigence linguistique : tu DOIS rédiger l'intégralité du résumé en {user_output_language}. C'est obligatoire." + + # Build USER message: Transcription + Summarization Instructions + Language Directive + prompt_text = f"""Transcription : +\"\"\" +{transcript_text} +\"\"\" + +Instructions de résumé : +{summarization_instructions} + +{language_directive}""" + + # Debug logging: Log the complete prompt being sent to the LLM + current_app.logger.info(f"Sending summarization prompt to LLM (length: {len(prompt_text)} chars). Set LOG_LEVEL=DEBUG to see full prompt details.") + current_app.logger.debug(f"=== SUMMARIZATION DEBUG for recording {recording_id} ===") + current_app.logger.debug(f"System message: {system_message_content}") + current_app.logger.debug(f"User prompt (length: {len(prompt_text)} chars):\n{prompt_text}") + current_app.logger.debug(f"=== END SUMMARIZATION DEBUG for recording {recording_id} ===") + + try: + completion = call_llm_completion( + messages=[ + {"role": "system", "content": system_message_content}, + {"role": "user", "content": prompt_text} + ], + temperature=0.5, + max_tokens=int(os.environ.get("SUMMARY_MAX_TOKENS", "3000")), + user_id=recording.user_id, + operation_type='summarization' + ) + + raw_response = completion.choices[0].message.content + current_app.logger.info(f"Raw LLM response for recording {recording_id}: '{raw_response}'") + + summary = clean_llm_response(raw_response) if raw_response else "" + current_app.logger.info(f"Processed summary length for recording {recording_id}: {len(summary)} characters") + + if summary: + recording.summary = summary + db.session.commit() + current_app.logger.info(f"Summary generated successfully for recording {recording_id}") + + # Extract events if enabled for this user BEFORE marking as completed + if recording.owner and recording.owner.extract_events: + extract_events_from_transcript(recording_id, formatted_transcription, summary) + + # Mark as completed AFTER event extraction + recording.status = 'COMPLETED' + recording.completed_at = datetime.utcnow() + # Calculate and save summarization duration + summarization_end_time = time.time() + recording.summarization_duration_seconds = int(summarization_end_time - summarization_start_time) + db.session.commit() + current_app.logger.info(f"Summarization completed for recording {recording_id} in {recording.summarization_duration_seconds}s.") + + # Apply auto-shares for group tags after processing completes + apply_team_tag_auto_shares(recording_id) + + # Export to file if auto-export is enabled + if ENABLE_AUTO_EXPORT: + export_recording(recording_id) + else: + current_app.logger.warning(f"Empty summary generated for recording {recording_id}") + recording.summary = "[Summary not generated]" + recording.status = 'COMPLETED' + # Calculate and save summarization duration even for empty summary + summarization_end_time = time.time() + recording.summarization_duration_seconds = int(summarization_end_time - summarization_start_time) + db.session.commit() + + # Apply auto-shares for group tags after processing completes + apply_team_tag_auto_shares(recording_id) + + # Export to file if auto-export is enabled (even with empty summary, transcription may be useful) + if ENABLE_AUTO_EXPORT: + export_recording(recording_id) + + except Exception as e: + error_msg = format_api_error_message(str(e)) + current_app.logger.error(f"Error generating summary for recording {recording_id}: {str(e)}") + recording.summary = error_msg + recording.status = 'FAILED' + db.session.commit() + + +def extract_events_from_transcript(recording_id, transcript_text, summary_text): + """Extract calendar events from transcript using LLM. + + Args: + recording_id: ID of the recording + transcript_text: The formatted transcript text + summary_text: The generated summary text + """ + try: + recording = db.session.get(Recording, recording_id) + if not recording or not recording.owner or not recording.owner.extract_events: + return # Event extraction not enabled for this user + + current_app.logger.info(f"Extracting events for recording {recording_id}") + + # Delete existing events for this recording before extracting new ones + existing_events = Event.query.filter_by(recording_id=recording_id).all() + if existing_events: + current_app.logger.info(f"Clearing {len(existing_events)} existing events for recording {recording_id}") + for event in existing_events: + db.session.delete(event) + db.session.commit() + + # Get user language preference + user_output_language = None + if recording.owner: + user_output_language = recording.owner.output_language + + # Build comprehensive context information + current_date = datetime.now() + context_parts = [] + + # CRITICAL: Determine the reference date for relative date calculations + reference_date = None + reference_date_source = "" + + if recording.meeting_date: + # Prefer meeting date if available + reference_date = recording.meeting_date + reference_date_source = "Meeting Date" + context_parts.append(f"**DATE DE RÉUNION (utiliser pour les calculs de dates relatives) : {recording.meeting_date.strftime('%A, %B %d, %Y')}**") + elif recording.created_at: + # Fall back to upload date + reference_date = recording.created_at.date() + reference_date_source = "Upload Date (no meeting date available)" + context_parts.append(f"**DATE DE RÉFÉRENCE (utiliser pour les calculs de dates relatives) : {recording.created_at.strftime('%A, %B %d, %Y')}**") + + context_parts.append(f"Date d'aujourd'hui : {current_date.strftime('%A, %B %d, %Y')}") + context_parts.append(f"Heure actuelle : {current_date.strftime('%I:%M %p')}") + + # Add additional recording context + if recording.created_at: + context_parts.append(f"Enregistrement téléversé le : {recording.created_at.strftime('%B %d, %Y at %I:%M %p')}") + if recording.meeting_date and reference_date_source == "Meeting Date": + # Calculate days between meeting and today for context + # Ensure both sides are date objects (meeting_date might be datetime or date) + meeting_date_obj = recording.meeting_date.date() if isinstance(recording.meeting_date, datetime) else recording.meeting_date + days_since = (current_date.date() - meeting_date_obj).days + if days_since == 0: + context_parts.append("Cette réunion a eu lieu aujourd'hui") + elif days_since == 1: + context_parts.append("Cette réunion a eu lieu hier") + else: + context_parts.append(f"Cette réunion a eu lieu il y a {days_since} jours") + + # Add user context for better understanding + if recording.owner: + user_context = [] + if recording.owner.name: + user_context.append(f"Nom : {recording.owner.name}") + if recording.owner.job_title: + user_context.append(f"Poste : {recording.owner.job_title}") + if recording.owner.company: + user_context.append(f"Entreprise : {recording.owner.company}") + if user_context: + context_parts.append("Informations sur l'utilisateur : " + ", ".join(user_context)) + + # Add participants if available + if recording.participants: + context_parts.append(f"Participants à la réunion : {recording.participants}") + + context_section = "\n".join(context_parts) + + # Add language directive if user has a language preference + language_directive = "" + if user_output_language: + language_directive = f"\n\nEXIGENCE LINGUISTIQUE :\n**CRITIQUE** : Tu DOIS rédiger TOUS les titres et descriptions d'événements en {user_output_language}. C'est obligatoire. L'intégralité du contenu (titre, description, lieu) doit être en {user_output_language}." + + # Prepare the prompt for event extraction + event_prompt = f"""Analyse cette transcription de réunion pour en extraire les événements de calendrier. Utilise le contexte ci-dessous pour interpréter correctement les dates et heures relatives. + +CONTEXTE : +{context_section}{language_directive} + +INSTRUCTIONS : +1. **CRITIQUE** : Utilise la DATE DE RÉUNION ci-dessus comme point de référence pour TOUS les calculs de dates relatives +2. Quand quelqu'un dit « mercredi prochain », « demain » ou « la semaine prochaine », calcule à partir de la DATE DE RÉUNION, pas d'aujourd'hui +3. Exemple : Si la réunion est le 13 septembre 2025 et quelqu'un dit « mercredi prochain », c'est le 17 septembre 2025 +4. Si aucune heure n'est mentionnée, utilise 09:00:00 par défaut +5. Tiens compte des fuseaux horaires si mentionnés +6. Extrais UNIQUEMENT les événements explicitement discutés comme rendez-vous, réunions ou échéances futurs +7. N'extrais PAS les événements passés ni les discussions générales + +CRITÈRES STRICTS — Un événement DOIT avoir : +- Un mot d'action explicite (réunion, rendez-vous, appel, échéance, entrevue, présentation, révision, etc.) +- Une date/heure spécifique ou calculable +- Une durée raisonnable (moins de 8 heures, sauf événement multi-jours explicite) +- Un objectif ou ordre du jour clair + +NE PAS EXTRAIRE : +- Plans à long terme (périodes d'études, contrats, échéanciers de projets sur semaines/mois/années) +- Intentions générales sans planification concrète (« je vais étudier ici un an », « je vais travailler là-dessus ») +- Lieux déduits ou supposés — utiliser uniquement les lieux explicitement mentionnés +- Engagements vagues sans heure concrète (« on devrait se voir », « on se rappelle bientôt ») +- Événements personnels non discutés comme rendez-vous planifiés +- Événements nécessitant de deviner des détails critiques + +Pour chaque événement trouvé, extraire : +- Title : Titre clair et concis +- Description : Brève description avec le contexte de la réunion +- Start date/time : Date/heure calculée (format ISO YYYY-MM-DDTHH:MM:SS, utiliser 09:00:00 si aucune heure) +- End date/time : Fin de l'événement (format ISO, défaut 1 heure après le début) +- Location : Lieu (si mentionné) +- Attendees : Liste des personnes concernées (si mentionnées) +- Reminder minutes : Rappel en minutes avant l'événement (défaut 1 jour = 1440) + +Résumé de la transcription : +{summary_text} + +Extrait de la transcription (contexte additionnel) : +{transcript_text[:8000]} + +FORMAT DE RÉPONSE : +Réponds avec un objet JSON contenant un tableau "events". Si aucun événement, retourne un objet avec un tableau vide. + +Exemple : +{{ + "events": [ + {{ + "title": "Revue de projet trimestrielle", + "description": "Discussion sur l'avancement du projet et les prochaines étapes", + "start_datetime": "2025-07-22T14:00:00", + "end_datetime": "2025-07-22T15:30:00", + "location": "Salle de conférence A", + "attendees": ["Jean Tremblay", "Marie Lavoie"], + "reminder_minutes": 15 + }} + ] +}} + +EXEMPLES NÉGATIFS — NE PAS extraire : + +❌ « Je vais étudier ici pendant un an » → PAS un événement (plan à long terme) +❌ « Je travaille sur ce projet jusqu'en mars » → PAS un événement (durée, pas une réunion) +❌ « On devrait prendre un café un moment donné » → PAS un événement (vague, pas d'heure) +❌ « La session commence en septembre » → PAS un événement (information générale) +❌ « J'ai déménagé de Montréal » → PAS un événement (passé) + +✅ « On se voit mardi prochain à 14h pour réviser la proposition » → OUI (heure spécifique, action, objectif clair) +✅ « La date limite de soumission est vendredi à 17h » → OUI (échéance spécifique) +✅ « J'ai un rendez-vous chez le médecin demain à 10h » → OUI (rendez-vous spécifique) + +RÈGLES CRITIQUES : +1. **BASE TOUS LES CALCULS DE DATES SUR LA DATE DE RÉUNION FOURNIE DANS LE CONTEXTE** +2. Extrais uniquement les événements FUTURS par rapport à la DATE DE RÉUNION +3. Convertis toutes les dates relatives en utilisant la DATE DE RÉUNION comme référence +4. Si aucune heure n'est mentionnée, utilise toujours 09:00:00 (9h), PAS minuit +5. Inclus le contexte de la discussion dans la description +6. N'invente et ne suppose AUCUN événement non explicitement discuté +7. En cas de doute sur une date/heure, n'inclus pas cet événement""" + + # Build system message with language requirement if applicable + system_message_content = """Tu es un expert en extraction d'événements de calendrier à partir de transcriptions de réunions. Tu excelles à : +1. Interpréter les références de dates relatives (« mardi prochain », « demain », « dans deux semaines ») et les convertir en dates absolues +2. Identifier les vrais rendez-vous, réunions et échéances futurs dans les conversations +3. Distinguer les événements planifiés réels des discussions générales +4. Extraire avec précision les noms des participants et les détails des réunions + +Tu dois répondre uniquement en format JSON valide.""" + + if user_output_language: + system_message_content += f"\n\nExigence linguistique : tu DOIS rédiger TOUS les titres, descriptions et lieux d'événements en {user_output_language}. C'est obligatoire." + + completion = call_llm_completion( + messages=[ + {"role": "system", "content": system_message_content}, + {"role": "user", "content": event_prompt} + ], + temperature=0.2, + max_tokens=3000, + user_id=recording.user_id, + operation_type='event_extraction' + ) + + response_content = completion.choices[0].message.content + events_data = safe_json_loads(response_content, {}) + + # Handle both {"events": [...]} and direct array format + if isinstance(events_data, dict) and 'events' in events_data: + events_list = events_data['events'] + elif isinstance(events_data, list): + events_list = events_data + else: + events_list = [] + + current_app.logger.info(f"Found {len(events_list)} events for recording {recording_id}") + + # Save events to database + for event_data in events_list: + try: + # Parse dates + start_dt = None + end_dt = None + + if 'start_datetime' in event_data: + try: + # Try ISO format first + start_dt = datetime.fromisoformat(event_data['start_datetime'].replace('Z', '+00:00')) + except: + # Try other common formats + from dateutil import parser + try: + start_dt = parser.parse(event_data['start_datetime']) + except: + current_app.logger.warning(f"Could not parse start_datetime: {event_data['start_datetime']}") + continue # Skip this event if we can't parse the date + + if 'end_datetime' in event_data and event_data['end_datetime']: + try: + end_dt = datetime.fromisoformat(event_data['end_datetime'].replace('Z', '+00:00')) + except: + from dateutil import parser + try: + end_dt = parser.parse(event_data['end_datetime']) + except: + pass # End time is optional + + # Create event record + event = Event( + recording_id=recording_id, + title=event_data.get('title', 'Untitled Event')[:200], + description=event_data.get('description', ''), + start_datetime=start_dt, + end_datetime=end_dt, + location=event_data.get('location', '')[:500] if event_data.get('location') else None, + attendees=json.dumps(event_data.get('attendees', [])) if event_data.get('attendees') else None, + reminder_minutes=event_data.get('reminder_minutes', 15) + ) + + db.session.add(event) + current_app.logger.info(f"Added event '{event.title}' for recording {recording_id}") + + except Exception as e: + current_app.logger.error(f"Error saving event for recording {recording_id}: {str(e)}") + continue + + db.session.commit() + + # Refresh the recording to ensure events relationship is loaded + recording = db.session.get(Recording, recording_id) + if recording: + db.session.refresh(recording) + + except Exception as e: + current_app.logger.error(f"Error extracting events for recording {recording_id}: {str(e)}") + db.session.rollback() + + +def extract_audio_from_video(video_filepath, output_format='mp3', cleanup_original=True): + """Extract audio from video containers using FFmpeg. + + Behavior depends on AUDIO_COMPRESS_UPLOADS setting AND codec support: + - If compression enabled: Re-encodes to specified format (mp3/flac/opus) + - If compression disabled AND codec is supported: Copies stream (fast, preserves quality) + - If compression disabled AND codec is NOT supported: Re-encodes to ensure compatibility + + Args: + video_filepath: Path to input video file + output_format: Audio format ('mp3', 'wav', 'flac', 'copy'), default 'mp3' + cleanup_original: If True, deletes original video after extraction + + Returns: + tuple: (audio_filepath, mime_type) + + Raises: + FFmpegError: If audio extraction fails + FFmpegNotFoundError: If FFmpeg is not installed + """ + from src.utils.audio_conversion import get_supported_codecs + + try: + # Check if we can copy the stream (only if codec is supported) + can_copy_stream = False + if not AUDIO_COMPRESS_UPLOADS: + # Probe the video to check audio codec + try: + codec_info = get_codec_info(video_filepath, timeout=10) + audio_codec = codec_info.get('audio_codec') + supported_codecs = get_supported_codecs(needs_chunking=False) + + if audio_codec and audio_codec in supported_codecs: + can_copy_stream = True + current_app.logger.info(f"Audio codec '{audio_codec}' is supported, can copy stream") + else: + current_app.logger.info(f"Audio codec '{audio_codec}' not in supported codecs {supported_codecs}, will re-encode") + except FFProbeError as e: + current_app.logger.warning(f"Failed to probe video codec: {e}. Will re-encode to be safe.") + + if AUDIO_COMPRESS_UPLOADS: + # Re-encode to configured codec + current_app.logger.info(f"Extracting and compressing audio from video: {video_filepath} (codec: {AUDIO_CODEC})") + audio_filepath, mime_type = ffmpeg_extract_audio( + video_filepath, + output_format=AUDIO_CODEC, + bitrate=AUDIO_BITRATE, + cleanup_original=cleanup_original, + copy_stream=False + ) + elif can_copy_stream: + # Copy audio stream without re-encoding (fast, preserves quality) + current_app.logger.info(f"Extracting audio from video (stream copy, no re-encoding): {video_filepath}") + audio_filepath, mime_type = ffmpeg_extract_audio( + video_filepath, + output_format='copy', + cleanup_original=cleanup_original, + copy_stream=True + ) + else: + # Codec not supported - must re-encode for compatibility + current_app.logger.info(f"Extracting and converting audio from video: {video_filepath} (codec: {AUDIO_CODEC})") + audio_filepath, mime_type = ffmpeg_extract_audio( + video_filepath, + output_format=AUDIO_CODEC, + bitrate=AUDIO_BITRATE, + cleanup_original=cleanup_original, + copy_stream=False + ) + + current_app.logger.info(f"Successfully extracted audio to {audio_filepath}") + return audio_filepath, mime_type + + except FFmpegNotFoundError as e: + current_app.logger.error(str(e)) + raise Exception("Audio conversion tool (FFmpeg) not found on server.") + except FFmpegError as e: + current_app.logger.error(f"FFmpeg audio extraction failed for {video_filepath}: {str(e)}") + raise Exception(f"Audio extraction failed: {str(e)}") + except Exception as e: + current_app.logger.error(f"Error extracting audio from {video_filepath}: {str(e)}") + raise + + +def compress_lossless_audio(filepath, codec='mp3', bitrate='128k', codec_info=None): + """Compress lossless audio files to save storage. + + Only compresses lossless formats - already-compressed formats are skipped + to avoid quality degradation from re-encoding. + + Args: + filepath: Path to the audio file + codec: Target codec - 'mp3', 'flac', or 'opus' + bitrate: Bitrate for lossy codecs (ignored for FLAC) + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + tuple: (new_filepath, new_mime_type) or (original_filepath, None) if skipped + """ + # Use codec detection to check if file is lossless + try: + if not is_lossless_audio(filepath, timeout=10, codec_info=codec_info): + current_app.logger.debug(f"Skipping compression for {filepath} - not a lossless format") + return filepath, None + + # Get current codec info (use provided or fetch) + if codec_info is None: + codec_info_result = get_codec_info(filepath, timeout=10) + else: + codec_info_result = codec_info + current_codec = codec_info_result.get('audio_codec') + + # Skip if target is same as source (e.g., FLAC to FLAC when source is already FLAC) + if current_codec == codec: + current_app.logger.debug(f"Skipping compression for {filepath} - already in target codec") + return filepath, None + + except FFProbeError as e: + current_app.logger.warning(f"Failed to probe {filepath} for compression: {e}. Skipping compression.") + return filepath, None + + # Determine output extension and MIME type + codec_info = { + 'mp3': {'ext': '.mp3', 'mime': 'audio/mpeg'}, + 'flac': {'ext': '.flac', 'mime': 'audio/flac'}, + 'opus': {'ext': '.opus', 'mime': 'audio/opus'} + } + + if codec not in codec_info: + current_app.logger.warning(f"Unknown codec '{codec}', defaulting to mp3") + codec = 'mp3' + + output_ext = codec_info[codec]['ext'] + output_mime = codec_info[codec]['mime'] + + base_filepath = os.path.splitext(filepath)[0] + temp_filepath = f"{base_filepath}_compressed_temp{output_ext}" + final_filepath = f"{base_filepath}{output_ext}" + + try: + # Get original file size for logging + original_size = os.path.getsize(filepath) + + current_app.logger.info(f"Compressing {filepath} to {codec.upper()}...") + + # Use centralized compression utility + final_filepath, output_mime, _ = compress_audio( + filepath, + codec=codec, + bitrate=bitrate, + delete_original=True, + codec_info=None + ) + + return final_filepath, output_mime + + except FFmpegNotFoundError as e: + current_app.logger.error(str(e)) + raise Exception("Audio conversion tool (FFmpeg) not found on server.") + except FFmpegError as e: + current_app.logger.error(f"FFmpeg compression failed for {filepath}: {str(e)}") + raise Exception(f"Audio compression failed: {str(e)}") + except Exception as e: + current_app.logger.error(f"Error compressing audio {filepath}: {str(e)}") + raise + + +def merge_diarized_chunks(chunk_results): + """ + Merge diarized transcription chunks while remapping speaker labels to be unique. + + Since ASR services can't maintain speaker identity across chunks, each chunk's + speakers are remapped to unique IDs: + - Chunk 1: SPEAKER_00, SPEAKER_01 → SPEAKER_00, SPEAKER_01 + - Chunk 2: SPEAKER_00, SPEAKER_01 → SPEAKER_02, SPEAKER_03 + - etc. + + This function: + 1. Remaps speaker labels to be unique across all chunks + 2. Updates both segments and transcription text with new labels + 3. Adjusts timestamps based on chunk start_time + + Args: + chunk_results: List of chunk results with 'transcription', 'segments', 'start_time' + + Returns: + Tuple of (merged_text, merged_segments, all_speakers) + """ + from src.services.transcription import TranscriptionSegment + import re + + if not chunk_results: + return "", [], [] + + # Sort chunks by start time to ensure correct order + sorted_chunks = sorted(chunk_results, key=lambda x: x.get('start_time', 0)) + + merged_parts = [] + merged_segments = [] + all_speakers = set() + next_speaker_number = 0 # Track the next available speaker number + + for chunk_idx, chunk in enumerate(sorted_chunks): + chunk_segments = chunk.get('segments') or [] + + # Build speaker remapping for this chunk + # Maps original speaker label -> new unique speaker label + chunk_speakers = set() + for seg in chunk_segments: + if hasattr(seg, 'speaker'): + speaker = seg.speaker + else: + speaker = seg.get('speaker', 'Unknown') + if speaker: + chunk_speakers.add(speaker) + + # Also check chunk metadata for speakers + if chunk.get('speakers'): + for s in chunk['speakers']: + chunk_speakers.add(s) + + # Create remapping: sort speakers to ensure deterministic ordering + speaker_remap = {} + for original_speaker in sorted(chunk_speakers): + if original_speaker and original_speaker != 'Unknown': + # Extract number from speaker label (e.g., SPEAKER_00 -> 0) + # For first chunk, keep original numbering; for subsequent chunks, remap + if chunk_idx == 0: + # First chunk: keep original labels but track highest number + speaker_remap[original_speaker] = original_speaker + match = re.search(r'(\d+)$', original_speaker) + if match: + num = int(match.group(1)) + next_speaker_number = max(next_speaker_number, num + 1) + else: + # Subsequent chunks: remap to new unique numbers + new_speaker = f"SPEAKER_{next_speaker_number:02d}" + speaker_remap[original_speaker] = new_speaker + next_speaker_number += 1 + + # Update transcription text with remapped speakers + chunk_text = chunk.get('transcription', '').strip() + if chunk_text and chunk_idx > 0: + # Replace speaker labels in text (e.g., [SPEAKER_00]: -> [SPEAKER_02]:) + for original, remapped in speaker_remap.items(): + if original != remapped: + # Handle various formats: [SPEAKER_00]:, SPEAKER_00:, (SPEAKER_00) + chunk_text = re.sub( + rf'\[{re.escape(original)}\]', + f'[{remapped}]', + chunk_text + ) + chunk_text = re.sub( + rf'(? data URL + + for i, chunk in enumerate(chunks): + max_retries = 3 + retry_count = 0 + success = False + + while retry_count < max_retries and not success: + try: + retry_suffix = f" (retry {retry_count + 1}/{max_retries})" if retry_count > 0 else "" + current_app.logger.info(f"Processing chunk {i+1}/{len(chunks)}: {chunk['filename']} ({chunk['size_mb']:.1f}MB){retry_suffix}") + + # Transcribe chunk using connector + with open(chunk['path'], 'rb') as chunk_file: + # For diarization: first chunk gets diarize=True, subsequent chunks + # get diarize=True + known_speaker_references + if use_diarization: + request = TranscriptionRequest( + audio_file=chunk_file, + filename=chunk['filename'], + mime_type='audio/mpeg', # Chunks are always MP3 + language=language, + diarize=True, + known_speaker_names=known_speaker_names, + known_speaker_references=known_speaker_refs, + prompt=initial_prompt, + hotwords=hotwords, + ) + else: + request = TranscriptionRequest( + audio_file=chunk_file, + filename=chunk['filename'], + mime_type='audio/mpeg', + language=language, + diarize=False, + prompt=initial_prompt, + hotwords=hotwords, + ) + + response = connector.transcribe(request) + + # For the first diarized chunk, extract speaker samples for subsequent chunks + if use_diarization and i == 0 and response.segments: + current_app.logger.info(f"First chunk diarized with {len(response.speakers or [])} speakers, extracting samples...") + + # Extract speaker samples from the first chunk + speaker_samples = extract_speaker_samples( + audio_path=chunk['path'], + segments=[{ + 'speaker': seg.speaker, + 'start_time': seg.start_time, + 'end_time': seg.end_time + } for seg in response.segments], + output_dir=temp_dir, + min_duration=2.0, + max_duration=10.0, + max_speakers=4 + ) + + if speaker_samples: + # Convert to data URLs for the API + known_speaker_refs = samples_to_data_urls(speaker_samples) + known_speaker_names = list(known_speaker_refs.keys()) + current_app.logger.info(f"Extracted speaker references for {len(known_speaker_names)} speakers: {known_speaker_names}") + else: + current_app.logger.warning("Could not extract speaker samples from first chunk") + + # Store chunk result + chunk_result = { + 'index': chunk['index'], + 'start_time': chunk['start_time'], + 'end_time': chunk['end_time'], + 'duration': chunk['duration'], + 'size_mb': chunk['size_mb'], + 'transcription': response.text, + 'filename': chunk['filename'], + 'segments': response.segments if use_diarization else None, + 'speakers': response.speakers if use_diarization else None + } + chunk_results.append(chunk_result) + current_app.logger.info(f"Chunk {i+1} transcribed successfully: {len(response.text)} characters") + success = True + + except Exception as chunk_error: + retry_count += 1 + error_msg = str(chunk_error) + + if retry_count < max_retries: + wait_time = 15 if "timeout" not in error_msg.lower() else 30 + current_app.logger.warning(f"Chunk {i+1} failed (attempt {retry_count}/{max_retries}): {chunk_error}. Retrying in {wait_time}s...") + time.sleep(wait_time) + else: + current_app.logger.error(f"Chunk {i+1} failed after {max_retries} attempts: {chunk_error}") + chunk_result = { + 'index': chunk['index'], + 'start_time': chunk['start_time'], + 'end_time': chunk['end_time'], + 'transcription': f"[Chunk {i+1} transcription failed: {str(chunk_error)}]", + 'filename': chunk['filename'] + } + chunk_results.append(chunk_result) + + # Small delay between chunks + if i < len(chunks) - 1: + time.sleep(2) + + # Merge transcriptions + current_app.logger.info(f"Merging {len(chunk_results)} chunk transcriptions...") + + if use_diarization: + # For diarized chunks, merge text AND segments with adjusted timestamps + merged_text, merged_segments, all_speakers = merge_diarized_chunks(chunk_results) + + if not merged_text.strip(): + raise ChunkProcessingError("Merged transcription is empty") + + # Log statistics + chunking_service.log_processing_statistics(chunk_results) + + current_app.logger.info(f"Merged diarization: {len(merged_segments)} segments, {len(all_speakers)} speakers: {all_speakers}") + + # Return a TranscriptionResponse so segments are preserved + from src.services.transcription import TranscriptionResponse + return TranscriptionResponse( + text=merged_text, + segments=merged_segments, + speakers=all_speakers, + provider=connector.PROVIDER_NAME, + model=getattr(connector, 'model', 'unknown') + ) + else: + merged_transcription = chunking_service.merge_transcriptions(chunk_results) + + if not merged_transcription.strip(): + raise ChunkProcessingError("Merged transcription is empty") + + # Log statistics + chunking_service.log_processing_statistics(chunk_results) + + return merged_transcription + + except Exception as e: + current_app.logger.error(f"Chunking transcription failed for {filepath}: {e}") + if 'chunks' in locals(): + chunking_service.cleanup_chunks(chunks) + raise ChunkProcessingError(f"Chunked transcription failed: {str(e)}") + + +def transcribe_with_connector(app_context, recording_id, filepath, original_filename, start_time, mime_type=None, language=None, diarize=None, min_speakers=None, max_speakers=None, tag_id=None, hotwords=None, initial_prompt=None): + """ + Transcribe audio using the new connector-based architecture. + + This function uses the transcription connector system which supports: + - OpenAI Whisper (whisper-1) + - OpenAI GPT-4o Transcribe (gpt-4o-transcribe, gpt-4o-mini-transcribe) + - OpenAI GPT-4o Transcribe Diarize (gpt-4o-transcribe-diarize) - with speaker labels + - Custom ASR endpoints (whisper-asr-webservice, WhisperX, etc.) + + Args: + app_context: Flask app context + recording_id: ID of the recording to process + filepath: Path to the audio file + original_filename: Original filename for logging + start_time: Processing start time + mime_type: MIME type of the audio file + language: Optional language code override + diarize: Whether to enable diarization (None = use connector default) + min_speakers: Optional minimum speakers + max_speakers: Optional maximum speakers + tag_id: Optional tag ID to apply custom prompt from + hotwords: Optional comma-separated hotwords to bias recognition + initial_prompt: Optional initial prompt to steer transcription + """ + from src.services.transcription import ( + get_connector, TranscriptionRequest, TranscriptionCapability + ) + + with app_context: + recording = db.session.get(Recording, recording_id) + if not recording: + current_app.logger.error(f"Error: Recording {recording_id} not found for transcription.") + return + + try: + current_app.logger.info(f"Starting connector-based transcription for recording {recording_id}...") + recording.status = 'PROCESSING' + transcription_start_time = time.time() + db.session.commit() + + # Get the active transcription connector + connector = get_connector() + connector_name = connector.PROVIDER_NAME + current_app.logger.info(f"Using transcription connector: {connector_name}") + + # Check transcription budget before processing + can_proceed, usage_pct, budget_msg = transcription_tracker.check_budget(recording.user_id) + if not can_proceed: + current_app.logger.warning(f"User {recording.user_id} exceeded transcription budget: {budget_msg}") + recording.status = 'FAILED' + recording.error_msg = budget_msg + db.session.commit() + return + elif budget_msg: + # Log warning but continue + current_app.logger.warning(budget_msg) + + # Handle video extraction (keep existing logic) + actual_filepath = filepath + actual_content_type = mime_type or mimetypes.guess_type(original_filename)[0] or 'application/octet-stream' + actual_filename = original_filename + audio_filepath = None # Track temp audio extracted from video (for cleanup) + + # Use codec detection to check if file is a video + try: + is_video = is_video_file(filepath, timeout=10) + if is_video: + current_app.logger.info(f"Video detected for {original_filename}") + except FFProbeError as e: + current_app.logger.warning(f"Failed to probe {original_filename}: {e}. Falling back to MIME type detection.") + video_mime_types = [ + 'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm', + 'video/avi', 'video/x-ms-wmv', 'video/3gpp' + ] + is_video = ( + actual_content_type.startswith('video/') or + actual_content_type in video_mime_types + ) + + if is_video: + if VIDEO_PASSTHROUGH_ASR: + # Video passthrough: send original video directly to ASR without audio extraction + current_app.logger.info(f"Video passthrough: sending original video to ASR (no audio extraction)") + actual_filepath = filepath # Send video as-is to connector + if VIDEO_RETENTION: + # Also keep the video for playback + recording.audio_path = filepath + recording.mime_type = mimetypes.guess_type(filepath)[0] or 'video/mp4' + db.session.commit() + elif VIDEO_RETENTION: + # Video retention: keep original video, extract audio to temp for transcription only + current_app.logger.info(f"Video container detected, retaining video and extracting audio to temp...") + try: + audio_filepath, audio_mime_type = extract_audio_from_video(filepath, cleanup_original=False) + # Use extracted audio for transcription processing + actual_filepath = audio_filepath + actual_content_type = audio_mime_type + actual_filename = os.path.basename(audio_filepath) + + # Keep original video file as the stored media + recording.audio_path = filepath + recording.mime_type = mimetypes.guess_type(filepath)[0] or 'video/mp4' + db.session.commit() + current_app.logger.info(f"Video retained at: {filepath}, temp audio extracted: {audio_filepath}") + except Exception as e: + current_app.logger.error(f"Failed to extract audio from video: {str(e)}") + recording.status = 'FAILED' + recording.error_msg = f"Audio extraction failed: {str(e)}" + db.session.commit() + raise + else: + # Default: extract audio, delete original video + current_app.logger.info(f"Video container detected, extracting audio...") + try: + audio_filepath, audio_mime_type = extract_audio_from_video(filepath) + actual_filepath = audio_filepath + actual_content_type = audio_mime_type + actual_filename = os.path.basename(audio_filepath) + + recording.audio_path = audio_filepath + recording.mime_type = audio_mime_type + db.session.commit() + current_app.logger.info(f"Audio extracted: {audio_filepath}") + except Exception as e: + current_app.logger.error(f"Failed to extract audio from video: {str(e)}") + recording.status = 'FAILED' + recording.error_msg = f"Audio extraction failed: {str(e)}" + db.session.commit() + raise # Re-raise so job queue marks the job as failed + + # Validate and convert audio format if needed using unified conversion utility + # This respects: + # - connector_specs.unsupported_codecs (e.g., opus for OpenAI) + # - AUDIO_UNSUPPORTED_CODECS environment variable (user-specified exclusions) + # - AUDIO_COMPRESS_UPLOADS setting (lossless compression) + connector_specs = connector.specifications + converted_filepath = None # Track converted file for cleanup and retry + video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR + + if video_passthrough_active: + # Skip conversion and chunking — ASR backend handles the raw video + current_app.logger.info(f"Video passthrough: skipping codec conversion and chunking") + else: + try: + # Check if chunking will be needed (affects which codecs are supported) + needs_chunking_check = ( + chunking_service and + chunking_service.needs_chunking(actual_filepath, False, connector_specs) + ) + + conversion_result = convert_if_needed( + filepath=actual_filepath, + original_filename=actual_filename, + needs_chunking=needs_chunking_check, + is_asr_endpoint=False, # Using connector architecture + delete_original=False, # Keep original, we may need it for retry + connector_specs=connector_specs + ) + + if conversion_result.was_converted: + current_app.logger.info( + f"Audio converted: {conversion_result.original_codec} → {conversion_result.final_codec}, " + f"size: {conversion_result.original_size_mb:.1f}MB → {conversion_result.final_size_mb:.1f}MB" + ) + converted_filepath = conversion_result.output_path + actual_filepath = converted_filepath + actual_content_type = conversion_result.mime_type + actual_filename = os.path.basename(converted_filepath) + except (FFmpegError, FFmpegNotFoundError) as conv_error: + current_app.logger.error(f"Audio conversion failed: {conv_error}") + raise # Let the job fail - can't process this file + except Exception as e: + current_app.logger.warning(f"Could not validate/convert audio: {e}, proceeding with original file") + + # Determine if we should diarize + if diarize is None: + # Use connector's configured default (respects ASR_DIARIZE env var) + should_diarize = getattr(connector, 'default_diarize', connector.supports_diarization) + else: + should_diarize = diarize and connector.supports_diarization + + if should_diarize and not connector.supports_diarization: + current_app.logger.warning(f"Diarization requested but connector '{connector_name}' doesn't support it") + should_diarize = False + + # Check if chunking is needed for large files + # The chunking service respects this priority: + # 1. Connector handles internally (e.g., ASR endpoint) → no app-level chunking + # 2. User's ENABLE_CHUNKING=false → no chunking + # 3. User's CHUNK_LIMIT setting → use their settings + # 4. Connector defaults (max_file_size, recommended_chunk_seconds) + # 5. App default (20MB) + if video_passthrough_active: + should_chunk = False + current_app.logger.info(f"Video passthrough: chunking skipped (ASR backend handles internally)") + else: + current_app.logger.info(f"Chunking service available: {chunking_service is not None}") + current_app.logger.info(f"Connector specs: max_duration={connector_specs.max_duration_seconds}s, " + f"handles_internally={connector_specs.handles_chunking_internally}, " + f"recommended_chunk={connector_specs.recommended_chunk_seconds}s") + + if chunking_service: + should_chunk = chunking_service.needs_chunking(actual_filepath, False, connector_specs) + current_app.logger.info(f"Chunking decision: should_chunk={should_chunk}") + else: + should_chunk = False + current_app.logger.warning("Chunking service is disabled (ENABLE_CHUNKING=false or service not initialized)") + + # Retry loop for handling format/codec errors with MP3 conversion + max_attempts = 2 + last_error = None + + for attempt in range(max_attempts): + try: + if should_chunk: + # Use chunking for large files + file_size_mb = os.path.getsize(actual_filepath) / (1024 * 1024) + current_app.logger.info(f"File {actual_filepath} is large ({file_size_mb:.1f}MB), using chunking for transcription") + chunk_result = transcribe_chunks_with_connector( + connector, actual_filepath, actual_filename, actual_content_type, language, + diarize=should_diarize, # Pass diarization setting for speaker reference tracking + hotwords=hotwords, + initial_prompt=initial_prompt, + ) + + # Handle result based on type (TranscriptionResponse for diarized, string for plain) + if hasattr(chunk_result, 'segments') and chunk_result.segments and chunk_result.has_diarization(): + # Diarized response - store with segments for click-to-seek and speaker identification + recording.transcription = chunk_result.to_storage_format() + current_app.logger.info(f"Chunked diarized transcription completed: {len(chunk_result.text)} characters, {len(chunk_result.segments)} segments") + else: + # Plain text response + transcription_text = chunk_result.text if hasattr(chunk_result, 'text') else chunk_result + recording.transcription = transcription_text + current_app.logger.info(f"Chunked transcription completed: {len(transcription_text)} characters") + else: + # Build the transcription request for single file + with open(actual_filepath, 'rb') as audio_file: + request = TranscriptionRequest( + audio_file=audio_file, + filename=actual_filename, + mime_type=actual_content_type, + language=language, + diarize=should_diarize, + min_speakers=min_speakers, + max_speakers=max_speakers, + prompt=initial_prompt, + hotwords=hotwords, + ) + + current_app.logger.info(f"Transcribing with connector: diarize={should_diarize}, language={language}") + response = connector.transcribe(request) + + # Store the result + if response.segments and response.has_diarization(): + # Store as JSON with segments (diarized format) + recording.transcription = response.to_storage_format() + current_app.logger.info(f"Transcription completed with {len(response.segments)} segments and {len(response.speakers or [])} speakers") + else: + # Store as plain text (ensure it's a string) + transcription_text = response.text if isinstance(response.text, str) else '' + recording.transcription = transcription_text + current_app.logger.info(f"Transcription completed: {len(transcription_text)} characters") + + # Store speaker embeddings if available + if response.speaker_embeddings: + recording.speaker_embeddings = response.speaker_embeddings + current_app.logger.info(f"Stored speaker embeddings for speakers: {list(response.speaker_embeddings.keys())}") + + # If we reach here, transcription succeeded + break + + except Exception as e: + last_error = e + error_msg = str(e).lower() + + # Check if this is a format/codec error that might be fixed by MP3 conversion + is_format_error = any(phrase in error_msg for phrase in [ + 'corrupted', 'unsupported', 'invalid', 'format', 'codec', + 'could not find codec', 'audio file', 'decode' + ]) + + # Only retry with MP3 conversion on first attempt for format errors + if attempt == 0 and is_format_error and not converted_filepath: + current_app.logger.warning(f"Transcription failed with possible format error: {e}") + current_app.logger.info(f"Attempting MP3 conversion and retry...") + + # Check if file is already MP3 + try: + codec_info = get_codec_info(actual_filepath, timeout=10) + audio_codec = codec_info.get('audio_codec', '').lower() + needs_conversion = audio_codec != 'mp3' + except FFProbeError: + needs_conversion = not actual_filename.lower().endswith('.mp3') + + if needs_conversion: + try: + converted_filepath = convert_to_mp3(actual_filepath) + current_app.logger.info(f"Successfully converted to MP3: {converted_filepath}") + actual_filepath = converted_filepath + actual_content_type = 'audio/mpeg' + actual_filename = os.path.basename(converted_filepath) + # Recalculate if chunking is needed after conversion + should_chunk = ( + chunking_service and + chunking_service.needs_chunking(actual_filepath, False, connector_specs) + ) + continue # Retry with converted file + except (FFmpegError, FFmpegNotFoundError) as conv_error: + current_app.logger.error(f"Failed to convert to MP3: {conv_error}") + # Fall through to raise original error + else: + current_app.logger.warning(f"File is already MP3 but still getting format error") + + # Not a format error or already retried - propagate the error + raise + + # Clean up converted file if we created one and transcription succeeded + if converted_filepath and os.path.exists(converted_filepath): + try: + os.remove(converted_filepath) + current_app.logger.debug(f"Cleaned up converted file: {converted_filepath}") + except OSError: + pass # Best effort cleanup + + # Clean up temp audio extracted from video when video retention is enabled + if is_video and VIDEO_RETENTION and audio_filepath and audio_filepath != filepath: + try: + if os.path.exists(audio_filepath): + os.remove(audio_filepath) + current_app.logger.info(f"Cleaned up temp audio from video retention: {audio_filepath}") + except OSError: + pass # Best effort cleanup + + # Calculate and save transcription duration + transcription_end_time = time.time() + recording.transcription_duration_seconds = int(transcription_end_time - transcription_start_time) + db.session.commit() + current_app.logger.info(f"Transcription completed in {recording.transcription_duration_seconds}s") + + # Record transcription usage for billing/budgeting + try: + # Get actual audio duration (not processing time) + audio_duration = None + if chunking_service: + audio_duration = chunking_service.get_audio_duration(recording.audio_path) + + if audio_duration and audio_duration > 0: + # Get model name from connector if available + model_name = getattr(connector, 'model', None) or connector_name + transcription_tracker.record_usage( + user_id=recording.user_id, + connector_type=connector_name, + audio_duration_seconds=int(audio_duration), + model_name=model_name + ) + current_app.logger.info(f"Recorded transcription usage: {int(audio_duration)}s for user {recording.user_id}") + else: + current_app.logger.warning(f"Could not determine audio duration for usage tracking") + except Exception as usage_err: + # Don't fail transcription if usage tracking fails + current_app.logger.warning(f"Failed to record transcription usage: {usage_err}") + + # Apply auto speaker labelling if enabled and embeddings available + if recording.speaker_embeddings: + try: + from src.services.speaker_embedding_matcher import ( + apply_auto_speaker_labels, + apply_speaker_names_to_transcription, + update_speaker_profiles_from_recording + ) + + user = User.query.get(recording.user_id) + if user and user.auto_speaker_labelling: + current_app.logger.info(f"Applying auto speaker labelling for recording {recording.id}") + speaker_map = apply_auto_speaker_labels(recording, user) + + if speaker_map: + current_app.logger.info(f"Auto-matched speakers: {speaker_map}") + # Apply names to transcription + if apply_speaker_names_to_transcription(recording, speaker_map): + current_app.logger.info(f"Applied speaker names to transcription") + # Update speaker profiles with new embeddings + updated_count = update_speaker_profiles_from_recording(recording, speaker_map, user) + if updated_count > 0: + current_app.logger.info(f"Updated {updated_count} speaker profiles with new embeddings") + else: + current_app.logger.warning(f"Failed to apply speaker names to transcription for recording {recording.id}") + else: + current_app.logger.info(f"No speakers matched for auto-labelling") + except Exception as auto_label_err: + # Don't fail transcription if auto-labelling fails + current_app.logger.warning(f"Failed to apply auto speaker labelling: {auto_label_err}") + + # Check if auto-summarization is disabled (admin setting or user preference) + admin_setting = SystemSetting.get_setting('disable_auto_summarization', False) + admin_disabled = admin_setting if isinstance(admin_setting, bool) else str(admin_setting).lower() == 'true' + user = User.query.get(recording.user_id) + user_disabled = user and user.auto_summarization is False + will_auto_summarize = not admin_disabled and not user_disabled + + # Generate title immediately + generate_title_task(app_context, recording_id, will_auto_summarize=will_auto_summarize) + + if not will_auto_summarize: + reason = "admin setting" if admin_disabled else "user preference" + current_app.logger.info(f"Auto-summarization disabled ({reason}), skipping summary for recording {recording_id}") + recording = db.session.get(Recording, recording_id) + if recording: + recording.status = 'COMPLETED' + recording.completed_at = datetime.utcnow() + db.session.commit() + + # Apply auto-shares for group tags after processing completes + apply_team_tag_auto_shares(recording_id) + + # Export transcription-only if auto-export is enabled + if ENABLE_AUTO_EXPORT: + export_recording(recording_id) + else: + # Auto-generate summary for all recordings + current_app.logger.info(f"Auto-generating summary for recording {recording_id}") + generate_summary_only_task(app_context, recording_id) + + except Exception as e: + db.session.rollback() + error_msg = str(e) + error_type = type(e).__name__ + current_app.logger.error(f"Connector transcription FAILED for recording {recording_id}: [{error_type}] {error_msg}", exc_info=True) + + # Handle timeout errors specifically - log the configured timeout for debugging + if "timed out" in error_msg.lower() or "timeout" in error_msg.lower() or "Timeout" in error_type: + try: + from src.services.transcription import get_registry + registry = get_registry() + # Get timeout from connector config if available + connector_timeout = getattr(registry.get_active_connector(), 'timeout', None) + if connector_timeout: + current_app.logger.error(f"Timeout details - configured connector timeout: {connector_timeout}s") + else: + # Fall back to database/env setting + asr_timeout = SystemSetting.get_setting('asr_timeout_seconds', 1800) + current_app.logger.error(f"Timeout details - configured timeout: {asr_timeout}s") + except Exception: + pass # Don't fail the error handling if we can't get timeout info + + # Don't set recording.status = 'FAILED' here - let the job queue handle it + # The job queue will decide whether to retry or permanently fail, + # and only set FAILED status when all retries are exhausted + + # Re-raise so job queue marks the job as failed (and potentially retries) + raise + + +def transcribe_audio_task(app_context, recording_id, filepath, filename_for_asr, start_time, language=None, min_speakers=None, max_speakers=None, tag_id=None, hotwords=None, initial_prompt=None): + """Runs the transcription and summarization in a background thread. + + Uses the connector-based architecture which supports: + - OpenAI Whisper (whisper-1) + - OpenAI GPT-4o Transcribe (gpt-4o-transcribe, gpt-4o-mini-transcribe) + - OpenAI GPT-4o Transcribe Diarize (gpt-4o-transcribe-diarize) + - Custom ASR endpoints (whisper-asr-webservice, WhisperX, etc.) + + Args: + app_context: Flask app context + recording_id: ID of the recording to process + filepath: Path to the audio file + filename_for_asr: Filename to use for ASR + start_time: Processing start time + language: Optional language code override (from upload form) + min_speakers: Optional minimum speakers override (from upload form) + max_speakers: Optional maximum speakers override (from upload form) + tag_id: Optional tag ID to apply custom prompt from + hotwords: Optional comma-separated hotwords to bias recognition + initial_prompt: Optional initial prompt to steer transcription + """ + with app_context: + recording = db.session.get(Recording, recording_id) + # Determine diarization setting based on connector capabilities + # The connector will handle this, but we pass the user's preference + diarize_setting = None # Let connector decide based on its capabilities + + # Use language from upload form if provided, otherwise use user's default + # language='' (empty string) means auto-detect, language=None means use default + if language is not None: + # Explicit language selection (including empty string for auto-detect) + user_transcription_language = language if language else None # '' becomes None for connector + else: + # No language specified - use user's default + user_transcription_language = recording.owner.transcription_language if recording and recording.owner else None + + mime_type = recording.mime_type if recording else None + + transcribe_with_connector( + app_context, recording_id, filepath, filename_for_asr, start_time, + mime_type=mime_type, + language=user_transcription_language, + diarize=diarize_setting, + min_speakers=min_speakers, + max_speakers=max_speakers, + tag_id=tag_id, + hotwords=hotwords, + initial_prompt=initial_prompt, + ) + + # After transcription completes, calculate processing time + with app_context: + recording = db.session.get(Recording, recording_id) + if recording and recording.status in ['COMPLETED', 'FAILED']: + end_time = datetime.utcnow() + recording.processing_time_seconds = (end_time - start_time).total_seconds() + db.session.commit() + + +def transcribe_incognito(filepath, original_filename, language=None, min_speakers=None, max_speakers=None, user=None): + """ + Perform transcription without any database operations. + Used for Incognito Mode where no data is persisted. + + Args: + filepath: Path to the audio file + original_filename: Original filename for logging/processing + language: Optional language code for transcription + min_speakers: Optional minimum speakers for diarization + max_speakers: Optional maximum speakers for diarization + user: Optional user object for language/diarization preferences + + Returns: + dict with transcription, title, processing_time, etc. + """ + import time + import mimetypes + from src.services.transcription import get_registry, TranscriptionRequest + + start_time = time.time() + result = { + 'transcription': None, + 'title': 'Incognito Recording', + 'processing_time_seconds': 0, + 'audio_duration_seconds': None, + 'error': None + } + + try: + # Get the active connector + registry = get_registry() + connector = registry.get_active_connector() + + if not connector: + raise Exception("No transcription connector available") + + connector_specs = connector.specifications + connector_name = type(connector).__name__ + current_app.logger.info(f"[Incognito] Using transcription connector: {connector_name}") + + # Determine mime type + mime_type = mimetypes.guess_type(original_filename)[0] or 'audio/mpeg' + + # Handle video extraction if needed + actual_filepath = filepath + actual_filename = original_filename + actual_content_type = mime_type + + # Check if file is video and needs audio extraction + is_video = False + try: + is_video = is_video_file(filepath, timeout=10) + except FFProbeError as e: + current_app.logger.warning(f"[Incognito] Failed to probe file: {e}") + # Check by extension + video_extensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.wmv', '.flv', '.m4v'] + is_video = any(original_filename.lower().endswith(ext) for ext in video_extensions) + + video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR + + if is_video: + if VIDEO_PASSTHROUGH_ASR: + current_app.logger.info(f"[Incognito] Video passthrough: sending original video to ASR (no audio extraction)") + else: + current_app.logger.info(f"[Incognito] Video detected, extracting audio...") + try: + audio_filepath, audio_mime_type = extract_audio_from_video(filepath, cleanup_original=False) + actual_filepath = audio_filepath + actual_content_type = audio_mime_type + actual_filename = os.path.basename(audio_filepath) + except Exception as e: + current_app.logger.error(f"[Incognito] Failed to extract audio from video: {e}") + raise + + # Convert audio format if needed + if video_passthrough_active: + current_app.logger.info(f"[Incognito] Video passthrough: skipping codec conversion") + else: + try: + needs_chunking_check = ( + chunking_service and + chunking_service.needs_chunking(actual_filepath, False, connector_specs) + ) + + conversion_result = convert_if_needed( + filepath=actual_filepath, + original_filename=actual_filename, + needs_chunking=needs_chunking_check, + is_asr_endpoint=False, + delete_original=False, + connector_specs=connector_specs + ) + + if conversion_result.was_converted: + current_app.logger.info(f"[Incognito] Audio converted: {conversion_result.original_codec} -> {conversion_result.final_codec}") + actual_filepath = conversion_result.output_path + actual_content_type = conversion_result.mime_type + actual_filename = os.path.basename(conversion_result.output_path) + except Exception as e: + current_app.logger.warning(f"[Incognito] Audio conversion check failed: {e}, proceeding with original") + + # Get audio duration if chunking service is available + if chunking_service: + try: + result['audio_duration_seconds'] = int(chunking_service.get_audio_duration(actual_filepath)) + except Exception as e: + current_app.logger.warning(f"[Incognito] Could not get audio duration: {e}") + + # Determine diarization settings (respects ASR_DIARIZE env var) + should_diarize = getattr(connector, 'default_diarize', connector.supports_diarization) + + # Use user's language preference if not explicitly provided + if language is None and user: + language = user.transcription_language + + # Check if chunking is needed + if video_passthrough_active: + should_chunk = False + current_app.logger.info(f"[Incognito] Video passthrough: chunking skipped (ASR backend handles internally)") + else: + should_chunk = (chunking_service and + chunking_service.needs_chunking(actual_filepath, False, connector_specs)) + + current_app.logger.info(f"[Incognito] Starting transcription: diarize={should_diarize}, language={language}, chunking={should_chunk}") + + if should_chunk: + # Use chunking for large files + chunk_result = transcribe_chunks_with_connector( + connector, actual_filepath, actual_filename, actual_content_type, language, + diarize=should_diarize + ) + + if hasattr(chunk_result, 'segments') and chunk_result.segments and chunk_result.has_diarization(): + result['transcription'] = chunk_result.to_storage_format() + else: + result['transcription'] = chunk_result.text if hasattr(chunk_result, 'text') else chunk_result + else: + # Single file transcription + with open(actual_filepath, 'rb') as audio_file: + request = TranscriptionRequest( + audio_file=audio_file, + filename=actual_filename, + mime_type=actual_content_type, + language=language, + diarize=should_diarize, + min_speakers=min_speakers, + max_speakers=max_speakers + ) + + response = connector.transcribe(request) + + if response.segments and response.has_diarization(): + result['transcription'] = response.to_storage_format() + else: + result['transcription'] = response.text + + result['processing_time_seconds'] = int(time.time() - start_time) + current_app.logger.info(f"[Incognito] Transcription completed in {result['processing_time_seconds']}s") + + # Generate a title if we have transcription + if result['transcription'] and len(result['transcription']) > 10: + result['title'] = _generate_incognito_title(result['transcription'], user) + + return result + + except Exception as e: + current_app.logger.error(f"[Incognito] Transcription failed: {str(e)}", exc_info=True) + result['error'] = str(e) + result['processing_time_seconds'] = int(time.time() - start_time) + return result + + +def _generate_incognito_title(transcription_text, user=None): + """Generate a title for incognito recording without database storage.""" + if not client: + return "Incognito Recording" + + try: + # Get formatted text for LLM + formatted_text = format_transcription_for_llm(transcription_text) + # Limit text for title generation + limited_text = formatted_text[:5000] + + # Get user language preference + user_output_language = user.output_language if user else None + language_directive = f"Réponds en {user_output_language}." if user_output_language else "Réponds en français." + + prompt_text = f"""Génère un titre court pour cette transcription : + +{limited_text} + +Consignes : +- Maximum 8 mots +- Pas de formules génériques comme "Discussion sur" ou "Réunion concernant" +- Le sujet principal uniquement, direct et précis +- {language_directive} + +Titre :""" + + system_message_content = "Tu es un assistant qui génère des titres concis pour des transcriptions audio. Réponds uniquement avec le titre, sans ponctuation finale." + if user_output_language: + system_message_content += f" Réponds en {user_output_language}." + else: + system_message_content += " Réponds en français." + + # Use call_llm_completion without user_id tracking for incognito + completion = client.chat.completions.create( + model=TEXT_MODEL_NAME, + messages=[ + {"role": "system", "content": system_message_content}, + {"role": "user", "content": prompt_text} + ], + temperature=0.7, + max_tokens=100 + ) + + raw_response = completion.choices[0].message.content + title = clean_llm_response(raw_response) if raw_response else None + + if title and len(title.strip()) > 0: + return title.strip() + + except Exception as e: + current_app.logger.warning(f"[Incognito] Title generation failed: {e}") + + return "Incognito Recording" + + +def generate_incognito_summary(transcription_text, user=None): + """Generate a summary for incognito recording without database storage.""" + if not client: + return None + + try: + # Get formatted text for LLM + formatted_text = format_transcription_for_llm(transcription_text) + + # Get configurable transcript length limit + transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000) + if transcript_limit == -1: + transcript_text = formatted_text + else: + transcript_text = formatted_text[:transcript_limit] + + # Get user preferences + user_output_language = user.output_language if user else None + user_summary_prompt = user.summary_prompt if user else None + + language_directive = f"IMPORTANT: You MUST provide the summary in {user_output_language}." if user_output_language else "" + + # Determine summarization instructions + if user_summary_prompt: + summarization_instructions = user_summary_prompt + else: + admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None) + if admin_default_prompt: + summarization_instructions = admin_default_prompt + else: + summarization_instructions = """Generate a comprehensive summary that includes the following sections: +- **Key Issues Discussed**: A bulleted list of the main topics +- **Key Decisions Made**: A bulleted list of any decisions reached +- **Action Items**: A bulleted list of tasks assigned, including who is responsible if mentioned""" + + # Build messages + system_message_content = "You are an AI assistant that generates comprehensive summaries for meeting transcripts. Respond only with the summary in Markdown format." + if user_output_language: + system_message_content += f" You MUST generate the entire summary in {user_output_language}." + + prompt_text = f"""Transcription : +\"\"\" +{transcript_text} +\"\"\" + +Instructions de résumé : +{summarization_instructions} + +{language_directive}""" + + current_app.logger.info(f"[Incognito] Generating summary...") + + # Use client directly without user tracking for incognito + completion = client.chat.completions.create( + model=TEXT_MODEL_NAME, + messages=[ + {"role": "system", "content": system_message_content}, + {"role": "user", "content": prompt_text} + ], + temperature=0.5, + max_tokens=int(os.environ.get("SUMMARY_MAX_TOKENS", "3000")) + ) + + raw_response = completion.choices[0].message.content + summary = clean_llm_response(raw_response) if raw_response else None + + if summary: + current_app.logger.info(f"[Incognito] Summary generated: {len(summary)} characters") + return summary + + except Exception as e: + current_app.logger.warning(f"[Incognito] Summary generation failed: {e}") + + return None diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..d06bcb4 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,81 @@ +""" +Utility functions package for the Speakr application. + +This package contains various utility modules for: +- JSON parsing and handling +- Markdown to HTML conversion +- Datetime formatting and timezone handling +- Security utilities +""" + +from .json_parser import ( + auto_close_json, + safe_json_loads, + preprocess_json_escapes, + extract_json_object +) + +from .markdown import ( + md_to_html, + sanitize_html +) + +from .datetime import ( + local_datetime_filter +) + +from .security import ( + password_check, + is_safe_url, + admin_required, +) + +from .database import ( + add_column_if_not_exists, + migrate_column_type, + create_index_if_not_exists +) + +from .token_auth import ( + extract_token_from_request, + hash_token, + load_user_from_token, + is_token_authenticated +) + +from .error_formatting import ( + is_transcription_error, + format_error_for_user, + format_error_for_storage, + parse_stored_error +) + +__all__ = [ + # JSON parsing + 'auto_close_json', + 'safe_json_loads', + 'preprocess_json_escapes', + 'extract_json_object', + # Markdown/HTML + 'md_to_html', + 'sanitize_html', + # Datetime + 'local_datetime_filter', + # Security + 'password_check', + 'is_safe_url', + # Database + 'add_column_if_not_exists', + 'migrate_column_type', + 'create_index_if_not_exists', + # Token authentication + 'extract_token_from_request', + 'hash_token', + 'load_user_from_token', + 'is_token_authenticated', + # Error formatting + 'is_transcription_error', + 'format_error_for_user', + 'format_error_for_storage', + 'parse_stored_error', +] diff --git a/src/utils/audio_conversion.py b/src/utils/audio_conversion.py new file mode 100644 index 0000000..b13a9d9 --- /dev/null +++ b/src/utils/audio_conversion.py @@ -0,0 +1,405 @@ +""" +Audio conversion utility for handling codec detection and file conversion. + +This module provides a single, unified interface for handling ALL audio/video +conversion needs: +- Video to audio extraction +- Unsupported codec conversion +- Lossless audio compression + +Callers should ONLY use convert_if_needed() - it handles everything. +""" + +import os +import logging +from pathlib import Path +from typing import Optional, Tuple, Set, Dict, Any + +from src.utils.ffprobe import get_codec_info, is_lossless_audio, FFProbeError +from src.utils.ffmpeg_utils import compress_audio, extract_audio_from_video, FFmpegError, FFmpegNotFoundError +from src.config.app_config import AUDIO_COMPRESS_UPLOADS, AUDIO_CODEC, AUDIO_BITRATE, AUDIO_UNSUPPORTED_CODECS + +logger = logging.getLogger(__name__) + + +class ConversionResult: + """Result of a conversion operation.""" + + def __init__( + self, + output_path: str, + mime_type: str, + was_converted: bool, + was_compressed: bool, + original_size: int, + final_size: int, + original_codec: Optional[str] = None, + final_codec: Optional[str] = None + ): + self.output_path = output_path + self.mime_type = mime_type + self.was_converted = was_converted + self.was_compressed = was_compressed + self.original_size = original_size + self.final_size = final_size + self.original_codec = original_codec + self.final_codec = final_codec + + @property + def size_reduction_percent(self) -> float: + """Calculate size reduction percentage.""" + if self.original_size == 0: + return 0.0 + return ((self.original_size - self.final_size) / self.original_size) * 100 + + @property + def original_size_mb(self) -> float: + """Original size in megabytes.""" + return self.original_size / (1024 * 1024) + + @property + def final_size_mb(self) -> float: + """Final size in megabytes.""" + return self.final_size / (1024 * 1024) + + +def get_supported_codecs(needs_chunking: bool = False, connector_specs: Optional[Any] = None) -> Set[str]: + """ + Get the set of supported audio codecs. + + Args: + needs_chunking: If True, return only codecs that work well with chunking + connector_specs: Optional ConnectorSpecifications with provider-specific codec restrictions + + Returns: + Set of supported codec names (minus any excluded via env var or connector specs) + """ + # If connector defines explicit supported codecs, use those + if connector_specs and connector_specs.supported_codecs: + base_codecs = set(connector_specs.supported_codecs) + elif needs_chunking: + # For chunking: only support codecs that work well with chunking + base_codecs = {'pcm_s16le', 'pcm_s24le', 'pcm_f32le', 'mp3', 'flac'} + else: + # For direct transcription: support common formats + # Note: WebM containers are handled separately (by extension check in convert_if_needed) + # because MediaRecorder WebM files often lack seek cues, but the opus/vorbis codecs + # themselves are fine in proper containers (.opus, .ogg) + base_codecs = {'pcm_s16le', 'pcm_s24le', 'pcm_f32le', 'mp3', 'flac', 'aac', 'opus', 'vorbis'} + + # Remove connector-specific unsupported codecs + if connector_specs and connector_specs.unsupported_codecs: + excluded = base_codecs & set(connector_specs.unsupported_codecs) + if excluded: + logger.info(f"Excluding codecs from supported list (via connector specs): {excluded}") + base_codecs = base_codecs - set(connector_specs.unsupported_codecs) + + # Remove any global user-specified unsupported codecs (env var still applies) + if AUDIO_UNSUPPORTED_CODECS: + excluded = base_codecs & AUDIO_UNSUPPORTED_CODECS + if excluded: + logger.info(f"Excluding codecs from supported list (via AUDIO_UNSUPPORTED_CODECS): {excluded}") + return base_codecs - AUDIO_UNSUPPORTED_CODECS + + return base_codecs + + +def convert_if_needed( + filepath: str, + original_filename: Optional[str] = None, + codec_info: Optional[Dict[str, Any]] = None, + needs_chunking: bool = False, + is_asr_endpoint: bool = False, + delete_original: bool = True, + connector_specs: Optional[Any] = None +) -> ConversionResult: + """ + Handle ALL audio conversion needs in one place. + + This is the ONLY function callers should use. It handles: + 1. Video to audio extraction (if has_video) + 2. Unsupported codec conversion (if codec not supported) + 3. Lossless audio compression (if AUDIO_COMPRESS_UPLOADS enabled) + + The function makes intelligent decisions about what processing is needed + and performs it in the optimal order. + + Args: + filepath: Path to the audio/video file + original_filename: Original filename for logging (defaults to basename) + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + needs_chunking: Whether chunking will be used (affects supported codecs) + is_asr_endpoint: Whether using ASR endpoint (affects AAC handling) + delete_original: Whether to delete original file after successful conversion + connector_specs: Optional ConnectorSpecifications with provider-specific codec restrictions + + Returns: + ConversionResult with output path, mime type, and conversion stats + + Raises: + FFmpegNotFoundError: If FFmpeg is not available + FFmpegError: If conversion fails + """ + if original_filename is None: + original_filename = os.path.basename(filepath) + + # Get original file size + original_size = os.path.getsize(filepath) + + # Probe if codec info not provided + if codec_info is None: + try: + codec_info = get_codec_info(filepath, timeout=10) + logger.info( + f"Detected codec for {original_filename}: " + f"audio_codec={codec_info.get('audio_codec')}, " + f"has_video={codec_info.get('has_video', False)}" + ) + except FFProbeError as e: + logger.warning(f"Failed to probe {filepath}: {e}. Will attempt conversion.") + codec_info = None + + original_codec = codec_info.get('audio_codec') if codec_info else None + audio_codec = original_codec + has_video = codec_info.get('has_video', False) if codec_info else False + + # Get supported codecs based on processing mode and connector specs + supported_codecs = get_supported_codecs(needs_chunking, connector_specs) + + # Handle video files - extract audio + if has_video: + # Determine target codec for video extraction - fall back to mp3 if AUDIO_CODEC is unsupported + video_target_codec = AUDIO_CODEC + if connector_specs and connector_specs.unsupported_codecs: + if AUDIO_CODEC in connector_specs.unsupported_codecs: + video_target_codec = 'mp3' + logger.warning( + f"AUDIO_CODEC '{AUDIO_CODEC}' is not supported by connector, " + f"falling back to mp3 for video extraction from {original_filename}" + ) + + # Check if we can remux (copy) instead of transcode + can_remux = False + if audio_codec and audio_codec in supported_codecs: + try: + # Remux if audio is lossy, or if lossless but compression is disabled + is_lossless = is_lossless_audio(filepath, codec_info=codec_info) + can_remux = not is_lossless or not AUDIO_COMPRESS_UPLOADS + except Exception as e: + logger.warning(f"Could not determine if audio is lossless: {e}. Will transcode.") + + try: + if can_remux: + logger.info(f"Extracting audio from video (remux, no transcoding): {original_filename}") + output_filepath, mime_type = extract_audio_from_video( + filepath, + output_format='copy', + cleanup_original=delete_original, + copy_stream=True + ) + final_codec = audio_codec + else: + logger.info(f"Extracting and converting audio from video to {video_target_codec.upper()}: {original_filename}") + output_filepath, mime_type = extract_audio_from_video( + filepath, + output_format=video_target_codec, + bitrate=AUDIO_BITRATE, + cleanup_original=delete_original, + copy_stream=False + ) + final_codec = video_target_codec + + final_size = os.path.getsize(output_filepath) + reduction = ((original_size - final_size) / original_size * 100) if original_size > 0 else 0 + + logger.info( + f"Successfully extracted audio from {original_filename}: " + f"{original_size/1024/1024:.1f}MB -> {final_size/1024/1024:.1f}MB " + f"({reduction:.1f}% reduction)" + ) + + return ConversionResult( + output_path=output_filepath, + mime_type=mime_type, + was_converted=not can_remux, + was_compressed=False, + original_size=original_size, + final_size=final_size, + original_codec=original_codec, + final_codec=final_codec + ) + except FFmpegNotFoundError: + logger.error("FFmpeg not found") + raise + except FFmpegError as e: + logger.error(f"Failed to extract audio from video {filepath}: {e}") + raise + + # Handle audio files - check if conversion needed + needs_conversion = False + file_ext = os.path.splitext(filepath)[1].lower() + + # Note: Connector-specific codec restrictions are handled via connector_specs.unsupported_codecs + # which is already applied in get_supported_codecs() above + + if audio_codec is None: + needs_conversion = True + logger.info(f"Unknown codec for {original_filename}, will attempt conversion") + elif file_ext == '.webm': + # WebM containers from MediaRecorder often lack seek cues, making browser + # audio players unable to seek. Force conversion to a seekable format. + needs_conversion = True + logger.info(f"Converting {original_filename} - WebM container lacks seek support") + elif is_asr_endpoint and audio_codec == 'aac': + needs_conversion = True + logger.info(f"Converting AAC-encoded file for ASR endpoint compatibility") + elif audio_codec not in supported_codecs: + needs_conversion = True + logger.info(f"Converting {original_filename} (codec: {audio_codec}) - unsupported for processing") + + if needs_conversion: + # Determine target codec + # If chunking is needed, always convert to MP3 (chunking requires MP3 anyway) + # This avoids double conversion: original → configured codec → mp3 + if needs_chunking: + target_codec = 'mp3' + logger.info(f"Using MP3 for {original_filename} since chunking is needed") + else: + # Fall back to mp3 if AUDIO_CODEC is unsupported by connector + target_codec = AUDIO_CODEC + if connector_specs and connector_specs.unsupported_codecs: + if AUDIO_CODEC in connector_specs.unsupported_codecs: + target_codec = 'mp3' + logger.warning( + f"AUDIO_CODEC '{AUDIO_CODEC}' is not supported by connector, " + f"falling back to mp3 for {original_filename}" + ) + + logger.info(f"Converting {original_filename} to {target_codec.upper()}") + + try: + output_filepath, mime_type, _ = compress_audio( + filepath, + codec=target_codec, + bitrate=AUDIO_BITRATE, + delete_original=delete_original, + codec_info=codec_info + ) + + final_size = os.path.getsize(output_filepath) + reduction = ((original_size - final_size) / original_size * 100) if original_size > 0 else 0 + + logger.info( + f"Successfully converted {original_filename}: " + f"{original_size/1024/1024:.1f}MB -> {final_size/1024/1024:.1f}MB " + f"({reduction:.1f}% reduction)" + ) + + return ConversionResult( + output_path=output_filepath, + mime_type=mime_type, + was_converted=True, + was_compressed=False, + original_size=original_size, + final_size=final_size, + original_codec=original_codec, + final_codec=target_codec + ) + except FFmpegNotFoundError: + logger.error("FFmpeg not found") + raise + except FFmpegError as e: + logger.error(f"FFmpeg conversion failed for {filepath}: {e}") + raise + + # Audio file with supported codec - check if we should compress lossless + logger.info(f"Codec {audio_codec} is supported, no conversion needed") + + if AUDIO_COMPRESS_UPLOADS: + # Determine target codec for compression - fall back to mp3 if AUDIO_CODEC is unsupported + compress_target_codec = AUDIO_CODEC + if connector_specs and connector_specs.unsupported_codecs: + if AUDIO_CODEC in connector_specs.unsupported_codecs: + compress_target_codec = 'mp3' + logger.warning( + f"AUDIO_CODEC '{AUDIO_CODEC}' is not supported by connector, " + f"falling back to mp3 for lossless compression of {original_filename}" + ) + + try: + # Check if file is lossless + if is_lossless_audio(filepath, codec_info=codec_info): + # Skip if already in target codec (e.g., FLAC to FLAC) + if audio_codec == compress_target_codec: + logger.info(f"File already in target codec {compress_target_codec}, no compression needed") + return ConversionResult( + output_path=filepath, + mime_type=_guess_mime_type(filepath), + was_converted=False, + was_compressed=False, + original_size=original_size, + final_size=original_size, + original_codec=original_codec, + final_codec=audio_codec + ) + + logger.info(f"Compressing lossless audio ({audio_codec}) to {compress_target_codec.upper()}") + + # Perform compression + compressed_path, mime_type, _ = compress_audio( + filepath, + codec=compress_target_codec, + bitrate=AUDIO_BITRATE, + delete_original=delete_original, + codec_info=codec_info + ) + + final_size = os.path.getsize(compressed_path) + reduction = ((original_size - final_size) / original_size * 100) if original_size > 0 else 0 + + logger.info( + f"Successfully compressed {original_filename}: " + f"{original_size/1024/1024:.1f}MB -> {final_size/1024/1024:.1f}MB " + f"({reduction:.1f}% reduction)" + ) + + return ConversionResult( + output_path=compressed_path, + mime_type=mime_type, + was_converted=False, + was_compressed=True, + original_size=original_size, + final_size=final_size, + original_codec=original_codec, + final_codec=compress_target_codec + ) + except Exception as e: + logger.warning(f"Failed to compress lossless audio: {e}. Continuing with original.") + # Fall through to return original file + + # No processing needed - return original file + return ConversionResult( + output_path=filepath, + mime_type=_guess_mime_type(filepath), + was_converted=False, + was_compressed=False, + original_size=original_size, + final_size=original_size, + original_codec=original_codec, + final_codec=audio_codec + ) + + +def _guess_mime_type(filepath: str) -> str: + """ + Guess MIME type from file extension. + + Args: + filepath: Path to the file + + Returns: + MIME type string + """ + import mimetypes + mime_type, _ = mimetypes.guess_type(filepath) + return mime_type or 'application/octet-stream' diff --git a/src/utils/database.py b/src/utils/database.py new file mode 100644 index 0000000..a0ac0a3 --- /dev/null +++ b/src/utils/database.py @@ -0,0 +1,227 @@ +""" +Database schema migration utilities. + +IMPORTANT: All migrations must be compatible with both SQLite and PostgreSQL. +- Boolean defaults: SQLite uses 0/1, PostgreSQL requires FALSE/TRUE +- Type differences: SQLite DATETIME -> PostgreSQL TIMESTAMP, BLOB -> BYTEA +- Reserved keywords: "user", "order" etc. must be quoted +- The add_column_if_not_exists() function handles these automatically +- Use create_index_if_not_exists() for index creation with proper quoting +""" + +import re +from sqlalchemy import inspect, text + + +def add_column_if_not_exists(engine, table_name, column_name, column_type): + """ + Add a column to a table if it doesn't already exist. + + Args: + engine: SQLAlchemy engine + table_name: Name of the table + column_name: Name of the column to add + column_type: SQL type definition for the column + + Returns: + bool: True if column was added, False if it already existed + """ + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns(table_name)] + + if column_name not in columns: + if engine.name == 'postgresql': + # PostgreSQL requires TRUE/FALSE for boolean defaults, not 0/1 + if 'BOOLEAN' in column_type.upper(): + column_type = column_type.replace('DEFAULT 0', 'DEFAULT FALSE') + column_type = column_type.replace('DEFAULT 1', 'DEFAULT TRUE') + + # PostgreSQL uses TIMESTAMP, not DATETIME + column_type = re.sub(r'\bDATETIME\b', 'TIMESTAMP', column_type, flags=re.IGNORECASE) + + # PostgreSQL uses BYTEA, not BLOB + column_type = re.sub(r'\bBLOB\b', 'BYTEA', column_type, flags=re.IGNORECASE) + + # PostgreSQL interprets double-quoted strings as identifiers, not literals + # Convert DEFAULT "value" to DEFAULT 'value' + column_type = re.sub(r'''DEFAULT\s+"([^"]*)"''', r"DEFAULT '\1'", column_type, flags=re.IGNORECASE) + + with engine.connect() as conn: + # Quote identifiers to handle reserved keywords (e.g., "user" in PostgreSQL) + # MySQL uses backticks, PostgreSQL/SQLite use double quotes + # Handle special case where column_type includes the column name + if column_name in column_type: + if engine.name == 'mysql': + conn.execute(text(f'ALTER TABLE `{table_name}` ADD COLUMN {column_type}')) + else: + conn.execute(text(f'ALTER TABLE "{table_name}" ADD COLUMN {column_type}')) + else: + if engine.name == 'mysql': + conn.execute(text(f'ALTER TABLE `{table_name}` ADD COLUMN `{column_name}` {column_type}')) + else: + conn.execute(text(f'ALTER TABLE "{table_name}" ADD COLUMN "{column_name}" {column_type}')) + conn.commit() + return True + return False + + +def create_index_if_not_exists(engine, index_name, table_name, columns, unique=False): + """ + Create an index on a table if it doesn't already exist. + + Handles cross-database compatibility by properly quoting table names, + especially important for reserved keywords like 'user', 'order', etc. + + Args: + engine: SQLAlchemy engine + index_name: Name of the index to create + table_name: Name of the table + columns: Column(s) to index (string, can be comma-separated for composite) + unique: Whether to create a unique index (default False) + + Returns: + bool: True if index was created, False if it already existed or table doesn't exist + """ + inspector = inspect(engine) + + # Check if table exists + if table_name not in inspector.get_table_names(): + return False + + # Check if index already exists + existing_indexes = [idx['name'] for idx in inspector.get_indexes(table_name)] + if index_name in existing_indexes: + return False + + unique_clause = 'UNIQUE ' if unique else '' + + with engine.connect() as conn: + # Quote table name to handle reserved keywords (e.g., "user" in PostgreSQL) + # MySQL uses backticks, PostgreSQL/SQLite use double quotes + if engine.name == 'mysql': + quoted_table = f'`{table_name}`' + else: + quoted_table = f'"{table_name}"' + + # Note: IF NOT EXISTS may not be supported on all databases, but we already + # checked for existence above, so it's just a safety net + try: + conn.execute(text( + f'CREATE {unique_clause}INDEX IF NOT EXISTS {index_name} ON {quoted_table} ({columns})' + )) + except Exception: + # Some databases don't support IF NOT EXISTS, try without + conn.execute(text( + f'CREATE {unique_clause}INDEX {index_name} ON {quoted_table} ({columns})' + )) + conn.commit() + return True + + +def migrate_column_type(engine, table_name, column_name, new_type, transform_sql=None): + """ + Migrate a column to a new type if it exists. + + For SQLite, this uses a temporary column approach since SQLite doesn't support ALTER COLUMN. + + Args: + engine: SQLAlchemy engine + table_name: Name of the table + column_name: Name of the column to modify + new_type: New SQL type for the column + transform_sql: Optional SQL expression to transform existing data (e.g., "datetime(meeting_date || ' 12:00:00')") + If None, data is copied as-is + + Returns: + bool: True if column was migrated, False if it didn't exist or migration wasn't needed + """ + inspector = inspect(engine) + + # Check if table exists + if table_name not in inspector.get_table_names(): + return False + + columns = {col['name']: col for col in inspector.get_columns(table_name)} + + if column_name not in columns: + return False + + engine_name = engine.name + + with engine.connect() as conn: + if engine_name == 'sqlite': + # SQLite approach: use temporary column + temp_col = f"{column_name}_new" + + # Check if temp column already exists (migration interrupted?) + if temp_col in columns: + try: + # Try to drop it and start over + conn.execute(text(f'ALTER TABLE "{table_name}" DROP COLUMN "{temp_col}"')) + conn.commit() + except Exception: + # If we can't drop it, the migration may have partially completed + # Check if old column still exists + if column_name not in columns: + # Old column is gone, temp exists - just rename temp to complete migration + try: + conn.execute(text(f'ALTER TABLE "{table_name}" RENAME COLUMN "{temp_col}" TO "{column_name}"')) + conn.commit() + return True + except Exception as e: + # Can't complete, leave as-is + return False + # Both columns exist - abort to avoid data issues + return False + + # Add temporary column with new type + conn.execute(text(f'ALTER TABLE "{table_name}" ADD COLUMN "{temp_col}" {new_type}')) + + # Copy data with optional transformation + if transform_sql: + conn.execute(text(f'UPDATE "{table_name}" SET "{temp_col}" = {transform_sql} WHERE "{column_name}" IS NOT NULL')) + else: + conn.execute(text(f'UPDATE "{table_name}" SET "{temp_col}" = "{column_name}"')) + + # Drop old column (SQLite 3.35.0+ only) + try: + conn.execute(text(f'ALTER TABLE "{table_name}" DROP COLUMN "{column_name}"')) + # Drop succeeded, now rename temp to original name + conn.execute(text(f'ALTER TABLE "{table_name}" RENAME COLUMN "{temp_col}" TO "{column_name}"')) + conn.commit() + except Exception: + # Older SQLite - can't drop columns + # Rename temp column to original name (this will fail if original still exists) + try: + conn.execute(text(f'ALTER TABLE "{table_name}" RENAME COLUMN "{temp_col}" TO "{column_name}"')) + conn.commit() + except Exception: + # Can't rename because old column exists - this is OK for SQLite + # Just keep the new column and let the app use the old one + # The data in the old column is still valid + conn.rollback() + # Actually, let's just commit the temp column addition + # The model will use column_name which still exists with old data + # This is safe - new records will use the new model definition + return False + + elif engine_name == 'postgresql': + # PostgreSQL can alter column type directly + if transform_sql: + conn.execute(text(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" TYPE {new_type} USING {transform_sql}')) + else: + conn.execute(text(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" TYPE {new_type}')) + conn.commit() + + elif engine_name == 'mysql': + # MySQL can modify column type + conn.execute(text(f'ALTER TABLE `{table_name}` MODIFY COLUMN `{column_name}` {new_type}')) + + # Apply transformation if provided + if transform_sql: + conn.execute(text(f'UPDATE `{table_name}` SET `{column_name}` = {transform_sql} WHERE `{column_name}` IS NOT NULL')) + conn.commit() + + return True + + return False diff --git a/src/utils/datetime.py b/src/utils/datetime.py new file mode 100644 index 0000000..28e21bc --- /dev/null +++ b/src/utils/datetime.py @@ -0,0 +1,46 @@ +""" +Datetime utilities for timezone handling and formatting. + +This module provides functions for converting and formatting datetimes +with timezone awareness. +""" + +import os +import logging +import pytz +from babel.dates import format_datetime + +# Module-level logger +logger = logging.getLogger(__name__) + + +def local_datetime_filter(dt): + """ + Format a UTC datetime object to the user's local timezone. + + Args: + dt: datetime object to format (assumed UTC if naive) + + Returns: + str: Formatted datetime string in user's timezone + """ + if dt is None: + return "" + + # Get timezone from .env, default to UTC + user_tz_name = os.environ.get('TIMEZONE', 'UTC') + try: + user_tz = pytz.timezone(user_tz_name) + except pytz.UnknownTimeZoneError: + user_tz = pytz.utc + logger.warning(f"Invalid TIMEZONE '{user_tz_name}' in .env. Defaulting to UTC.") + + # If the datetime object is naive, assume it's UTC + if dt.tzinfo is None: + dt = pytz.utc.localize(dt) + + # Convert to the user's timezone + local_dt = dt.astimezone(user_tz) + + # Format it nicely + return format_datetime(local_dt, format='medium', locale='en_US') diff --git a/src/utils/error_formatting.py b/src/utils/error_formatting.py new file mode 100644 index 0000000..0bbcab6 --- /dev/null +++ b/src/utils/error_formatting.py @@ -0,0 +1,365 @@ +""" +User-friendly error formatting utility. + +Transforms technical error messages into user-friendly explanations with +actionable guidance. Works for both known error patterns and unknown errors. +""" + +import re +import json +from typing import Dict, Optional, Tuple + + +# Known error patterns with user-friendly messages +ERROR_PATTERNS = [ + # File size errors + { + 'patterns': [ + r'maximum content size limit.*exceeded', + r'file.*too large', + r'413.*exceeded', + r'payload too large', + ], + 'title': 'File Too Large', + 'message': 'The audio file exceeds the maximum size allowed by the transcription service.', + 'guidance': 'Try enabling audio chunking in your settings, or compress the audio file before uploading.', + 'icon': 'fa-file-audio', + 'type': 'size_limit' + }, + # Timeout errors + { + 'patterns': [ + r'timed?\s*out', + r'timeout', + r'deadline exceeded', + r'request took too long', + ], + 'title': 'Processing Timeout', + 'message': 'The transcription took too long to complete.', + 'guidance': 'This can happen with very long recordings. Try splitting the audio into smaller parts, or increase the timeout setting if available.', + 'icon': 'fa-clock', + 'type': 'timeout' + }, + # Authentication errors + { + 'patterns': [ + r'401.*unauthorized', + r'invalid.*api.*key', + r'authentication.*failed', + r'api key.*invalid', + r'incorrect api key', + ], + 'title': 'Authentication Error', + 'message': 'The transcription service rejected the API credentials.', + 'guidance': 'Please check that your API key is correct and has not expired. Contact your administrator if the problem persists.', + 'icon': 'fa-key', + 'type': 'auth' + }, + # Rate limit errors + { + 'patterns': [ + r'rate.*limit', + r'too many requests', + r'429', + r'quota.*exceeded', + ], + 'title': 'Rate Limit Exceeded', + 'message': 'Too many requests were sent to the transcription service.', + 'guidance': 'Please wait a few minutes before trying again. The system will automatically retry failed jobs.', + 'icon': 'fa-hourglass-half', + 'type': 'rate_limit' + }, + # Connection errors + { + 'patterns': [ + r'connection.*refused', + r'connection.*reset', + r'could not connect', + r'network.*unreachable', + r'name.*resolution.*failed', + r'dns.*failed', + ], + 'title': 'Connection Error', + 'message': 'Could not connect to the transcription service.', + 'guidance': 'Please check your internet connection and ensure the transcription service is available. If using a self-hosted service, verify it is running.', + 'icon': 'fa-wifi', + 'type': 'connection' + }, + # Service unavailable + { + 'patterns': [ + r'503.*service unavailable', + r'502.*bad gateway', + r'500.*internal server error', + r'service.*unavailable', + r'server.*error', + ], + 'title': 'Service Unavailable', + 'message': 'The transcription service is temporarily unavailable.', + 'guidance': 'This is usually temporary. Please try again in a few minutes.', + 'icon': 'fa-server', + 'type': 'service_error' + }, + # Invalid audio format + { + 'patterns': [ + r'invalid.*file.*format', + r'unsupported.*format', + r'could not.*decode', + r'audio.*corrupt', + r'not.*valid.*audio', + ], + 'title': 'Invalid Audio Format', + 'message': 'The audio file format is not supported or the file may be corrupted.', + 'guidance': 'Try converting the audio to MP3 or WAV format before uploading. If the file plays correctly on your device, try re-exporting it.', + 'icon': 'fa-file-audio', + 'type': 'format' + }, + # Insufficient funds/billing + { + 'patterns': [ + r'insufficient.*funds', + r'billing.*issue', + r'payment.*required', + r'account.*suspended', + ], + 'title': 'Billing Issue', + 'message': 'There is a billing issue with the transcription service account.', + 'guidance': 'Please check your account status and payment information with the transcription service provider.', + 'icon': 'fa-credit-card', + 'type': 'billing' + }, + # Model not found + { + 'patterns': [ + r'model.*not.*found', + r'invalid.*model', + r'model.*does not exist', + ], + 'title': 'Model Not Available', + 'message': 'The requested transcription model is not available.', + 'guidance': 'Please check the model name in your settings. The model may have been deprecated or renamed.', + 'icon': 'fa-microchip', + 'type': 'model' + }, + # Audio extraction failed + { + 'patterns': [ + r'audio.*extraction.*failed', + r'could not.*extract.*audio', + r'ffmpeg.*failed', + r'no audio.*stream', + ], + 'title': 'Audio Extraction Failed', + 'message': 'Could not extract audio from the uploaded file.', + 'guidance': 'The file may be corrupted or in an unsupported format. Try converting it to a standard audio format (MP3, WAV) before uploading.', + 'icon': 'fa-file-video', + 'type': 'extraction' + }, +] + + +def extract_error_details(error_text: str) -> Dict: + """ + Extract structured error details from raw error text. + Attempts to parse JSON error responses from APIs. + """ + details = { + 'raw': error_text, + 'code': None, + 'message': None, + 'type': None, + } + + # Try to extract error code + code_match = re.search(r'(?:error\s*code|status)[:\s]*(\d{3})', error_text, re.IGNORECASE) + if code_match: + details['code'] = code_match.group(1) + + # Try to parse JSON error structure + json_match = re.search(r'\{[^{}]*["\']error["\'][^{}]*\}', error_text) + if json_match: + try: + # Clean up the JSON-like string + json_str = json_match.group(0).replace("'", '"') + error_obj = json.loads(json_str) + if 'error' in error_obj: + err = error_obj['error'] + if isinstance(err, dict): + details['message'] = err.get('message') + details['type'] = err.get('type') + details['code'] = details['code'] or err.get('code') + except (json.JSONDecodeError, KeyError): + pass + + # Try to extract message from common patterns + if not details['message']: + msg_match = re.search(r"['\"]message['\"]\s*:\s*['\"]([^'\"]+)['\"]", error_text) + if msg_match: + details['message'] = msg_match.group(1) + + return details + + +def format_error_for_user(error_text: str) -> Dict: + """ + Transform a technical error message into a user-friendly format. + + Returns: + Dict with keys: + - title: Short, user-friendly title + - message: Plain language explanation + - guidance: Actionable suggestion + - icon: FontAwesome icon class + - type: Error category + - technical: Original error (for advanced users/debugging) + - is_known: Whether this matched a known pattern + """ + if not error_text: + return { + 'title': 'Unknown Error', + 'message': 'An unexpected error occurred.', + 'guidance': 'Please try again. If the problem persists, contact support.', + 'icon': 'fa-exclamation-triangle', + 'type': 'unknown', + 'technical': '', + 'is_known': False + } + + error_lower = error_text.lower() + + # Check against known patterns + for pattern_info in ERROR_PATTERNS: + for pattern in pattern_info['patterns']: + if re.search(pattern, error_lower): + return { + 'title': pattern_info['title'], + 'message': pattern_info['message'], + 'guidance': pattern_info['guidance'], + 'icon': pattern_info['icon'], + 'type': pattern_info['type'], + 'technical': error_text, + 'is_known': True + } + + # Unknown error - try to make it more readable + details = extract_error_details(error_text) + + # Clean up the error message for display + clean_message = details['message'] or error_text + + # Remove common prefixes + for prefix in ['Transcription failed:', 'Processing failed:', 'Error:', 'Exception:']: + if clean_message.startswith(prefix): + clean_message = clean_message[len(prefix):].strip() + + # Truncate very long messages + if len(clean_message) > 200: + clean_message = clean_message[:200] + '...' + + # Generate a reasonable title based on error code + title = 'Processing Error' + if details['code']: + code = details['code'] + if code.startswith('4'): + title = 'Request Error' + elif code.startswith('5'): + title = 'Server Error' + + return { + 'title': title, + 'message': clean_message, + 'guidance': 'If this error persists, try reprocessing the recording or contact support for assistance.', + 'icon': 'fa-exclamation-circle', + 'type': 'unknown', + 'technical': error_text, + 'is_known': False + } + + +def format_error_for_storage(error_text: str) -> str: + """ + Format an error message for storage in the database. + Returns a JSON string that can be parsed by the frontend for nice display. + + The format is: ERROR_JSON:{"title": "...", "message": "...", ...} + + This allows the frontend to detect formatted errors and display them nicely, + while still being human-readable if viewed raw. + """ + formatted = format_error_for_user(error_text) + + # Create a compact JSON representation + error_data = { + 't': formatted['title'], + 'm': formatted['message'], + 'g': formatted['guidance'], + 'i': formatted['icon'], + 'y': formatted['type'], + 'k': formatted['is_known'], + } + + # Only include technical details if it adds value + if formatted['technical'] and formatted['technical'] != formatted['message']: + error_data['d'] = formatted['technical'][:500] # Limit technical detail length + + try: + json_str = json.dumps(error_data, ensure_ascii=False) + return f"ERROR_JSON:{json_str}" + except (TypeError, ValueError): + # Fallback to plain text if JSON encoding fails + return f"{formatted['title']}: {formatted['message']}" + + +def parse_stored_error(stored_text: str) -> Optional[Dict]: + """ + Parse a stored error message. Returns the formatted error dict if it's + a JSON-formatted error, or None if it's plain text. + """ + if not stored_text or not stored_text.startswith('ERROR_JSON:'): + return None + + try: + json_str = stored_text[11:] # Remove 'ERROR_JSON:' prefix + data = json.loads(json_str) + return { + 'title': data.get('t', 'Error'), + 'message': data.get('m', 'An error occurred'), + 'guidance': data.get('g', ''), + 'icon': data.get('i', 'fa-exclamation-circle'), + 'type': data.get('y', 'unknown'), + 'is_known': data.get('k', False), + 'technical': data.get('d', ''), + } + except (json.JSONDecodeError, KeyError, TypeError): + return None + + +def is_transcription_error(transcription_text: str) -> bool: + """ + Check if the transcription text is actually an error message. + + Returns True if the text is an error message (not valid transcription content). + This should be used to prevent operations like summarization or chat on failed recordings. + """ + if not transcription_text: + return False + + # Check for JSON-formatted error + if transcription_text.startswith('ERROR_JSON:'): + return True + + # Check for legacy error prefixes + error_prefixes = [ + 'Transcription failed:', + 'Processing failed:', + 'ASR processing failed:', + 'Audio extraction failed:', + 'Upload/Processing failed:', + ] + + for prefix in error_prefixes: + if transcription_text.startswith(prefix): + return True + + return False diff --git a/src/utils/ffmpeg_utils.py b/src/utils/ffmpeg_utils.py new file mode 100644 index 0000000..5072528 --- /dev/null +++ b/src/utils/ffmpeg_utils.py @@ -0,0 +1,448 @@ +"""Centralized FFmpeg utilities for consistent audio/video processing.""" + +import os +import subprocess +import tempfile +from contextlib import contextmanager +from typing import Optional, Tuple +from flask import current_app + + +# Configuration constants +DEFAULT_MP3_BITRATE = os.getenv('AUDIO_BITRATE', '128k') +DEFAULT_SAMPLE_RATE = os.getenv('AUDIO_SAMPLE_RATE', '44100') +DEFAULT_CHANNELS = int(os.getenv('AUDIO_CHANNELS', '1')) # Mono for speech +DEFAULT_COMPRESSION_LEVEL = int(os.getenv('AUDIO_COMPRESSION_LEVEL', '2')) + + +class FFmpegError(Exception): + """Custom exception for FFmpeg-related errors.""" + pass + + +class FFmpegNotFoundError(FFmpegError): + """Raised when FFmpeg executable is not found.""" + pass + + +def convert_to_mp3( + input_path: str, + output_path: Optional[str] = None, + bitrate: str = DEFAULT_MP3_BITRATE, + sample_rate: str = DEFAULT_SAMPLE_RATE, + channels: int = DEFAULT_CHANNELS, + compression_level: int = DEFAULT_COMPRESSION_LEVEL +) -> str: + """ + Convert audio/video file to MP3 format using FFmpeg. + + Args: + input_path: Path to input audio/video file + output_path: Path for output MP3 file (auto-generated if None) + bitrate: MP3 bitrate (e.g., '128k', '192k') + sample_rate: Sample rate in Hz (e.g., '44100', '48000') + channels: Number of audio channels (1=mono, 2=stereo) + compression_level: MP3 compression level (0-9, higher=better compression) + + Returns: + Path to the created MP3 file + + Raises: + FFmpegNotFoundError: If FFmpeg is not installed + FFmpegError: If conversion fails + """ + if output_path is None: + base = os.path.splitext(input_path)[0] + output_path = f"{base}.mp3" + + cmd = [ + 'ffmpeg', + '-i', input_path, + '-y', # Overwrite output + '-acodec', 'libmp3lame', + '-b:a', bitrate, + '-ar', sample_rate, + '-ac', str(channels), + '-compression_level', str(compression_level), + output_path + ] + + _run_ffmpeg_command(cmd, f"MP3 conversion of {os.path.basename(input_path)}") + return output_path + + +def extract_audio_from_video( + video_path: str, + output_format: str = 'mp3', + bitrate: str = DEFAULT_MP3_BITRATE, + cleanup_original: bool = True, + copy_stream: bool = False +) -> Tuple[str, str]: + """ + Extract audio track from video file. + + Args: + video_path: Path to video file + output_format: Audio format ('mp3', 'wav', 'flac', 'copy') + bitrate: Audio bitrate for lossy formats (ignored if copy_stream=True) + cleanup_original: Whether to delete the original video file + copy_stream: If True, copy audio stream without re-encoding (fast, preserves quality) + If False, re-encode to specified format + + Returns: + Tuple of (audio_filepath, mime_type) + + Raises: + FFmpegNotFoundError: If FFmpeg is not installed + FFmpegError: If extraction fails + """ + base_path = os.path.splitext(video_path)[0] + + try: + if copy_stream or output_format == 'copy': + # Copy audio stream without re-encoding - need to detect the format first + from src.utils.ffprobe import get_codec_info + + try: + codec_info = get_codec_info(video_path, timeout=10) + audio_codec = codec_info.get('audio_codec', 'unknown') + + # Map codec to extension and MIME type + codec_map = { + 'aac': {'ext': 'm4a', 'mime': 'audio/mp4'}, + 'mp3': {'ext': 'mp3', 'mime': 'audio/mpeg'}, + 'opus': {'ext': 'opus', 'mime': 'audio/opus'}, + 'vorbis': {'ext': 'ogg', 'mime': 'audio/ogg'}, + 'flac': {'ext': 'flac', 'mime': 'audio/flac'}, + } + + if audio_codec in codec_map: + output_ext = codec_map[audio_codec]['ext'] + mime_type = codec_map[audio_codec]['mime'] + else: + # Default to m4a for unknown codecs + current_app.logger.warning(f"Unknown audio codec '{audio_codec}', defaulting to m4a container") + output_ext = 'm4a' + mime_type = 'audio/mp4' + + temp_audio_path = f"{base_path}_audio_temp.{output_ext}" + final_audio_path = f"{base_path}_audio.{output_ext}" + + cmd = [ + 'ffmpeg', + '-i', video_path, + '-y', + '-vn', # No video + '-acodec', 'copy', # Copy audio stream without re-encoding + temp_audio_path + ] + + current_app.logger.info(f"Copying audio stream (codec: {audio_codec}) without re-encoding") + + except Exception as probe_error: + current_app.logger.warning(f"Failed to detect audio codec: {probe_error}. Falling back to MP3 encoding.") + # Fallback to MP3 encoding if we can't detect the codec + output_ext = 'mp3' + mime_type = 'audio/mpeg' + temp_audio_path = f"{base_path}_audio_temp.{output_ext}" + final_audio_path = f"{base_path}_audio.{output_ext}" + + cmd = [ + 'ffmpeg', + '-i', video_path, + '-y', + '-vn', + '-acodec', 'libmp3lame', + '-b:a', bitrate, + '-ar', DEFAULT_SAMPLE_RATE, + '-ac', str(DEFAULT_CHANNELS), + '-compression_level', str(DEFAULT_COMPRESSION_LEVEL), + temp_audio_path + ] + + elif output_format == 'mp3': + temp_audio_path = f"{base_path}_audio_temp.mp3" + final_audio_path = f"{base_path}_audio.mp3" + cmd = [ + 'ffmpeg', + '-i', video_path, + '-y', + '-vn', # No video + '-acodec', 'libmp3lame', + '-b:a', bitrate, + '-ar', DEFAULT_SAMPLE_RATE, + '-ac', str(DEFAULT_CHANNELS), + '-compression_level', str(DEFAULT_COMPRESSION_LEVEL), + temp_audio_path + ] + mime_type = 'audio/mpeg' + elif output_format == 'wav': + temp_audio_path = f"{base_path}_audio_temp.wav" + final_audio_path = f"{base_path}_audio.wav" + cmd = [ + 'ffmpeg', + '-i', video_path, + '-y', + '-vn', + '-acodec', 'pcm_s16le', + '-ar', DEFAULT_SAMPLE_RATE, + temp_audio_path + ] + mime_type = 'audio/wav' + elif output_format == 'flac': + temp_audio_path = f"{base_path}_audio_temp.flac" + final_audio_path = f"{base_path}_audio.flac" + cmd = [ + 'ffmpeg', + '-i', video_path, + '-y', + '-vn', + '-acodec', 'flac', + '-compression_level', '12', + temp_audio_path + ] + mime_type = 'audio/flac' + elif output_format == 'opus': + temp_audio_path = f"{base_path}_audio_temp.opus" + final_audio_path = f"{base_path}_audio.opus" + cmd = [ + 'ffmpeg', + '-i', video_path, + '-y', + '-vn', + '-acodec', 'libopus', + '-b:a', bitrate, + temp_audio_path + ] + mime_type = 'audio/opus' + else: + raise ValueError(f"Unsupported output format: {output_format}") + + _run_ffmpeg_command(cmd, f"Audio extraction from {os.path.basename(video_path)}") + + # Optionally preserve temp file for debugging + if os.getenv('PRESERVE_TEMP_AUDIO', 'false').lower() == 'true': + import shutil + debug_path = temp_audio_path.replace('_temp', '_debug') + shutil.copy2(temp_audio_path, debug_path) + current_app.logger.info(f"Debug: Preserved temp audio file as {debug_path}") + + # Rename temp file to final filename + os.rename(temp_audio_path, final_audio_path) + + if cleanup_original: + try: + os.remove(video_path) + current_app.logger.info(f"Cleaned up original video: {os.path.basename(video_path)}") + except Exception as e: + current_app.logger.warning(f"Failed to cleanup video {video_path}: {e}") + + return final_audio_path, mime_type + + except Exception as e: + # Clean up temp file on error + if os.path.exists(temp_audio_path): + try: + os.remove(temp_audio_path) + except: + pass + raise + + +def compress_audio( + input_path: str, + codec: str = 'mp3', + bitrate: str = DEFAULT_MP3_BITRATE, + delete_original: bool = True, + codec_info: Optional[dict] = None +) -> Tuple[str, str, Optional[dict]]: + """ + Compress audio file to specified codec. + + Args: + input_path: Path to input audio file + codec: Target codec ('mp3', 'flac', 'opus') + bitrate: Bitrate for lossy codecs (ignored for FLAC) + delete_original: Whether to delete the original file after compression + codec_info: Optional pre-fetched codec info (returned as-is, not updated) + + Returns: + Tuple of (output_path, mime_type, codec_info) + Note: codec_info is returned unchanged (None after compression) + + Raises: + FFmpegNotFoundError: If FFmpeg is not installed + FFmpegError: If compression fails + """ + codec_config = { + 'mp3': { + 'ext': '.mp3', + 'mime': 'audio/mpeg', + 'cmd_args': [ + '-acodec', 'libmp3lame', + '-b:a', bitrate, + '-ar', DEFAULT_SAMPLE_RATE, + '-ac', str(DEFAULT_CHANNELS) + ] + }, + 'flac': { + 'ext': '.flac', + 'mime': 'audio/flac', + 'cmd_args': ['-acodec', 'flac', '-compression_level', '12'] + }, + 'opus': { + 'ext': '.opus', + 'mime': 'audio/opus', + 'cmd_args': ['-acodec', 'libopus', '-b:a', bitrate] + } + } + + if codec not in codec_config: + raise ValueError(f"Unsupported codec: {codec}. Supported: {list(codec_config.keys())}") + + config = codec_config[codec] + base_path = os.path.splitext(input_path)[0] + temp_output_path = f"{base_path}_compressed_temp{config['ext']}" + final_output_path = f"{base_path}{config['ext']}" + + try: + # Get original file size for logging + original_size = os.path.getsize(input_path) + + cmd = ['ffmpeg', '-i', input_path, '-y'] + config['cmd_args'] + [temp_output_path] + + _run_ffmpeg_command(cmd, f"Compression of {os.path.basename(input_path)} to {codec}") + + # Get compressed file size + compressed_size = os.path.getsize(temp_output_path) + ratio = (1 - compressed_size / original_size) * 100 if original_size > 0 else 0 + + current_app.logger.info( + f"Compressed {os.path.basename(input_path)}: " + f"{original_size / 1024 / 1024:.1f}MB -> " + f"{compressed_size / 1024 / 1024:.1f}MB ({ratio:.1f}% reduction)" + ) + + # Remove original and rename temp to final + if delete_original: + os.remove(input_path) + current_app.logger.debug(f"Deleted original file: {input_path}") + os.rename(temp_output_path, final_output_path) + + # Return codec_info as None since file was converted (codec changed) + return final_output_path, config['mime'], None + + except Exception as e: + # Clean up temp file if it exists + if os.path.exists(temp_output_path): + try: + os.remove(temp_output_path) + except: + pass + # Re-raise with codec_info preservation + raise + + +def extract_audio_segment( + input_path: str, + output_path: str, + start_time: float, + duration: float +) -> None: + """ + Extract a segment from an audio file. + + Args: + input_path: Path to input audio file + output_path: Path for output segment + start_time: Start time in seconds + duration: Duration in seconds + + Raises: + FFmpegNotFoundError: If FFmpeg is not installed + FFmpegError: If extraction fails + """ + cmd = [ + 'ffmpeg', + '-i', input_path, + '-ss', str(start_time), + '-t', str(duration), + '-vn', # Drop video streams (audio segment only) + '-c:a', 'copy', # Copy audio codec (no re-encoding) + '-y', + output_path + ] + + _run_ffmpeg_command(cmd, f"Segment extraction from {os.path.basename(input_path)}") + + +@contextmanager +def temp_audio_conversion(input_path: str, target_format: str = 'mp3'): + """ + Context manager for temporary audio conversion. + Automatically cleans up temp file on exit. + + Example: + with temp_audio_conversion(input_path, 'mp3') as mp3_path: + # Use mp3_path + process_audio(mp3_path) + # mp3_path is automatically deleted + + Args: + input_path: Path to input audio file + target_format: Target format ('mp3', 'wav', etc.) + + Yields: + Path to temporary converted file + """ + temp_path = None + try: + with tempfile.NamedTemporaryFile(suffix=f'.{target_format}', delete=False) as temp_file: + temp_path = temp_file.name + + if target_format == 'mp3': + convert_to_mp3(input_path, temp_path) + else: + raise ValueError(f"Unsupported target format: {target_format}") + + yield temp_path + + finally: + if temp_path and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except Exception as e: + current_app.logger.warning(f"Failed to cleanup temp file {temp_path}: {e}") + + +def _run_ffmpeg_command(cmd: list, operation_description: str) -> None: + """ + Execute FFmpeg command with consistent error handling. + + Args: + cmd: FFmpeg command as list of strings + operation_description: Human-readable description for error messages + + Raises: + FFmpegNotFoundError: If FFmpeg is not installed + FFmpegError: If FFmpeg command fails + """ + try: + current_app.logger.debug(f"Running FFmpeg command: {' '.join(cmd)}") + result = subprocess.run( + cmd, + check=True, + capture_output=True, + text=True + ) + current_app.logger.debug(f"FFmpeg {operation_description} completed successfully") + + except FileNotFoundError: + error_msg = "FFmpeg not found. Please ensure FFmpeg is installed and in the system's PATH." + current_app.logger.error(error_msg) + raise FFmpegNotFoundError(error_msg) + + except subprocess.CalledProcessError as e: + error_msg = f"{operation_description} failed: {e.stderr}" + current_app.logger.error(f"FFmpeg error: {error_msg}") + raise FFmpegError(error_msg) \ No newline at end of file diff --git a/src/utils/ffprobe.py b/src/utils/ffprobe.py new file mode 100644 index 0000000..08add8b --- /dev/null +++ b/src/utils/ffprobe.py @@ -0,0 +1,499 @@ +""" +FFprobe utility for detecting audio/video codecs and format information. + +This module provides functions to inspect media files using ffprobe and return +structured information about their codecs, streams, and formats. +""" + +import json +import logging +import subprocess +from datetime import datetime +from typing import Optional, Dict, Any, Tuple + +logger = logging.getLogger(__name__) + + +class FFProbeError(Exception): + """Raised when ffprobe fails to analyze a file.""" + pass + + +def probe(filename: str, cmd: str = 'ffprobe', timeout: Optional[int] = None) -> Dict[str, Any]: + """ + Run ffprobe on the specified file and return a JSON representation of the output. + + Args: + filename: Path to the media file to probe + cmd: Command to use (default: 'ffprobe') + timeout: Optional timeout in seconds + + Returns: + Dictionary containing streams and format information + + Raises: + FFProbeError: if ffprobe returns a non-zero exit code + """ + args = [cmd, '-show_format', '-show_streams', '-of', 'json', filename] + p = None + + try: + p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + communicate_kwargs = {} + if timeout is not None: + communicate_kwargs['timeout'] = timeout + out, err = p.communicate(**communicate_kwargs) + + if p.returncode != 0: + error_msg = err.decode('utf-8', errors='ignore') + raise FFProbeError(f'ffprobe failed: {error_msg}') + + return json.loads(out.decode('utf-8')) + except subprocess.TimeoutExpired: + if p: + p.kill() + raise FFProbeError(f'ffprobe timed out after {timeout} seconds') + except FileNotFoundError: + raise FFProbeError('ffprobe command not found. Please ensure ffmpeg is installed.') + except json.JSONDecodeError as e: + raise FFProbeError(f'Failed to parse ffprobe output: {e}') + + +def get_codec_info(filename: str, timeout: Optional[int] = None) -> Dict[str, Any]: + """ + Get codec information for a media file. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + + Returns: + Dictionary with keys: + - audio_codec: Audio codec name (e.g., 'pcm_s16le', 'aac', 'mp3') + - video_codec: Video codec name if present, or None + - has_video: Boolean indicating if file contains video stream + - has_audio: Boolean indicating if file contains audio stream + - format_name: Container format name (e.g., 'wav', 'mov,mp4,m4a') + - duration: Duration in seconds (float) + - sample_rate: Audio sample rate if available + - channels: Number of audio channels if available + - bit_rate: Bit rate if available + + Raises: + FFProbeError: if ffprobe fails to analyze the file + """ + try: + probe_data = probe(filename, timeout=timeout) + except FFProbeError: + raise + + result = { + 'audio_codec': None, + 'video_codec': None, + 'has_video': False, + 'has_audio': False, + 'format_name': None, + 'duration': None, + 'sample_rate': None, + 'channels': None, + 'bit_rate': None + } + + # Extract format information + if 'format' in probe_data: + fmt = probe_data['format'] + result['format_name'] = fmt.get('format_name') + + if 'duration' in fmt: + try: + result['duration'] = float(fmt['duration']) + except (ValueError, TypeError): + pass + + if 'bit_rate' in fmt: + try: + result['bit_rate'] = int(fmt['bit_rate']) + except (ValueError, TypeError): + pass + + # Extract stream information + if 'streams' in probe_data: + for stream in probe_data['streams']: + codec_type = stream.get('codec_type') + codec_name = stream.get('codec_name') + + if codec_type == 'audio': + result['has_audio'] = True + if result['audio_codec'] is None: # Use first audio stream + result['audio_codec'] = codec_name + result['sample_rate'] = stream.get('sample_rate') + result['channels'] = stream.get('channels') + + elif codec_type == 'video': + result['has_video'] = True + if result['video_codec'] is None: # Use first video stream + result['video_codec'] = codec_name + + return result + + +def is_video_file(filename: str, timeout: Optional[int] = None, codec_info: Optional[Dict[str, Any]] = None) -> bool: + """ + Check if a file contains video streams. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + True if file contains video streams, False otherwise + """ + try: + if codec_info is None: + codec_info = get_codec_info(filename, timeout=timeout) + return codec_info['has_video'] + except FFProbeError as e: + logger.warning(f"Failed to probe {filename}: {e}") + return False + + +def is_audio_file(filename: str, timeout: Optional[int] = None, codec_info: Optional[Dict[str, Any]] = None) -> bool: + """ + Check if a file contains audio streams. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + True if file contains audio streams, False otherwise + """ + try: + if codec_info is None: + codec_info = get_codec_info(filename, timeout=timeout) + return codec_info['has_audio'] + except FFProbeError as e: + logger.warning(f"Failed to probe {filename}: {e}") + return False + + +def get_audio_codec(filename: str, timeout: Optional[int] = None, codec_info: Optional[Dict[str, Any]] = None) -> Optional[str]: + """ + Get the audio codec name for a file. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + Audio codec name (e.g., 'pcm_s16le', 'aac', 'mp3', 'opus'), or None if no audio + """ + try: + if codec_info is None: + codec_info = get_codec_info(filename, timeout=timeout) + return codec_info['audio_codec'] + except FFProbeError as e: + logger.warning(f"Failed to probe {filename}: {e}") + return None + + +def needs_audio_conversion(filename: str, supported_codecs: list, timeout: Optional[int] = None, codec_info: Optional[Dict[str, Any]] = None) -> Tuple[bool, Optional[str]]: + """ + Check if a file needs audio conversion based on its codec. + + Args: + filename: Path to the media file + supported_codecs: List of supported audio codec names + timeout: Optional timeout in seconds + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + Tuple of (needs_conversion: bool, current_codec: str or None) + """ + try: + if codec_info is None: + codec_info = get_codec_info(filename, timeout=timeout) + + # If it has video, it likely needs conversion + if codec_info['has_video']: + return True, codec_info.get('audio_codec') + + # If no audio at all, cannot convert + if not codec_info['has_audio']: + logger.warning(f"File {filename} has no audio streams") + return False, None + + audio_codec = codec_info['audio_codec'] + + # Check if codec is in supported list + if audio_codec in supported_codecs: + return False, audio_codec + + return True, audio_codec + + except FFProbeError as e: + logger.warning(f"Failed to probe {filename}: {e}") + # Default to attempting conversion on error + return True, None + + +def is_lossless_audio(filename: str, timeout: Optional[int] = None, codec_info: Optional[Dict[str, Any]] = None) -> bool: + """ + Check if a file uses a lossless audio codec. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + True if file uses lossless audio codec, False otherwise + """ + lossless_codecs = { + 'pcm_s16le', 'pcm_s24le', 'pcm_s32le', + 'pcm_f32le', 'pcm_f64le', + 'pcm_u8', 'pcm_u16le', 'pcm_u24le', 'pcm_u32le', + 'flac', 'alac', 'ape', 'wavpack', 'tta', + 'mlp', 'truehd' + } + + try: + if codec_info is None: + codec_info = get_codec_info(filename, timeout=timeout) + audio_codec = codec_info['audio_codec'] + return audio_codec in lossless_codecs if audio_codec else False + except FFProbeError as e: + logger.warning(f"Failed to probe {filename}: {e}") + return False + + +def get_duration(filename: str, timeout: Optional[int] = None, codec_info: Optional[Dict[str, Any]] = None) -> Optional[float]: + """ + Get the duration of a media file in seconds. + + Uses multiple methods to determine duration: + 1. Format-level duration (fastest, works for most files) + 2. Packet timestamps fallback (for files without duration metadata like some WebM) + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + codec_info: Optional pre-fetched codec info to avoid redundant probe calls + + Returns: + Duration in seconds, or None if unable to determine + """ + try: + if codec_info is None: + codec_info = get_codec_info(filename, timeout=timeout) + + # Try format-level duration first + if codec_info['duration'] is not None: + return codec_info['duration'] + + # Fallback: scan packets to find the last timestamp + # This works for WebM and other files without duration metadata + return _get_duration_from_packets(filename, timeout=timeout) + except FFProbeError as e: + logger.warning(f"Failed to probe {filename}: {e}") + return None + + +def _get_duration_from_packets(filename: str, timeout: Optional[int] = None) -> Optional[float]: + """ + Get duration by scanning packet timestamps (fallback for files without duration metadata). + + This is slower than format-level duration but works for WebM and similar files + that don't store duration in the container metadata. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + + Returns: + Duration in seconds, or None if unable to determine + """ + try: + args = [ + 'ffprobe', '-v', 'error', + '-show_entries', 'packet=pts_time', + '-select_streams', 'a:0', # First audio stream + '-of', 'csv=p=0', + filename + ] + + p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + communicate_kwargs = {} + if timeout is not None: + communicate_kwargs['timeout'] = timeout + out, err = p.communicate(**communicate_kwargs) + + if p.returncode != 0: + logger.debug(f"Packet scan failed for {filename}") + return None + + # Parse the output to find the last timestamp + lines = out.decode('utf-8').strip().split('\n') + last_valid_time = None + for line in reversed(lines): + line = line.strip() + if line and line != 'N/A': + try: + last_valid_time = float(line) + break + except ValueError: + continue + + if last_valid_time is not None: + logger.debug(f"Got duration from packets for {filename}: {last_valid_time}") + return last_valid_time + + return None + except subprocess.TimeoutExpired: + logger.warning(f"Packet scan timed out for {filename}") + return None + except Exception as e: + logger.warning(f"Error scanning packets for {filename}: {e}") + return None + + +def get_creation_date(filename: str, timeout: Optional[int] = None, use_file_mtime: bool = True) -> Optional[datetime]: + """ + Extract the creation/recording date from a media file's metadata. + + Checks various metadata tags commonly used by recorders and devices: + - creation_time (MP4, M4A, MOV) + - date (various formats) + - encoded_date (some encoders) + + Falls back to file modification time if no metadata found and use_file_mtime is True. + + Args: + filename: Path to the media file + timeout: Optional timeout in seconds + use_file_mtime: If True, fall back to file modification time when no metadata found + + Returns: + datetime object if creation date found, None otherwise + """ + import os + + try: + probe_data = probe(filename, timeout=timeout) + except FFProbeError as e: + logger.warning(f"Failed to probe {filename} for creation date: {e}") + # Even if probe fails, we can still try file mtime + if use_file_mtime: + return _get_file_mtime(filename) + return None + + # Tags to check for creation date (in order of preference) + date_tags = ['creation_time', 'date', 'encoded_date', 'date_recorded', 'recording_time'] + + # Check format-level tags first + if 'format' in probe_data and 'tags' in probe_data['format']: + tags = probe_data['format']['tags'] + for tag in date_tags: + # Check both lowercase and original case + value = tags.get(tag) or tags.get(tag.upper()) + if value: + parsed = _parse_date_string(value) + if parsed: + logger.debug(f"Found creation date from format tag '{tag}': {parsed}") + return parsed + + # Check stream-level tags + if 'streams' in probe_data: + for stream in probe_data['streams']: + if 'tags' in stream: + tags = stream['tags'] + for tag in date_tags: + value = tags.get(tag) or tags.get(tag.upper()) + if value: + parsed = _parse_date_string(value) + if parsed: + logger.debug(f"Found creation date from stream tag '{tag}': {parsed}") + return parsed + + # Fall back to file modification time + if use_file_mtime: + mtime = _get_file_mtime(filename) + if mtime: + logger.debug(f"Using file modification time as creation date: {mtime}") + return mtime + + logger.debug(f"No creation date found for {filename}") + return None + + +def _get_file_mtime(filename: str) -> Optional[datetime]: + """ + Get the file's modification time as a datetime. + + Args: + filename: Path to the file + + Returns: + datetime object or None if unable to get mtime + """ + import os + + try: + stat_info = os.stat(filename) + return datetime.fromtimestamp(stat_info.st_mtime) + except (OSError, ValueError) as e: + logger.warning(f"Failed to get file mtime for {filename}: {e}") + return None + + +def _parse_date_string(date_str: str) -> Optional[datetime]: + """ + Parse various date string formats commonly found in media metadata. + + Args: + date_str: Date string to parse + + Returns: + datetime object if parsing successful, None otherwise + """ + if not date_str: + return None + + # Common formats in media files + formats = [ + '%Y-%m-%dT%H:%M:%S.%fZ', # ISO 8601 with microseconds and Z + '%Y-%m-%dT%H:%M:%SZ', # ISO 8601 with Z + '%Y-%m-%dT%H:%M:%S.%f%z', # ISO 8601 with microseconds and timezone + '%Y-%m-%dT%H:%M:%S%z', # ISO 8601 with timezone + '%Y-%m-%dT%H:%M:%S.%f', # ISO 8601 with microseconds + '%Y-%m-%dT%H:%M:%S', # ISO 8601 basic + '%Y-%m-%d %H:%M:%S', # Common datetime + '%Y/%m/%d %H:%M:%S', # Alternate datetime + '%Y-%m-%d', # Date only + '%Y/%m/%d', # Alternate date only + '%d-%m-%Y %H:%M:%S', # European format + '%d/%m/%Y %H:%M:%S', # European format alternate + ] + + # Clean up the string + date_str = date_str.strip() + + for fmt in formats: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + + # Try fromisoformat as a fallback (handles many ISO variants) + try: + # Replace Z with +00:00 for fromisoformat compatibility + clean_str = date_str.replace('Z', '+00:00') + return datetime.fromisoformat(clean_str) + except ValueError: + pass + + logger.debug(f"Could not parse date string: {date_str}") + return None \ No newline at end of file diff --git a/src/utils/file_hash.py b/src/utils/file_hash.py new file mode 100644 index 0000000..7dab386 --- /dev/null +++ b/src/utils/file_hash.py @@ -0,0 +1,24 @@ +"""File hashing utility for duplicate detection.""" + +import hashlib + + +def compute_file_sha256(filepath, chunk_size=8192): + """ + Compute SHA-256 hash of a file, reading in chunks to handle large files. + + Args: + filepath: Path to the file to hash + chunk_size: Size of chunks to read at a time (default 8KB) + + Returns: + 64-character hex digest string + """ + sha256 = hashlib.sha256() + with open(filepath, 'rb') as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + sha256.update(chunk) + return sha256.hexdigest() diff --git a/src/utils/json_parser.py b/src/utils/json_parser.py new file mode 100644 index 0000000..f98533a --- /dev/null +++ b/src/utils/json_parser.py @@ -0,0 +1,215 @@ +""" +JSON parsing utilities for handling LLM responses and malformed JSON. + +This module provides robust JSON parsing functions that can handle common +issues with LLM-generated JSON, including: +- Incomplete/unterminated JSON structures +- Escape sequence problems +- JSON embedded in markdown code blocks +- Nested quotes and special characters +""" + +import json +import re +import ast +import logging + +# Module-level logger +logger = logging.getLogger(__name__) + + +def auto_close_json(json_string): + """ + Attempts to close an incomplete JSON string by appending necessary brackets and braces. + This is a simplified parser and may not handle all edge cases, but is + designed to fix unterminated strings from API responses. + """ + if not isinstance(json_string, str): + return json_string + + stack = [] + in_string = False + escape_next = False + + for char in json_string: + if escape_next: + escape_next = False + continue + + if char == '\\': + escape_next = True + continue + + if char == '"': + # We don't handle escaped quotes inside strings perfectly, + # but this is a simple heuristic. + if not escape_next: + in_string = not in_string + + if not in_string: + if char == '{': + stack.append('}') + elif char == '[': + stack.append(']') + elif char == '}': + if stack and stack[-1] == '}': + stack.pop() + elif char == ']': + if stack and stack[-1] == ']': + stack.pop() + + # If we are inside a string at the end, close it. + if in_string: + json_string += '"' + + # Close any remaining open structures + while stack: + json_string += stack.pop() + + return json_string + + +def preprocess_json_escapes(json_string): + """ + Preprocess JSON string to fix common escape issues from LLM responses. + Uses a more sophisticated approach to handle nested quotes properly. + """ + if not json_string: + return json_string + + result = [] + i = 0 + in_string = False + escape_next = False + expecting_value = False # Track if we're expecting a value (after :) + + while i < len(json_string): + char = json_string[i] + + if escape_next: + # This character is escaped, add it as-is + result.append(char) + escape_next = False + elif char == '\\': + # This is an escape character + result.append(char) + escape_next = True + elif char == ':' and not in_string: + # We found a colon, next string will be a value + result.append(char) + expecting_value = True + elif char == ',' and not in_string: + # We found a comma, reset expecting_value + result.append(char) + expecting_value = False + elif char == '"': + if not in_string: + # Starting a string + in_string = True + result.append(char) + else: + # We're in a string, check if this quote should be escaped + # Look ahead to see if this is the end of the string value + j = i + 1 + while j < len(json_string) and json_string[j].isspace(): + j += 1 + + # For keys (not expecting_value), only end on colon + # For values (expecting_value), end on comma, closing brace, or closing bracket + if expecting_value: + end_chars = ',}]' + else: + end_chars = ':' + + if j < len(json_string) and json_string[j] in end_chars: + # This is the end of the string + in_string = False + result.append(char) + if not expecting_value: + # We just finished a key, next will be expecting value + expecting_value = True + else: + # This is an inner quote that should be escaped + result.append('\\"') + else: + result.append(char) + + i += 1 + + return ''.join(result) + + +def extract_json_object(text): + """ + Extract the first complete JSON object or array from text using regex. + """ + # Look for JSON object + obj_match = re.search(r'\{.*\}', text, re.DOTALL) + if obj_match: + return obj_match.group(0) + + # Look for JSON array + arr_match = re.search(r'\[.*\]', text, re.DOTALL) + if arr_match: + return arr_match.group(0) + + # Return original if no JSON structure found + return text + + +def safe_json_loads(json_string, fallback_value=None): + """ + Safely parse JSON with preprocessing to handle common LLM JSON formatting issues. + + Args: + json_string (str): The JSON string to parse + fallback_value: Value to return if parsing fails (default: None) + + Returns: + Parsed JSON object or fallback_value if parsing fails + """ + if not json_string or not isinstance(json_string, str): + logger.warning(f"Invalid JSON input: {type(json_string)} - {json_string}") + return fallback_value + + # Step 1: Clean the input string + cleaned_json = json_string.strip() + + # Step 2: Extract JSON from markdown code blocks if present + json_match = re.search(r'```(?:json)?\s*(.*?)\s*```', cleaned_json, re.DOTALL) + if json_match: + cleaned_json = json_match.group(1).strip() + + # Step 3: Try multiple parsing strategies + parsing_strategies = [ + # Strategy 1: Direct parsing (for well-formed JSON) + lambda x: json.loads(x), + + # Strategy 2: Fix common escape issues + lambda x: json.loads(preprocess_json_escapes(x)), + + # Strategy 3: Use ast.literal_eval as fallback for simple cases + lambda x: ast.literal_eval(x) if x.startswith(('{', '[')) else None, + + # Strategy 4: Extract JSON object/array using regex + lambda x: json.loads(extract_json_object(x)), + + # Strategy 5: Auto-close incomplete JSON and parse + lambda x: json.loads(auto_close_json(x)), + ] + + for i, strategy in enumerate(parsing_strategies): + try: + result = strategy(cleaned_json) + if result is not None: + if i > 0: # Log if we had to use a fallback strategy + logger.info(f"JSON parsed successfully using strategy {i+1}") + return result + except (json.JSONDecodeError, ValueError, SyntaxError) as e: + if i == 0: # Only log the first failure to avoid spam + logger.debug(f"JSON parsing strategy {i+1} failed: {e}") + continue + + # All strategies failed + logger.error(f"All JSON parsing strategies failed for: {cleaned_json[:200]}...") + return fallback_value diff --git a/src/utils/localization.py b/src/utils/localization.py new file mode 100644 index 0000000..a1a440e --- /dev/null +++ b/src/utils/localization.py @@ -0,0 +1,214 @@ +""" +Server-side localization utilities for export templates. + +This module provides utilities to load localized labels from +static/locales/*.json files for use in export templates. +""" + +import json +import os +import logging +from pathlib import Path +from typing import Dict, Optional +from datetime import datetime + +logger = logging.getLogger(__name__) + +# Cache for loaded locales +_locale_cache: Dict[str, dict] = {} + + +def get_locales_dir() -> Path: + """Get the path to the locales directory.""" + # Navigate from src/utils to static/locales + base_dir = Path(__file__).parent.parent.parent + return base_dir / 'static' / 'locales' + + +def load_locale(language: str) -> dict: + """ + Load locale data for a given language. + + Args: + language: Language code (e.g., 'en', 'de', 'fr') + + Returns: + Dictionary containing all locale strings + """ + # Check cache first + if language in _locale_cache: + return _locale_cache[language] + + locales_dir = get_locales_dir() + locale_file = locales_dir / f'{language}.json' + + # Fallback to English if requested language doesn't exist + if not locale_file.exists(): + logger.warning(f"Locale file not found for '{language}', falling back to English") + locale_file = locales_dir / 'en.json' + language = 'en' + + try: + with open(locale_file, 'r', encoding='utf-8') as f: + locale_data = json.load(f) + _locale_cache[language] = locale_data + return locale_data + except (json.JSONDecodeError, IOError) as e: + logger.error(f"Error loading locale file '{locale_file}': {e}") + # Return empty dict on error + return {} + + +def get_export_labels(language: str) -> dict: + """ + Get localized export labels for a given language. + + Args: + language: Language code (e.g., 'en', 'de', 'fr') + + Returns: + Dictionary containing export-specific labels + """ + locale_data = load_locale(language) + + # Get exportLabels section, or fall back to defaults + export_labels = locale_data.get('exportLabels', {}) + + # Default English labels as fallback + defaults = { + 'metadata': 'Metadata', + 'notes': 'Notes', + 'summary': 'Summary', + 'transcription': 'Transcription', + 'date': 'Date', + 'created': 'Created', + 'originalFile': 'Original File', + 'fileSize': 'File Size', + 'participants': 'Participants', + 'tags': 'Tags', + 'transcriptionTime': 'Transcription Time', + 'summarizationTime': 'Summarization Time', + 'footer': 'Generated with [Speakr](https://github.com/learnedmachine/speakr)' + } + + # Merge defaults with loaded labels + result = defaults.copy() + result.update(export_labels) + + return result + + +def format_date_localized(dt: datetime, language: str) -> str: + """ + Format a datetime in a localized format. + + Args: + dt: The datetime to format + language: Language code for localization + + Returns: + Localized date string + """ + if dt is None: + return '' + + # Define locale-specific date formats + date_formats = { + 'en': '%B %d, %Y', # January 15, 2026 + 'de': '%d. %B %Y', # 15. Januar 2026 + 'fr': '%d %B %Y', # 15 janvier 2026 + 'es': '%d de %B de %Y', # 15 de enero de 2026 + 'zh': '%Y年%m月%d日', # 2026年01月15日 + 'ru': '%d %B %Y г.', # 15 января 2026 г. + } + + # Month names for different languages + month_names = { + 'de': { + 'January': 'Januar', 'February': 'Februar', 'March': 'März', + 'April': 'April', 'May': 'Mai', 'June': 'Juni', + 'July': 'Juli', 'August': 'August', 'September': 'September', + 'October': 'Oktober', 'November': 'November', 'December': 'Dezember' + }, + 'fr': { + 'January': 'janvier', 'February': 'février', 'March': 'mars', + 'April': 'avril', 'May': 'mai', 'June': 'juin', + 'July': 'juillet', 'August': 'août', 'September': 'septembre', + 'October': 'octobre', 'November': 'novembre', 'December': 'décembre' + }, + 'es': { + 'January': 'enero', 'February': 'febrero', 'March': 'marzo', + 'April': 'abril', 'May': 'mayo', 'June': 'junio', + 'July': 'julio', 'August': 'agosto', 'September': 'septiembre', + 'October': 'octubre', 'November': 'noviembre', 'December': 'diciembre' + }, + 'ru': { + 'January': 'января', 'February': 'февраля', 'March': 'марта', + 'April': 'апреля', 'May': 'мая', 'June': 'июня', + 'July': 'июля', 'August': 'августа', 'September': 'сентября', + 'October': 'октября', 'November': 'ноября', 'December': 'декабря' + } + } + + # Get format for language, default to English + date_format = date_formats.get(language, date_formats['en']) + + # Format the date + formatted = dt.strftime(date_format) + + # Replace English month names with localized versions + if language in month_names: + for eng, local in month_names[language].items(): + formatted = formatted.replace(eng, local) + + return formatted + + +def format_datetime_localized(dt: datetime, language: str) -> str: + """ + Format a datetime with time in a localized format. + + Args: + dt: The datetime to format + language: Language code for localization + + Returns: + Localized datetime string + """ + if dt is None: + return '' + + date_part = format_date_localized(dt, language) + + # Time format varies by language + time_formats = { + 'en': '%I:%M %p', # 02:30 PM + 'de': '%H:%M Uhr', # 14:30 Uhr + 'fr': '%H:%M', # 14:30 + 'es': '%H:%M', # 14:30 + 'zh': '%H:%M', # 14:30 + 'ru': '%H:%M', # 14:30 + } + + time_format = time_formats.get(language, time_formats['en']) + time_part = dt.strftime(time_format) + + # Combine with appropriate connector + connectors = { + 'en': ' at ', + 'de': ' um ', + 'fr': ' à ', + 'es': ' a las ', + 'zh': ' ', + 'ru': ' в ', + } + + connector = connectors.get(language, ' at ') + + return f"{date_part}{connector}{time_part}" + + +def clear_locale_cache(): + """Clear the locale cache (useful for testing or hot-reloading).""" + global _locale_cache + _locale_cache = {} diff --git a/src/utils/markdown.py b/src/utils/markdown.py new file mode 100644 index 0000000..8b55635 --- /dev/null +++ b/src/utils/markdown.py @@ -0,0 +1,132 @@ +""" +Markdown and HTML utilities for converting and sanitizing text content. + +This module provides functions for converting markdown to HTML and +sanitizing HTML to prevent XSS and other security issues. +""" + +import re +import markdown +import bleach + +# --- Initialize Markdown Once (Performance Optimization) --- +# Create a single reusable Markdown instance to avoid reinitializing extensions on every call +_markdown_instance = markdown.Markdown(extensions=[ + 'fenced_code', # Fenced code blocks + 'tables', # Table support + 'attr_list', # Attribute lists + 'def_list', # Definition lists + 'footnotes', # Footnotes + 'abbr', # Abbreviations + 'codehilite', # Syntax highlighting for code blocks + 'smarty' # Smart quotes, dashes, etc. +]) + + +def sanitize_html(text): + """ + Sanitize HTML content to prevent XSS and other security issues. + + Args: + text (str): HTML text to sanitize + + Returns: + str: Sanitized HTML text + """ + if not text: + return "" + + # Remove any template-like syntax that could be exploited + # Remove {{ }} style template syntax + text = re.sub(r'\{\{.*?\}\}', '', text, flags=re.DOTALL) + text = re.sub(r'\{%.*?%\}', '', text, flags=re.DOTALL) + + # Remove other template-like syntax + text = re.sub(r'<%.*?%>', '', text, flags=re.DOTALL) + text = re.sub(r'<\?.*?\?>', '', text, flags=re.DOTALL) + + # Define allowed tags and attributes for safe HTML + allowed_tags = [ + 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'img', 'table', 'thead', + 'tbody', 'tr', 'th', 'td', 'dl', 'dt', 'dd', 'div', 'span', 'hr', 'sup', 'sub' + ] + + allowed_attributes = { + 'a': ['href', 'title'], + 'img': ['src', 'alt', 'title', 'width', 'height'], + 'code': ['class'], # For syntax highlighting + 'pre': ['class'], # For syntax highlighting + 'div': ['class'], # For code blocks + 'span': ['class'], # For syntax highlighting + 'th': ['align'], + 'td': ['align'], + 'table': ['class'] + } + + # Sanitize the HTML to remove dangerous content + sanitized_html = bleach.clean( + text, + tags=allowed_tags, + attributes=allowed_attributes, + protocols=['http', 'https', 'mailto'], + strip=True # Strip disallowed tags instead of escaping them + ) + + return sanitized_html + + +def md_to_html(text): + """ + Convert markdown text to sanitized HTML. + + Args: + text (str): Markdown text to convert + + Returns: + str: Sanitized HTML output + """ + if not text: + return "" + + # Fix list spacing + def fix_list_spacing(text): + lines = text.split('\n') + result = [] + in_list = False + + for line in lines: + stripped = line.strip() + + # Check if this line is a list item (starts with -, *, +, or number.) + is_list_item = ( + stripped.startswith(('- ', '* ', '+ ')) or + (stripped and stripped[0].isdigit() and '. ' in stripped[:10]) + ) + + # If we're starting a new list or continuing a list, ensure proper spacing + if is_list_item: + if not in_list and result and result[-1].strip(): + # Starting a new list - add blank line before + result.append('') + in_list = True + elif in_list and stripped and not is_list_item: + # Ending a list - add blank line after the list + if result and result[-1].strip(): + result.append('') + in_list = False + + result.append(line) + + return '\n'.join(result) + + # Fix list spacing + processed_text = fix_list_spacing(text) + + # Convert markdown to HTML using the pre-configured singleton instance + # Reset the instance to clear any state from previous conversions + _markdown_instance.reset() + html = _markdown_instance.convert(processed_text) + + # Apply sanitization to the generated HTML + return sanitize_html(html) diff --git a/src/utils/security.py b/src/utils/security.py new file mode 100644 index 0000000..a58e8f5 --- /dev/null +++ b/src/utils/security.py @@ -0,0 +1,65 @@ +""" +Security utilities for password validation and other security functions. + +This module provides security-related utility functions for the application. +""" + +import re +from functools import wraps +from wtforms.validators import ValidationError +from urllib.parse import urlparse, urljoin +from flask import request, jsonify +from flask_login import login_required, current_user + + +def password_check(form, field): + """ + Custom WTForms validator for password strength. + + Validates that passwords meet security requirements: + - At least 8 characters long + - Contains at least one uppercase letter + - Contains at least one lowercase letter + - Contains at least one number + - Contains at least one special character + + Args: + form: WTForms form object + field: WTForms field object containing the password + + Raises: + ValidationError: If password doesn't meet requirements + """ + password = field.data + if len(password) < 8: + raise ValidationError('Password must be at least 8 characters long.') + if not re.search(r'[A-Z]', password): + raise ValidationError('Password must contain at least one uppercase letter.') + if not re.search(r'[a-z]', password): + raise ValidationError('Password must contain at least one lowercase letter.') + if not re.search(r'[0-9]', password): + raise ValidationError('Password must contain at least one number.') + if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): + raise ValidationError('Password must contain at least one special character.') + + +# --- Access control decorators --- + +def admin_required(f): + """Decorator that requires the current user to be authenticated and an admin.""" + @wraps(f) + @login_required + def decorated(*args, **kwargs): + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + return f(*args, **kwargs) + return decorated + + +# --- URL Security --- + +def is_safe_url(target): + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc + diff --git a/src/utils/token_auth.py b/src/utils/token_auth.py new file mode 100644 index 0000000..9fd0086 --- /dev/null +++ b/src/utils/token_auth.py @@ -0,0 +1,108 @@ +""" +Token authentication utilities. + +This module provides token-based authentication for API access, +allowing users to authenticate with Bearer tokens instead of session cookies. +""" + +import hashlib +from datetime import datetime +from flask import request +from src.models import APIToken, User + + +def extract_token_from_request(): + """ + Extract API token from various possible locations in the request. + + Checks in order: + 1. Authorization header with Bearer scheme + 2. X-API-Token header + 3. API-Token header + 4. 'token' query parameter + + Returns: + str: The extracted token, or None if not found + """ + # Check Authorization header (Bearer token) + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + return auth_header[7:] # Remove 'Bearer ' prefix + + # Check X-API-Token header + token = request.headers.get('X-API-Token') + if token: + return token + + # Check API-Token header + token = request.headers.get('API-Token') + if token: + return token + + # Check query parameter + token = request.args.get('token') + if token: + return token + + return None + + +def hash_token(token): + """ + Hash a token using SHA-256. + + Args: + token (str): The plaintext token to hash + + Returns: + str: The hexadecimal hash of the token + """ + return hashlib.sha256(token.encode()).hexdigest() + + +def load_user_from_token(): + """ + Load a user from an API token in the request. + + This function is used by Flask-Login's request_loader to authenticate + users via API tokens instead of sessions. + + Returns: + User: The authenticated user, or None if authentication fails + """ + # Extract token from request + token = extract_token_from_request() + if not token: + return None + + # Hash the token to look up in database + token_hash = hash_token(token) + + # Find the token in the database + api_token = APIToken.query.filter_by(token_hash=token_hash).first() + + # Validate token + if not api_token: + return None + + if not api_token.is_valid(): + return None + + # Update last used timestamp + api_token.last_used_at = datetime.utcnow() + from src.database import db + db.session.commit() + + # Return the associated user + return api_token.user + + +def is_token_authenticated(): + """ + Check if the current request is authenticated via API token. + + Returns: + bool: True if a valid token was provided, False otherwise + """ + token = extract_token_from_request() + return token is not None diff --git a/src/utils/vapid_keys.py b/src/utils/vapid_keys.py new file mode 100644 index 0000000..e910084 --- /dev/null +++ b/src/utils/vapid_keys.py @@ -0,0 +1,101 @@ +""" +VAPID Key Management +Auto-generates and stores VAPID keys for push notifications +""" +import os +import json +from pathlib import Path + + +def generate_vapid_keys(): + """Generate new VAPID keys using pywebpush""" + try: + from pywebpush import webpush + + # Generate keys + vapid_claims = webpush.WebPusher().vapid_claims + + # For newer versions of pywebpush, use this approach: + from py_vapid import Vapid + vapid = Vapid() + vapid.generate_keys() + + return { + 'public_key': vapid.public_key.export_public(encoding='uncompressed'), + 'private_key': vapid.private_key.export_private(encoding='pem') + } + except ImportError: + print("[VAPID] pywebpush not installed. Push notifications will be disabled.") + print("[VAPID] Install with: pip install pywebpush") + return None + except Exception as e: + print(f"[VAPID] Failed to generate keys: {e}") + return None + + +def get_vapid_keys_file(): + """Get path to VAPID keys storage file""" + # Store in /config directory (persistent in Docker) + config_dir = Path(os.getenv('CONFIG_DIR', '/config')) + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / 'vapid_keys.json' + + +def load_vapid_keys(): + """Load existing VAPID keys or generate new ones""" + keys_file = get_vapid_keys_file() + + # Try to load existing keys + if keys_file.exists(): + try: + with open(keys_file, 'r') as f: + keys = json.load(f) + print(f"[VAPID] Loaded existing keys from {keys_file}") + return keys + except Exception as e: + print(f"[VAPID] Failed to load existing keys: {e}") + # Continue to generate new keys + + # Generate new keys + print("[VAPID] Generating new VAPID keys...") + keys = generate_vapid_keys() + + if keys: + # Save keys to file + try: + with open(keys_file, 'w') as f: + json.dump(keys, f, indent=2) + + # Set restrictive permissions (owner read/write only) + os.chmod(keys_file, 0o600) + + print(f"[VAPID] Saved new keys to {keys_file}") + print(f"[VAPID] Public key: {keys['public_key'][:50]}...") + return keys + except Exception as e: + print(f"[VAPID] Failed to save keys: {e}") + return keys + else: + print("[VAPID] Push notifications disabled - pywebpush not available") + return None + + +def get_public_key(): + """Get the public VAPID key for client use""" + keys = load_vapid_keys() + return keys['public_key'] if keys else None + + +def get_private_key(): + """Get the private VAPID key for server use""" + keys = load_vapid_keys() + return keys['private_key'] if keys else None + + +# Initialize on module import +VAPID_KEYS = load_vapid_keys() +VAPID_ENABLED = VAPID_KEYS is not None + +# Make keys available as module-level variables +VAPID_PUBLIC_KEY = VAPID_KEYS['public_key'] if VAPID_KEYS else None +VAPID_PRIVATE_KEY = VAPID_KEYS['private_key'] if VAPID_KEYS else None diff --git a/static/css/loading.css b/static/css/loading.css new file mode 100644 index 0000000..c7ff82b --- /dev/null +++ b/static/css/loading.css @@ -0,0 +1,83 @@ +/* Critical loading styles - inline these in the HTML head for instant loading */ +.app-loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-primary, #1a1b26); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.3s ease-out; +} + +.app-loading-overlay.fade-out { + opacity: 0; + pointer-events: none; +} + +.app-loading-content { + text-align: center; +} + +.app-loading-spinner { + width: 50px; + height: 50px; + margin: 0 auto 20px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--text-accent, #7aa2f7); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.app-loading-text { + color: var(--text-muted, #a0a0b0); + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + letter-spacing: 0.5px; +} + +.app-loading-logo { + width: 60px; + height: 60px; + margin: 0 auto 20px; + opacity: 0.8; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Hide body content until ready */ +body.app-loading { + overflow: hidden; +} + +body.app-loading > *:not(.app-loading-overlay) { + opacity: 0; +} + +/* Dark mode default colors */ +@media (prefers-color-scheme: dark) { + .app-loading-overlay { + background: #1a1b26; + } +} + +/* Light mode if explicitly set */ +body.light .app-loading-overlay { + background: #ffffff; +} + +body.light .app-loading-spinner { + border-color: rgba(0, 0, 0, 0.1); + border-top-color: #3b82f6; +} + +body.light .app-loading-text { + color: #6b7280; +} \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..50d1951 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,4266 @@ +body.unloaded { + opacity: 0; + transition: opacity 0.5s ease-in-out; +} + +[v-cloak] { + display: none; +} + +/* Color Scheme CSS Variables */ +:root { + /* Default Light Theme (Blue) - More muted with better contrast */ + --bg-primary: #e8eaed; /* muted gray */ + --bg-secondary: #f8f9fa; /* off-white */ + --bg-tertiary: #f0f2f4; /* light gray */ + --bg-accent: #c3d9f7; /* muted blue */ + --bg-accent-hover: #aec9f2; /* deeper muted blue */ + --bg-button: #2563eb; /* blue-600 */ + --bg-button-hover: #1d4ed8; /* blue-700 */ + --bg-danger: #dc2626; /* red-600 */ + --bg-danger-hover: #b91c1c; /* red-700 */ + --bg-danger-light: #fce4e4; /* muted red */ + --bg-info-light: #c3d9f7; /* muted blue */ + --bg-warn-light: #d4e8f5; /* soft blue */ + --bg-success-light: #c6f0dc; /* muted green */ + --bg-pending-light: #eeeeec; /* stone */ + --bg-input: #fafbfc; /* slightly gray white */ + --bg-audio-player: var(--bg-tertiary); + + --text-primary: #1f2937; /* gray-800 */ + --text-secondary: #374151; /* gray-700 */ + --text-muted: #6b7280; /* gray-500 */ + --text-light: #9ca3af; /* gray-400 */ + --text-accent: #1d4ed8; /* blue-700 */ + --text-button: #ffffff; /* white */ + --text-danger: #b91c1c; /* red-700 */ + --text-danger-strong: #991b1b; /* red-800 */ + --text-info-strong: #1e40af; /* blue-800 */ + --text-warn-strong: #0369a1; /* sky-700 - friendly blue tone */ + --text-success-strong: #065f46; /* green-800 */ + --text-pending-strong: #44403c; /* stone-700 */ + + --border-primary: #e5e7eb; /* gray-200 */ + --border-secondary: #d1d5db; /* gray-300 */ + --border-accent: #93c5fd; /* blue-300 */ + --border-danger: #f87171; /* red-400 */ + --border-focus: #3b82f6; /* blue-500 */ + --ring-focus: #bfdbfe; /* blue-200 */ + + --scrollbar-track: #f1f1f1; + --scrollbar-thumb: #c5c5c5; + --scrollbar-thumb-hover: #a8a8a8; + + /* Toast notification colors */ + --bg-success: #10b981; + --border-success: #059669; +} + +/* Light Theme Variants */ +.theme-light-emerald { + --bg-primary: #e6f0e9; /* muted green-gray */ + --bg-secondary: #f4f9f5; /* soft off-white green */ + --bg-tertiary: #ebf4ed; /* light muted green */ + --bg-accent: #d0e8d7; /* muted pastel green */ + --bg-accent-hover: #c1e0ca; /* deeper muted green */ + --bg-button: #059669; /* keep button visible */ + --bg-button-hover: #047857; /* keep button visible */ + --bg-input: #f9fcfa; /* slightly green white */ + --text-accent: #047857; /* keep accent text visible */ + --border-primary: #d4e5d8; /* muted green border */ + --border-secondary: #c2dcc9; /* soft green border */ + --border-accent: #a8d0b3; /* medium green border */ + --border-focus: #10b981; /* keep focus visible */ + --ring-focus: #d0e8d7; /* muted green ring */ + --bg-audio-player: var(--bg-tertiary); +} + +.theme-light-purple { + --bg-primary: #ebe9f0; /* muted purple-gray */ + --bg-secondary: #f7f6f9; /* soft off-white purple */ + --bg-tertiary: #f0eef4; /* light muted purple */ + --bg-accent: #dcd7e8; /* muted pastel purple */ + --bg-accent-hover: #d0c9df; /* deeper muted purple */ + --bg-button: #7c3aed; /* keep button visible */ + --bg-button-hover: #6d28d9; /* keep button visible */ + --bg-input: #faf9fc; /* slightly purple white */ + --text-accent: #6d28d9; /* keep accent text visible */ + --border-primary: #dbd6e6; /* muted purple border */ + --border-secondary: #cfc8dd; /* soft purple border */ + --border-accent: #bab0d2; /* medium purple border */ + --border-focus: #8b5cf6; /* keep focus visible */ + --ring-focus: #dcd7e8; /* muted purple ring */ + --bg-audio-player: var(--bg-tertiary); +} + +.theme-light-rose { + --bg-primary: #f0e9ed; /* muted rose-gray */ + --bg-secondary: #f9f6f7; /* soft off-white rose */ + --bg-tertiary: #f4eef1; /* light muted rose */ + --bg-accent: #e6d7df; /* muted pastel rose */ + --bg-accent-hover: #dfc9d4; /* deeper muted rose */ + --bg-button: #e11d48; /* keep button visible */ + --bg-button-hover: #be185d; /* keep button visible */ + --bg-input: #fcf9fa; /* slightly rose white */ + --text-accent: #be185d; /* keep accent text visible */ + --border-primary: #e5d6de; /* muted rose border */ + --border-secondary: #dbc8d2; /* soft rose border */ + --border-accent: #d0b0bf; /* medium rose border */ + --border-focus: #ec4899; /* keep focus visible */ + --ring-focus: #e6d7df; /* muted rose ring */ + --bg-audio-player: var(--bg-tertiary); +} + +.theme-light-amber { + --bg-primary: #efebe6; /* muted amber-gray */ + --bg-secondary: #f9f7f4; /* soft off-white amber */ + --bg-tertiary: #f3efe9; /* light muted amber */ + --bg-accent: #e5ddd1; /* muted pastel amber */ + --bg-accent-hover: #ddd2c2; /* deeper muted amber */ + --bg-button: #d97706; /* keep button visible */ + --bg-button-hover: #b45309; /* keep button visible */ + --bg-input: #fbfaf8; /* slightly amber white */ + --text-accent: #b45309; /* keep accent text visible */ + --border-primary: #e2dace; /* muted amber border */ + --border-secondary: #d9cdb9; /* soft amber border */ + --border-accent: #cdbca1; /* medium amber border */ + --border-focus: #f59e0b; /* keep focus visible */ + --ring-focus: #e5ddd1; /* muted amber ring */ + --bg-audio-player: var(--bg-tertiary); +} + +.theme-light-teal { + --bg-primary: #e7f0ef; /* muted teal-gray */ + --bg-secondary: #f5f9f8; /* soft off-white teal */ + --bg-tertiary: #ecf4f3; /* light muted teal */ + --bg-accent: #d2e5e2; /* muted pastel teal */ + --bg-accent-hover: #c3ddd9; /* deeper muted teal */ + --bg-button: #0d9488; /* keep button visible */ + --bg-button-hover: #0f766e; /* keep button visible */ + --bg-input: #f9fcfb; /* slightly teal white */ + --text-accent: #0f766e; /* keep accent text visible */ + --border-primary: #c8dfdc; /* muted teal border */ + --border-secondary: #b5d3ce; /* soft teal border */ + --border-accent: #9cc5be; /* medium teal border */ + --border-focus: #14b8a6; /* keep focus visible */ + --ring-focus: #d2e5e2; /* muted teal ring */ + --bg-audio-player: var(--bg-tertiary); +} + +/* Dark Theme Base */ +.dark { + --bg-primary: #111827; /* gray-900 */ + --bg-secondary: #1f2937; /* gray-800 */ + --bg-tertiary: #374151; /* gray-700 */ + --bg-accent: #1e3a8a; /* blue-900 */ + --bg-accent-hover: #1e40af; /* blue-800 */ + --bg-button: #2563eb; /* blue-600 */ + --bg-button-hover: #3b82f6; /* blue-500 */ + --bg-danger: #dc2626; /* red-600 */ + --bg-danger-hover: #ef4444; /* red-500 */ + --bg-danger-light: #7f1d1d; /* red-900 */ + --bg-info-light: #1e3a8a; /* blue-900 */ + --bg-warn-light: #164e63; /* cyan-800 - soft, friendly dark blue */ + --bg-success-light: #064e3b; /* green-900 */ + --bg-pending-light: #292524; /* stone-800 */ + --bg-input: #374151; /* gray-700 */ + --bg-audio-player: var(--bg-secondary); + + --text-primary: #f3f4f6; /* gray-100 */ + --text-secondary: #d1d5db; /* gray-300 */ + --text-muted: #cbd5e1; /* slate-300 - improved contrast */ + --text-light: #94a3b8; /* slate-400 - better visibility */ + --text-accent: #60a5fa; /* blue-400 */ + --text-button: #ffffff; /* white */ + --text-danger: #f87171; /* red-400 */ + --text-danger-strong: #fca5a5; /* red-300 */ + --text-info-strong: #93c5fd; /* blue-300 */ + --text-warn-strong: #67e8f9; /* cyan-300 - soft, friendly cyan text */ + --text-success-strong: #6ee7b7; /* green-300 */ + --text-pending-strong: #d6d3d1; /* stone-300 */ + + --border-primary: #475569; /* slate-600 - more visible */ + --border-secondary: #64748b; /* slate-500 - better contrast */ + --border-accent: #1d4ed8; /* blue-700 */ + --border-danger: #ef4444; /* red-500 */ + --border-focus: #3b82f6; /* blue-500 */ + --ring-focus: #1e40af; /* blue-800 */ + + --scrollbar-track: #2d3748; /* gray-800 */ + --scrollbar-thumb: #4a5568; /* gray-600 */ + --scrollbar-thumb-hover: #718096; /* gray-500 */ +} + +/* Dark Theme Variants */ +.dark.theme-dark-emerald { + --bg-primary: #1a2420; /* lighter dark with emerald tint */ + --bg-secondary: #243028; /* medium dark with emerald hint */ + --bg-tertiary: #2e3c30; /* lighter dark with emerald hint */ + --bg-accent: #384838; /* visible emerald accent */ + --bg-accent-hover: #425440; /* lighter emerald accent */ + --bg-button: #059669; /* keep button visible */ + --bg-button-hover: #10b981; /* keep button visible */ + --bg-input: #2e3c30; /* darker emerald input background */ + --text-accent: #7dd3ae; /* muted pastel emerald text */ + --border-primary: #3a4540; /* visible emerald border */ + --border-secondary: #485548; /* lighter emerald border */ + --border-accent: #556550; /* medium emerald border */ + --border-focus: #10b981; /* keep focus visible */ + --ring-focus: #384838; /* visible ring */ + --bg-audio-player: var(--bg-tertiary); + --scrollbar-thumb: #485548; /* emerald tinted scrollbar */ + --scrollbar-thumb-hover: #556655; /* brighter emerald on hover */ +} + +.dark.theme-dark-purple { + --bg-primary: #1e1a24; /* lighter dark with purple tint */ + --bg-secondary: #2a2430; /* medium dark with purple hint */ + --bg-tertiary: #36303c; /* lighter dark with purple hint */ + --bg-accent: #423c48; /* visible purple accent */ + --bg-accent-hover: #4e4854; /* lighter purple accent */ + --bg-button: #7c3aed; /* keep button visible */ + --bg-button-hover: #8b5cf6; /* keep button visible */ + --bg-input: #36303c; /* darker purple input background */ + --text-accent: #b8a5d4; /* muted pastel purple text */ + --border-primary: #484050; /* visible purple border */ + --border-secondary: #555058; /* lighter purple border */ + --border-accent: #626060; /* medium purple border */ + --border-focus: #8b5cf6; /* keep focus visible */ + --ring-focus: #423c48; /* visible ring */ + --bg-audio-player: var(--bg-tertiary); + --scrollbar-thumb: #555058; /* purple tinted scrollbar */ + --scrollbar-thumb-hover: #666068; /* brighter purple on hover */ +} + +.dark.theme-dark-rose { + --bg-primary: #241a20; /* lighter dark with rose tint */ + --bg-secondary: #302428; /* medium dark with rose hint */ + --bg-tertiary: #3c3030; /* lighter dark with rose hint */ + --bg-accent: #483c40; /* visible rose accent */ + --bg-accent-hover: #54484c; /* lighter rose accent */ + --bg-button: #e11d48; /* keep button visible */ + --bg-button-hover: #f43f5e; /* keep button visible */ + --bg-input: #3c3030; /* darker rose input background */ + --text-accent: #d4a5b4; /* muted pastel rose text */ + --border-primary: #504048; /* visible rose border */ + --border-secondary: #585050; /* lighter rose border */ + --border-accent: #606058; /* medium rose border */ + --border-focus: #f43f5e; /* keep focus visible */ + --ring-focus: #483c40; /* visible ring */ + --bg-audio-player: var(--bg-tertiary); + --scrollbar-thumb: #554850; /* rose tinted scrollbar */ + --scrollbar-thumb-hover: #665860; /* brighter rose on hover */ +} + +.dark.theme-dark-amber { + --bg-primary: #24201a; /* lighter dark with amber tint */ + --bg-secondary: #302824; /* medium dark with amber hint */ + --bg-tertiary: #3c342e; /* lighter dark with amber hint */ + --bg-accent: #484038; /* visible amber accent */ + --bg-accent-hover: #544c42; /* lighter amber accent */ + --bg-button: #d97706; /* keep button visible */ + --bg-button-hover: #f59e0b; /* keep button visible */ + --bg-input: #3c342e; /* darker amber input background */ + --text-accent: #d4c5a5; /* muted pastel amber text */ + --border-primary: #504840; /* visible amber border */ + --border-secondary: #585548; /* lighter amber border */ + --border-accent: #606250; /* medium amber border */ + --border-focus: #f59e0b; /* keep focus visible */ + --ring-focus: #484038; /* visible ring */ + --bg-audio-player: var(--bg-tertiary); + --scrollbar-thumb: #585548; /* amber tinted scrollbar */ + --scrollbar-thumb-hover: #686658; /* brighter amber on hover */ +} + +.dark.theme-dark-teal { + --bg-primary: #1a2424; /* lighter dark with teal tint */ + --bg-secondary: #243030; /* medium dark with teal hint */ + --bg-tertiary: #2e3c3c; /* lighter dark with teal hint */ + --bg-accent: #384848; /* visible teal accent */ + --bg-accent-hover: #425454; /* lighter teal accent */ + --bg-button: #0d9488; /* keep button visible */ + --bg-button-hover: #14b8a6; /* keep button visible */ + --bg-input: #2e3c3c; /* darker teal input background */ + --text-accent: #a5d4d0; /* muted pastel teal text */ + --border-primary: #404848; /* visible teal border */ + --border-secondary: #485555; /* lighter teal border */ + --border-accent: #506262; /* medium teal border */ + --border-focus: #14b8a6; /* keep focus visible */ + --ring-focus: #384848; /* visible ring */ + --bg-audio-player: var(--bg-tertiary); + --scrollbar-thumb: #485555; /* teal tinted scrollbar */ + --scrollbar-thumb-hover: #586666; /* brighter teal on hover */ +} + +/* Modern UI styles */ +.height-100 { height: 100%; } +.drag-area { transition: background-color 0.3s ease, border-color 0.3s ease; } +/* Global Scrollbar Styles */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; + border-radius: 10px; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 10px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + border: 2px solid transparent; + background-clip: padding-box; +} +/* Fix scrollbar corner for rounded containers */ +::-webkit-scrollbar-corner { + background: transparent; +} +html { /* Apply base colors to html for smoother transitions */ + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.3s, color 0.3s; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} +html { + height: 100%; + margin: 0; +} +body { + height: 100%; + margin: 0; + overflow-y: auto; /* Allow scrolling on the body */ +} + +/* Mobile fly-in menu specific styles */ +@media (max-width: 1023px) { /* Corresponds to Tailwind's lg breakpoint */ + .sidebar-container.fixed { + height: 100vh; /* Full viewport height */ + overflow-y: auto; /* Allow scrolling within the fly-in menu */ + z-index: 9999 !important; /* Ensure it's above everything else */ + } + /* .main-content-area styling for mobile is handled by v-if in html now */ + /* + .main-content-area { + transition: filter 0.3s ease-in-out; + } + body.mobile-menu-open .main-content-area { + filter: blur(4px); + } + */ + body.mobile-menu-open { + overflow: hidden; /* Prevent body scroll when mobile menu is open */ + } + + /* Fix for mobile sidebar visibility */ + .sidebar-container { + z-index: 50 !important; /* Ensure proper z-index */ + } + + /* Ensure the mobile sidebar shows above the overlay */ + .fixed.inset-y-0.left-0.z-40 { /* This targets the overlay, not the sidebar */ + z-index: 50 !important; + } + + /* Custom easing for mobile sidebar slide animation */ + /* This targets the main sidebar div when it's in mobile fly-in mode */ + /* and has the transition-transform class applied by Vue/Tailwind. */ + div.fixed.z-50.transition-transform { + transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55); + } +} + +/* Mobile viewport height fix */ +@media (max-width: 767px) { /* Target mobile devices specifically */ + .main { + min-height: 4000px !important; /* Fixed viewport height for mobile */ + } +} +#app { + min-height: 100%; /* Full viewport height */ + display: flex; + flex-direction: column; +} +main { + flex: 1; + position: relative; + display: flex; + flex-direction: column; + overflow-y: auto; /* Allow scrolling on main */ +} + +/* Sidebar styles with flexible height */ +.sidebar-container { + height: 100%; /* Use relative height */ + display: flex; + flex-direction: column; + overflow-y: auto; /* Allow sidebar to scroll */ +} + +/* Grid container with flex layout and flexible height */ +.grid-container { + height: 100%; /* Use relative height */ + display: flex; + flex-direction: column; + overflow-y: auto; /* Allow scrolling */ +} + +.sidebar-header { + flex-shrink: 0; /* Prevent header from shrinking */ +} + +.sidebar-content { + flex-grow: 1; + overflow-y: auto; /* Enable scrolling for content */ + padding-right: 6px; /* Space for scrollbar */ + min-height: 0; /* Added for robust flex scrolling */ +} +.progress-popup { position: fixed; bottom: 1rem; left: 1rem; z-index: 100; transition: all 0.3s ease-in-out; min-width: 300px; max-width: 400px; border-radius: 12px; overflow: hidden; } +.progress-popup.minimized { transform: translateY(calc(100% - 45px)); } +.progress-list-item { display: grid; grid-template-columns: auto 1fr auto; gap: 0.5rem; align-items: center; } +.progress-list-item .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.summary-box, +.notes-box { + flex: 1; + width: 100%; + min-height: 0; + min-width: 0; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--bg-tertiary); + padding: 1rem; + border-radius: 0.75rem; + border: 1px solid var(--border-primary); + scrollbar-gutter: stable; + color: var(--text-secondary); + box-shadow: 0 1px 2px rgba(0,0,0,0.03); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.transcription-box { + background-color: var(--bg-tertiary); + padding: 1rem; /* p-4 */ + border-radius: 0.75rem; /* rounded-xl */ + border: 1px solid var(--border-primary); + min-height: 0; /* Allow proper flex shrinking for scrolling */ + overflow-y: auto; /* Enable vertical scrolling */ + white-space: normal; /* Allow markdown HTML to control spacing */ + font-family: inherit; /* Use body font */ + font-size: 0.875rem; /* text-sm */ + line-height: 1.5; /* Consistent line height */ + box-shadow: 0 1px 2px rgba(0,0,0,0.03); /* Subtle shadow */ + flex: 1; /* Take up available space */ + color: var(--text-secondary); +} + +/* Standardize border radius for all content boxes */ +.transcription-box, +.summary-box, +.chat-container, +textarea, +div[v-if="!editingParticipants"], +div[v-if="!editingNotes"] { + border-radius: 0.75rem !important; /* rounded-xl */ +} + +/* Content boxes with flex layout */ +.content-column { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; /* Allow content to size based on parent container */ + flex: 1 1 auto; /* Grow and shrink as needed */ + overflow: hidden; +} + +/* Left column content (participants, transcription, tabs) */ +.left-content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; /* Allow flex to work properly */ + overflow: hidden; /* Prevent this container from scrolling */ +} + +/* Right column content (audio player, chat) */ +.right-content { + display: flex; + flex-direction: column; + height: 100%; + gap: 1rem; + overflow-y: auto; /* Enable scrolling for content that exceeds container */ + min-height: 0; /* Allows flex to work properly */ +} + +/* Participants section - small fixed height */ +.participants-section { + flex-shrink: 0; + margin-bottom: 1rem; /* Add space below participants box */ +} + +/* Transcription section - takes up significant space */ +.transcription-section { + /* flex: 2; */ /* Removed: Let JavaScript control flex values */ + min-height: 0; /* Critical for flex shrinking */ + overflow: hidden; /* Contain overflow to enable proper flex behavior */ + display: flex; + flex-direction: column; +} + +/* Tab content section - takes up remaining space and aligns with chat box */ +.tab-section { + min-height: 0; /* Critical for flex items */ + overflow: hidden; /* Prevent internal overflow from affecting layout */ + display: flex; + flex-direction: column; +} + +/* Audio player section - small fixed height */ +.audio-section { + flex-shrink: 0; +} + +/* Chat section - takes up remaining space */ +.chat-section { + flex: 1; + min-height: 0; /* Allows flex to work properly */ + overflow-y: auto; /* Changed from hidden to auto to enable scrolling */ + display: flex; + flex-direction: column; +} + +.tab-content-box { + flex: 1; + overflow-y: auto; + min-height: 0; /* Allows flex to work properly */ + height: 100%; /* Fill available height */ + display: flex; + flex-direction: column; +} + +.chat-content-box { + flex: 1; + overflow-y: auto; + min-height: 0; /* Allows flex to work properly */ +} +.metadata-panel { + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 0.75rem; /* rounded-xl to match others */ + padding: 1rem; /* p-4 to be consistent */ + /* margin-top removed to align with other boxes */ + font-size: 0.875rem; /* text-sm */ + color: var(--text-secondary); + flex: 1; /* Take up available space */ + height: 100%; /* Fill the container height */ + overflow-y: auto; /* Enable scrolling when content overflows */ +} +.metadata-panel dt { + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.1rem; +} +.metadata-panel dd { + margin-left: 0; + margin-bottom: 0.5rem; + word-break: break-all; /* Wrap long filenames */ +} + .status-badge { + display: inline-block; + padding: 0.12rem 0.5rem; /* Even smaller padding */ + font-size: 0.6rem; /* Smaller text */ + font-weight: 500; /* font-medium */ + border-radius: 9999px; /* rounded-full */ + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + letter-spacing: 0.025em; + vertical-align: middle; /* Align with text */ + margin-left: 0.5rem; /* Less space */ + opacity: 0.85; /* Slightly more subtle */ + transition: opacity 0.2s ease; + } + .status-badge:hover { + opacity: 1; + } + .status-processing { color: #1d4ed8; background-color: #dbeafe; } /* text-blue-800 bg-blue-100 */ + .status-summarizing { color: #92400e; background-color: #fef3c7; } /* text-amber-800 bg-amber-100 */ + .status-completed { color: #065f46; background-color: #d1fae5; } /* text-green-800 bg-green-100 */ + .status-failed { color: #991b1b; background-color: #fee2e2; } /* text-red-800 bg-red-100 */ + .status-pending { color: #57534e; background-color: #f5f5f4; } /* text-stone-700 bg-stone-100 */ + .status-highlighted { color: #d97706; background-color: #fef3c7; border: 1px solid #f59e0b; } /* text-amber-700 bg-amber-100 border-amber-500 */ + .status-inbox { color: #1d4ed8; background-color: #dbeafe; border: 1px solid #3b82f6; } /* text-blue-700 bg-blue-100 border-blue-500 */ + + /* Clickable badge styles */ + .clickable-badge { + cursor: pointer; + transition: all 0.2s ease; + } + + .clickable-badge:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.15); + opacity: 0.8; + } + + /* Transcription box with flex layout */ + .transcription-box { + flex: 1; + overflow-y: auto; + position: relative; + min-height: 0; /* Allows flex to work properly */ + } + + /* Modern copy button styles */ + .copy-btn { + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + color: var(--text-secondary); + border-radius: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* Desktop only: match height of toggle buttons when icon-only */ + @media (min-width: 1024px) { + #leftMainColumn .view-mode-toggle { + height: 1.75rem; + } + #leftMainColumn .toggle-button { + height: 1.75rem; + padding-top: 0; + padding-bottom: 0; + line-height: 1; + } + #leftMainColumn .copy-btn { + height: 1.75rem; + padding-top: 0; + padding-bottom: 0; + line-height: 1; + } + } + + .copy-btn:hover { + background-color: var(--bg-accent-hover); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .dark .copy-btn { + background-color: var(--bg-secondary); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.dark .copy-btn:hover { + background-color: var(--bg-tertiary); +} + + /* Hover edit button styles */ +.content-box { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + + .hover-edit-btn { + position: absolute; + top: 10px; + right: 10px; + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.75rem; + cursor: pointer; + z-index: 10; + transition: all 0.2s ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + opacity: 0; + } + + .content-box:hover .hover-edit-btn { + opacity: 1; + } + + .hover-edit-btn:hover { + background-color: #f3f4f6; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .dark .hover-edit-btn { + background-color: rgba(55, 65, 81, 0.9); + border-color: #4b5563; + } + + .dark .hover-edit-btn:hover { + background-color: #4b5563; + } + + /* Modern chat section styles */ + .chat-container { + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + display: flex; + flex-direction: column; + height: 100%; + min-height: 300px; /* Minimum height for chat container */ + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + overflow: hidden; + } + + .chat-messages { + flex-grow: 1; + overflow-y: auto; + padding: 1.25rem; + } + + .chat-input-container { + border-top: 1px solid #e5e7eb; + padding: 0.75rem; + display: flex; + background-color: var(--bg-tertiary); + } + + .message { + margin-bottom: 1.25rem; + max-width: 80%; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + line-height: 1.5; + } + + .user-message { + background-color: var(--accent-primary); + color: var(--text-accent-contrast); + border-radius: 1.25rem 1.25rem 0.25rem 1.25rem; + padding: 0.875rem 1rem; + margin-left: auto; + border: 1px solid var(--border-accent); + } + + .ai-message { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border-radius: 1.25rem 1.25rem 1.25rem 0.25rem; + padding: 0.875rem 1rem; + border: 1px solid var(--border-secondary); + overflow-x: auto; /* Enable horizontal scrolling for wide content like tables */ + } + + + .copyable { + position: relative; + } + + /* Markdown styling */ + .ai-message h1, .ai-message h2, .ai-message h3, + .summary-box h1, .summary-box h2, .summary-box h3, + .notes-box h1, .notes-box h2, .notes-box h3 { + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; + } + + .ai-message h1, .summary-box h1, .notes-box h1 { font-size: 1.25rem; } + .ai-message h2, .summary-box h2, .notes-box h2 { font-size: 1.15rem; } + .ai-message h3, .summary-box h3, .notes-box h3 { font-size: 1.05rem; } + + .ai-message p, .summary-box p, .notes-box p { + margin-bottom: 0.75rem; + } + + .ai-message ul, .ai-message ol, + .summary-box ul, .summary-box ol, + .notes-box ul, .notes-box ol { + margin-left: 1.5rem; + margin-right: 0.5rem; + margin-bottom: 0.75rem; + padding-right: 0.5rem; + list-style-position: outside; + display: block; + width: auto; + max-width: calc(100% - 2rem); + box-sizing: border-box; + overflow-wrap: break-word; + word-break: break-word; + } + + .ai-message ul, .summary-box ul, .notes-box ul { list-style-type: disc; } + .ai-message ol, .summary-box ol, .notes-box ol { list-style-type: decimal; } + + .ai-message li, .summary-box li, .notes-box li { + display: list-item; + margin-bottom: 0.25rem; + width: auto; + max-width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; + word-wrap: break-word; + hyphens: auto; + } + + .ai-message code, .summary-box code, .notes-box code { + background-color: var(--bg-tertiary); + color: var(--text-primary); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.875rem; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .ai-message pre, .summary-box pre, .notes-box pre { + background-color: var(--bg-tertiary); + color: var(--text-primary); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + margin-bottom: 0.75rem; + } + + .ai-message pre code, .summary-box pre code, .notes-box pre code { + background-color: transparent; + padding: 0; + border-radius: 0; + color: inherit; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + } + + /* Wrap tables in scrollable container */ + .summary-box > table, .notes-box > table { + display: block; + width: 100%; + overflow-x: auto; + } + + .ai-message table, .summary-box table, .notes-box table { + border-collapse: collapse; + width: 100%; + margin-bottom: 0.75rem; + } + + .ai-message th, .ai-message td, + .summary-box th, .summary-box td, + .notes-box th, .notes-box td { + border: 1px solid var(--border-secondary); /* Use theme variable */ + padding: 0.5rem; + text-align: left; + } + + .ai-message th, .summary-box th, .notes-box th { + background-color: var(--bg-tertiary); /* Use theme variable */ + font-weight: 600; + } + + .ai-message blockquote, .summary-box blockquote, .notes-box blockquote { + border-left: 4px solid var(--border-secondary); /* Use theme variable */ + padding-left: 1rem; + margin-left: 0; + margin-bottom: 0.75rem; + color: var(--text-muted); /* Use theme variable */ + } + + /* Main content container - ensure it fills available space and allows scrolling */ +.flex-grow.flex.flex-col.md\:flex-row.gap-6.overflow-hidden { + max-height: none !important; /* Override the inline style */ + height: 100%; + flex: 1; + overflow-y: auto; /* Allow scrolling */ +} + +/* Ensure the main content container can grow properly and scroll - works for both col-span-3 and col-span-4 */ +.lg\:col-span-3.bg-\[var\(--bg-secondary\)\].p-6.rounded-lg.shadow-md.flex.flex-col.max-h-85vh, +.lg\:col-span-4.bg-\[var\(--bg-secondary\)\].p-6.rounded-lg.shadow-md.flex.flex-col.max-h-85vh { + /* max-height: none !important; /* Let Tailwind class 'max-h-85vh' apply */ + height: 100%; /* Occupy the height of its grid cell */ + flex: 1; /* For its own children, as it's a flex container */ + overflow-y: auto; /* Allow its own content to scroll if it exceeds its height (max 85vh) */ +} + +/* Ensure the main content area maintains its internal layout regardless of sidebar state */ +.main-content-area { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +/* Ensure the main content columns container maintains consistent behavior */ +#mainContentColumns { + display: flex; + flex-direction: row; + flex: 1; + min-height: 0; + overflow: hidden; + width: 100%; /* Ensure full width usage */ +} + +/* Ensure left and right columns maintain their proportional widths */ +#leftMainColumn { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + flex-shrink: 0; /* Prevent shrinking */ + border-right: 1px solid var(--border-primary); + /* Width is controlled by inline style */ +} + +#rightMainColumn { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + overflow: hidden; + /* Width will be calculated as remaining space */ +} + +/* Ensure the main column resizer remains functional */ +#mainColumnResizer { + flex-shrink: 0; +} + +/* Form styling for edit modal */ +.form-group { + position: relative; + transition: all 0.3s ease; +} + +.form-group:hover { + transform: translateY(-1px); +} + +.form-group input:focus, +.form-group textarea:focus { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); +} + +/* Dark mode adjustments for form elements */ +.dark .form-group input:focus, +.dark .form-group textarea:focus { + box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.3); +} + +/* Elegant divider styling */ +.relative.py-3 { + margin: 0.5rem 0; +} + +/* Toast notification styles */ +.toast { + padding: 12px 18px; + border-radius: 8px; + background-color: #4CAF50; + color: white; + font-size: 14px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + opacity: 0; + transform: translateY(20px); + transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); + display: flex; + align-items: center; + min-width: 200px; +} + + .toast.show { + opacity: 1; + transform: translateY(0); + } + + .toast i { + margin-right: 8px; + } + + /* Copy button animation */ + @keyframes copy-success { + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } + } + + .copy-success { + animation: copy-success 0.3s ease; + color: #4CAF50 !important; + } + +/* Fix for sidebar height and scrolling */ +/* Remove fixed height constraint from the grid container to allow natural height */ +.grid.grid-cols-1.lg\:grid-cols-4.gap-6.flex-grow { + display: grid; + min-height: 0; /* Allow the grid to shrink if needed */ + overflow: visible; /* Allow overflow to be visible and scroll with the main window */ + align-items: start; /* Align grid items to start to prevent stretching */ +} + +/* Set a fixed height for the sidebar only */ +.lg\:col-span-1.bg-\[var\(--bg-secondary\)\].p-4.rounded-lg.shadow-md.sidebar-container.max-h-85vh { + display: flex; + flex-direction: column; + overflow: hidden; /* Hide overflow at container level */ + height: calc(100vh - 10rem); + position: sticky; + top: 1rem; /* Stick to the top with some padding */ +} + +/* Make sure the sidebar content scrolls internally */ +.sidebar-content { + flex: 1; + overflow-y: auto; /* Enable scrolling for content */ + min-height: 0; /* Allow content to shrink */ +} + +/* Ensure the main content area uses the window scroll and has consistent height */ +.lg\:col-span-3.bg-\[var\(--bg-secondary\)\].p-6.rounded-lg.shadow-md.flex.flex-col.max-h-85vh { + max-height: calc(100vh - 10rem) !important; /* Match sidebar height */ + height: calc(100vh - 10rem); /* Fixed height to match sidebar */ + overflow-y: auto; /* Enable internal scrolling */ +} + +/* Allow the main container to use window scrolling */ +main { + overflow-y: visible; /* Use window scrolling instead of internal scrolling */ +} + +/* Ensure body scrolls when content exceeds viewport */ +body { + overflow-y: auto; /* Allow scrolling on the body */ +} + +/* Fix recording list item layout to prevent title wrapping */ +.sidebar-content li { + display: flex; + align-items: center; + min-height: 3rem; /* Consistent minimum height for all items */ + max-height: 3rem; /* Prevent items from growing taller */ +} + +/* Ensure recording title container doesn't grow beyond available space */ +.sidebar-content li .flex.items-center.overflow-hidden { + min-width: 0; /* Allow flex item to shrink below content size */ + flex: 1; /* Take up available space */ +} + +/* Ensure truncate works properly on recording titles */ +.sidebar-content li .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; /* Allow text to shrink */ +} + +/* Blinking animation for recording indicator */ +@keyframes blink-animation { + 0% { opacity: 1; } + 50% { opacity: 0.3; } + 100% { opacity: 1; } +} +.blink { + animation: blink-animation 1.5s infinite; +} + +/* Speaker tag styling in the modal */ +.speaker-tag { + font-weight: 600; /* Make speaker tags bold */ + color: var(--text-accent); + padding: 2px 4px; + border-radius: 4px; + transition: all 0.2s ease-in-out; +} + +.speaker-highlight { + padding: 3px 6px; + border-radius: 6px; + transition: all 0.3s ease; + position: relative; +} + +/* Speaker-specific highlight styles that match speaker colors - reduced glow */ +.speaker-highlight.speaker-color-1 { + background-color: #E3F2FD; + color: #0D47A1; + box-shadow: 0 0 8px rgba(227, 242, 253, 0.6), 0 0 12px rgba(13, 71, 161, 0.2); +} + +.speaker-highlight.speaker-color-2 { + background-color: #F3E5F5; + color: #6A1B9A; + box-shadow: 0 0 8px rgba(243, 229, 245, 0.6), 0 0 12px rgba(106, 27, 154, 0.2); +} + +.speaker-highlight.speaker-color-3 { + background-color: #E8F5E8; + color: #1B5E20; + box-shadow: 0 0 8px rgba(232, 245, 232, 0.6), 0 0 12px rgba(27, 94, 32, 0.2); +} + +.speaker-highlight.speaker-color-4 { + background-color: #FFF3E0; + color: #E65100; + box-shadow: 0 0 8px rgba(255, 243, 224, 0.6), 0 0 12px rgba(230, 81, 0, 0.2); +} + +.speaker-highlight.speaker-color-5 { + background-color: #FCE4EC; + color: #AD1457; + box-shadow: 0 0 8px rgba(252, 228, 236, 0.6), 0 0 12px rgba(173, 20, 87, 0.2); +} + +.speaker-highlight.speaker-color-6 { + background-color: #E0F7FA; + color: #006064; + box-shadow: 0 0 8px rgba(224, 247, 250, 0.6), 0 0 12px rgba(0, 96, 100, 0.2); +} + +.speaker-highlight.speaker-color-7 { + background-color: #FFF9C4; + color: #F57F17; + box-shadow: 0 0 8px rgba(255, 249, 196, 0.6), 0 0 12px rgba(245, 127, 23, 0.2); +} + +.speaker-highlight.speaker-color-8 { + background-color: #EFEBE9; + color: #5D4037; + box-shadow: 0 0 8px rgba(239, 235, 233, 0.6), 0 0 12px rgba(93, 64, 55, 0.2); +} + +.speaker-highlight.speaker-color-9 { + background-color: #E8EAF6; + color: #283593; + box-shadow: 0 0 8px rgba(232, 234, 246, 0.6), 0 0 12px rgba(40, 53, 147, 0.2); +} + +.speaker-highlight.speaker-color-10 { + background-color: #F1F8E9; + color: #558B2F; + box-shadow: 0 0 8px rgba(241, 248, 233, 0.6), 0 0 12px rgba(85, 139, 47, 0.2); +} + +.speaker-highlight.speaker-color-11 { + background-color: #FFEBEE; + color: #C62828; + box-shadow: 0 0 8px rgba(255, 235, 238, 0.6), 0 0 12px rgba(198, 40, 40, 0.2); +} + +.speaker-highlight.speaker-color-12 { + background-color: #ECEFF1; + color: #37474F; + box-shadow: 0 0 8px rgba(236, 239, 241, 0.6), 0 0 12px rgba(55, 71, 79, 0.2); +} + +.speaker-highlight.speaker-color-13 { + background-color: #FFF8E1; + color: #FF8F00; + box-shadow: 0 0 8px rgba(255, 248, 225, 0.6), 0 0 12px rgba(255, 143, 0, 0.2); +} + +.speaker-highlight.speaker-color-14 { + background-color: #EDE7F6; + color: #4527A0; + box-shadow: 0 0 8px rgba(237, 231, 246, 0.6), 0 0 12px rgba(69, 39, 160, 0.2); +} + +.speaker-highlight.speaker-color-15 { + background-color: #E0F2F1; + color: #00695C; + box-shadow: 0 0 8px rgba(224, 242, 241, 0.6), 0 0 12px rgba(0, 105, 92, 0.2); +} + +.speaker-highlight.speaker-color-16 { + background-color: #FBE9E7; + color: #BF360C; + box-shadow: 0 0 8px rgba(251, 233, 231, 0.6), 0 0 12px rgba(191, 54, 12, 0.2); +} + +/* Dark mode speaker-specific highlights - reduced glow */ +.dark .speaker-highlight.speaker-color-1 { + background-color: #1E3A5F; + color: #A5C9EA; + box-shadow: 0 0 8px rgba(30, 58, 95, 0.6), 0 0 12px rgba(165, 201, 234, 0.2); +} + +.dark .speaker-highlight.speaker-color-2 { + background-color: #4A2C5A; + color: #D4A5D4; + box-shadow: 0 0 8px rgba(74, 44, 90, 0.6), 0 0 12px rgba(212, 165, 212, 0.2); +} + +.dark .speaker-highlight.speaker-color-3 { + background-color: #1F4A3C; + color: #A8D5A8; + box-shadow: 0 0 8px rgba(31, 74, 60, 0.6), 0 0 12px rgba(168, 213, 168, 0.2); +} + +.dark .speaker-highlight.speaker-color-4 { + background-color: #5A3A1F; + color: #E6B366; + box-shadow: 0 0 8px rgba(90, 58, 31, 0.6), 0 0 12px rgba(230, 179, 102, 0.2); +} + +.dark .speaker-highlight.speaker-color-5 { + background-color: #5A2C3E; + color: #E6A5C4; + box-shadow: 0 0 8px rgba(90, 44, 62, 0.6), 0 0 12px rgba(230, 165, 196, 0.2); +} + +.dark .speaker-highlight.speaker-color-6 { + background-color: #1F4A47; + color: #A5D5D0; + box-shadow: 0 0 8px rgba(31, 74, 71, 0.6), 0 0 12px rgba(165, 213, 208, 0.2); +} + +.dark .speaker-highlight.speaker-color-7 { + background-color: #4A4A1F; + color: #E6E266; + box-shadow: 0 0 8px rgba(74, 74, 31, 0.6), 0 0 12px rgba(230, 226, 102, 0.2); +} + +.dark .speaker-highlight.speaker-color-8 { + background-color: #3E2723; + color: #D7CCC8; + box-shadow: 0 0 8px rgba(62, 39, 35, 0.6), 0 0 12px rgba(215, 204, 200, 0.2); +} + +.dark .speaker-highlight.speaker-color-9 { + background-color: #1A237E; + color: #9FA8DA; + box-shadow: 0 0 8px rgba(26, 35, 126, 0.6), 0 0 12px rgba(159, 168, 218, 0.2); +} + +.dark .speaker-highlight.speaker-color-10 { + background-color: #33691E; + color: #C5E1A5; + box-shadow: 0 0 8px rgba(51, 105, 30, 0.6), 0 0 12px rgba(197, 225, 165, 0.2); +} + +.dark .speaker-highlight.speaker-color-11 { + background-color: #5A1F1F; + color: #EF9A9A; + box-shadow: 0 0 8px rgba(90, 31, 31, 0.6), 0 0 12px rgba(239, 154, 154, 0.2); +} + +.dark .speaker-highlight.speaker-color-12 { + background-color: #263238; + color: #B0BEC5; + box-shadow: 0 0 8px rgba(38, 50, 56, 0.6), 0 0 12px rgba(176, 190, 197, 0.2); +} + +.dark .speaker-highlight.speaker-color-13 { + background-color: #4A3F1F; + color: #FFE082; + box-shadow: 0 0 8px rgba(74, 63, 31, 0.6), 0 0 12px rgba(255, 224, 130, 0.2); +} + +.dark .speaker-highlight.speaker-color-14 { + background-color: #311B92; + color: #B39DDB; + box-shadow: 0 0 8px rgba(49, 27, 146, 0.6), 0 0 12px rgba(179, 157, 219, 0.2); +} + +.dark .speaker-highlight.speaker-color-15 { + background-color: #004D40; + color: #80CBC4; + box-shadow: 0 0 8px rgba(0, 77, 64, 0.6), 0 0 12px rgba(128, 203, 196, 0.2); +} + +.dark .speaker-highlight.speaker-color-16 { + background-color: #4E2C1F; + color: #FFAB91; + box-shadow: 0 0 8px rgba(78, 44, 31, 0.6), 0 0 12px rgba(255, 171, 145, 0.2); +} + +/* Speaker Legend and Bubble Styles */ +.speaker-legend { + position: sticky; + top: 0; + z-index: 10; + margin-bottom: 0.5rem; + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 0.375rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + overflow: hidden; + transition: all 0.3s ease; +} + +.dark .speaker-legend { + border: 1px solid var(--border-secondary); + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.speaker-legend-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.dark .speaker-legend-header { + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border-secondary); +} + +.speaker-legend-header:hover { + background-color: var(--bg-accent); +} + +.speaker-legend-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.speaker-legend-toggle { + color: var(--text-muted); + font-size: 0.75rem; + transition: transform 0.3s ease, color 0.2s ease; +} + +.speaker-legend.expanded .speaker-legend-toggle { + transform: rotate(180deg); + color: var(--text-accent); +} + +.speaker-legend-content { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease, padding 0.3s ease; +} + +.speaker-legend.expanded .speaker-legend-content { + max-height: 200px; /* Reasonable max height for scrolling */ + overflow-y: auto; + padding: 0.5rem 0.75rem; +} + +.speaker-legend-item { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; + width: auto; + max-width: fit-content; + border: 1px solid rgba(255,255,255,0.2); + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.speaker-legend-item:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.15); +} + +.speaker-name { + font-weight: 500; +} + +/* Compact legend when collapsed - show as many speakers as fit */ +.speaker-legend:not(.expanded) .speaker-legend-content { + display: flex; + flex-direction: row; + flex-wrap: wrap; + overflow: hidden; + max-height: 3rem; /* Allow up to 2 rows of speakers */ + padding: 0.25rem 0.75rem; +} + +.speaker-legend:not(.expanded) .speaker-legend-item { + flex-shrink: 1; + min-width: fit-content; +} + +.speaker-count-indicator { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 400; + margin-left: 0.25rem; +} + +/* Floating Processing Indicator */ +.processing-indicator-floating { + position: sticky; + top: 0; + z-index: 15; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-secondary); + border: 1px solid var(--border-accent); + border-radius: 0.5rem; + padding: 0.625rem 0.875rem; + margin-bottom: 0.75rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + animation: slideDown 0.3s ease-out; +} + +.dark .processing-indicator-floating { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.processing-indicator-floating.minimized { + position: sticky; + top: 0; + right: 0; + width: auto; + padding: 0; + background: transparent; + border: none; + box-shadow: none; + justify-content: flex-end; + margin-bottom: 0.5rem; +} + +.processing-indicator-content { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.processing-indicator-content i { + font-size: 1rem; +} + +.processing-indicator-minimize { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 0.25rem; + transition: all 0.2s ease; +} + +.processing-indicator-minimize:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.processing-indicator-expand { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 1px solid var(--border-accent); + background: var(--bg-secondary); + color: var(--text-accent); + cursor: pointer; + border-radius: 50%; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease; +} + +.dark .processing-indicator-expand { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.processing-indicator-expand:hover { + background: var(--bg-tertiary); + transform: scale(1.1); +} + +/* Enhanced transcription display with speaker bubbles */ +.transcription-with-speakers { + line-height: 1.6; + font-family: inherit; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.speaker-bubble { + display: inline-block; + margin: 0.125rem 0.25rem 0.125rem 0; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + max-width: fit-content; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + min-width: fit-content; + width: auto; + word-wrap: break-word; + position: relative; + box-shadow: 0 1px 2px rgba(0,0,0,0.08); + transition: all 0.2s ease; + border: 1px solid rgba(255,255,255,0.3); + vertical-align: top; +} + +.speaker-bubble:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.12); +} + +.speaker-bubble.speaker-me { + border-bottom-right-radius: 0.25rem; +} + +.speaker-bubble:not(.speaker-me) { + border-bottom-left-radius: 0.25rem; +} + +.speaker-bubble-content { + margin: 0; + color: inherit; + font-size: 0.875rem; + line-height: 1.4; + white-space: pre-wrap; +} + +/* Bubble row container for horizontal grouping */ +.bubble-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + margin-bottom: 0.5rem; + gap: 0.25rem; + width: 100%; +} + +.bubble-row.speaker-me { + justify-content: flex-end; +} + +.bubble-row:not(.speaker-me) { + justify-content: flex-start; +} + +/* Individual bubbles within rows should size to content and allow wrapping */ +.bubble-row .speaker-bubble { + flex: 0 0 auto; + max-width: calc(100% - 0.5rem); + width: auto; + min-width: fit-content; + margin: 0; +} + +/* Ensure bubbles can wrap to fill available space on mobile - preserve desktop styling */ +@media (max-width: 1023px) { + /* Target all bubble rows on mobile - preserve desktop gap and margin */ + .bubble-row, + .mobile-content-box .bubble-row, + .transcription-with-speakers .bubble-row { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + width: 100% !important; + gap: 0.25rem !important; + margin-bottom: 0.5rem !important; + align-items: flex-start !important; + } + + /* Target all speaker bubbles on mobile - preserve desktop spacing and styling */ + .speaker-bubble, + .mobile-content-box .speaker-bubble, + .bubble-row .speaker-bubble { + display: inline-block !important; + flex: 0 0 auto !important; + max-width: calc(70% - 0.25rem) !important; + width: auto !important; + min-width: fit-content !important; + margin: 0.125rem 0.25rem 0.125rem 0 !important; /* Preserve desktop margin */ + flex-shrink: 1 !important; + vertical-align: top !important; + padding: 0.5rem 0.75rem !important; /* Preserve desktop padding */ + border-radius: 0.75rem !important; /* Preserve desktop border-radius */ + box-shadow: 0 1px 2px rgba(0,0,0,0.08) !important; /* Preserve desktop shadow */ + transition: all 0.2s ease !important; /* Preserve desktop transition */ + border: 1px solid rgba(255,255,255,0.3) !important; /* Preserve desktop border */ + } + + /* Preserve desktop hover effects */ + .speaker-bubble:hover, + .mobile-content-box .speaker-bubble:hover, + .bubble-row .speaker-bubble:hover { + transform: translateY(-1px) !important; + box-shadow: 0 2px 4px rgba(0,0,0,0.12) !important; + } + + /* Preserve desktop bubble content styling */ + .speaker-bubble .speaker-bubble-content, + .mobile-content-box .speaker-bubble .speaker-bubble-content { + margin: 0 !important; + color: inherit !important; + font-size: 0.875rem !important; + line-height: 1.4 !important; + white-space: pre-wrap !important; + } + + /* Force horizontal layout for speaker rows */ + .bubble-row.speaker-me, + .bubble-row:not(.speaker-me) { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + } + + /* Override any conflicting styles */ + .transcription-with-speakers { + display: block !important; + } +} + +/* Simple view with speaker tablets */ +.transcription-simple-view { + line-height: 1.6; + font-family: inherit; +} + +.speaker-segment { + margin-bottom: 1rem; + transition: background-color 0.3s ease, box-shadow 0.3s ease; +} + +/* Active segment highlighting - for currently playing transcript */ +.speaker-segment.active-playing-segment, +.transcript-segment.active-playing-segment { + background-color: var(--bg-accent) !important; + box-shadow: 0 0 0 2px var(--border-accent) !important; + animation: pulse-highlight 0.5s ease-in-out; + padding: 0.75rem !important; + margin: 0.25rem 0 !important; + border-radius: 0.5rem !important; +} + +.speaker-bubble.active-playing-segment { + background-color: var(--bg-accent) !important; + box-shadow: 0 0 0 2px var(--border-accent) !important; + animation: pulse-highlight 0.5s ease-in-out; + transform: scale(1.02); +} + +@keyframes pulse-highlight { + 0% { + box-shadow: 0 0 0 0 var(--border-accent); + } + 50% { + box-shadow: 0 0 0 4px var(--border-accent); + } + 100% { + box-shadow: 0 0 0 2px var(--border-accent); + } +} + +/* Follow Player Mode Checkbox Styling */ +.follow-player-control { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + transition: all 0.2s ease; + cursor: pointer; + user-select: none; +} + +.follow-player-control:hover { + background-color: var(--bg-accent-hover); +} + +.follow-player-control input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1.5px solid var(--border-secondary); + border-radius: 0.25rem; + background-color: var(--bg-input); + cursor: pointer; + position: relative; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.follow-player-control input[type="checkbox"]:hover { + border-color: var(--border-accent); +} + +.follow-player-control input[type="checkbox"]:checked { + background-color: var(--bg-button); + border-color: var(--bg-button); + animation: checkboxPop 0.2s ease-out; +} + +.follow-player-control input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 3.5px; + height: 7px; + border: solid var(--text-button); + border-width: 0 2px 2px 0; + transform: translate(-50%, -60%) rotate(45deg); + animation: checkmarkDraw 0.2s ease-out; +} + +@keyframes checkboxPop { + 0% { + transform: scale(0.8); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +@keyframes checkmarkDraw { + 0% { + width: 0; + height: 0; + opacity: 0; + } + 100% { + width: 3.5px; + height: 7px; + opacity: 1; + } +} + +/* Selection checkbox - used for bulk selection in sidebar */ +.selection-checkbox { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border: 2px solid var(--border-secondary); + border-radius: 0.25rem; + background-color: var(--bg-input); + cursor: pointer; + position: relative; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.selection-checkbox:hover { + border-color: var(--border-accent); +} + +.selection-checkbox:checked { + background-color: var(--bg-button); + border-color: var(--bg-button); + animation: checkboxPop 0.2s ease-out; +} + +.selection-checkbox:checked::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 4px; + height: 9px; + border: solid var(--text-button); + border-width: 0 2.5px 2.5px 0; + transform: translate(-50%, -60%) rotate(45deg); + animation: checkmarkDraw 0.2s ease-out; +} + +/* Vue transition for slide-up (bulk action bar) */ +.slide-up-enter-active, +.slide-up-leave-active { + transition: transform 0.3s ease; +} +.slide-up-enter-from, +.slide-up-leave-to { + transform: translateY(100%); +} + +/* Vue transition for slide-right (vertical bulk action bar) */ +.slide-right-enter-active, +.slide-right-leave-active { + transition: transform 0.3s ease, opacity 0.3s ease; +} +.slide-right-enter-from, +.slide-right-leave-to { + transform: translateX(-100%) translateY(-50%); + opacity: 0; +} + +.follow-player-control .follow-icon { + font-size: 11px; + flex-shrink: 0; + opacity: 0.85; +} + +.follow-player-control:hover .follow-icon { + opacity: 1; +} + +.speaker-tablet { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + margin-bottom: 0.5rem; + margin-right: 0.5rem; + border: none; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.speaker-text { + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.5; + margin-left: 0; +} + +/* View mode toggle - compact tablet style */ +.view-mode-toggle { + display: inline-flex; + align-items: stretch; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 0.5rem; + padding: 0; + gap: 0; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); +} + +.toggle-button { + padding: 0.35rem 0.75rem; + border: none; + border-radius: 0.5rem; + background-color: transparent; + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.toggle-button.active { + background-color: var(--bg-accent); + color: var(--text-accent); +} + +.toggle-button:hover:not(.active) { + background-color: var(--bg-secondary); + color: var(--text-secondary); +} + +.toggle-button i { + font-size: 0.75rem; + margin-right: 0; +} +.toggle-button span + i, +.toggle-button i + span { + margin-left: 0.25rem; +} + +.dark .view-mode-toggle { + background-color: var(--bg-secondary); + border-color: var(--border-secondary); +} + +/* Speaker colors - enhanced palette with 16 colors for better variation */ +.speaker-color-1 { background-color: #E3F2FD; color: #0D47A1; } /* Blue */ +.speaker-color-2 { background-color: #F3E5F5; color: #6A1B9A; } /* Purple */ +.speaker-color-3 { background-color: #E8F5E9; color: #1B5E20; } /* Green */ +.speaker-color-4 { background-color: #FFF3E0; color: #E65100; } /* Orange */ +.speaker-color-5 { background-color: #FCE4EC; color: #AD1457; } /* Pink */ +.speaker-color-6 { background-color: #E0F7FA; color: #006064; } /* Cyan */ +.speaker-color-7 { background-color: #FFF9C4; color: #F57F17; } /* Yellow */ +.speaker-color-8 { background-color: #EFEBE9; color: #5D4037; } /* Brown */ +.speaker-color-9 { background-color: #E8EAF6; color: #283593; } /* Indigo */ +.speaker-color-10 { background-color: #F1F8E9; color: #558B2F; } /* Lime */ +.speaker-color-11 { background-color: #FFEBEE; color: #C62828; } /* Red */ +.speaker-color-12 { background-color: #ECEFF1; color: #37474F; } /* Blue Grey */ +.speaker-color-13 { background-color: #FFF8E1; color: #FF8F00; } /* Amber */ +.speaker-color-14 { background-color: #EDE7F6; color: #4527A0; } /* Deep Purple */ +.speaker-color-15 { background-color: #E0F2F1; color: #00695C; } /* Teal */ +.speaker-color-16 { background-color: #FBE9E7; color: #BF360C; } /* Deep Orange */ + +/* Dark mode speaker colors - tasteful muted pastels with 16 colors */ +.dark .speaker-color-1 { background-color: #1E3A5F; color: #A5C9EA; } /* Blue */ +.dark .speaker-color-2 { background-color: #4A2C5A; color: #D4A5D4; } /* Purple */ +.dark .speaker-color-3 { background-color: #1F4A3C; color: #A8D5A8; } /* Green */ +.dark .speaker-color-4 { background-color: #5A3A1F; color: #E6B366; } /* Orange */ +.dark .speaker-color-5 { background-color: #5A2C3E; color: #E6A5C4; } /* Pink */ +.dark .speaker-color-6 { background-color: #1F4A47; color: #A5D5D0; } /* Cyan */ +.dark .speaker-color-7 { background-color: #4A4A1F; color: #E6E266; } /* Yellow */ +.dark .speaker-color-8 { background-color: #3E2723; color: #D7CCC8; } /* Brown */ +.dark .speaker-color-9 { background-color: #1A237E; color: #9FA8DA; } /* Indigo */ +.dark .speaker-color-10 { background-color: #33691E; color: #C5E1A5; } /* Lime */ +.dark .speaker-color-11 { background-color: #5A1F1F; color: #EF9A9A; } /* Red */ +.dark .speaker-color-12 { background-color: #263238; color: #B0BEC5; } /* Blue Grey */ +.dark .speaker-color-13 { background-color: #4A3F1F; color: #FFE082; } /* Amber */ +.dark .speaker-color-14 { background-color: #311B92; color: #B39DDB; } /* Deep Purple */ +.dark .speaker-color-15 { background-color: #004D40; color: #80CBC4; } /* Teal */ +.dark .speaker-color-16 { background-color: #4E2C1F; color: #FFAB91; } /* Deep Orange */ + +/* Meeting/Created time styling improvements */ +.recording-metadata { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin-top: 0.5rem; + font-size: 0.875rem; +} + +.metadata-item { + display: flex; + align-items: center; + gap: 0.375rem; + color: var(--text-muted); + transition: color 0.2s ease; + white-space: nowrap; +} + +.metadata-item:hover { + color: var(--text-secondary); +} + +.metadata-icon { + color: var(--text-accent); + opacity: 0.75; + font-size: 0.75rem; + width: 12px; + text-align: center; + flex-shrink: 0; +} + +.metadata-label { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.metadata-value { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.metadata-value.editable { + cursor: pointer; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + transition: all 0.2s ease; +} + +.metadata-value.editable:hover { + background-color: var(--bg-tertiary); + color: var(--text-accent); +} + +.metadata-edit-input { + padding: 0.125rem 0.5rem; + background: transparent; + border: none; + border-bottom: 1px solid var(--border-secondary); + color: var(--text-primary); + font-size: inherit; + width: 140px; +} + +.metadata-edit-input:focus { + outline: none; + border-bottom-color: var(--border-focus); +} + +.metadata-edit-button { + margin-left: 0.25rem; + padding: 0.125rem; + background: none; + border: none; + color: var(--text-accent); + cursor: pointer; + font-size: 0.75rem; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.metadata-edit-button:hover { + opacity: 1; +} + +.modal-content { + max-height: calc(100vh - 200px); + overflow-y: auto; +} + +/* Mobile Tabbed Layout */ +@media (max-width: 1023px) { + /* Desktop layout's main content is hidden via v-if="!isMobileScreen" in HTML */ + /* .desktop-layout { + display: none !important; + } */ + + /* Show mobile layout only on mobile */ + .mobile-layout { + display: flex !important; + flex-direction: column; + height: calc(100vh - 120px); /* Account for header and footer */ + overflow: hidden; + } + + /* Mobile audio player section */ + .mobile-audio-section { + flex-shrink: 0; + padding: 1rem; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-primary); + } + + /* Mobile tab navigation */ + .mobile-tab-nav { + flex-shrink: 0; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-primary); + padding: 0 1rem; + } + + .mobile-tab-nav .tab-list { + display: flex; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + } + + .mobile-tab-nav .tab-list::-webkit-scrollbar { + display: none; + } + + .mobile-tab-button { + flex-shrink: 0; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-bottom: 2px solid transparent; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + } + + .mobile-tab-button.active { + color: var(--text-accent); + border-bottom-color: var(--border-focus); + } + + .mobile-tab-button:hover { + color: var(--text-secondary); + } + + /* Mobile tab content area */ + .mobile-tab-content { + flex: 1; + overflow: hidden; + background-color: var(--bg-secondary); + } + + .mobile-tab-panel { + height: 100%; + overflow-y: auto; + padding: 1rem; + display: none; + } + + .mobile-tab-panel.active { + display: block; + } + +/* Mobile tab content styling */ +.mobile-content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-primary); +} + +/* Mobile fullscreen button for editors */ +.mobile-fullscreen-btn { + padding: 0.375rem; + background: none; + border: 1px solid var(--border-secondary); + border-radius: 0.375rem; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.75rem; +} + +.mobile-fullscreen-btn:hover { + background-color: var(--bg-tertiary); + color: var(--text-accent); +} + + .mobile-content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-primary); + } + + .mobile-content-title { + font-weight: 600; + color: var(--text-secondary); + flex: 1; + } + + .mobile-content-actions { + display: flex; + gap: 0.5rem; + } + + .mobile-action-btn { + padding: 0.5rem; + background: none; + border: 1px solid var(--border-secondary); + border-radius: 0.375rem; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + } + + .mobile-action-btn:hover { + background-color: var(--bg-tertiary); + color: var(--text-accent); + } + + .mobile-content-box { + flex: 1; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 0.75rem; + padding: 1rem; + overflow-y: auto; + overflow-x: hidden; + font-size: 0.875rem; + line-height: 1.5; + /* Fix scrollbar clipping rounded corners */ + scrollbar-gutter: stable; + color: var(--text-secondary); + min-height: 0; + } + + /* Remove inner box styling for summary and notes on mobile */ + .mobile-content-box .summary-box, + .mobile-content-box .notes-box { + background-color: transparent; + border: none; + padding: 0; + border-radius: 0; + height: auto; + } + + /* Remove padding from mobile-content-box when it contains markdown editor */ + .mobile-content-box:has(.markdown-editor-container) { + padding: 0; + overflow: hidden; /* Ensure rounded corners are respected */ + } + + /* Make editor fill the rounded container properly */ + .mobile-content-box .markdown-editor-container { + height: 100%; + } + + .mobile-content-box .markdown-editor-container .EasyMDEContainer { + border: none !important; + border-radius: 0.75rem; + height: 100%; + } + + .mobile-content-box .markdown-editor-container .EasyMDEContainer .editor-toolbar { + border: none !important; + border-radius: 0.75rem 0.75rem 0 0; + border-bottom: 1px solid var(--border-primary) !important; + } + + .mobile-content-box .markdown-editor-container .EasyMDEContainer .CodeMirror { + border: none !important; + border-radius: 0 0 0.75rem 0.75rem; + } + + .mobile-content-box .markdown-editor-container .EasyMDEContainer .CodeMirror-scroll { + border-radius: 0 0 0.75rem 0.75rem; + } + + /* Mobile chat layout */ + .mobile-chat-content { + height: 100%; + display: flex; + flex-direction: column; + } + + .mobile-chat-messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + background-color: var(--bg-secondary); /* Changed from --bg-tertiary */ + border: 1px solid var(--border-primary); + border-radius: 0.75rem 0.75rem 0 0; + margin-bottom: 0; + } + + .mobile-chat-input { + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-top: none; + border-radius: 0 0 0.75rem 0.75rem; + padding: 0.75rem; + display: flex; + gap: 0.5rem; + } + + .mobile-chat-input textarea { /* Changed from input to textarea */ + flex: 1; + padding: 0.5rem; + border: 1px solid var(--border-secondary); + border-radius: 0.375rem; + background-color: var(--bg-input); + color: var(--text-primary); + font-size: 0.875rem; + line-height: 1.5; /* Added for consistent line height */ + resize: none; /* Prevent manual resizing */ + min-height: calc(1.5em + 1rem); /* Approx 1 line with padding */ + max-height: calc(1.5em * 4 + 1rem); /* Approx 4 lines with padding */ + overflow-y: auto; /* Allow scrolling after max-height */ + } + +/* Desktop chat input textarea styling */ +.chat-input-container textarea { + min-height: calc(1.5em * 3 + 1rem); /* Approx 3 lines with padding */ + max-height: calc(1.5em * 6 + 1rem); /* Approx 6 lines with padding */ + overflow-y: auto; /* Allow scrolling after max-height */ + line-height: 1.5; /* Consistent line height */ + font-family: inherit; /* Use the same font as the rest of the app */ +} + + .mobile-chat-input button { + padding: 0.5rem 1rem; + background-color: var(--bg-button); + color: var(--text-button); + border: none; + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + } + + .mobile-chat-input button:disabled { + background-color: var(--bg-tertiary); + color: var(--text-muted); + cursor: not-allowed; + } +} + +/* Hide mobile layout on desktop */ +@media (min-width: 1024px) { + .mobile-layout { + display: none !important; + } + + .desktop-layout { + display: block !important; + } +} + +/* Responsive adjustments for mobile */ +@media (max-width: 1023px) { + /* Ensure modals are not too wide on small screens */ + .fixed.inset-0 .w-full.max-w-md, + .fixed.inset-0 .w-full.max-w-lg, + .fixed.inset-0 .w-full.max-w-4xl { + max-width: 95vw !important; + max-height: 90vh; + } + + /* Ensure the main content area is visible when the mobile menu is closed */ + .main-content-area { + display: block !important; + } + +} + +/* Responsive title that shrinks font size instead of wrapping */ +.responsive-title { + font-size: clamp(1.125rem, 3.5vw, 1.5rem); /* More aggressive responsive scaling */ + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + min-width: 0; +} + +/* Ensure the title container doesn't grow beyond available space */ +.responsive-title-container { + min-width: 0; + flex: 1; + max-width: calc(100% - 200px); /* Reserve space for buttons */ +} + +/* Responsive button container */ +.responsive-button-container { + display: flex; + gap: 0.375rem; + flex-shrink: 0; + flex-wrap: wrap; + align-items: center; +} + +/* Responsive button styling */ +.responsive-button-container button { + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + min-width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +/* On very narrow screens, make buttons smaller */ +@media (max-width: 640px) { + .responsive-title { + font-size: clamp(1rem, 4vw, 1.25rem); + } + + .responsive-title-container { + max-width: calc(100% - 150px); /* Less space reserved for buttons */ + } + + .responsive-button-container { + gap: 0.25rem; + } + + .responsive-button-container button { + padding: 0.25rem 0.375rem; + font-size: 0.75rem; + min-width: 1.75rem; + height: 1.75rem; + } + + /* Hide button text on very small screens, show only icons */ + .responsive-button-container button span { + display: none; + } + + .responsive-button-container button i { + margin: 0; + } +} + +/* Header layout improvements for better responsiveness */ +.recording-header { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.recording-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + min-height: 2.5rem; +} + +.recording-metadata-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + font-size: 0.875rem; +} + +/* Ensure metadata items stay on same line when possible */ +@media (min-width: 768px) { + .recording-metadata-row { + flex-wrap: nowrap; + gap: 1.5rem; + } +} + +/* Toast notifications */ +#toastContainer { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; +} + +.toast { + background: var(--bg-success); + color: var(--text-success-strong); + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid var(--border-success); + transform: translateX(100%); + opacity: 0; + transition: all 0.3s ease-in-out; + pointer-events: auto; + display: flex; + align-items: center; + gap: 8px; + max-width: 300px; +} + +.toast.show { + transform: translateX(0); + opacity: 1; +} + +.toast i { + flex-shrink: 0; +} + +/* Toast message stacking with fly-in animation */ +.toast-message { + transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; + pointer-events: auto; + transform: translateX(400px); + margin-bottom: 0.5rem; + display: block !important; + max-width: 450px; + word-wrap: break-word; + /* All other styles handled by Tailwind classes */ +} + +.toast-message.toast-show { + transform: translateX(0); +} + +#toastContainer { + display: flex; + flex-direction: column; + align-items: flex-end; + max-width: 450px; +} + +/* Discrete horizontal resize divider */ +#mainColumnResizer { + width: 12px !important; + background-color: transparent !important; + cursor: ew-resize !important; + margin: 0 -6px !important; + position: relative; + z-index: 10; +} + +#mainColumnResizer:hover::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 4px; + height: 60px; + background-color: var(--border-accent); + border-radius: 2px; + transition: background-color 0.15s ease; +} + +/* Ensure consistent layout behavior when sidebar is collapsed */ +.sidebar-container.lg\:col-span-1 { + transition: all 0.3s ease; +} + +/* Smooth transition for main content area when sidebar toggles */ +.main-content-area { + transition: all 0.3s ease; +} + +/* Preserve column widths during sidebar transitions */ +.content-column { + transition: none !important; /* Disable transitions on content columns to prevent layout shifts */ +} + +/* Ensure the grid container adapts smoothly */ +.grid.grid-cols-1.lg\:grid-cols-4.gap-6.flex-grow { + transition: all 0.3s ease; +} + +/* Force consistent internal layout regardless of parent grid span */ +.main-content-area #mainContentColumns { + width: 100% !important; + max-width: none !important; + min-width: 0 !important; +} + +/* Ensure left column maintains its set width - respect inline styles */ +.main-content-area #leftMainColumn { + flex-shrink: 0 !important; + min-width: 0 !important; + /* Don't override width - let inline style control it */ +} + +/* Ensure right column fills remaining space */ +.main-content-area #rightMainColumn { + flex: 1 !important; + min-width: 0 !important; + width: auto !important; +} + +/* Specific rule to ensure inline width styles are respected */ +#leftMainColumn[style*="width"] { + flex-basis: auto !important; + flex-grow: 0 !important; + flex-shrink: 0 !important; +} + +/* Prevent layout shifts when grid span changes */ +.main-content-area .recording-header, +.main-content-area .participants-section, +.main-content-area .transcription-section, +.main-content-area .tab-section, +.main-content-area .audio-section, +.main-content-area .chat-section { + width: 100% !important; + max-width: none !important; +} + +/* Discrete vertical resize divider */ +.resize-handle { + height: 12px !important; + background-color: transparent !important; + cursor: ns-resize !important; + margin: 2px 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; + border-radius: 0 !important; +} + +.resize-handle .w-10 { + width: 60px !important; + height: 4px !important; + background-color: var(--text-light) !important; + border-radius: 2px !important; + transition: background-color 0.15s ease !important; +} + +.resize-handle:hover .w-10 { + background-color: var(--border-accent) !important; +} + +/* Color Scheme Modal Styles */ +.color-scheme-modal { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 1rem; + backdrop-filter: blur(4px); + transition: all 0.3s ease-in-out; +} + +.color-scheme-modal-content { + background-color: var(--bg-secondary); + border-radius: 1rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 42rem; + max-height: 90vh; + overflow: hidden; + transform: scale(1); + transition: all 0.3s ease-in-out; +} + +.color-scheme-header { + background: linear-gradient(135deg, var(--bg-accent), var(--bg-secondary)); + padding: 1.5rem; + border-bottom: 1px solid var(--border-primary); +} + +.color-scheme-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.color-scheme-subtitle { + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 0.5rem; + margin-bottom: 0; +} + +.color-scheme-body { + padding: 1.5rem; + max-height: calc(90vh - 200px); + overflow-y: auto; +} + +.color-scheme-section { + margin-bottom: 2rem; +} + +.color-scheme-section:last-child { + margin-bottom: 0; +} + +.color-scheme-section-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.color-scheme-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.color-scheme-option { + border: 2px solid var(--border-primary); + border-radius: 0.75rem; + padding: 1rem; + cursor: pointer; + transition: all 0.2s ease; + background-color: var(--bg-tertiary); + position: relative; + overflow: hidden; +} + +.color-scheme-option:hover { + border-color: var(--border-accent); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); +} + +.color-scheme-option.active { + border-color: var(--border-focus); + background-color: var(--bg-accent); + box-shadow: 0 0 0 3px var(--ring-focus); +} + +.color-scheme-preview { + height: 60px; + border-radius: 0.5rem; + margin-bottom: 0.75rem; + display: flex; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.color-scheme-preview-segment { + flex: 1; + transition: all 0.2s ease; +} + +.color-scheme-option:hover .color-scheme-preview-segment { + transform: scale(1.02); +} + +.color-scheme-name { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; + font-size: 0.875rem; +} + +.color-scheme-description { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.4; +} + +.color-scheme-check { + position: absolute; + top: 0.75rem; + right: 0.75rem; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background-color: var(--bg-button); + color: var(--text-button); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + opacity: 0; + transform: scale(0.8); + transition: all 0.2s ease; +} + +.color-scheme-option.active .color-scheme-check { + opacity: 1; + transform: scale(1); +} + +.color-scheme-footer { + background-color: var(--bg-tertiary); + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-primary); + display: flex; + justify-content: space-between; + align-items: center; +} + +.color-scheme-close-btn { + padding: 0.5rem 1rem; + background-color: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-secondary); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + font-weight: 500; +} + +.color-scheme-close-btn:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.color-scheme-reset-btn { + padding: 0.5rem 1rem; + background-color: transparent; + color: var(--text-muted); + border: 1px solid var(--border-secondary); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + font-weight: 500; +} + +.color-scheme-reset-btn:hover { + background-color: var(--bg-danger-light); + color: var(--text-danger); + border-color: var(--border-danger); +} + +/* Color scheme preview colors */ +.preview-blue-primary { background-color: #2563eb; } +.preview-blue-secondary { background-color: #dbeafe; } +.preview-blue-tertiary { background-color: #93c5fd; } + +.preview-emerald-primary { background-color: #059669; } +.preview-emerald-secondary { background-color: #d1fae5; } +.preview-emerald-tertiary { background-color: #6ee7b7; } + +.preview-purple-primary { background-color: #7c3aed; } +.preview-purple-secondary { background-color: #e9d5ff; } +.preview-purple-tertiary { background-color: #c4b5fd; } + +.preview-rose-primary { background-color: #e11d48; } +.preview-rose-secondary { background-color: #fce7f3; } +.preview-rose-tertiary { background-color: #f9a8d4; } + +.preview-amber-primary { background-color: #d97706; } +.preview-amber-secondary { background-color: #fef3c7; } +.preview-amber-tertiary { background-color: #fcd34d; } + +.preview-teal-primary { background-color: #0d9488; } +.preview-teal-secondary { background-color: #ccfbf1; } +.preview-teal-tertiary { background-color: #5eead4; } + +/* Dark theme preview colors */ +.preview-dark-blue-primary { background-color: #1e3a8a; } +.preview-dark-blue-secondary { background-color: #1e40af; } +.preview-dark-blue-tertiary { background-color: #60a5fa; } + +.preview-dark-emerald-primary { background-color: #064e3b; } +.preview-dark-emerald-secondary { background-color: #065f46; } +.preview-dark-emerald-tertiary { background-color: #6ee7b7; } + +.preview-dark-purple-primary { background-color: #581c87; } +.preview-dark-purple-secondary { background-color: #6b21a8; } +.preview-dark-purple-tertiary { background-color: #c4b5fd; } + +.preview-dark-rose-primary { background-color: #881337; } +.preview-dark-rose-secondary { background-color: #9f1239; } +.preview-dark-rose-tertiary { background-color: #fda4af; } + +.preview-dark-amber-primary { background-color: #78350f; } +.preview-dark-amber-secondary { background-color: #92400e; } +.preview-dark-amber-tertiary { background-color: #fcd34d; } + +.preview-dark-teal-primary { background-color: #134e4a; } +.preview-dark-teal-secondary { background-color: #115e59; } +.preview-dark-teal-tertiary { background-color: #5eead4; } + +/* Line clamp utility for text truncation */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Responsive adjustments for color scheme modal */ +@media (max-width: 768px) { + .color-scheme-modal { + padding: 0.5rem; + } + + .color-scheme-modal-content { + max-width: 95vw; + max-height: 95vh; + } + + .color-scheme-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .color-scheme-header { + padding: 1rem; + } + + .color-scheme-body { + padding: 1rem; + } + + .color-scheme-footer { + padding: 1rem; + flex-direction: column; + gap: 0.75rem; + } +} + +/* Enhanced Audio Player Styling for Better Dark Mode Visibility */ +.audio-player-container { + background: var(--bg-audio-player); + border: 1px solid var(--border-primary); + border-radius: 0.75rem; + padding: 1rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.dark .audio-player-container { + border: 1px solid var(--border-secondary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Audio player controls styling */ +.audio-player-container audio { + width: 100%; + height: 40px; + background-color: var(--bg-tertiary); + border-radius: 0.5rem; + border: 1px solid var(--border-primary); + outline: none; +} + +.dark .audio-player-container audio { + background-color: var(--bg-secondary); + border: 1px solid var(--border-secondary); +} + +/* Audio player controls for WebKit browsers */ +.audio-player-container audio::-webkit-media-controls-panel { + background-color: var(--bg-tertiary); + border-radius: 0.5rem; +} + +.dark .audio-player-container audio::-webkit-media-controls-panel { + background-color: var(--bg-secondary); +} + +.audio-player-container audio::-webkit-media-controls-enclosure { + border: none; + background: none; +} + +.audio-player-container audio::-webkit-media-controls-play-button, +.audio-player-container audio::-webkit-media-controls-pause-button { + background-color: var(--bg-button); + border-radius: 50%; + margin: 0 0.25rem; +} + +.dark .audio-player-container audio::-webkit-media-controls-play-button, +.dark .audio-player-container audio::-webkit-media-controls-pause-button { +} + +.audio-player-container audio::-webkit-media-controls-timeline { + background-color: var(--border-primary); + border-radius: 0.25rem; + margin: 0 0.5rem; +} + +.dark .audio-player-container audio::-webkit-media-controls-timeline { + background-color: var(--border-secondary); +} + +.audio-player-container audio::-webkit-media-controls-current-time-display, +.audio-player-container audio::-webkit-media-controls-time-remaining-display { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.dark .audio-player-container audio::-webkit-media-controls-current-time-display, +.dark .audio-player-container audio::-webkit-media-controls-time-remaining-display { + color: var(--text-primary); +} + +/* Audio player volume controls */ +.audio-player-container audio::-webkit-media-controls-volume-slider { + background-color: var(--border-primary); + border-radius: 0.25rem; +} + +.dark .audio-player-container audio::-webkit-media-controls-volume-slider { + background-color: var(--border-secondary); +} + +.audio-player-container audio::-webkit-media-controls-mute-button { + background-color: transparent; +} + +/* Dark mode: use browser's native dark theme for audio controls */ +.dark audio { + color-scheme: dark; +} + +/* Custom volume slider styling - accent colored */ +.volume-slider { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + height: 6px; +} + +.volume-slider::-webkit-slider-runnable-track { + background: var(--border-accent); + opacity: 0.5; + height: 6px; + border-radius: 3px; +} + +.dark .volume-slider::-webkit-slider-runnable-track { + background: var(--border-accent); + opacity: 0.6; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-accent); + margin-top: -3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + cursor: pointer; + transition: transform 0.15s ease; +} + +.volume-slider::-moz-range-track { + background: var(--border-accent); + opacity: 0.5; + height: 6px; + border-radius: 3px; +} + +.dark .volume-slider::-moz-range-track { + background: var(--border-accent); + opacity: 0.6; +} + +.volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-accent); + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + cursor: pointer; + transition: transform 0.15s ease; +} + +.volume-slider:hover::-webkit-slider-thumb { + transform: scale(1.2); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4); +} + +.volume-slider:hover::-moz-range-thumb { + transform: scale(1.2); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4); +} + +/* Vertical volume slider (popup) */ +.volume-slider-vertical { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + writing-mode: vertical-lr; + direction: rtl; + width: 20px; +} + +.volume-slider-vertical::-webkit-slider-runnable-track { + background: var(--border-accent); + opacity: 0.5; + width: 6px; + border-radius: 3px; +} + +.dark .volume-slider-vertical::-webkit-slider-runnable-track { + background: var(--border-accent); + opacity: 0.6; +} + +.volume-slider-vertical::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-accent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + cursor: pointer; +} + +.volume-slider-vertical::-moz-range-track { + background: var(--border-accent); + opacity: 0.5; + width: 6px; + border-radius: 3px; +} + +.dark .volume-slider-vertical::-moz-range-track { + background: var(--border-accent); + opacity: 0.6; +} + +.volume-slider-vertical::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-accent); + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + cursor: pointer; +} + +/* Player play button - accent colored with proper contrast */ +.player-play-button { + background-color: var(--text-accent); + color: white; +} + +.player-play-button:hover { + filter: brightness(1.1); +} + +.dark .player-play-button { + background-color: var(--text-accent); + color: var(--bg-primary); +} + +.dark .player-play-button:hover { + filter: brightness(1.15); +} + +/* Playback Speed Control Dropdown */ +.speed-dropdown { + animation: speedDropdownFade 0.12s ease-out; + width: 52px; +} + +@keyframes speedDropdownFade { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Custom audio player wrapper for better control */ +.custom-audio-wrapper { + position: relative; + background: var(--bg-audio-player); + border: 1px solid var(--border-primary); + border-radius: 0.75rem; + padding: 1rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.dark .custom-audio-wrapper { + border: 1px solid var(--border-secondary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + background: var(--bg-audio-player); +} + +/* Audio player title/metadata styling */ +.audio-player-title { + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dark .audio-player-title { + color: var(--text-primary); +} + +.audio-player-icon { + color: var(--text-accent); + font-size: 1rem; +} + +/* Audio player duration/time display */ +.audio-player-time { + color: var(--text-muted); + font-size: 0.75rem; + margin-top: 0.5rem; + text-align: center; +} + +.dark .audio-player-time { + color: var(--text-secondary); +} + +/* ASR Editor Table Styles */ +.asr-editor-table { + width: 100%; + border-collapse: collapse; /* Changed to collapse */ +} + +.asr-editor-table th { + padding: 0.75rem 1rem; + text-align: left; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 2px solid var(--border-primary); +} + +.asr-editor-table td { + padding: 0; /* No padding - let inputs fill the cell */ + vertical-align: middle; + border: 1px solid var(--border-secondary); +} + +/* More visible borders in dark mode */ +.dark .asr-editor-table, +.dark .asr-editor-table th, +.dark .asr-editor-table td { + border-color: rgba(148, 163, 184, 0.3); /* slate-400 with opacity for visibility */ +} + +/* ASR Editor inputs - blend into table cells */ +.asr-editor-table input, +.asr-editor-table textarea { + border-radius: 0 !important; /* Override global rounded corners */ + margin: 0; + display: block; + min-height: 100%; +} + +.asr-editor-table textarea { + vertical-align: top; + min-height: 2.5rem; /* Minimum height for single line */ + line-height: 1.5; +} + +/* Sticky header for ASR editor table */ +.asr-editor-table thead { + position: sticky; + top: 0; + z-index: 10; +} + +.asr-editor-table thead th { + background-color: var(--bg-tertiary); + border-collapse: collapse; +} + +/* Custom Number Input with Subtle Spinners */ +.custom-number-input { + position: relative; +} + +.custom-number-input input[type="number"] { + -moz-appearance: textfield; + padding-right: 1.75rem; /* Space for spinners */ +} + +.custom-number-input input[type="number"]::-webkit-inner-spin-button, +.custom-number-input input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.number-spinners { + position: absolute; + right: 0; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 1.75rem; + opacity: 0; + transition: opacity 0.2s ease; +} + +.custom-number-input:hover .number-spinners { + opacity: 1; +} + +.number-spinners button { + height: 50%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background-color: transparent; + border: none; + cursor: pointer; + padding: 0; +} + +.number-spinners button:hover { + color: var(--text-primary); +} + +.number-spinners button svg { + width: 0.75rem; + height: 0.75rem; +} + +/* Speaker Combobox Styles */ +.speaker-combobox { + position: relative; +} + +.speaker-combobox-input { + width: 100%; + padding-right: 2rem; /* Space for dropdown arrow */ +} + +.speaker-combobox-arrow { + position: absolute; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + color: var(--text-muted); + pointer-events: none; +} + +.speaker-combobox-suggestions { + position: absolute; + z-index: 10; + width: 100%; + margin-top: 0.25rem; + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 0.5rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 12rem; + overflow-y: auto; +} + +.speaker-suggestion-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 0.875rem; +} + +.speaker-suggestion-item:hover { + background-color: var(--bg-accent); +} + +/* Markdown Editor Styles */ +.markdown-editor-container { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.markdown-editor-container .EasyMDEContainer { + background: var(--bg-input); + border: none; + border-radius: 0.75rem; + display: flex; + flex-direction: column; + height: 100%; +} + +.markdown-editor-container .EasyMDEContainer .CodeMirror { + background: var(--bg-input); + color: var(--text-primary); + border: none; + border-radius: 0 0 0.75rem 0.75rem; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.875rem; + line-height: 1.5; + flex: 1; + min-height: 0; + overflow: hidden !important; +} + +/* Fix double scrolling by ensuring only the wrapper scrolls */ +.markdown-editor-container .EasyMDEContainer .CodeMirror-scroll { + overflow: auto !important; + height: 100% !important; +} + +.markdown-editor-container .EasyMDEContainer .CodeMirror-sizer { + min-height: 100% !important; +} + +.markdown-editor-container .EasyMDEContainer .CodeMirror-focused { + outline: none; + box-shadow: 0 0 0 2px var(--ring-focus); +} + +.markdown-editor-container .EasyMDEContainer .editor-toolbar { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-secondary); + border-radius: 0.75rem 0.75rem 0 0; + padding: 0.5rem; + flex-shrink: 0; +} + +.markdown-editor-container .EasyMDEContainer .editor-toolbar button { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + border-radius: 0.25rem; + padding: 0.25rem; + margin: 0 0.125rem; + transition: all 0.2s ease; +} + +.markdown-editor-container .EasyMDEContainer .editor-toolbar button:hover { + background: var(--bg-accent); + color: var(--text-accent); + border-color: var(--border-accent); +} + +.markdown-editor-container .EasyMDEContainer .editor-toolbar button.active { + background: var(--bg-accent); + color: var(--text-accent); + border-color: var(--border-accent); +} + +/* EasyMDE Fullscreen Mode Fixes */ +.EasyMDEContainer .editor-fullscreen { + z-index: 9999 !important; + /* Add top padding to ensure toolbar is visible below the app header */ + padding-top: 0 !important; /* Reset padding - we'll handle this in the CodeMirror element */ +} + +/* Force hide speaker legend during any fullscreen mode - comprehensive approach */ +.editor-fullscreen .speaker-legend, +.CodeMirror-fullscreen .speaker-legend, +body:has(.editor-fullscreen) .speaker-legend, +body:has(.CodeMirror-fullscreen) .speaker-legend { + display: none !important; +} + +/* Alternative: Lower z-index approach for speaker legend during fullscreen */ +.editor-fullscreen ~ .speaker-legend, +.CodeMirror-fullscreen ~ .speaker-legend, +.editor-fullscreen + .speaker-legend, +.CodeMirror-fullscreen + .speaker-legend { + z-index: -1 !important; +} + +/* Target the specific fullscreen container that EasyMDE creates */ +.CodeMirror-fullscreen-wrapper { + z-index: 9999 !important; +} + +/* Ensure fullscreen editor is above everything */ +.CodeMirror-fullscreen { + z-index: 99998 !important; /* Just below the toolbar */ + /* Add top padding to ensure content starts below the toolbar */ + padding-top: 20px !important; /* Increased padding to match toolbar height */ + margin-top: 0 !important; /* Remove any margin */ + box-sizing: border-box !important; /* Ensure padding is included in height */ +} + +/* Ensure the editor toolbar is visible in fullscreen mode */ +.editor-toolbar.fullscreen { + position: fixed !important; + top: 0 !important; /* Position at the very top of the screen */ + z-index: 99999 !important; /* Extremely high z-index to be above everything */ + background-color: var(--bg-tertiary) !important; + border-bottom: 1px solid var(--border-secondary) !important; + width: 100% !important; /* Full width */ + padding: 5px !important; /* Reduced padding */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; /* Add shadow for better visibility */ + height: 60px !important; /* Further increased height to ensure buttons are fully visible */ + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + box-sizing: border-box !important; /* Ensure padding is included in height */ +} + +/* Ensure the editor preview is properly positioned in fullscreen mode */ +.editor-preview-side.editor-preview-active-side { + top: 60px !important; /* Match the padding-top of the editor */ + padding-top: 0 !important; /* Remove default padding */ + background-color: var(--bg-input) !important; /* Match editor background */ + margin-top: 0 !important; /* Remove any margin */ + z-index: 99997 !important; /* Below the editor but above other elements */ +} + +/* Ensure the editor preview in split-screen mode is properly positioned */ +.CodeMirror-sided.CodeMirror-fullscreen { + padding-top: 20px !important; /* Match the padding of regular fullscreen mode */ + width: 50% !important; /* Ensure editor takes exactly half the width */ + background-color: var(--bg-input) !important; /* Match editor background */ + margin-top: 0 !important; /* Remove any margin */ + box-sizing: border-box !important; /* Ensure padding is included in height */ +} + +/* Fix the split-screen preview to match the editor */ +.editor-preview-active-side.editor-preview-side { + top: 60px !important; /* Match the editor padding */ + padding-top: 0 !important; /* Remove default padding */ + margin-top: 0 !important; /* Remove any margin */ + height: calc(100% - 60px) !important; /* Adjust height to account for top position */ + border-top: none !important; /* Remove top border */ + background-color: var(--bg-input) !important; /* Match editor background */ + z-index: 99997 !important; /* Below the editor but above other elements */ + box-sizing: border-box !important; /* Ensure padding is included in height */ +} + +/* Hide speaker legend when any fullscreen editor is active */ +body.CodeMirror-fullscreen .speaker-legend, +.CodeMirror-fullscreen .speaker-legend { + display: none !important; +} + +/* Ensure the save/cancel buttons are visible in fullscreen mode */ +.markdown-editor-container .flex.justify-end.gap-2.mt-2 { + position: fixed !important; + bottom: 20px !important; + right: 20px !important; + z-index: 100000 !important; /* Above everything else */ + background-color: var(--bg-secondary) !important; + padding: 10px !important; + border-radius: 8px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; + border: 1px solid var(--border-primary) !important; +} + +/* Additional fixes for fullscreen mode */ +body.fullscreen-mode { + overflow: hidden !important; /* Prevent scrolling when in fullscreen mode */ +} + +/* Ensure the editor container is properly positioned in fullscreen mode */ +.CodeMirror-fullscreen-wrapper { + z-index: 99998 !important; /* Below the toolbar but above other elements */ +} + +/* Ensure the editor is properly positioned in fullscreen mode */ +.CodeMirror-fullscreen .CodeMirror-scroll { + padding-top: 0 !important; /* Remove default padding */ + margin-top: 0 !important; /* Remove any margin */ +} + +/* Additional fixes for toolbar buttons to ensure they're fully visible */ +.editor-toolbar.fullscreen button { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + height: 40px !important; /* Increased height for better visibility */ + width: 40px !important; /* Increased width for better visibility */ + margin: 0 3px !important; /* Increased margin for better spacing */ + padding: 0 !important; + font-size: 18px !important; /* Increased font size for better visibility */ + line-height: 1 !important; + background-color: var(--bg-secondary) !important; /* Add background color for better visibility */ + border: 1px solid var(--border-secondary) !important; /* Add border for better visibility */ + border-radius: 4px !important; /* Add border radius for better visibility */ +} + +/* Add hover state for toolbar buttons */ +.editor-toolbar.fullscreen button:hover { + background-color: var(--bg-accent) !important; + color: var(--text-accent) !important; + transform: translateY(-1px) !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; +} + +/* Add active state for toolbar buttons */ +.editor-toolbar.fullscreen button.active { + background-color: var(--bg-accent) !important; + color: var(--text-accent) !important; + border-color: var(--border-accent) !important; +} + +/* Ensure the editor toolbar wrapper is properly positioned */ +.editor-toolbar-wrapper { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + z-index: 99999 !important; +} + +.markdown-editor-container .EasyMDEContainer .editor-preview { + background: var(--bg-input); + color: var(--text-primary); + padding: 1rem; + border-radius: 0 0 0.5rem 0.5rem; +} + +.markdown-editor-container .EasyMDEContainer .editor-preview-side { + background: var(--bg-input); + color: var(--text-primary); + border-left: 1px solid var(--border-secondary); + padding-top: 0 !important; /* Remove default padding */ + box-sizing: border-box !important; /* Ensure padding is included in height */ +} + +/* Dark mode adjustments for markdown editor */ +.dark .markdown-editor-container .EasyMDEContainer .CodeMirror { + background: var(--bg-input); + color: var(--text-primary); +} + +/* Fix cursor/caret color in dark mode for ALL CodeMirror instances */ +.dark .CodeMirror-cursor { + border-left: 2px solid var(--text-primary) !important; +} + +/* Also fix the caret in the actual textarea (when CodeMirror is not active) */ +.dark textarea, +.dark input[type="text"], +.dark input[type="search"] { + caret-color: var(--text-primary); +} + +/* Ensure cursor is visible in all markdown editors */ +.markdown-editor-container .CodeMirror-cursor, +.recording-notes-editor .CodeMirror-cursor { + border-left: 2px solid var(--text-primary) !important; +} + +.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar { + background: var(--bg-tertiary); + border-bottom-color: var(--border-secondary); +} + +.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar button { + color: var(--text-secondary); +} + +.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar button:hover, +.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar button.active { + background: var(--bg-accent); + color: var(--text-accent); + border-color: var(--border-accent); +} + +/* Markdown preview styling in notes */ +.notes-preview h1, .notes-preview h2, .notes-preview h3, +.notes-preview h4, .notes-preview h5, .notes-preview h6 { + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.notes-preview h1 { font-size: 1.5rem; } +.notes-preview h2 { font-size: 1.25rem; } +.notes-preview h3 { font-size: 1.125rem; } + +.notes-preview p { + margin-bottom: 0.75rem; + line-height: 1.6; +} + +.notes-preview ul, .notes-preview ol { + margin-left: 1.5rem; + margin-bottom: 0.75rem; +} + +.notes-preview li { + margin-bottom: 0.25rem; +} + +.notes-preview code { + background-color: var(--bg-tertiary); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.875rem; +} + +.notes-preview pre { + background-color: var(--bg-tertiary); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-bottom: 0.75rem; +} + +.notes-preview pre code { + background-color: transparent; + padding: 0; +} + +.notes-preview blockquote { + border-left: 4px solid var(--border-accent); + padding-left: 1rem; + margin-left: 0; + margin-bottom: 0.75rem; + color: var(--text-muted); + font-style: italic; +} + +.notes-preview table { + border-collapse: collapse; + width: 100%; + margin-bottom: 0.75rem; +} + +.notes-preview th, .notes-preview td { + border: 1px solid var(--border-secondary); + padding: 0.5rem; + text-align: left; +} + +.notes-preview th { + background-color: var(--bg-tertiary); + font-weight: 600; +} + +/* Recording notes editor styles */ +.recording-notes-editor .EasyMDEContainer { + height: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--border-secondary); + border-radius: 0.5rem; + overflow: hidden; +} + +.summary-editor-container { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.recording-notes-editor .CodeMirror, +.recording-notes-editor .editor-preview, +.recording-notes-editor .editor-preview-side { + flex-grow: 1; + background: var(--bg-input); + color: var(--text-primary); +} + +.recording-notes-editor .editor-toolbar { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-secondary); +} + +.recording-notes-editor .editor-toolbar button { + color: var(--text-secondary); + border: none; +} + +.recording-notes-editor .editor-toolbar button:hover, +.recording-notes-editor .editor-toolbar button.active { + background: var(--bg-accent); + color: var(--text-accent); + border: none; +} + +.recording-notes-editor .CodeMirror-cursor { + border-left: 1px solid var(--text-primary); +} + +/* Recording notes editor base */ +.recording-notes-editor { + display: flex !important; + flex-direction: column !important; + min-height: 150px !important; + overflow: hidden !important; +} + +/* During active recording: fill available space */ +.recording-notes-editor.recording-active { + flex: 1 !important; + min-height: 0 !important; + max-height: none !important; +} + +/* Notes editor inside accordion — fill expanded section */ +.recording-notes-editor.accordion-expanded { + flex: 1 !important; + min-height: 0 !important; + max-height: none !important; +} + +.recording-notes-editor .EasyMDEContainer { + height: 100% !important; /* Fill parent container */ + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +.recording-notes-editor .EasyMDEContainer .CodeMirror { + flex: 1 !important; /* Take all available space */ + height: auto !important; /* Let flex control height */ + min-height: 0 !important; /* Allow flex shrinking */ + overflow: hidden !important; /* Prevent CodeMirror from growing */ +} + +.recording-notes-editor .EasyMDEContainer .CodeMirror-scroll { + height: 100% !important; /* Fill CodeMirror container */ + overflow-y: auto !important; /* Enable internal scrolling */ + min-height: 0 !important; /* Allow flex shrinking */ +} + +.recording-notes-editor .EasyMDEContainer .CodeMirror-sizer { + min-height: auto !important; /* Don't force content to fill editor */ +} + +/* Recording notes textarea fallback - constrained height with internal scrolling */ +.recording-notes-editor textarea { + background-color: var(--bg-input) !important; + color: var(--text-primary) !important; + border-color: var(--border-secondary) !important; + flex: 1 !important; /* Fill editor container */ + height: auto !important; /* Let flex control height */ + min-height: 0 !important; /* Allow flex shrinking within capped parent */ + overflow-y: auto !important; /* Enable internal scrolling */ + resize: none !important; /* Prevent manual resizing */ +} + +.recording-notes-editor textarea:focus { + background-color: var(--bg-input) !important; + border-color: var(--border-focus) !important; + box-shadow: 0 0 0 2px var(--ring-focus) !important; +} + +/* Ensure all textareas in recording view use theme colors and constrained height */ +.recording-notes-editor textarea, +textarea[ref="recordingNotesEditor"] { + background-color: var(--bg-input) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-secondary) !important; + flex: 1 !important; /* Fill editor container */ + height: auto !important; /* Let flex control height */ + min-height: 0 !important; /* Allow flex shrinking within capped parent */ + overflow-y: auto !important; /* Enable internal scrolling */ + resize: none !important; /* Prevent manual resizing */ +} + +/* Dark mode specific adjustments for recording notes */ +.dark .recording-notes-editor textarea, +.dark textarea[ref="recordingNotesEditor"] { + background-color: var(--bg-input) !important; + color: var(--text-primary) !important; + border-color: var(--border-secondary) !important; +} + +/* Focus states for recording notes textarea */ +.recording-notes-editor textarea:focus, +textarea[ref="recordingNotesEditor"]:focus { + outline: none !important; + border-color: var(--border-focus) !important; + box-shadow: 0 0 0 2px var(--ring-focus) !important; +} + +.mobile-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.mobile-header { + flex-shrink: 0; +} + +.mobile-audio-player { + flex-shrink: 0; +} + +.mobile-tabs { + flex-shrink: 0; + overflow-x: auto; +} + +.mobile-content { + flex-grow: 1; + overflow-y: auto; +} + +/* --- New Sidebar & Main Content Layout --- */ + +/* Sidebar Container */ +.sidebar { + position: fixed; /* Changed to fixed to overlay correctly */ + top: 69px; /* Height of the header */ + left: 0; + bottom: 0; + width: 320px; /* w-80 */ + background-color: var(--bg-secondary); + border-right: 1px solid var(--border-primary); + z-index: 50; /* Higher z-index to be above backdrop */ + transform: translateX(0); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; +} + +.sidebar.collapsed { + transform: translateX(-100%); +} + +/* Wrapper to prevent sidebar's own content from squishing */ +.sidebar-content-wrapper { + width: 320px; /* Fixed width */ + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Main Content Area */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + transition: padding-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); + padding-left: 320px; /* Default space for the sidebar */ +} + +.main-content.sidebar-open { + padding-left: 320px; +} + +/* When sidebar is collapsed, main content takes full width */ +.main-content:not(.sidebar-open) { + padding-left: 0; +} + +/* --- Responsive Styles --- */ +@media (max-width: 1023px) { + /* On mobile, main content always takes full width */ + .main-content { + padding-left: 0 !important; + } + + /* Sidebar is an overlay on mobile */ + .sidebar { + top: 0; /* Cover full height on mobile */ + z-index: 60; /* Higher z-index for mobile overlay */ + } + + /* Make buttons smaller in mobile view */ + .mobile-action-btn, + .p-2.rounded-lg { + padding: 0.375rem !important; + min-width: 1.5rem !important; + height: 1.5rem !important; + font-size: 0.75rem !important; + } + + /* Ensure icons are properly sized */ + .mobile-action-btn i, + .p-2.rounded-lg i { + font-size: 0.875rem !important; + } +} + +/* --- Tab Navigation Responsive Styles --- */ +@media (max-width: 640px) { + /* Mobile tab navigation */ + nav.tab-nav { + display: flex !important; + flex-direction: column !important; + gap: 0 !important; + border-bottom: none !important; + margin-bottom: 0 !important; + space: 0 !important; + } + + nav.tab-nav a, + nav.tab-nav button { + width: 100% !important; + text-align: left !important; + border-bottom: 1px solid var(--border-primary) !important; + border-left: 3px solid transparent !important; + border-right: none !important; + border-top: none !important; + padding: 0.75rem 1rem !important; + margin-bottom: 0 !important; + margin-left: 0 !important; + margin-right: 0 !important; + display: flex !important; + align-items: center !important; + white-space: nowrap !important; + } + + nav.tab-nav a.border-\\[var\\(--border-accent\\)\\], + nav.tab-nav button:has(.border-\\[var\\(--border-focus\\)\\]) { + border-left: 3px solid var(--border-accent) !important; + background-color: var(--bg-tertiary) !important; + } + + /* Fix spacing between elements */ + .tab-nav > * + * { + margin-left: 0 !important; + } +} + +/* Custom Checkbox Styling for Speaker Modal */ +input[type="checkbox"].speaker-checkbox { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 1.125rem; + height: 1.125rem; + border: 2px solid var(--border-secondary); + border-radius: 0.25rem; + background-color: var(--bg-input); + cursor: pointer; + position: relative; + transition: all 0.15s ease; + flex-shrink: 0; +} + +input[type="checkbox"].speaker-checkbox:hover { + border-color: var(--text-accent); + background-color: var(--bg-secondary); +} + +input[type="checkbox"].speaker-checkbox:focus { + outline: none; + border-color: var(--text-accent); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +input[type="checkbox"].speaker-checkbox:checked { + background-color: var(--text-accent); + border-color: var(--text-accent); +} + +input[type="checkbox"].speaker-checkbox:checked::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 0.25rem; + height: 0.5rem; + border: solid white; + border-width: 0 2px 2px 0; + transform: translate(-50%, -60%) rotate(45deg); +} + +input[type="checkbox"].speaker-checkbox:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Dark mode adjustments */ +:root[data-theme="dark"] input[type="checkbox"].speaker-checkbox { + border-color: rgba(255, 255, 255, 0.2); + background-color: rgba(255, 255, 255, 0.05); +} + +:root[data-theme="dark"] input[type="checkbox"].speaker-checkbox:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +:root[data-theme="dark"] input[type="checkbox"].speaker-checkbox:checked { + background-color: var(--text-accent); + border-color: var(--text-accent); +} + +/* Custom Select Dropdown Styling */ +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%236b7280' d='M4.5 5.5L8 9l3.5-3.5L10.75 4.75 8 7.5 5.25 4.75z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + background-size: 1em 1em; + padding-right: 2rem; + cursor: pointer; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, background-color 0.15s ease-in-out; +} + +/* Dark mode select styling */ +.dark select { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%23cbd5e1' d='M4.5 5.5L8 9l3.5-3.5L10.75 4.75 8 7.5 5.25 4.75z'/%3E%3C/svg%3E"); + background-color: var(--bg-input); + border-color: var(--border-secondary); +} + +/* Hover and focus states for select */ +select:hover { + border-color: var(--border-accent); +} + +select:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.dark select:focus { + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +/* Disabled select styling */ +select:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--bg-tertiary); +} + +/* Input and textarea improvements for dark mode */ +.dark input[type="text"], +.dark input[type="email"], +.dark input[type="password"], +.dark input[type="date"], +.dark input[type="number"], +.dark textarea { + background-color: var(--bg-input); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.dark input[type="text"]:focus, +.dark input[type="email"]:focus, +.dark input[type="password"]:focus, +.dark input[type="date"]:focus, +.dark input[type="number"]:focus, +.dark textarea:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +/* Placeholder text improvements */ +.dark input::placeholder, +.dark textarea::placeholder { + color: var(--text-light); + opacity: 0.7; +} + +/* Description text improvements */ +.dark .text-xs.text-gray-500, +.dark .text-xs.text-gray-400, +.dark .text-sm.text-gray-500, +.dark .text-sm.text-gray-400 { + color: var(--text-muted) !important; +} + +/* Label text improvements */ +.dark label { + color: var(--text-secondary); +} + +/* ===== Video Fullscreen Overlay ===== */ +.video-fullscreen-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: #000; + display: flex; + align-items: center; + justify-content: center; + cursor: none; +} +.video-fullscreen-overlay:has(.video-fullscreen-controls.visible) { + cursor: default; +} +.video-fullscreen-video { + width: 100%; + height: 100%; + object-fit: contain; + cursor: inherit; +} +.video-fullscreen-subtitle { + position: absolute; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + max-width: 80%; + max-height: 30vh; + overflow-y: auto; + text-align: center; + padding: 8px 16px; + background: rgba(0, 0, 0, 0.75); + border-radius: 8px; + color: #fff; + font-size: 1.25rem; + line-height: 1.6; + pointer-events: auto; + transition: bottom 0.3s ease; + overscroll-behavior: contain; +} +.video-fullscreen-subtitle.subtitle-shifted { + bottom: 100px; +} +.video-fullscreen-subtitle-speaker { + font-weight: 600; + margin-right: 6px; +} +.video-fullscreen-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + padding-top: 40px; + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s, transform 0.3s; + pointer-events: none; +} +.video-fullscreen-controls.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +/* Fullscreen volume slider - white themed */ +.fullscreen-volume-slider { + -webkit-appearance: none; + appearance: none; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.fullscreen-volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} +.fullscreen-volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + cursor: pointer; + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} +.fullscreen-volume-slider::-moz-range-track { + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; +} diff --git a/static/img/dark-mode.png b/static/img/dark-mode.png new file mode 100644 index 0000000..423c600 Binary files /dev/null and b/static/img/dark-mode.png differ diff --git a/static/img/edit_transcription.png b/static/img/edit_transcription.png new file mode 100644 index 0000000..0c28936 Binary files /dev/null and b/static/img/edit_transcription.png differ diff --git a/static/img/favicon.ico b/static/img/favicon.ico new file mode 100644 index 0000000..edf51c5 Binary files /dev/null and b/static/img/favicon.ico differ diff --git a/static/img/icon-16x16.png b/static/img/icon-16x16.png new file mode 100644 index 0000000..316c938 Binary files /dev/null and b/static/img/icon-16x16.png differ diff --git a/static/img/icon-180x180.png b/static/img/icon-180x180.png new file mode 100644 index 0000000..9db1844 Binary files /dev/null and b/static/img/icon-180x180.png differ diff --git a/static/img/icon-192x192.png b/static/img/icon-192x192.png new file mode 100644 index 0000000..9069af8 Binary files /dev/null and b/static/img/icon-192x192.png differ diff --git a/static/img/icon-32x32.png b/static/img/icon-32x32.png new file mode 100644 index 0000000..bacff1f Binary files /dev/null and b/static/img/icon-32x32.png differ diff --git a/static/img/icon-512x512.png b/static/img/icon-512x512.png new file mode 100644 index 0000000..7def29e Binary files /dev/null and b/static/img/icon-512x512.png differ diff --git a/static/img/icon-maskable-512x512.png b/static/img/icon-maskable-512x512.png new file mode 100644 index 0000000..d867c12 Binary files /dev/null and b/static/img/icon-maskable-512x512.png differ diff --git a/static/img/intuitive-speaker-identification.png b/static/img/intuitive-speaker-identification.png new file mode 100644 index 0000000..b73df08 Binary files /dev/null and b/static/img/intuitive-speaker-identification.png differ diff --git a/static/img/light-mode.png b/static/img/light-mode.png new file mode 100644 index 0000000..2e4eea9 Binary files /dev/null and b/static/img/light-mode.png differ diff --git a/static/img/logo-dictia.png b/static/img/logo-dictia.png new file mode 100644 index 0000000..3ab0e3b Binary files /dev/null and b/static/img/logo-dictia.png differ diff --git a/static/img/main.png b/static/img/main.png new file mode 100644 index 0000000..5e43eb0 Binary files /dev/null and b/static/img/main.png differ diff --git a/static/img/main2.png b/static/img/main2.png new file mode 100644 index 0000000..5b863a2 Binary files /dev/null and b/static/img/main2.png differ diff --git a/static/img/manual-auto-speaker-identification.png b/static/img/manual-auto-speaker-identification.png new file mode 100644 index 0000000..82fff9d Binary files /dev/null and b/static/img/manual-auto-speaker-identification.png differ diff --git a/static/img/multilingual-support.png b/static/img/multilingual-support.png new file mode 100644 index 0000000..c89443d Binary files /dev/null and b/static/img/multilingual-support.png differ diff --git a/static/img/rec1.png b/static/img/rec1.png new file mode 100644 index 0000000..5631e63 Binary files /dev/null and b/static/img/rec1.png differ diff --git a/static/img/rec2.png b/static/img/rec2.png new file mode 100644 index 0000000..ec77bc3 Binary files /dev/null and b/static/img/rec2.png differ diff --git a/static/img/rec3.png b/static/img/rec3.png new file mode 100644 index 0000000..621ad55 Binary files /dev/null and b/static/img/rec3.png differ diff --git a/static/img/screenshots/main-view.png b/static/img/screenshots/main-view.png new file mode 100644 index 0000000..791418d Binary files /dev/null and b/static/img/screenshots/main-view.png differ diff --git a/static/img/screenshots/mobile-view.png b/static/img/screenshots/mobile-view.png new file mode 100644 index 0000000..48a6438 Binary files /dev/null and b/static/img/screenshots/mobile-view.png differ diff --git a/static/img/screenshots/recording-interface.png b/static/img/screenshots/recording-interface.png new file mode 100644 index 0000000..09bd911 Binary files /dev/null and b/static/img/screenshots/recording-interface.png differ diff --git a/static/img/screenshots/transcript-view.png b/static/img/screenshots/transcript-view.png new file mode 100644 index 0000000..7454f3e Binary files /dev/null and b/static/img/screenshots/transcript-view.png differ diff --git a/static/img/simple-transcription-view.png b/static/img/simple-transcription-view.png new file mode 100644 index 0000000..7e9acde Binary files /dev/null and b/static/img/simple-transcription-view.png differ diff --git a/static/img/speaker-suggestions.png b/static/img/speaker-suggestions.png new file mode 100644 index 0000000..7f50c3a Binary files /dev/null and b/static/img/speaker-suggestions.png differ diff --git a/static/img/tags.png b/static/img/tags.png new file mode 100644 index 0000000..a8fedcf Binary files /dev/null and b/static/img/tags.png differ diff --git a/static/img/transcription-bubble-view.png b/static/img/transcription-bubble-view.png new file mode 100644 index 0000000..7d5efad Binary files /dev/null and b/static/img/transcription-bubble-view.png differ diff --git a/static/img/transcription-chat-bubble-view.png b/static/img/transcription-chat-bubble-view.png new file mode 100644 index 0000000..16dbb6c Binary files /dev/null and b/static/img/transcription-chat-bubble-view.png differ diff --git a/static/js/app.modular.js b/static/js/app.modular.js new file mode 100644 index 0000000..8155381 --- /dev/null +++ b/static/js/app.modular.js @@ -0,0 +1,2603 @@ +const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue; + +// Import composables +import { useRecordings } from './modules/composables/recordings.js'; +import { useUpload } from './modules/composables/upload.js'; +import { useAudio } from './modules/composables/audio.js'; +import { useUI } from './modules/composables/ui.js'; +import { useModals } from './modules/composables/modals.js'; +import { useSharing } from './modules/composables/sharing.js'; +import { useReprocess } from './modules/composables/reprocess.js'; +import { useTranscription } from './modules/composables/transcription.js'; +import { useSpeakers } from './modules/composables/speakers.js'; +import { useChat } from './modules/composables/chat.js'; +import { useTags } from './modules/composables/tags.js'; +import { usePWA } from './modules/composables/pwa.js'; +import { useVirtualScroll, getVirtualItemKey } from './modules/composables/virtualScroll.js'; +import { useBulkSelection } from './modules/composables/bulk-selection.js'; +import { useBulkOperations } from './modules/composables/bulk-operations.js'; +import { useFolders } from './modules/composables/folders.js'; + +// Import utilities +import { showToast } from './modules/utils/toast.js'; +import { getContrastTextColor } from './modules/utils/colors.js'; + +// Number of speaker colors available in CSS (must match styles.css) +const SPEAKER_COLOR_COUNT = 16; + +// Parse transcription text to detect if it's an error message +const parseTranscriptionError = (text) => { + if (!text) return null; + + // Check for JSON-formatted error from backend + if (text.startsWith('ERROR_JSON:')) { + try { + const jsonStr = text.substring(11); + const data = JSON.parse(jsonStr); + const _t = (key, fb) => (window.i18n && window.i18n.t) ? window.i18n.t(key) : fb; + return { + title: data.t || _t('errors.fallbackTitle', 'Error'), + message: data.m || _t('errors.fallbackMessage', 'An error occurred'), + guidance: data.g || '', + icon: data.i || 'fa-exclamation-circle', + type: data.y || 'unknown', + isKnown: data.k || false, + technical: data.d || '' + }; + } catch (e) { + console.error('Failed to parse error JSON:', e); + } + } + + // Check for legacy error format + const errorPrefixes = [ + 'Transcription failed:', + 'Processing failed:', + 'ASR processing failed:', + 'Audio extraction failed:' + ]; + + for (const prefix of errorPrefixes) { + if (text.startsWith(prefix)) { + return parseUnformattedError(text); + } + } + + return null; +}; + +// Parse unformatted error messages and make them user-friendly +const parseUnformattedError = (text) => { + const _t = (key, fb) => (window.i18n && window.i18n.t) ? window.i18n.t(key) : fb; + const lowerText = text.toLowerCase(); + + // Known error patterns + const patterns = [ + { + patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], + title: _t('errors.fileTooLargeTitle', 'File Too Large'), + message: _t('errors.fileTooLargeMessage', 'The audio file exceeds the maximum size allowed by the transcription service.'), + guidance: _t('errors.fileTooLargeGuidance', 'Try enabling audio chunking in your settings, or compress the audio file before uploading.'), + icon: 'fa-file-audio', + type: 'size_limit' + }, + { + patterns: ['timed out', 'timeout', 'deadline exceeded'], + title: _t('errors.processingTimeout', 'Processing Timeout'), + message: _t('errors.processingTimeoutMessage', 'The transcription took too long to complete.'), + guidance: _t('errors.processingTimeoutGuidance', 'This can happen with very long recordings. Try splitting the audio into smaller parts.'), + icon: 'fa-clock', + type: 'timeout' + }, + { + patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], + title: _t('errors.authenticationError', 'Authentication Error'), + message: _t('errors.authenticationErrorMessage', 'The transcription service rejected the API credentials.'), + guidance: _t('errors.authenticationErrorGuidance', 'Please check that the API key is correct and has not expired.'), + icon: 'fa-key', + type: 'auth' + }, + { + patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], + title: _t('errors.rateLimitExceeded', 'Rate Limit Exceeded'), + message: _t('errors.rateLimitExceededMessage', 'Too many requests were sent to the transcription service.'), + guidance: _t('errors.rateLimitExceededGuidance', 'Please wait a few minutes and try reprocessing.'), + icon: 'fa-hourglass-half', + type: 'rate_limit' + }, + { + patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], + title: _t('errors.connectionError', 'Connection Error'), + message: _t('errors.connectionErrorMessage', 'Could not connect to the transcription service.'), + guidance: _t('errors.connectionErrorGuidance', 'Check your internet connection and ensure the service is available.'), + icon: 'fa-wifi', + type: 'connection' + }, + { + patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], + title: _t('errors.serviceUnavailable', 'Service Unavailable'), + message: _t('errors.serviceUnavailableMessage', 'The transcription service is temporarily unavailable.'), + guidance: _t('errors.serviceUnavailableGuidance', 'This is usually temporary. Please try again in a few minutes.'), + icon: 'fa-server', + type: 'service_error' + }, + { + patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt', 'not valid audio'], + title: _t('errors.invalidAudioFormat', 'Invalid Audio Format'), + message: _t('errors.invalidAudioFormatMessage', 'The audio file format is not supported or the file may be corrupted.'), + guidance: _t('errors.invalidAudioFormatGuidance', 'Try converting the audio to MP3 or WAV format before uploading.'), + icon: 'fa-file-audio', + type: 'format' + }, + { + patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], + title: _t('errors.audioExtractionFailed', 'Audio Extraction Failed'), + message: _t('errors.audioExtractionFailedMessage', 'Could not extract audio from the uploaded file.'), + guidance: _t('errors.audioExtractionFailedGuidance', 'Try converting the file to a standard audio format (MP3, WAV) before uploading.'), + icon: 'fa-file-video', + type: 'extraction' + } + ]; + + // Check patterns + for (const pattern of patterns) { + for (const p of pattern.patterns) { + if (lowerText.includes(p)) { + return { + title: pattern.title, + message: pattern.message, + guidance: pattern.guidance, + icon: pattern.icon, + type: pattern.type, + isKnown: true, + technical: text + }; + } + } + } + + // Unknown error - clean it up + let cleanMessage = text; + for (const prefix of ['Transcription failed:', 'Processing failed:', 'Error:', 'ASR processing failed:']) { + if (cleanMessage.startsWith(prefix)) { + cleanMessage = cleanMessage.substring(prefix.length).trim(); + } + } + + // Truncate if too long + if (cleanMessage.length > 200) { + cleanMessage = cleanMessage.substring(0, 200) + '...'; + } + + return { + title: _t('errors.processingError', 'Processing Error'), + message: cleanMessage, + guidance: _t('errors.processingErrorGuidance', 'If this error persists, try reprocessing the recording.'), + icon: 'fa-exclamation-circle', + type: 'unknown', + isKnown: false, + technical: text + }; +}; + +// Wait for the DOM to be fully loaded before mounting the Vue app +document.addEventListener('DOMContentLoaded', async () => { + // Initialize i18n before creating Vue app (if not already initialized) + try { + if (window.i18n && !window.i18n.currentLocale) { + const appElement = document.getElementById('app'); + const userLang = appElement?.dataset.userLanguage || localStorage.getItem('preferredLanguage') || 'en'; + + // Add timeout to prevent indefinite waiting + await Promise.race([ + window.i18n.init(userLang), + new Promise((resolve) => setTimeout(resolve, 3000)) + ]); + + console.log('i18n initialized with language:', userLang); + } else if (window.i18n && window.i18n.currentLocale) { + console.log('i18n already initialized with language:', window.i18n.currentLocale); + } + } catch (error) { + console.error('Error initializing i18n:', error); + // Continue anyway with fallback translations + } + + // CSRF Token Integration with Vue.js + const csrfToken = ref(document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')); + + // Register Service Worker (non-blocking) + if ('serviceWorker' in navigator) { + // Delay registration to not block page load + setTimeout(() => { + navigator.serviceWorker.register('/static/sw.js') + .then(registration => { + console.log('ServiceWorker registration successful with scope:', registration.scope); + }) + .catch(error => { + console.warn('ServiceWorker registration failed (non-critical):', error); + }); + }, 1000); + } + + // Create a safe t function that's always available + const safeT = (key, params = {}) => { + if (!window.i18n || !window.i18n.t) { + return key; + } + return window.i18n.t(key, params); + }; + + const app = createApp({ + setup() { + // ========================================================================= + // STATE DECLARATIONS - All reactive state stays here for proper reactivity + // ========================================================================= + + // --- Core State --- + const currentView = ref('upload'); + const dragover = ref(false); + const recordings = ref([]); + const selectedRecording = ref(null); + const selectedTab = ref('summary'); + const searchQuery = ref(''); + const isLoadingRecordings = ref(true); + const globalError = ref(null); + + // Advanced filter state + const showAdvancedFilters = ref(false); + const filterTags = ref([]); + const filterSpeakers = ref([]); + const filterTagSearch = ref(''); + const filterSpeakerSearch = ref(''); + const filterDateRange = ref({ start: '', end: '' }); + const filterDatePreset = ref(''); + const filterTextQuery = ref(''); + const filterStarred = ref(false); + const filterInbox = ref(false); + const showArchivedRecordings = ref(false); + const showSharedWithMe = ref(false); + + // --- Pagination State --- + const currentPage = ref(1); + const perPage = ref(25); + const totalRecordings = ref(0); + const totalPages = ref(0); + const hasNextPage = ref(false); + const hasPrevPage = ref(false); + const isLoadingMore = ref(false); + const searchDebounceTimer = ref(null); + + // --- Enhanced Search & Organization State --- + const sortBy = ref('created_at'); + const selectedTagFilter = ref(null); + + // --- UI State --- + const browser = ref('unknown'); + const isSidebarCollapsed = ref(false); + const searchTipsExpanded = ref(false); + const isUserMenuOpen = ref(false); + const tokenBudget = ref({ + has_budget: false, + budget: null, + usage: 0, + percentage: 0 + }); + const isDarkMode = ref(false); + const currentColorScheme = ref('blue'); + const showColorSchemeModal = ref(false); + const windowWidth = ref(window.innerWidth); + const mobileTab = ref('transcript'); + const isMetadataExpanded = ref(false); + const expandedSection = ref('settings'); // 'notes' or 'settings' for recording view accordion + const showSortOptions = ref(false); + + // --- i18n State --- + const currentLanguage = ref('en'); + const currentLanguageName = ref('English'); + const availableLanguages = ref([]); + const showLanguageMenu = ref(false); + + // --- Upload State --- + const uploadQueue = ref([]); + const allJobs = ref([]); // Backend job queue (queued, processing, completed, failed) + const currentlyProcessingFile = ref(null); + const processingProgress = ref(0); + const processingMessage = ref(''); + const isProcessingActive = ref(false); + const pollInterval = ref(null); + const progressPopupMinimized = ref(false); + const progressPopupClosed = ref(false); + const maxFileSizeMB = ref(250); + const chunkingEnabled = ref(true); + const chunkingMode = ref('size'); + const chunkingLimit = ref(20); + const chunkingLimitDisplay = ref('20MB'); + const maxConcurrentUploads = ref(3); + const recordingDisclaimer = ref(''); + const showRecordingDisclaimerModal = ref(false); + const pendingRecordingMode = ref(null); + const uploadDisclaimer = ref(''); + const showUploadDisclaimerModal = ref(false); + const customBanner = ref(''); + const showBanner = ref(true); + + // --- Audio Recording State --- + const isRecording = ref(false); + const mediaRecorder = ref(null); + const audioChunks = ref([]); + const audioBlobURL = ref(null); + const recordingTime = ref(0); + const recordingInterval = ref(null); + const canRecordAudio = ref(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia); + const systemAudioSupported = ref(false); + const systemAudioError = ref(''); + const recordingNotes = ref(''); + const showSystemAudioHelp = ref(false); + const showSystemAudioHelpModal = ref(false); + const showRecoveryModal = ref(false); + const recoverableRecording = ref(null); + const asrLanguage = ref(''); + const asrMinSpeakers = ref(''); + const asrMaxSpeakers = ref(''); + const audioContext = ref(null); + const analyser = ref(null); + const micAnalyser = ref(null); + const systemAnalyser = ref(null); + const visualizer = ref(null); + const micVisualizer = ref(null); + const systemVisualizer = ref(null); + const animationFrameId = ref(null); + const recordingMode = ref('microphone'); + const activeStreams = ref([]); + + // --- Wake Lock and Background Recording --- + const wakeLock = ref(null); + const recordingNotification = ref(null); + const isPageVisible = ref(true); + + // --- PWA Features --- + const deferredInstallPrompt = ref(null); + const showInstallButton = ref(false); + const isPWAInstalled = ref(false); + const notificationPermission = ref('default'); + const pushSubscription = ref(null); + const appBadgeCount = ref(0); + const currentMediaMetadata = ref(null); + const isMediaSessionActive = ref(false); + + // --- Incognito Mode State --- + const enableIncognitoMode = ref(false); // Server config + const incognitoMode = ref(false); + const incognitoRecording = ref(null); + const incognitoProcessing = ref(false); + + // --- Bulk Selection State --- + const selectionMode = ref(false); + const selectedRecordingIds = ref(new Set()); + const bulkActionInProgress = ref(false); + + // --- Recording Size Monitoring --- + const estimatedFileSize = ref(0); + const fileSizeWarningShown = ref(false); + const recordingQuality = ref('optimized'); + const actualBitrate = ref(0); + const maxRecordingMB = ref(200); + const sizeCheckInterval = ref(null); + + // Advanced Options for ASR + const showAdvancedOptions = ref(false); + const uploadLanguage = ref(''); + const uploadMinSpeakers = ref(''); + const uploadMaxSpeakers = ref(''); + const uploadHotwords = ref(''); + const uploadInitialPrompt = ref(''); + + // Tag Selection + const availableTags = ref([]); + const selectedTagIds = ref([]); + const uploadTagSearchFilter = ref(''); + + // Folder Selection + const availableFolders = ref([]); + const selectedFolderId = ref(null); + const foldersEnabled = ref(false); + const filterFolder = ref(''); + + // --- Modal State --- + const showEditModal = ref(false); + const showDeleteModal = ref(false); + const showEditTagsModal = ref(false); + const selectedNewTagId = ref(''); + const tagSearchFilter = ref(''); + const showReprocessModal = ref(false); + const showResetModal = ref(false); + const showSpeakerModal = ref(false); + const speakerModalTab = ref('speakers'); // 'speakers' or 'transcript' for mobile view + const showShareModal = ref(false); + const showSharesListModal = ref(false); + const showTextEditorModal = ref(false); + const showAsrEditorModal = ref(false); + const editingRecording = ref(null); + const editingTranscriptionContent = ref(''); + const editingSegments = ref([]); + const availableSpeakers = ref([]); + const showEditSpeakersModal = ref(false); + const editingSpeakersList = ref([]); + const databaseSpeakers = ref([]); + const editingSpeakerSuggestions = ref({}); + const showEditParticipantsModal = ref(false); + const editingParticipantsList = ref([]); + const editingParticipantSuggestions = ref({}); + const allParticipants = ref([]); + const recordingToShare = ref(null); + const shareOptions = reactive({ + share_summary: true, + share_notes: true, + }); + const generatedShareLink = ref(''); + const existingShareDetected = ref(false); + const recordingPublicShares = ref([]); // All public shares for current recording + const isLoadingPublicShares = ref(false); + const userShares = ref([]); + const isLoadingShares = ref(false); + const copiedShareId = ref(null); + const shareToDelete = ref(null); + const showShareDeleteModal = ref(false); + const recordingToDelete = ref(null); + const recordingToReset = ref(null); + const reprocessType = ref(null); + const reprocessRecording = ref(null); + const isAutoIdentifying = ref(false); + const asrReprocessOptions = reactive({ + language: '', + min_speakers: null, + max_speakers: null + }); + const summaryReprocessPromptSource = ref('default'); + const summaryReprocessSelectedTagId = ref(''); + const summaryReprocessCustomPrompt = ref(''); + const speakerMap = ref({}); + const speakerColorMap = ref({}); // Stable mapping of speaker ID → color class + const modalSpeakers = ref([]); + const speakerDisplayMap = ref({}); + const regenerateSummaryAfterSpeakerUpdate = ref(true); + const speakerSuggestions = ref({}); + const loadingSuggestions = ref({}); + const activeSpeakerInput = ref(null); + const voiceSuggestions = ref({}); + const loadingVoiceSuggestions = ref(false); + + // --- DateTime Picker State --- + const showDateTimePicker = ref(false); + const pickerMonth = ref(new Date().getMonth()); + const pickerYear = ref(new Date().getFullYear()); + const pickerHour = ref(12); + const pickerMinute = ref(0); + const pickerAmPm = ref('PM'); + const pickerSelectedDate = ref(null); + const dateTimePickerTarget = ref(null); + const dateTimePickerCallback = ref(null); + + // --- Transcript Editing State --- + const editingSegmentIndex = ref(null); + const editingSpeakerIndex = ref(null); + const showEditTextModal = ref(false); + const editedText = ref(''); + const showAddSpeakerModal = ref(false); + const newSpeakerName = ref(''); + const newSpeakerIsMe = ref(false); + const newSpeakerSuggestions = ref([]); + const loadingNewSpeakerSuggestions = ref(false); + const showNewSpeakerSuggestions = ref(false); + const editedTranscriptData = ref(null); + + // --- Inline Editing State --- + const editingTitle = ref(false); + const originalTitle = ref(''); + const editingParticipants = ref(false); + const editingMeetingDate = ref(false); + const editingSummary = ref(false); + const editingNotes = ref(false); + const tempNotesContent = ref(''); + const tempSummaryContent = ref(''); + const autoSaveTimer = ref(null); + const autoSaveDelay = 2000; + + // --- Markdown Editor State --- + const notesMarkdownEditor = ref(null); + const markdownEditorInstance = ref(null); + const summaryMarkdownEditor = ref(null); + const summaryMarkdownEditorInstance = ref(null); + const recordingNotesEditor = ref(null); + const recordingMarkdownEditorInstance = ref(null); + + // --- Transcription State --- + const transcriptionViewMode = ref('simple'); + const legendExpanded = ref(false); + const highlightedSpeaker = ref(null); + const showDownloadMenu = ref(false); + const currentPlayingSegmentIndex = ref(null); + const followPlayerMode = ref(false); + const processingIndicatorMinimized = ref(false); + + // --- Chat State --- + const showChat = ref(false); + const isChatMaximized = ref(false); + const chatMessages = ref([]); + const chatInput = ref(''); + const isChatLoading = ref(false); + const chatMessagesRef = ref(null); + const chatInputRef = ref(null); + + // --- Audio Player State (Main Player) --- + const playerVolume = ref(1.0); + const audioIsPlaying = ref(false); + const audioCurrentTime = ref(0); + const audioDuration = ref(0); + const audioIsMuted = ref(false); + const audioIsLoading = ref(false); + const asrEditorAudio = ref(null); + const playbackRate = ref(1.0); + const showSpeedMenu = ref(false); + const playbackSpeeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; + const speedMenuPosition = ref({}); + const showVolumeSlider = ref(false); + const showModalVolumeSlider = ref(false); + const showDuplicatesModal = ref(false); + const videoCollapsed = ref(false); + const videoFullscreen = ref(false); + const fullscreenControlsVisible = ref(true); + const fullscreenControlsTimer = ref(null); + const duplicatesModalData = ref(null); + + // --- Modal Audio Player State (Independent from main) --- + const modalAudioCurrentTime = ref(0); + const modalAudioDuration = ref(0); + const modalAudioIsPlaying = ref(false); + const modalPlaybackRate = ref(1.0); + + // --- Column Resizing State --- + const leftColumnWidth = ref(60); + const rightColumnWidth = ref(40); + const isResizing = ref(false); + + // --- Dropdown Positioning --- + const dropdownPositions = ref({}); + // Single-ref dropdown tracking for ASR editor (performance optimization) + const openAsrDropdownIndex = ref(null); + + // --- App Configuration --- + const useAsrEndpoint = ref(false); + const connectorSupportsDiarization = ref(false); // Connector capability for diarization UI + const connectorSupportsSpeakerCount = ref(false); // Connector capability for min/max speakers + const currentUserName = ref(''); + const canDeleteRecordings = ref(true); + const enableInternalSharing = ref(false); + const enableArchiveToggle = ref(false); + const showUsernamesInUI = ref(false); + + // --- Internal Sharing State --- + const showUnifiedShareModal = ref(false); + const internalShareUserSearch = ref(''); + const internalShareSearchResults = ref([]); + const internalShareRecording = ref(null); + const internalSharePermissions = ref({ can_edit: false, can_reshare: false }); + const internalShareMaxPermissions = ref({ can_edit: true, can_reshare: true }); // Permission ceiling for current user + const recordingInternalShares = ref([]); + const isLoadingInternalShares = ref(false); + const isSearchingUsers = ref(false); + const allUsers = ref([]); + const isLoadingAllUsers = ref(false); + + // --- Reprocessing Polls --- + const reprocessingPolls = ref(new Map()); + + // --- Speaker Groups State --- + const currentSpeakerGroupIndex = ref(0); + const speakerGroups = ref([]); + + // --- Virtual Scroll Container Refs --- + const speakerModalTranscriptRef = ref(null); + const mainTranscriptRef = ref(null); + const asrEditorRef = ref(null); + + // --- Computed properties needed by composables --- + const isMobileScreen = computed(() => windowWidth.value < 1024); + const isMobileDevice = computed(() => { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + ('ontouchstart' in window) || + (navigator.maxTouchPoints > 0); + }); + + const colorSchemes = { + light: [ + { id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', class: '' }, + { id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', class: 'theme-light-emerald' }, + { id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', class: 'theme-light-purple' }, + { id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', class: 'theme-light-rose' }, + { id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', class: 'theme-light-amber' }, + { id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', class: 'theme-light-teal' } + ], + dark: [ + { id: 'blue', name: 'Midnight Blue', description: 'Deep blue theme for focused work', class: '' }, + { id: 'emerald', name: 'Dark Forest', description: 'Rich green theme for comfortable viewing', class: 'theme-dark-emerald' }, + { id: 'purple', name: 'Deep Purple', description: 'Mysterious purple theme for creativity', class: 'theme-dark-purple' }, + { id: 'rose', name: 'Dark Rose', description: 'Muted pink theme with subtle warmth', class: 'theme-dark-rose' }, + { id: 'amber', name: 'Dark Amber', description: 'Warm brown theme for cozy sessions', class: 'theme-dark-amber' }, + { id: 'teal', name: 'Deep Teal', description: 'Dark teal theme for calm focus', class: 'theme-dark-teal' } + ] + }; + + // ========================================================================= + // COLLECT ALL STATE INTO SINGLE OBJECT FOR COMPOSABLES + // ========================================================================= + const state = { + // Core + currentView, dragover, recordings, selectedRecording, selectedTab, searchQuery, + isLoadingRecordings, globalError, csrfToken, + + // Filters + showAdvancedFilters, filterTags, filterSpeakers, filterTagSearch, filterSpeakerSearch, + filterDateRange, filterDatePreset, filterTextQuery, filterStarred, filterInbox, + showArchivedRecordings, showSharedWithMe, sortBy, selectedTagFilter, + + // Pagination + currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage, + isLoadingMore, searchDebounceTimer, + + // UI + browser, isSidebarCollapsed, searchTipsExpanded, isUserMenuOpen, tokenBudget, isDarkMode, + currentColorScheme, showColorSchemeModal, windowWidth, mobileTab, isMetadataExpanded, expandedSection, + showSortOptions, currentLanguage, currentLanguageName, availableLanguages, showLanguageMenu, + colorSchemes, isMobileScreen, isMobileDevice, + + // Upload + uploadQueue, allJobs, currentlyProcessingFile, processingProgress, processingMessage, + isProcessingActive, pollInterval, progressPopupMinimized, progressPopupClosed, + maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, chunkingLimitDisplay, + maxConcurrentUploads, recordingDisclaimer, showRecordingDisclaimerModal, pendingRecordingMode, + uploadDisclaimer, showUploadDisclaimerModal, + customBanner, showBanner, + showAdvancedOptions, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt, + availableTags, selectedTagIds, uploadTagSearchFilter, + availableFolders, selectedFolderId, foldersEnabled, filterFolder, + + // Audio Recording + isRecording, mediaRecorder, audioChunks, audioBlobURL, recordingTime, recordingInterval, + canRecordAudio, canRecordSystemAudio, systemAudioSupported, systemAudioError, + recordingNotes, showSystemAudioHelp, showSystemAudioHelpModal, asrLanguage, asrMinSpeakers, asrMaxSpeakers, + audioContext, analyser, micAnalyser, systemAnalyser, visualizer, micVisualizer, + systemVisualizer, animationFrameId, recordingMode, activeStreams, + wakeLock, recordingNotification, isPageVisible, + estimatedFileSize, fileSizeWarningShown, recordingQuality, actualBitrate, + maxRecordingMB, sizeCheckInterval, + + // PWA Features + deferredInstallPrompt, showInstallButton, isPWAInstalled, + notificationPermission, pushSubscription, appBadgeCount, + currentMediaMetadata, isMediaSessionActive, + + // Incognito Mode + enableIncognitoMode, incognitoMode, incognitoRecording, incognitoProcessing, + + // Bulk Selection + selectionMode, selectedRecordingIds, bulkActionInProgress, + + // Modals + showEditModal, showDeleteModal, showEditTagsModal, selectedNewTagId, tagSearchFilter, + showReprocessModal, showResetModal, showSpeakerModal, speakerModalTab, showShareModal, showSharesListModal, + showTextEditorModal, showAsrEditorModal, editingRecording, editingTranscriptionContent, + editingSegments, availableSpeakers, showEditSpeakersModal, editingSpeakersList, + databaseSpeakers, editingSpeakerSuggestions, + showEditParticipantsModal, editingParticipantsList, editingParticipantSuggestions, allParticipants, + recordingToShare, shareOptions, + generatedShareLink, existingShareDetected, recordingPublicShares, isLoadingPublicShares, + userShares, isLoadingShares, copiedShareId, + shareToDelete, showShareDeleteModal, recordingToDelete, recordingToReset, + reprocessType, reprocessRecording, isAutoIdentifying, asrReprocessOptions, + summaryReprocessPromptSource, summaryReprocessSelectedTagId, summaryReprocessCustomPrompt, + speakerMap, speakerColorMap, modalSpeakers, speakerDisplayMap, regenerateSummaryAfterSpeakerUpdate, speakerSuggestions, + loadingSuggestions, activeSpeakerInput, voiceSuggestions, loadingVoiceSuggestions, + + // DateTime Picker + showDateTimePicker, pickerMonth, pickerYear, pickerHour, pickerMinute, + pickerAmPm, pickerSelectedDate, dateTimePickerTarget, dateTimePickerCallback, + + // Transcript Editing + editingSegmentIndex, editingSpeakerIndex, showEditTextModal, editedText, + showAddSpeakerModal, newSpeakerName, newSpeakerIsMe, newSpeakerSuggestions, + loadingNewSpeakerSuggestions, showNewSpeakerSuggestions, editedTranscriptData, + + // Inline Editing + editingTitle, originalTitle, + editingParticipants, editingMeetingDate, editingSummary, editingNotes, + tempNotesContent, tempSummaryContent, autoSaveTimer, autoSaveDelay, + + // Markdown + notesMarkdownEditor, markdownEditorInstance, summaryMarkdownEditor, + summaryMarkdownEditorInstance, recordingNotesEditor, recordingMarkdownEditorInstance, + + // Transcription + transcriptionViewMode, legendExpanded, highlightedSpeaker, showDownloadMenu, + currentPlayingSegmentIndex, followPlayerMode, processingIndicatorMinimized, + + // Chat + showChat, isChatMaximized, chatMessages, chatInput, isChatLoading, chatMessagesRef, chatInputRef, + + // Audio Player + playerVolume, audioIsPlaying, audioCurrentTime, audioDuration, audioIsMuted, audioIsLoading, asrEditorAudio, + modalAudioCurrentTime, modalAudioDuration, modalAudioIsPlaying, modalPlaybackRate, + playbackRate, showSpeedMenu, playbackSpeeds, speedMenuPosition, showVolumeSlider, showModalVolumeSlider, + videoFullscreen, fullscreenControlsVisible, fullscreenControlsTimer, videoCollapsed, + + // Column Resizing + leftColumnWidth, rightColumnWidth, isResizing, + + // Dropdown Positioning + dropdownPositions, + openAsrDropdownIndex, + + // App Config + useAsrEndpoint, connectorSupportsDiarization, connectorSupportsSpeakerCount, currentUserName, canDeleteRecordings, enableInternalSharing, enableArchiveToggle, showUsernamesInUI, + + // Internal Sharing + showUnifiedShareModal, internalShareUserSearch, internalShareSearchResults, + internalShareRecording, internalSharePermissions, internalShareMaxPermissions, recordingInternalShares, + isLoadingInternalShares, isSearchingUsers, allUsers, isLoadingAllUsers, + + // Reprocessing + reprocessingPolls, + + // Speaker Groups + currentSpeakerGroupIndex, speakerGroups, + + // Virtual Scroll + speakerModalTranscriptRef, mainTranscriptRef, asrEditorRef + }; + + // ========================================================================= + // TRANSLATION FUNCTION + // ========================================================================= + const t = safeT; + const tc = (key, count, params = {}) => { + if (!window.i18n || !window.i18n.tc) { + return key; + } + return window.i18n.tc(key, count, params); + }; + + // ========================================================================= + // UTILITY FUNCTIONS + // ========================================================================= + // showToast is now imported from modules/utils/toast.js + + const setGlobalError = (message, duration = 5000) => { + // Use toast system for all errors instead of the old global error banner + showToast(message, 'fa-exclamation-circle', duration, 'error'); + }; + + const loadTokenBudget = async () => { + try { + const response = await fetch('/api/user/token-budget'); + if (response.ok) { + tokenBudget.value = await response.json(); + } + } catch (error) { + console.error('Error loading token budget:', error); + } + }; + + // Helper function to calculate global segment index in bubble view + const getBubbleGlobalIndex = (rowIndex, bubbleIndex) => { + if (!processedTranscription.value.bubbleRows) return 0; + + let globalIndex = 0; + for (let i = 0; i < rowIndex; i++) { + globalIndex += processedTranscription.value.bubbleRows[i].bubbles.length; + } + globalIndex += bubbleIndex; + return globalIndex; + }; + + // Modal audio handlers (independent from main player) + const handleModalAudioTimeUpdate = (event) => { + modalAudioCurrentTime.value = event.target.currentTime; + }; + const handleModalAudioLoadedMetadata = (event) => { + const duration = event.target.duration; + if (duration && isFinite(duration) && duration > 0) { + modalAudioDuration.value = duration; + } + }; + const handleModalAudioPlayPause = (event) => { + modalAudioIsPlaying.value = !event.target.paused; + }; + const modalAudioProgressPercent = computed(() => { + if (!modalAudioDuration.value) return 0; + return (modalAudioCurrentTime.value / modalAudioDuration.value) * 100; + }); + const resetModalAudioState = () => { + modalAudioCurrentTime.value = 0; + modalAudioDuration.value = 0; + modalAudioIsPlaying.value = false; + }; + + const formatFileSize = (bytes) => { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatDisplayDate = (dateString) => { + if (!dateString) return ''; + try { + let date = new Date(dateString); + if (isNaN(date.getTime())) { + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const [year, month, day] = dateString.split('-').map(Number); + date = new Date(year, month - 1, day); + } else { + return dateString; + } + } + if (isNaN(date.getTime())) { + return dateString; + } + return date.toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } catch (e) { + return dateString; + } + }; + + const formatShortDate = (dateString) => { + if (!dateString) return ''; + try { + let date = new Date(dateString); + if (isNaN(date.getTime())) { + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const [year, month, day] = dateString.split('-').map(Number); + date = new Date(year, month - 1, day); + } + } + if (isNaN(date.getTime())) { + return dateString; + } + const now = new Date(); + const isCurrentYear = date.getFullYear() === now.getFullYear(); + if (isCurrentYear) { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch (e) { + return dateString; + } + }; + + const formatStatus = (status) => { + const statusMap = { + 'PENDING': t('status.pending'), + 'PROCESSING': t('status.processing'), + 'SUMMARIZING': t('status.summarizing'), + 'COMPLETED': t('status.completed'), + 'FAILED': t('status.failed') + }; + return statusMap[status] || status; + }; + + const getStatusClass = (status) => { + switch(status) { + case 'COMPLETED': return 'status-completed'; + case 'PROCESSING': return 'status-processing'; + case 'SUMMARIZING': return 'status-summarizing'; + case 'PENDING': return 'status-pending'; + case 'FAILED': return 'status-failed'; + default: return ''; + } + }; + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const formatDuration = (totalSeconds) => { + if (!totalSeconds && totalSeconds !== 0) return ''; + totalSeconds = Math.round(totalSeconds); + if (totalSeconds < 1) { + return '< 1s'; + } + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (totalSeconds < 60) { + return `${seconds}s`; + } + let parts = []; + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (hours === 0 && seconds > 0) { + parts.push(`${seconds}s`); + } + return parts.join(' '); + }; + + const formatEventDateTime = (dateString, timeOnly = false) => { + if (!dateString) return ''; + const date = new Date(dateString); + if (timeOnly) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleString([], { + weekday: 'short', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + }; + + // Date helper functions + const getDateForSorting = (recording) => { + const dateStr = sortBy.value === 'meeting_date' + ? (recording.meeting_date || recording.created_at) + : recording.created_at; + return dateStr ? new Date(dateStr) : null; + }; + + const isToday = (date) => { + const today = new Date(); + return date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear(); + }; + + const isYesterday = (date) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return date.getDate() === yesterday.getDate() && + date.getMonth() === yesterday.getMonth() && + date.getFullYear() === yesterday.getFullYear(); + }; + + const isThisWeek = (date) => { + const now = new Date(); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay()); + startOfWeek.setHours(0, 0, 0, 0); + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 7); + return date >= startOfWeek && date < endOfWeek && !isToday(date) && !isYesterday(date); + }; + + const isLastWeek = (date) => { + const now = new Date(); + const startOfLastWeek = new Date(now); + startOfLastWeek.setDate(now.getDate() - now.getDay() - 7); + startOfLastWeek.setHours(0, 0, 0, 0); + const endOfLastWeek = new Date(startOfLastWeek); + endOfLastWeek.setDate(startOfLastWeek.getDate() + 7); + return date >= startOfLastWeek && date < endOfLastWeek; + }; + + const isThisMonth = (date) => { + const now = new Date(); + return date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear() && + !isToday(date) && !isYesterday(date) && !isThisWeek(date) && !isLastWeek(date); + }; + + const isLastMonth = (date) => { + const now = new Date(); + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return date.getMonth() === lastMonth.getMonth() && + date.getFullYear() === lastMonth.getFullYear(); + }; + + const isSameDay = (date1, date2) => { + return date1.getDate() === date2.getDate() && + date1.getMonth() === date2.getMonth() && + date1.getFullYear() === date2.getFullYear(); + }; + + // Bundle utilities for composables + const utils = { + t, tc, setGlobalError, showToast, formatFileSize, formatDisplayDate, formatShortDate, + formatStatus, getStatusClass, formatTime, formatDuration, formatEventDateTime, + getDateForSorting, isToday, isYesterday, isThisWeek, isLastWeek, isThisMonth, isLastMonth, isSameDay, + nextTick, + onChatComplete: loadTokenBudget // Refresh token budget after chat + }; + + // ========================================================================= + // COMPUTED PROPERTIES (define before composables that need them) + // ========================================================================= + const processedTranscription = computed(() => { + if (!selectedRecording.value?.transcription) { + return { hasDialogue: false, content: '', speakers: [], simpleSegments: [], bubbleRows: [], isError: false }; + } + + const transcription = selectedRecording.value.transcription; + + // Check for error message format + const errorInfo = parseTranscriptionError(transcription); + if (errorInfo) { + return { + hasDialogue: false, + isJson: false, + isError: true, + error: errorInfo, + content: '', + speakers: [], + simpleSegments: [], + bubbleRows: [] + }; + } + + let transcriptionData; + + try { + transcriptionData = JSON.parse(transcription); + } catch (e) { + transcriptionData = null; + } + + // Handle new simplified JSON format (array of segments) + if (transcriptionData && Array.isArray(transcriptionData)) { + const wasDiarized = transcriptionData.some(segment => segment.speaker); + + if (!wasDiarized) { + const segments = transcriptionData.map(segment => ({ + sentence: segment.sentence, + startTime: segment.start_time, + })); + return { + hasDialogue: false, + isJson: true, + content: segments.map(s => s.sentence).join('\n'), + simpleSegments: segments, + speakers: [], + bubbleRows: [] + }; + } + + // Extract unique speakers in order of first appearance + const speakers = [...new Set(transcriptionData.map(segment => segment.speaker).filter(Boolean))]; + + // Build stable color map: assign colors 1, 2, 3... based on order of first appearance + // This map is stored and reused - colors never change once assigned + const speakerColors = {}; + speakers.forEach((speaker, index) => { + // Use existing color if already mapped, otherwise assign next color + if (speakerColorMap.value[speaker]) { + speakerColors[speaker] = speakerColorMap.value[speaker]; + } else { + const colorIndex = Object.keys(speakerColorMap.value).length; + speakerColors[speaker] = `speaker-color-${(colorIndex % SPEAKER_COLOR_COUNT) + 1}`; + speakerColorMap.value[speaker] = speakerColors[speaker]; + } + }); + + const simpleSegments = transcriptionData.map(segment => ({ + speakerId: segment.speaker, + speaker: speakerMap.value[segment.speaker]?.name || segment.speaker, + sentence: segment.sentence, + startTime: segment.start_time || segment.startTime, + endTime: segment.end_time || segment.endTime, + color: speakerColors[segment.speaker] || 'speaker-color-1' + })); + + const processedSimpleSegments = []; + let lastSpeakerId = null; + simpleSegments.forEach(segment => { + processedSimpleSegments.push({ + ...segment, + showSpeaker: segment.speakerId !== lastSpeakerId + }); + lastSpeakerId = segment.speakerId; + }); + + const bubbleRows = []; + let lastBubbleSpeakerId = null; + simpleSegments.forEach(segment => { + if (bubbleRows.length === 0 || segment.speakerId !== lastBubbleSpeakerId) { + bubbleRows.push({ + speaker: segment.speaker, + color: segment.color, + isMe: segment.speaker && (typeof segment.speaker === 'string') && segment.speaker.toLowerCase().includes('me'), + bubbles: [] + }); + lastBubbleSpeakerId = segment.speakerId; + } + bubbleRows[bubbleRows.length - 1].bubbles.push({ + sentence: segment.sentence, + startTime: segment.startTime || segment.start_time, + color: segment.color + }); + }); + + return { + hasDialogue: true, + isJson: true, + segments: simpleSegments, + simpleSegments: processedSimpleSegments, + bubbleRows: bubbleRows, + speakers: speakers.map(speaker => ({ + name: speakerMap.value[speaker]?.name || speaker, + color: speakerColors[speaker] + })) + }; + + } else { + // Fallback for plain text transcription + const speakerRegex = /\[([^\]]+)\]:\s*/g; + const hasDialogue = speakerRegex.test(transcription); + + if (!hasDialogue) { + return { + hasDialogue: false, + isJson: false, + content: transcription, + speakers: [], + simpleSegments: [], + bubbleRows: [] + }; + } + + speakerRegex.lastIndex = 0; + const speakers = new Set(); + let match; + while ((match = speakerRegex.exec(transcription)) !== null) { + speakers.add(match[1]); + } + + const speakerList = Array.from(speakers); + const speakerColors = {}; + speakerList.forEach((speaker) => { + // Use existing color if already mapped, otherwise assign next color + if (speakerColorMap.value[speaker]) { + speakerColors[speaker] = speakerColorMap.value[speaker]; + } else { + const colorIndex = Object.keys(speakerColorMap.value).length; + speakerColors[speaker] = `speaker-color-${(colorIndex % SPEAKER_COLOR_COUNT) + 1}`; + speakerColorMap.value[speaker] = speakerColors[speaker]; + } + }); + + const segments = []; + const lines = transcription.split('\n'); + let currentSpeakerId = null; + let currentText = ''; + + for (const line of lines) { + const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/); + if (speakerMatch) { + if (currentSpeakerId && currentText.trim()) { + segments.push({ + speakerId: currentSpeakerId, + speaker: speakerMap.value[currentSpeakerId]?.name || currentSpeakerId, + sentence: currentText.trim(), + color: speakerColors[currentSpeakerId] || 'speaker-color-1' + }); + } + currentSpeakerId = speakerMatch[1]; + currentText = speakerMatch[2]; + } else if (currentSpeakerId && line.trim()) { + currentText += ' ' + line.trim(); + } else if (!currentSpeakerId && line.trim()) { + segments.push({ + speakerId: null, + speaker: null, + sentence: line.trim(), + color: 'speaker-color-1' + }); + } + } + + if (currentSpeakerId && currentText.trim()) { + segments.push({ + speakerId: currentSpeakerId, + speaker: speakerMap.value[currentSpeakerId]?.name || currentSpeakerId, + sentence: currentText.trim(), + color: speakerColors[currentSpeakerId] || 'speaker-color-1' + }); + } + + const simpleSegments = []; + let lastSpeakerId = null; + segments.forEach(segment => { + simpleSegments.push({ + ...segment, + showSpeaker: segment.speakerId !== lastSpeakerId, + sentence: segment.sentence || segment.text + }); + lastSpeakerId = segment.speakerId; + }); + + const bubbleRows = []; + let currentRow = null; + segments.forEach(segment => { + if (!currentRow || currentRow.speakerId !== segment.speakerId) { + if (currentRow) bubbleRows.push(currentRow); + currentRow = { + speakerId: segment.speakerId, + speaker: segment.speaker, + color: segment.color, + bubbles: [], + isMe: segment.speaker && segment.speaker.toLowerCase().includes('me') + }; + } + currentRow.bubbles.push({ + sentence: segment.sentence, + color: segment.color + }); + }); + if (currentRow) bubbleRows.push(currentRow); + + return { + hasDialogue: true, + isJson: false, + segments: segments, + simpleSegments: simpleSegments, + bubbleRows: bubbleRows, + speakers: speakerList.map(speaker => ({ + name: speakerMap.value[speaker]?.name || speaker, + color: speakerColors[speaker] || 'speaker-color-1' + })) + }; + } + }); + + // Subtitle computed for fullscreen video overlay + const currentSubtitle = computed(() => { + const idx = currentPlayingSegmentIndex.value; + if (idx === null) return null; + const t = processedTranscription.value; + if (!t?.simpleSegments?.[idx]) return null; + const seg = t.simpleSegments[idx]; + return { + text: seg.sentence, + speaker: t.hasDialogue ? seg.speaker : null, + color: seg.color + }; + }); + + // ========================================================================= + // INITIALIZE COMPOSABLES (after processedTranscription is defined) + // ========================================================================= + // Create reprocess composable first so it can be passed to recordings + const reprocessComposable = useReprocess(state, utils); + const recordingsComposable = useRecordings(state, utils, reprocessComposable); + const uploadComposable = useUpload(state, utils); + + // Upload disclaimer handlers + const acceptUploadDisclaimer = () => { + showUploadDisclaimerModal.value = false; + // Temporarily clear disclaimer to prevent re-trigger, then call startUpload + const saved = uploadDisclaimer.value; + uploadDisclaimer.value = ''; + uploadComposable.startUpload(); + uploadDisclaimer.value = saved; + }; + + const cancelUploadDisclaimer = () => { + showUploadDisclaimerModal.value = false; + }; + + // Add startUpload to utils for audio composable to use + utils.startUploadQueue = uploadComposable.startUpload; + + const audioComposable = useAudio(state, utils); + const uiComposable = useUI(state, utils, processedTranscription); + const modalsComposable = useModals(state, utils); + const sharingComposable = useSharing(state, utils); + const transcriptionComposable = useTranscription(state, utils); + const chatComposable = useChat(state, utils); + const pwaComposable = usePWA(state, utils); + const tagsComposable = useTags({ + recordings, + availableTags, + selectedRecording, + showEditTagsModal, + editingRecording, + tagSearchFilter, + showToast, + setGlobalError + }); + + // Folders composable + const foldersComposable = useFolders({ + recordings, + availableFolders, + selectedRecording, + showToast, + setGlobalError + }); + + // Bulk selection composable + const bulkSelectionComposable = useBulkSelection({ + selectionMode, + selectedRecordingIds, + recordings, + selectedRecording, + currentView + }); + + // Bulk operations composable (needs selection composable methods) + const bulkOperationsComposable = useBulkOperations({ + selectedRecordingIds, + selectedRecordings: bulkSelectionComposable.selectedRecordings, + recordings, + selectedRecording, + bulkActionInProgress, + availableTags, + availableFolders, + showToast, + setGlobalError, + exitSelectionMode: bulkSelectionComposable.exitSelectionMode, + startReprocessingPoll: reprocessComposable.startReprocessingPoll + }); + + // ========================================================================= + // VIRTUAL SCROLL SETUP (for performance with long transcriptions) + // Must be before speakers composable since it uses scrollToSegmentIndex + // ========================================================================= + // Create a computed ref for the segments array + const transcriptSegments = computed(() => processedTranscription.value.simpleSegments || []); + + // Virtual scroll for speaker modal transcript (main performance bottleneck) + const speakerModalVirtualScroll = useVirtualScroll({ + items: transcriptSegments, + itemHeight: 52, // Approximate height of each segment row + containerRef: speakerModalTranscriptRef, + overscan: 8 + }); + + // Virtual scroll for main transcription panel + const mainTranscriptVirtualScroll = useVirtualScroll({ + items: transcriptSegments, + itemHeight: 48, + containerRef: mainTranscriptRef, + overscan: 5 + }); + + // Virtual scroll for ASR editor modal (uses editingSegments) + const asrEditorVirtualScroll = useVirtualScroll({ + items: editingSegments, + itemHeight: 44, // Table row height + containerRef: asrEditorRef, + overscan: 10 + }); + + // Helper to scroll to a segment by index (for speaker navigation) + const scrollToSegmentIndex = (index) => { + if (showSpeakerModal.value) { + speakerModalVirtualScroll.scrollToIndex(index, 'smooth'); + } else { + mainTranscriptVirtualScroll.scrollToIndex(index, 'smooth'); + } + }; + + // Add scrollToSegmentIndex to utils for composables that need it + utils.scrollToSegmentIndex = scrollToSegmentIndex; + utils.resetModalAudioState = resetModalAudioState; + utils.resetAsrEditorScroll = () => asrEditorVirtualScroll.reset(); + utils.resetSpeakerModalScroll = () => speakerModalVirtualScroll.reset(); + utils.getSpeakerModalVisibleRange = () => speakerModalVirtualScroll.visibleRange.value; + + // Speakers composable needs processedTranscription and scrollToSegmentIndex + const speakersComposable = useSpeakers(state, utils, processedTranscription); + + const groupedRecordings = computed(() => { + const groups = {}; + const groupDates = {}; // Track the most recent date in each group + + recordings.value.forEach(recording => { + const date = getDateForSorting(recording); + if (!date) return; + + let group; + const now = new Date(); + + // Check for future dates first + if (date > now && !isToday(date)) { + group = t('sidebar.upcoming'); + } else if (isToday(date)) { + group = t('sidebar.today'); + } else if (isYesterday(date)) { + group = t('sidebar.yesterday'); + } else if (isThisWeek(date)) { + group = t('sidebar.thisWeek'); + } else if (isLastWeek(date)) { + group = t('sidebar.lastWeek'); + } else if (isThisMonth(date)) { + group = t('sidebar.thisMonth'); + } else if (isLastMonth(date)) { + group = t('sidebar.lastMonth'); + } else { + group = t('sidebar.older'); + } + + if (!groups[group]) { + groups[group] = []; + groupDates[group] = date; + } + groups[group].push(recording); + + // Track the most recent (largest) date in each group + if (date > groupDates[group]) { + groupDates[group] = date; + } + }); + + // Sort groups by their most recent date (descending - newest first) + return Object.entries(groups) + .sort(([a], [b]) => groupDates[b] - groupDates[a]) + .map(([title, items]) => ({ title, items })); + }); + + const filteredAvailableTags = computed(() => { + return availableTags.value.filter(tag => + !selectedTagIds.value.includes(tag.id) && + (!tagSearchFilter.value || tag.name.toLowerCase().includes(tagSearchFilter.value.toLowerCase())) + ); + }); + + // Filtered tags for sidebar filter (searches by name) + const filteredTagsForFilter = computed(() => { + if (!filterTagSearch.value) return availableTags.value; + const search = filterTagSearch.value.toLowerCase(); + return availableTags.value.filter(tag => + tag.name.toLowerCase().includes(search) + ); + }); + + // Filtered speakers for sidebar filter (searches by name) + const filteredSpeakersForFilter = computed(() => { + if (!filterSpeakerSearch.value) return availableSpeakers.value; + const search = filterSpeakerSearch.value.toLowerCase(); + return availableSpeakers.value.filter(speaker => + speaker.name.toLowerCase().includes(search) + ); + }); + + const selectedTags = computed(() => { + return selectedTagIds.value.map(id => + availableTags.value.find(t => t.id === id) + ).filter(Boolean); + }); + + const toasts = ref([]); + + // Date preset options for filters + const datePresetOptions = computed(() => { + return [ + { value: 'today', label: t('sidebar.today') }, + { value: 'yesterday', label: t('sidebar.yesterday') }, + { value: 'thisweek', label: t('sidebar.thisWeek') }, + { value: 'lastweek', label: t('sidebar.lastWeek') }, + { value: 'thismonth', label: t('sidebar.thisMonth') }, + { value: 'lastmonth', label: t('sidebar.lastMonth') } + ]; + }); + + // Language options for ASR + const languageOptions = computed(() => { + return [ + { value: '', label: t('form.autoDetect') }, + { value: 'en', label: t('languages.en') }, + { value: 'es', label: t('languages.es') }, + { value: 'fr', label: t('languages.fr') }, + { value: 'de', label: t('languages.de') }, + { value: 'it', label: t('languages.it') }, + { value: 'pt', label: t('languages.pt') }, + { value: 'nl', label: t('languages.nl') }, + { value: 'ru', label: t('languages.ru') }, + { value: 'zh', label: t('languages.zh') }, + { value: 'ja', label: t('languages.ja') }, + { value: 'ko', label: t('languages.ko') } + ]; + }); + + // Recording metadata for sidebar + const activeRecordingMetadata = computed(() => { + if (!selectedRecording.value) return []; + + const recording = selectedRecording.value; + const metadata = []; + + if (recording.created_at) { + // Format duration in human-readable format (e.g., "2m 30s") + const formatProcessingDuration = (seconds) => { + if (!seconds && seconds !== 0) return null; + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + }; + + // Build tooltip with processing breakdown + let tooltipParts = [`Processed: ${formatDisplayDate(recording.completed_at || recording.created_at)}`]; + + if (recording.transcription_duration_seconds) { + tooltipParts.push(`Transcription: ${formatProcessingDuration(recording.transcription_duration_seconds)}`); + } + if (recording.summarization_duration_seconds) { + tooltipParts.push(`Summarization: ${formatProcessingDuration(recording.summarization_duration_seconds)}`); + } + + const tooltipText = tooltipParts.length > 1 ? tooltipParts.join('\n') : null; + + metadata.push({ + icon: 'fas fa-history', + text: formatDisplayDate(recording.created_at), + fullText: tooltipText + }); + } + + if (recording.file_size) { + metadata.push({ + icon: 'fas fa-file-audio', + text: formatFileSize(recording.file_size) + }); + } + + if (recording.duration) { + metadata.push({ + icon: 'fas fa-clock', + text: formatDuration(recording.duration) + }); + } + + if (recording.original_filename) { + const maxLength = 30; + const truncated = recording.original_filename.length > maxLength + ? recording.original_filename.substring(0, maxLength) + '...' + : recording.original_filename; + metadata.push({ + icon: 'fas fa-file', + text: truncated, + fullText: recording.original_filename + }); + } + + return metadata; + }); + + // Upload queue computed properties + const totalInQueue = computed(() => uploadQueue.value.length); + const completedInQueue = computed(() => uploadQueue.value.filter(item => item.status === 'completed' || item.status === 'failed').length); + // Filter out upload completions that already have a backend job (to avoid duplicates) + const finishedFilesInQueue = computed(() => { + const backendRecordingIds = new Set(allJobs.value.map(j => j.recording_id)); + return uploadQueue.value.filter(item => + ['completed', 'failed'].includes(item.status) && + !backendRecordingIds.has(item.recordingId) + ); + }); + const waitingFilesInQueue = computed(() => uploadQueue.value.filter(item => item.status === 'ready')); + const pendingQueueFiles = computed(() => uploadQueue.value.filter(item => item.status === 'queued')); + + // Backend processing queue - recordings being processed on the server + const backendProcessingRecordings = computed(() => { + return recordings.value.filter(r => ['PENDING', 'PROCESSING', 'SUMMARIZING', 'QUEUED'].includes(r.status)); + }); + + // Job queue polling state + let jobQueuePollInterval = null; + let lastJobQueueFetch = 0; // Timestamp of last fetch + const JOB_QUEUE_POLL_INTERVAL = 5000; // Poll every 5 seconds when active + const JOB_QUEUE_FETCH_DEBOUNCE = 2000; // Minimum 2 seconds between fetches + + // Computed properties for different job states + const activeJobs = computed(() => allJobs.value.filter(j => ['queued', 'processing'].includes(j.job_status))); + const completedJobs = computed(() => allJobs.value.filter(j => j.job_status === 'completed')); + const failedJobs = computed(() => allJobs.value.filter(j => j.job_status === 'failed')); + + // Job queue details map (for backward compatibility with progress popup) + const jobQueueDetails = computed(() => { + const detailsMap = {}; + for (const job of allJobs.value) { + // Use recording_id as key, store the most relevant job (prefer active over completed) + if (!detailsMap[job.recording_id] || ['queued', 'processing'].includes(job.job_status)) { + detailsMap[job.recording_id] = job; + } + } + return detailsMap; + }); + + // Fetch job queue status from backend (with debounce protection) + const fetchJobQueueStatus = async (force = false) => { + const now = Date.now(); + // Debounce: skip if fetched recently (unless forced) + if (!force && (now - lastJobQueueFetch) < JOB_QUEUE_FETCH_DEBOUNCE) { + return; + } + lastJobQueueFetch = now; + + try { + const response = await fetch('/api/recordings/job-queue-status'); + if (response.ok) { + const data = await response.json(); + allJobs.value = data.jobs || []; + } else if (response.status === 429) { + console.warn('Job queue polling rate limited'); + } + } catch (error) { + console.error('Error fetching job queue status:', error); + } + }; + + // Start polling job queue status + const startJobQueuePolling = () => { + if (jobQueuePollInterval) return; + fetchJobQueueStatus(true); // Fetch immediately (forced) + jobQueuePollInterval = setInterval(() => fetchJobQueueStatus(true), JOB_QUEUE_POLL_INTERVAL); + }; + + const stopJobQueuePolling = () => { + if (jobQueuePollInterval) { + clearInterval(jobQueuePollInterval); + jobQueuePollInterval = null; + } + }; + + // Check if we have active items that need polling + const hasActiveProcessing = computed(() => { + const completedStatuses = ['completed', 'failed', 'COMPLETED', 'FAILED']; + const hasActiveUploads = uploadQueue.value.some(item => + !completedStatuses.includes(item.status) + ); + const hasActiveJobs = activeJobs.value.length > 0; + const hasProcessingRecordings = backendProcessingRecordings.value.length > 0; + return hasActiveUploads || hasActiveJobs || hasProcessingRecordings; + }); + + // Start/stop polling based on whether we have active items + watch(hasActiveProcessing, (hasActive) => { + if (hasActive) { + startJobQueuePolling(); + } else { + // Stop polling after a delay (to catch final status updates) + setTimeout(() => { + if (!hasActiveProcessing.value) { + stopJobQueuePolling(); + } + }, 10000); + } + }, { immediate: true }); + + // When popup opens, do a one-time fetch to populate it + watch(() => progressPopupClosed.value, (closed) => { + if (!closed) { + // Popup just opened - fetch current status + fetchJobQueueStatus(); + } + }); + + // Track completed recording IDs to detect new completions + const completedRecordingIds = new Set(); + + // Watch allJobs for completed/failed transitions - update local recordings state + watch(allJobs, async (jobs) => { + for (const job of jobs) { + if (job.job_status === 'completed' && !completedRecordingIds.has(job.recording_id)) { + completedRecordingIds.add(job.recording_id); + try { + const fullResponse = await fetch(`/api/recordings/${job.recording_id}`); + if (fullResponse.ok) { + const data = await fullResponse.json(); + const idx = recordings.value.findIndex(r => r.id === job.recording_id); + if (idx !== -1) { + recordings.value[idx] = data; + } + if (selectedRecording.value?.id === job.recording_id) { + selectedRecording.value = data; + } + // Update display name on upload queue item + const queueItem = uploadQueue.value.find(u => u.recordingId === job.recording_id); + if (queueItem) { + queueItem.displayName = data.title || data.original_filename || queueItem.file?.name; + queueItem.status = 'completed'; + } + // Refresh token budget + if (typeof loadTokenBudget === 'function') loadTokenBudget(); + } + } catch (err) { + console.error(`Error fetching completed recording ${job.recording_id}:`, err); + } + } else if (job.job_status === 'failed' && !completedRecordingIds.has(`fail_${job.recording_id}`)) { + completedRecordingIds.add(`fail_${job.recording_id}`); + try { + const failedResponse = await fetch(`/api/recordings/${job.recording_id}`); + if (failedResponse.ok) { + const failedData = await failedResponse.json(); + const idx = recordings.value.findIndex(r => r.id === job.recording_id); + if (idx !== -1) { + recordings.value[idx] = failedData; + } + if (selectedRecording.value?.id === job.recording_id) { + selectedRecording.value = failedData; + } + const queueItem = uploadQueue.value.find(u => u.recordingId === job.recording_id); + if (queueItem) { + queueItem.status = 'failed'; + queueItem.error = failedData.error_message || safeT('errors.processingFailedOnServer'); + } + } + } catch (err) { + console.error(`Error fetching failed recording ${job.recording_id}:`, err); + } + } + } + }, { deep: true }); + + // Get job details for a recording + const getJobDetails = (recordingId) => { + return jobQueueDetails.value[recordingId] || null; + }; + + // Retry a failed job + const retryJob = async (jobId) => { + try { + const response = await fetch(`/api/recordings/jobs/${jobId}/retry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (response.ok) { + fetchJobQueueStatus(); + showToast(safeT('messages.jobQueuedForRetry'), 'success'); + } else { + const data = await response.json(); + showToast(data.error || safeT('messages.failedToRetryJob'), 'error'); + } + } catch (error) { + console.error('Error retrying job:', error); + showToast(safeT('messages.failedToRetryJob'), 'error'); + } + }; + + // Delete/clear a job + const deleteJob = async (jobId) => { + try { + const response = await fetch(`/api/recordings/jobs/${jobId}`, { + method: 'DELETE' + }); + if (response.ok) { + fetchJobQueueStatus(); + } else { + const data = await response.json(); + showToast(data.error || safeT('messages.failedToDeleteJob'), 'error'); + } + } catch (error) { + console.error('Error deleting job:', error); + } + }; + + // Clear all completed jobs + const clearCompletedJobs = async () => { + try { + const response = await fetch('/api/recordings/jobs/clear-completed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (response.ok) { + // Clear upload queue completed/failed items + uploadQueue.value = uploadQueue.value.filter(item => + !['completed', 'failed', 'COMPLETED', 'FAILED'].includes(item.status) + ); + // Force fetch to update the job list (bypass debounce) + await fetchJobQueueStatus(true); + } + } catch (error) { + console.error('Error clearing completed jobs:', error); + } + }; + + // Combined clear function for backward compatibility + const clearAllCompleted = () => { + clearCompletedJobs(); + }; + + // ============================================ + // UNIFIED PROGRESS TRACKING SYSTEM + // Merges upload queue, backend recordings, and job queue into single list + // Each recording appears ONCE with its current status + // ============================================ + const unifiedProgressItems = computed(() => { + const items = new Map(); // Key by recordingId or clientId + + // 1. First, add all backend jobs (these have the most accurate status) + for (const job of allJobs.value) { + const key = `rec_${job.recording_id}`; + const existing = items.get(key); + + // Determine unified status from job + let unifiedStatus = 'queued'; + if (job.job_status === 'processing') { + unifiedStatus = job.queue_type === 'summary' ? 'summarizing' : 'transcribing'; + } else if (job.job_status === 'completed') { + unifiedStatus = 'completed'; + } else if (job.job_status === 'failed') { + unifiedStatus = 'failed'; + } + + // Prefer active jobs over completed/failed + if (!existing || ['queued', 'transcribing', 'summarizing'].includes(unifiedStatus)) { + items.set(key, { + id: key, + recordingId: job.recording_id, + jobId: job.id, + clientId: null, + title: job.recording_title || 'Untitled', + status: unifiedStatus, + progress: unifiedStatus === 'transcribing' ? 50 : (unifiedStatus === 'summarizing' ? 80 : null), + progressMessage: unifiedStatus === 'queued' ? `#${job.position || '?'} in queue` : + unifiedStatus === 'transcribing' ? 'Transcribing audio...' : + unifiedStatus === 'summarizing' ? 'Generating summary...' : + unifiedStatus === 'completed' ? 'Done' : 'Failed', + queuePosition: job.position, + errorMessage: job.error_message, + friendlyError: job.error_message ? parseUnformattedError(job.error_message) : null, + completedAt: job.completed_at, + source: 'job' + }); + } + } + + // 2. Add upload queue items (client-side tracking) + for (const upload of uploadQueue.value) { + // If we have a recordingId and it's already tracked from jobs, merge upload info + if (upload.recordingId) { + const key = `rec_${upload.recordingId}`; + const existing = items.get(key); + + if (existing) { + existing.clientId = upload.clientId; + existing.file = upload.file; + existing.duplicateWarning = upload.duplicateWarning || null; + // If still uploading, override job status with upload status + if (upload.status === 'uploading') { + existing.status = 'uploading'; + existing.progress = upload.progress || 0; + existing.progressMessage = 'Uploading...'; + existing.title = upload.displayName || upload.file?.name || existing.title; + } + continue; + } + } + + // Determine unified status from upload status (per-item progress) + let unifiedStatus = 'ready'; + let progressVal = upload.progress || 0; + let progressMsg = 'Waiting to upload...'; + + if (upload.status === 'uploading') { + unifiedStatus = 'uploading'; + progressMsg = 'Uploading...'; + } else if (upload.status === 'pending') { + unifiedStatus = 'queued'; + progressVal = 100; + progressMsg = 'Uploaded, waiting for processing...'; + } else if (upload.status === 'completed' || upload.status === 'COMPLETED') { + unifiedStatus = 'completed'; + progressMsg = 'Done'; + } else if (upload.status === 'failed' || upload.status === 'FAILED') { + unifiedStatus = 'upload_failed'; + progressMsg = upload.error || 'Upload failed'; + } else if (upload.status === 'ready') { + unifiedStatus = 'ready'; + progressMsg = 'Waiting to upload...'; + } else if (upload.status === 'queued') { + unifiedStatus = 'ready'; + progressMsg = 'Waiting to upload...'; + } + + const key = upload.recordingId ? `rec_${upload.recordingId}` : `client_${upload.clientId}`; + + // Skip if we already have an entry with the same recordingId (from jobs) + if (upload.recordingId && items.has(key)) { + continue; + } + + items.set(key, { + id: key, + recordingId: upload.recordingId, + jobId: null, + clientId: upload.clientId, + title: upload.displayName || upload.file?.name || 'Unknown file', + status: unifiedStatus, + progress: progressVal, + progressMessage: progressMsg, + queuePosition: null, + errorMessage: upload.status === 'failed' ? upload.error : null, + duplicateWarning: upload.duplicateWarning || null, + file: upload.file, + source: 'upload' + }); + } + + // Convert to array and sort: active first, then by status priority + const statusOrder = { + 'uploading': 1, + 'transcribing': 2, + 'summarizing': 3, + 'queued': 4, + 'ready': 5, + 'completed': 6, + 'failed': 7, + 'upload_failed': 8 + }; + + return Array.from(items.values()).sort((a, b) => { + return (statusOrder[a.status] || 99) - (statusOrder[b.status] || 99); + }); + }); + + // Filtered views of unified items + const activeProgressItems = computed(() => + unifiedProgressItems.value.filter(item => + ['uploading', 'transcribing', 'summarizing', 'queued', 'ready'].includes(item.status) + ) + ); + + const completedProgressItems = computed(() => + unifiedProgressItems.value.filter(item => item.status === 'completed') + ); + + const failedProgressItems = computed(() => + unifiedProgressItems.value.filter(item => + ['failed', 'upload_failed'].includes(item.status) + ) + ); + + // Helper to get status display info + const getStatusDisplay = (status) => { + const displays = { + 'ready': { label: 'Waiting', color: 'gray', icon: 'fa-clock' }, + 'uploading': { label: 'Uploading', color: 'blue', icon: 'fa-cloud-upload-alt', animate: true }, + 'queued': { label: 'Queued', color: 'yellow', icon: 'fa-clock' }, + 'transcribing': { label: 'Transcribing', color: 'purple', icon: 'fa-microphone-alt', animate: true }, + 'summarizing': { label: 'Summarizing', color: 'green', icon: 'fa-file-alt', animate: true }, + 'completed': { label: 'Done', color: 'green', icon: 'fa-check-circle' }, + 'failed': { label: 'Failed', color: 'red', icon: 'fa-exclamation-circle' }, + 'upload_failed': { label: 'Upload Failed', color: 'red', icon: 'fa-exclamation-circle' } + }; + return displays[status] || displays['ready']; + }; + + // Cancel/remove an item from the queue + const removeProgressItem = async (item) => { + if (item.jobId && ['failed', 'completed'].includes(item.status)) { + // Delete backend job + await deleteJob(item.jobId); + } else if (item.clientId && !item.jobId) { + // Remove from upload queue + uploadQueue.value = uploadQueue.value.filter(u => u.clientId !== item.clientId); + } + }; + + // Retry a failed item + const retryProgressItem = async (item) => { + if (item.jobId) { + await retryJob(item.jobId); + } + }; + + // Track recently completed for backward compat (now using allJobs) + const recentlyCompletedBackend = computed(() => { + return completedJobs.value.map(j => ({ + id: j.recording_id, + title: j.recording_title || 'Untitled', + status: 'completed', + completedAt: j.completed_at + })); + }); + + // Combined processing queue count + const totalProcessingCount = computed(() => { + return activeProgressItems.value.length; + }); + + // Should show the processing popup + const showProcessingPopup = computed(() => { + return unifiedProgressItems.value.length > 0; + }); + + // All completed items count + const allCompletedCount = computed(() => { + return completedProgressItems.value.length + failedProgressItems.value.length; + }); + + // Speaker computed properties + const hasSpeakerNames = computed(() => { + // Check if any speaker has a non-empty name + return Object.values(speakerMap.value).some(speakerData => + speakerData && speakerData.name && speakerData.name.trim() !== '' + ); + }); + + // Tags with custom prompts for reprocess modal + const tagsWithCustomPrompts = computed(() => { + return availableTags.value.filter(tag => tag.custom_prompt && tag.custom_prompt.trim() !== ''); + }); + + // Recording disclaimer parsed as markdown + const recordingDisclaimerHtml = computed(() => { + if (!recordingDisclaimer.value || recordingDisclaimer.value.trim() === '') { + return ''; + } + return marked.parse(recordingDisclaimer.value); + }); + + // Upload disclaimer parsed as markdown + const uploadDisclaimerHtml = computed(() => { + if (!uploadDisclaimer.value || uploadDisclaimer.value.trim() === '') { + return ''; + } + return marked.parse(uploadDisclaimer.value); + }); + + // Custom banner parsed as markdown + const customBannerHtml = computed(() => { + if (!customBanner.value || customBanner.value.trim() === '') { + return ''; + } + return marked.parse(customBanner.value); + }); + + // Get tag prompt preview + const getTagPromptPreview = (tagId) => { + const tag = availableTags.value.find(t => t.id == tagId); + if (tag && tag.custom_prompt) { + // Return first 100 characters of the custom prompt + return tag.custom_prompt.length > 100 + ? tag.custom_prompt.substring(0, 100) + '...' + : tag.custom_prompt; + } + return ''; + }; + + // Duplicates modal + const openDuplicatesModal = (duplicateInfo) => { + duplicatesModalData.value = duplicateInfo; + showDuplicatesModal.value = true; + }; + + const navigateToDuplicate = (id) => { + showDuplicatesModal.value = false; + const rec = recordings.value.find(r => r.id === id); + // selectRecording always re-fetches full data from the API + recordingsComposable.selectRecording(rec || { id }); + }; + + // ========================================================================= + // WATCHERS + // ========================================================================= + // Watch for search query changes + watch(searchQuery, (newQuery) => { + recordingsComposable.debouncedSearch(newQuery); + }); + + // Auto-apply filters when they change + watch(filterTags, () => { + recordingsComposable.applyAdvancedFilters(); + }, { deep: true }); + + watch(filterSpeakers, () => { + recordingsComposable.applyAdvancedFilters(); + }, { deep: true }); + + watch(filterDatePreset, () => { + recordingsComposable.applyAdvancedFilters(); + }); + + watch(filterDateRange, () => { + recordingsComposable.applyAdvancedFilters(); + }, { deep: true }); + + watch(filterTextQuery, (newValue) => { + clearTimeout(searchDebounceTimer.value); + searchDebounceTimer.value = setTimeout(() => { + recordingsComposable.applyAdvancedFilters(); + }, 300); + }); + + watch(filterStarred, () => { + recordingsComposable.loadRecordings(1, false, searchQuery.value); + }); + + watch(filterInbox, () => { + recordingsComposable.loadRecordings(1, false, searchQuery.value); + }); + + watch(filterFolder, (newValue) => { + // Persist folder selection to localStorage + if (newValue) { + localStorage.setItem('selectedFolder', newValue); + } else { + localStorage.removeItem('selectedFolder'); + } + recordingsComposable.loadRecordings(1, false, searchQuery.value); + }); + + watch(sortBy, () => { + recordingsComposable.loadRecordings(1, false, searchQuery.value); + }); + + watch(showArchivedRecordings, (newValue, oldValue) => { + // Prevent unnecessary reloads when being set by the other watcher + if (newValue === oldValue) return; + + // Reload recordings when switching between archived/normal view + if (showArchivedRecordings.value) { + showSharedWithMe.value = false; // Can't show both at once + } + recordingsComposable.loadRecordings(1, false, searchQuery.value); + }); + + watch(showSharedWithMe, (newValue, oldValue) => { + // Prevent unnecessary reloads when being set by the other watcher + if (newValue === oldValue) return; + + // Reload recordings when switching to/from shared view + if (showSharedWithMe.value) { + showArchivedRecordings.value = false; // Can't show both at once + } + recordingsComposable.loadRecordings(1, false, searchQuery.value); + }); + + // Watch for view changes to initialize recording notes editor + watch(currentView, async (newView, oldView) => { + if (newView === 'recording') { + // Initialize recording notes editor when entering recording view + await nextTick(); + uiComposable.initializeRecordingNotesEditor(); + } else if (oldView === 'recording') { + // Destroy editor when leaving recording view + uiComposable.destroyRecordingNotesEditor(); + } + + // Clear incognito data when navigating away from detail view + // This ensures incognito data doesn't linger when user goes to upload/recording view + if (oldView === 'detail' && newView !== 'detail') { + if (uploadComposable.hasIncognitoRecording()) { + console.log('[Incognito] Clearing data on view change from detail'); + sessionStorage.removeItem('speakr_incognito_recording'); + incognitoRecording.value = null; + } + } + }); + + // Re-initialize recording notes editor when recording stops (DOM switches from recording template to accordion template) + watch(isRecording, async (newVal, oldVal) => { + if (oldVal === true && newVal === false && currentView.value === 'recording') { + uiComposable.destroyRecordingNotesEditor(); + expandedSection.value = recordingNotes.value ? 'notes' : 'settings'; + await nextTick(); + uiComposable.initializeRecordingNotesEditor(); + } + }); + + // Refresh CodeMirror when notes section becomes visible in accordion + watch(expandedSection, async (newSection) => { + if (newSection === 'notes' && recordingMarkdownEditorInstance.value) { + await nextTick(); + recordingMarkdownEditorInstance.value.codemirror.refresh(); + } + }); + + // Watch for mobile tab changes to reinitialize editors if still in edit mode + watch(mobileTab, async (newTab) => { + // Wait for DOM to update + await nextTick(); + + // If switching to summary tab and still in edit mode, reinitialize editor + if (newTab === 'summary' && editingSummary.value) { + uiComposable.initializeSummaryMarkdownEditor(); + } + + // If switching to notes tab and still in edit mode, reinitialize editor + if (newTab === 'notes' && editingNotes.value) { + uiComposable.initializeMarkdownEditor(); + } + }); + + // Watch for desktop tab changes to reinitialize editors if still in edit mode + watch(selectedTab, async (newTab) => { + // Wait for DOM to update + await nextTick(); + + // If switching to summary tab and still in edit mode, reinitialize editor + if (newTab === 'summary' && editingSummary.value) { + uiComposable.initializeSummaryMarkdownEditor(); + } + + // If switching to notes tab and still in edit mode, reinitialize editor + if (newTab === 'notes' && editingNotes.value) { + uiComposable.initializeMarkdownEditor(); + } + }); + + // Watch for selectedRecording changes to reset chat + watch(selectedRecording, (newRecording, oldRecording) => { + // Only clear if we're actually switching to a different recording + if (oldRecording && newRecording && oldRecording.id !== newRecording.id) { + chatMessages.value = []; + chatInput.value = ''; + } + }); + + // ========================================================================= + // LIFECYCLE + // ========================================================================= + onMounted(async () => { + // Get config from data attributes + const appElement = document.getElementById('app'); + if (appElement) { + useAsrEndpoint.value = appElement.dataset.useAsrEndpoint === 'True'; + connectorSupportsDiarization.value = appElement.dataset.connectorSupportsDiarization === 'True'; + connectorSupportsSpeakerCount.value = appElement.dataset.connectorSupportsSpeakerCount === 'True'; + currentUserName.value = appElement.dataset.currentUserName || ''; + } + + // Initialize UI + uiComposable.initializeDarkMode(); + uiComposable.initializeColorScheme(); + uiComposable.initializeSidebar(); + + // Check for recoverable recording from IndexedDB + try { + const recoverable = await audioComposable.checkForRecoverableRecording(); + if (recoverable && recoverable.chunks && recoverable.chunks.length > 0) { + recoverableRecording.value = recoverable; + showRecoveryModal.value = true; + console.log('[App] Found recoverable recording, showing recovery dialog'); + } + } catch (error) { + console.error('[App] Failed to check for recoverable recording:', error); + } + + // Load initial data + await Promise.all([ + recordingsComposable.loadRecordings(), + recordingsComposable.loadTags(), + recordingsComposable.loadFolders(), + recordingsComposable.loadSpeakers(), + loadTokenBudget() + ]); + + // Clean up orphaned incognito data if we're not viewing incognito recording + // This can happen if user navigated away without the cleanup triggering + if (uploadComposable.hasIncognitoRecording() && selectedRecording.value?.id !== 'incognito') { + console.log('[App] Cleaning up orphaned incognito data from sessionStorage'); + sessionStorage.removeItem('speakr_incognito_recording'); + incognitoRecording.value = null; + } + + // Load config + try { + const response = await fetch('/api/config'); + if (response.ok) { + const config = await response.json(); + maxFileSizeMB.value = config.max_file_size_mb || 250; + chunkingEnabled.value = config.chunking_enabled !== false; + chunkingMode.value = config.chunking_mode || 'size'; + chunkingLimit.value = config.chunking_limit || 20; + recordingDisclaimer.value = config.recording_disclaimer || ''; + uploadDisclaimer.value = config.upload_disclaimer || ''; + customBanner.value = config.custom_banner || ''; + canDeleteRecordings.value = config.can_delete_recordings !== false; + enableInternalSharing.value = config.enable_internal_sharing === true; + enableArchiveToggle.value = config.enable_archive_toggle === true; + showUsernamesInUI.value = config.show_usernames_in_ui === true; + enableIncognitoMode.value = config.enable_incognito_mode === true; + foldersEnabled.value = config.enable_folders === true; + maxConcurrentUploads.value = config.max_concurrent_uploads || 3; + + // Restore saved folder selection from localStorage + if (foldersEnabled.value) { + const savedFolder = localStorage.getItem('selectedFolder'); + if (savedFolder) { + filterFolder.value = savedFolder; + } + } + + // Set default incognito mode state if feature enabled and default is true + if (config.enable_incognito_mode && config.incognito_mode_default) { + incognitoMode.value = true; + } + } + } catch (error) { + console.error('Failed to load config:', error); + } + + // Initialize UI settings from localStorage + uiComposable.initializeUI(); + + // Load incognito recording from sessionStorage if exists (only if feature is enabled) + if (enableIncognitoMode.value) { + uploadComposable.loadIncognitoRecording(); + } + + // Initialize audio capabilities + await audioComposable.initializeAudio(); + + // Initialize PWA features + pwaComposable.initPWA(); + + // Show app - hide loader and show main content + const loader = document.getElementById('loader'); + const appEl = document.getElementById('app'); + if (loader) { + loader.style.opacity = '0'; + setTimeout(() => { + loader.style.display = 'none'; + }, 500); + } + if (appEl) { + appEl.style.opacity = '1'; + appEl.classList.remove('opacity-0'); + } + + // Also hide AppLoader overlay if it exists + if (window.AppLoader) { + window.AppLoader.hide(); + } + + // Window resize handler + window.addEventListener('resize', () => { + windowWidth.value = window.innerWidth; + }); + + // Visibility change handler for wake lock + document.addEventListener('visibilitychange', audioComposable.handleVisibilityChange); + + // Prevent data loss on tab close/refresh during recording or incognito mode + window.addEventListener('beforeunload', (e) => { + // Check for unsaved recording + if (audioComposable.hasUnsavedRecording()) { + e.preventDefault(); + e.returnValue = ''; // Chrome requires this + return 'You have an unsaved recording. Are you sure you want to leave?'; + } + // Check for incognito recording that would be lost + // Only warn if we're currently viewing the incognito recording + // (if user navigated away, they've implicitly abandoned it or already been warned) + if (uploadComposable.hasIncognitoRecording() && selectedRecording.value?.id === 'incognito') { + e.preventDefault(); + e.returnValue = ''; // Chrome requires this + return 'You have an incognito recording that will be lost. Are you sure you want to leave?'; + } + }); + + // Initialize bulk selection keyboard listeners + bulkSelectionComposable.initSelectionKeyboardListeners(); + }); + + // ========================================================================= + // RECORDING RECOVERY FUNCTIONS + // ========================================================================= + + const recoverRecording = async () => { + try { + showRecoveryModal.value = false; + + const recovered = await audioComposable.recoverRecordingFromDB(); + if (recovered) { + currentView.value = 'recording'; + showToast(safeT('messages.recordingRecovered'), 'success'); + } else { + showToast(safeT('messages.failedToRecoverRecording'), 'error'); + } + + recoverableRecording.value = null; + } catch (error) { + console.error('[App] Failed to recover recording:', error); + showToast(safeT('messages.errorRecoveringRecording'), 'error'); + } + }; + + const cancelRecovery = async () => { + try { + showRecoveryModal.value = false; + + // Clear the recording from IndexedDB + await audioComposable.clearRecordingSession(); + + showToast(safeT('messages.recordingDiscarded'), 'info'); + recoverableRecording.value = null; + } catch (error) { + console.error('[App] Failed to discard recording:', error); + } + }; + + const formatRecordingMode = (mode) => { + const modes = { + 'microphone': t('recording.modeMicrophone'), + 'system': t('recording.modeSystem'), + 'both': t('recording.modeBoth') + }; + return modes[mode] || mode; + }; + + // ========================================================================= + // WATCHERS + // ========================================================================= + + // Update badge count when recordings change + watch(recordings, (newRecordings) => { + if (newRecordings && Array.isArray(newRecordings)) { + pwaComposable.updateBadgeCount(newRecordings); + } + }); + + // ========================================================================= + // RETURN ALL STATE AND METHODS + // ========================================================================= + return { + // Translation + t, tc, + + // State + ...state, + + // Computed + isMobileScreen, + isMobileDevice, + processedTranscription, + groupedRecordings, + filteredAvailableTags, + filteredTagsForFilter, + filteredSpeakersForFilter, + selectedTags, + colorSchemes, + dropdownPositions, + toasts, + datePresetOptions, + languageOptions, + activeRecordingMetadata, + totalInQueue, + completedInQueue, + finishedFilesInQueue, + waitingFilesInQueue, + pendingQueueFiles, + backendProcessingRecordings, + totalProcessingCount, + showProcessingPopup, + jobQueueDetails, + getJobDetails, + allJobs, + activeJobs, + completedJobs, + failedJobs, + retryJob, + deleteJob, + clearCompletedJobs, + recentlyCompletedBackend, + clearAllCompleted, + allCompletedCount, + // Unified progress tracking + unifiedProgressItems, + activeProgressItems, + completedProgressItems, + failedProgressItems, + getStatusDisplay, + removeProgressItem, + retryProgressItem, + hasSpeakerNames, + showDuplicatesModal, + videoCollapsed, + videoFullscreen, + fullscreenControlsVisible, + currentSubtitle, + duplicatesModalData, + openDuplicatesModal, + navigateToDuplicate, + tagsWithCustomPrompts, + recordingDisclaimerHtml, + uploadDisclaimerHtml, + customBannerHtml, + acceptUploadDisclaimer, + cancelUploadDisclaimer, + getTagPromptPreview, + + // Utilities + formatFileSize, + formatDisplayDate, + formatShortDate, + formatStatus, + getStatusClass, + formatTime, + formatDuration, + formatEventDateTime, + formatDateTime: formatEventDateTime, // Alias for recovery modal + setGlobalError, + showToast, + loadTokenBudget, + getContrastTextColor, + getBubbleGlobalIndex, + formatRecordingMode, + + // Modal audio (independent from main player) + modalAudioCurrentTime, + modalAudioDuration, + modalAudioIsPlaying, + modalAudioProgressPercent, + handleModalAudioTimeUpdate, + handleModalAudioLoadedMetadata, + handleModalAudioPlayPause, + resetModalAudioState, + + // Virtual scroll + speakerModalTranscriptRef, + mainTranscriptRef, + asrEditorRef, + speakerModalVisibleSegments: speakerModalVirtualScroll.visibleItems, + speakerModalSpacerBefore: speakerModalVirtualScroll.spacerBefore, + speakerModalSpacerAfter: speakerModalVirtualScroll.spacerAfter, + onSpeakerModalScroll: speakerModalVirtualScroll.onScroll, + mainTranscriptVisibleSegments: mainTranscriptVirtualScroll.visibleItems, + mainTranscriptSpacerBefore: mainTranscriptVirtualScroll.spacerBefore, + mainTranscriptSpacerAfter: mainTranscriptVirtualScroll.spacerAfter, + onMainTranscriptScroll: mainTranscriptVirtualScroll.onScroll, + asrEditorVisibleSegments: asrEditorVirtualScroll.visibleItems, + asrEditorSpacerBefore: asrEditorVirtualScroll.spacerBefore, + asrEditorSpacerAfter: asrEditorVirtualScroll.spacerAfter, + onAsrEditorScroll: asrEditorVirtualScroll.onScroll, + scrollToSegmentIndex, + getVirtualItemKey, + + // Recording recovery + showRecoveryModal, + recoverableRecording, + recoverRecording, + cancelRecovery, + + // Composable methods + ...recordingsComposable, + ...uploadComposable, + ...audioComposable, + ...uiComposable, + ...modalsComposable, + ...sharingComposable, + ...reprocessComposable, + ...transcriptionComposable, + ...speakersComposable, + ...chatComposable, + ...tagsComposable, + ...foldersComposable, + ...pwaComposable, + ...bulkSelectionComposable, + ...bulkOperationsComposable + }; + }, + delimiters: ['${', '}'] + }); + + app.config.globalProperties.t = safeT; + app.config.globalProperties.tc = (key, count, params = {}) => { + if (!window.i18n || !window.i18n.tc) { + return key; + } + return window.i18n.tc(key, count, params); + }; + + app.provide('t', safeT); + app.provide('tc', (key, count, params = {}) => { + if (!window.i18n || !window.i18n.tc) { + return key; + } + return window.i18n.tc(key, count, params); + }); + + app.mount('#app'); +}); diff --git a/static/js/composables/index.js b/static/js/composables/index.js new file mode 100644 index 0000000..d18b276 --- /dev/null +++ b/static/js/composables/index.js @@ -0,0 +1,17 @@ +/** + * Composables index - export all composables for easy importing + * + * Usage (with ES modules/build system): + * import { useRecordings, useUpload, useUI } from './composables'; + */ + +export { usePagination } from './usePagination.js'; +export { useUI } from './useUI.js'; +export { useFilters } from './useFilters.js'; +export { usePlayer } from './usePlayer.js'; +export { useSharing } from './useSharing.js'; +export { useTranscript } from './useTranscript.js'; +export { useChat } from './useChat.js'; +export { useAudioRecorder } from './useAudioRecorder.js'; +export { useUpload } from './useUpload.js'; +export { useRecordings } from './useRecordings.js'; diff --git a/static/js/composables/useAudioRecorder.js b/static/js/composables/useAudioRecorder.js new file mode 100644 index 0000000..fa35d70 --- /dev/null +++ b/static/js/composables/useAudioRecorder.js @@ -0,0 +1,217 @@ +/** + * Audio Recorder composable + * Handles audio recording from microphone and/or system audio + */ + +import { ref, computed } from 'vue'; + +export function useAudioRecorder() { + // State + const isRecording = ref(false); + const isPaused = ref(false); + const audioChunks = ref([]); + const audioBlobURL = ref(null); + const recordingMode = ref('microphone'); + const mediaRecorder = ref(null); + const audioContext = ref(null); + const activeStreams = ref([]); + const recordingDuration = ref(0); + const recordingSize = ref(0); + const actualBitrate = ref(128000); + const recordingTimer = ref(null); + const recordingNotes = ref(''); + const showRecordingDisclaimerModal = ref(false); + const pendingRecordingMode = ref(null); + const recordingDisclaimer = ref(''); + + // Computed + const canRecordAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia); + + const recordingTimeFormatted = computed(() => { + const hours = Math.floor(recordingDuration.value / 3600); + const mins = Math.floor((recordingDuration.value % 3600) / 60); + const secs = recordingDuration.value % 60; + if (hours > 0) { + return hours + ':' + String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0'); + } + return mins + ':' + String(secs).padStart(2, '0'); + }); + + // Methods + const startRecording = async (mode = 'microphone') => { + if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) { + pendingRecordingMode.value = mode; + showRecordingDisclaimerModal.value = true; + return; + } + await startRecordingActual(mode); + }; + + const acceptDisclaimer = async () => { + showRecordingDisclaimerModal.value = false; + if (pendingRecordingMode.value) { + await startRecordingActual(pendingRecordingMode.value); + pendingRecordingMode.value = null; + } + }; + + const cancelDisclaimer = () => { + showRecordingDisclaimerModal.value = false; + pendingRecordingMode.value = null; + }; + + const startRecordingActual = async (mode = 'microphone') => { + recordingMode.value = mode; + audioChunks.value = []; + audioBlobURL.value = null; + recordingNotes.value = ''; + activeStreams.value = []; + recordingDuration.value = 0; + recordingSize.value = 0; + + try { + let combinedStream = null; + let micStream = null; + let systemStream = null; + + if (mode === 'microphone' || mode === 'both') { + if (!canRecordAudio.value) throw new Error('Microphone not supported'); + micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + activeStreams.value.push(micStream); + } + + if (mode === 'system' || mode === 'both') { + if (!canRecordSystemAudio.value) throw new Error('System audio not supported'); + try { + systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); + if (systemStream.getAudioTracks().length === 0) { + systemStream.getVideoTracks().forEach(track => track.stop()); + throw new Error('System audio permission not granted'); + } + activeStreams.value.push(systemStream); + } catch (err) { + if (mode === 'system') throw err; + systemStream = null; + } + } + + // Combine streams + if (micStream && systemStream) { + audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); + const micSource = audioContext.value.createMediaStreamSource(micStream); + const systemSource = audioContext.value.createMediaStreamSource(systemStream); + const destination = audioContext.value.createMediaStreamDestination(); + micSource.connect(destination); + systemSource.connect(destination); + combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]); + } else if (systemStream) { + combinedStream = new MediaStream(systemStream.getAudioTracks()); + } else if (micStream) { + combinedStream = micStream; + } + + if (!combinedStream) throw new Error('No audio streams available'); + + // Create MediaRecorder + const options = { mimeType: 'audio/webm;codecs=opus', audioBitsPerSecond: 32000 }; + if (MediaRecorder.isTypeSupported(options.mimeType)) { + mediaRecorder.value = new MediaRecorder(combinedStream, options); + actualBitrate.value = 32000; + } else { + mediaRecorder.value = new MediaRecorder(combinedStream); + actualBitrate.value = 128000; + } + + mediaRecorder.value.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + audioChunks.value.push(event.data); + recordingSize.value += event.data.size; + } + }; + + mediaRecorder.value.onstop = () => { + const audioBlob = new Blob(audioChunks.value, { type: mediaRecorder.value.mimeType }); + audioBlobURL.value = URL.createObjectURL(audioBlob); + }; + + mediaRecorder.value.start(1000); + isRecording.value = true; + + recordingTimer.value = setInterval(() => { + recordingDuration.value++; + }, 1000); + + } catch (error) { + stopAllStreams(); + throw error; + } + }; + + const stopRecording = () => { + if (mediaRecorder.value && isRecording.value) { + mediaRecorder.value.stop(); + isRecording.value = false; + isPaused.value = false; + + if (recordingTimer.value) { + clearInterval(recordingTimer.value); + recordingTimer.value = null; + } + stopAllStreams(); + } + }; + + const pauseRecording = () => { + if (mediaRecorder.value && isRecording.value && !isPaused.value) { + mediaRecorder.value.pause(); + isPaused.value = true; + if (recordingTimer.value) { + clearInterval(recordingTimer.value); + recordingTimer.value = null; + } + } + }; + + const resumeRecording = () => { + if (mediaRecorder.value && isRecording.value && isPaused.value) { + mediaRecorder.value.resume(); + isPaused.value = false; + recordingTimer.value = setInterval(() => { + recordingDuration.value++; + }, 1000); + } + }; + + const stopAllStreams = () => { + activeStreams.value.forEach(stream => { + stream.getTracks().forEach(track => track.stop()); + }); + activeStreams.value = []; + + if (audioContext.value) { + audioContext.value.close().catch(e => console.error("Error closing AudioContext:", e)); + audioContext.value = null; + } + }; + + const resetRecording = () => { + stopRecording(); + audioChunks.value = []; + audioBlobURL.value = null; + recordingDuration.value = 0; + recordingSize.value = 0; + recordingNotes.value = ''; + }; + + const getRecordingBlob = () => { + if (audioChunks.value.length === 0) return null; + return new Blob(audioChunks.value, { type: 'audio/webm' }); + }; + + return { + isRecording, isPaused, audioBlobURL, recordingMode, recordingDuration, recordingSize, recordingNotes, + showRecordingDisclaimerModal, recordingDisclaimer, canRecordAudio, canRecordSystemAudio, recordingTimeFormatted, + startRecording, stopRecording, pauseRecording, resumeRecording, resetRecording, acceptDisclaimer, cancelDisclaimer, getRecordingBlob + }; +} diff --git a/static/js/composables/useChat.js b/static/js/composables/useChat.js new file mode 100644 index 0000000..fcfe3aa --- /dev/null +++ b/static/js/composables/useChat.js @@ -0,0 +1,234 @@ +/** + * Chat composable + * Handles chat/inquire functionality with streaming responses + */ + +import { ref, reactive, nextTick } from 'vue'; + +export function useChat() { + // State + const chatMessages = ref([]); + const chatInput = ref(''); + const isChatLoading = ref(false); + const chatMessagesRef = ref(null); + const isChatExpanded = ref(false); + + // Methods + const isChatScrolledToBottom = () => { + if (!chatMessagesRef.value) return true; + const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value; + const scrollableHeight = scrollHeight - clientHeight; + if (scrollableHeight <= 0) return true; + const scrollPercentage = scrollTop / scrollableHeight; + return scrollPercentage >= 0.95; // Within bottom 5% + }; + + const scrollChatToBottom = () => { + if (chatMessagesRef.value) { + requestAnimationFrame(() => { + if (chatMessagesRef.value) { + chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight; + } + }); + } + }; + + const sendMessage = async (recordingId) => { + if (!chatInput.value.trim() || isChatLoading.value) { + return; + } + + const message = chatInput.value.trim(); + + if (!Array.isArray(chatMessages.value)) { + chatMessages.value = []; + } + + chatMessages.value.push({ role: 'user', content: message }); + chatInput.value = ''; + isChatLoading.value = true; + + await nextTick(); + scrollChatToBottom(); + + let assistantMessage = null; + + try { + const messageHistory = chatMessages.value + .slice(0, -1) + .map(msg => ({ role: msg.role, content: msg.content })); + + const response = await fetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recording_id: recordingId, + message: message, + message_history: messageHistory + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to get chat response'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const processStream = async () => { + let isFirstChunk = true; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6); + if (jsonStr) { + try { + const data = JSON.parse(jsonStr); + + if (data.thinking) { + const shouldScroll = isChatScrolledToBottom(); + + if (isFirstChunk) { + isChatLoading.value = false; + assistantMessage = reactive({ + role: 'assistant', + content: '', + html: '', + thinking: data.thinking, + thinkingExpanded: false + }); + chatMessages.value.push(assistantMessage); + isFirstChunk = false; + } else if (assistantMessage) { + if (assistantMessage.thinking) { + assistantMessage.thinking += '\n\n' + data.thinking; + } else { + assistantMessage.thinking = data.thinking; + } + } + + if (shouldScroll) { + await nextTick(); + scrollChatToBottom(); + } + } + + if (data.delta) { + const shouldScroll = isChatScrolledToBottom(); + + if (isFirstChunk) { + isChatLoading.value = false; + assistantMessage = reactive({ + role: 'assistant', + content: '', + html: '', + thinking: '', + thinkingExpanded: false + }); + chatMessages.value.push(assistantMessage); + isFirstChunk = false; + } + + assistantMessage.content += data.delta; + if (window.marked) { + assistantMessage.html = window.marked.parse(assistantMessage.content); + } else { + assistantMessage.html = assistantMessage.content; + } + + if (shouldScroll) { + await nextTick(); + scrollChatToBottom(); + } + } + + if (data.end_of_stream) { + return; + } + + if (data.error) { + throw new Error(data.error); + } + } catch (e) { + console.error('Error parsing stream data:', e); + } + } + } + } + } + }; + + await processStream(); + + } catch (error) { + console.error('Chat Error:', error); + if (assistantMessage) { + assistantMessage.content = `Error: ${error.message}`; + assistantMessage.html = `Error: ${error.message}`; + } else { + chatMessages.value.push({ + role: 'assistant', + content: `Error: ${error.message}`, + html: `Error: ${error.message}` + }); + } + } finally { + isChatLoading.value = false; + await nextTick(); + if (isChatScrolledToBottom()) { + scrollChatToBottom(); + } + } + }; + + const clearChat = () => { + chatMessages.value = []; + chatInput.value = ''; + isChatLoading.value = false; + }; + + const toggleThinking = (message) => { + if (message.thinking) { + message.thinkingExpanded = !message.thinkingExpanded; + } + }; + + const setChatRef = (el) => { + chatMessagesRef.value = el; + }; + + const handleChatInput = (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + // Trigger send message (caller should provide recordingId) + return true; + } + return false; + }; + + return { + // State + chatMessages, + chatInput, + isChatLoading, + chatMessagesRef, + isChatExpanded, + + // Methods + sendMessage, + clearChat, + toggleThinking, + setChatRef, + scrollChatToBottom, + handleChatInput + }; +} diff --git a/static/js/composables/useFilters.js b/static/js/composables/useFilters.js new file mode 100644 index 0000000..35fedc0 --- /dev/null +++ b/static/js/composables/useFilters.js @@ -0,0 +1,128 @@ +/** + * Filters composable + * Handles search, filtering, and sorting functionality + */ + +import { ref, computed } from 'vue'; +import { parseDateRange } from '../utils/dateUtils.js'; + +export function useFilters() { + // State + const searchQuery = ref(''); + const showAdvancedFilters = ref(false); + const filterTags = ref([]); + const filterFolder = ref(''); // '' = all, 'none' = no folder, or folder id + const filterDateRange = ref({ start: '', end: '' }); + const filterDatePreset = ref(''); + const filterTextQuery = ref(''); + const showArchivedRecordings = ref(false); + const showSharedWithMe = ref(false); + const sortBy = ref('created_at'); + const selectedTagFilter = ref(null); + const searchDebounceTimer = ref(null); + + // Methods + const toggleAdvancedFilters = () => { + showAdvancedFilters.value = !showAdvancedFilters.value; + }; + + const setDatePreset = (preset) => { + filterDatePreset.value = preset; + const range = parseDateRange(preset); + filterDateRange.value = { + start: range.start ? range.start.toISOString().split('T')[0] : '', + end: range.end ? range.end.toISOString().split('T')[0] : '' + }; + }; + + const clearDateFilter = () => { + filterDatePreset.value = ''; + filterDateRange.value = { start: '', end: '' }; + }; + + const toggleTagFilter = (tagId) => { + const index = filterTags.value.indexOf(tagId); + if (index > -1) { + filterTags.value.splice(index, 1); + } else { + filterTags.value.push(tagId); + } + }; + + const clearTagFilters = () => { + filterTags.value = []; + selectedTagFilter.value = null; + }; + + const clearFolderFilter = () => { + filterFolder.value = ''; + }; + + const clearAllFilters = () => { + filterTags.value = []; + filterFolder.value = ''; + filterDateRange.value = { start: '', end: '' }; + filterDatePreset.value = ''; + filterTextQuery.value = ''; + selectedTagFilter.value = null; + searchQuery.value = ''; + }; + + const toggleArchivedView = () => { + showArchivedRecordings.value = !showArchivedRecordings.value; + if (showArchivedRecordings.value) { + showSharedWithMe.value = false; + } + }; + + const toggleSharedView = () => { + showSharedWithMe.value = !showSharedWithMe.value; + if (showSharedWithMe.value) { + showArchivedRecordings.value = false; + } + }; + + const setSortBy = (field) => { + sortBy.value = field; + }; + + const hasActiveFilters = computed(() => { + return filterTags.value.length > 0 || + filterFolder.value || + filterDateRange.value.start || + filterDateRange.value.end || + filterTextQuery.value || + searchQuery.value; + }); + + return { + // State + searchQuery, + showAdvancedFilters, + filterTags, + filterFolder, + filterDateRange, + filterDatePreset, + filterTextQuery, + showArchivedRecordings, + showSharedWithMe, + sortBy, + selectedTagFilter, + searchDebounceTimer, + + // Computed + hasActiveFilters, + + // Methods + toggleAdvancedFilters, + setDatePreset, + clearDateFilter, + toggleTagFilter, + clearTagFilters, + clearFolderFilter, + clearAllFilters, + toggleArchivedView, + toggleSharedView, + setSortBy + }; +} diff --git a/static/js/composables/usePagination.js b/static/js/composables/usePagination.js new file mode 100644 index 0000000..f23e7c6 --- /dev/null +++ b/static/js/composables/usePagination.js @@ -0,0 +1,95 @@ +/** + * Pagination composable + * Handles pagination state and navigation + */ + +import { ref, computed } from 'vue'; + +export function usePagination() { + // State + const currentPage = ref(1); + const perPage = ref(25); + const totalRecordings = ref(0); + const totalPages = ref(0); + const hasNextPage = ref(false); + const hasPrevPage = ref(false); + const isLoadingMore = ref(false); + + // Computed + const paginationInfo = computed(() => { + const start = (currentPage.value - 1) * perPage.value + 1; + const end = Math.min(currentPage.value * perPage.value, totalRecordings.value); + return { + start, + end, + total: totalRecordings.value, + currentPage: currentPage.value, + totalPages: totalPages.value + }; + }); + + // Methods + const updatePagination = (pagination) => { + if (!pagination) { + // Reset pagination for non-paginated views + currentPage.value = 1; + totalPages.value = 1; + hasNextPage.value = false; + hasPrevPage.value = false; + return; + } + + currentPage.value = pagination.page; + totalRecordings.value = pagination.total; + totalPages.value = pagination.total_pages; + hasNextPage.value = pagination.has_next; + hasPrevPage.value = pagination.has_prev; + }; + + const goToPage = (page) => { + if (page < 1 || page > totalPages.value) return; + currentPage.value = page; + }; + + const nextPage = () => { + if (hasNextPage.value) { + currentPage.value++; + } + }; + + const prevPage = () => { + if (hasPrevPage.value) { + currentPage.value--; + } + }; + + const reset = () => { + currentPage.value = 1; + totalRecordings.value = 0; + totalPages.value = 0; + hasNextPage.value = false; + hasPrevPage.value = false; + isLoadingMore.value = false; + }; + + return { + // State + currentPage, + perPage, + totalRecordings, + totalPages, + hasNextPage, + hasPrevPage, + isLoadingMore, + + // Computed + paginationInfo, + + // Methods + updatePagination, + goToPage, + nextPage, + prevPage, + reset + }; +} diff --git a/static/js/composables/usePlayer.js b/static/js/composables/usePlayer.js new file mode 100644 index 0000000..caef972 --- /dev/null +++ b/static/js/composables/usePlayer.js @@ -0,0 +1,147 @@ +/** + * Audio Player composable + * Handles audio playback functionality + */ + +import { ref, computed, watch } from 'vue'; + +export function usePlayer() { + // State + const isPlaying = ref(false); + const currentTime = ref(0); + const duration = ref(0); + const playbackRate = ref(1.0); + const audioElement = ref(null); + + // Computed + const progress = computed(() => { + if (!duration.value) return 0; + return (currentTime.value / duration.value) * 100; + }); + + const formattedCurrentTime = computed(() => { + return formatTime(currentTime.value); + }); + + const formattedDuration = computed(() => { + return formatTime(duration.value); + }); + + // Methods + const formatTime = (seconds) => { + if (!seconds || isNaN(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const initPlayer = (audio) => { + audioElement.value = audio; + + if (!audio) return; + + audio.addEventListener('loadedmetadata', () => { + duration.value = audio.duration; + }); + + audio.addEventListener('timeupdate', () => { + currentTime.value = audio.currentTime; + }); + + audio.addEventListener('play', () => { + isPlaying.value = true; + }); + + audio.addEventListener('pause', () => { + isPlaying.value = false; + }); + + audio.addEventListener('ended', () => { + isPlaying.value = false; + currentTime.value = 0; + }); + }; + + const play = () => { + if (audioElement.value) { + audioElement.value.play(); + } + }; + + const pause = () => { + if (audioElement.value) { + audioElement.value.pause(); + } + }; + + const togglePlayPause = () => { + if (isPlaying.value) { + pause(); + } else { + play(); + } + }; + + const seek = (time) => { + if (audioElement.value) { + audioElement.value.currentTime = time; + currentTime.value = time; + } + }; + + const seekPercent = (percent) => { + if (audioElement.value && duration.value) { + const time = (percent / 100) * duration.value; + seek(time); + } + }; + + const skip = (seconds) => { + if (audioElement.value) { + const newTime = Math.max(0, Math.min(duration.value, currentTime.value + seconds)); + seek(newTime); + } + }; + + const setPlaybackRate = (rate) => { + playbackRate.value = rate; + if (audioElement.value) { + audioElement.value.playbackRate = rate; + } + }; + + const reset = () => { + if (audioElement.value) { + audioElement.value.pause(); + audioElement.value.currentTime = 0; + } + isPlaying.value = false; + currentTime.value = 0; + duration.value = 0; + }; + + return { + // State + isPlaying, + currentTime, + duration, + playbackRate, + audioElement, + + // Computed + progress, + formattedCurrentTime, + formattedDuration, + + // Methods + initPlayer, + play, + pause, + togglePlayPause, + seek, + seekPercent, + skip, + setPlaybackRate, + reset + }; +} diff --git a/static/js/composables/useRecordings.js b/static/js/composables/useRecordings.js new file mode 100644 index 0000000..67fc632 --- /dev/null +++ b/static/js/composables/useRecordings.js @@ -0,0 +1,327 @@ +/** + * Recordings composable + * Handles recordings list, selection, and CRUD operations + */ + +import { ref, computed } from 'vue'; +import { apiRequest } from '../utils/apiClient.js'; + +export function useRecordings() { + // State + const recordings = ref([]); + const selectedRecording = ref(null); + const isLoadingRecordings = ref(true); + const globalError = ref(null); + const currentView = ref('upload'); + const availableTags = ref([]); + const selectedTagIds = ref([]); + const showTagModal = ref(false); + const showDeleteModal = ref(false); + const recordingToDelete = ref(null); + + // Computed + const completedRecordings = computed(() => { + return recordings.value.filter(r => r.status === 'COMPLETED'); + }); + + const processingRecordings = computed(() => { + return recordings.value.filter(r => ['PENDING', 'PROCESSING', 'SUMMARIZING'].includes(r.status)); + }); + + const hasRecordings = computed(() => recordings.value.length > 0); + + // Methods + const loadRecordings = async (page = 1, filters = {}) => { + globalError.value = null; + isLoadingRecordings.value = true; + + try { + let endpoint = '/api/recordings'; + if (filters.archived) { + endpoint = '/api/recordings/archived'; + } else if (filters.sharedWithMe) { + endpoint = '/api/recordings/shared-with-me'; + } + + const params = new URLSearchParams({ + page: page.toString(), + per_page: '25' + }); + + if (filters.query) { + params.set('q', filters.query.trim()); + } + + const response = await fetch(`${endpoint}?${params}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to load recordings'); + } + + const recordingsList = filters.archived || filters.sharedWithMe ? data : data.recordings; + + if (!Array.isArray(recordingsList)) { + throw new Error('Invalid response format'); + } + + recordings.value = recordingsList; + + // Restore last selected recording + const lastRecordingId = localStorage.getItem('lastSelectedRecordingId'); + if (lastRecordingId && recordingsList.length > 0) { + const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId); + if (recordingToSelect) { + selectRecording(recordingToSelect); + } + } + + return filters.archived || filters.sharedWithMe ? null : data.pagination; + + } catch (error) { + globalError.value = error.message; + throw error; + } finally { + isLoadingRecordings.value = false; + } + }; + + const selectRecording = async (recording) => { + if (!recording) return; + + selectedRecording.value = recording; + currentView.value = 'recording'; + localStorage.setItem('lastSelectedRecordingId', recording.id); + + // Load full recording details if needed + if (!recording.transcription && recording.status === 'COMPLETED') { + try { + const data = await apiRequest(`/api/recordings/${recording.id}`); + Object.assign(selectedRecording.value, data); + } catch (error) { + console.error('Error loading recording details:', error); + } + } + }; + + const deselectRecording = () => { + selectedRecording.value = null; + currentView.value = 'upload'; + localStorage.removeItem('lastSelectedRecordingId'); + }; + + const deleteRecording = async (recordingId) => { + try { + await apiRequest(`/api/recordings/${recordingId}`, { + method: 'DELETE' + }); + + recordings.value = recordings.value.filter(r => r.id !== recordingId); + + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + deselectRecording(); + } + + return true; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const archiveRecording = async (recordingId) => { + try { + await apiRequest(`/api/recordings/${recordingId}/archive`, { + method: 'POST' + }); + + recordings.value = recordings.value.filter(r => r.id !== recordingId); + + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + deselectRecording(); + } + + return true; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const unarchiveRecording = async (recordingId) => { + try { + await apiRequest(`/api/recordings/${recordingId}/unarchive`, { + method: 'POST' + }); + + recordings.value = recordings.value.filter(r => r.id !== recordingId); + + return true; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const updateRecording = async (recordingId, updates) => { + try { + const data = await apiRequest(`/api/recordings/${recordingId}`, { + method: 'PUT', + body: JSON.stringify(updates) + }); + + const index = recordings.value.findIndex(r => r.id === recordingId); + if (index > -1) { + Object.assign(recordings.value[index], data.recording || data); + } + + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + Object.assign(selectedRecording.value, data.recording || data); + } + + return data.recording || data; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const regenerateSummary = async (recordingId, customPrompt = null) => { + try { + const body = customPrompt ? { custom_prompt: customPrompt } : {}; + const data = await apiRequest(`/api/recordings/${recordingId}/regenerate-summary`, { + method: 'POST', + body: JSON.stringify(body) + }); + + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + selectedRecording.value.status = 'SUMMARIZING'; + } + + return data; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const loadTags = async () => { + try { + const data = await apiRequest('/api/tags'); + availableTags.value = data; + } catch (error) { + console.error('Error loading tags:', error); + } + }; + + const addTagToRecording = async (recordingId, tagId) => { + try { + const data = await apiRequest(`/api/recordings/${recordingId}/tags`, { + method: 'POST', + body: JSON.stringify({ tag_id: tagId }) + }); + + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + selectedRecording.value.tags = data.tags || []; + } + + return data; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const removeTagFromRecording = async (recordingId, tagId) => { + try { + await apiRequest(`/api/recordings/${recordingId}/tags/${tagId}`, { + method: 'DELETE' + }); + + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tagId); + } + + return true; + } catch (error) { + globalError.value = error.message; + throw error; + } + }; + + const toggleHighlight = async (recordingId) => { + const recording = recordings.value.find(r => r.id === recordingId); + if (!recording) return; + + const newValue = !recording.is_highlighted; + + try { + await updateRecording(recordingId, { is_highlighted: newValue }); + } catch (error) { + throw error; + } + }; + + const setGlobalError = (message) => { + globalError.value = message; + }; + + const clearGlobalError = () => { + globalError.value = null; + }; + + const confirmDelete = (recording) => { + recordingToDelete.value = recording; + showDeleteModal.value = true; + }; + + const cancelDelete = () => { + recordingToDelete.value = null; + showDeleteModal.value = false; + }; + + const executeDelete = async () => { + if (recordingToDelete.value) { + await deleteRecording(recordingToDelete.value.id); + cancelDelete(); + } + }; + + return { + // State + recordings, + selectedRecording, + isLoadingRecordings, + globalError, + currentView, + availableTags, + selectedTagIds, + showTagModal, + showDeleteModal, + recordingToDelete, + + // Computed + completedRecordings, + processingRecordings, + hasRecordings, + + // Methods + loadRecordings, + selectRecording, + deselectRecording, + deleteRecording, + archiveRecording, + unarchiveRecording, + updateRecording, + regenerateSummary, + loadTags, + addTagToRecording, + removeTagFromRecording, + toggleHighlight, + setGlobalError, + clearGlobalError, + confirmDelete, + cancelDelete, + executeDelete + }; +} diff --git a/static/js/composables/useSharing.js b/static/js/composables/useSharing.js new file mode 100644 index 0000000..60706c4 --- /dev/null +++ b/static/js/composables/useSharing.js @@ -0,0 +1,195 @@ +/** + * Sharing composable + * Handles public and internal sharing functionality + */ + +import { ref } from 'vue'; +import { apiRequest } from '../utils/apiClient.js'; + +export function useSharing() { + // State + const showShareModal = ref(false); + const showInternalShareModal = ref(false); + const shareUrl = ref(''); + const shareSettings = ref({ + shareSummary: true, + shareNotes: true + }); + const internalShareSettings = ref({ + userId: null, + canEdit: false, + canReshare: false + }); + const isLoadingShare = ref(false); + const shareError = ref(null); + + // Methods + const openShareModal = async (recording) => { + showShareModal.value = true; + shareError.value = null; + isLoadingShare.value = true; + + try { + const data = await apiRequest(`/api/recording/${recording.id}/share`); + + if (data.exists) { + shareUrl.value = data.share_url; + shareSettings.value = { + shareSummary: data.share.share_summary, + shareNotes: data.share.share_notes + }; + } else { + shareUrl.value = ''; + } + } catch (error) { + shareError.value = error.message; + } finally { + isLoadingShare.value = false; + } + }; + + const createShare = async (recordingId) => { + isLoadingShare.value = true; + shareError.value = null; + + try { + const data = await apiRequest(`/api/recording/${recordingId}/share`, { + method: 'POST', + body: JSON.stringify(shareSettings.value) + }); + + shareUrl.value = data.share_url; + return data; + } catch (error) { + shareError.value = error.message; + throw error; + } finally { + isLoadingShare.value = false; + } + }; + + const updateShare = async (shareId) => { + isLoadingShare.value = true; + shareError.value = null; + + try { + const data = await apiRequest(`/api/share/${shareId}`, { + method: 'PUT', + body: JSON.stringify(shareSettings.value) + }); + + return data; + } catch (error) { + shareError.value = error.message; + throw error; + } finally { + isLoadingShare.value = false; + } + }; + + const deleteShare = async (shareId) => { + isLoadingShare.value = true; + shareError.value = null; + + try { + await apiRequest(`/api/share/${shareId}`, { + method: 'DELETE' + }); + + shareUrl.value = ''; + } catch (error) { + shareError.value = error.message; + throw error; + } finally { + isLoadingShare.value = false; + } + }; + + const copyShareUrl = async () => { + try { + await navigator.clipboard.writeText(shareUrl.value); + return true; + } catch (error) { + console.error('Failed to copy:', error); + return false; + } + }; + + const openInternalShareModal = (recording) => { + showInternalShareModal.value = true; + shareError.value = null; + internalShareSettings.value = { + userId: null, + canEdit: false, + canReshare: false + }; + }; + + const shareInternally = async (recordingId) => { + isLoadingShare.value = true; + shareError.value = null; + + try { + const data = await apiRequest(`/api/recordings/${recordingId}/share-internal`, { + method: 'POST', + body: JSON.stringify({ + user_id: internalShareSettings.value.userId, + can_edit: internalShareSettings.value.canEdit, + can_reshare: internalShareSettings.value.canReshare + }) + }); + + return data; + } catch (error) { + shareError.value = error.message; + throw error; + } finally { + isLoadingShare.value = false; + } + }; + + const revokeInternalShare = async (shareId) => { + isLoadingShare.value = true; + shareError.value = null; + + try { + await apiRequest(`/api/internal-shares/${shareId}`, { + method: 'DELETE' + }); + } catch (error) { + shareError.value = error.message; + throw error; + } finally { + isLoadingShare.value = false; + } + }; + + const closeShareModal = () => { + showShareModal.value = false; + showInternalShareModal.value = false; + shareUrl.value = ''; + shareError.value = null; + }; + + return { + // State + showShareModal, + showInternalShareModal, + shareUrl, + shareSettings, + internalShareSettings, + isLoadingShare, + shareError, + + // Methods + openShareModal, + createShare, + updateShare, + deleteShare, + copyShareUrl, + openInternalShareModal, + shareInternally, + revokeInternalShare, + closeShareModal + }; +} diff --git a/static/js/composables/useTranscript.js b/static/js/composables/useTranscript.js new file mode 100644 index 0000000..0c2032e --- /dev/null +++ b/static/js/composables/useTranscript.js @@ -0,0 +1,209 @@ +/** + * Transcript composable + * Handles transcript viewing and editing functionality + */ + +import { ref, computed } from 'vue'; +import { apiRequest } from '../utils/apiClient.js'; + +export function useTranscript() { + // State + const selectedTab = ref('summary'); + const isEditingTranscript = ref(false); + const editedTranscription = ref(''); + const isEditingSummary = ref(false); + const editedSummary = ref(''); + const isEditingNotes = ref(false); + const editedNotes = ref(''); + const isInlineEditingTitle = ref(false); + const editedTitle = ref(''); + const isSavingChanges = ref(false); + const transcriptSearchQuery = ref(''); + const highlightedText = ref(''); + + // Methods + const setTab = (tab) => { + selectedTab.value = tab; + }; + + const startEditingTranscript = (recording) => { + isEditingTranscript.value = true; + editedTranscription.value = recording.transcription || ''; + }; + + const cancelEditingTranscript = () => { + isEditingTranscript.value = false; + editedTranscription.value = ''; + }; + + const saveTranscript = async (recordingId) => { + isSavingChanges.value = true; + + try { + const data = await apiRequest(`/api/recordings/${recordingId}/transcript`, { + method: 'PUT', + body: JSON.stringify({ + transcription: editedTranscription.value + }) + }); + + isEditingTranscript.value = false; + return data.recording; + } catch (error) { + throw error; + } finally { + isSavingChanges.value = false; + } + }; + + const startEditingSummary = (recording) => { + isEditingSummary.value = true; + editedSummary.value = recording.summary || ''; + }; + + const cancelEditingSummary = () => { + isEditingSummary.value = false; + editedSummary.value = ''; + }; + + const saveSummary = async (recordingId) => { + isSavingChanges.value = true; + + try { + const data = await apiRequest(`/api/recordings/${recordingId}/summary`, { + method: 'PUT', + body: JSON.stringify({ + summary: editedSummary.value + }) + }); + + isEditingSummary.value = false; + return data.recording; + } catch (error) { + throw error; + } finally { + isSavingChanges.value = false; + } + }; + + const startEditingNotes = (recording) => { + isEditingNotes.value = true; + editedNotes.value = recording.notes || ''; + }; + + const cancelEditingNotes = () => { + isEditingNotes.value = false; + editedNotes.value = ''; + }; + + const saveNotes = async (recordingId) => { + isSavingChanges.value = true; + + try { + const data = await apiRequest(`/api/recordings/${recordingId}/notes`, { + method: 'PUT', + body: JSON.stringify({ + notes: editedNotes.value + }) + }); + + isEditingNotes.value = false; + return data.recording; + } catch (error) { + throw error; + } finally { + isSavingChanges.value = false; + } + }; + + const startEditingTitle = (recording) => { + isInlineEditingTitle.value = true; + editedTitle.value = recording.title || ''; + }; + + const cancelEditingTitle = () => { + isInlineEditingTitle.value = false; + editedTitle.value = ''; + }; + + const saveTitle = async (recordingId) => { + isSavingChanges.value = true; + + try { + const data = await apiRequest(`/api/recordings/${recordingId}`, { + method: 'PUT', + body: JSON.stringify({ + title: editedTitle.value + }) + }); + + isInlineEditingTitle.value = false; + return data.recording; + } catch (error) { + throw error; + } finally { + isSavingChanges.value = false; + } + }; + + const searchInTranscript = (text, query) => { + if (!query) { + highlightedText.value = text; + return text; + } + + const regex = new RegExp(`(${query})`, 'gi'); + highlightedText.value = text.replace(regex, '$1'); + return highlightedText.value; + }; + + const exportTranscript = async (recordingId, format) => { + try { + const response = await fetch(`/api/recordings/${recordingId}/export/${format}`); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `transcript.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } catch (error) { + throw error; + } + }; + + return { + // State + selectedTab, + isEditingTranscript, + editedTranscription, + isEditingSummary, + editedSummary, + isEditingNotes, + editedNotes, + isInlineEditingTitle, + editedTitle, + isSavingChanges, + transcriptSearchQuery, + highlightedText, + + // Methods + setTab, + startEditingTranscript, + cancelEditingTranscript, + saveTranscript, + startEditingSummary, + cancelEditingSummary, + saveSummary, + startEditingNotes, + cancelEditingNotes, + saveNotes, + startEditingTitle, + cancelEditingTitle, + saveTitle, + searchInTranscript, + exportTranscript + }; +} diff --git a/static/js/composables/useUI.js b/static/js/composables/useUI.js new file mode 100644 index 0000000..f5d9dce --- /dev/null +++ b/static/js/composables/useUI.js @@ -0,0 +1,147 @@ +/** + * UI State composable + * Handles all UI-related state (theme, sidebar, modals, etc.) + */ + +import { ref, computed, watch, onMounted } from 'vue'; + +export function useUI() { + // State + const browser = ref('unknown'); + const isSidebarCollapsed = ref(false); + const searchTipsExpanded = ref(false); + const isUserMenuOpen = ref(false); + const isDarkMode = ref(false); + const currentColorScheme = ref('blue'); + const showColorSchemeModal = ref(false); + const windowWidth = ref(window.innerWidth); + const mobileTab = ref('transcript'); + const isMetadataExpanded = ref(false); + const currentLanguage = ref('en'); + const currentLanguageName = ref('English'); + const availableLanguages = ref([]); + const showLanguageMenu = ref(false); + + // Computed + const isMobile = computed(() => windowWidth.value < 768); + const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1024); + const isDesktop = computed(() => windowWidth.value >= 1024); + + const colorSchemes = [ + { name: 'blue', label: 'Blue', primary: '#3b82f6', hover: '#2563eb' }, + { name: 'purple', label: 'Purple', primary: '#8b5cf6', hover: '#7c3aed' }, + { name: 'green', label: 'Green', primary: '#10b981', hover: '#059669' }, + { name: 'orange', label: 'Orange', primary: '#f59e0b', hover: '#d97706' }, + { name: 'pink', label: 'Pink', primary: '#ec4899', hover: '#db2777' }, + { name: 'red', label: 'Red', primary: '#ef4444', hover: '#dc2626' } + ]; + + // Methods + const detectBrowser = () => { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf('firefox') > -1) browser.value = 'firefox'; + else if (userAgent.indexOf('chrome') > -1 && userAgent.indexOf('edge') === -1) browser.value = 'chrome'; + else if (userAgent.indexOf('safari') > -1 && userAgent.indexOf('chrome') === -1) browser.value = 'safari'; + else if (userAgent.indexOf('edge') > -1) browser.value = 'edge'; + else browser.value = 'unknown'; + }; + + const toggleSidebar = () => { + isSidebarCollapsed.value = !isSidebarCollapsed.value; + localStorage.setItem('sidebarCollapsed', isSidebarCollapsed.value.toString()); + }; + + const toggleDarkMode = () => { + isDarkMode.value = !isDarkMode.value; + document.documentElement.classList.toggle('dark', isDarkMode.value); + localStorage.setItem('darkMode', isDarkMode.value ? 'enabled' : 'disabled'); + }; + + const setColorScheme = (scheme) => { + currentColorScheme.value = scheme; + document.documentElement.setAttribute('data-color-scheme', scheme); + localStorage.setItem('colorScheme', scheme); + }; + + const loadUIPreferences = () => { + // Load dark mode + const savedDarkMode = localStorage.getItem('darkMode'); + if (savedDarkMode === 'enabled') { + isDarkMode.value = true; + document.documentElement.classList.add('dark'); + } + + // Load color scheme + const savedScheme = localStorage.getItem('colorScheme'); + if (savedScheme && colorSchemes.find(s => s.name === savedScheme)) { + setColorScheme(savedScheme); + } + + // Load sidebar state + const savedSidebar = localStorage.getItem('sidebarCollapsed'); + if (savedSidebar === 'true') { + isSidebarCollapsed.value = true; + } + }; + + const handleResize = () => { + windowWidth.value = window.innerWidth; + }; + + const toggleUserMenu = () => { + isUserMenuOpen.value = !isUserMenuOpen.value; + }; + + const closeUserMenu = () => { + isUserMenuOpen.value = false; + }; + + const setMobileTab = (tab) => { + mobileTab.value = tab; + }; + + const toggleMetadata = () => { + isMetadataExpanded.value = !isMetadataExpanded.value; + }; + + // Initialize + onMounted(() => { + detectBrowser(); + loadUIPreferences(); + window.addEventListener('resize', handleResize); + }); + + return { + // State + browser, + isSidebarCollapsed, + searchTipsExpanded, + isUserMenuOpen, + isDarkMode, + currentColorScheme, + showColorSchemeModal, + windowWidth, + mobileTab, + isMetadataExpanded, + currentLanguage, + currentLanguageName, + availableLanguages, + showLanguageMenu, + + // Computed + isMobile, + isTablet, + isDesktop, + colorSchemes, + + // Methods + toggleSidebar, + toggleDarkMode, + setColorScheme, + loadUIPreferences, + toggleUserMenu, + closeUserMenu, + setMobileTab, + toggleMetadata + }; +} diff --git a/static/js/composables/useUpload.js b/static/js/composables/useUpload.js new file mode 100644 index 0000000..48db06d --- /dev/null +++ b/static/js/composables/useUpload.js @@ -0,0 +1,280 @@ +/** + * Upload composable + * Handles file upload queue and processing + */ + +import { ref, computed, nextTick } from 'vue'; +import { uploadFile } from '../utils/apiClient.js'; + +export function useUpload() { + // State + const uploadQueue = ref([]); + const currentlyProcessingFile = ref(null); + const processingProgress = ref(0); + const processingMessage = ref(''); + const isProcessingActive = ref(false); + const pollInterval = ref(null); + const progressPopupMinimized = ref(false); + const progressPopupClosed = ref(false); + const maxFileSizeMB = ref(250); + const chunkingEnabled = ref(true); + const dragover = ref(false); + + // Computed + const hasQueuedFiles = computed(() => { + return uploadQueue.value.some(item => item.status === 'queued'); + }); + + const processingCount = computed(() => { + return uploadQueue.value.filter(item => item.status === 'processing' || item.status === 'queued').length; + }); + + const completedCount = computed(() => { + return uploadQueue.value.filter(item => item.status === 'completed').length; + }); + + const errorCount = computed(() => { + return uploadQueue.value.filter(item => item.status === 'error').length; + }); + + // Methods + const addFilesToQueue = (files) => { + const maxFileSize = maxFileSizeMB.value * 1024 * 1024; + + for (const file of files) { + if (file.size > maxFileSize) { + uploadQueue.value.push({ + file, + status: 'error', + error: `File exceeds maximum size of ${maxFileSizeMB.value}MB`, + clientId: Date.now() + Math.random() + }); + continue; + } + + const isAudio = file.type.startsWith('audio/') || + file.type.startsWith('video/') || + /\.(mp3|wav|ogg|m4a|flac|webm|weba|mp4|mov|avi|mkv)$/i.test(file.name); + + if (!isAudio) { + uploadQueue.value.push({ + file, + status: 'error', + error: 'File type not supported', + clientId: Date.now() + Math.random() + }); + continue; + } + + uploadQueue.value.push({ + file, + status: 'queued', + recordingId: null, + clientId: Date.now() + Math.random(), + error: null + }); + } + + if (!isProcessingActive.value && hasQueuedFiles.value) { + startProcessingQueue(); + } + }; + + const startProcessingQueue = async () => { + if (isProcessingActive.value) return; + + const nextItem = uploadQueue.value.find(item => item.status === 'queued'); + if (!nextItem) { + isProcessingActive.value = false; + return; + } + + isProcessingActive.value = true; + currentlyProcessingFile.value = nextItem; + nextItem.status = 'uploading'; + processingProgress.value = 0; + processingMessage.value = 'Uploading...'; + + try { + const data = await uploadFile('/api/recordings/upload', nextItem.file, (progress) => { + processingProgress.value = progress; + processingMessage.value = `Uploading... ${Math.round(progress)}%`; + }); + + nextItem.recordingId = data.recording_id; + nextItem.status = 'processing'; + processingMessage.value = 'Processing...'; + + // Start polling for status + pollProcessingStatus(nextItem); + + } catch (error) { + nextItem.status = 'error'; + nextItem.error = error.message; + currentlyProcessingFile.value = null; + isProcessingActive.value = false; + + // Continue with next file + if (hasQueuedFiles.value) { + await nextTick(); + startProcessingQueue(); + } + } + }; + + const pollProcessingStatus = (queueItem) => { + if (pollInterval.value) { + clearInterval(pollInterval.value); + } + + pollInterval.value = setInterval(async () => { + try { + const response = await fetch(`/api/recordings/${queueItem.recordingId}/status`); + const data = await response.json(); + + if (data.status === 'COMPLETED') { + clearInterval(pollInterval.value); + pollInterval.value = null; + + queueItem.status = 'completed'; + currentlyProcessingFile.value = null; + isProcessingActive.value = false; + processingProgress.value = 100; + processingMessage.value = 'Complete!'; + + // Continue with next file + if (hasQueuedFiles.value) { + await nextTick(); + startProcessingQueue(); + } + + } else if (data.status === 'ERROR') { + clearInterval(pollInterval.value); + pollInterval.value = null; + + queueItem.status = 'error'; + queueItem.error = data.error_message || 'Processing failed'; + currentlyProcessingFile.value = null; + isProcessingActive.value = false; + + // Continue with next file + if (hasQueuedFiles.value) { + await nextTick(); + startProcessingQueue(); + } + + } else { + // Still processing + if (data.status === 'SUMMARIZING') { + processingMessage.value = 'Generating summary...'; + processingProgress.value = 80; + } else { + processingMessage.value = 'Transcribing...'; + processingProgress.value = 50; + } + } + + } catch (error) { + console.error('Error polling status:', error); + } + }, 3000); + }; + + const removeFromQueue = (clientId) => { + const index = uploadQueue.value.findIndex(item => item.clientId === clientId); + if (index > -1) { + uploadQueue.value.splice(index, 1); + } + }; + + const clearCompletedFromQueue = () => { + uploadQueue.value = uploadQueue.value.filter(item => + item.status !== 'completed' && item.status !== 'error' + ); + }; + + const handleDragEnter = (event) => { + event.preventDefault(); + dragover.value = true; + }; + + const handleDragLeave = (event) => { + event.preventDefault(); + dragover.value = false; + }; + + const handleDrop = (event) => { + event.preventDefault(); + dragover.value = false; + + const files = Array.from(event.dataTransfer.files); + if (files.length > 0) { + addFilesToQueue(files); + } + }; + + const handleFileSelect = (event) => { + const files = Array.from(event.target.files); + if (files.length > 0) { + addFilesToQueue(files); + } + event.target.value = ''; + }; + + const minimizeProgressPopup = () => { + progressPopupMinimized.value = true; + }; + + const maximizeProgressPopup = () => { + progressPopupMinimized.value = false; + }; + + const closeProgressPopup = () => { + progressPopupClosed.value = true; + }; + + const loadConfig = async () => { + try { + const response = await fetch('/api/config'); + const data = await response.json(); + maxFileSizeMB.value = data.max_file_size_mb || 250; + chunkingEnabled.value = data.chunking_enabled !== false; + } catch (error) { + console.error('Error loading config:', error); + } + }; + + return { + // State + uploadQueue, + currentlyProcessingFile, + processingProgress, + processingMessage, + isProcessingActive, + progressPopupMinimized, + progressPopupClosed, + maxFileSizeMB, + chunkingEnabled, + dragover, + + // Computed + hasQueuedFiles, + processingCount, + completedCount, + errorCount, + + // Methods + addFilesToQueue, + startProcessingQueue, + removeFromQueue, + clearCompletedFromQueue, + handleDragEnter, + handleDragLeave, + handleDrop, + handleFileSelect, + minimizeProgressPopup, + maximizeProgressPopup, + closeProgressPopup, + loadConfig + }; +} diff --git a/static/js/config/push-config.js b/static/js/config/push-config.js new file mode 100644 index 0000000..0d5932a --- /dev/null +++ b/static/js/config/push-config.js @@ -0,0 +1,78 @@ +/** + * Push Notification Configuration + * + * AUTO-CONFIGURATION: + * ------------------ + * Push notifications are now auto-configured! + * + * On first server startup: + * 1. VAPID keys are automatically generated (requires pywebpush) + * 2. Keys are saved to /config/vapid_keys.json (persists across restarts) + * 3. Public key is served via /api/push/config + * 4. Client fetches config dynamically + * + * No manual configuration needed - just make sure: + * - pywebpush is installed: pip install pywebpush + * - /config directory is mounted as Docker volume (for persistence) + */ + +// Cached config fetched from server +let cachedConfig = null; + +/** + * Fetch push notification config from server + */ +export async function getPushConfig() { + if (cachedConfig) { + return cachedConfig; + } + + try { + const response = await fetch('/api/push/config'); + if (!response.ok) { + console.warn('[Push Config] Failed to fetch config:', response.status); + return { enabled: false, public_key: null }; + } + + cachedConfig = await response.json(); + console.log('[Push Config] Loaded from server:', cachedConfig.enabled ? 'enabled' : 'disabled'); + return cachedConfig; + } catch (error) { + console.error('[Push Config] Error fetching config:', error); + return { enabled: false, public_key: null }; + } +} + +/** + * Check if push notifications are enabled + */ +export async function isPushEnabled() { + const config = await getPushConfig(); + return config.enabled && !!config.public_key; +} + +/** + * Get VAPID public key from server + */ +export async function getPublicKey() { + const config = await getPushConfig(); + return config.public_key; +} + +/** + * Convert a base64 string to Uint8Array (required for push subscription) + */ +export function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/static/js/csrf-refresh.js b/static/js/csrf-refresh.js new file mode 100644 index 0000000..f1a8abe --- /dev/null +++ b/static/js/csrf-refresh.js @@ -0,0 +1,206 @@ +// CSRF Token Management with Auto-Refresh +class CSRFManager { + constructor() { + this.token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + this.refreshPromise = null; + this.setupFetchInterceptor(); + } + + async refreshToken() { + // Prevent multiple simultaneous refresh requests + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.performTokenRefresh(); + try { + const newToken = await this.refreshPromise; + return newToken; + } finally { + this.refreshPromise = null; + } + } + + async performTokenRefresh() { + try { + console.log('Refreshing CSRF token...'); + + // Use the original fetch to avoid recursion + const originalFetch = window.originalFetch || fetch; + const response = await originalFetch('/api/csrf-token', { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to refresh CSRF token: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + throw new Error(`Expected JSON response but got ${contentType}. Response: ${text.substring(0, 200)}`); + } + + const data = await response.json(); + if (data.csrf_token) { + this.token = data.csrf_token; + // Update the meta tag for any other code that might read it + const metaTag = document.querySelector('meta[name="csrf-token"]'); + if (metaTag) { + metaTag.setAttribute('content', this.token); + } + + // Update Vue.js reactive token if available + if (window.app && window.app.csrfToken !== undefined) { + window.app.csrfToken = this.token; + } + + console.log('CSRF token refreshed successfully'); + return this.token; + } else { + throw new Error('No CSRF token in response'); + } + } catch (error) { + console.error('Failed to refresh CSRF token:', error); + throw error; + } + } + + setupFetchInterceptor() { + // Store original fetch if not already stored + if (!window.originalFetch) { + window.originalFetch = window.fetch; + } + + const originalFetch = window.originalFetch; + const self = this; + + window.fetch = async function(url, options = {}) { + // Skip CSRF token for the token refresh endpoint to avoid recursion + if (url.includes('/api/csrf-token')) { + return originalFetch(url, options); + } + + // Add CSRF token to headers for API requests + const newOptions = { ...options }; + if (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') || + url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers')) { + + newOptions.headers = { + 'X-CSRFToken': self.token, + ...newOptions.headers + }; + } + + // Make the request + let response = await originalFetch(url, newOptions); + + // Check for CSRF token expiration + if ((response.status === 400 || response.status === 403) && + (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') || + url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers'))) { + + try { + // Try to parse as JSON first + const responseClone = response.clone(); + let isCSRFError = false; + + try { + const errorData = await responseClone.json(); + const errorMessage = errorData.error || ''; + isCSRFError = errorMessage.toLowerCase().includes('csrf') || + errorMessage.toLowerCase().includes('token'); + } catch (jsonError) { + // If JSON parsing fails, check if it's an HTML error page + const textResponse = await response.clone().text(); + isCSRFError = textResponse.toLowerCase().includes('csrf') || + textResponse.toLowerCase().includes('token') || + textResponse.includes(' { + window.csrfManager = new CSRFManager(); + + // Set up periodic token refresh every 45 minutes (before 1-hour expiration) + setInterval(() => { + if (window.csrfManager) { + console.log('Performing periodic CSRF token refresh...'); + window.csrfManager.manualRefresh(); + } + }, 45 * 60 * 1000); // 45 minutes + + // Refresh CSRF token when page becomes visible again (wake from sleep, tab switch) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && window.csrfManager) { + console.log('[CSRF] Page visible again, refreshing token...'); + window.csrfManager.manualRefresh(); + } + }); + + // Heartbeat gap detection: if setInterval drifts > 2 min, system was asleep + let lastHeartbeat = Date.now(); + setInterval(() => { + const now = Date.now(); + const drift = now - lastHeartbeat - 60000; + if (drift > 120000) { + console.log(`[CSRF] Heartbeat drift ${Math.round(drift / 1000)}s detected, refreshing token...`); + if (window.csrfManager) { + window.csrfManager.manualRefresh(); + } + } + lastHeartbeat = now; + }, 60000); +}); diff --git a/static/js/i18n.js b/static/js/i18n.js new file mode 100644 index 0000000..8c07785 --- /dev/null +++ b/static/js/i18n.js @@ -0,0 +1,297 @@ +/** + * Lightweight i18n (internationalization) system for Speakr + * Handles loading and managing translations with template variable support + */ + +class I18n { + constructor() { + this.translations = {}; + this.currentLocale = 'en'; + this.fallbackLocale = 'en'; + this.loadedLocales = new Set(); + } + + /** + * Initialize i18n with default locale + * @param {string} locale - Initial locale code (e.g., 'en', 'es', 'fr', 'zh') + */ + async init(locale = 'en') { + // Get saved locale from localStorage or use browser language + const savedLocale = localStorage.getItem('preferredLanguage'); + const browserLocale = navigator.language.split('-')[0]; + + this.currentLocale = savedLocale || locale || browserLocale || 'en'; + + // Load the initial locale + await this.loadLocale(this.currentLocale); + + // Load fallback locale if different + if (this.currentLocale !== this.fallbackLocale) { + await this.loadLocale(this.fallbackLocale); + } + } + + /** + * Load translations for a specific locale + * @param {string} locale - Locale code to load + */ + async loadLocale(locale) { + if (this.loadedLocales.has(locale)) { + return; // Already loaded + } + + try { + const response = await fetch(`/static/locales/${locale}.json`); + if (!response.ok) { + throw new Error(`Failed to load locale: ${locale}`); + } + + const translations = await response.json(); + this.translations[locale] = translations; + this.loadedLocales.add(locale); + + console.log(`Loaded locale: ${locale}`); + } catch (error) { + console.error(`Error loading locale ${locale}:`, error); + + // If failed to load requested locale and it's not the fallback, try fallback + if (locale !== this.fallbackLocale) { + console.log(`Failed to load ${locale}, will use ${this.fallbackLocale} as fallback`); + // Don't change currentLocale - keep user's preference + // Just ensure fallback translations are available + await this.loadLocale(this.fallbackLocale); + } + } + } + + /** + * Change the current locale + * @param {string} locale - New locale code + */ + async setLocale(locale) { + await this.loadLocale(locale); + this.currentLocale = locale; + localStorage.setItem('preferredLanguage', locale); + + // Dispatch custom event for locale change + window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } })); + } + + /** + * Get the current locale + * @returns {string} Current locale code + */ + getLocale() { + return this.currentLocale; + } + + /** + * Get available locales + * @returns {Array} List of available locale codes + */ + getAvailableLocales() { + return [ + { code: 'en', name: 'English', nativeName: 'English' }, + { code: 'es', name: 'Spanish', nativeName: 'Español' }, + { code: 'fr', name: 'French', nativeName: 'Français' }, + { code: 'zh', name: 'Chinese', nativeName: '中文' }, + { code: 'ru', name: 'Russian', nativeName: 'Русский' }, + ]; + } + + /** + * Translate a key with optional parameters + * @param {string} key - Translation key (e.g., 'common.save' or 'nav.upload') + * @param {Object} params - Optional parameters for template replacement + * @param {string} locale - Optional specific locale (defaults to current) + * @returns {string} Translated text + */ + t(key, params = {}, locale = null) { + const targetLocale = locale || this.currentLocale; + + // Get translation from current locale or fallback + let translation = this.getNestedTranslation(targetLocale, key); + + if (!translation && targetLocale !== this.fallbackLocale) { + translation = this.getNestedTranslation(this.fallbackLocale, key); + } + + if (!translation) { + console.warn(`Translation not found for key: ${key}`); + return key; // Return the key itself as fallback + } + + // Replace template variables + return this.interpolate(translation, params); + } + + /** + * Get nested translation value from object + * @param {string} locale - Locale to search in + * @param {string} key - Dot-separated key path + * @returns {string|null} Translation value or null + */ + getNestedTranslation(locale, key) { + if (!this.translations[locale]) { + return null; + } + + const keys = key.split('.'); + let value = this.translations[locale]; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k]; + } else { + return null; + } + } + + return typeof value === 'string' ? value : null; + } + + /** + * Replace template variables in translation string + * @param {string} text - Text with placeholders like {{variable}} + * @param {Object} params - Parameters to replace + * @returns {string} Interpolated text + */ + interpolate(text, params) { + return text.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return params.hasOwnProperty(key) ? params[key] : match; + }); + } + + /** + * Handle pluralization + * @param {string} key - Base translation key + * @param {number} count - Count for pluralization + * @param {Object} params - Additional parameters + * @returns {string} Translated text with proper pluralization + */ + tc(key, count, params = {}) { + const pluralKey = count === 1 ? key : `${key}Plural`; + return this.t(pluralKey, { ...params, count }); + } + + /** + * Format date according to locale + * @param {Date|string} date - Date to format + * @param {Object} options - Intl.DateTimeFormat options + * @returns {string} Formatted date string + */ + formatDate(date, options = {}) { + const d = date instanceof Date ? date : new Date(date); + return new Intl.DateTimeFormat(this.currentLocale, options).format(d); + } + + /** + * Format number according to locale + * @param {number} number - Number to format + * @param {Object} options - Intl.NumberFormat options + * @returns {string} Formatted number string + */ + formatNumber(number, options = {}) { + return new Intl.NumberFormat(this.currentLocale, options).format(number); + } + + /** + * Format file size with appropriate unit + * @param {number} bytes - Size in bytes + * @returns {string} Formatted file size + */ + formatFileSize(bytes) { + const units = ['bytes', 'kilobytes', 'megabytes', 'gigabytes']; + const unitValues = [1, 1024, 1048576, 1073741824]; + + let unitIndex = 0; + for (let i = unitValues.length - 1; i >= 0; i--) { + if (bytes >= unitValues[i]) { + unitIndex = i; + break; + } + } + + const value = Math.round(bytes / unitValues[unitIndex] * 10) / 10; + return this.t(`fileSize.${units[unitIndex]}`, { count: value }); + } + + /** + * Format duration with appropriate unit + * @param {number} seconds - Duration in seconds + * @returns {string} Formatted duration + */ + formatDuration(seconds) { + if (seconds < 60) { + return this.tc('duration.seconds', Math.round(seconds), { count: Math.round(seconds) }); + } else if (seconds < 3600) { + const minutes = Math.round(seconds / 60); + return this.tc('duration.minutes', minutes, { count: minutes }); + } else { + const hours = Math.round(seconds / 3600 * 10) / 10; + return this.tc('duration.hours', hours, { count: hours }); + } + } + + /** + * Format relative time (e.g., "2 hours ago") + * @param {Date|string} date - Date to format + * @returns {string} Formatted relative time + */ + formatRelativeTime(date) { + const d = date instanceof Date ? date : new Date(date); + const now = new Date(); + const diffSeconds = Math.floor((now - d) / 1000); + + if (diffSeconds < 60) { + return this.t('time.justNow'); + } else if (diffSeconds < 3600) { + const minutes = Math.floor(diffSeconds / 60); + return minutes === 1 + ? this.t('time.minuteAgo') + : this.t('time.minutesAgo', { count: minutes }); + } else if (diffSeconds < 86400) { + const hours = Math.floor(diffSeconds / 3600); + return hours === 1 + ? this.t('time.hourAgo') + : this.t('time.hoursAgo', { count: hours }); + } else if (diffSeconds < 604800) { + const days = Math.floor(diffSeconds / 86400); + return days === 1 + ? this.t('time.dayAgo') + : this.t('time.daysAgo', { count: days }); + } else if (diffSeconds < 2592000) { + const weeks = Math.floor(diffSeconds / 604800); + return weeks === 1 + ? this.t('time.weekAgo') + : this.t('time.weeksAgo', { count: weeks }); + } else if (diffSeconds < 31536000) { + const months = Math.floor(diffSeconds / 2592000); + return months === 1 + ? this.t('time.monthAgo') + : this.t('time.monthsAgo', { count: months }); + } else { + const years = Math.floor(diffSeconds / 31536000); + return years === 1 + ? this.t('time.yearAgo') + : this.t('time.yearsAgo', { count: years }); + } + } +} + +// Create global i18n instance +const i18n = new I18n(); + +// Create a fallback t function immediately +if (typeof window !== 'undefined') { + // Ensure window.i18n exists with at least a basic t function + window.i18n = i18n; + + // Add a fallback t function if the class method isn't ready + if (!window.i18n.t) { + window.i18n.t = function(key, params) { + console.warn('i18n.t called before initialization, returning key:', key); + return key; + }; + } +} \ No newline at end of file diff --git a/static/js/loading.js b/static/js/loading.js new file mode 100644 index 0000000..630a59a --- /dev/null +++ b/static/js/loading.js @@ -0,0 +1,201 @@ +/** + * App loading overlay management + * Prevents FOUC (Flash of Unstyled Content) during page initialization + */ + +window.AppLoader = { + initialized: false, + readyChecks: [], + initTime: Date.now(), + + /** + * Initialize the loading system + */ + init() { + if (this.initialized) return; + this.initialized = true; + this.initTime = Date.now(); + + // Add loading class to body + document.body.classList.add('app-loading'); + + // Create loading overlay if it doesn't exist + if (!document.querySelector('.app-loading-overlay')) { + this.createOverlay(); + } + + // Set up ready checks + this.setupReadyChecks(); + }, + + /** + * Create the loading overlay element + */ + createOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'app-loading-overlay'; + overlay.innerHTML = ` +
+
+
Loading...
+
+ `; + document.body.appendChild(overlay); + }, + + /** + * Add a ready check condition + */ + addReadyCheck(checkFn) { + this.readyChecks.push(checkFn); + }, + + /** + * Setup default ready checks + */ + setupReadyChecks() { + // Check if DOM is ready + this.addReadyCheck(() => { + return document.readyState === 'complete' || document.readyState === 'interactive'; + }); + + // Check if styles are loaded (optional - don't block on this) + this.addReadyCheck(() => { + try { + const styles = document.querySelector('link[href*="styles.css"]'); + // If stylesheet isn't found or loaded, continue anyway after 2 seconds + return !styles || styles.sheet || (Date.now() - this.initTime) > 2000; + } catch (e) { + console.warn('Error checking stylesheet:', e); + return true; // Don't block on stylesheet errors + } + }); + + // Check if theme is initialized (optional - don't block on this) + this.addReadyCheck(() => { + try { + const computed = window.getComputedStyle(document.documentElement); + const bgPrimary = computed.getPropertyValue('--bg-primary').trim(); + // Accept if property exists or if 2 seconds have passed + return bgPrimary !== '' || (Date.now() - this.initTime) > 2000; + } catch (e) { + console.warn('Error checking CSS variables:', e); + return true; // Don't block on CSS variable errors + } + }); + }, + + /** + * Check if all conditions are met + */ + isReady() { + if (this.readyChecks.length === 0) return true; + return this.readyChecks.every(check => { + try { + return check(); + } catch (e) { + return false; + } + }); + }, + + /** + * Hide the loading overlay + */ + hide() { + try { + // Remove app-loading class immediately to show content + document.body.classList.remove('app-loading'); + + // Find all loading overlays (might be multiple) + const overlays = document.querySelectorAll('.app-loading-overlay'); + + if (overlays.length > 0) { + overlays.forEach(overlay => { + // Force display none immediately in Firefox + overlay.style.display = 'none'; + + // Then do graceful removal + overlay.classList.add('fade-out'); + setTimeout(() => { + try { + overlay.remove(); + } catch (e) { + console.warn('Could not remove overlay:', e); + } + }, 100); + }); + } + + console.log('Loader hidden successfully'); + } catch (error) { + console.error('Error hiding loader:', error); + // Force hide everything as last resort + document.body.classList.remove('app-loading'); + const overlays = document.querySelectorAll('.app-loading-overlay'); + overlays.forEach(o => { + o.style.display = 'none'; + try { o.remove(); } catch (e) {} + }); + } + }, + + /** + * Wait for app to be ready then hide overlay + */ + async waitForReady(timeout = 5000) { + const startTime = Date.now(); + let hideExecuted = false; + + const safeHide = () => { + if (!hideExecuted) { + hideExecuted = true; + try { + this.hide(); + } catch (error) { + console.error('Error hiding loader:', error); + // Force hide even if error occurs + const overlay = document.querySelector('.app-loading-overlay'); + if (overlay) overlay.remove(); + document.body.classList.remove('app-loading'); + } + } + }; + + const checkReady = () => { + try { + const elapsed = Date.now() - startTime; + if (this.isReady()) { + console.log('App ready, hiding loader'); + safeHide(); + } else if (elapsed > timeout) { + console.warn('Loader timeout reached, forcing hide'); + safeHide(); + } else { + requestAnimationFrame(checkReady); + } + } catch (error) { + console.error('Error in checkReady:', error); + safeHide(); + } + }; + + // Hard timeout as absolute failsafe - hide after 10 seconds no matter what + setTimeout(() => { + if (!hideExecuted) { + console.warn('Hard timeout reached (10s), forcing loader hide'); + safeHide(); + } + }, 10000); + + // Start checking after a minimum display time + setTimeout(checkReady, 300); + } +}; + +// Auto-initialize on script load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => AppLoader.init()); +} else { + AppLoader.init(); +} \ No newline at end of file diff --git a/static/js/modules/composables/audio.js b/static/js/modules/composables/audio.js new file mode 100644 index 0000000..5953a49 --- /dev/null +++ b/static/js/modules/composables/audio.js @@ -0,0 +1,895 @@ +/** + * Audio recording composable + * Handles microphone/system audio recording with visualizers and wake lock + */ + +import * as RecordingDB from '../db/recording-persistence.js'; +import * as IncognitoStorage from '../db/incognito-storage.js'; + +export function useAudio(state, utils) { + const { + isRecording, mediaRecorder, audioContext, analyser, micAnalyser, systemAnalyser, + audioChunks, recordingTime, recordingInterval, recordingMode, audioBlobURL, + estimatedFileSize, actualBitrate, recordingNotes, recordingQuality, + maxRecordingMB, fileSizeWarningShown, sizeCheckInterval, recordingDisclaimer, + showRecordingDisclaimerModal, pendingRecordingMode, currentView, isDarkMode, wakeLock, animationFrameId, + activeStreams, visualizer, micVisualizer, systemVisualizer, canRecordAudio, + canRecordSystemAudio, systemAudioSupported, systemAudioError, globalError, + selectedTagIds, selectedFolderId, asrLanguage, asrMinSpeakers, asrMaxSpeakers, uploadQueue, + progressPopupMinimized, progressPopupClosed, + // Incognito mode + enableIncognitoMode, incognitoMode, incognitoRecording, incognitoProcessing, + processingMessage, processingProgress, selectedRecording + } = state; + + const { showToast, setGlobalError, formatFileSize, startUploadQueue } = utils; + + // Local state for pending streams and chunk tracking + let pendingDisplayStream = null; + let currentChunkIndex = 0; + + // iOS detection + const isiOS = () => { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + }; + + // Silent audio for iOS wake lock alternative + let silentAudio = null; + + // Create silent audio using data URL (1 second of silence) + const createSilentAudio = () => { + if (!silentAudio) { + // Base64 encoded 1-second silent MP3 + const silentMp3 = 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7v////////////////////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQCgAAAAAAAAAOEfxVqYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQZDwP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU='; + silentAudio = new Audio(silentMp3); + silentAudio.loop = true; + silentAudio.volume = 0.01; // Very low volume, almost silent + } + return silentAudio; + }; + + // Start iOS wake lock (play silent audio) + const startiOSWakeLock = async () => { + try { + const audio = createSilentAudio(); + await audio.play(); + console.log('[iOS Wake Lock] Silent audio playing to prevent sleep'); + return true; + } catch (error) { + console.warn('[iOS Wake Lock] Failed to start silent audio:', error); + showToast('iOS wake lock may not work - keep screen active', 'warning'); + return false; + } + }; + + // Stop iOS wake lock (stop silent audio) + const stopiOSWakeLock = () => { + if (silentAudio) { + silentAudio.pause(); + silentAudio.currentTime = 0; + console.log('[iOS Wake Lock] Silent audio stopped'); + } + }; + + // Acquire wake lock to prevent screen from sleeping during recording + const acquireWakeLock = async () => { + // iOS doesn't support Wake Lock API - use silent audio instead + if (isiOS()) { + return await startiOSWakeLock(); + } + + // Android/Desktop: use native Wake Lock API + try { + if ('wakeLock' in navigator) { + wakeLock.value = await navigator.wakeLock.request('screen'); + console.log('[WakeLock] Acquired - screen will stay awake during recording'); + + // Listen for wake lock release + wakeLock.value.addEventListener('release', () => { + console.log('[WakeLock] Released'); + }); + + return true; + } else { + console.warn('[WakeLock] Wake Lock API not supported'); + showToast('Screen may sleep during recording', 'info'); + return false; + } + } catch (err) { + console.warn('[WakeLock] Could not acquire:', err.message); + if (err.name === 'NotAllowedError') { + showToast('Screen lock permission denied', 'warning'); + } else if (err.name === 'NotSupportedError') { + showToast('Wake lock not supported on this device', 'info'); + } + return false; + } + }; + + // Release wake lock + const releaseWakeLock = async () => { + // iOS: stop silent audio + if (isiOS()) { + stopiOSWakeLock(); + return; + } + + // Android/Desktop: release native wake lock + if (wakeLock.value) { + try { + await wakeLock.value.release(); + wakeLock.value = null; + console.log('[WakeLock] Released'); + } catch (err) { + console.warn('[WakeLock] Could not release:', err.message); + } + } + }; + + // Show recording notification + const showRecordingNotification = async () => { + if ('Notification' in window && Notification.permission === 'granted') { + // Notifications handled by service worker + } + }; + + // Note: System audio capability detection is now handled by computed property + // canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) + + // Hide recording notification + const hideRecordingNotification = async () => { + // Notifications cleared when recording stops + }; + + // Handle visibility change (for wake lock re-acquisition) + const handleVisibilityChange = async () => { + if (document.visibilityState === 'visible' && isRecording.value) { + console.log('[Visibility] Page visible, re-acquiring wake lock'); + const acquired = await acquireWakeLock(); + if (acquired) { + showToast('Recording resumed - screen will stay awake', 'success'); + } + } else if (document.visibilityState === 'hidden' && isRecording.value) { + console.log('[Visibility] Page hidden, wake lock may be released by browser'); + } + }; + + // Start recording + // IMPORTANT: For Firefox, getDisplayMedia MUST be the first async call from user gesture + const startRecording = async (mode = 'microphone') => { + const needsDisplayMedia = mode === 'system' || mode === 'both'; + + // For system audio modes, get display media FIRST before any other operations + // This is required for Firefox's "transient activation" security model + if (needsDisplayMedia) { + try { + const displayStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true + }); + + // Check if we got an audio track + const audioTrack = displayStream.getAudioTracks()[0]; + if (!audioTrack) { + displayStream.getTracks().forEach(track => track.stop()); + showToast('No audio track - check "Share audio" option', 'error'); + return; + } + + // Store stream for use after disclaimer (if any) + pendingDisplayStream = displayStream; + } catch (error) { + console.error('[Recording] Failed to get display media:', error); + if (error.name === 'NotAllowedError') { + showToast('Screen sharing was cancelled', 'error'); + } else { + showToast(`Failed to capture: ${error.message}`, 'error'); + } + return; + } + } + + // Now check for disclaimer (after we've secured the display stream) + if (recordingDisclaimer.value && recordingDisclaimer.value.trim() !== '') { + showRecordingDisclaimerModal.value = true; + pendingRecordingMode.value = mode; + return; + } + + await startRecordingInternal(mode); + }; + + // Accept recording disclaimer and start recording + const acceptRecordingDisclaimer = async () => { + showRecordingDisclaimerModal.value = false; + await startRecordingInternal(pendingRecordingMode.value || 'microphone'); + }; + + // Cancel recording disclaimer + const cancelRecordingDisclaimer = () => { + showRecordingDisclaimerModal.value = false; + // Clean up pending display stream if user cancels + if (pendingDisplayStream) { + pendingDisplayStream.getTracks().forEach(track => track.stop()); + pendingDisplayStream = null; + } + pendingRecordingMode.value = null; + }; + + // Internal start recording function + const startRecordingInternal = async (mode) => { + try { + recordingMode.value = mode; + audioChunks.value = []; + recordingTime.value = 0; + estimatedFileSize.value = 0; + fileSizeWarningShown.value = false; + + // Initialize IndexedDB session + currentChunkIndex = 0; + + let stream; + let combinedStream; + + if (mode === 'microphone') { + if (!canRecordAudio.value) { + throw new Error('Microphone recording is not available. Make sure you are using HTTPS.'); + } + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 48000 + } + }); + activeStreams.value = [stream]; + + audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); + const source = audioContext.value.createMediaStreamSource(stream); + analyser.value = audioContext.value.createAnalyser(); + analyser.value.fftSize = 256; + source.connect(analyser.value); + + } else if (mode === 'system') { + if (!canRecordSystemAudio.value) { + throw new Error('System audio recording is not available. Make sure you are using HTTPS.'); + } + // Use pre-obtained display stream (required for Firefox user gesture) + // or get it now for browsers that don't require immediate call + const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + + if (pendingDisplayStream) { + stream = pendingDisplayStream; + pendingDisplayStream = null; + } else { + const displayMediaConstraints = { + video: true, + audio: isFirefox ? true : { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false + } + }; + stream = await navigator.mediaDevices.getDisplayMedia(displayMediaConstraints); + } + + const audioTrack = stream.getAudioTracks()[0]; + if (!audioTrack) { + stream.getTracks().forEach(track => track.stop()); + const browserName = isFirefox ? 'Firefox' : 'your browser'; + throw new Error( + `No system audio track available. In ${browserName}, please:\n` + + `1. Share a BROWSER TAB that is actively playing audio\n` + + `2. Make sure "Share tab audio" checkbox is checked\n` + + `3. The audio must be playing when you start sharing` + ); + } + + // Stop video track + stream.getVideoTracks().forEach(track => track.stop()); + stream = new MediaStream([audioTrack]); + activeStreams.value = [stream]; + + audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); + const source = audioContext.value.createMediaStreamSource(stream); + analyser.value = audioContext.value.createAnalyser(); + analyser.value.fftSize = 256; + source.connect(analyser.value); + + } else if (mode === 'both') { + if (!canRecordAudio.value || !canRecordSystemAudio.value) { + throw new Error('Recording is not available. Make sure you are using HTTPS.'); + } + const micStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 48000 + } + }); + + // Use pre-obtained display stream or get it now + const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + let displayStream; + + if (pendingDisplayStream) { + displayStream = pendingDisplayStream; + pendingDisplayStream = null; + } else { + displayStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: isFirefox ? true : { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false + } + }); + } + + const systemAudioTrack = displayStream.getAudioTracks()[0]; + if (!systemAudioTrack) { + micStream.getTracks().forEach(track => track.stop()); + displayStream.getTracks().forEach(track => track.stop()); + const browserName = isFirefox ? 'Firefox' : 'your browser'; + throw new Error( + `No system audio track available. In ${browserName}, please:\n` + + `1. Share a BROWSER TAB that is actively playing audio\n` + + `2. Make sure "Share tab audio" checkbox is checked\n` + + `3. The audio must be playing when you start sharing` + ); + } + + // Stop video tracks + displayStream.getVideoTracks().forEach(track => track.stop()); + + // Create audio context and combine streams + audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); + const destination = audioContext.value.createMediaStreamDestination(); + + const micSource = audioContext.value.createMediaStreamSource(micStream); + const systemSource = audioContext.value.createMediaStreamSource(new MediaStream([systemAudioTrack])); + + // Create analysers for each source + micAnalyser.value = audioContext.value.createAnalyser(); + micAnalyser.value.fftSize = 256; + systemAnalyser.value = audioContext.value.createAnalyser(); + systemAnalyser.value.fftSize = 256; + + micSource.connect(micAnalyser.value); + micSource.connect(destination); + systemSource.connect(systemAnalyser.value); + systemSource.connect(destination); + + combinedStream = destination.stream; + activeStreams.value = [micStream, displayStream]; + stream = combinedStream; + } + + // Determine best mime type + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : 'audio/webm'; + + const recorder = new MediaRecorder(stream, { mimeType }); + + // Start IndexedDB recording session - convert Vue reactive objects to plain objects + try { + await RecordingDB.startRecordingSession({ + mode, + notes: recordingNotes.value || '', + tags: selectedTagIds.value ? [...selectedTagIds.value] : [], // Convert reactive array to plain array + asrOptions: { + language: asrLanguage.value || '', + min_speakers: asrMinSpeakers.value || '', + max_speakers: asrMaxSpeakers.value || '' + }, + mimeType + }); + } catch (dbError) { + console.warn('[Recording] IndexedDB persistence failed, continuing without persistence:', dbError); + } + + recorder.ondataavailable = async (event) => { + if (event.data.size > 0) { + audioChunks.value.push(event.data); + + // Save chunk to IndexedDB for crash recovery + try { + await RecordingDB.saveChunk(event.data, currentChunkIndex); + await RecordingDB.updateRecordingMetadata({ + duration: recordingTime.value, + notes: recordingNotes.value || '' + }); + currentChunkIndex++; + } catch (dbError) { + // Don't spam console - recording continues in memory regardless + } + } + }; + + recorder.onstop = () => { + const blob = new Blob(audioChunks.value, { type: mimeType }); + audioBlobURL.value = URL.createObjectURL(blob); + stopSizeMonitoring(); + }; + + mediaRecorder.value = recorder; + recorder.start(5000); // 5-second chunks for less overhead while still enabling crash recovery + isRecording.value = true; + // Switch to recording view immediately so pending wake-lock/notification awaits don't block Safari rendering + currentView.value = 'recording'; + + // Start timer + recordingInterval.value = setInterval(() => { + recordingTime.value++; + }, 1000); + + // Start size monitoring + startSizeMonitoring(); + + // Acquire wake lock + await acquireWakeLock(); + + // Show notification + await showRecordingNotification(); + + // Start visualizers + drawVisualizers(); + + // Notify service worker + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'RECORDING_STATE', + isRecording: true + }); + } + + } catch (error) { + console.error('Recording error:', error); + setGlobalError(`Failed to start recording: ${error.message}`); + + // Clean up any started streams + if (activeStreams.value.length > 0) { + activeStreams.value.forEach(stream => { + stream.getTracks().forEach(track => track.stop()); + }); + activeStreams.value = []; + } + } + }; + + // Stop recording + const stopRecording = async () => { + if (mediaRecorder.value && isRecording.value) { + mediaRecorder.value.stop(); + isRecording.value = false; + + // Clear the recording timer + if (recordingInterval.value) { + clearInterval(recordingInterval.value); + recordingInterval.value = null; + } + + stopSizeMonitoring(); + cancelAnimationFrame(animationFrameId.value); + animationFrameId.value = null; + + // Stop all active media streams (mic, screen share, etc.) + if (activeStreams.value.length > 0) { + activeStreams.value.forEach(stream => { + stream.getTracks().forEach(track => track.stop()); + }); + activeStreams.value = []; + } + + // Release wake lock + await releaseWakeLock(); + + // Hide recording notification + await hideRecordingNotification(); + + // Notify service worker + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'RECORDING_STATE', + isRecording: false, + duration: recordingTime.value + }); + } + } + }; + + // Upload recorded audio + const uploadRecordedAudio = async () => { + if (!audioBlobURL.value) { + setGlobalError("No recorded audio to upload."); + return; + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' }); + + // Get selected tags as objects and create a DEEP copy to prevent reactivity issues + const selectedTagsTemp = selectedTagIds.value.map(tagId => { + const tag = state.availableTags.value.find(t => t.id == tagId); + return tag || null; + }).filter(Boolean); + + // Deep clone to completely break reactivity chain - JSON parse/stringify removes all proxies + const selectedTags = JSON.parse(JSON.stringify(selectedTagsTemp)); + + // Add to upload queue + uploadQueue.value.push({ + file: recordedFile, + notes: recordingNotes.value, + tags: selectedTags, // Completely non-reactive deep copy + folder_id: selectedFolderId.value, + preserveOptions: true, // Prevents startUpload from overwriting recording's options + asrOptions: { + language: asrLanguage.value, + min_speakers: asrMinSpeakers.value, + max_speakers: asrMaxSpeakers.value + }, + status: 'queued', + recordingId: null, + clientId: `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + error: null, + willAutoSummarize: false, // Server will tell us via SUMMARIZING status + // Callback to clear IndexedDB session AFTER successful upload (not before) + onUploadSuccess: async () => { + try { + await RecordingDB.clearRecordingSession(); + console.log('[Recording] IndexedDB session cleared after successful upload'); + } catch (dbError) { + console.warn('[Recording] Failed to clear IndexedDB session:', dbError); + } + } + }); + + discardRecording(); + + // Return to upload view (main UI) + currentView.value = 'upload'; + + // Start upload immediately + progressPopupMinimized.value = false; + progressPopupClosed.value = false; + + if (startUploadQueue) { + startUploadQueue(); + } + }; + + // Upload recorded audio in incognito mode + const uploadRecordedAudioIncognito = async () => { + if (!audioBlobURL.value) { + setGlobalError("No recorded audio to upload."); + return; + } + + // Check if incognito state is available + if (!incognitoProcessing || !incognitoRecording) { + console.warn('[Incognito] Incognito state not available, falling back to normal upload'); + uploadRecordedAudio(); + return; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' }); + + incognitoProcessing.value = true; + processingMessage.value = 'Processing recording in incognito mode...'; + processingProgress.value = 10; + progressPopupMinimized.value = false; + progressPopupClosed.value = false; + + try { + const formData = new FormData(); + formData.append('file', recordedFile); + + // Add ASR options + if (asrLanguage.value) { + formData.append('language', asrLanguage.value); + } + if (asrMinSpeakers.value && asrMinSpeakers.value !== '') { + formData.append('min_speakers', asrMinSpeakers.value.toString()); + } + if (asrMaxSpeakers.value && asrMaxSpeakers.value !== '') { + formData.append('max_speakers', asrMaxSpeakers.value.toString()); + } + + // Request auto-summarization + formData.append('auto_summarize', 'true'); + + processingMessage.value = 'Uploading recording for incognito processing...'; + processingProgress.value = 20; + + console.log('[Incognito] Uploading recorded audio'); + + const response = await fetch('/api/recordings/incognito', { + method: 'POST', + body: formData + }); + + processingProgress.value = 50; + + // Parse response + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const text = await response.text(); + const titleMatch = text.match(/([^<]+)<\/title>/i); + throw new Error(titleMatch?.[1] || `Server error (${response.status})`); + } + + const data = await response.json(); + + if (!response.ok || data.error) { + throw new Error(data.error || `Processing failed with status ${response.status}`); + } + + processingProgress.value = 80; + processingMessage.value = 'Processing complete!'; + + // Store result in sessionStorage + const incognitoData = { + id: 'incognito', + incognito: true, + title: data.title || 'Incognito Recording', + transcription: data.transcription, + summary: data.summary, + summary_html: data.summary_html, + created_at: data.created_at, + original_filename: data.original_filename, + file_size: data.file_size, + audio_duration_seconds: data.audio_duration_seconds, + processing_time_seconds: data.processing_time_seconds, + status: 'COMPLETED' + }; + + IncognitoStorage.saveIncognitoRecording(incognitoData); + incognitoRecording.value = incognitoData; + + // Clear IndexedDB session + try { + await RecordingDB.clearRecordingSession(); + } catch (dbError) { + console.warn('[Recording] Failed to clear IndexedDB session:', dbError); + } + + // Clear recording state (must await so currentView='upload' completes + // before we override it with 'detail', otherwise the deferred + // currentView='upload' fires after 'detail' and the view watcher + // clears incognito data thinking we navigated away) + await discardRecording(); + + processingProgress.value = 100; + processingMessage.value = 'Incognito recording ready!'; + + // Auto-select the incognito recording and switch to detail view + selectedRecording.value = incognitoData; + currentView.value = 'detail'; + + // Reset incognito mode toggle + incognitoMode.value = false; + + // Show toast + showToast('Incognito recording processed - data will be lost when tab closes', 'fa-user-secret'); + + console.log('[Incognito] Recording processing complete'); + + } catch (error) { + console.error('[Incognito] Recording processing failed:', error); + setGlobalError(`Incognito processing failed: ${error.message}`); + } finally { + incognitoProcessing.value = false; + } + }; + + // Discard recording + const discardRecording = async () => { + if (audioBlobURL.value) { + URL.revokeObjectURL(audioBlobURL.value); + } + audioBlobURL.value = null; + audioChunks.value = []; + isRecording.value = false; + recordingTime.value = 0; + if (recordingInterval.value) clearInterval(recordingInterval.value); + recordingNotes.value = ''; + selectedTagIds.value = []; + asrLanguage.value = ''; + asrMinSpeakers.value = ''; + asrMaxSpeakers.value = ''; + + // Clear IndexedDB session + try { + await RecordingDB.clearRecordingSession(); + } catch (dbError) { + console.warn('[Recording] Failed to clear IndexedDB session:', dbError); + } + + await releaseWakeLock(); + await hideRecordingNotification(); + + // Return to upload view + currentView.value = 'upload'; + }; + + // Draw single visualizer + const drawSingleVisualizer = (analyserNode, canvasElement) => { + if (!analyserNode || !canvasElement) return; + + const bufferLength = analyserNode.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + analyserNode.getByteFrequencyData(dataArray); + + const canvasCtx = canvasElement.getContext('2d'); + const WIDTH = canvasElement.width; + const HEIGHT = canvasElement.height; + + canvasCtx.clearRect(0, 0, WIDTH, HEIGHT); + + const barWidth = (WIDTH / bufferLength) * 1.5; + let barHeight; + let x = 0; + + const buttonColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button').trim(); + const buttonHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button-hover').trim(); + + const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT); + if (isDarkMode.value) { + gradient.addColorStop(0, buttonColor); + gradient.addColorStop(0.6, buttonHoverColor); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + } else { + gradient.addColorStop(0, buttonColor); + gradient.addColorStop(0.5, buttonHoverColor); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0.1)'); + } + + for (let i = 0; i < bufferLength; i++) { + barHeight = dataArray[i] / 2.5; + canvasCtx.fillStyle = gradient; + canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight); + x += barWidth + 2; + } + }; + + // Draw visualizers + const drawVisualizers = () => { + if (!isRecording.value) { + if (animationFrameId.value) { + cancelAnimationFrame(animationFrameId.value); + animationFrameId.value = null; + } + return; + } + + animationFrameId.value = requestAnimationFrame(drawVisualizers); + + if (recordingMode.value === 'both') { + drawSingleVisualizer(micAnalyser.value, micVisualizer.value); + drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value); + } else { + drawSingleVisualizer(analyser.value, visualizer.value); + } + }; + + // Update file size estimate + const updateFileSizeEstimate = () => { + if (!isRecording.value || !audioChunks.value.length) return; + + const totalSize = audioChunks.value.reduce((sum, chunk) => sum + chunk.size, 0); + estimatedFileSize.value = totalSize; + + if (recordingTime.value > 0) { + actualBitrate.value = (totalSize * 8) / recordingTime.value; + } + + // Check for size warning + const sizeMB = totalSize / (1024 * 1024); + const warningThresholdMB = maxRecordingMB.value * 0.8; + + if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) { + fileSizeWarningShown.value = true; + showToast( + `Recording size is ${formatFileSize(totalSize)}. Consider stopping soon.`, + 'fa-exclamation-triangle', + 5000 + ); + } + + // Auto-stop if max size reached + if (sizeMB > maxRecordingMB.value) { + stopRecording(); + showToast( + `Recording automatically stopped at ${formatFileSize(totalSize)}`, + 'fa-stop-circle', + 7000 + ); + } + }; + + // Start size monitoring + const startSizeMonitoring = () => { + if (sizeCheckInterval.value) { + clearInterval(sizeCheckInterval.value); + } + sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 2000); + }; + + // Stop size monitoring + const stopSizeMonitoring = () => { + if (sizeCheckInterval.value) { + clearInterval(sizeCheckInterval.value); + sizeCheckInterval.value = null; + } + }; + + // Check if there's an unsaved recording + const hasUnsavedRecording = () => { + return isRecording.value || audioBlobURL.value; + }; + + // Recover recording from IndexedDB + const recoverRecordingFromDB = async () => { + try { + const recovered = await RecordingDB.recoverRecording(); + if (!recovered) { + return null; + } + + // Restore chunks + audioChunks.value = recovered.chunks; + + // Create blob URL + const blob = new Blob(recovered.chunks, { type: recovered.metadata.mimeType }); + audioBlobURL.value = URL.createObjectURL(blob); + + // Restore metadata + recordingMode.value = recovered.metadata.mode; + recordingNotes.value = recovered.metadata.notes; + selectedTagIds.value = recovered.metadata.tags; + recordingTime.value = recovered.metadata.duration; + + if (recovered.metadata.asrOptions) { + asrLanguage.value = recovered.metadata.asrOptions.language || ''; + asrMinSpeakers.value = recovered.metadata.asrOptions.min_speakers || ''; + asrMaxSpeakers.value = recovered.metadata.asrOptions.max_speakers || ''; + } + + console.log('[Recording] Successfully recovered recording from IndexedDB'); + return recovered.metadata; + } catch (error) { + console.error('[Recording] Failed to recover recording:', error); + return null; + } + }; + + // No initialization needed - system audio detection is handled by computed property + const initializeAudio = async () => { + // Placeholder for future initialization if needed + }; + + return { + startRecording, + stopRecording, + discardRecording, + uploadRecordedAudio, + uploadRecordedAudioIncognito, + acceptRecordingDisclaimer, + cancelRecordingDisclaimer, + updateFileSizeEstimate, + startSizeMonitoring, + stopSizeMonitoring, + drawVisualizers, + drawSingleVisualizer, + handleVisibilityChange, + hasUnsavedRecording, + acquireWakeLock, + releaseWakeLock, + initializeAudio, + recoverRecordingFromDB, + checkForRecoverableRecording: RecordingDB.checkForRecoverableRecording, + clearRecordingSession: RecordingDB.clearRecordingSession + }; +} diff --git a/static/js/modules/composables/audioPlayer.js b/static/js/modules/composables/audioPlayer.js new file mode 100644 index 0000000..6a949d7 --- /dev/null +++ b/static/js/modules/composables/audioPlayer.js @@ -0,0 +1,338 @@ +/** + * Audio Player Composable + * + * Centralized audio playback functionality for consistent behavior across the app. + * This module handles: + * - Playback state (playing, paused, loading) + * - Time tracking (current time, duration) + * - Volume/mute control + * - Seeking with progress bar support + * - Server-side duration support (for formats like WebM that don't report duration) + * + * Usage: + * const player = useAudioPlayer(ref, computed); + * // In template: @loadedmetadata="player.handleLoadedMetadata" + * // When recording changes: player.setServerDuration(recording.audio_duration) + */ + +export function useAudioPlayer(ref, computed) { + // --- State --- + const isPlaying = ref(false); + const currentTime = ref(0); + const duration = ref(0); + const isMuted = ref(false); + const isLoading = ref(false); + const volume = ref(1.0); + + // Progress bar drag state + const isDragging = ref(false); + const dragPreviewPercent = ref(0); + + // Track if we have a reliable server-side duration + let hasServerDuration = false; + + // --- Computed --- + const progressPercent = computed(() => { + // Use preview position while dragging for smooth UI + if (isDragging.value) { + return dragPreviewPercent.value; + } + if (!duration.value) return 0; + return (currentTime.value / duration.value) * 100; + }); + + // Preview time display while dragging + const displayCurrentTime = computed(() => { + if (isDragging.value && duration.value) { + return (dragPreviewPercent.value / 100) * duration.value; + } + return currentTime.value; + }); + + // --- Duration Management --- + + /** + * Set duration from server-side ffprobe value. + * This is more reliable than browser metadata for some formats (WebM, etc.) + */ + const setServerDuration = (serverDuration) => { + if (serverDuration && isFinite(serverDuration) && serverDuration > 0) { + duration.value = serverDuration; + hasServerDuration = true; + } else { + hasServerDuration = false; + } + }; + + /** + * Try to set duration from browser, only if we don't have a server-side value. + * Browser duration can be Infinity for some WebM files. + */ + const trySetBrowserDuration = (browserDuration) => { + if (hasServerDuration) { + // Don't overwrite reliable server-side duration + return; + } + if (browserDuration && isFinite(browserDuration) && browserDuration > 0) { + duration.value = browserDuration; + } + }; + + // --- Event Handlers --- + + const handlePlayPause = (event) => { + isPlaying.value = !event.target.paused; + }; + + const handleLoadedMetadata = (event) => { + trySetBrowserDuration(event.target.duration); + isLoading.value = false; + }; + + const handleDurationChange = (event) => { + // WebM and some formats may initially report Infinity duration + // This handler catches when the actual duration becomes available + trySetBrowserDuration(event.target.duration); + }; + + const handleTimeUpdate = (event) => { + currentTime.value = event.target.currentTime; + + // Fallback: if duration wasn't set yet, try to get it now + if (!duration.value || duration.value === 0) { + trySetBrowserDuration(event.target.duration); + } + }; + + const handleEnded = () => { + isPlaying.value = false; + currentTime.value = 0; + }; + + const handleWaiting = () => { + isLoading.value = true; + }; + + const handleCanPlay = (event) => { + isLoading.value = false; + + // Fallback: try to get duration if not set yet + if (!duration.value || duration.value === 0) { + trySetBrowserDuration(event.target.duration); + } + }; + + const handleVolumeChange = (event) => { + volume.value = event.target.volume; + isMuted.value = event.target.muted; + }; + + // --- Actions --- + + /** + * Get the audio element. Override this for custom element selection. + */ + let getAudioElement = () => { + return document.querySelector('audio[ref="audioPlayerElement"]') || + document.querySelector('video[ref="audioPlayerElement"]') || + document.querySelector('audio') || + document.querySelector('video'); + }; + + /** + * Set custom audio element getter. + */ + const setAudioElementGetter = (getter) => { + getAudioElement = getter; + }; + + const play = () => { + const audio = getAudioElement(); + if (audio) { + audio.play().catch(err => console.warn('Play failed:', err)); + } + }; + + const pause = () => { + const audio = getAudioElement(); + if (audio) { + audio.pause(); + } + }; + + const togglePlayback = () => { + const audio = getAudioElement(); + if (!audio) return; + + if (audio.paused) { + audio.play().catch(err => console.warn('Play failed:', err)); + } else { + audio.pause(); + } + }; + + const seekTo = (time) => { + const audio = getAudioElement(); + if (!audio || !isFinite(time)) return; + + const maxTime = isFinite(audio.duration) ? audio.duration : time; + audio.currentTime = Math.max(0, Math.min(time, maxTime)); + }; + + const seekByPercent = (percent) => { + const audio = getAudioElement(); + if (!audio || !duration.value || !isFinite(duration.value)) return; + + const time = (percent / 100) * duration.value; + audio.currentTime = time; + }; + + const setVolume = (value) => { + const audio = getAudioElement(); + if (audio) { + audio.volume = Math.max(0, Math.min(1, value)); + volume.value = audio.volume; + } + }; + + const toggleMute = () => { + const audio = getAudioElement(); + if (!audio) return; + + if (audio.muted || audio.volume === 0) { + audio.muted = false; + if (audio.volume === 0) { + audio.volume = 0.5; + } + isMuted.value = false; + } else { + audio.muted = true; + isMuted.value = true; + } + }; + + // --- Progress Bar Drag Support --- + + const startProgressDrag = (event) => { + const bar = event.currentTarget.querySelector('.h-2') || event.currentTarget; + const rect = bar.getBoundingClientRect(); + const isTouch = event.type === 'touchstart'; + + const getPercent = (evt) => { + const clientX = isTouch ? evt.touches[0].clientX : evt.clientX; + return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); + }; + + // Start dragging - show preview + isDragging.value = true; + dragPreviewPercent.value = getPercent(event); + + const onMove = (evt) => { + evt.preventDefault(); + const clientX = isTouch ? evt.touches[0].clientX : evt.clientX; + dragPreviewPercent.value = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); + }; + + const onUp = () => { + document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove); + document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp); + // Seek to final position on release + seekByPercent(dragPreviewPercent.value); + isDragging.value = false; + }; + + document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false }); + document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp); + }; + + // --- Utility --- + + const formatTime = (seconds) => { + if (!seconds || isNaN(seconds)) return '0:00'; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hours > 0) { + return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + /** + * Reset all player state (call when changing recordings) + */ + const reset = () => { + isPlaying.value = false; + currentTime.value = 0; + duration.value = 0; + isMuted.value = false; + isLoading.value = false; + hasServerDuration = false; + }; + + /** + * Initialize with a recording object. + * Automatically uses server-side duration if available. + */ + const initWithRecording = (recording) => { + reset(); + if (recording && recording.audio_duration) { + setServerDuration(recording.audio_duration); + } + }; + + return { + // State + isPlaying, + currentTime, + duration, + isMuted, + isLoading, + volume, + isDragging, + dragPreviewPercent, + + // Computed + progressPercent, + displayCurrentTime, + + // Duration management + setServerDuration, + trySetBrowserDuration, + + // Event handlers (wire these to <audio> element) + handlePlayPause, + handleLoadedMetadata, + handleDurationChange, + handleTimeUpdate, + handleEnded, + handleWaiting, + handleCanPlay, + handleVolumeChange, + + // Actions + play, + pause, + togglePlayback, + seekTo, + seekByPercent, + setVolume, + toggleMute, + startProgressDrag, + setAudioElementGetter, + + // Utility + formatTime, + reset, + initWithRecording + }; +} + +/** + * Create a standalone audio player instance. + * Use this for pages that don't have Vue's ref/computed (like share.html). + */ +export function createStandalonePlayer(Vue) { + const { ref, computed } = Vue; + return useAudioPlayer(ref, computed); +} diff --git a/static/js/modules/composables/bulk-operations.js b/static/js/modules/composables/bulk-operations.js new file mode 100644 index 0000000..3d3d8c6 --- /dev/null +++ b/static/js/modules/composables/bulk-operations.js @@ -0,0 +1,475 @@ +/** + * Bulk Operations Composable + * Handles bulk API operations for multiple recordings + */ + +const { ref, computed } = Vue; + +export function useBulkOperations({ + selectedRecordingIds, + selectedRecordings, + recordings, + selectedRecording, + bulkActionInProgress, + availableTags, + availableFolders, + showToast, + setGlobalError, + startReprocessingPoll +}) { + // Modal state + const showBulkDeleteModal = ref(false); + const showBulkTagModal = ref(false); + const showBulkReprocessModal = ref(false); + const showBulkFolderModal = ref(false); + const bulkTagAction = ref('add'); // 'add' or 'remove' + const bulkTagSelectedId = ref(''); + const bulkReprocessType = ref('summary'); // 'transcription' or 'summary' + + // Get CSRF token + const getCsrfToken = () => { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + }; + + // Helper to get selected IDs as array + const getSelectedIds = () => { + return Array.from(selectedRecordingIds.value); + }; + + // ========================================= + // Bulk Delete + // ========================================= + + const openBulkDeleteModal = () => { + showBulkDeleteModal.value = true; + }; + + const closeBulkDeleteModal = () => { + showBulkDeleteModal.value = false; + }; + + const executeBulkDelete = async () => { + const ids = getSelectedIds(); + if (ids.length === 0) return; + + bulkActionInProgress.value = true; + closeBulkDeleteModal(); + + try { + const response = await fetch('/api/recordings/bulk', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ recording_ids: ids }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete recordings'); + } + + // Remove deleted recordings from local state + const deletedIds = new Set(data.deleted_ids || ids); + recordings.value = recordings.value.filter(r => !deletedIds.has(r.id)); + + // Clear selected recording if it was deleted + if (selectedRecording.value && deletedIds.has(selectedRecording.value.id)) { + selectedRecording.value = null; + } + + // Remove deleted IDs from selection + deletedIds.forEach(id => selectedRecordingIds.value.delete(id)); + + const count = deletedIds.size; + showToast(`${count} recording${count !== 1 ? 's' : ''} deleted`, 'fa-trash', 3000, 'success'); + } catch (error) { + console.error('Bulk delete error:', error); + setGlobalError(`Failed to delete recordings: ${error.message}`); + } finally { + bulkActionInProgress.value = false; + } + }; + + // ========================================= + // Bulk Tag Operations + // ========================================= + + const openBulkTagModal = (action = 'add') => { + bulkTagAction.value = action; + bulkTagSelectedId.value = ''; + showBulkTagModal.value = true; + }; + + const closeBulkTagModal = () => { + showBulkTagModal.value = false; + bulkTagSelectedId.value = ''; + }; + + const executeBulkTag = async () => { + const ids = getSelectedIds(); + const tagId = bulkTagSelectedId.value; + const action = bulkTagAction.value; + + // Validate before making API call + if (ids.length === 0) { + console.warn('No recordings selected for bulk tag operation'); + return; + } + if (!tagId && tagId !== 0) { + console.warn('No tag selected for bulk tag operation'); + return; + } + + bulkActionInProgress.value = true; + closeBulkTagModal(); + + try { + const response = await fetch('/api/recordings/bulk-tags', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + recording_ids: ids, + tag_id: parseInt(tagId), + action: action + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || `Failed to ${action} tag`); + } + + // Update local state + const tag = availableTags.value.find(t => t.id == tagId); + if (tag) { + const affectedIds = new Set(data.affected_ids || ids); + recordings.value.forEach(recording => { + if (affectedIds.has(recording.id)) { + if (!recording.tags) recording.tags = []; + + if (action === 'add') { + // Add tag if not already present + if (!recording.tags.find(t => t.id === tag.id)) { + recording.tags.push(tag); + } + } else { + // Remove tag + recording.tags = recording.tags.filter(t => t.id !== tag.id); + } + } + }); + + // Update selected recording if affected + if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) { + if (!selectedRecording.value.tags) selectedRecording.value.tags = []; + + if (action === 'add') { + if (!selectedRecording.value.tags.find(t => t.id === tag.id)) { + selectedRecording.value.tags.push(tag); + } + } else { + selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tag.id); + } + } + } + + const count = data.affected_ids?.length || ids.length; + const actionText = action === 'add' ? 'added to' : 'removed from'; + showToast(`Tag ${actionText} ${count} recording${count !== 1 ? 's' : ''}`, 'fa-tags', 3000, 'success'); + } catch (error) { + console.error('Bulk tag error:', error); + setGlobalError(`Failed to ${action} tag: ${error.message}`); + } finally { + bulkActionInProgress.value = false; + } + }; + + // ========================================= + // Bulk Reprocess + // ========================================= + + const openBulkReprocessModal = () => { + bulkReprocessType.value = 'summary'; + showBulkReprocessModal.value = true; + }; + + const closeBulkReprocessModal = () => { + showBulkReprocessModal.value = false; + }; + + const executeBulkReprocess = async () => { + const ids = getSelectedIds(); + if (ids.length === 0) return; + + bulkActionInProgress.value = true; + closeBulkReprocessModal(); + + try { + const response = await fetch('/api/recordings/bulk-reprocess', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + recording_ids: ids, + type: bulkReprocessType.value + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to queue reprocessing'); + } + + // Update status for queued recordings + const queuedIds = new Set(data.queued_ids || ids); + const newStatus = bulkReprocessType.value === 'transcription' ? 'PROCESSING' : 'SUMMARIZING'; + + recordings.value.forEach(recording => { + if (queuedIds.has(recording.id)) { + recording.status = newStatus; + // Start polling for each + if (startReprocessingPoll) { + startReprocessingPoll(recording.id); + } + } + }); + + if (selectedRecording.value && queuedIds.has(selectedRecording.value.id)) { + selectedRecording.value.status = newStatus; + } + + const count = queuedIds.size; + const typeText = bulkReprocessType.value === 'transcription' ? 'Transcription' : 'Summary'; + showToast(`${typeText} reprocessing queued for ${count} recording${count !== 1 ? 's' : ''}`, 'fa-sync-alt', 3000, 'success'); + } catch (error) { + console.error('Bulk reprocess error:', error); + setGlobalError(`Failed to queue reprocessing: ${error.message}`); + } finally { + bulkActionInProgress.value = false; + } + }; + + // ========================================= + // Bulk Toggle (Inbox/Highlight) + // ========================================= + + const bulkToggleInbox = async (value = null) => { + const ids = getSelectedIds(); + if (ids.length === 0) return; + + // If no value specified, toggle based on majority + if (value === null) { + const inboxCount = selectedRecordings.value.filter(r => r.is_inbox).length; + value = inboxCount < ids.length / 2; + } + + bulkActionInProgress.value = true; + + try { + const response = await fetch('/api/recordings/bulk-toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + recording_ids: ids, + field: 'inbox', + value: value + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update inbox status'); + } + + // Update local state + const affectedIds = new Set(data.affected_ids || ids); + recordings.value.forEach(recording => { + if (affectedIds.has(recording.id)) { + recording.is_inbox = value; + } + }); + + if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) { + selectedRecording.value.is_inbox = value; + } + + const count = affectedIds.size; + const actionText = value ? 'added to' : 'removed from'; + showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText} inbox`, 'fa-inbox', 3000, 'success'); + } catch (error) { + console.error('Bulk toggle inbox error:', error); + setGlobalError(`Failed to update inbox status: ${error.message}`); + } finally { + bulkActionInProgress.value = false; + } + }; + + const bulkToggleHighlight = async (value = null) => { + const ids = getSelectedIds(); + if (ids.length === 0) return; + + // If no value specified, toggle based on majority + if (value === null) { + const highlightCount = selectedRecordings.value.filter(r => r.is_highlighted).length; + value = highlightCount < ids.length / 2; + } + + bulkActionInProgress.value = true; + + try { + const response = await fetch('/api/recordings/bulk-toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + recording_ids: ids, + field: 'highlight', + value: value + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update highlight status'); + } + + // Update local state + const affectedIds = new Set(data.affected_ids || ids); + recordings.value.forEach(recording => { + if (affectedIds.has(recording.id)) { + recording.is_highlighted = value; + } + }); + + if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) { + selectedRecording.value.is_highlighted = value; + } + + const count = affectedIds.size; + const actionText = value ? 'highlighted' : 'unhighlighted'; + showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText}`, 'fa-star', 3000, 'success'); + } catch (error) { + console.error('Bulk toggle highlight error:', error); + setGlobalError(`Failed to update highlight status: ${error.message}`); + } finally { + bulkActionInProgress.value = false; + } + }; + + // ========================================= + // Bulk Folder Assignment + // ========================================= + + const bulkAssignFolder = async (folderId) => { + const ids = getSelectedIds(); + if (ids.length === 0) return; + + bulkActionInProgress.value = true; + showBulkFolderModal.value = false; + + try { + const response = await fetch('/api/recordings/bulk/folder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + recording_ids: ids, + folder_id: folderId + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update folders'); + } + + // Update local state + const folder = folderId ? availableFolders.value.find(f => f.id === folderId) : null; + recordings.value.forEach(recording => { + if (ids.includes(recording.id)) { + recording.folder_id = folderId; + recording.folder = folder; + } + }); + + // Update selected recording if affected + if (selectedRecording.value && ids.includes(selectedRecording.value.id)) { + selectedRecording.value.folder_id = folderId; + selectedRecording.value.folder = folder; + } + + // Update folder recording counts + if (availableFolders.value) { + availableFolders.value.forEach(f => { + const count = recordings.value.filter(r => r.folder_id === f.id).length; + f.recording_count = count; + }); + } + + const count = data.updated_count || ids.length; + if (folderId) { + showToast(`${count} recording${count !== 1 ? 's' : ''} moved to "${folder?.name || 'folder'}"`, 'fa-folder', 3000, 'success'); + } else { + showToast(`${count} recording${count !== 1 ? 's' : ''} removed from folder`, 'fa-folder-minus', 3000, 'success'); + } + } catch (error) { + console.error('Bulk folder assignment error:', error); + setGlobalError(`Failed to update folders: ${error.message}`); + } finally { + bulkActionInProgress.value = false; + } + }; + + return { + // Modal state + showBulkDeleteModal, + showBulkTagModal, + showBulkReprocessModal, + showBulkFolderModal, + bulkTagAction, + bulkTagSelectedId, + bulkReprocessType, + + // Bulk Delete + openBulkDeleteModal, + closeBulkDeleteModal, + executeBulkDelete, + + // Bulk Tag + openBulkTagModal, + closeBulkTagModal, + executeBulkTag, + + // Bulk Reprocess + openBulkReprocessModal, + closeBulkReprocessModal, + executeBulkReprocess, + + // Bulk Toggle + bulkToggleInbox, + bulkToggleHighlight, + + // Bulk Folder + bulkAssignFolder + }; +} diff --git a/static/js/modules/composables/bulk-selection.js b/static/js/modules/composables/bulk-selection.js new file mode 100644 index 0000000..1da48c5 --- /dev/null +++ b/static/js/modules/composables/bulk-selection.js @@ -0,0 +1,111 @@ +/** + * Bulk Selection Composable + * Handles multi-select functionality for recordings + */ + +const { computed } = Vue; + +export function useBulkSelection({ + selectionMode, + selectedRecordingIds, + recordings, + selectedRecording, + currentView +}) { + // Computed + const selectedCount = computed(() => selectedRecordingIds.value.size); + + const selectedRecordings = computed(() => { + return recordings.value.filter(r => selectedRecordingIds.value.has(r.id)); + }); + + const allVisibleSelected = computed(() => { + if (recordings.value.length === 0) return false; + return recordings.value.every(r => selectedRecordingIds.value.has(r.id)); + }); + + const isSelected = (id) => { + return selectedRecordingIds.value.has(id); + }; + + // Methods + const enterSelectionMode = () => { + selectionMode.value = true; + selectedRecordingIds.value = new Set(); + }; + + const exitSelectionMode = () => { + selectionMode.value = false; + selectedRecordingIds.value = new Set(); + }; + + const toggleSelection = (id) => { + const newSet = new Set(selectedRecordingIds.value); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + selectedRecordingIds.value = newSet; + }; + + const selectAll = () => { + const newSet = new Set(); + recordings.value.forEach(r => newSet.add(r.id)); + selectedRecordingIds.value = newSet; + }; + + const clearSelection = () => { + selectedRecordingIds.value = new Set(); + }; + + // Keyboard handler for selection mode + const handleSelectionKeyboard = (event) => { + if (!selectionMode.value) return; + + // Escape to exit selection mode + if (event.key === 'Escape') { + exitSelectionMode(); + event.preventDefault(); + } + + // Ctrl/Cmd + A to select all + if ((event.ctrlKey || event.metaKey) && event.key === 'a') { + // Only if not in an input field + if (document.activeElement.tagName !== 'INPUT' && + document.activeElement.tagName !== 'TEXTAREA' && + !document.activeElement.isContentEditable) { + event.preventDefault(); + selectAll(); + } + } + }; + + // Initialize keyboard listener + const initSelectionKeyboardListeners = () => { + document.addEventListener('keydown', handleSelectionKeyboard); + }; + + const cleanupSelectionKeyboardListeners = () => { + document.removeEventListener('keydown', handleSelectionKeyboard); + }; + + return { + // Computed + selectedCount, + selectedRecordings, + allVisibleSelected, + + // Methods + isSelected, + enterSelectionMode, + exitSelectionMode, + toggleSelection, + selectAll, + clearSelection, + + // Keyboard + initSelectionKeyboardListeners, + cleanupSelectionKeyboardListeners + }; +} diff --git a/static/js/modules/composables/chat.js b/static/js/modules/composables/chat.js new file mode 100644 index 0000000..f202e8f --- /dev/null +++ b/static/js/modules/composables/chat.js @@ -0,0 +1,380 @@ +/** + * Chat composable + * Handles AI chat functionality with streaming responses + */ + +export function useChat(state, utils) { + const { + showChat, isChatMaximized, chatMessages, chatInput, + isChatLoading, chatMessagesRef, chatInputRef, selectedRecording, csrfToken + } = state; + + const { showToast, setGlobalError, onChatComplete, t } = utils; + + // Helper function to check if chat is scrolled to bottom (within bottom 5%) + const isChatScrolledToBottom = () => { + if (!chatMessagesRef.value) return true; + const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value; + const scrollableHeight = scrollHeight - clientHeight; + if (scrollableHeight <= 0) return true; + const scrollPercentage = scrollTop / scrollableHeight; + return scrollPercentage >= 0.95; + }; + + // Helper function to scroll chat to bottom + const scrollChatToBottom = () => { + if (chatMessagesRef.value) { + requestAnimationFrame(() => { + if (chatMessagesRef.value) { + chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight; + } + }); + } + }; + + const focusChatInput = () => { + Vue.nextTick(() => { + if (chatInputRef.value) { + chatInputRef.value.focus(); + } + }); + }; + + const toggleChatMaximize = () => { + if (isChatMaximized.value) { + isChatMaximized.value = false; + } else { + isChatMaximized.value = true; + if (!showChat.value) { + showChat.value = true; + } + } + }; + + const sendChatMessage = async () => { + if (!chatInput.value.trim() || isChatLoading.value || !selectedRecording.value || selectedRecording.value.status !== 'COMPLETED') { + return; + } + + const message = chatInput.value.trim(); + + if (!Array.isArray(chatMessages.value)) { + chatMessages.value = []; + } + + chatMessages.value.push({ role: 'user', content: message }); + chatInput.value = ''; + isChatLoading.value = true; + focusChatInput(); + + await Vue.nextTick(); + scrollChatToBottom(); + + let assistantMessage = null; + + try { + const messageHistory = chatMessages.value + .slice(0, -1) + .map(msg => ({ role: msg.role, content: msg.content })); + + // Check if this is an incognito recording + const isIncognito = selectedRecording.value.incognito === true; + let response; + + if (isIncognito) { + // Use incognito chat endpoint - pass transcription directly + response = await fetch('/api/recordings/incognito/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transcription: selectedRecording.value.transcription, + participants: selectedRecording.value.participants || '', + notes: selectedRecording.value.notes || '', + message: message, + message_history: messageHistory + }) + }); + } else { + // Use regular chat endpoint + response = await fetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recording_id: selectedRecording.value.id, + message: message, + message_history: messageHistory + }) + }); + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to get chat response'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const processStream = async () => { + let isFirstChunk = true; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6); + // Handle [DONE] marker from incognito endpoint + if (jsonStr === '[DONE]') { + return; + } + if (jsonStr) { + try { + const data = JSON.parse(jsonStr); + if (data.thinking) { + const shouldScroll = isChatScrolledToBottom(); + + if (isFirstChunk) { + isChatLoading.value = false; + assistantMessage = Vue.reactive({ + role: 'assistant', + content: '', + html: '', + thinking: data.thinking, + thinkingExpanded: false + }); + chatMessages.value.push(assistantMessage); + isFirstChunk = false; + } else if (assistantMessage) { + if (assistantMessage.thinking) { + assistantMessage.thinking += '\n\n' + data.thinking; + } else { + assistantMessage.thinking = data.thinking; + } + } + + if (shouldScroll) { + await Vue.nextTick(); + scrollChatToBottom(); + } + } + // Handle both 'delta' (regular) and 'content' (incognito) formats + const textContent = data.delta || data.content; + if (textContent) { + const shouldScroll = isChatScrolledToBottom(); + + if (isFirstChunk) { + isChatLoading.value = false; + assistantMessage = Vue.reactive({ + role: 'assistant', + content: '', + html: '', + thinking: '', + thinkingExpanded: false + }); + chatMessages.value.push(assistantMessage); + isFirstChunk = false; + } + + assistantMessage.content += textContent; + assistantMessage.html = marked.parse(assistantMessage.content); + + if (shouldScroll) { + await Vue.nextTick(); + scrollChatToBottom(); + } + } + if (data.end_of_stream) { + return; + } + if (data.error) { + if (data.budget_exceeded) { + throw new Error(t('adminDashboard.tokenBudgetExceeded')); + } + throw new Error(data.error); + } + } catch (e) { + console.error('Error parsing stream data:', e); + } + } + } + } + } + }; + + await processStream(); + + } catch (error) { + console.error('Chat Error:', error); + if (assistantMessage) { + assistantMessage.content = `Error: ${error.message}`; + assistantMessage.html = `<span class="text-red-500">Error: ${error.message}</span>`; + } else { + chatMessages.value.push({ + role: 'assistant', + content: `Error: ${error.message}`, + html: `<span class="text-red-500">Error: ${error.message}</span>` + }); + } + } finally { + isChatLoading.value = false; + await Vue.nextTick(); + if (isChatScrolledToBottom()) { + scrollChatToBottom(); + } + focusChatInput(); + // Refresh token budget after chat completion + if (onChatComplete) { + onChatComplete(); + } + } + }; + + const handleChatKeydown = (event) => { + if (event.key === 'Enter') { + if (event.ctrlKey || event.shiftKey) { + return; + } else { + event.preventDefault(); + sendChatMessage(); + } + } + }; + + const clearChat = () => { + if (chatMessages.value.length > 0) { + chatMessages.value = []; + showToast(t('chat.cleared'), 'fa-broom'); + } + }; + + const downloadChat = async () => { + if (!selectedRecording.value || chatMessages.value.length === 0) { + showToast(t('chat.noMessagesToDownload'), 'fa-exclamation-circle'); + return; + } + + try { + const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${selectedRecording.value.id}/download/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfTokenValue + }, + body: JSON.stringify({ + messages: chatMessages.value + }) + }); + + if (!response.ok) { + const error = await response.json(); + showToast(error.error || t('chat.downloadFailed'), 'fa-exclamation-circle'); + return; + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'chat.docx'; + if (contentDisposition) { + const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition); + if (utf8Match) { + filename = decodeURIComponent(utf8Match[1]); + } else { + const regularMatch = /filename="(.+)"/.exec(contentDisposition); + if (regularMatch) { + filename = regularMatch[1]; + } + } + } + a.download = filename; + + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast(t('chat.downloadSuccess')); + } catch (error) { + console.error('Download failed:', error); + showToast(t('chat.downloadFailed'), 'fa-exclamation-circle'); + } + }; + + const copyMessage = (text, event) => { + const button = event.currentTarget; + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text) + .then(() => { + showToast(t('messages.copiedSuccessfully')); + animateCopyButton(button); + }) + .catch(err => { + console.error('Copy failed:', err); + showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle'); + fallbackCopyTextToClipboard(text, button); + }); + } else { + fallbackCopyTextToClipboard(text, button); + } + }; + + const animateCopyButton = (button) => { + button.classList.add('copy-success'); + const originalContent = button.innerHTML; + button.innerHTML = '<i class="fas fa-check"></i>'; + setTimeout(() => { + button.classList.remove('copy-success'); + button.innerHTML = originalContent; + }, 1500); + }; + + const fallbackCopyTextToClipboard = (text, button = null) => { + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + showToast(t('messages.copiedSuccessfully')); + if (button) animateCopyButton(button); + } else { + showToast(t('messages.copyNotSupported'), 'fa-exclamation-circle'); + } + } catch (err) { + console.error('Fallback copy failed:', err); + showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle'); + } + }; + + return { + isChatScrolledToBottom, + scrollChatToBottom, + toggleChatMaximize, + sendChatMessage, + handleChatKeydown, + clearChat, + downloadChat, + copyMessage, + animateCopyButton, + fallbackCopyTextToClipboard + }; +} diff --git a/static/js/modules/composables/folders.js b/static/js/modules/composables/folders.js new file mode 100644 index 0000000..152e384 --- /dev/null +++ b/static/js/modules/composables/folders.js @@ -0,0 +1,173 @@ +/** + * Folders Management Composable + * Handles folder operations for recordings + */ + +const { computed, ref } = Vue; + +export function useFolders({ + recordings, + availableFolders, + selectedRecording, + showToast, + setGlobalError +}) { + // Computed / Helpers + const getRecordingFolder = (recording) => { + if (!recording || !recording.folder_id) return null; + // Try to get from recording.folder first, then lookup + if (recording.folder) return recording.folder; + return availableFolders.value?.find(f => f.id === recording.folder_id) || null; + }; + + const getFolderById = (folderId) => { + if (!folderId || !availableFolders.value) return null; + // Use == for loose equality to handle string/number type mismatch (e.g., from localStorage) + return availableFolders.value.find(f => f.id == folderId) || null; + }; + + const getFolderColor = (folderId) => { + const folder = getFolderById(folderId); + return folder?.color || '#10B981'; + }; + + const getFolderName = (folderId) => { + const folder = getFolderById(folderId); + return folder?.name || 'Folder'; + }; + + const getAvailableFoldersForRecording = () => { + if (!availableFolders.value) return []; + return availableFolders.value; + }; + + // Methods + const assignFolderToRecording = async (recordingId, folderId) => { + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch(`/api/recordings/${recordingId}/folder`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ folder_id: folderId || null }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update folder'); + } + + const updatedRecording = await response.json(); + + // Update local recording data + const recordingInList = recordings.value.find(r => r.id === recordingId); + if (recordingInList) { + recordingInList.folder_id = updatedRecording.folder_id; + recordingInList.folder = updatedRecording.folder; + } + + // Update selectedRecording if it matches + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + selectedRecording.value.folder_id = updatedRecording.folder_id; + selectedRecording.value.folder = updatedRecording.folder; + } + + // Update folder recording counts + if (availableFolders.value) { + availableFolders.value.forEach(f => { + const count = recordings.value.filter(r => r.folder_id === f.id).length; + f.recording_count = count; + }); + } + + if (folderId) { + const folder = availableFolders.value?.find(f => f.id === folderId); + showToast(`Moved to folder "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success'); + } else { + showToast('Removed from folder', 'fa-folder-minus', 2000, 'success'); + } + + return updatedRecording; + + } catch (error) { + console.error('Error updating folder:', error); + setGlobalError(`Failed to update folder: ${error.message}`); + return null; + } + }; + + const removeRecordingFromFolder = async (recordingId) => { + return assignFolderToRecording(recordingId, null); + }; + + const bulkAssignFolder = async (recordingIds, folderId) => { + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch('/api/recordings/bulk/folder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + recording_ids: recordingIds, + folder_id: folderId || null + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update folders'); + } + + const result = await response.json(); + + // Update local recording data + recordingIds.forEach(id => { + const recording = recordings.value.find(r => r.id === id); + if (recording) { + recording.folder_id = folderId || null; + recording.folder = folderId ? availableFolders.value?.find(f => f.id === folderId) : null; + } + }); + + // Update folder recording counts + if (availableFolders.value) { + availableFolders.value.forEach(f => { + const count = recordings.value.filter(r => r.folder_id === f.id).length; + f.recording_count = count; + }); + } + + if (folderId) { + const folder = availableFolders.value?.find(f => f.id === folderId); + showToast(`${result.updated_count} recording(s) moved to "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success'); + } else { + showToast(`${result.updated_count} recording(s) removed from folder`, 'fa-folder-minus', 2000, 'success'); + } + + return result; + + } catch (error) { + console.error('Error bulk updating folders:', error); + setGlobalError(`Failed to update folders: ${error.message}`); + return null; + } + }; + + return { + // Methods + getRecordingFolder, + getFolderById, + getFolderColor, + getFolderName, + getAvailableFoldersForRecording, + assignFolderToRecording, + removeRecordingFromFolder, + bulkAssignFolder + }; +} diff --git a/static/js/modules/composables/index.js b/static/js/modules/composables/index.js new file mode 100644 index 0000000..cb94750 --- /dev/null +++ b/static/js/modules/composables/index.js @@ -0,0 +1,33 @@ +/** + * Composables module exports + * + * Each composable encapsulates related functionality: + * - recordings: Loading, selecting, filtering recordings + * - upload: File upload queue management + * - audio: Microphone/system audio recording + * - ui: Dark mode, color schemes, sidebar + * - transcription: Transcription editing (ASR editor, text editor) + * - speakers: Speaker identification and management + * - reprocess: Reprocessing transcription/summary + * - sharing: Public/internal sharing + * - modals: Modal dialog management + * - chat: AI chat functionality + * - pwa: PWA features (install prompt, notifications, badging, media session) + * - tokens: API token management + */ + +export { useRecordings } from './recordings.js'; +export { useUpload } from './upload.js'; +export { useAudio } from './audio.js'; +export { useUI } from './ui.js'; +export { useModals } from './modals.js'; +export { useSharing } from './sharing.js'; +export { useReprocess } from './reprocess.js'; +export { useTranscription } from './transcription.js'; +export { useSpeakers } from './speakers.js'; +export { useChat } from './chat.js'; +export { usePWA } from './pwa.js'; +export { useTokens } from './tokens.js'; +export { useBulkSelection } from './bulk-selection.js'; +export { useBulkOperations } from './bulk-operations.js'; +export { useFolders } from './folders.js'; diff --git a/static/js/modules/composables/modals.js b/static/js/modules/composables/modals.js new file mode 100644 index 0000000..5dae37d --- /dev/null +++ b/static/js/modules/composables/modals.js @@ -0,0 +1,659 @@ +/** + * Modal management composable + * Handles opening, closing, and saving modal dialogs + */ + +export function useModals(state, utils) { + const { + showEditModal, showDeleteModal, showEditTagsModal, + showReprocessModal, showResetModal, showShareModal, + showSharesListModal, showTextEditorModal, showAsrEditorModal, + showEditSpeakersModal, showAddSpeakerModal, showEditTextModal, + showShareDeleteModal, showUnifiedShareModal, showColorSchemeModal, + showSystemAudioHelpModal, editingRecording, recordingToDelete, recordingToReset, + selectedRecording, recordings, selectedNewTagId, tagSearchFilter, + availableTags, currentView, totalRecordings, toasts, uploadQueue, allJobs, + // DateTime picker state + showDateTimePicker, pickerMonth, pickerYear, pickerHour, pickerMinute, + pickerAmPm, pickerSelectedDate, dateTimePickerTarget, dateTimePickerCallback + } = state; + + const { showToast, setGlobalError } = utils; + const { computed } = Vue; + + // ========================================= + // Edit Recording Modal + // ========================================= + + const openEditModal = (recording) => { + editingRecording.value = { ...recording }; + showEditModal.value = true; + }; + + const cancelEdit = () => { + showEditModal.value = false; + editingRecording.value = null; + }; + + const saveEdit = async () => { + if (!editingRecording.value) return; + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/api/recordings/${editingRecording.value.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + title: editingRecording.value.title, + participants: editingRecording.value.participants, + meeting_date: editingRecording.value.meeting_date, + notes: editingRecording.value.notes + }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to save changes'); + + // Update local data + const index = recordings.value.findIndex(r => r.id === editingRecording.value.id); + if (index !== -1) { + recordings.value[index] = { ...recordings.value[index], ...editingRecording.value }; + } + if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) { + selectedRecording.value = { ...selectedRecording.value, ...editingRecording.value }; + } + + showToast('Recording updated!', 'fa-check-circle'); + showEditModal.value = false; + editingRecording.value = null; + } catch (error) { + setGlobalError(`Failed to save changes: ${error.message}`); + } + }; + + // ========================================= + // Delete Recording Modal + // ========================================= + + const confirmDelete = (recording) => { + recordingToDelete.value = recording; + showDeleteModal.value = true; + }; + + const cancelDelete = () => { + showDeleteModal.value = false; + recordingToDelete.value = null; + }; + + const deleteRecording = async () => { + if (!recordingToDelete.value) return; + const deletedId = recordingToDelete.value.id; + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${deletedId}`, { + method: 'DELETE', + headers: { 'X-CSRFToken': csrfToken } + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to delete recording'); + + // Remove from recordings list + recordings.value = recordings.value.filter(r => r.id !== deletedId); + totalRecordings.value--; + + // Remove from upload queue if present (frontend tracking) + if (uploadQueue?.value) { + uploadQueue.value = uploadQueue.value.filter(item => item.recordingId !== deletedId); + } + + // Remove from backend job queue if present (backend processing tracking) + // This is critical - without this, deleted recordings remain in processing queue + if (allJobs?.value) { + allJobs.value = allJobs.value.filter(job => job.recording_id !== deletedId); + } + + // Clear selected recording if it's the one being deleted + if (selectedRecording.value?.id === deletedId) { + selectedRecording.value = null; + currentView.value = 'upload'; + } + + showToast('Recording deleted.', 'fa-trash'); + showDeleteModal.value = false; + recordingToDelete.value = null; + } catch (error) { + setGlobalError(`Failed to delete recording: ${error.message}`); + } + }; + + // ========================================= + // Archive Recording + // ========================================= + + const archiveRecording = async (recording) => { + if (!recording) return; + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/api/recordings/${recording.id}/archive`, { + method: 'POST', + headers: { 'X-CSRFToken': csrfToken } + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to archive recording'); + + recording.is_archived = true; + recording.audio_deleted_at = data.audio_deleted_at; + + // Update in recordings list + const index = recordings.value.findIndex(r => r.id === recording.id); + if (index !== -1) { + recordings.value[index].is_archived = true; + recordings.value[index].audio_deleted_at = data.audio_deleted_at; + } + + showToast('Recording archived (audio deleted)', 'fa-archive'); + } catch (error) { + setGlobalError(`Failed to archive recording: ${error.message}`); + } + }; + + // ========================================= + // Edit Tags Modal + // ========================================= + + const openEditTagsModal = () => { + selectedNewTagId.value = ''; + tagSearchFilter.value = ''; + showEditTagsModal.value = true; + }; + + const closeEditTagsModal = () => { + showEditTagsModal.value = false; + }; + + const addTagToRecording = async (tagId) => { + if (!selectedRecording.value || !tagId) return; + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ tag_id: tagId }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to add tag'); + + // Find the tag object + const tag = availableTags.value.find(t => t.id === tagId); + if (tag) { + if (!selectedRecording.value.tags) { + selectedRecording.value.tags = []; + } + selectedRecording.value.tags.push(tag); + } + + // Update in recordings list + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1 && tag) { + if (!recordings.value[index].tags) { + recordings.value[index].tags = []; + } + recordings.value[index].tags.push(tag); + } + + selectedNewTagId.value = ''; + showToast('Tag added!', 'fa-tag'); + } catch (error) { + setGlobalError(`Failed to add tag: ${error.message}`); + } + }; + + const removeTagFromRecording = async (tagId) => { + if (!selectedRecording.value || !tagId) return; + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags/${tagId}`, { + method: 'DELETE', + headers: { 'X-CSRFToken': csrfToken } + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to remove tag'); + + // Remove from selected recording + if (selectedRecording.value.tags) { + selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tagId); + } + + // Update in recordings list + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1 && recordings.value[index].tags) { + recordings.value[index].tags = recordings.value[index].tags.filter(t => t.id !== tagId); + } + + showToast('Tag removed!', 'fa-tag'); + } catch (error) { + setGlobalError(`Failed to remove tag: ${error.message}`); + } + }; + + // ========================================= + // Reset Modal + // ========================================= + + const openResetModal = (recording) => { + recordingToReset.value = recording; + showResetModal.value = true; + }; + + const cancelReset = () => { + showResetModal.value = false; + recordingToReset.value = null; + }; + + const resetRecording = async () => { + if (!recordingToReset.value) return; + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${recordingToReset.value.id}/reset_status`, { + method: 'POST', + headers: { 'X-CSRFToken': csrfToken } + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to reset recording'); + + // Update recording status + const index = recordings.value.findIndex(r => r.id === recordingToReset.value.id); + if (index !== -1) { + recordings.value[index].status = 'PENDING'; + recordings.value[index].transcription = ''; + recordings.value[index].summary = ''; + } + + if (selectedRecording.value?.id === recordingToReset.value.id) { + selectedRecording.value.status = 'PENDING'; + selectedRecording.value.transcription = ''; + selectedRecording.value.summary = ''; + } + + showToast('Recording reset for reprocessing.', 'fa-redo'); + showResetModal.value = false; + recordingToReset.value = null; + } catch (error) { + setGlobalError(`Failed to reset recording: ${error.message}`); + } + }; + + // ========================================= + // System Audio Help Modal + // ========================================= + + const openSystemAudioHelpModal = () => { + showSystemAudioHelpModal.value = true; + }; + + const closeSystemAudioHelpModal = () => { + showSystemAudioHelpModal.value = false; + }; + + // ========================================= + // Toast Management + // ========================================= + + const dismissToast = (id) => { + toasts.value = toasts.value.filter(t => t.id !== id); + }; + + // Aliases for template compatibility + const editRecording = openEditModal; + const editRecordingTags = openEditTagsModal; + + // ========================================= + // DateTime Picker + // ========================================= + + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + + // Generate available years (10 years before and after current year) + const availableYears = computed(() => { + const currentYear = new Date().getFullYear(); + const years = []; + for (let y = currentYear - 10; y <= currentYear + 10; y++) { + years.push(y); + } + return years; + }); + + // Generate hours for 12-hour format + const hours12 = computed(() => { + const hours = []; + for (let h = 1; h <= 12; h++) { + hours.push({ value: h, label: h.toString() }); + } + return hours; + }); + + // Generate minutes + const minutes = computed(() => { + const mins = []; + for (let m = 0; m < 60; m++) { + mins.push(m); + } + return mins; + }); + + // Generate calendar days for current month view + const calendarDays = computed(() => { + const days = []; + const year = pickerYear.value; + const month = pickerMonth.value; + + // First day of the month + const firstDay = new Date(year, month, 1); + const startingDay = firstDay.getDay(); + + // Last day of the month + const lastDay = new Date(year, month + 1, 0); + const totalDays = lastDay.getDate(); + + // Previous month days to fill the grid + const prevMonthLastDay = new Date(year, month, 0).getDate(); + for (let i = startingDay - 1; i >= 0; i--) { + days.push({ + day: prevMonthLastDay - i, + date: new Date(year, month - 1, prevMonthLastDay - i), + inMonth: false, + isToday: false, + isSelected: false + }); + } + + // Current month days + const today = new Date(); + for (let d = 1; d <= totalDays; d++) { + const date = new Date(year, month, d); + const isToday = date.toDateString() === today.toDateString(); + const isSelected = pickerSelectedDate.value && + date.toDateString() === pickerSelectedDate.value.toDateString(); + days.push({ + day: d, + date: date, + inMonth: true, + isToday: isToday, + isSelected: isSelected + }); + } + + // Next month days to fill the grid (6 rows * 7 days = 42 total) + const remainingDays = 42 - days.length; + for (let d = 1; d <= remainingDays; d++) { + days.push({ + day: d, + date: new Date(year, month + 1, d), + inMonth: false, + isToday: false, + isSelected: false + }); + } + + return days; + }); + + const openDateTimePicker = (target, currentValue, callback) => { + dateTimePickerTarget.value = target; + dateTimePickerCallback.value = callback; + + // Parse current value if exists + if (currentValue) { + const date = new Date(currentValue); + if (!isNaN(date.getTime())) { + pickerSelectedDate.value = date; + pickerMonth.value = date.getMonth(); + pickerYear.value = date.getFullYear(); + + let hours = date.getHours(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours === 0 ? 12 : hours; + + pickerHour.value = hours; + pickerMinute.value = date.getMinutes(); + pickerAmPm.value = ampm; + } else { + setPickerToNow(); + } + } else { + setPickerToNow(); + } + + showDateTimePicker.value = true; + }; + + const setPickerToNow = () => { + const now = new Date(); + pickerSelectedDate.value = now; + pickerMonth.value = now.getMonth(); + pickerYear.value = now.getFullYear(); + + let hours = now.getHours(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours === 0 ? 12 : hours; + + pickerHour.value = hours; + pickerMinute.value = now.getMinutes(); + pickerAmPm.value = ampm; + }; + + const closeDateTimePicker = () => { + showDateTimePicker.value = false; + dateTimePickerTarget.value = null; + dateTimePickerCallback.value = null; + }; + + const prevMonth = () => { + if (pickerMonth.value === 0) { + pickerMonth.value = 11; + pickerYear.value--; + } else { + pickerMonth.value--; + } + }; + + const nextMonth = () => { + if (pickerMonth.value === 11) { + pickerMonth.value = 0; + pickerYear.value++; + } else { + pickerMonth.value++; + } + }; + + const updatePickerView = () => { + // Called when month/year dropdowns change + // The computed calendarDays will automatically update + }; + + const selectDate = (date) => { + pickerSelectedDate.value = date; + }; + + const setToNow = () => { + setPickerToNow(); + }; + + const setToToday = () => { + const today = new Date(); + pickerSelectedDate.value = today; + pickerMonth.value = today.getMonth(); + pickerYear.value = today.getFullYear(); + // Keep the current time + }; + + const clearDateTime = () => { + pickerSelectedDate.value = null; + const now = new Date(); + pickerMonth.value = now.getMonth(); + pickerYear.value = now.getFullYear(); + pickerHour.value = 12; + pickerMinute.value = 0; + pickerAmPm.value = 'PM'; + }; + + const formatPickerPreview = () => { + if (!pickerSelectedDate.value) return ''; + + const date = pickerSelectedDate.value; + const monthName = monthNames[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + + const hour = pickerHour.value; + const minute = pickerMinute.value.toString().padStart(2, '0'); + const ampm = pickerAmPm.value; + + return `${monthName} ${day}, ${year} at ${hour}:${minute} ${ampm}`; + }; + + const applyDateTime = () => { + if (!pickerSelectedDate.value) { + // If no date selected, just close + closeDateTimePicker(); + return; + } + + // Build the full datetime + const date = new Date(pickerSelectedDate.value); + let hours = pickerHour.value; + + // Convert 12-hour to 24-hour + if (pickerAmPm.value === 'AM') { + hours = hours === 12 ? 0 : hours; + } else { + hours = hours === 12 ? 12 : hours + 12; + } + + date.setHours(hours); + date.setMinutes(pickerMinute.value); + date.setSeconds(0); + date.setMilliseconds(0); + + // Format as ISO string for storage (YYYY-MM-DDTHH:mm:ss) + const isoString = date.toISOString().slice(0, 19); + + // Call the callback with the result + if (dateTimePickerCallback.value) { + dateTimePickerCallback.value(isoString, date); + } + + closeDateTimePicker(); + }; + + // Helper to open datetime picker for meeting date + const openMeetingDatePicker = () => { + if (!selectedRecording.value) return; + + openDateTimePicker( + 'meeting_date', + selectedRecording.value.meeting_date, + (isoString) => { + selectedRecording.value.meeting_date = isoString; + // Auto-save the change + saveInlineMeetingDate(); + } + ); + }; + + // Save meeting date inline (similar to other inline edits) + const saveInlineMeetingDate = async () => { + if (!selectedRecording.value) return; + + const fullPayload = { + id: selectedRecording.value.id, + title: selectedRecording.value.title, + participants: selectedRecording.value.participants, + notes: selectedRecording.value.notes, + summary: selectedRecording.value.summary, + meeting_date: selectedRecording.value.meeting_date + }; + + try { + const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch('/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfTokenValue + }, + body: JSON.stringify(fullPayload) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to save meeting date'); + + showToast('Meeting date updated!', 'fa-calendar-check'); + } catch (error) { + showToast(`Failed to save: ${error.message}`, 'fa-exclamation-circle', 3000, 'error'); + } + }; + + return { + // Edit modal + openEditModal, + editRecording, + cancelEdit, + saveEdit, + + // Delete modal + confirmDelete, + cancelDelete, + deleteRecording, + + // Archive + archiveRecording, + + // Tags modal + openEditTagsModal, + editRecordingTags, + closeEditTagsModal, + addTagToRecording, + removeTagFromRecording, + + // Reset modal + openResetModal, + cancelReset, + resetRecording, + + // System audio help + openSystemAudioHelpModal, + closeSystemAudioHelpModal, + + // Toast + dismissToast, + + // DateTime picker + monthNames, + dayNames, + availableYears, + hours12, + minutes, + calendarDays, + openDateTimePicker, + closeDateTimePicker, + prevMonth, + nextMonth, + updatePickerView, + selectDate, + setToNow, + setToToday, + clearDateTime, + formatPickerPreview, + applyDateTime, + openMeetingDatePicker + }; +} diff --git a/static/js/modules/composables/pwa.js b/static/js/modules/composables/pwa.js new file mode 100644 index 0000000..4688a9a --- /dev/null +++ b/static/js/modules/composables/pwa.js @@ -0,0 +1,518 @@ +/** + * PWA Features Composable + * Handles install prompt, push notifications, badging, and other PWA APIs + */ + +import { isPushEnabled, getPublicKey, urlBase64ToUint8Array } from '../../config/push-config.js'; + +export function usePWA(state, utils) { + const { + deferredInstallPrompt, + showInstallButton, + isPWAInstalled, + notificationPermission, + pushSubscription, + appBadgeCount + } = state; + + const { showToast } = utils; + + // --- Install Prompt --- + + /** + * Handle beforeinstallprompt event + * This event is fired when the browser detects the app can be installed + */ + const handleBeforeInstallPrompt = (e) => { + console.log('[PWA] beforeinstallprompt event fired'); + // Prevent the mini-infobar from appearing on mobile + e.preventDefault(); + // Stash the event so it can be triggered later + deferredInstallPrompt.value = e; + // Show our custom install button + showInstallButton.value = true; + }; + + /** + * Prompt user to install the PWA + */ + const promptInstall = async () => { + if (!deferredInstallPrompt.value) { + console.log('[PWA] No deferred install prompt available'); + return; + } + + // Show the install prompt + deferredInstallPrompt.value.prompt(); + + // Wait for the user's response + const { outcome } = await deferredInstallPrompt.value.userChoice; + console.log(`[PWA] User response to install prompt: ${outcome}`); + + if (outcome === 'accepted') { + showToast('Installing Speakr...', 'success'); + } + + // Clear the deferred prompt since it can only be used once + deferredInstallPrompt.value = null; + showInstallButton.value = false; + }; + + /** + * Check if app is already installed + */ + const checkIfInstalled = () => { + // Check if running in standalone mode (installed PWA) + if (window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone === true) { + isPWAInstalled.value = true; + showInstallButton.value = false; + console.log('[PWA] App is installed and running in standalone mode'); + } + }; + + /** + * Handle appinstalled event + */ + const handleAppInstalled = () => { + console.log('[PWA] App was installed'); + isPWAInstalled.value = true; + showInstallButton.value = false; + showToast('Speakr installed successfully!', 'success'); + }; + + // --- Push Notifications --- + + /** + * Request notification permission + */ + const requestNotificationPermission = async () => { + if (!('Notification' in window)) { + console.warn('[PWA] This browser does not support notifications'); + return false; + } + + try { + const permission = await Notification.requestPermission(); + notificationPermission.value = permission; + console.log(`[PWA] Notification permission: ${permission}`); + + if (permission === 'granted') { + showToast('Notifications enabled', 'success'); + return true; + } else if (permission === 'denied') { + showToast('Notification permission denied', 'error'); + return false; + } + } catch (error) { + console.error('[PWA] Error requesting notification permission:', error); + return false; + } + }; + + /** + * Subscribe to push notifications + */ + const subscribeToPushNotifications = async () => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + console.warn('[PWA] Push notifications not supported'); + showToast('Push notifications not supported in this browser', 'warning'); + return null; + } + + // Check if push is enabled on server + const enabled = await isPushEnabled(); + if (!enabled) { + console.warn('[PWA] Push notifications not configured on server'); + showToast('Push notifications not available. Install pywebpush on server.', 'warning'); + return null; + } + + // Get public key from server + const publicKey = await getPublicKey(); + if (!publicKey) { + console.error('[PWA] Failed to get VAPID public key from server'); + showToast('Failed to configure push notifications', 'error'); + return null; + } + + try { + const registration = await navigator.serviceWorker.ready; + + // Check if already subscribed + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + // Subscribe to push notifications + console.log('[PWA] Creating new push subscription...'); + + const applicationServerKey = urlBase64ToUint8Array(publicKey); + + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey + }); + + // Send subscription to server + const success = await sendSubscriptionToServer(subscription); + + if (success) { + pushSubscription.value = subscription; + showToast('Push notifications enabled', 'success'); + console.log('[PWA] Push subscription successful:', subscription); + } else { + console.warn('[PWA] Failed to save subscription on server'); + showToast('Failed to enable push notifications', 'error'); + return null; + } + } else { + pushSubscription.value = subscription; + console.log('[PWA] Already subscribed to push notifications'); + } + + return subscription; + } catch (error) { + console.error('[PWA] Failed to subscribe to push notifications:', error); + + if (error.name === 'NotAllowedError') { + showToast('Push notification permission denied', 'error'); + } else { + showToast('Failed to enable push notifications', 'error'); + } + + return null; + } + }; + + /** + * Send subscription to server for storage + */ + const sendSubscriptionToServer = async (subscription) => { + try { + const response = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(subscription), + credentials: 'same-origin' + }); + + if (!response.ok) { + console.error('[PWA] Server rejected push subscription:', response.status); + return false; + } + + const data = await response.json(); + console.log('[PWA] Subscription saved on server:', data); + return true; + } catch (error) { + console.error('[PWA] Failed to send subscription to server:', error); + return false; + } + }; + + /** + * Unsubscribe from push notifications + */ + const unsubscribeFromPushNotifications = async () => { + if (!pushSubscription.value) { + console.log('[PWA] No active push subscription to unsubscribe'); + return true; + } + + try { + // Unsubscribe on client + await pushSubscription.value.unsubscribe(); + + // Remove from server + await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(pushSubscription.value), + credentials: 'same-origin' + }); + + pushSubscription.value = null; + showToast('Push notifications disabled', 'info'); + console.log('[PWA] Unsubscribed from push notifications'); + return true; + } catch (error) { + console.error('[PWA] Failed to unsubscribe from push notifications:', error); + showToast('Failed to disable push notifications', 'error'); + return false; + } + }; + + /** + * Show a local notification + */ + const showNotification = async (title, options = {}) => { + if (!('Notification' in window)) { + console.warn('[PWA] Notifications not supported'); + return; + } + + if (Notification.permission !== 'granted') { + const granted = await requestNotificationPermission(); + if (!granted) return; + } + + try { + const registration = await navigator.serviceWorker.ready; + + const defaultOptions = { + icon: '/static/img/icon-192x192.png', + badge: '/static/img/icon-192x192.png', + vibrate: [200, 100, 200], + tag: 'speakr-notification', + renotify: true, + ...options + }; + + await registration.showNotification(title, defaultOptions); + } catch (error) { + console.error('[PWA] Error showing notification:', error); + } + }; + + // --- Badging API --- + + /** + * Set app badge count + */ + const setAppBadge = async (count) => { + if (!('setAppBadge' in navigator)) { + console.log('[PWA] Badging API not supported'); + return; + } + + try { + if (count > 0) { + await navigator.setAppBadge(count); + appBadgeCount.value = count; + console.log(`[PWA] App badge set to ${count}`); + } else { + await navigator.clearAppBadge(); + appBadgeCount.value = 0; + console.log('[PWA] App badge cleared'); + } + } catch (error) { + console.error('[PWA] Error setting app badge:', error); + } + }; + + /** + * Clear app badge + */ + const clearAppBadge = async () => { + await setAppBadge(0); + }; + + /** + * Update badge with unread count + */ + const updateBadgeCount = async (audioFiles) => { + if (!audioFiles || !Array.isArray(audioFiles)) return; + + // Count unread recordings (those still in inbox) + const unreadCount = audioFiles.filter(file => file.in_inbox).length; + await setAppBadge(unreadCount); + }; + + // --- Media Session API --- + + /** + * Set up Media Session for audio playback control + * @param {Object} metadata - Track metadata { title, artist, album, artwork } + * @param {Object} handlers - Action handlers { play, pause, seekbackward, seekforward, previoustrack, nexttrack } + */ + const setupMediaSession = (metadata, handlers = {}) => { + if (!('mediaSession' in navigator)) { + console.log('[PWA] Media Session API not supported'); + return false; + } + + try { + // Set metadata + if (metadata) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: metadata.title || 'Untitled Recording', + artist: metadata.artist || 'Speakr', + album: metadata.album || 'Recordings', + artwork: metadata.artwork || [ + { src: '/static/img/icon-192x192.png', sizes: '192x192', type: 'image/png' }, + { src: '/static/img/icon-512x512.png', sizes: '512x512', type: 'image/png' } + ] + }); + currentMediaMetadata.value = metadata; + } + + // Set action handlers + const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop']; + + actions.forEach(action => { + if (handlers[action]) { + try { + navigator.mediaSession.setActionHandler(action, handlers[action]); + } catch (error) { + console.warn(`[PWA] The ${action} action is not supported`); + } + } + }); + + // Set position state if provided + if (handlers.setPositionState) { + try { + navigator.mediaSession.setPositionState(handlers.setPositionState); + } catch (error) { + console.warn('[PWA] setPositionState not supported:', error); + } + } + + isMediaSessionActive.value = true; + console.log('[PWA] Media Session configured successfully'); + return true; + } catch (error) { + console.error('[PWA] Error setting up Media Session:', error); + return false; + } + }; + + /** + * Update Media Session position state + * @param {Object} state - { duration, playbackRate, position } + */ + const updateMediaSessionPosition = (state) => { + if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return; + + try { + navigator.mediaSession.setPositionState({ + duration: state.duration || 0, + playbackRate: state.playbackRate || 1.0, + position: state.position || 0 + }); + } catch (error) { + console.warn('[PWA] Error updating position state:', error); + } + }; + + /** + * Update Media Session playback state + * @param {string} state - 'playing' | 'paused' | 'none' + */ + const updateMediaSessionPlaybackState = (state) => { + if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return; + + try { + navigator.mediaSession.playbackState = state; + } catch (error) { + console.warn('[PWA] Error updating playback state:', error); + } + }; + + /** + * Clear Media Session + */ + const clearMediaSession = () => { + if (!('mediaSession' in navigator)) return; + + try { + navigator.mediaSession.metadata = null; + const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop']; + actions.forEach(action => { + try { + navigator.mediaSession.setActionHandler(action, null); + } catch (e) { /* ignore */ } + }); + isMediaSessionActive.value = false; + currentMediaMetadata.value = null; + console.log('[PWA] Media Session cleared'); + } catch (error) { + console.error('[PWA] Error clearing Media Session:', error); + } + }; + + // --- Background Sync --- + + /** + * Register background sync for upload retry + */ + const registerBackgroundSync = async (tag = 'sync-uploads') => { + if (!('serviceWorker' in navigator) || !('sync' in ServiceWorkerRegistration.prototype)) { + console.log('[PWA] Background sync not supported'); + return false; + } + + try { + const registration = await navigator.serviceWorker.ready; + await registration.sync.register(tag); + console.log(`[PWA] Background sync registered: ${tag}`); + return true; + } catch (error) { + console.error('[PWA] Failed to register background sync:', error); + return false; + } + }; + + /** + * Initialize PWA features + */ + const initPWA = () => { + // Check if already installed + checkIfInstalled(); + + // Listen for beforeinstallprompt event + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + // Listen for appinstalled event + window.addEventListener('appinstalled', handleAppInstalled); + + // Check notification permission status + if ('Notification' in window) { + notificationPermission.value = Notification.permission; + } + + console.log('[PWA] PWA features initialized'); + }; + + /** + * Cleanup PWA event listeners + */ + const cleanupPWA = () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.removeEventListener('appinstalled', handleAppInstalled); + }; + + return { + // Install prompt + promptInstall, + checkIfInstalled, + + // Notifications + requestNotificationPermission, + subscribeToPushNotifications, + unsubscribeFromPushNotifications, + showNotification, + + // Badging + setAppBadge, + clearAppBadge, + updateBadgeCount, + + // Media Session + setupMediaSession, + updateMediaSessionPosition, + updateMediaSessionPlaybackState, + clearMediaSession, + + // Background sync + registerBackgroundSync, + + // Initialization + initPWA, + cleanupPWA + }; +} diff --git a/static/js/modules/composables/recordings.js b/static/js/modules/composables/recordings.js new file mode 100644 index 0000000..7e6e910 --- /dev/null +++ b/static/js/modules/composables/recordings.js @@ -0,0 +1,482 @@ +/** + * Recording management composable + * Handles loading, selecting, filtering, and managing recordings + */ + +import * as IncognitoStorage from '../db/incognito-storage.js'; + +export function useRecordings(state, utils, reprocessComposable) { + const { + recordings, selectedRecording, isLoadingRecordings, isLoadingMore, + currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage, + showSharedWithMe, showArchivedRecordings, searchQuery, searchDebounceTimer, + filterTags, filterSpeakers, filterDatePreset, filterDateRange, filterTextQuery, + filterStarred, filterInbox, filterFolder, sortBy, + availableTags, availableSpeakers, availableFolders, selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt, + useAsrEndpoint, connectorSupportsDiarization, globalError, uploadQueue, isProcessingActive, currentView, + isMobileScreen, isSidebarCollapsed, isRecording, audioBlobURL, + speakerColorMap, + // Incognito mode + incognitoRecording + } = state; + + const { setGlobalError, showToast } = utils; + + // Load recordings from API + const loadRecordings = async (page = 1, append = false, searchQueryParam = '') => { + globalError.value = null; + if (!append) { + isLoadingRecordings.value = true; + } else { + isLoadingMore.value = true; + } + + try { + const endpoint = '/api/recordings'; + + const params = new URLSearchParams({ + page: page.toString(), + per_page: perPage.value.toString() + }); + + if (searchQueryParam.trim()) { + params.set('q', searchQueryParam.trim()); + } + + // Add sort parameter + if (sortBy.value) { + params.set('sort_by', sortBy.value); + } + + // Add archived/shared/starred/inbox filters as query params (ANDed with other filters) + if (showArchivedRecordings.value) { + params.set('archived', 'true'); + } + if (showSharedWithMe.value) { + params.set('shared', 'true'); + } + if (filterStarred.value) { + params.set('starred', 'true'); + } + if (filterInbox.value) { + params.set('inbox', 'true'); + } + + // Add folder filter + if (filterFolder && filterFolder.value) { + params.set('folder', filterFolder.value); + } + + const response = await fetch(`${endpoint}?${params}`); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to load recordings'); + + const recordingsList = data.recordings; + const pagination = data.pagination; + + if (!Array.isArray(recordingsList)) { + console.error('Unexpected response format:', data); + throw new Error('Invalid response format from server'); + } + + if (pagination) { + currentPage.value = pagination.page; + totalRecordings.value = pagination.total; + totalPages.value = pagination.total_pages; + hasNextPage.value = pagination.has_next; + hasPrevPage.value = pagination.has_prev; + } else { + currentPage.value = 1; + totalRecordings.value = recordingsList.length; + totalPages.value = 1; + hasNextPage.value = false; + hasPrevPage.value = false; + } + + if (append) { + recordings.value = [...recordings.value, ...recordingsList]; + } else { + recordings.value = recordingsList; + const lastRecordingId = localStorage.getItem('lastSelectedRecordingId'); + if (lastRecordingId && recordingsList.length > 0) { + const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId); + if (recordingToSelect) { + selectRecording(recordingToSelect); + } + } + } + + // NOTE: Removed auto-queueing of incomplete recordings. + // Backend processing recordings are now shown via backendProcessingRecordings + // computed property, which filters recordings by status (PENDING, PROCESSING, etc.) + // The job queue system (ProcessingJob) handles background processing. + + } catch (error) { + console.error('Load Recordings Error:', error); + setGlobalError(`Failed to load recordings: ${error.message}`); + if (!append) { + recordings.value = []; + } + } finally { + isLoadingRecordings.value = false; + isLoadingMore.value = false; + } + }; + + const loadMoreRecordings = async () => { + if (!hasNextPage.value || isLoadingMore.value) return; + await loadRecordings(currentPage.value + 1, true, searchQuery.value); + }; + + const performSearch = async (query = '') => { + currentPage.value = 1; + await loadRecordings(1, false, query); + }; + + const debouncedSearch = (query) => { + if (searchDebounceTimer.value) { + clearTimeout(searchDebounceTimer.value); + } + searchDebounceTimer.value = setTimeout(() => { + performSearch(query); + }, 300); + }; + + const loadTags = async () => { + try { + const response = await fetch('/api/tags'); + if (response.ok) { + availableTags.value = await response.json(); + } else { + availableTags.value = []; + } + } catch (error) { + console.warn('Error loading tags:', error); + availableTags.value = []; + } + }; + + const loadFolders = async () => { + try { + const response = await fetch('/api/folders'); + if (response.ok) { + availableFolders.value = await response.json(); + } else { + availableFolders.value = []; + } + } catch (error) { + console.warn('Error loading folders:', error); + availableFolders.value = []; + } + }; + + const loadSpeakers = async () => { + try { + const response = await fetch('/speakers'); + if (response.ok) { + availableSpeakers.value = await response.json(); + } else { + availableSpeakers.value = []; + } + } catch (error) { + console.warn('Error loading speakers:', error); + availableSpeakers.value = []; + } + }; + + const selectRecording = async (recording) => { + if (hasUnsavedRecording()) { + if (!confirm('You have an unsaved recording. Are you sure you want to leave?')) { + return; + } + } + + // Check if switching away from incognito recording to a regular recording + if (incognitoRecording && incognitoRecording.value && + selectedRecording.value?.id === 'incognito' && + recording?.id !== 'incognito') { + if (!confirm('Switching to another recording will discard your incognito recording. Continue?')) { + return; + } + // Clear incognito recording immediately - this is the "incognito" promise + IncognitoStorage.clearIncognitoRecording(); + incognitoRecording.value = null; + } + + // Also clear any orphaned incognito data when selecting a non-incognito recording + // This handles edge cases like page refresh where the above check doesn't trigger + if (recording?.id !== 'incognito' && IncognitoStorage.hasIncognitoRecording()) { + console.log('[Incognito] Clearing orphaned incognito data'); + IncognitoStorage.clearIncognitoRecording(); + if (incognitoRecording) { + incognitoRecording.value = null; + } + } + + // Reset modal audio state when switching recordings + if (utils.resetModalAudioState) { + utils.resetModalAudioState(); + } + + // Clear speaker color map when switching recordings - new colors will be assigned on first render + if (speakerColorMap) { + speakerColorMap.value = {}; + } + + selectedRecording.value = recording; + + if (recording && recording.id) { + localStorage.setItem('lastSelectedRecordingId', recording.id); + + try { + const response = await fetch(`/api/recordings/${recording.id}`); + if (response.ok) { + const fullRecording = await response.json(); + selectedRecording.value = fullRecording; + + const index = recordings.value.findIndex(r => r.id === recording.id); + if (index !== -1) { + recordings.value[index] = fullRecording; + } + + // Auto-start polling if recording is still processing or summarizing + if (['PROCESSING', 'SUMMARIZING'].includes(fullRecording.status)) { + console.log(`[AUTO-POLL] Recording ${fullRecording.id} is in ${fullRecording.status} state, starting auto-polling`); + if (reprocessComposable && reprocessComposable.startReprocessingPoll) { + reprocessComposable.startReprocessingPoll(fullRecording.id); + } else { + console.warn('[AUTO-POLL] reprocessComposable.startReprocessingPoll not available'); + } + } + } + } catch (error) { + console.error('Error loading full recording:', error); + } + } + + if (isMobileScreen.value) { + isSidebarCollapsed.value = true; + } + + currentView.value = 'detail'; + + if (isRecording.value) { + // Don't interrupt recording + } + if (audioBlobURL.value) { + // Don't discard recorded audio + } + }; + + const hasUnsavedRecording = () => { + return isRecording.value || audioBlobURL.value; + }; + + const toggleInbox = async (recording) => { + if (!recording || !recording.id) return; + + try { + const response = await fetch(`/recording/${recording.id}/toggle_inbox`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to toggle inbox status'); + + // Update the recording in the UI + recording.is_inbox = data.is_inbox; + + // Update in the recordings list + const index = recordings.value.findIndex(r => r.id === recording.id); + if (index !== -1) { + recordings.value[index].is_inbox = data.is_inbox; + } + + showToast(`Recording ${data.is_inbox ? 'moved to inbox' : 'marked as read'}`); + } catch (error) { + console.error('Toggle Inbox Error:', error); + setGlobalError(`Failed to toggle inbox status: ${error.message}`); + } + }; + + const toggleHighlight = async (recording) => { + if (!recording || !recording.id) return; + + try { + const response = await fetch(`/recording/${recording.id}/toggle_highlight`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to toggle highlighted status'); + + // Update the recording in the UI + recording.is_highlighted = data.is_highlighted; + + // Update in the recordings list + const index = recordings.value.findIndex(r => r.id === recording.id); + if (index !== -1) { + recordings.value[index].is_highlighted = data.is_highlighted; + } + + showToast(`Recording ${data.is_highlighted ? 'highlighted' : 'unhighlighted'}`); + } catch (error) { + console.error('Toggle Highlight Error:', error); + setGlobalError(`Failed to toggle highlighted status: ${error.message}`); + } + }; + + const getRecordingTags = (recording) => { + if (!recording || !recording.tags) return []; + return recording.tags || []; + }; + + const getAvailableTagsForRecording = (recording) => { + if (!recording || !availableTags.value) return []; + const recordingTagIds = getRecordingTags(recording).map(tag => tag.id); + return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id)); + }; + + const filterByTag = (tag) => { + filterTags.value = [tag.id]; + applyAdvancedFilters(); + }; + + const buildSearchQuery = () => { + let query = []; + + if (filterTextQuery.value.trim()) { + query.push(filterTextQuery.value.trim()); + } + + if (filterTags.value.length > 0) { + const tagNames = filterTags.value.map(tagId => { + const tag = availableTags.value.find(t => t.id === tagId); + return tag ? `tag:${tag.name.replace(/\s+/g, '_')}` : ''; + }).filter(Boolean); + query.push(...tagNames); + } + + if (filterSpeakers.value.length > 0) { + const speakerNames = filterSpeakers.value.map(name => + `speaker:${name.replace(/\s+/g, '_')}` + ); + query.push(...speakerNames); + } + + if (filterDatePreset.value) { + query.push(`date:${filterDatePreset.value}`); + } else if (filterDateRange.value.start || filterDateRange.value.end) { + if (filterDateRange.value.start) { + query.push(`date_from:${filterDateRange.value.start}`); + } + if (filterDateRange.value.end) { + query.push(`date_to:${filterDateRange.value.end}`); + } + } + + return query.join(' '); + }; + + const applyAdvancedFilters = () => { + searchQuery.value = buildSearchQuery(); + }; + + const clearAllFilters = () => { + filterTags.value = []; + filterSpeakers.value = []; + filterDateRange.value = { start: '', end: '' }; + filterDatePreset.value = ''; + filterTextQuery.value = ''; + filterStarred.value = false; + filterInbox.value = false; + // Note: filterFolder is NOT cleared here - it's a navigation element, not a filter + searchQuery.value = ''; + }; + + const clearTagFilter = () => { + searchQuery.value = ''; + clearAllFilters(); + }; + + const addTagToSelection = (tagId) => { + if (!selectedTagIds.value.includes(tagId)) { + selectedTagIds.value.push(tagId); + applyTagDefaults(); + } + }; + + const removeTagFromSelection = (tagId) => { + const index = selectedTagIds.value.indexOf(tagId); + if (index > -1) { + selectedTagIds.value.splice(index, 1); + applyTagDefaults(); + } + }; + + const applyTagDefaults = () => { + const selectedTags = selectedTagIds.value.map(tagId => + availableTags.value.find(tag => tag.id == tagId) + ).filter(Boolean); + + const firstTag = selectedTags[0]; + if (firstTag && connectorSupportsDiarization.value) { + if (firstTag.default_language) { + uploadLanguage.value = firstTag.default_language; + } + if (firstTag.default_min_speakers) { + uploadMinSpeakers.value = firstTag.default_min_speakers; + } + if (firstTag.default_max_speakers) { + uploadMaxSpeakers.value = firstTag.default_max_speakers; + } + } + if (firstTag) { + if (firstTag.default_hotwords) { + uploadHotwords.value = firstTag.default_hotwords; + } + if (firstTag.default_initial_prompt) { + uploadInitialPrompt.value = firstTag.default_initial_prompt; + } + } + }; + + const pollInboxRecordings = async () => { + try { + const response = await fetch('/api/recordings/inbox-count'); + if (response.ok) { + const data = await response.json(); + // Update inbox count in UI if needed + } + } catch (error) { + // Silent fail for polling + } + }; + + return { + loadRecordings, + loadMoreRecordings, + performSearch, + debouncedSearch, + loadTags, + loadFolders, + loadSpeakers, + selectRecording, + hasUnsavedRecording, + toggleInbox, + toggleHighlight, + getRecordingTags, + getAvailableTagsForRecording, + filterByTag, + buildSearchQuery, + applyAdvancedFilters, + clearAllFilters, + clearTagFilter, + addTagToSelection, + removeTagFromSelection, + applyTagDefaults, + pollInboxRecordings + }; +} diff --git a/static/js/modules/composables/reprocess.js b/static/js/modules/composables/reprocess.js new file mode 100644 index 0000000..32859a6 --- /dev/null +++ b/static/js/modules/composables/reprocess.js @@ -0,0 +1,450 @@ +/** + * Reprocessing composable + * Handles reprocessing transcription and summary + */ + +import * as IncognitoStorage from '../db/incognito-storage.js'; + +export function useReprocess(state, utils) { + const { nextTick } = Vue; + + const { + showReprocessModal, showResetModal, reprocessType, + reprocessRecording, recordingToReset, selectedRecording, + recordings, asrReprocessOptions, summaryReprocessPromptSource, + summaryReprocessSelectedTagId, summaryReprocessCustomPrompt, + availableTags, processingProgress, processingMessage, + currentlyProcessingFile, uploadQueue + } = state; + + const { showToast, setGlobalError, onChatComplete } = utils; + + // Store for active polling intervals + const reprocessingPolls = new Map(); + + // ========================================= + // Reprocess Modal + // ========================================= + + const openReprocessModal = (type, recording = null) => { + reprocessType.value = type; + reprocessRecording.value = recording || selectedRecording.value; + showReprocessModal.value = true; + + // Reset options + if (type === 'transcription') { + asrReprocessOptions.language = ''; + asrReprocessOptions.min_speakers = ''; + asrReprocessOptions.max_speakers = ''; + } else { + summaryReprocessPromptSource.value = 'default'; + summaryReprocessSelectedTagId.value = ''; + summaryReprocessCustomPrompt.value = ''; + } + }; + + const closeReprocessModal = () => { + showReprocessModal.value = false; + reprocessRecording.value = null; + reprocessType.value = null; + }; + + const confirmReprocess = openReprocessModal; + const cancelReprocess = closeReprocessModal; + + // ========================================= + // Reset Status + // ========================================= + + const confirmReset = (recording) => { + recordingToReset.value = recording; + showResetModal.value = true; + }; + + const cancelReset = () => { + showResetModal.value = false; + recordingToReset.value = null; + }; + + const executeReset = async () => { + if (!recordingToReset.value) return; + + const recordingId = recordingToReset.value.id; + + // Close the modal first + cancelReset(); + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${recordingId}/reset_status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + } + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to reset recording status'); + + // Update recording status in list + const index = recordings.value.findIndex(r => r.id === recordingId); + if (index !== -1) { + recordings.value[index].status = 'FAILED'; + } + + if (selectedRecording.value?.id === recordingId) { + selectedRecording.value.status = 'FAILED'; + } + + showToast('Recording status reset to FAILED', 'fa-undo'); + } catch (error) { + setGlobalError(`Failed to reset status: ${error.message}`); + } + }; + + const executeReprocess = async () => { + if (!reprocessRecording.value || !reprocessType.value) return; + + const recordingId = reprocessRecording.value.id; + const type = reprocessType.value; + + closeReprocessModal(); + + if (type === 'transcription') { + await reprocessTranscription( + recordingId, + asrReprocessOptions.language, + asrReprocessOptions.min_speakers, + asrReprocessOptions.max_speakers + ); + } else { + await reprocessSummary( + recordingId, + summaryReprocessPromptSource.value, + summaryReprocessSelectedTagId.value, + summaryReprocessCustomPrompt.value + ); + } + }; + + // ========================================= + // Transcription Reprocessing + // ========================================= + + const reprocessTranscription = async (recordingId, language, minSpeakers, maxSpeakers) => { + if (!recordingId) { + setGlobalError('No recording ID provided for reprocessing.'); + return; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const requestBody = { + language: language || '' // Always send language - empty string means auto-detect + }; + if (minSpeakers && minSpeakers !== '') requestBody.min_speakers = parseInt(minSpeakers); + if (maxSpeakers && maxSpeakers !== '') requestBody.max_speakers = parseInt(maxSpeakers); + + const response = await fetch(`/recording/${recordingId}/reprocess_transcription`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to start transcription reprocessing'); + + // Update recording status in list + const index = recordings.value.findIndex(r => r.id === recordingId); + if (index !== -1) { + recordings.value[index].status = 'PROCESSING'; + } + + if (selectedRecording.value?.id === recordingId) { + selectedRecording.value.status = 'PROCESSING'; + } + + showToast('Transcription reprocessing started', 'fa-sync-alt'); + + // Start polling for progress + startReprocessingPoll(recordingId); + } catch (error) { + setGlobalError(`Failed to start transcription reprocessing: ${error.message}`); + } + }; + + // ========================================= + // Summary Reprocessing + // ========================================= + + const reprocessSummary = async (recordingId, promptSource, selectedTagId, customPrompt) => { + if (!recordingId) { + setGlobalError('No recording ID provided for reprocessing.'); + return; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const requestBody = { reprocess_summary: true }; + + if (promptSource === 'tag' && selectedTagId) { + const selectedTag = availableTags.value.find(t => t.id == selectedTagId); + if (selectedTag && selectedTag.custom_prompt) { + requestBody.custom_prompt = selectedTag.custom_prompt; + } + } else if (promptSource === 'custom' && customPrompt) { + requestBody.custom_prompt = customPrompt; + } + + const response = await fetch(`/recording/${recordingId}/reprocess_summary`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to start summary reprocessing'); + + // Update recording status in list + const index = recordings.value.findIndex(r => r.id === recordingId); + if (index !== -1) { + recordings.value[index].status = 'SUMMARIZING'; + } + + if (selectedRecording.value?.id === recordingId) { + selectedRecording.value.status = 'SUMMARIZING'; + } + + showToast('Summary reprocessing started', 'fa-sync-alt'); + + // Start polling for progress + startReprocessingPoll(recordingId); + } catch (error) { + setGlobalError(`Failed to start summary reprocessing: ${error.message}`); + } + }; + + // ========================================= + // Generate Summary + // ========================================= + + const generateSummary = async () => { + if (!selectedRecording.value) return; + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + // Check if this is an incognito recording + if (selectedRecording.value.incognito === true) { + // Use incognito summary endpoint - generate synchronously + const response = await fetch('/api/recordings/incognito/summary', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + transcription: selectedRecording.value.transcription + }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to generate summary'); + + // Update the incognito recording with the new summary + selectedRecording.value.summary = data.summary; + selectedRecording.value.summary_html = data.summary_html; + + // Update sessionStorage + IncognitoStorage.updateIncognitoRecording({ + summary: data.summary, + summary_html: data.summary_html + }); + + showToast('Summary generated', 'fa-file-alt'); + return; + } + + // Regular recording - use existing flow + const response = await fetch(`/recording/${selectedRecording.value.id}/generate_summary`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + } + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to start summary generation'); + + selectedRecording.value.status = 'SUMMARIZING'; + + const recordingInList = recordings.value.find(r => r.id === selectedRecording.value.id); + if (recordingInList) { + recordingInList.status = 'SUMMARIZING'; + } + + showToast('Summary generation started', 'fa-file-alt'); + + // Start polling for progress + startReprocessingPoll(selectedRecording.value.id); + } catch (error) { + setGlobalError(`Failed to generate summary: ${error.message}`); + } + }; + + // ========================================= + // Progress Polling + // ========================================= + + const startReprocessingPoll = (recordingId) => { + // Stop existing poll if any + stopReprocessingPoll(recordingId); + + // Track if we've already fetched full data for SUMMARIZING status + let hasFetchedForSummarizing = false; + + const pollInterval = setInterval(async () => { + try { + // Use lightweight status-only endpoint + const response = await fetch(`/recording/${recordingId}/status`); + if (!response.ok) throw new Error('Status check failed'); + + const statusData = await response.json(); + + // Update status in recordings list + const index = recordings.value.findIndex(r => r.id === recordingId); + + if (index !== -1) { + // Create new object to ensure Vue reactivity + recordings.value[index] = { + ...recordings.value[index], + status: statusData.status + }; + } + + // Update selectedRecording with new object reference for reactivity + if (selectedRecording.value?.id === recordingId) { + selectedRecording.value = { + ...selectedRecording.value, + status: statusData.status + }; + } + + // Check if summarization has started (fetch transcript) or processing is complete + if (statusData.status === 'SUMMARIZING' || statusData.status === 'COMPLETED') { + // Only fetch once when status first becomes SUMMARIZING + const shouldFetch = (statusData.status === 'SUMMARIZING' && !hasFetchedForSummarizing) || + statusData.status === 'COMPLETED'; + + if (shouldFetch) { + // Mark that we've fetched for SUMMARIZING + if (statusData.status === 'SUMMARIZING') { + hasFetchedForSummarizing = true; + } + + // Only stop polling if COMPLETED, keep polling during SUMMARIZING + if (statusData.status === 'COMPLETED') { + stopReprocessingPoll(recordingId); + } + + // Fetch the full recording with updated data + const fullResponse = await fetch(`/api/recordings/${recordingId}`); + + if (fullResponse.ok) { + const fullData = await fullResponse.json(); + + // Update in recordings list first + const currentIndex = recordings.value.findIndex(r => r.id === recordingId); + if (currentIndex !== -1) { + recordings.value[currentIndex] = fullData; + } + + // Always update selectedRecording if it's the current recording + if (selectedRecording.value?.id === recordingId) { + selectedRecording.value = fullData; + await nextTick(); + } + } + + if (statusData.status === 'COMPLETED') { + showToast('Processing completed!', 'fa-check-circle'); + // Refresh token budget after LLM operations complete + if (onChatComplete) onChatComplete(); + } + } + } else if (statusData.status === 'FAILED') { + stopReprocessingPoll(recordingId); + + // Fetch full recording data to get error details for display + try { + const failedResponse = await fetch(`/api/recordings/${recordingId}`); + if (failedResponse.ok) { + const failedData = await failedResponse.json(); + + // Update in recordings list + const currentIndex = recordings.value.findIndex(r => r.id === recordingId); + if (currentIndex !== -1) { + recordings.value[currentIndex] = failedData; + } + + // Update selectedRecording to show error in transcription panel + if (selectedRecording.value?.id === recordingId) { + selectedRecording.value = failedData; + await nextTick(); + } + } + } catch (err) { + console.error('Failed to fetch error details:', err); + } + + showToast('Processing failed', 'fa-exclamation-circle'); + } + } catch (error) { + console.error('Polling error:', error); + stopReprocessingPoll(recordingId); + } + }, 3000); + + reprocessingPolls.set(recordingId, pollInterval); + }; + + const stopReprocessingPoll = (recordingId) => { + if (reprocessingPolls.has(recordingId)) { + clearInterval(reprocessingPolls.get(recordingId)); + reprocessingPolls.delete(recordingId); + } + }; + + return { + // Reprocess modal + openReprocessModal, + closeReprocessModal, + confirmReprocess, + cancelReprocess, + executeReprocess, + + // Reset status + confirmReset, + cancelReset, + executeReset, + + // Transcription + reprocessTranscription, + + // Summary + reprocessSummary, + generateSummary, + + // Polling + startReprocessingPoll, + stopReprocessingPoll + }; +} diff --git a/static/js/modules/composables/sharing.js b/static/js/modules/composables/sharing.js new file mode 100644 index 0000000..6b60e98 --- /dev/null +++ b/static/js/modules/composables/sharing.js @@ -0,0 +1,659 @@ +/** + * Sharing composable + * Handles public and internal sharing of recordings + */ + +export function useSharing(state, utils) { + const { + showShareModal, showSharesListModal, showShareDeleteModal, + showUnifiedShareModal, recordingToShare, shareOptions, + generatedShareLink, existingShareDetected, recordingPublicShares, isLoadingPublicShares, + userShares, isLoadingShares, copiedShareId, shareToDelete, selectedRecording, recordings, + internalShareUserSearch, internalShareSearchResults, + internalShareRecording, internalSharePermissions, internalShareMaxPermissions, + recordingInternalShares, isLoadingInternalShares, + isSearchingUsers, allUsers, isLoadingAllUsers, + enableInternalSharing, showUsernamesInUI + } = state; + + const { showToast, setGlobalError } = utils; + + let userSearchTimeout = null; + + // Helper function to format share dates + const formatShareDate = (dateString) => { + if (!dateString) return 'Unknown date'; + + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + // If today + if (diffDays === 0) { + return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + } + // If yesterday + else if (diffDays === 1) { + return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + } + // If within last week + else if (diffDays < 7) { + return date.toLocaleDateString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: true }); + } + // Otherwise show full date + else { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); + } + } catch (e) { + console.error('Error formatting date:', e); + return dateString; + } + }; + + // Helper function to get color class for username (like speaker colors) + const getUserColorClass = (username) => { + if (!username) return 'speaker-color-1'; + + // Simple hash function to generate consistent color from username + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = ((hash << 5) - hash) + username.charCodeAt(i); + hash = hash & hash; // Convert to 32bit integer + } + + // Map to color classes 1-16 + const colorNum = (Math.abs(hash) % 16) + 1; + return `speaker-color-${colorNum}`; + }; + + // ========================================= + // Public Sharing + // ========================================= + + const openShareModal = async (recording) => { + recordingToShare.value = recording; + shareOptions.share_summary = true; + shareOptions.share_notes = true; + generatedShareLink.value = ''; + existingShareDetected.value = false; + recordingPublicShares.value = []; + showShareModal.value = true; + + // Load all public shares for this recording + isLoadingPublicShares.value = true; + try { + const response = await fetch(`/api/shares`); + if (response.ok) { + const allShares = await response.json(); + // Filter to only shares for this recording and add share_url + recordingPublicShares.value = allShares + .filter(share => share.recording_id === recording.id) + .map(share => ({ + ...share, + share_url: `${window.location.origin}/share/${share.public_id}` + })); + } + } catch (error) { + console.error('Error loading public shares:', error); + recordingPublicShares.value = []; + } finally { + isLoadingPublicShares.value = false; + } + }; + + const closeShareModal = () => { + showShareModal.value = false; + recordingToShare.value = null; + existingShareDetected.value = false; + recordingPublicShares.value = []; + }; + + const createShare = async (forceNew = false) => { + const recording = recordingToShare.value || internalShareRecording.value; + if (!recording) return; + + try { + const response = await fetch(`/api/recording/${recording.id}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...shareOptions, + force_new: forceNew + }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to create share link'); + + generatedShareLink.value = data.share_url; + existingShareDetected.value = data.existing && !forceNew; + + // Add to the shares list (works for both share modal and unified modal) + if (!data.existing) { + recordingPublicShares.value.push({ + ...data.share, + share_url: `${window.location.origin}/share/${data.share.public_id}` + }); + // Update the recording's share count in the UI + await refreshRecordingShareCounts(); + } else if (data.existing && !recordingPublicShares.value.find(s => s.id === data.share.id)) { + // If existing but not in list, add it + recordingPublicShares.value.push({ + ...data.share, + share_url: `${window.location.origin}/share/${data.share.public_id}` + }); + } + + if (data.existing && !forceNew) { + showToast('Using existing share link', 'fa-link'); + } else { + showToast('Share link created successfully!', 'fa-check-circle'); + } + } catch (error) { + setGlobalError(`Failed to create share link: ${error.message}`); + } + }; + + const confirmDeletePublicShare = (share) => { + shareToDelete.value = share; + showShareDeleteModal.value = true; + }; + + const deletePublicShare = async () => { + if (!shareToDelete.value) return; + const shareId = shareToDelete.value.id; + + try { + const response = await fetch(`/api/share/${shareId}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to delete share'); + + // Remove from the shares list (both modals use different arrays) + recordingPublicShares.value = recordingPublicShares.value.filter(s => s.id !== shareId); + userShares.value = userShares.value.filter(s => s.id !== shareId); + + // Update the recording's share count in the UI + await refreshRecordingShareCounts(); + + showToast('Share link deleted successfully.', 'fa-check-circle'); + showShareDeleteModal.value = false; + shareToDelete.value = null; + } catch (error) { + setGlobalError(`Failed to delete share: ${error.message}`); + } + }; + + const copyPublicShareLink = (shareUrl) => { + navigator.clipboard.writeText(shareUrl).then(() => { + showToast('Share link copied to clipboard!', 'fa-check-circle'); + }).catch(() => { + setGlobalError('Failed to copy link to clipboard'); + }); + }; + + const copyPublicShareLinkWithFeedback = (shareUrl, shareId) => { + navigator.clipboard.writeText(shareUrl).then(() => { + copiedShareId.value = shareId; + showToast('Share link copied to clipboard!', 'fa-check-circle'); + + // Reset after delay + setTimeout(() => { + copiedShareId.value = null; + }, 1500); + }).catch(() => { + setGlobalError('Failed to copy link to clipboard'); + }); + }; + + const refreshRecordingShareCounts = async () => { + // Refresh the current recording if one is selected + const recording = recordingToShare.value || internalShareRecording.value || selectedRecording.value; + if (!recording) return; + + try { + const response = await fetch(`/api/recordings/${recording.id}`); + if (response.ok) { + const updatedRecording = await response.json(); + + // Update in recordings list + const index = recordings.value.findIndex(r => r.id === recording.id); + if (index !== -1) { + // Preserve reactivity by updating specific fields + recordings.value[index].public_share_count = updatedRecording.public_share_count || 0; + recordings.value[index].shared_with_count = updatedRecording.shared_with_count || 0; + } + + // Update selected recording if it's the same one + if (selectedRecording.value && selectedRecording.value.id === recording.id) { + selectedRecording.value.public_share_count = updatedRecording.public_share_count || 0; + selectedRecording.value.shared_with_count = updatedRecording.shared_with_count || 0; + } + + // Update internal share recording if it's the same one + if (internalShareRecording.value && internalShareRecording.value.id === recording.id) { + internalShareRecording.value.public_share_count = updatedRecording.public_share_count || 0; + internalShareRecording.value.shared_with_count = updatedRecording.shared_with_count || 0; + } + + // Update recording to share if it's the same one + if (recordingToShare.value && recordingToShare.value.id === recording.id) { + recordingToShare.value.public_share_count = updatedRecording.public_share_count || 0; + recordingToShare.value.shared_with_count = updatedRecording.shared_with_count || 0; + } + } + } catch (error) { + console.error('Failed to refresh recording share counts:', error); + } + }; + + const copyShareLink = () => { + if (!generatedShareLink.value) return; + navigator.clipboard.writeText(generatedShareLink.value).then(() => { + showToast('Share link copied to clipboard!'); + }); + }; + + const copyIndividualShareLink = (shareId) => { + const input = document.getElementById(`share-link-${shareId}`); + if (!input) return; + + const button = input.nextElementSibling; + if (!button) return; + + navigator.clipboard.writeText(input.value).then(() => { + copiedShareId.value = shareId; + showToast('Share link copied to clipboard!', 'fa-check'); + + // Apply success state + button.style.transition = 'background-color 0.2s ease'; + button.style.backgroundColor = 'var(--bg-success, #10b981)'; + + // Revert after delay + setTimeout(() => { + button.style.backgroundColor = ''; + copiedShareId.value = null; + setTimeout(() => { + button.style.transition = ''; + }, 200); + }, 1500); + }).catch(err => { + console.error('Failed to copy share link:', err); + }); + }; + + // ========================================= + // Shares List + // ========================================= + + const openSharesList = async () => { + isLoadingShares.value = true; + showSharesListModal.value = true; + try { + const response = await fetch('/api/shares'); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to load shared items'); + userShares.value = data; + } catch (error) { + setGlobalError(`Failed to load shared items: ${error.message}`); + } finally { + isLoadingShares.value = false; + } + }; + + const closeSharesList = () => { + showSharesListModal.value = false; + userShares.value = []; + }; + + const updateShare = async (share) => { + try { + const response = await fetch(`/api/share/${share.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + share_summary: share.share_summary, + share_notes: share.share_notes + }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update share'); + showToast('Share permissions updated.', 'fa-check-circle'); + } catch (error) { + setGlobalError(`Failed to update share: ${error.message}`); + } + }; + + const confirmDeleteShare = (share) => { + shareToDelete.value = share; + showShareDeleteModal.value = true; + }; + + const cancelDeleteShare = () => { + shareToDelete.value = null; + showShareDeleteModal.value = false; + }; + + // ========================================= + // Internal Sharing + // ========================================= + + const loadAllUsers = async () => { + if (!showUsernamesInUI.value) return; + + isLoadingAllUsers.value = true; + try { + const response = await fetch('/api/users/search?q='); + if (!response.ok) { + if (response.status === 403) { + throw new Error('Internal sharing is not enabled'); + } + throw new Error('Failed to load users'); + } + const data = await response.json(); + allUsers.value = data; + } catch (error) { + setGlobalError(`Failed to load users: ${error.message}`); + allUsers.value = []; + } finally { + isLoadingAllUsers.value = false; + } + }; + + const searchInternalShareUsers = async () => { + const query = internalShareUserSearch.value.trim(); + + // If SHOW_USERNAMES_IN_UI is enabled, filter allUsers locally + if (showUsernamesInUI.value) { + // Get list of user IDs that already have access + const sharedUserIds = new Set(recordingInternalShares.value.map(share => share.user_id)); + + // Filter out already-shared users + const availableUsers = allUsers.value.filter(user => !sharedUserIds.has(user.id)); + + if (query.length === 0) { + internalShareSearchResults.value = availableUsers; + } else { + internalShareSearchResults.value = availableUsers.filter(user => + user.username.toLowerCase().includes(query.toLowerCase()) || + (user.email && user.email.toLowerCase().includes(query.toLowerCase())) + ); + } + return; + } + + // Otherwise, use server-side search + if (query.length < 2) { + internalShareSearchResults.value = []; + return; + } + + clearTimeout(userSearchTimeout); + userSearchTimeout = setTimeout(async () => { + isSearchingUsers.value = true; + try { + const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`); + if (!response.ok) { + if (response.status === 403) { + throw new Error('Internal sharing is not enabled'); + } + throw new Error('Failed to search users'); + } + const data = await response.json(); + internalShareSearchResults.value = data; + } catch (error) { + setGlobalError(`Failed to search users: ${error.message}`); + internalShareSearchResults.value = []; + } finally { + isSearchingUsers.value = false; + } + }, 300); + }; + + const openUnifiedShareModal = async (recording) => { + internalShareRecording.value = recording; + internalShareUserSearch.value = ''; + internalShareSearchResults.value = []; + internalSharePermissions.value = { can_edit: false, can_reshare: false }; + recordingPublicShares.value = []; + shareOptions.share_summary = true; + shareOptions.share_notes = true; + + // PERMISSION CEILING: Calculate maximum permissions current user can grant + // If viewing a shared recording (not owner), constrain to their permissions + if (recording.is_shared && recording.share_info) { + internalShareMaxPermissions.value = { + can_edit: recording.share_info.can_edit || false, + can_reshare: recording.share_info.can_reshare || false + }; + } else { + // Owner has unlimited permissions + internalShareMaxPermissions.value = { + can_edit: true, + can_reshare: true + }; + } + + showUnifiedShareModal.value = true; + + // Load all public shares for this recording + isLoadingPublicShares.value = true; + try { + const response = await fetch(`/api/shares`); + if (response.ok) { + const allShares = await response.json(); + // Filter to only shares for this recording and add share_url + recordingPublicShares.value = allShares + .filter(share => share.recording_id === recording.id) + .map(share => ({ + ...share, + share_url: `${window.location.origin}/share/${share.public_id}` + })); + } + } catch (error) { + console.error('Error loading public shares:', error); + recordingPublicShares.value = []; + } finally { + isLoadingPublicShares.value = false; + } + + // Load existing internal shares + isLoadingInternalShares.value = true; + try { + const response = await fetch(`/api/recordings/${recording.id}/shares-internal`); + if (!response.ok) { + if (response.status === 403) { + throw new Error('Internal sharing is not enabled'); + } + throw new Error('Failed to load shares'); + } + const data = await response.json(); + recordingInternalShares.value = data.shares || []; + } catch (error) { + setGlobalError(`Failed to load shares: ${error.message}`); + recordingInternalShares.value = []; + } finally { + isLoadingInternalShares.value = false; + } + + // If SHOW_USERNAMES_IN_UI is enabled, load all users + if (showUsernamesInUI.value) { + await loadAllUsers(); + internalShareSearchResults.value = allUsers.value; + } + }; + + const closeUnifiedShareModal = () => { + showUnifiedShareModal.value = false; + internalShareRecording.value = null; + internalShareUserSearch.value = ''; + internalShareSearchResults.value = []; + recordingInternalShares.value = []; + recordingPublicShares.value = []; + allUsers.value = []; + }; + + // Legacy function names for backward compatibility + const openInternalShareModal = openUnifiedShareModal; + const openManageInternalSharesModal = openUnifiedShareModal; + const closeInternalShareModal = closeUnifiedShareModal; + const closeManageInternalSharesModal = closeUnifiedShareModal; + + const reloadInternalShares = async () => { + if (!internalShareRecording.value) return; + + isLoadingInternalShares.value = true; + try { + const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/shares-internal`); + if (!response.ok) { + throw new Error('Failed to load shares'); + } + const data = await response.json(); + recordingInternalShares.value = data.shares || []; + } catch (error) { + setGlobalError(`Failed to reload shares: ${error.message}`); + } finally { + isLoadingInternalShares.value = false; + } + }; + + const shareWithUsername = async () => { + if (!internalShareRecording.value) return; + + const username = internalShareUserSearch.value.trim(); + if (!username) { + setGlobalError('Please enter a username'); + return; + } + + isSearchingUsers.value = true; + try { + // Search for the exact username + const searchResponse = await fetch(`/api/users/search?q=${encodeURIComponent(username)}`); + if (!searchResponse.ok) { + if (searchResponse.status === 403) { + throw new Error('Internal sharing is not enabled'); + } + throw new Error('Failed to find user'); + } + + const users = await searchResponse.json(); + + if (users.length === 0) { + setGlobalError(`User "${username}" not found`); + return; + } + + // Use the first matching user (should be exact match from backend) + const user = users[0]; + await createInternalShare(user.id, user.username); + + // Clear input on success + internalShareUserSearch.value = ''; + } catch (error) { + setGlobalError(error.message || 'Failed to share with user'); + } finally { + isSearchingUsers.value = false; + } + }; + + const createInternalShare = async (userId, username) => { + if (!internalShareRecording.value) return; + + try { + const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/share-internal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: userId, + can_edit: internalSharePermissions.value.can_edit, + can_reshare: internalSharePermissions.value.can_reshare + }) + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to share recording'); + } + + const displayName = showUsernamesInUI.value ? username : `User #${userId}`; + showToast(`Recording shared with ${displayName}`, 'fa-share-alt'); + + // Reset permissions for next share + internalSharePermissions.value = { can_edit: false, can_reshare: false }; + + // Reload shares to show the new share in the list + await reloadInternalShares(); + + // Update the recording's share count in the UI + await refreshRecordingShareCounts(); + } catch (error) { + setGlobalError(`Failed to share recording: ${error.message}`); + } + }; + + const revokeInternalShare = async (shareId, username) => { + if (!internalShareRecording.value) return; + + try { + const response = await fetch(`/api/internal-shares/${shareId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to revoke share'); + } + + recordingInternalShares.value = recordingInternalShares.value.filter(s => s.id !== shareId); + const displayName = showUsernamesInUI.value ? username : 'User'; + showToast(`Access revoked for ${displayName}`, 'fa-user-times'); + + // Update the recording's share count in the UI + await refreshRecordingShareCounts(); + } catch (error) { + setGlobalError(`Failed to revoke share: ${error.message}`); + } + }; + + return { + // Utilities + formatShareDate, + getUserColorClass, + + // Public sharing + openShareModal, + closeShareModal, + createShare, + copyShareLink, + copyPublicShareLink, + copyPublicShareLinkWithFeedback, + copyIndividualShareLink, + confirmDeletePublicShare, + deletePublicShare, + refreshRecordingShareCounts, + + // Shares list + openSharesList, + closeSharesList, + updateShare, + confirmDeleteShare, + cancelDeleteShare, + deleteShare: deletePublicShare, // Alias for template compatibility + copiedShareId, + + // Internal sharing + loadAllUsers, + searchInternalShareUsers, + openUnifiedShareModal, + closeUnifiedShareModal, + openInternalShareModal, + closeInternalShareModal, + openManageInternalSharesModal, + closeManageInternalSharesModal, + reloadInternalShares, + shareWithUsername, + createInternalShare, + revokeInternalShare + }; +} diff --git a/static/js/modules/composables/speakers.js b/static/js/modules/composables/speakers.js new file mode 100644 index 0000000..9ab2f0e --- /dev/null +++ b/static/js/modules/composables/speakers.js @@ -0,0 +1,1251 @@ +/** + * Speaker management composable + * Handles speaker identification, naming, and navigation + */ + +export function useSpeakers(state, utils, processedTranscription) { + const { nextTick } = Vue; + const { + showSpeakerModal, speakerModalTab, showAddSpeakerModal, showEditSpeakersModal, + showEditTextModal, selectedRecording, recordings, + speakerMap, speakerColorMap, modalSpeakers, speakerDisplayMap, speakerSuggestions, loadingSuggestions, + activeSpeakerInput, regenerateSummaryAfterSpeakerUpdate, + editingSpeakersList, databaseSpeakers, editingSpeakerSuggestions, + editSpeakerDropdownPositions, newSpeakerName, newSpeakerIsMe, + newSpeakerSuggestions, loadingNewSpeakerSuggestions, showNewSpeakerSuggestions, + editingSegmentIndex, editingSpeakerIndex, editedText, editedTranscriptData, highlightedSpeaker, + isAutoIdentifying, availableSpeakers, editingSegments, + currentSpeakerGroupIndex, speakerGroups, currentUserName, + voiceSuggestions, loadingVoiceSuggestions + } = state; + + const { showToast, setGlobalError, onChatComplete } = utils; + + // i18n helper — falls back to the provided fallback string if i18n is not loaded + const t = (key, params, fallback) => window.i18n ? window.i18n.t(key, params) : (fallback || key); + const tc = (key, count, params) => window.i18n ? window.i18n.tc(key, count, params) : (params && params.count != null ? `${params.count}` : key); + + // Current speaker highlight state + let currentSpeakerId = null; + + // Number of speaker colors available in CSS (must match styles.css and app.modular.js) + const SPEAKER_COLOR_COUNT = 16; + + // Get speaker color from the shared color map + // If speaker not in map, assign next available color + const getSpeakerColor = (speakerId) => { + if (speakerColorMap.value[speakerId]) { + return speakerColorMap.value[speakerId]; + } + // Assign next color to new speaker + const colorIndex = Object.keys(speakerColorMap.value).length; + const color = `speaker-color-${(colorIndex % SPEAKER_COLOR_COUNT) + 1}`; + speakerColorMap.value[speakerId] = color; + return color; + }; + + // Helper to pause outer audio player when opening modals with their own player + const pauseOuterAudioPlayer = () => { + // Find the audio player in the right panel (not in a modal) + const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') || + document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)'); + if (outerAudio && !outerAudio.paused) { + outerAudio.pause(); + } + }; + + // ========================================= + // Speaker Identification Modal + // ========================================= + + const openSpeakerModal = () => { + if (!selectedRecording.value) return; + + // Pause outer audio player to avoid conflicts with modal's player + pauseOuterAudioPlayer(); + + // Clear any existing speaker map data first + speakerMap.value = {}; + speakerDisplayMap.value = {}; + + // Get the same speaker order used in processedTranscription + const transcription = selectedRecording.value?.transcription; + let speakers = []; + + if (transcription) { + try { + const transcriptionData = JSON.parse(transcription); + if (transcriptionData && Array.isArray(transcriptionData)) { + // Use the exact same logic as processedTranscription to get speakers + speakers = [...new Set(transcriptionData.map(segment => segment.speaker).filter(Boolean))]; + } + } catch (e) { + // Fall back to getIdentifiedSpeakers if JSON parsing fails + speakers = getIdentifiedSpeakers(); + } + } + + // Initialize speaker map FIRST with colors from shared color map + // Clear existing map and rebuild it + speakerMap.value = {}; + speakerDisplayMap.value = {}; + speakers.forEach(speaker => { + speakerMap.value[speaker] = { + name: '', + isMe: false, + color: getSpeakerColor(speaker) + }; + speakerDisplayMap.value[speaker] = speaker; + }); + + // Set modalSpeakers AFTER speakerMap is populated (triggers render) + modalSpeakers.value = speakers; + + highlightedSpeaker.value = null; + speakerSuggestions.value = {}; + loadingSuggestions.value = {}; + activeSpeakerInput.value = null; + isAutoIdentifying.value = false; + regenerateSummaryAfterSpeakerUpdate.value = true; + voiceSuggestions.value = {}; + speakerModalTab.value = 'speakers'; // Reset to speakers tab on mobile + + showSpeakerModal.value = true; + + // Reset virtual scroll state for fresh modal render + if (utils.resetSpeakerModalScroll) { + utils.resetSpeakerModalScroll(); + } + + // Load voice-based suggestions if embeddings are available + loadVoiceSuggestions(); + }; + + const getIdentifiedSpeakers = () => { + // Ensure we have a valid recording and transcription + if (!selectedRecording.value?.transcription) { + return []; + } + + const transcription = selectedRecording.value.transcription; + let transcriptionData; + + try { + transcriptionData = JSON.parse(transcription); + } catch (e) { + transcriptionData = null; + } + + // Handle new simplified JSON format (array of segments) + if (transcriptionData && Array.isArray(transcriptionData)) { + // JSON format - extract speakers in order of appearance + const speakersInOrder = []; + const seenSpeakers = new Set(); + transcriptionData.forEach(segment => { + if (segment.speaker && !seenSpeakers.has(segment.speaker)) { + seenSpeakers.add(segment.speaker); + speakersInOrder.push(segment.speaker); + } + }); + return speakersInOrder; + } else if (typeof transcription === 'string') { + // Plain text format - find speakers in order of appearance + const speakerRegex = /\[([^\]]+)\]:/g; + const speakersInOrder = []; + const seenSpeakers = new Set(); + let match; + while ((match = speakerRegex.exec(transcription)) !== null) { + const speaker = match[1].trim(); + if (speaker && !seenSpeakers.has(speaker)) { + seenSpeakers.add(speaker); + speakersInOrder.push(speaker); + } + } + return speakersInOrder; + } + return []; + }; + + const closeSpeakerModal = () => { + // Pause any playing modal audio before closing + const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video'); + if (modalAudio) { + modalAudio.pause(); + } + // Reset modal audio state (keep main player independent) + if (utils.resetModalAudioState) { + utils.resetModalAudioState(); + } + + showSpeakerModal.value = false; + showAutoIdDropdown.value = false; + highlightedSpeaker.value = null; + // Clear the speaker map to prevent stale data from persisting + speakerMap.value = {}; + speakerSuggestions.value = {}; + loadingSuggestions.value = {}; + clearSpeakerHighlight(); + }; + + const saveTranscriptImmediately = async (transcriptData) => { + if (!selectedRecording.value) return; + + try { + // Save transcript without closing modal + const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => { + if (speakerData.name && speakerData.name.trim() !== '') { + acc[speakerId] = speakerData; + } + return acc; + }, {}); + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcript`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + transcript_data: transcriptData, + speaker_map: filteredSpeakerMap, + regenerate_summary: false // Don't regenerate on immediate saves + }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update transcript'); + + // Update recordings list and selected recording without closing modal + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index] = data.recording; + } + selectedRecording.value = data.recording; + editedTranscriptData.value = null; + + showToast(t('help.saved'), 'fa-check-circle', 2000, 'success'); + } catch (error) { + console.error('Save Transcript Error:', error); + showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 3000, 'error'); + } + }; + + const saveTranscriptEdits = async () => { + if (!selectedRecording.value || !editedTranscriptData.value) { + return saveSpeakerNames(); // Fall back to regular speaker name save + } + + try { + // Save both speaker names and transcript edits + const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => { + if (speakerData.name && speakerData.name.trim() !== '') { + acc[speakerId] = speakerData; + } + return acc; + }, {}); + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcript`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + transcript_data: editedTranscriptData.value, + speaker_map: filteredSpeakerMap, + regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value + }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update transcript'); + + closeSpeakerModal(); + + // If summary regeneration was requested, update status immediately + if (regenerateSummaryAfterSpeakerUpdate.value && data.summary_queued) { + // Update recording status to SUMMARIZING immediately for UI feedback + const summarizingRecording = { ...data.recording, status: 'SUMMARIZING' }; + + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index] = summarizingRecording; + } + selectedRecording.value = summarizingRecording; + editedTranscriptData.value = null; + + showToast(t('help.transcriptUpdated'), 'fa-check-circle'); + showToast(t('help.summaryRegenerationStarted'), 'fa-sync-alt'); + + // Poll for summary completion + pollForSummaryCompletion(selectedRecording.value.id); + } else { + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index] = data.recording; + } + selectedRecording.value = data.recording; + editedTranscriptData.value = null; + + showToast(t('help.transcriptUpdated'), 'fa-check-circle'); + } + } catch (error) { + console.error('Save Transcript Error:', error); + showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 3000, 'error'); + } + }; + + const saveSpeakerNames = async () => { + if (!selectedRecording.value) return; + + // If there are transcript edits, save those instead + if (editedTranscriptData.value) { + return saveTranscriptEdits(); + } + + // Create a filtered speaker map that excludes entries with blank names + const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => { + if (speakerData.name && speakerData.name.trim() !== '') { + acc[speakerId] = speakerData; + } + return acc; + }, {}); + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${selectedRecording.value.id}/update_speakers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + speaker_map: filteredSpeakerMap, + regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value + }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update speaker names'); + + closeSpeakerModal(); + + // If summary regeneration was requested, update status immediately + if (regenerateSummaryAfterSpeakerUpdate.value && data.summary_queued) { + // Update recording status to SUMMARIZING immediately for UI feedback + const summarizingRecording = { ...data.recording, status: 'SUMMARIZING' }; + + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index] = summarizingRecording; + } + selectedRecording.value = summarizingRecording; + + showToast(t('help.speakerNamesUpdated'), 'fa-check-circle'); + showToast(t('help.summaryRegenerationStarted'), 'fa-sync-alt'); + + // Poll for summary completion + pollForSummaryCompletion(selectedRecording.value.id); + } else { + // The backend returns the fully updated recording object + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index] = data.recording; + } + selectedRecording.value = data.recording; + + showToast(t('help.speakerNamesUpdated'), 'fa-check-circle'); + } + } catch (error) { + setGlobalError(`Failed to save speaker names: ${error.message}`); + } + }; + + // Poll for summary completion after regeneration + const pollForSummaryCompletion = async (recordingId) => { + const maxAttempts = 40; // Poll for up to 2 minutes (40 * 3 seconds) + let attempts = 0; + + const pollInterval = setInterval(async () => { + attempts++; + + try { + // Use lightweight status-only endpoint for polling + const response = await fetch(`/recording/${recordingId}/status`); + if (!response.ok) { + clearInterval(pollInterval); + return; + } + + const statusData = await response.json(); + + // Update status in recordings list + const index = recordings.value.findIndex(r => r.id === recordingId); + if (index !== -1) { + // Create new object to ensure Vue reactivity + recordings.value[index] = { + ...recordings.value[index], + status: statusData.status + }; + } + + // Update selectedRecording with new object reference for reactivity + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + selectedRecording.value = { + ...selectedRecording.value, + status: statusData.status + }; + } + + // Check if summarization is complete + if (statusData.status === 'COMPLETED') { + clearInterval(pollInterval); + + // Now fetch the full recording with the new summary + const fullResponse = await fetch(`/api/recordings/${recordingId}`); + if (fullResponse.ok) { + const fullData = await fullResponse.json(); + + // Update in recordings list first + const currentIndex = recordings.value.findIndex(r => r.id === recordingId); + if (currentIndex !== -1) { + recordings.value[currentIndex] = fullData; + } + + // Always update selectedRecording if it's the current recording + if (selectedRecording.value && selectedRecording.value.id === recordingId) { + selectedRecording.value = fullData; + // Force Vue to detect the change + await nextTick(); + } + } + + showToast(t('help.summaryUpdated'), 'fa-check-circle'); + // Refresh token budget after LLM operation + if (onChatComplete) onChatComplete(); + } else if (statusData.status === 'FAILED' || statusData.status === 'ERROR') { + // Stop polling if it failed + clearInterval(pollInterval); + showToast(t('help.summaryGenerationFailed'), 'fa-exclamation-circle', 3000, 'error'); + } else if (attempts >= maxAttempts) { + // Stop polling after max attempts + clearInterval(pollInterval); + showToast(t('help.summaryGenerationTimedOut'), 'fa-clock', 3000, 'warning'); + } + } catch (error) { + console.error('Error polling for summary:', error); + clearInterval(pollInterval); + } + }, 3000); // Poll every 3 seconds + }; + + // ========================================= + // Speaker Suggestions + // ========================================= + + const loadVoiceSuggestions = async () => { + if (!selectedRecording.value?.id) return; + + loadingVoiceSuggestions.value = true; + voiceSuggestions.value = {}; + + try { + const response = await fetch(`/speakers/suggestions/${selectedRecording.value.id}`); + if (!response.ok) throw new Error('Failed to load voice suggestions'); + + const data = await response.json(); + + if (data.success && data.suggestions) { + // Only keep suggestions that have matches + voiceSuggestions.value = Object.fromEntries( + Object.entries(data.suggestions).filter(([_, matches]) => matches && matches.length > 0) + ); + } + } catch (error) { + console.error('Error loading voice suggestions:', error); + voiceSuggestions.value = {}; + } finally { + loadingVoiceSuggestions.value = false; + } + }; + + const applyVoiceSuggestion = (speakerId, suggestion) => { + if (speakerMap.value[speakerId]) { + speakerMap.value[speakerId].name = suggestion.name; + // Don't delete the suggestion - let it reappear if user clears the field + } + }; + + // Handle "This is Me" checkbox changes + const handleIsMeChange = (speakerId) => { + if (!speakerMap.value[speakerId]) return; + + if (speakerMap.value[speakerId].isMe) { + // Checkbox is now checked - set the name to current user's name + speakerMap.value[speakerId].name = currentUserName.value || 'Me'; + } else { + // Checkbox is now unchecked - clear the name + speakerMap.value[speakerId].name = ''; + } + }; + + // Determine if voice suggestion pill should be shown inside the input field + const shouldShowVoiceSuggestionPill = (speakerId) => { + // Don't show if no suggestions available + if (!voiceSuggestions.value[speakerId] || voiceSuggestions.value[speakerId].length === 0) { + return false; + } + + // Don't show if "This is Me" is checked + if (speakerMap.value[speakerId]?.isMe) { + return false; + } + + // Only show when the input field is empty + const typedName = speakerMap.value[speakerId]?.name?.trim(); + if (typedName && typedName.length > 0) { + return false; + } + + return true; + }; + + const searchSpeakers = async (query, speakerId) => { + if (!query || query.length < 2) { + speakerSuggestions.value[speakerId] = []; + return; + } + + loadingSuggestions.value[speakerId] = true; + + try { + const response = await fetch(`/speakers/search?q=${encodeURIComponent(query)}`); + if (!response.ok) throw new Error('Failed to search speakers'); + + const speakers = await response.json(); + speakerSuggestions.value[speakerId] = speakers; + } catch (error) { + console.error('Error searching speakers:', error); + speakerSuggestions.value[speakerId] = []; + } finally { + loadingSuggestions.value[speakerId] = false; + } + }; + + const selectSpeakerSuggestion = (speakerId, suggestion) => { + if (speakerMap.value[speakerId]) { + speakerMap.value[speakerId].name = suggestion.name; + speakerSuggestions.value[speakerId] = []; + activeSpeakerInput.value = null; + } + }; + + const closeSpeakerSuggestionsOnClick = (event) => { + // Check if the click was on an input field or dropdown + const clickedInput = event.target.closest('input[type="text"]'); + const clickedDropdown = event.target.closest('.absolute.z-10'); + + // If not clicking on input or dropdown, close all suggestions + if (!clickedInput && !clickedDropdown) { + Object.keys(speakerSuggestions.value).forEach(speakerId => { + speakerSuggestions.value[speakerId] = []; + }); + } + }; + + // ========================================= + // Speaker Navigation (Index-Based for Virtual Scroll) + // ========================================= + + /** + * Find speaker groups by analyzing segment data (not DOM). + * Returns groups with startIndex instead of startElement for virtual scroll compatibility. + */ + const findSpeakerGroups = (speakerId) => { + if (!speakerId) return []; + + // Get segments from processedTranscription + const segments = processedTranscription.value?.simpleSegments || []; + if (segments.length === 0) return []; + + const groups = []; + let currentGroup = null; + let lastSpeakerId = null; + + segments.forEach((segment, index) => { + const segmentSpeakerId = segment.speakerId; + + if (segmentSpeakerId === speakerId) { + // If this is a new group (not consecutive with previous) + if (lastSpeakerId !== speakerId) { + currentGroup = { + startIndex: index, + indices: [index] + }; + groups.push(currentGroup); + } else if (currentGroup) { + // Add to existing group + currentGroup.indices.push(index); + } + } + lastSpeakerId = segmentSpeakerId; + }); + + return groups; + }; + + const highlightSpeakerInTranscript = (speakerId) => { + highlightedSpeaker.value = speakerId; + + if (speakerId) { + // Find all speaker groups for navigation (index-based, no DOM queries) + speakerGroups.value = findSpeakerGroups(speakerId); + + if (speakerGroups.value.length > 0) { + // Get the current visible range from the virtual scroll + const visibleRange = utils.getSpeakerModalVisibleRange ? utils.getSpeakerModalVisibleRange() : null; + + if (visibleRange) { + const { start: visibleStart, end: visibleEnd } = visibleRange; + const visibleCenter = Math.floor((visibleStart + visibleEnd) / 2); + + // Check if any group is already visible + const visibleGroupIndex = speakerGroups.value.findIndex(group => + group.startIndex >= visibleStart && group.startIndex < visibleEnd + ); + + if (visibleGroupIndex !== -1) { + // A group is already visible, just set it as current (no scroll needed) + currentSpeakerGroupIndex.value = visibleGroupIndex; + } else { + // No group visible - find the nearest group to the visible center + let nearestIndex = 0; + let nearestDistance = Infinity; + + speakerGroups.value.forEach((group, index) => { + const distance = Math.abs(group.startIndex - visibleCenter); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + }); + + currentSpeakerGroupIndex.value = nearestIndex; + + // Scroll to the nearest group + const nearestGroup = speakerGroups.value[nearestIndex]; + if (nearestGroup && typeof nearestGroup.startIndex === 'number' && utils.scrollToSegmentIndex) { + utils.scrollToSegmentIndex(nearestGroup.startIndex); + } + } + } else { + // Fallback: no visible range available, scroll to first group + currentSpeakerGroupIndex.value = 0; + const firstGroup = speakerGroups.value[0]; + if (firstGroup && typeof firstGroup.startIndex === 'number' && utils.scrollToSegmentIndex) { + utils.scrollToSegmentIndex(firstGroup.startIndex); + } + } + } else { + currentSpeakerGroupIndex.value = -1; + } + } else { + speakerGroups.value = []; + currentSpeakerGroupIndex.value = -1; + } + }; + + /** + * Select a speaker for navigation from the dropdown. + * Uses index-based navigation compatible with virtual scrolling. + */ + const selectSpeakerForNavigation = (speakerId) => { + if (!speakerId) { + highlightedSpeaker.value = null; + speakerGroups.value = []; + currentSpeakerGroupIndex.value = -1; + return; + } + + highlightedSpeaker.value = speakerId; + + // Find groups immediately (no DOM dependency) + speakerGroups.value = findSpeakerGroups(speakerId); + currentSpeakerGroupIndex.value = 0; + + // Scroll to first occurrence + if (speakerGroups.value.length > 0) { + const firstGroup = speakerGroups.value[0]; + if (firstGroup && typeof firstGroup.startIndex === 'number') { + if (utils.scrollToSegmentIndex) { + utils.scrollToSegmentIndex(firstGroup.startIndex); + } + } + } + }; + + const navigateToNextSpeakerGroup = () => { + if (speakerGroups.value.length === 0) return; + + // Update the index + currentSpeakerGroupIndex.value = (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length; + const group = speakerGroups.value[currentSpeakerGroupIndex.value]; + if (group && typeof group.startIndex === 'number') { + if (utils.scrollToSegmentIndex) { + utils.scrollToSegmentIndex(group.startIndex); + } + } + }; + + const navigateToPrevSpeakerGroup = () => { + if (speakerGroups.value.length === 0) return; + + // Update the index + currentSpeakerGroupIndex.value = currentSpeakerGroupIndex.value <= 0 + ? speakerGroups.value.length - 1 + : currentSpeakerGroupIndex.value - 1; + const group = speakerGroups.value[currentSpeakerGroupIndex.value]; + if (group && typeof group.startIndex === 'number') { + if (utils.scrollToSegmentIndex) { + utils.scrollToSegmentIndex(group.startIndex); + } + } + }; + + const focusSpeaker = (speakerId) => { + // Set this as the active speaker input + activeSpeakerInput.value = speakerId; + // Only highlight if not already highlighted (to preserve navigation state) + if (highlightedSpeaker.value !== speakerId) { + highlightSpeakerInTranscript(speakerId); + } + }; + + const blurSpeaker = () => { + // Clear the active speaker input after a delay to allow clicking on suggestions + setTimeout(() => { + activeSpeakerInput.value = null; + speakerSuggestions.value = {}; + }, 200); + clearSpeakerHighlight(); + }; + + const clearSpeakerHighlight = () => { + highlightedSpeaker.value = null; + }; + + // ========================================= + // Auto-Identify Speakers + // ========================================= + + // Split button dropdown visibility + click-outside handling + const showAutoIdDropdown = Vue.ref(false); + const autoIdSplitBtn = Vue.ref(null); + + const onAutoIdClickOutside = (e) => { + if (autoIdSplitBtn.value && !autoIdSplitBtn.value.contains(e.target)) { + showAutoIdDropdown.value = false; + } + }; + Vue.watch(showAutoIdDropdown, (open) => { + if (open) { + document.addEventListener('click', onAutoIdClickOutside, true); + } else { + document.removeEventListener('click', onAutoIdClickOutside, true); + } + }); + + /** + * Auto-identify speakers via LLM. + * @param {boolean} identifyAll - When false (default), only fill speakers with empty names. + * When true, overwrite all speaker names. + */ + const autoIdentifySpeakers = async (identifyAll = false) => { + showAutoIdDropdown.value = false; + + if (!selectedRecording.value) { + showToast(t('help.noRecordingSelected'), 'fa-exclamation-circle'); + return; + } + + isAutoIdentifying.value = true; + showToast(t('help.startingAutoIdentification'), 'fa-magic'); + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${selectedRecording.value.id}/auto_identify_speakers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + current_speaker_map: speakerMap.value + }) + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Unknown error occurred during auto-identification.'); + } + + // Check if there's a message (e.g., all speakers already identified) + if (data.message) { + showToast(data.message, 'fa-info-circle'); + return; + } + + // Update speakerMap with the identified names + let identifiedCount = 0; + for (const speakerId in data.speaker_map) { + const identifiedName = data.speaker_map[speakerId]; + if (speakerMap.value[speakerId] && identifiedName && identifiedName.trim() !== '') { + // Skip speakers that already have a name unless identifyAll is true + if (!identifyAll && speakerMap.value[speakerId].name && speakerMap.value[speakerId].name.trim() !== '') { + continue; + } + speakerMap.value[speakerId].name = identifiedName; + identifiedCount++; + } + } + + if (identifiedCount > 0) { + showToast(tc('help.speakersIdentified', identifiedCount, { count: identifiedCount }), 'fa-check-circle'); + } else { + showToast(t('help.noSpeakersIdentified'), 'fa-info-circle'); + } + + // Refresh token budget after LLM operation + if (onChatComplete) onChatComplete(); + + } catch (error) { + console.error('Auto Identify Speakers Error:', error); + showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 5000, 'error'); + } finally { + isAutoIdentifying.value = false; + } + }; + + // ========================================= + // Apply Suggested Names + // ========================================= + + /** True when any unnamed, non-isMe speaker has voice or autocomplete suggestions */ + const hasAnySuggestions = Vue.computed(() => { + for (const speakerId of modalSpeakers.value) { + const data = speakerMap.value[speakerId]; + if (!data || data.isMe) continue; + if (data.name && data.name.trim() !== '') continue; + // Check voice suggestions + if (voiceSuggestions.value[speakerId] && voiceSuggestions.value[speakerId].length > 0) { + return true; + } + // Check autocomplete suggestions + if (speakerSuggestions.value[speakerId] && speakerSuggestions.value[speakerId].length > 0) { + return true; + } + } + return false; + }); + + /** Bulk-apply voice suggestions (priority) then autocomplete suggestions to empty names only */ + const applySuggestedNames = () => { + let appliedCount = 0; + for (const speakerId of modalSpeakers.value) { + const data = speakerMap.value[speakerId]; + if (!data || data.isMe) continue; + if (data.name && data.name.trim() !== '') continue; + + // Priority 1: voice suggestions + const voice = voiceSuggestions.value[speakerId]; + if (voice && voice.length > 0) { + data.name = voice[0].name; + appliedCount++; + continue; + } + + // Priority 2: autocomplete suggestions + const auto = speakerSuggestions.value[speakerId]; + if (auto && auto.length > 0) { + data.name = auto[0].name; + appliedCount++; + } + } + + if (appliedCount > 0) { + showToast(tc('help.appliedSuggestedNames', appliedCount, { count: appliedCount }), 'fa-check-circle'); + } else { + showToast(t('help.noSuggestionsToApply'), 'fa-info-circle'); + } + }; + + // ========================================= + // Add Speaker Modal + // ========================================= + + const searchNewSpeaker = async () => { + const query = newSpeakerName.value; + if (!query || query.length < 2) { + newSpeakerSuggestions.value = []; + return; + } + + loadingNewSpeakerSuggestions.value = true; + try { + const response = await fetch(`/speakers/search?q=${encodeURIComponent(query)}`); + if (!response.ok) throw new Error('Failed to search speakers'); + + const speakers = await response.json(); + newSpeakerSuggestions.value = speakers; + } catch (error) { + console.error('Error searching speakers:', error); + newSpeakerSuggestions.value = []; + } finally { + loadingNewSpeakerSuggestions.value = false; + } + }; + + const selectNewSpeakerSuggestion = (suggestion) => { + newSpeakerName.value = suggestion.name; + newSpeakerSuggestions.value = []; + showNewSpeakerSuggestions.value = false; + }; + + const hideNewSpeakerSuggestionsDelayed = () => { + setTimeout(() => { + showNewSpeakerSuggestions.value = false; + newSpeakerSuggestions.value = []; + }, 200); + }; + + const openAddSpeakerModal = () => { + newSpeakerName.value = ''; + newSpeakerIsMe.value = false; + newSpeakerSuggestions.value = []; + loadingNewSpeakerSuggestions.value = false; + showNewSpeakerSuggestions.value = false; + showAddSpeakerModal.value = true; + }; + + const closeAddSpeakerModal = () => { + showAddSpeakerModal.value = false; + newSpeakerName.value = ''; + newSpeakerIsMe.value = false; + newSpeakerSuggestions.value = []; + loadingNewSpeakerSuggestions.value = false; + showNewSpeakerSuggestions.value = false; + }; + + const addNewSpeaker = () => { + const name = newSpeakerIsMe.value ? (currentUserName.value || 'Me') : newSpeakerName.value.trim(); + + if (!newSpeakerIsMe.value && !name) { + showToast(t('help.pleaseEnterSpeakerName'), 'fa-exclamation-circle'); + return; + } + + // Generate new speaker ID + const existingSpeakerNumbers = modalSpeakers.value + .map(s => { + const match = s.match(/^SPEAKER_(\d+)$/); + return match ? parseInt(match[1]) : -1; + }) + .filter(n => n >= 0); + + const nextNumber = existingSpeakerNumbers.length > 0 + ? Math.max(...existingSpeakerNumbers) + 1 + : modalSpeakers.value.length; + + const newSpeakerId = `SPEAKER_${String(nextNumber).padStart(2, '0')}`; + + // Add to speakerMap FIRST (before modalSpeakers) to avoid render race condition + speakerMap.value[newSpeakerId] = { + name: name, + isMe: newSpeakerIsMe.value, + color: getSpeakerColor(newSpeakerId) + }; + + // Add to speakerDisplayMap + speakerDisplayMap.value[newSpeakerId] = newSpeakerId; + + // Add to modalSpeakers LAST (triggers re-render, but speakerMap is already populated) + modalSpeakers.value.push(newSpeakerId); + + closeAddSpeakerModal(); + showToast(t('help.speakerAdded'), 'fa-check-circle'); + }; + + // ========================================= + // Edit Speakers Modal + // ========================================= + + const openEditSpeakersModal = async () => { + // Close any open suggestions + editingSegments.value.forEach(seg => seg.showSuggestions = false); + // Copy current speakers to editing list with original and current properties + editingSpeakersList.value = availableSpeakers.value.map(s => ({ + original: s, + current: s + })); + // Fetch speakers from database for autocomplete + try { + const response = await fetch('/speakers'); + const speakers = await response.json(); + // Keep full objects with id and name for autocomplete dropdown + databaseSpeakers.value = speakers; + } catch (e) { + console.error('Failed to fetch speakers:', e); + databaseSpeakers.value = []; + } + editingSpeakerSuggestions.value = {}; + showEditSpeakersModal.value = true; + }; + + const closeEditSpeakersModal = () => { + showEditSpeakersModal.value = false; + editingSpeakersList.value = []; + }; + + const addEditingSpeaker = () => { + editingSpeakersList.value.push({ original: '', current: '' }); + }; + + const removeEditingSpeaker = (index) => { + editingSpeakersList.value.splice(index, 1); + }; + + const filterEditingSpeakerSuggestions = (index) => { + const query = editingSpeakersList.value[index]?.current?.toLowerCase().trim() || ''; + if (query === '') { + // Show all speakers when field is empty/focused + editingSpeakerSuggestions.value[index] = [...databaseSpeakers.value]; + } else { + editingSpeakerSuggestions.value[index] = databaseSpeakers.value.filter( + s => s.name.toLowerCase().includes(query) + ); + } + }; + + const selectEditingSpeakerSuggestion = (index, name) => { + editingSpeakersList.value[index].current = name; + editingSpeakerSuggestions.value[index] = []; + }; + + const closeEditingSpeakerSuggestions = (index) => { + editingSpeakerSuggestions.value[index] = []; + }; + + const onEditSpeakerBlur = (index) => { + // Delay closing to allow clicking on suggestions + setTimeout(() => { + closeEditingSpeakerSuggestions(index); + }, 200); + }; + + const getEditSpeakerDropdownPosition = (index) => { + // Find the input element for this index and calculate position + const inputs = document.querySelectorAll('[class*="edit-speakers-modal"] input[placeholder="New name..."], .max-w-md input[placeholder="New name..."]'); + if (inputs[index]) { + const rect = inputs[index].getBoundingClientRect(); + return { + top: rect.bottom + 2 + 'px', + left: rect.left + 'px', + width: rect.width + 'px' + }; + } + return { top: '0px', left: '0px', width: '200px' }; + }; + + const saveEditingSpeakers = async () => { + const map = {}; + editingSpeakersList.value.forEach(item => { + if (item.original && item.current) { + map[item.original] = item.current; + } + }); + + // Update ASR editor state if it's open + if (editingSegments.value.length > 0) { + // Build new list of available speakers + const newSpeakers = new Set(); + + // Apply renames to all segments + editingSegments.value.forEach(segment => { + if (map[segment.speaker]) { + segment.speaker = map[segment.speaker]; + } + newSpeakers.add(segment.speaker); + }); + + // Add any newly added speakers from the modal + editingSpeakersList.value.forEach(item => { + if (!item.original && item.current) { + // This is a new speaker (no original) + newSpeakers.add(item.current); + } + }); + + // Update available speakers list + availableSpeakers.value = [...newSpeakers].sort(); + + // Update filtered speakers for all segments + editingSegments.value.forEach(segment => { + segment.filteredSpeakers = [...availableSpeakers.value]; + }); + + closeEditSpeakersModal(); + showToast(t('help.speakersUpdatedSaveToApply'), 'fa-check-circle'); + } else { + // Regular flow for non-ASR editor context + speakerMap.value = map; + closeEditSpeakersModal(); + await saveSpeakerNames(); + } + }; + + // ========================================= + // Edit Text Modal + // ========================================= + + const openEditTextModal = (segmentIndex) => { + if (!selectedRecording.value?.transcription) return; + + try { + const transcriptionData = JSON.parse(selectedRecording.value.transcription); + if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[segmentIndex]) { + editingSegmentIndex.value = segmentIndex; + editedText.value = transcriptionData[segmentIndex].sentence || ''; + showEditTextModal.value = true; + } + } catch (e) { + console.error('Error opening text editor:', e); + showToast(t('help.errorOpeningTextEditor'), 'fa-exclamation-circle', 3000, 'error'); + } + }; + + const closeEditTextModal = () => { + showEditTextModal.value = false; + editingSegmentIndex.value = null; + editedText.value = ''; + }; + + const saveEditedText = async () => { + if (editingSegmentIndex.value === null || !selectedRecording.value?.transcription) return; + + try { + const transcriptionData = JSON.parse(selectedRecording.value.transcription); + if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[editingSegmentIndex.value]) { + transcriptionData[editingSegmentIndex.value].sentence = editedText.value; + editedTranscriptData.value = transcriptionData; + + // Update the recording's transcription temporarily for UI update + selectedRecording.value.transcription = JSON.stringify(transcriptionData); + + closeEditTextModal(); + + // Immediately persist the change + showToast(t('help.savingProgress'), 'fa-spinner fa-spin'); + await saveTranscriptImmediately(transcriptionData); + } + } catch (e) { + console.error('Error saving text:', e); + showToast(t('help.errorSavingText'), 'fa-exclamation-circle', 3000, 'error'); + } + }; + + // ========================================= + // Change Speaker in Segment + // ========================================= + + const openSpeakerChangeDropdown = (segmentIndex) => { + editingSpeakerIndex.value = editingSpeakerIndex.value === segmentIndex ? null : segmentIndex; + }; + + const changeSpeaker = async (segmentIndex, newSpeakerId) => { + if (!selectedRecording.value?.transcription) return; + + try { + const transcriptionData = JSON.parse(selectedRecording.value.transcription); + if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[segmentIndex]) { + transcriptionData[segmentIndex].speaker = newSpeakerId; + editedTranscriptData.value = transcriptionData; + + // Update the recording's transcription temporarily for UI update + selectedRecording.value.transcription = JSON.stringify(transcriptionData); + + editingSpeakerIndex.value = null; + + // Immediately persist the change + showToast(t('help.savingProgress'), 'fa-spinner fa-spin'); + await saveTranscriptImmediately(transcriptionData); + } + } catch (e) { + console.error('Error changing speaker:', e); + showToast(t('help.errorChangingSpeaker'), 'fa-exclamation-circle', 3000, 'error'); + } + }; + + return { + // Speaker modal + openSpeakerModal, + closeSpeakerModal, + saveSpeakerNames, + + // Suggestions + loadVoiceSuggestions, + applyVoiceSuggestion, + handleIsMeChange, + shouldShowVoiceSuggestionPill, + searchSpeakers, + selectSpeakerSuggestion, + closeSpeakerSuggestionsOnClick, + + // Navigation + findSpeakerGroups, + highlightSpeakerInTranscript, + selectSpeakerForNavigation, + navigateToNextSpeakerGroup, + navigateToPrevSpeakerGroup, + focusSpeaker, + blurSpeaker, + clearSpeakerHighlight, + + // Auto-identify + autoIdentifySpeakers, + showAutoIdDropdown, + autoIdSplitBtn, + hasAnySuggestions, + applySuggestedNames, + + // Add speaker + openAddSpeakerModal, + closeAddSpeakerModal, + addNewSpeaker, + searchNewSpeaker, + selectNewSpeakerSuggestion, + hideNewSpeakerSuggestionsDelayed, + + // Edit speakers modal + openEditSpeakersModal, + closeEditSpeakersModal, + addEditingSpeaker, + removeEditingSpeaker, + filterEditingSpeakerSuggestions, + selectEditingSpeakerSuggestion, + closeEditingSpeakerSuggestions, + onEditSpeakerBlur, + getEditSpeakerDropdownPosition, + saveEditingSpeakers, + + // Edit text + openEditTextModal, + closeEditTextModal, + saveEditedText, + + // Change speaker + openSpeakerChangeDropdown, + changeSpeaker + }; +} diff --git a/static/js/modules/composables/tags.js b/static/js/modules/composables/tags.js new file mode 100644 index 0000000..6e4a7b1 --- /dev/null +++ b/static/js/modules/composables/tags.js @@ -0,0 +1,297 @@ +/** + * Tags Management Composable + * Handles tag operations for recordings + */ + +const { computed, ref } = Vue; + +export function useTags({ + recordings, + availableTags, + selectedRecording, + showEditTagsModal, + editingRecording, + tagSearchFilter, + showToast, + setGlobalError +}) { + // State (using passed refs from parent) + + // --- Tag Drag-and-Drop State for Edit Modal --- + const modalDraggedTagIndex = ref(null); + const modalDragOverTagIndex = ref(null); + + // Computed + const getRecordingTags = (recording) => { + if (!recording || !recording.tags) return []; + return recording.tags; + }; + + const getAvailableTagsForRecording = (recording) => { + if (!recording || !availableTags.value) return []; + const recordingTagIds = getRecordingTags(recording).map(tag => tag.id); + return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id)); + }; + + const filteredAvailableTagsForModal = computed(() => { + if (!editingRecording.value) return []; + const availableTagsForRec = getAvailableTagsForRecording(editingRecording.value); + if (!tagSearchFilter.value) return availableTagsForRec; + + const filter = tagSearchFilter.value.toLowerCase(); + return availableTagsForRec.filter(tag => + tag.name.toLowerCase().includes(filter) + ); + }); + + // Methods + const editRecordingTags = (recording) => { + editingRecording.value = recording; + tagSearchFilter.value = ''; + showEditTagsModal.value = true; + }; + + const closeEditTagsModal = () => { + showEditTagsModal.value = false; + editingRecording.value = null; + tagSearchFilter.value = ''; + }; + + const addTagToRecording = async (tagId) => { + if (!tagId || !editingRecording.value) return; + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ tag_id: tagId }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to add tag'); + } + + // Update local recording data + const tagToAdd = availableTags.value.find(tag => tag.id == tagId); + if (tagToAdd) { + // Check if tag already exists to prevent duplicates + const tagExists = editingRecording.value.tags?.some(t => t.id === tagToAdd.id); + if (!tagExists) { + if (!editingRecording.value.tags) { + editingRecording.value.tags = []; + } + editingRecording.value.tags.push(tagToAdd); + } + + // Also update in recordings list (only if different object) + const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id); + if (recordingInList && recordingInList !== editingRecording.value) { + const tagExistsInList = recordingInList.tags?.some(t => t.id === tagToAdd.id); + if (!tagExistsInList) { + if (!recordingInList.tags) { + recordingInList.tags = []; + } + recordingInList.tags.push(tagToAdd); + } + } + + // Update selectedRecording if it matches (only if different object) + if (selectedRecording.value && + selectedRecording.value.id === editingRecording.value.id && + selectedRecording.value !== editingRecording.value) { + const tagExistsInSelected = selectedRecording.value.tags?.some(t => t.id === tagToAdd.id); + if (!tagExistsInSelected) { + if (!selectedRecording.value.tags) { + selectedRecording.value.tags = []; + } + selectedRecording.value.tags.push(tagToAdd); + } + } + } + + showToast('Tag added successfully', 'fa-check-circle', 2000, 'success'); + + } catch (error) { + console.error('Error adding tag to recording:', error); + setGlobalError(`Failed to add tag: ${error.message}`); + } + }; + + const removeTagFromRecording = async (tagId) => { + if (!editingRecording.value) return; + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/${tagId}`, { + method: 'DELETE', + headers: { + 'X-CSRFToken': csrfToken + } + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to remove tag'); + } + + // Update local recording data + editingRecording.value.tags = editingRecording.value.tags.filter(tag => tag.id !== tagId); + + // Also update in recordings list + const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id); + if (recordingInList && recordingInList !== editingRecording.value && recordingInList.tags) { + recordingInList.tags = recordingInList.tags.filter(tag => tag.id !== tagId); + } + + // Update selectedRecording if it matches + if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id && selectedRecording.value.tags) { + selectedRecording.value.tags = selectedRecording.value.tags.filter(tag => tag.id !== tagId); + } + + showToast('Tag removed successfully', 'fa-check-circle', 2000, 'success'); + + } catch (error) { + console.error('Error removing tag from recording:', error); + setGlobalError(`Failed to remove tag: ${error.message}`); + } + }; + + // --- Modal Tag Reordering --- + + const reorderModalTags = async (fromIndex, toIndex) => { + if (!editingRecording.value || !editingRecording.value.tags) return; + + // Reorder locally first for immediate visual feedback + const tags = [...editingRecording.value.tags]; + const [removed] = tags.splice(fromIndex, 1); + tags.splice(toIndex, 0, removed); + editingRecording.value.tags = tags; + + // Update in recordings list + const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id); + if (recordingInList && recordingInList !== editingRecording.value) { + recordingInList.tags = [...tags]; + } + + // Update selectedRecording if it matches + if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) { + selectedRecording.value.tags = [...tags]; + } + + // Persist to backend + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const tagIds = tags.map(t => t.id); + + const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/reorder`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ tag_ids: tagIds }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to reorder tags'); + } + + showToast('Tags reordered', 'fa-arrows-alt', 1500, 'success'); + + } catch (error) { + console.error('Error reordering tags:', error); + setGlobalError(`Failed to save tag order: ${error.message}`); + } + }; + + // === MOUSE DRAG HANDLERS (Modal) === + const handleModalTagDragStart = (index, event) => { + modalDraggedTagIndex.value = index; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', index.toString()); + }; + + const handleModalTagDragOver = (index, event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + modalDragOverTagIndex.value = index; + }; + + const handleModalTagDrop = (targetIndex, event) => { + event.preventDefault(); + if (modalDraggedTagIndex.value !== null && modalDraggedTagIndex.value !== targetIndex) { + reorderModalTags(modalDraggedTagIndex.value, targetIndex); + } + modalDraggedTagIndex.value = null; + modalDragOverTagIndex.value = null; + }; + + const handleModalTagDragEnd = () => { + modalDraggedTagIndex.value = null; + modalDragOverTagIndex.value = null; + }; + + // === TOUCH HANDLERS (Modal - Mobile) === + let modalTouchStartIndex = null; + + const handleModalTagTouchStart = (index, event) => { + modalTouchStartIndex = index; + modalDraggedTagIndex.value = index; + }; + + const handleModalTagTouchMove = (event) => { + if (modalTouchStartIndex === null) return; + event.preventDefault(); + + const touch = event.touches[0]; + const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); + const tagElement = elementBelow?.closest('[data-modal-tag-index]'); + + if (tagElement) { + const targetIndex = parseInt(tagElement.dataset.modalTagIndex); + modalDragOverTagIndex.value = targetIndex; + } + }; + + const handleModalTagTouchEnd = () => { + if (modalTouchStartIndex !== null && modalDragOverTagIndex.value !== null && + modalTouchStartIndex !== modalDragOverTagIndex.value) { + reorderModalTags(modalTouchStartIndex, modalDragOverTagIndex.value); + } + modalTouchStartIndex = null; + modalDraggedTagIndex.value = null; + modalDragOverTagIndex.value = null; + }; + + return { + // Computed + filteredAvailableTagsForModal, + + // Methods + getRecordingTags, + getAvailableTagsForRecording, + editRecordingTags, + closeEditTagsModal, + addTagToRecording, + removeTagFromRecording, + + // Modal Tag Drag-and-Drop + modalDraggedTagIndex, + modalDragOverTagIndex, + handleModalTagDragStart, + handleModalTagDragOver, + handleModalTagDrop, + handleModalTagDragEnd, + handleModalTagTouchStart, + handleModalTagTouchMove, + handleModalTagTouchEnd + }; +} diff --git a/static/js/modules/composables/tokens.js b/static/js/modules/composables/tokens.js new file mode 100644 index 0000000..c1d4323 --- /dev/null +++ b/static/js/modules/composables/tokens.js @@ -0,0 +1,286 @@ +/** + * API Tokens Management Composable + * Handles API token operations for user authentication + */ + +const { ref, computed } = Vue; + +export function useTokens({ showToast, setGlobalError }) { + // State + const tokens = ref([]); + const isLoadingTokens = ref(false); + const showCreateTokenModal = ref(false); + const showTokenSecretModal = ref(false); + const newTokenSecret = ref(''); + const newTokenData = ref(null); + const tokenForm = ref({ + name: '', + expires_in_days: 0 // 0 = no expiration + }); + + // Computed + const hasTokens = computed(() => tokens.value.length > 0); + + const activeTokens = computed(() => { + return tokens.value.filter(token => !token.revoked && !isTokenExpired(token)); + }); + + const expiredOrRevokedTokens = computed(() => { + return tokens.value.filter(token => token.revoked || isTokenExpired(token)); + }); + + // Helper methods + const isTokenExpired = (token) => { + if (!token.expires_at) return false; + const expiryDate = new Date(token.expires_at); + return expiryDate < new Date(); + }; + + const formatTokenDate = (dateString) => { + if (!dateString) return 'Never'; + const date = new Date(dateString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + }; + + const getTokenStatus = (token) => { + if (token.revoked) return 'revoked'; + if (isTokenExpired(token)) return 'expired'; + return 'active'; + }; + + const getTokenStatusClass = (token) => { + const status = getTokenStatus(token); + const baseClasses = 'px-2 py-1 text-xs font-semibold rounded'; + + switch (status) { + case 'active': + return `${baseClasses} bg-green-100 text-green-800`; + case 'expired': + return `${baseClasses} bg-yellow-100 text-yellow-800`; + case 'revoked': + return `${baseClasses} bg-red-100 text-red-800`; + default: + return `${baseClasses} bg-gray-100 text-gray-800`; + } + }; + + // API methods + const loadTokens = async () => { + isLoadingTokens.value = true; + try { + const response = await fetch('/api/tokens', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to load tokens'); + } + + const data = await response.json(); + tokens.value = data.tokens || []; + } catch (error) { + console.error('Error loading tokens:', error); + setGlobalError('Failed to load API tokens: ' + error.message); + } finally { + isLoadingTokens.value = false; + } + }; + + const createToken = async () => { + if (!tokenForm.value.name || tokenForm.value.name.trim() === '') { + showToast('Please enter a token name', 'error'); + return; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch('/api/tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + name: tokenForm.value.name, + expires_in_days: parseInt(tokenForm.value.expires_in_days) || 0 + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create token'); + } + + const data = await response.json(); + + // Store the plaintext token to show to user (only shown once) + newTokenSecret.value = data.token; + newTokenData.value = { + id: data.id, + name: data.name, + created_at: data.created_at, + expires_at: data.expires_at + }; + + // Add to tokens list (without the plaintext token) + tokens.value.unshift({ + id: data.id, + name: data.name, + created_at: data.created_at, + last_used_at: data.last_used_at, + expires_at: data.expires_at, + revoked: data.revoked + }); + + // Reset form + tokenForm.value = { + name: '', + expires_in_days: 0 + }; + + // Close create modal and show secret modal + showCreateTokenModal.value = false; + showTokenSecretModal.value = true; + + showToast('API token created successfully', 'success'); + } catch (error) { + console.error('Error creating token:', error); + showToast('Failed to create token: ' + error.message, 'error'); + } + }; + + const revokeToken = async (tokenId, tokenName) => { + if (!confirm(`Are you sure you want to revoke the token "${tokenName}"? This action cannot be undone and any applications using this token will lose access.`)) { + return; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch(`/api/tokens/${tokenId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + } + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to revoke token'); + } + + // Remove from local list + tokens.value = tokens.value.filter(t => t.id !== tokenId); + + showToast('Token revoked successfully', 'success'); + } catch (error) { + console.error('Error revoking token:', error); + showToast('Failed to revoke token: ' + error.message, 'error'); + } + }; + + const updateTokenName = async (tokenId, newName) => { + if (!newName || newName.trim() === '') { + showToast('Token name cannot be empty', 'error'); + return; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const response = await fetch(`/api/tokens/${tokenId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ name: newName }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update token'); + } + + const data = await response.json(); + + // Update local token + const token = tokens.value.find(t => t.id === tokenId); + if (token) { + token.name = data.name; + } + + showToast('Token name updated', 'success'); + } catch (error) { + console.error('Error updating token:', error); + showToast('Failed to update token: ' + error.message, 'error'); + } + }; + + const copyTokenToClipboard = async (token) => { + try { + await navigator.clipboard.writeText(token); + showToast('Token copied to clipboard', 'success'); + } catch (error) { + console.error('Error copying token:', error); + showToast('Failed to copy token to clipboard', 'error'); + } + }; + + const openCreateTokenModal = () => { + tokenForm.value = { + name: '', + expires_in_days: 0 + }; + showCreateTokenModal.value = true; + }; + + const closeCreateTokenModal = () => { + showCreateTokenModal.value = false; + tokenForm.value = { + name: '', + expires_in_days: 0 + }; + }; + + const closeTokenSecretModal = () => { + showTokenSecretModal.value = false; + newTokenSecret.value = ''; + newTokenData.value = null; + }; + + return { + // State + tokens, + isLoadingTokens, + showCreateTokenModal, + showTokenSecretModal, + newTokenSecret, + newTokenData, + tokenForm, + + // Computed + hasTokens, + activeTokens, + expiredOrRevokedTokens, + + // Methods + isTokenExpired, + formatTokenDate, + getTokenStatus, + getTokenStatusClass, + loadTokens, + createToken, + revokeToken, + updateTokenName, + copyTokenToClipboard, + openCreateTokenModal, + closeCreateTokenModal, + closeTokenSecretModal + }; +} diff --git a/static/js/modules/composables/transcription.js b/static/js/modules/composables/transcription.js new file mode 100644 index 0000000..6ac52ad --- /dev/null +++ b/static/js/modules/composables/transcription.js @@ -0,0 +1,484 @@ +/** + * Transcription editing composable + * Handles ASR editor, text editor, and segment management + */ + +export function useTranscription(state, utils) { + const { + showTextEditorModal, showAsrEditorModal, selectedRecording, + editingTranscriptionContent, editingSegments, availableSpeakers, + recordings, dropdownPositions, openAsrDropdownIndex + } = state; + + const { showToast, setGlobalError, nextTick } = utils; + + // ========================================= + // Text Editor Modal + // ========================================= + + const openTranscriptionEditor = () => { + if (!selectedRecording.value || !selectedRecording.value.transcription) { + return; + } + + // Check if transcription is JSON (ASR format) + try { + const parsed = JSON.parse(selectedRecording.value.transcription); + if (Array.isArray(parsed)) { + openAsrEditorModal(); + } else { + openTextEditorModal(); + } + } catch (e) { + // Not JSON, use text editor + openTextEditorModal(); + } + }; + + const openTextEditorModal = () => { + if (!selectedRecording.value) return; + editingTranscriptionContent.value = selectedRecording.value.transcription || ''; + showTextEditorModal.value = true; + }; + + const closeTextEditorModal = () => { + showTextEditorModal.value = false; + editingTranscriptionContent.value = ''; + }; + + const saveTranscription = async () => { + if (!selectedRecording.value) return; + await saveTranscriptionContent(editingTranscriptionContent.value); + closeTextEditorModal(); + }; + + // ========================================= + // ASR Editor Modal + // ========================================= + + // Helper to pause outer audio player when opening modals with their own player + const pauseOuterAudioPlayer = () => { + const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') || + document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)'); + if (outerAudio && !outerAudio.paused) { + outerAudio.pause(); + } + }; + + const openAsrEditorModal = async () => { + if (!selectedRecording.value) return; + + // Pause outer audio player to avoid conflicts with modal's player + pauseOuterAudioPlayer(); + + try { + const segments = JSON.parse(selectedRecording.value.transcription); + + // Populate available speakers from THIS recording only + const speakersInTranscript = [...new Set(segments.map(s => s.speaker))].sort(); + availableSpeakers.value = speakersInTranscript; + + editingSegments.value = segments.map((s, i) => ({ + ...s, + id: i, + showSuggestions: false, + filteredSpeakers: [...speakersInTranscript] + })); + + showAsrEditorModal.value = true; + + // Reset virtual scroll state for fresh modal render + if (utils.resetAsrEditorScroll) { + utils.resetAsrEditorScroll(); + } + } catch (e) { + console.error("Could not parse transcription as JSON for ASR editor:", e); + setGlobalError("This transcription is not in the correct format for the ASR editor."); + } + }; + + const closeAsrEditorModal = () => { + // Pause any playing modal audio before closing + const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video'); + if (modalAudio) { + modalAudio.pause(); + } + // Reset modal audio state (keep main player independent) + if (utils.resetModalAudioState) { + utils.resetModalAudioState(); + } + + showAsrEditorModal.value = false; + editingSegments.value = []; + }; + + const saveAsrTranscription = async () => { + if (!selectedRecording.value) return; + + // Remove extra UI fields and save the rest + const contentToSave = JSON.stringify(editingSegments.value.map(({ id, showSuggestions, filteredSpeakers, ...rest }) => rest)); + + await saveTranscriptionContent(contentToSave); + closeAsrEditorModal(); + }; + + // ========================================= + // Segment Management + // ========================================= + + const adjustTime = (index, field, amount) => { + if (editingSegments.value[index]) { + editingSegments.value[index][field] = Math.max(0, + editingSegments.value[index][field] + amount + ); + } + }; + + const filterSpeakerSuggestions = (index) => { + const segment = editingSegments.value[index]; + if (segment) { + const query = segment.speaker?.toLowerCase() || ''; + if (query === '') { + segment.filteredSpeakers = [...availableSpeakers.value]; + } else { + segment.filteredSpeakers = availableSpeakers.value.filter( + speaker => speaker.toLowerCase().includes(query) + ); + } + } + }; + + // O(1) dropdown management using single ref instead of O(n) forEach + const openSpeakerSuggestions = (index) => { + if (editingSegments.value[index]) { + // Simply set the open index - O(1) instead of O(n) forEach + openAsrDropdownIndex.value = index; + filterSpeakerSuggestions(index); + updateDropdownPosition(index); + } + }; + + const closeSpeakerSuggestions = (index) => { + // Only close if this index is currently open + if (openAsrDropdownIndex.value === index) { + openAsrDropdownIndex.value = null; + } + }; + + const closeAllSpeakerSuggestions = () => { + // O(1) instead of O(n) - just set to null + openAsrDropdownIndex.value = null; + }; + + // Helper to check if a dropdown is open (for template v-if) + const isDropdownOpen = (index) => { + return openAsrDropdownIndex.value === index; + }; + + const getDropdownPosition = (index) => { + const pos = dropdownPositions.value[index]; + if (pos) { + const style = { + left: pos.left + 'px', + width: pos.width + 'px' + }; + + // When opening upward, anchor from bottom so dropdown grows upward + if (pos.openUpward) { + style.bottom = pos.bottom + 'px'; + style.top = 'auto'; + } else { + style.top = pos.top + 'px'; + style.bottom = 'auto'; + } + + // Apply calculated max height + if (pos.maxHeight) { + style.maxHeight = pos.maxHeight + 'px'; + } + return style; + } + return { top: '0px', left: '0px' }; + }; + + const updateDropdownPosition = (index) => { + nextTick(() => { + // Find row by data attribute to work correctly with virtual scrolling + const row = document.querySelector(`.asr-editor-table tbody tr[data-segment-index="${index}"]`); + if (row) { + const cell = row.querySelector('td:first-child'); + if (cell) { + const rect = cell.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + // Calculate available space above and below + const spaceBelow = viewportHeight - rect.bottom - 10; + const spaceAbove = rect.top - 10; + + // Determine max height based on available space (cap at 192px which is max-h-48) + const maxDropdownHeight = 192; + + let top, bottom, openUpward, maxHeight; + + if (spaceBelow >= maxDropdownHeight || spaceBelow >= spaceAbove) { + // Open downward + top = rect.bottom + 2; + bottom = null; + openUpward = false; + maxHeight = Math.min(spaceBelow, maxDropdownHeight); + } else { + // Open upward - anchor from bottom so dropdown grows upward + openUpward = true; + maxHeight = Math.min(spaceAbove, maxDropdownHeight); + // Bottom is distance from viewport bottom to the top of the cell + bottom = viewportHeight - rect.top + 2; + top = null; + } + + dropdownPositions.value[index] = { + top: top, + bottom: bottom, + left: rect.left, + width: rect.width, + openUpward: openUpward, + maxHeight: maxHeight + }; + } + } + }); + }; + + const selectSpeaker = (index, speaker) => { + if (editingSegments.value[index]) { + editingSegments.value[index].speaker = speaker; + closeSpeakerSuggestions(index); + } + }; + + const addSegment = () => { + const lastSegment = editingSegments.value[editingSegments.value.length - 1]; + const newStart = lastSegment ? lastSegment.end_time : 0; + + editingSegments.value.push({ + speaker: availableSpeakers.value[0] || 'Speaker 1', + start_time: newStart, + end_time: newStart + 5, + sentence: '', + id: editingSegments.value.length, + showSuggestions: false, + filteredSpeakers: [...availableSpeakers.value] + }); + }; + + const removeSegment = (index) => { + editingSegments.value.splice(index, 1); + // Re-index segments + editingSegments.value.forEach((seg, i) => { + seg.id = i; + }); + }; + + const addSegmentBelow = (index) => { + const currentSegment = editingSegments.value[index]; + const nextSegment = editingSegments.value[index + 1]; + + const newStart = currentSegment.end_time; + const newEnd = nextSegment ? nextSegment.start_time : newStart + 5; + + editingSegments.value.splice(index + 1, 0, { + speaker: currentSegment.speaker, + start_time: newStart, + end_time: newEnd, + sentence: '', + id: index + 1, + showSuggestions: false, + filteredSpeakers: [...availableSpeakers.value] + }); + + // Re-index segments + editingSegments.value.forEach((seg, i) => { + seg.id = i; + }); + }; + + const seekToSegmentTime = (time) => { + // Find audio elements and use the one in a visible modal (z-50) + const mediaElements = document.querySelectorAll('.fixed.z-50 audio, .fixed.z-50 video'); + const audioElement = mediaElements.length > 0 ? mediaElements[mediaElements.length - 1] : null; + if (audioElement) { + audioElement.currentTime = time; + audioElement.play(); + } + }; + + const autoResizeTextarea = (event) => { + const textarea = event.target; + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + }; + + // ========================================= + // Save Transcription Content + // ========================================= + + const saveTranscriptionContent = async (content) => { + if (!selectedRecording.value) return; + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcription`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ transcription: content }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update transcription'); + + // Update recording + selectedRecording.value.transcription = content; + + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index].transcription = content; + } + + showToast('Transcription updated successfully!', 'fa-check-circle'); + } catch (error) { + setGlobalError(`Failed to save transcription: ${error.message}`); + } + }; + + // ========================================= + // Save Summary + // ========================================= + + const saveSummary = async (summary) => { + if (!selectedRecording.value) return; + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const payload = { + id: selectedRecording.value.id, + title: selectedRecording.value.title, + participants: selectedRecording.value.participants, + notes: selectedRecording.value.notes, + summary: summary, + meeting_date: selectedRecording.value.meeting_date + }; + const response = await fetch('/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update summary'); + + // Update recording + selectedRecording.value.summary = summary; + + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index].summary = summary; + } + + showToast('Summary saved!', 'fa-check-circle'); + } catch (error) { + setGlobalError(`Failed to save summary: ${error.message}`); + } + }; + + // ========================================= + // Save Notes + // ========================================= + + const saveNotes = async (notes) => { + if (!selectedRecording.value) return; + + // Handle incognito recordings - save to sessionStorage only + if (selectedRecording.value.incognito) { + selectedRecording.value.notes = notes; + // Update sessionStorage + try { + const stored = sessionStorage.getItem('speakr_incognito_recording'); + if (stored) { + const data = JSON.parse(stored); + data.notes = notes; + sessionStorage.setItem('speakr_incognito_recording', JSON.stringify(data)); + } + } catch (e) { + console.error('[Incognito] Failed to save notes to sessionStorage:', e); + } + showToast('Notes saved (in browser only)', 'fa-check-circle'); + return; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch(`/api/recordings/${selectedRecording.value.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ notes }) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to update notes'); + + // Update recording + selectedRecording.value.notes = notes; + + const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); + if (index !== -1) { + recordings.value[index].notes = notes; + } + + showToast('Notes saved!', 'fa-check-circle'); + } catch (error) { + setGlobalError(`Failed to save notes: ${error.message}`); + } + }; + + return { + // Text editor + openTranscriptionEditor, + openTextEditorModal, + closeTextEditorModal, + saveTranscription, + + // ASR editor + openAsrEditorModal, + closeAsrEditorModal, + saveAsrTranscription, + + // Segment management + adjustTime, + filterSpeakerSuggestions, + openSpeakerSuggestions, + closeSpeakerSuggestions, + closeAllSpeakerSuggestions, + isDropdownOpen, + getDropdownPosition, + updateDropdownPosition, + selectSpeaker, + addSegment, + removeSegment, + addSegmentBelow, + seekToSegmentTime, + autoResizeTextarea, + + // Save + saveTranscriptionContent, + saveSummary, + saveNotes + }; +} diff --git a/static/js/modules/composables/ui.js b/static/js/modules/composables/ui.js new file mode 100644 index 0000000..deb54a8 --- /dev/null +++ b/static/js/modules/composables/ui.js @@ -0,0 +1,2110 @@ +/** + * UI management composable + * Handles dark mode, color schemes, sidebar, and other UI state + */ + +export function useUI(state, utils, processedTranscription) { + const { + isDarkMode, currentColorScheme, colorSchemes, isSidebarCollapsed, + showColorSchemeModal, isUserMenuOpen, currentView, selectedRecording, + windowWidth, isMobileScreen, showAdvancedFilters, showSortOptions, + searchTipsExpanded, isMetadataExpanded, editingParticipants, editingMeetingDate, + editingSummary, tempSummaryContent, summaryMarkdownEditorInstance, + leftColumnWidth, rightColumnWidth, isResizing, playerVolume, + audioIsPlaying, audioCurrentTime, audioDuration, audioIsMuted, audioIsLoading, + editingNotes, tempNotesContent, transcriptionViewMode, + notesMarkdownEditor, markdownEditorInstance, autoSaveTimer, csrfToken, + summaryMarkdownEditor, recordingNotesEditor, recordingMarkdownEditorInstance, + recordingNotes, showDownloadMenu, currentPlayingSegmentIndex, followPlayerMode, + playbackRate, showSpeedMenu, playbackSpeeds, modalPlaybackRate, speedMenuPosition, + videoFullscreen, fullscreenControlsVisible, fullscreenControlsTimer, videoCollapsed + } = state; + + const autoSaveDelay = 2000; // 2 seconds + + const { showToast, nextTick, t } = utils; + const { ref, computed, watch } = Vue; + + // isMobile computed + const isMobile = computed(() => windowWidth.value < 768); + + // Toggle dark mode + const toggleDarkMode = () => { + isDarkMode.value = !isDarkMode.value; + localStorage.setItem('darkMode', isDarkMode.value); + + if (isDarkMode.value) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + // Re-apply current color scheme for new mode + applyColorScheme(currentColorScheme.value); + }; + + // Initialize dark mode from storage + const initializeDarkMode = () => { + const savedMode = localStorage.getItem('darkMode'); + if (savedMode !== null) { + isDarkMode.value = savedMode === 'true'; + } else { + // Check system preference + isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + if (isDarkMode.value) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }; + + // Apply a color scheme + const applyColorScheme = (schemeId, mode = null) => { + const targetMode = mode || (isDarkMode.value ? 'dark' : 'light'); + const scheme = colorSchemes[targetMode].find(s => s.id === schemeId); + + if (!scheme) { + console.warn(`Color scheme '${schemeId}' not found for mode '${targetMode}'`); + return; + } + + // Remove all theme classes + const allThemeClasses = [ + ...colorSchemes.light.map(s => s.class), + ...colorSchemes.dark.map(s => s.class) + ].filter(c => c !== ''); + + document.documentElement.classList.remove(...allThemeClasses); + + // Add new theme class if not default + if (scheme.class) { + document.documentElement.classList.add(scheme.class); + } + + currentColorScheme.value = schemeId; + localStorage.setItem('colorScheme', schemeId); + }; + + // Initialize color scheme from storage + const initializeColorScheme = () => { + const savedScheme = localStorage.getItem('colorScheme'); + if (savedScheme) { + applyColorScheme(savedScheme); + } else { + // Apply default scheme + applyColorScheme('blue'); + } + }; + + // Open color scheme modal + const openColorSchemeModal = () => { + showColorSchemeModal.value = true; + isUserMenuOpen.value = false; + }; + + // Close color scheme modal + const closeColorSchemeModal = () => { + showColorSchemeModal.value = false; + }; + + // Select a color scheme + const selectColorScheme = (schemeId) => { + applyColorScheme(schemeId); + showToast(t('messages.colorSchemeApplied'), 'fa-palette'); + }; + + // Reset to default color scheme + const resetColorScheme = () => { + applyColorScheme('blue'); + showToast(t('messages.colorSchemeReset'), 'fa-undo'); + }; + + // Toggle sidebar + const toggleSidebar = () => { + isSidebarCollapsed.value = !isSidebarCollapsed.value; + localStorage.setItem('sidebarCollapsed', isSidebarCollapsed.value); + }; + + // Initialize sidebar state + const initializeSidebar = () => { + const saved = localStorage.getItem('sidebarCollapsed'); + if (saved !== null) { + isSidebarCollapsed.value = saved === 'true'; + } + }; + + // Switch to upload view + const switchToUploadView = () => { + currentView.value = 'upload'; + if (isMobileScreen.value) { + isSidebarCollapsed.value = true; + } + }; + + // Switch to detail view + const switchToDetailView = () => { + currentView.value = 'detail'; + }; + + // Switch to recording view + const switchToRecordingView = () => { + currentView.value = 'recording'; + if (isMobileScreen.value) { + isSidebarCollapsed.value = true; + } + }; + + // Set global error + const setGlobalError = (message, duration = 7000) => { + if (state.globalError) { + state.globalError.value = message; + if (duration > 0) { + setTimeout(() => { + if (state.globalError.value === message) { + state.globalError.value = null; + } + }, duration); + } + } + }; + + // Format file size + const formatFileSize = (bytes) => { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + // Format display date + const formatDisplayDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // Format short date + const formatShortDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diff = now - date; + const oneDay = 24 * 60 * 60 * 1000; + + if (diff < oneDay) { + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit' + }); + } else if (diff < 7 * oneDay) { + return date.toLocaleDateString(undefined, { + weekday: 'short' + }); + } else { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }); + } + }; + + // Format status + const formatStatus = (status) => { + if (!status || status === 'COMPLETED') return ''; + const statusMap = { + 'PENDING': t('status.queued'), + 'QUEUED': t('status.queued'), + 'PROCESSING': t('status.processing'), + 'TRANSCRIBING': t('status.transcribing'), + 'SUMMARIZING': t('status.summarizing'), + 'FAILED': t('status.failed'), + 'UPLOADING': t('status.uploading') + }; + return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); + }; + + // Get status class + const getStatusClass = (status) => { + switch(status) { + case 'PENDING': return 'status-pending'; + case 'QUEUED': return 'status-pending'; + case 'PROCESSING': return 'status-processing'; + case 'SUMMARIZING': return 'status-summarizing'; + case 'COMPLETED': return ''; + case 'FAILED': return 'status-failed'; + default: return 'status-pending'; + } + }; + + // Format time (seconds to HH:MM:SS) + const formatTime = (seconds) => { + if (!seconds && seconds !== 0) return '00:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + // Format duration in seconds to human readable + const formatDuration = (seconds) => { + if (!seconds && seconds !== 0) return ''; + + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + + const parts = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (s > 0 || parts.length === 0) parts.push(`${s}s`); + + return parts.join(' '); + }; + + // Format processing duration + const formatProcessingDuration = (seconds) => { + if (!seconds) return ''; + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } else if (seconds < 3600) { + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}m ${s}s`; + } else { + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + return `${h}h ${m}m`; + } + }; + + // --- Inline Editing --- + const saveInlineEdit = async (field) => { + if (!selectedRecording.value) return; + + const fullPayload = { + id: selectedRecording.value.id, + title: selectedRecording.value.title, + participants: selectedRecording.value.participants, + notes: selectedRecording.value.notes, + summary: selectedRecording.value.summary, + meeting_date: selectedRecording.value.meeting_date + }; + + try { + const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const response = await fetch('/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfTokenValue + }, + body: JSON.stringify(fullPayload) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to save metadata'); + + // Update the recording with returned HTML + if (data.recording) { + if (field === 'notes' && data.recording.notes_html) { + selectedRecording.value.notes_html = data.recording.notes_html; + } else if (field === 'summary' && data.recording.summary_html) { + selectedRecording.value.summary_html = data.recording.summary_html; + } + } + } catch (error) { + showToast(t('messages.failedToSave', { error: error.message }), 'fa-exclamation-circle', 3000, 'error'); + } + }; + + const toggleEditParticipants = () => { + editingParticipants.value = !editingParticipants.value; + if (!editingParticipants.value) { + saveInlineEdit('participants'); + } + }; + + const toggleEditMeetingDate = () => { + editingMeetingDate.value = !editingMeetingDate.value; + if (!editingMeetingDate.value) { + saveInlineEdit('meeting_date'); + } + }; + + const toggleEditTitle = () => { + if (!selectedRecording.value) return; + + // Check if user has permission to edit + if (selectedRecording.value.can_edit === false) { + showToast(t('messages.noPermissionToEdit'), 'fa-exclamation-circle', 3000, 'error'); + return; + } + + if (!state.editingTitle.value) { + // Start editing + state.originalTitle.value = selectedRecording.value.title || ''; + state.editingTitle.value = true; + nextTick(() => { + // Focus the input field + const titleInput = document.querySelector('input[ref="titleInput"]'); + if (titleInput) { + titleInput.focus(); + titleInput.select(); + } + }); + } + }; + + const saveTitle = async () => { + if (!selectedRecording.value) return; + + state.editingTitle.value = false; + + // Only save if title changed + if (selectedRecording.value.title !== state.originalTitle.value) { + await saveInlineEdit('title'); + } + }; + + const cancelEditTitle = () => { + if (!selectedRecording.value) return; + + // Restore original title + selectedRecording.value.title = state.originalTitle.value; + state.editingTitle.value = false; + }; + + const toggleEditSummary = () => { + editingSummary.value = !editingSummary.value; + if (editingSummary.value) { + tempSummaryContent.value = selectedRecording.value?.summary || ''; + nextTick(() => { + initializeSummaryMarkdownEditor(); + }); + } + }; + + const cancelEditSummary = () => { + if (summaryMarkdownEditorInstance.value) { + summaryMarkdownEditorInstance.value.toTextArea(); + summaryMarkdownEditorInstance.value = null; + } + editingSummary.value = false; + // Restore original content + if (selectedRecording.value) { + selectedRecording.value.summary = tempSummaryContent.value; + } + }; + + const saveEditSummary = async () => { + if (summaryMarkdownEditorInstance.value) { + selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value(); + summaryMarkdownEditorInstance.value.toTextArea(); + summaryMarkdownEditorInstance.value = null; + } + editingSummary.value = false; + await saveInlineEdit('summary'); + }; + + const initializeSummaryMarkdownEditor = () => { + if (!summaryMarkdownEditor.value) return; + + try { + summaryMarkdownEditorInstance.value = new EasyMDE({ + element: summaryMarkdownEditor.value, + spellChecker: false, + autofocus: true, + placeholder: t('form.enterSummaryMarkdown'), + initialValue: selectedRecording.value?.summary || '', + status: false, + toolbar: [ + "bold", "italic", "heading", "|", + "quote", "unordered-list", "ordered-list", "|", + "link", "image", "|", + "preview", "side-by-side", "fullscreen" + ], + previewClass: ["editor-preview", "notes-preview"], + theme: isDarkMode.value ? "dark" : "light" + }); + + // Add auto-save functionality + summaryMarkdownEditorInstance.value.codemirror.on('change', () => { + if (autoSaveTimer.value) { + clearTimeout(autoSaveTimer.value); + } + autoSaveTimer.value = setTimeout(() => { + autoSaveSummary(); + }, autoSaveDelay); + }); + } catch (error) { + console.error('Failed to initialize summary markdown editor:', error); + editingSummary.value = true; + } + }; + + const autoSaveSummary = async () => { + if (summaryMarkdownEditorInstance.value && editingSummary.value) { + // Just save the content to the model, don't exit edit mode + selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value(); + // Silently save to backend without changing UI state + try { + const payload = { + id: selectedRecording.value.id, + title: selectedRecording.value.title, + participants: selectedRecording.value.participants, + notes: selectedRecording.value.notes, + summary: selectedRecording.value.summary, + meeting_date: selectedRecording.value.meeting_date + }; + const response = await fetch('/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken.value + }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + if (response.ok && data.recording) { + // Update the HTML rendered versions if they exist + if (data.recording.summary_html) { + selectedRecording.value.summary_html = data.recording.summary_html; + } + } else { + console.error('Failed to auto-save summary'); + } + } catch (error) { + console.error('Error auto-saving summary:', error); + } + } + }; + + const toggleTranscriptionViewMode = () => { + transcriptionViewMode.value = transcriptionViewMode.value === 'simple' ? 'bubble' : 'simple'; + localStorage.setItem('transcriptionViewMode', transcriptionViewMode.value); + }; + + const toggleEditNotes = () => { + editingNotes.value = !editingNotes.value; + if (editingNotes.value) { + tempNotesContent.value = selectedRecording.value?.notes || ''; + // Initialize markdown editor when entering edit mode + nextTick(() => { + initializeMarkdownEditor(); + }); + } + }; + + const cancelEditNotes = () => { + if (markdownEditorInstance.value) { + markdownEditorInstance.value.toTextArea(); + markdownEditorInstance.value = null; + } + editingNotes.value = false; + // Restore original content + if (selectedRecording.value) { + selectedRecording.value.notes = tempNotesContent.value; + } + }; + + const saveEditNotes = async () => { + if (markdownEditorInstance.value) { + // Get the markdown content from the editor + selectedRecording.value.notes = markdownEditorInstance.value.value(); + markdownEditorInstance.value.toTextArea(); + markdownEditorInstance.value = null; + } + editingNotes.value = false; + await saveInlineEdit('notes'); + }; + + const initializeMarkdownEditor = () => { + if (!notesMarkdownEditor.value) return; + + try { + markdownEditorInstance.value = new EasyMDE({ + element: notesMarkdownEditor.value, + spellChecker: false, + autofocus: true, + placeholder: t('form.enterNotesMarkdown'), + initialValue: selectedRecording.value?.notes || '', + status: false, + toolbar: [ + "bold", "italic", "heading", "|", + "quote", "unordered-list", "ordered-list", "|", + "link", "image", "|", + "preview", "side-by-side", "fullscreen" + ], + previewClass: ["editor-preview", "notes-preview"], + theme: isDarkMode.value ? "dark" : "light" + }); + + // Add auto-save functionality + markdownEditorInstance.value.codemirror.on('change', () => { + if (autoSaveTimer.value) { + clearTimeout(autoSaveTimer.value); + } + autoSaveTimer.value = setTimeout(() => { + autoSaveNotes(); + }, autoSaveDelay); + }); + } catch (error) { + console.error('Failed to initialize markdown editor:', error); + // Fallback to regular textarea editing + editingNotes.value = true; + } + }; + + const autoSaveNotes = async () => { + if (markdownEditorInstance.value && editingNotes.value) { + // Just save the content to the model, don't exit edit mode + selectedRecording.value.notes = markdownEditorInstance.value.value(); + // Silently save to backend without changing UI state + try { + const payload = { + id: selectedRecording.value.id, + title: selectedRecording.value.title, + participants: selectedRecording.value.participants, + notes: selectedRecording.value.notes, + summary: selectedRecording.value.summary, + meeting_date: selectedRecording.value.meeting_date + }; + const response = await fetch('/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken.value + }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + if (response.ok && data.recording) { + // Update the HTML rendered versions if they exist + if (data.recording.notes_html) { + selectedRecording.value.notes_html = data.recording.notes_html; + } + } else { + console.error('Failed to auto-save notes'); + } + } catch (error) { + console.error('Error auto-saving notes:', error); + } + } + }; + + const clickToEditNotes = () => { + // Allow clicking on empty notes area to start editing + if (!editingNotes.value && (!selectedRecording.value?.notes || selectedRecording.value.notes.trim() === '')) { + toggleEditNotes(); + } + }; + + const clickToEditSummary = () => { + // Allow clicking on empty summary area to start editing + if (!editingSummary.value && (!selectedRecording.value?.summary || selectedRecording.value.summary.trim() === '')) { + toggleEditSummary(); + } + }; + + const downloadNotes = async () => { + if (!selectedRecording.value || !selectedRecording.value.notes) { + showToast(t('messages.noNotesAvailableDownload'), 'fa-exclamation-circle'); + return; + } + + try { + const response = await fetch(`/recording/${selectedRecording.value.id}/download/notes`); + if (!response.ok) { + const error = await response.json(); + showToast(error.error || t('messages.notesDownloadFailed'), 'fa-exclamation-circle'); + return; + } + + // Create blob and download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedRecording.value.title || 'notes'}.md`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast(t('messages.notesDownloadSuccess')); + } catch (error) { + showToast(t('messages.notesDownloadFailed'), 'fa-exclamation-circle'); + } + }; + + const downloadEventICS = async (event) => { + if (!event || !event.id) { + showToast(t('messages.invalidEventData'), 'fa-exclamation-circle'); + return; + } + + try { + const response = await fetch(`/api/event/${event.id}/ics`); + if (!response.ok) { + const error = await response.json(); + showToast(error.error || t('messages.eventDownloadFailed'), 'fa-exclamation-circle'); + return; + } + + // Create blob and download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `${event.title || 'event'}.ics`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast(t('messages.eventDownloadSuccess', { title: event.title }), 'fa-calendar-check', 3000); + } catch (error) { + console.error('Download failed:', error); + showToast(t('messages.eventDownloadFailed'), 'fa-exclamation-circle'); + } + }; + + const downloadICS = async () => { + if (!selectedRecording.value || !selectedRecording.value.events || selectedRecording.value.events.length === 0) { + showToast(t('messages.noEventsToExport'), 'fa-exclamation-circle'); + return; + } + + try { + const response = await fetch(`/api/recording/${selectedRecording.value.id}/events/ics`); + if (!response.ok) { + const error = await response.json(); + showToast(error.error || t('messages.eventsExportFailed'), 'fa-exclamation-circle'); + return; + } + + // Create blob and download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `events-${selectedRecording.value.title || selectedRecording.value.id}.ics`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast(t('messages.eventsExportSuccess', { count: selectedRecording.value.events.length }), 'fa-calendar-check'); + } catch (error) { + console.error('Download all events ICS error:', error); + showToast(t('messages.eventsExportFailed'), 'fa-exclamation-circle'); + } + }; + + const deleteEvent = async (event) => { + if (!event || !event.id) { + showToast(t('messages.invalidEventData'), 'fa-exclamation-circle'); + return; + } + + // Confirm deletion + if (!confirm(t('events.confirmDelete', { title: event.title }) || `Delete event "${event.title}"?`)) { + return; + } + + try { + const response = await fetch(`/api/event/${event.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || '' + } + }); + + if (!response.ok) { + const error = await response.json(); + showToast(error.error || t('events.deleteFailed') || 'Failed to delete event', 'fa-exclamation-circle'); + return; + } + + // Remove event from local state + if (selectedRecording.value && selectedRecording.value.events) { + selectedRecording.value.events = selectedRecording.value.events.filter(e => e.id !== event.id); + } + + showToast(t('events.deleted') || 'Event deleted', 'fa-check-circle'); + } catch (error) { + console.error('Delete event failed:', error); + showToast(t('events.deleteFailed') || 'Failed to delete event', 'fa-exclamation-circle'); + } + }; + + const formatEventDateTime = (dateTimeStr) => { + if (!dateTimeStr) return ''; + try { + const date = new Date(dateTimeStr); + const options = { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }; + return date.toLocaleString(undefined, options); + } catch (e) { + return dateTimeStr; + } + }; + + // --- Column Resizing --- + const startColumnResize = (event) => { + isResizing.value = true; + const startX = event.clientX; + const startLeftWidth = leftColumnWidth.value; + + const handleMouseMove = (e) => { + if (!isResizing.value) return; + + const container = document.getElementById('mainContentColumns'); + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const deltaX = e.clientX - startX; + const containerWidth = containerRect.width; + const deltaPercent = (deltaX / containerWidth) * 100; + + let newLeftWidth = startLeftWidth + deltaPercent; + newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); + + leftColumnWidth.value = newLeftWidth; + rightColumnWidth.value = 100 - newLeftWidth; + }; + + const handleMouseUp = () => { + isResizing.value = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + localStorage.setItem('transcriptColumnWidth', leftColumnWidth.value); + localStorage.setItem('summaryColumnWidth', rightColumnWidth.value); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + event.preventDefault(); + }; + + // --- Audio Player --- + const seekAudio = (time, context = 'main') => { + let audioPlayer = null; + if (context === 'modal') { + audioPlayer = document.querySelector('audio.speaker-modal-transcript') || document.querySelector('video.speaker-modal-transcript'); + } else { + audioPlayer = document.querySelector('.main-content-area audio') || document.querySelector('.main-content-area video'); + } + + if (!audioPlayer) { + audioPlayer = document.querySelector('audio') || document.querySelector('video'); + } + + if (audioPlayer && isFinite(time)) { + const wasPlaying = !audioPlayer.paused; + try { + audioPlayer.currentTime = time; + if (wasPlaying) { + audioPlayer.play().catch(e => console.warn('Play after seek failed:', e)); + } + } catch (e) { + console.warn('Seek failed:', e); + } + } + }; + + const seekAudioFromEvent = (event) => { + const segmentElement = event.target.closest('[data-start-time]'); + if (!segmentElement) return; + + const time = parseFloat(segmentElement.dataset.startTime); + if (isNaN(time)) return; + + const isInSpeakerModal = event.target.closest('.speaker-modal-transcript') !== null; + const context = isInSpeakerModal ? 'modal' : 'main'; + + seekAudio(time, context); + }; + + const onPlayerVolumeChange = (event) => { + const newVolume = event.target.volume; + playerVolume.value = newVolume; + localStorage.setItem('playerVolume', newVolume); + }; + + // --- Custom Audio Player Controls --- + const getAudioElement = () => { + // First check fullscreen overlay (teleported to body) + const fullscreenMedia = document.querySelector('.video-fullscreen-overlay video'); + if (fullscreenMedia) return fullscreenMedia; + // Then check for audio/video in visible modals (z-50 class) - these take priority + const modalMedia = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video'); + if (modalMedia) { + return modalMedia; + } + // Fall back to main player in right column (desktop) or detail view (mobile) + return document.querySelector('#rightMainColumn audio') || + document.querySelector('#rightMainColumn video') || + document.querySelector('.detail-view audio') || + document.querySelector('.detail-view video') || + document.querySelector('audio') || + document.querySelector('video'); + }; + + const toggleAudioPlayback = () => { + const audio = getAudioElement(); + if (!audio) return; + + if (audio.paused) { + audio.play(); + } else { + audio.pause(); + } + }; + + const toggleAudioMute = () => { + const audio = getAudioElement(); + if (!audio) return; + + audio.muted = !audio.muted; + audioIsMuted.value = audio.muted; + }; + + const setAudioVolume = (volume) => { + const audio = getAudioElement(); + if (!audio) return; + + audio.volume = Math.max(0, Math.min(1, volume)); + playerVolume.value = audio.volume; + localStorage.setItem('playerVolume', audio.volume); + + if (audio.volume === 0) { + audio.muted = true; + audioIsMuted.value = true; + } else if (audio.muted) { + audio.muted = false; + audioIsMuted.value = false; + } + }; + + const seekAudioTo = (time) => { + const audio = getAudioElement(); + if (!audio || !isFinite(time)) return; + + // Use our tracked duration (server-side) as fallback if browser duration is broken + const maxTime = audioDuration.value || (isFinite(audio.duration) ? audio.duration : time); + try { + audio.currentTime = Math.max(0, Math.min(time, maxTime)); + } catch (e) { + console.warn('Seek failed:', e); + } + }; + + const seekAudioByPercent = (percent) => { + const audio = getAudioElement(); + // Use our tracked duration (server-side) which works for WebM files without duration metadata + const dur = audioDuration.value || audio?.duration; + if (!audio || !dur || !isFinite(dur)) return; + + const time = (percent / 100) * dur; + try { + audio.currentTime = time; + } catch (e) { + console.warn('Seek by percent failed:', e); + } + }; + + // Progress bar drag state + const isDraggingProgress = ref(false); + const dragPreviewPercent = ref(0); + + // Handle progress bar drag - supports both mouse and touch, only seeks on release + const startProgressDrag = (event) => { + const bar = event.currentTarget.querySelector('.progress-track') || event.currentTarget.querySelector('.h-2') || event.currentTarget; + const rect = bar.getBoundingClientRect(); + const isTouch = event.type === 'touchstart'; + + const getPercent = (evt) => { + const clientX = isTouch ? evt.touches[0].clientX : evt.clientX; + return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); + }; + + const getPercentFromEnd = (evt) => { + const clientX = isTouch ? evt.changedTouches[0].clientX : evt.clientX; + return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); + }; + + // Start dragging - show preview + isDraggingProgress.value = true; + dragPreviewPercent.value = getPercent(event); + + const onMove = (evt) => { + evt.preventDefault(); + const clientX = isTouch ? evt.touches[0].clientX : evt.clientX; + dragPreviewPercent.value = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); + }; + + const onUp = (evt) => { + document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove); + document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp); + // Seek to final position on release + seekAudioByPercent(dragPreviewPercent.value); + isDraggingProgress.value = false; + }; + + document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false }); + document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp); + }; + + const handleAudioPlayPause = (event) => { + audioIsPlaying.value = !event.target.paused; + }; + + const handleAudioLoadedMetadata = (event) => { + const duration = event.target.duration; + // Only set browser duration if we don't already have a server-side duration + // Server-side duration (from ffprobe) is more reliable for formats like WebM + if (!audioDuration.value || audioDuration.value === 0) { + if (duration && isFinite(duration) && duration > 0) { + audioDuration.value = duration; + } + } + audioIsLoading.value = false; + + // Apply saved playback rate when audio loads + if (playbackRate.value !== 1) { + event.target.playbackRate = playbackRate.value; + } + }; + + const handleAudioEnded = () => { + audioIsPlaying.value = false; + audioCurrentTime.value = 0; + }; + + const handleCustomAudioTimeUpdate = (event) => { + audioCurrentTime.value = event.target.currentTime; + + // Fallback: if duration wasn't set yet, try to get it now + if (!audioDuration.value || audioDuration.value === 0) { + const duration = event.target.duration; + if (duration && isFinite(duration) && duration > 0) { + audioDuration.value = duration; + } + } + + // Also call the existing handler for segment tracking + handleAudioTimeUpdate(event); + }; + + const handleAudioWaiting = () => { + audioIsLoading.value = true; + }; + + const handleAudioCanPlay = (event) => { + audioIsLoading.value = false; + + // Fallback: try to get duration if not set yet + if (!audioDuration.value || audioDuration.value === 0) { + const duration = event.target.duration; + if (duration && isFinite(duration) && duration > 0) { + audioDuration.value = duration; + } + } + }; + + const handleAudioDurationChange = (event) => { + // WebM and some other formats may initially report Infinity duration + // This handler catches when the actual duration becomes available + // Only set if we don't already have a server-side duration + if (!audioDuration.value || audioDuration.value === 0) { + const duration = event.target.duration; + if (duration && isFinite(duration) && duration > 0) { + audioDuration.value = duration; + } + } + }; + + const formatAudioTime = (seconds) => { + if (!seconds || isNaN(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // --- Playback Speed Control --- + const formatPlaybackRate = (rate) => { + if (rate === 1) return '1x'; + if (rate === Math.floor(rate)) return `${rate}x`; + return `${rate}x`; + }; + + const setPlaybackRate = (rate) => { + playbackRate.value = rate; + localStorage.setItem('playbackRate', rate); + + // Apply to all audio and video elements + document.querySelectorAll('audio, video').forEach(el => { + el.playbackRate = rate; + }); + }; + + const cyclePlaybackRate = () => { + const currentIndex = playbackSpeeds.indexOf(playbackRate.value); + const nextIndex = (currentIndex + 1) % playbackSpeeds.length; + setPlaybackRate(playbackSpeeds[nextIndex]); + }; + + const cycleModalPlaybackRate = () => { + const currentIndex = playbackSpeeds.indexOf(modalPlaybackRate.value); + const nextIndex = (currentIndex + 1) % playbackSpeeds.length; + modalPlaybackRate.value = playbackSpeeds[nextIndex]; + + // Apply to modal audio elements + const modalAudio = document.querySelector('.speaker-modal-transcript')?.closest('.fixed')?.querySelector('audio') || + document.querySelector('.speaker-modal-transcript')?.closest('.fixed')?.querySelector('video') || + document.querySelector('[ref="speakerModalAudioRef"]') || + document.querySelector('[ref="asrEditorAudioRef"]'); + if (modalAudio) { + modalAudio.playbackRate = modalPlaybackRate.value; + } + }; + + const initializePlaybackRate = () => { + const savedRate = localStorage.getItem('playbackRate'); + if (savedRate) { + const rate = parseFloat(savedRate); + if (playbackSpeeds.includes(rate)) { + playbackRate.value = rate; + } + } + }; + + const updateSpeedMenuPosition = (buttonEl) => { + if (!buttonEl) return; + const rect = buttonEl.getBoundingClientRect(); + const menuWidth = 52; + const menuMaxHeight = 160; + const gap = 4; + const safeZone = 60; // Account for header/navbar + + // Check available space above and below + const spaceAbove = rect.top - safeZone; + const spaceBelow = window.innerHeight - rect.bottom - 8; + + // Decide direction: prefer above, but use below if not enough space + const showBelow = spaceAbove < menuMaxHeight && spaceBelow > spaceAbove; + + const right = window.innerWidth - rect.right; + + if (showBelow) { + // Position below the button + speedMenuPosition.value = { + right: `${Math.max(8, right)}px`, + top: `${rect.bottom + gap}px`, + bottom: 'auto', + maxHeight: `${Math.min(menuMaxHeight, spaceBelow)}px` + }; + } else { + // Position above the button + speedMenuPosition.value = { + right: `${Math.max(8, right)}px`, + bottom: `${window.innerHeight - rect.top + gap}px`, + top: 'auto', + maxHeight: `${Math.min(menuMaxHeight, spaceAbove)}px` + }; + } + }; + + const audioProgressPercent = computed(() => { + // Use preview position while dragging for smooth UI + if (isDraggingProgress.value) { + return dragPreviewPercent.value; + } + if (!audioDuration.value) return 0; + return (audioCurrentTime.value / audioDuration.value) * 100; + }); + + // Preview time display while dragging + const displayCurrentTime = computed(() => { + if (isDraggingProgress.value && audioDuration.value) { + return (dragPreviewPercent.value / 100) * audioDuration.value; + } + return audioCurrentTime.value; + }); + + // Reset audio player state (called when recording changes) + const resetAudioPlayerState = () => { + audioIsPlaying.value = false; + audioCurrentTime.value = 0; + audioDuration.value = 0; + audioIsMuted.value = false; + audioIsLoading.value = false; + }; + + // --- Active Segment Tracking --- + + // Binary search to find the segment containing the current time + // Returns the index of the last segment where startTime <= currentTime + // O(log n) instead of O(n) - critical for long transcriptions (4500+ segments) + const binarySearchSegment = (segments, currentTime) => { + if (segments.length === 0) return null; + + let low = 0; + let high = segments.length - 1; + let result = null; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const startTime = segments[mid].startTime || segments[mid].start_time; + + if (startTime === undefined) { + // Skip segments without timing info + high = mid - 1; + continue; + } + + if (startTime <= currentTime) { + result = mid; // This segment is a candidate + low = mid + 1; // Look for later segments that might also match + } else { + high = mid - 1; // Current time is before this segment + } + } + + return result; + }; + + const handleAudioTimeUpdate = (event) => { + const transcription = processedTranscription.value; + + if (!transcription || !transcription.isJson) { + return; + } + + const audioElement = event.target; + const currentTime = audioElement.currentTime; + + // Find the segment that contains the current time + const segments = transcription.simpleSegments || []; + + if (segments.length === 0) { + return; + } + + // Find the active segment index using binary search - O(log n) + const activeIndex = binarySearchSegment(segments, currentTime); + + // Only update if changed + if (activeIndex !== currentPlayingSegmentIndex.value) { + currentPlayingSegmentIndex.value = activeIndex; + + // Scroll to active segment if follow mode is enabled + if (followPlayerMode.value && activeIndex !== null) { + scrollToActiveSegment(activeIndex); + } + } + }; + + const scrollToActiveSegment = (segmentIndex) => { + // Find the active segment element + const segments = document.querySelectorAll('.transcript-segment[data-segment-index], .speaker-segment[data-segment-index], .speaker-bubble[data-segment-index]'); + if (segments[segmentIndex]) { + segments[segmentIndex].scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } + }; + + const toggleFollowPlayerMode = () => { + followPlayerMode.value = !followPlayerMode.value; + localStorage.setItem('followPlayerMode', followPlayerMode.value); + + if (followPlayerMode.value) { + showToast(t('messages.followPlayerEnabled'), 'fa-link'); + // Scroll to current position if we have an active segment + if (currentPlayingSegmentIndex.value !== null) { + scrollToActiveSegment(currentPlayingSegmentIndex.value); + } + } else { + showToast(t('messages.followPlayerDisabled'), 'fa-unlink'); + } + }; + + // --- Video Fullscreen --- + const handleFullscreenKeydown = (e) => { + if (e.key === 'Escape' || e.key === 'f') { + exitVideoFullscreen(); + } else if (e.key === ' ' || e.key === 'k') { + e.preventDefault(); + toggleAudioPlayback(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + seekAudioTo(audioCurrentTime.value - 10); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + seekAudioTo(audioCurrentTime.value + 10); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const audio = getAudioElement(); + if (audio) setAudioVolume(Math.min(1, audio.volume + 0.1)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + const audio = getAudioElement(); + if (audio) setAudioVolume(Math.max(0, audio.volume - 0.1)); + } + }; + + const resetFullscreenControlsTimer = () => { + fullscreenControlsVisible.value = true; + if (fullscreenControlsTimer.value) { + clearTimeout(fullscreenControlsTimer.value); + } + fullscreenControlsTimer.value = setTimeout(() => { + if (audioIsPlaying.value) { + fullscreenControlsVisible.value = false; + } + }, 3000); + }; + + const handleFullscreenMouseMove = () => { + resetFullscreenControlsTimer(); + }; + + const enterVideoFullscreen = () => { + videoFullscreen.value = true; + videoCollapsed.value = false; + fullscreenControlsVisible.value = true; + resetFullscreenControlsTimer(); + document.addEventListener('keydown', handleFullscreenKeydown); + document.body.style.overflow = 'hidden'; + }; + + const exitVideoFullscreen = () => { + videoFullscreen.value = false; + fullscreenControlsVisible.value = true; + if (fullscreenControlsTimer.value) { + clearTimeout(fullscreenControlsTimer.value); + fullscreenControlsTimer.value = null; + } + document.removeEventListener('keydown', handleFullscreenKeydown); + document.body.style.overflow = ''; + }; + + // --- Copy Functions --- + const animateCopyButton = (button) => { + if (!button) return; + const icon = button.querySelector('i'); + if (icon) { + const originalClass = icon.className; + icon.className = 'fas fa-check'; + setTimeout(() => { + icon.className = originalClass; + }, 2000); + } + }; + + const fallbackCopyTextToClipboard = (text) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + showToast(t('messages.copiedSuccessfully')); + } catch (err) { + showToast(t('messages.copyFailed'), 'fa-exclamation-circle'); + } + document.body.removeChild(textArea); + }; + + const copyTranscription = (event) => { + if (!selectedRecording.value || !selectedRecording.value.transcription) { + showToast(t('messages.noTranscriptionToCopy'), 'fa-exclamation-circle'); + return; + } + + const button = event?.currentTarget; + let textToCopy = ''; + + try { + const transcriptionData = JSON.parse(selectedRecording.value.transcription); + if (Array.isArray(transcriptionData)) { + const wasDiarized = transcriptionData.some(segment => segment.speaker); + if (wasDiarized) { + textToCopy = transcriptionData.map(segment => { + return `[${segment.speaker}]: ${segment.text || segment.sentence}`; + }).join('\n'); + } else { + textToCopy = transcriptionData.map(segment => segment.text || segment.sentence).join('\n'); + } + } else { + textToCopy = selectedRecording.value.transcription; + } + } catch (e) { + textToCopy = selectedRecording.value.transcription; + } + + animateCopyButton(button); + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(textToCopy) + .then(() => showToast(t('messages.transcriptionCopied'))) + .catch(() => fallbackCopyTextToClipboard(textToCopy)); + } else { + fallbackCopyTextToClipboard(textToCopy); + } + }; + + const copySummary = (event) => { + if (!selectedRecording.value || !selectedRecording.value.summary) { + showToast(t('messages.noSummaryToCopy'), 'fa-exclamation-circle'); + return; + } + const button = event?.currentTarget; + animateCopyButton(button); + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(selectedRecording.value.summary) + .then(() => showToast(t('messages.summaryCopied'))) + .catch(() => fallbackCopyTextToClipboard(selectedRecording.value.summary)); + } else { + fallbackCopyTextToClipboard(selectedRecording.value.summary); + } + }; + + const copyNotes = (event) => { + if (!selectedRecording.value || !selectedRecording.value.notes) { + showToast(t('messages.noNotesToCopy'), 'fa-exclamation-circle'); + return; + } + const button = event?.currentTarget; + animateCopyButton(button); + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(selectedRecording.value.notes) + .then(() => showToast(t('messages.notesCopied'))) + .catch(() => fallbackCopyTextToClipboard(selectedRecording.value.notes)); + } else { + fallbackCopyTextToClipboard(selectedRecording.value.notes); + } + }; + + // --- Download Functions --- + const downloadSummary = async () => { + if (!selectedRecording.value || !selectedRecording.value.summary) { + showToast(t('messages.noSummaryToDownload'), 'fa-exclamation-circle'); + return; + } + + try { + const response = await fetch(`/recording/${selectedRecording.value.id}/download/summary`); + if (!response.ok) { + const error = await response.json(); + showToast(error.error || t('messages.summaryDownloadFailed'), 'fa-exclamation-circle'); + return; + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'summary.docx'; + if (contentDisposition) { + const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition); + if (utf8Match) { + filename = decodeURIComponent(utf8Match[1]); + } else { + const regularMatch = /filename="(.+)"/.exec(contentDisposition); + if (regularMatch) { + filename = regularMatch[1]; + } + } + } + a.download = filename; + + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast(t('messages.summaryDownloadSuccess')); + } catch (error) { + showToast(t('messages.summaryDownloadFailed'), 'fa-exclamation-circle'); + } + }; + + + const downloadTranscriptWord = async () => { + if (!selectedRecording.value || !selectedRecording.value.transcription) { + showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle'); + return; + } + + try { + const response = await fetch(`/recording/${selectedRecording.value.id}/download/transcript/word`); + if (!response.ok) { + const error = await response.json(); + showToast(error.error || 'Erreur lors du téléchargement Word', 'fa-exclamation-circle'); + return; + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'transcript.docx'; + if (contentDisposition) { + const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition); + if (utf8Match) { + filename = decodeURIComponent(utf8Match[1]); + } else { + const regularMatch = /filename="(.+)"/.exec(contentDisposition); + if (regularMatch) { + filename = regularMatch[1]; + } + } + } + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast('Transcription Word téléchargée !'); + } catch (error) { + showToast('Erreur lors du téléchargement Word', 'fa-exclamation-circle'); + } + }; + + const downloadTranscript = async () => { + if (!selectedRecording.value || !selectedRecording.value.transcription) { + showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle'); + return; + } + + try { + // First, fetch available templates + const templatesResponse = await fetch('/api/transcript-templates'); + let templates = []; + if (templatesResponse.ok) { + templates = await templatesResponse.json(); + } + + // If there are templates, show a selection dialog + let templateId = null; + if (templates.length > 0) { + // Create a simple modal for template selection + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; + modal.innerHTML = ` + <div class="bg-[var(--bg-secondary)] rounded-lg p-6 max-w-md w-full mx-4"> + <h3 class="text-lg font-semibold mb-4">${t('transcriptTemplates.selectTemplate')}</h3> + <div class="space-y-2 max-h-60 overflow-y-auto"> + ${templates.map(tmpl => ` + <button class="template-option w-full text-left p-3 rounded border border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)] ${tmpl.is_default ? 'ring-2 ring-[var(--ring-focus)]' : ''}" data-template-id="${tmpl.id}"> + <div class="font-medium">${tmpl.name}</div> + ${tmpl.description ? `<div class="text-sm text-[var(--text-muted)]">${tmpl.description}</div>` : ''} + ${tmpl.is_default ? `<div class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>${t('transcriptTemplates.default')}</div>` : ''} + </button> + `).join('')} + </div> + <div class="mt-4 flex gap-2"> + <button class="cancel-btn px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-accent-light)]">${t('transcriptTemplates.cancel')}</button> + <button class="download-without-template-btn px-4 py-2 bg-[var(--bg-accent)] text-white rounded hover:bg-[var(--bg-accent-hover)]">${t('transcriptTemplates.downloadWithoutTemplate')}</button> + </div> + </div> + `; + document.body.appendChild(modal); + + // Wait for user selection + await new Promise((resolve) => { + modal.querySelectorAll('.template-option').forEach(btn => { + btn.addEventListener('click', () => { + templateId = btn.dataset.templateId; + modal.remove(); + resolve(); + }); + }); + + modal.querySelector('.cancel-btn').addEventListener('click', () => { + templateId = 'cancelled'; + modal.remove(); + resolve(); + }); + + modal.querySelector('.download-without-template-btn').addEventListener('click', () => { + templateId = 'none'; + modal.remove(); + resolve(); + }); + + modal.addEventListener('click', (e) => { + if (e.target === modal) { + templateId = 'cancelled'; + modal.remove(); + resolve(); + } + }); + }); + + if (templateId === null || templateId === undefined || templateId === 'cancelled') { + return; + } + } + + // If templateId is 'none', download raw transcript without any template + if (templateId === 'none') { + let rawText = ''; + try { + const transcriptionData = JSON.parse(selectedRecording.value.transcription); + if (Array.isArray(transcriptionData)) { + rawText = transcriptionData.map(segment => { + const speaker = segment.speaker || 'Unknown'; + const text = segment.sentence || ''; + return `${speaker}: ${text}`; + }).join('\n'); + } else { + rawText = selectedRecording.value.transcription; + } + } catch (e) { + rawText = selectedRecording.value.transcription; + } + + const blob = new Blob([rawText], { type: 'text/plain;charset=utf-8' }); + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = `${selectedRecording.value.title || 'transcript'}_raw.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + + showToast(t('messages.transcriptDownloadSuccess')); + return; + } + + // Download the transcript with the selected template + const url = templateId + ? `/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}` + : `/recording/${selectedRecording.value.id}/download/transcript`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(t('messages.transcriptDownloadFailed')); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get('content-disposition'); + let filename = 'transcript.txt'; + if (contentDisposition) { + const matches = contentDisposition.match(/filename="([^"]+)"/); + if (matches && matches[1]) { + filename = matches[1]; + } + } + + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + + showToast(t('messages.transcriptDownloadSuccess')); + } catch (error) { + console.error('Error downloading transcript:', error); + showToast(t('messages.transcriptDownloadFailed'), 'fa-exclamation-circle'); + } + }; + + // Download with default template (no modal) + const downloadWithDefaultTemplate = async () => { + if (!selectedRecording.value || !selectedRecording.value.transcription) { + showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle'); + return; + } + + try { + // Download using the default template (server will use user's default) + const response = await fetch(`/recording/${selectedRecording.value.id}/download/transcript`); + if (!response.ok) { + throw new Error(t('messages.transcriptDownloadFailed')); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get('content-disposition'); + let filename = 'transcript.txt'; + if (contentDisposition) { + const matches = contentDisposition.match(/filename="([^"]+)"/); + if (matches && matches[1]) { + filename = matches[1]; + } + } + + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + + showToast(t('messages.transcriptDownloadSuccess')); + } catch (error) { + console.error('Error downloading transcript:', error); + showToast(t('messages.transcriptDownloadFailed'), 'fa-exclamation-circle'); + } + }; + + // Show template selector modal (reuses the modal from downloadTranscript) + const showTemplateSelector = async () => { + // This calls the full downloadTranscript which shows the modal + await downloadTranscript(); + }; + + // Initialize UI settings from localStorage + const initializeUI = () => { + // Load saved column widths + const savedLeftWidth = localStorage.getItem('transcriptColumnWidth'); + const savedRightWidth = localStorage.getItem('summaryColumnWidth'); + if (savedLeftWidth && savedRightWidth) { + leftColumnWidth.value = parseFloat(savedLeftWidth); + rightColumnWidth.value = parseFloat(savedRightWidth); + } + + // Load saved transcription view mode + const savedViewMode = localStorage.getItem('transcriptionViewMode'); + if (savedViewMode) { + transcriptionViewMode.value = savedViewMode; + } + + // Load saved player volume + const savedVolume = localStorage.getItem('playerVolume'); + if (savedVolume) { + playerVolume.value = parseFloat(savedVolume); + } + + // Load saved follow player mode + const savedFollowMode = localStorage.getItem('followPlayerMode'); + if (savedFollowMode !== null) { + followPlayerMode.value = savedFollowMode === 'true'; + } + + // Load saved playback rate + initializePlaybackRate(); + + // Watch for recording changes to reset active segment and audio player state + watch(selectedRecording, (newRecording) => { + if (videoFullscreen.value) exitVideoFullscreen(); + currentPlayingSegmentIndex.value = null; + resetAudioPlayerState(); + // Use server-side duration if available (more reliable than browser metadata) + if (newRecording && newRecording.audio_duration) { + audioDuration.value = newRecording.audio_duration; + } + }); + + // Set up global click handler to close dropdowns when clicking outside + setupGlobalClickHandler(); + }; + + /** + * Set up a global click handler to close all dropdowns when clicking outside + * This provides elegant UX by closing menus when users click elsewhere + */ + const setupGlobalClickHandler = () => { + document.addEventListener('click', (event) => { + const target = event.target; + + // Close user menu if clicking outside of it + if (isUserMenuOpen.value) { + const userMenuButton = target.closest('[data-user-menu-toggle]'); + const userMenuDropdown = target.closest('[data-user-menu-dropdown]'); + + if (!userMenuButton && !userMenuDropdown) { + isUserMenuOpen.value = false; + } + } + + // Close sort options if clicking outside + if (showSortOptions.value) { + const sortButton = target.closest('[data-sort-toggle]'); + const sortDropdown = target.closest('[data-sort-dropdown]'); + + if (!sortButton && !sortDropdown) { + showSortOptions.value = false; + } + } + + // Close download menu if clicking outside + if (showDownloadMenu.value) { + const downloadButton = target.closest('[data-download-toggle]'); + const downloadDropdown = target.closest('[data-download-dropdown]'); + + if (!downloadButton && !downloadDropdown) { + showDownloadMenu.value = false; + } + } + + // Close language menu if clicking outside + if (state.showLanguageMenu && state.showLanguageMenu.value) { + const languageButton = target.closest('[data-language-toggle]'); + const languageDropdown = target.closest('[data-language-dropdown]'); + + if (!languageButton && !languageDropdown) { + state.showLanguageMenu.value = false; + } + } + + // Close speed menu if clicking outside + if (showSpeedMenu.value) { + const speedButton = target.closest('[data-speed-toggle]'); + const speedDropdown = target.closest('[data-speed-dropdown]'); + + if (!speedButton && !speedDropdown) { + showSpeedMenu.value = false; + } + } + }); + }; + + // Initialize recording notes markdown editor + const initializeRecordingNotesEditor = () => { + if (!recordingNotesEditor.value) return; + + // Destroy existing instance if any + if (recordingMarkdownEditorInstance.value) { + recordingMarkdownEditorInstance.value.toTextArea(); + recordingMarkdownEditorInstance.value = null; + } + + try { + recordingMarkdownEditorInstance.value = new EasyMDE({ + element: recordingNotesEditor.value, + spellChecker: false, + autofocus: false, + placeholder: t('form.enterNotesMarkdown'), + initialValue: recordingNotes.value || '', + status: false, + toolbar: [ + "bold", "italic", "heading", "|", + "quote", "unordered-list", "ordered-list", "|", + "link", "|", + "preview", "side-by-side", "fullscreen" + ], + previewClass: ["editor-preview", "notes-preview"], + theme: isDarkMode.value ? "dark" : "light" + }); + + // Sync changes back to recordingNotes + recordingMarkdownEditorInstance.value.codemirror.on('change', () => { + recordingNotes.value = recordingMarkdownEditorInstance.value.value(); + }); + } catch (error) { + console.error('Failed to initialize recording notes markdown editor:', error); + } + }; + + // Destroy recording notes markdown editor + const destroyRecordingNotesEditor = () => { + if (recordingMarkdownEditorInstance.value) { + // Save current value before destroying + recordingNotes.value = recordingMarkdownEditorInstance.value.value(); + recordingMarkdownEditorInstance.value.toTextArea(); + recordingMarkdownEditorInstance.value = null; + } + }; + + // ========================================= + // Participants Modal + // ========================================= + + const openParticipantsModal = async () => { + if (!selectedRecording.value) return; + + // Parse current participants into array + const participants = selectedRecording.value.participants + ? selectedRecording.value.participants.split(',').map(p => p.trim()).filter(Boolean) + : []; + + state.editingParticipantsList.value = participants.map(name => ({ name })); + + // Fetch speakers from database for autocomplete + try { + const response = await fetch('/speakers'); + if (response.ok) { + const speakers = await response.json(); + state.allParticipants.value = speakers.map(s => s.name).sort(); + } + } catch (e) { + console.error('Failed to fetch speakers:', e); + state.allParticipants.value = []; + } + + state.editingParticipantSuggestions.value = {}; + state.showEditParticipantsModal.value = true; + }; + + const closeEditParticipantsModal = () => { + state.showEditParticipantsModal.value = false; + state.editingParticipantsList.value = []; + }; + + const addParticipant = () => { + state.editingParticipantsList.value.push({ name: '' }); + }; + + const removeParticipant = (index) => { + state.editingParticipantsList.value.splice(index, 1); + delete state.editingParticipantSuggestions.value[index]; + }; + + const filterParticipantSuggestions = (index) => { + // Close all other dropdowns first + closeAllParticipantSuggestions(); + + const query = state.editingParticipantsList.value[index]?.name?.toLowerCase().trim() || ''; + if (query === '') { + // Show all participants when field is empty/focused + state.editingParticipantSuggestions.value[index] = [...state.allParticipants.value]; + } else { + state.editingParticipantSuggestions.value[index] = state.allParticipants.value.filter( + p => p.toLowerCase().includes(query) + ); + } + }; + + const selectParticipantSuggestion = (index, name) => { + state.editingParticipantsList.value[index].name = name; + state.editingParticipantSuggestions.value[index] = []; + }; + + const closeParticipantSuggestions = (index) => { + state.editingParticipantSuggestions.value[index] = []; + }; + + const closeParticipantSuggestionsDelayed = (index) => { + setTimeout(() => closeParticipantSuggestions(index), 200); + }; + + const closeAllParticipantSuggestions = () => { + state.editingParticipantSuggestions.value = {}; + }; + + const getParticipantDropdownPosition = (index) => { + // Find the input element for this index and calculate position + const inputs = document.querySelectorAll('.max-w-md input[placeholder="' + t('form.participantNamePlaceholder') + '"]'); + if (inputs[index]) { + const rect = inputs[index].getBoundingClientRect(); + return { + top: rect.bottom + 2 + 'px', + left: rect.left + 'px', + width: rect.width + 'px' + }; + } + return { top: '0px', left: '0px', width: '200px' }; + }; + + const saveParticipants = async () => { + if (!selectedRecording.value) return; + + // Join participant names with comma + const participantsString = state.editingParticipantsList.value + .map(p => p.name.trim()) + .filter(Boolean) + .join(', '); + + // Update the recording + selectedRecording.value.participants = participantsString; + + // Use the same save endpoint as inline editing + const fullPayload = { + id: selectedRecording.value.id, + title: selectedRecording.value.title, + participants: selectedRecording.value.participants, + notes: selectedRecording.value.notes, + summary: selectedRecording.value.summary, + meeting_date: selectedRecording.value.meeting_date + }; + + try { + const response = await fetch('/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken.value + }, + body: JSON.stringify(fullPayload) + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.error || t('messages.failedToSaveParticipants')); + + showToast(t('common.changesSaved'), 'fa-check-circle'); + closeEditParticipantsModal(); + } catch (error) { + console.error('Save error:', error); + utils.setGlobalError(t('messages.saveParticipantsFailed', { error: error.message })); + } + }; + + return { + // Initialization + initializeUI, + toggleDarkMode, + initializeDarkMode, + applyColorScheme, + initializeColorScheme, + openColorSchemeModal, + closeColorSchemeModal, + selectColorScheme, + resetColorScheme, + toggleSidebar, + initializeSidebar, + switchToUploadView, + switchToDetailView, + switchToRecordingView, + setGlobalError, + formatFileSize, + formatDisplayDate, + formatShortDate, + formatStatus, + getStatusClass, + formatTime, + formatDuration, + formatProcessingDuration, + // Inline editing + toggleEditTitle, + saveTitle, + cancelEditTitle, + toggleEditParticipants, + toggleEditMeetingDate, + toggleEditSummary, + cancelEditSummary, + saveEditSummary, + initializeSummaryMarkdownEditor, + autoSaveSummary, + toggleEditNotes, + cancelEditNotes, + saveEditNotes, + initializeMarkdownEditor, + autoSaveNotes, + clickToEditNotes, + clickToEditSummary, + // Recording notes editor + initializeRecordingNotesEditor, + destroyRecordingNotesEditor, + downloadNotes, + downloadEventICS, + downloadICS, + deleteEvent, + formatEventDateTime, + // View mode + toggleTranscriptionViewMode, + // Column resizing + startColumnResize, + // Audio player + seekAudio, + seekAudioFromEvent, + onPlayerVolumeChange, + handleAudioTimeUpdate, + toggleFollowPlayerMode, + scrollToActiveSegment, + // Custom audio player + toggleAudioPlayback, + toggleAudioMute, + setAudioVolume, + seekAudioTo, + seekAudioByPercent, + startProgressDrag, + handleAudioPlayPause, + handleAudioLoadedMetadata, + handleAudioEnded, + handleCustomAudioTimeUpdate, + handleAudioWaiting, + handleAudioCanPlay, + handleAudioDurationChange, + formatAudioTime, + audioProgressPercent, + displayCurrentTime, + isDraggingProgress, + audioIsPlaying, + audioCurrentTime, + audioDuration, + audioIsMuted, + audioIsLoading, + resetAudioPlayerState, + // Playback speed + formatPlaybackRate, + setPlaybackRate, + cyclePlaybackRate, + cycleModalPlaybackRate, + updateSpeedMenuPosition, + // Video fullscreen + enterVideoFullscreen, + exitVideoFullscreen, + handleFullscreenMouseMove, + resetFullscreenControlsTimer, + // Copy functions + copyTranscription, + copySummary, + copyNotes, + // Download functions + downloadSummary, + downloadTranscript, + downloadTranscriptWord, + downloadWithDefaultTemplate, + showTemplateSelector, + // Participants modal + openParticipantsModal, + closeEditParticipantsModal, + addParticipant, + removeParticipant, + filterParticipantSuggestions, + selectParticipantSuggestion, + closeParticipantSuggestions, + closeParticipantSuggestionsDelayed, + closeAllParticipantSuggestions, + getParticipantDropdownPosition, + saveParticipants, + // Computed + isMobile + }; +} diff --git a/static/js/modules/composables/upload.js b/static/js/modules/composables/upload.js new file mode 100644 index 0000000..98ab448 --- /dev/null +++ b/static/js/modules/composables/upload.js @@ -0,0 +1,824 @@ +/** + * Upload management composable + * Handles file uploads, queue processing, and progress tracking + */ + +import * as FailedUploads from '../db/failed-uploads.js'; +import * as IncognitoStorage from '../db/incognito-storage.js'; + +// Parse error message and return friendly error info +function getFriendlyError(errorMessage, t) { + const _t = t || ((key) => key); + if (!errorMessage) return { title: _t('errors.processingError'), message: _t('errors.processingErrorMessage') }; + const lowerText = errorMessage.toLowerCase(); + const patterns = [ + { patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], title: _t('errors.fileTooLargeTitle'), guidance: _t('errors.enableChunkingGuidance') }, + { patterns: ['timed out', 'timeout', 'deadline exceeded'], title: _t('errors.processingTimeout'), guidance: _t('errors.splitAudioGuidance') }, + { patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], title: _t('errors.authenticationError'), guidance: _t('errors.checkApiKeyGuidance') }, + { patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], title: _t('errors.rateLimitExceeded'), guidance: _t('errors.waitAndRetryGuidance') }, + { patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], title: _t('errors.connectionError'), guidance: _t('errors.checkNetworkGuidance') }, + { patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], title: _t('errors.serviceUnavailable'), guidance: _t('errors.tryAgainLaterGuidance') }, + { patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt'], title: _t('errors.invalidAudioFormat'), guidance: _t('errors.convertFormatGuidance') }, + { patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], title: _t('errors.audioExtractionFailed'), guidance: _t('errors.convertStandardGuidance') }, + ]; + for (const pattern of patterns) { + for (const p of pattern.patterns) { + if (lowerText.includes(p)) return { title: pattern.title, guidance: pattern.guidance }; + } + } + return { title: _t('errors.processingError'), guidance: _t('errors.processingErrorFallbackGuidance') }; +} + +export function useUpload(state, utils) { + const { + uploadQueue, currentlyProcessingFile, processingProgress, processingMessage, + isProcessingActive, pollInterval, progressPopupMinimized, progressPopupClosed, + maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, maxConcurrentUploads, + recordings, selectedRecording, totalRecordings, globalError, + selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt, + useAsrEndpoint, connectorSupportsDiarization, asrLanguage, asrMinSpeakers, asrMaxSpeakers, + dragover, availableTags, uploadTagSearchFilter, + // Folder state + availableFolders, selectedFolderId, + // Incognito mode state + incognitoMode, incognitoRecording, incognitoProcessing, + // View state + currentView, + // Upload disclaimer state + uploadDisclaimer, showUploadDisclaimerModal + } = state; + + const { computed, nextTick, ref } = Vue; + + const { setGlobalError, showToast, formatFileSize, onChatComplete, t } = utils; + + // Compute selected tags from IDs + const selectedTags = computed(() => { + return selectedTagIds.value.map(id => + availableTags.value.find(t => t.id === id) + ).filter(Boolean); + }); + + // --- Tag Drag-and-Drop State --- + const draggedTagIndex = ref(null); + const dragOverTagIndex = ref(null); + + // Reorder selectedTagIds array + const reorderSelectedTags = (fromIndex, toIndex) => { + const tagIds = [...selectedTagIds.value]; + const [removed] = tagIds.splice(fromIndex, 1); + tagIds.splice(toIndex, 0, removed); + selectedTagIds.value = tagIds; + applyTagDefaults(); // Re-apply defaults since first tag may have changed + }; + + // === MOUSE DRAG HANDLERS === + const handleTagDragStart = (index, event) => { + draggedTagIndex.value = index; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', index.toString()); + }; + + const handleTagDragOver = (index, event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + dragOverTagIndex.value = index; + }; + + const handleTagDrop = (targetIndex, event) => { + event.preventDefault(); + if (draggedTagIndex.value !== null && draggedTagIndex.value !== targetIndex) { + reorderSelectedTags(draggedTagIndex.value, targetIndex); + } + draggedTagIndex.value = null; + dragOverTagIndex.value = null; + }; + + const handleTagDragEnd = () => { + draggedTagIndex.value = null; + dragOverTagIndex.value = null; + }; + + // === TOUCH HANDLERS (Mobile) === + let touchStartIndex = null; + + const handleTagTouchStart = (index, event) => { + touchStartIndex = index; + draggedTagIndex.value = index; + }; + + const handleTagTouchMove = (event) => { + if (touchStartIndex === null) return; + event.preventDefault(); + + const touch = event.touches[0]; + const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); + const tagElement = elementBelow?.closest('[data-tag-index]'); + + if (tagElement) { + const targetIndex = parseInt(tagElement.dataset.tagIndex); + dragOverTagIndex.value = targetIndex; + } + }; + + const handleTagTouchEnd = () => { + if (touchStartIndex !== null && dragOverTagIndex.value !== null && + touchStartIndex !== dragOverTagIndex.value) { + reorderSelectedTags(touchStartIndex, dragOverTagIndex.value); + } + touchStartIndex = null; + draggedTagIndex.value = null; + dragOverTagIndex.value = null; + }; + + // Handle drag events + const handleDragOver = (e) => { + e.preventDefault(); + dragover.value = true; + }; + + const handleDragLeave = (e) => { + if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) { + return; + } + dragover.value = false; + }; + + const handleDrop = (e) => { + e.preventDefault(); + dragover.value = false; + addFilesToQueue(e.dataTransfer.files); + }; + + const handleFileSelect = (e) => { + addFilesToQueue(e.target.files); + e.target.value = null; + }; + + // Add files to the upload queue + const addFilesToQueue = (files) => { + let filesAdded = 0; + for (const file of files) { + const fileObject = file.file ? file.file : file; + const notes = file.notes || null; + const tags = file.tags || selectedTags.value || []; + const asrOptions = file.asrOptions || { + language: asrLanguage.value, + min_speakers: asrMinSpeakers.value, + max_speakers: asrMaxSpeakers.value + }; + + // Check if it's an audio file or video container with audio + const isAudioFile = fileObject && ( + fileObject.type.startsWith('audio/') || + fileObject.type === 'video/mp4' || + fileObject.type === 'video/quicktime' || + fileObject.type === 'video/x-msvideo' || + fileObject.type === 'video/webm' || + fileObject.name.toLowerCase().endsWith('.amr') || + fileObject.name.toLowerCase().endsWith('.3gp') || + fileObject.name.toLowerCase().endsWith('.3gpp') || + fileObject.name.toLowerCase().endsWith('.mp4') || + fileObject.name.toLowerCase().endsWith('.mov') || + fileObject.name.toLowerCase().endsWith('.avi') || + fileObject.name.toLowerCase().endsWith('.mkv') || + fileObject.name.toLowerCase().endsWith('.webm') || + fileObject.name.toLowerCase().endsWith('.weba') + ); + + if (isAudioFile) { + // Only check general file size limit + if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) { + setGlobalError(t('upload.fileExceedsMaxSize', { name: fileObject.name, size: maxFileSizeMB.value })); + continue; + } + + const clientId = `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + uploadQueue.value.push({ + file: fileObject, + notes: notes, + tags: tags, + asrOptions: asrOptions, + status: 'queued', + recordingId: null, + clientId: clientId, + error: null, + willAutoSummarize: false // Server will tell us via SUMMARIZING status + }); + filesAdded++; + } else if (fileObject) { + setGlobalError(t('upload.invalidFileType', { name: fileObject.name })); + } + } + if (filesAdded > 0) { + console.log(`Added ${filesAdded} file(s) to the queue.`); + } + }; + + // Remove a file from the queue before processing starts + const removeFromQueue = (clientId) => { + const index = uploadQueue.value.findIndex(item => item.clientId === clientId); + if (index !== -1 && (uploadQueue.value[index].status === 'queued' || uploadQueue.value[index].status === 'ready')) { + uploadQueue.value.splice(index, 1); + console.log(`Removed file from queue: ${clientId}`); + } + }; + + // Cancel a waiting file from the upload progress queue + const cancelWaitingFile = (clientId) => { + const index = uploadQueue.value.findIndex(item => item.clientId === clientId); + if (index !== -1 && uploadQueue.value[index].status === 'ready') { + uploadQueue.value.splice(index, 1); + console.log(`Cancelled waiting file: ${clientId}`); + showToast(t('upload.fileRemovedFromQueue'), 'fa-trash'); + } + }; + + // Clear completed uploads from queue + const clearCompletedUploads = () => { + uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status)); + }; + + // Start processing all queued files + const startUpload = () => { + const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued'); + if (pendingFiles.length === 0) { + return; + } + // Show upload disclaimer if configured + if (uploadDisclaimer.value && uploadDisclaimer.value.trim() !== '') { + showUploadDisclaimerModal.value = true; + return; + } + // Update all queued files with current tags and ASR options + // AND change their status to 'ready' so they move to upload progress immediately + for (const item of uploadQueue.value) { + if (item.status === 'queued') { + if (!item.preserveOptions) { + // For file uploads: use current UI selection (user may have changed tags after dropping) + item.tags = [...selectedTags.value]; + item.asrOptions = { + language: asrLanguage.value, + min_speakers: asrMinSpeakers.value, + max_speakers: asrMaxSpeakers.value, + hotwords: uploadHotwords.value, + initial_prompt: uploadInitialPrompt.value, + }; + item.folder_id = selectedFolderId.value; + } + // Change status to 'ready' to remove from upload view but keep in queue + item.status = 'ready'; + } + } + progressPopupMinimized.value = false; + progressPopupClosed.value = false; + startProcessingQueue(); + }; + + // --- Parallel Upload System --- + // Concurrency limiter: configurable via MAX_CONCURRENT_UPLOADS env var (default 3) + let activeUploadCount = 0; + const pendingUploadQueue = []; // Functions waiting for a slot + + const acquireUploadSlot = () => { + return new Promise(resolve => { + if (activeUploadCount < (maxConcurrentUploads?.value || 3)) { + activeUploadCount++; + resolve(); + } else { + pendingUploadQueue.push(resolve); + } + }); + }; + + const releaseUploadSlot = () => { + activeUploadCount--; + if (pendingUploadQueue.length > 0) { + activeUploadCount++; + const next = pendingUploadQueue.shift(); + next(); + } + // When all uploads are done, clear processing active flag + const stillUploading = uploadQueue.value.some(item => + ['uploading', 'ready'].includes(item.status) + ); + if (!stillUploading) { + isProcessingActive.value = false; + } + }; + + const resetCurrentFileProcessingState = () => { + if (pollInterval.value) clearInterval(pollInterval.value); + pollInterval.value = null; + currentlyProcessingFile.value = null; + processingProgress.value = 0; + processingMessage.value = ''; + }; + + /** + * Upload a single file to the server. + * Acquires a concurrency slot, uploads, then releases. + * Status updates are per-item (no global processingProgress). + */ + const uploadSingleFile = async (fileItem) => { + await acquireUploadSlot(); + + fileItem.status = 'uploading'; + fileItem.progress = 5; + + try { + const formData = new FormData(); + formData.append('file', fileItem.file); + + // Send file's lastModified timestamp for meeting_date + if (fileItem.file.lastModified) { + const lastModified = fileItem.file.lastModified; + formData.append('file_last_modified', lastModified.toString()); + } + + if (fileItem.notes) { + formData.append('notes', fileItem.notes); + } + + // Add tags if selected + const tagsToUse = fileItem.tags || selectedTags.value || []; + tagsToUse.forEach((tag, index) => { + const tagId = tag.id || tag; + formData.append(`tag_ids[${index}]`, tagId); + }); + + // Add folder if selected + const folderToUse = fileItem.folder_id || selectedFolderId.value; + if (folderToUse) { + formData.append('folder_id', folderToUse); + } + + // Add ASR options + const asrOpts = fileItem.asrOptions || {}; + const language = asrOpts.language || uploadLanguage.value; + if (language) { + formData.append('language', language); + } + + if (connectorSupportsDiarization.value) { + const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value; + const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value; + + if (minSpeakers && minSpeakers !== '') { + formData.append('min_speakers', minSpeakers.toString()); + } + if (maxSpeakers && maxSpeakers !== '') { + formData.append('max_speakers', maxSpeakers.toString()); + } + } + + // Add hotwords and initial prompt + const hotwords = asrOpts.hotwords || uploadHotwords.value; + const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value; + if (hotwords && hotwords.trim()) { + formData.append('hotwords', hotwords.trim()); + } + if (initialPrompt && initialPrompt.trim()) { + formData.append('initial_prompt', initialPrompt.trim()); + } + + // Refresh CSRF token before upload (prevents stale token after sleep/idle) + let csrfToken; + if (window.csrfManager) { + try { + csrfToken = await window.csrfManager.refreshToken(); + } catch (e) { + csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + } + } else { + csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + } + + // Use XMLHttpRequest for per-file upload progress + const data = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + // Map upload progress to 5-90% range + fileItem.progress = Math.round(5 + (e.loaded / e.total) * 85); + } + }; + + xhr.onload = () => { + const contentType = xhr.getResponseHeader('content-type') || ''; + if (!contentType.includes('application/json')) { + const titleMatch = xhr.responseText.match(/<title>([^<]+)<\/title>/i); + const h1Match = xhr.responseText.match(/<h1>([^<]+)<\/h1>/i); + reject(new Error(titleMatch?.[1] || h1Match?.[1] || + `Server error (${xhr.status}): Response was not JSON`)); + return; + } + + let parsed; + try { + parsed = JSON.parse(xhr.responseText); + } catch { + reject(new Error(`Invalid JSON response (${xhr.status})`)); + return; + } + + if (xhr.status === 202 && parsed.id) { + resolve(parsed); + } else if (!String(xhr.status).startsWith('2')) { + let errorMsg = parsed.error || `Upload failed with status ${xhr.status}`; + if (xhr.status === 413) errorMsg = parsed.error || `File too large. Max: ${parsed.max_size_mb?.toFixed(0) || maxFileSizeMB.value} MB.`; + reject(new Error(errorMsg)); + } else { + reject(new Error('Unexpected success response from server after upload.')); + } + }; + + xhr.onerror = () => reject(new Error('Network error during upload')); + xhr.ontimeout = () => reject(new Error('Upload timed out')); + + // Store abort controller on item for cancellation + fileItem._xhr = xhr; + + xhr.open('POST', '/upload'); + if (csrfToken) { + xhr.setRequestHeader('X-CSRFToken', csrfToken); + } + xhr.send(formData); + }); + + // Upload succeeded - recording is now on the server + console.log(`File ${fileItem.file.name} uploaded. Recording ID: ${data.id}. Server will process via job queue.`); + fileItem.status = 'pending'; + fileItem.recordingId = data.id; + fileItem.progress = 100; + + // Add to recordings list + recordings.value.unshift(data); + totalRecordings.value++; + + // Clear recording session only after confirmed upload + if (fileItem.onUploadSuccess) { + await fileItem.onUploadSuccess(); + } + + // Handle duplicate warning + if (data.duplicate_warning) { + const warning = data.duplicate_warning; + const existingDate = warning.existing_created_at + ? new Date(warning.existing_created_at).toLocaleDateString() + : ''; + const existingName = warning.existing_title || 'Unknown'; + showToast( + `⚠️ ${existingName} (${existingDate})`, + 'fa-copy' + ); + fileItem.duplicateWarning = warning; + } + + } catch (error) { + console.error(`Upload Error for ${fileItem.file.name} (Client ID: ${fileItem.clientId}):`, error); + fileItem.status = 'failed'; + fileItem.error = error.message; + fileItem.progress = 0; + + // Show friendly error message + const friendlyErr = getFriendlyError(error.message, t); + setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`); + + // Store failed upload in IndexedDB for background sync retry + try { + await FailedUploads.storeFailedUpload({ + file: fileItem.file, + fileName: fileItem.file.name, + fileSize: fileItem.file.size, + clientId: fileItem.clientId, + notes: fileItem.notes, + tags: fileItem.tags, + asrOptions: fileItem.asrOptions, + error: error.message + }); + + if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { + const registration = await navigator.serviceWorker.ready; + await registration.sync.register('sync-uploads'); + console.log('[Upload] Registered background sync for failed upload'); + } + } catch (syncError) { + console.warn('[Upload] Failed to register background sync:', syncError); + } + } finally { + fileItem._xhr = null; + releaseUploadSlot(); + } + }; + + /** + * Start uploading all ready files in parallel (with concurrency limit). + * Processing status is tracked via allJobs polling in app.modular.js. + */ + const startProcessingQueue = async () => { + const readyItems = uploadQueue.value.filter(item => item.status === 'ready'); + if (readyItems.length === 0) { + console.log("No files ready to upload."); + return; + } + + isProcessingActive.value = true; + console.log(`Starting parallel upload of ${readyItems.length} file(s) (max ${maxConcurrentUploads?.value || 3} concurrent)...`); + + // Fire off all uploads concurrently (semaphore handles limiting) + const uploadPromises = readyItems.map(item => uploadSingleFile(item)); + // Don't await - let them run in background. isProcessingActive is cleared by releaseUploadSlot. + Promise.allSettled(uploadPromises).then(() => { + console.log('All uploads settled.'); + }); + }; + + // Keep backward-compat aliases + const startStatusPolling = (fileItem, recordingId) => { + // No longer needed - allJobs polling handles status tracking + fileItem.recordingId = recordingId; + }; + + const pollProcessingStatus = () => { + // No-op: status tracking is now handled by allJobs polling in app.modular.js + }; + + // Tag selection helpers + const addTagToSelection = (tagId) => { + if (!selectedTagIds.value.includes(tagId)) { + selectedTagIds.value.push(tagId); + applyTagDefaults(); + } + }; + + const removeTagFromSelection = (tagId) => { + const index = selectedTagIds.value.indexOf(tagId); + if (index > -1) { + selectedTagIds.value.splice(index, 1); + applyTagDefaults(); + } + }; + + const applyTagDefaults = () => { + const selectedTagsObjects = selectedTagIds.value.map(tagId => + availableTags.value.find(tag => tag.id == tagId) + ).filter(Boolean); + + const firstTag = selectedTagsObjects[0]; + if (firstTag && connectorSupportsDiarization.value) { + if (firstTag.default_language) { + uploadLanguage.value = firstTag.default_language; + } + if (firstTag.default_min_speakers) { + uploadMinSpeakers.value = firstTag.default_min_speakers; + } + if (firstTag.default_max_speakers) { + uploadMaxSpeakers.value = firstTag.default_max_speakers; + } + } + // Apply hotwords/initial_prompt from first tag (works for all connectors) + if (firstTag) { + if (firstTag.default_hotwords) { + uploadHotwords.value = firstTag.default_hotwords; + } + if (firstTag.default_initial_prompt) { + uploadInitialPrompt.value = firstTag.default_initial_prompt; + } + } + }; + + // Computed property for filtered available tags in upload view + const filteredAvailableTagsForUpload = computed(() => { + const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id)); + if (!uploadTagSearchFilter.value) return availableForSelection; + + const filter = uploadTagSearchFilter.value.toLowerCase(); + return availableForSelection.filter(tag => + tag.name.toLowerCase().includes(filter) + ); + }); + + // === INCOGNITO MODE FUNCTIONS === + + /** + * Upload and process a file in incognito mode. + * The file is processed synchronously and no data is saved to the database. + * Results are stored only in sessionStorage. + */ + const startIncognitoUpload = async () => { + const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued'); + if (pendingFiles.length === 0) { + return; + } + + // Only process the first file for incognito mode + const fileItem = pendingFiles[0]; + + // Check if incognito mode state is available + if (!incognitoMode || !incognitoProcessing || !incognitoRecording) { + console.warn('[Incognito] Incognito state not available, falling back to normal upload'); + startUpload(); + return; + } + + incognitoProcessing.value = true; + processingMessage.value = t('incognito.processingInProgress'); + processingProgress.value = 10; + progressPopupMinimized.value = false; + progressPopupClosed.value = false; + + try { + const formData = new FormData(); + formData.append('file', fileItem.file); + + // Add ASR options + const asrOpts = fileItem.asrOptions || {}; + const language = asrOpts.language || uploadLanguage.value; + const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value; + const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value; + + if (language) { + formData.append('language', language); + } + if (minSpeakers && minSpeakers !== '') { + formData.append('min_speakers', minSpeakers.toString()); + } + if (maxSpeakers && maxSpeakers !== '') { + formData.append('max_speakers', maxSpeakers.toString()); + } + + const hotwords = asrOpts.hotwords || uploadHotwords.value; + const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value; + if (hotwords && hotwords.trim()) { + formData.append('hotwords', hotwords.trim()); + } + if (initialPrompt && initialPrompt.trim()) { + formData.append('initial_prompt', initialPrompt.trim()); + } + + // Request auto-summarization + formData.append('auto_summarize', 'true'); + + processingMessage.value = t('incognito.uploadingFile'); + processingProgress.value = 20; + + console.log('[Incognito] Uploading file:', fileItem.file.name); + + const response = await fetch('/api/recordings/incognito', { + method: 'POST', + body: formData + }); + + processingProgress.value = 50; + + // Parse response + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const text = await response.text(); + const titleMatch = text.match(/<title>([^<]+)<\/title>/i); + throw new Error(titleMatch?.[1] || `Server error (${response.status})`); + } + + const data = await response.json(); + + if (!response.ok || data.error) { + throw new Error(data.error || `Processing failed with status ${response.status}`); + } + + processingProgress.value = 80; + processingMessage.value = t('incognito.processingComplete'); + + // Store result in sessionStorage + const incognitoData = { + id: 'incognito', + incognito: true, + title: data.title || t('incognito.recordingTitle'), + transcription: data.transcription, + summary: data.summary, + summary_html: data.summary_html, + created_at: data.created_at, + original_filename: data.original_filename, + file_size: data.file_size, + audio_duration_seconds: data.audio_duration_seconds, + processing_time_seconds: data.processing_time_seconds, + status: 'COMPLETED' + }; + + IncognitoStorage.saveIncognitoRecording(incognitoData); + incognitoRecording.value = incognitoData; + + // Remove the processed file from queue + const index = uploadQueue.value.findIndex(item => item.clientId === fileItem.clientId); + if (index !== -1) { + uploadQueue.value.splice(index, 1); + } + + processingProgress.value = 100; + processingMessage.value = t('incognito.recordingReady'); + + // Auto-select the incognito recording and switch to detail view + selectedRecording.value = incognitoData; + currentView.value = 'detail'; + + // Show toast + showToast(t('incognito.recordingProcessed'), 'fa-user-secret'); + + console.log('[Incognito] Processing complete'); + + } catch (error) { + console.error('[Incognito] Processing failed:', error); + const friendlyErr = getFriendlyError(error.message, t); + setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`); + fileItem.status = 'failed'; + fileItem.error = error.message; + } finally { + incognitoProcessing.value = false; + processingProgress.value = 0; + processingMessage.value = ''; + } + }; + + /** + * Clear the incognito recording with confirmation + */ + const clearIncognitoRecordingWithConfirm = () => { + if (incognitoRecording && incognitoRecording.value) { + if (confirm(t('incognito.discardConfirm'))) { + IncognitoStorage.clearIncognitoRecording(); + incognitoRecording.value = null; + // If the incognito recording was selected, clear selection + if (selectedRecording.value?.id === 'incognito') { + selectedRecording.value = null; + } + showToast(t('incognito.recordingDiscarded'), 'fa-trash'); + } + } + }; + + /** + * Select the incognito recording for viewing + */ + const selectIncognitoRecording = () => { + if (incognitoRecording && incognitoRecording.value) { + selectedRecording.value = incognitoRecording.value; + currentView.value = 'detail'; + } + }; + + /** + * Load incognito recording from sessionStorage on app init + */ + const loadIncognitoRecording = () => { + const stored = IncognitoStorage.getIncognitoRecording(); + if (stored && incognitoRecording) { + incognitoRecording.value = stored; + console.log('[Incognito] Loaded recording from sessionStorage'); + } + }; + + /** + * Check if there's an incognito recording (for navigation guards) + */ + const hasIncognitoRecording = () => { + return IncognitoStorage.hasIncognitoRecording(); + }; + + return { + handleDragOver, + handleDragLeave, + handleDrop, + handleFileSelect, + addFilesToQueue, + removeFromQueue, + cancelWaitingFile, + clearCompletedUploads, + startUpload, + startProcessingQueue, + resetCurrentFileProcessingState, + startStatusPolling, + pollProcessingStatus, + addTagToSelection, + removeTagFromSelection, + applyTagDefaults, + filteredAvailableTagsForUpload, + // Tag drag-and-drop + draggedTagIndex, + dragOverTagIndex, + handleTagDragStart, + handleTagDragOver, + handleTagDrop, + handleTagDragEnd, + handleTagTouchStart, + handleTagTouchMove, + handleTagTouchEnd, + // Incognito mode + startIncognitoUpload, + clearIncognitoRecordingWithConfirm, + selectIncognitoRecording, + loadIncognitoRecording, + hasIncognitoRecording + }; +} diff --git a/static/js/modules/composables/virtualScroll.js b/static/js/modules/composables/virtualScroll.js new file mode 100644 index 0000000..620feed --- /dev/null +++ b/static/js/modules/composables/virtualScroll.js @@ -0,0 +1,204 @@ +/** + * Virtual Scrolling Composable + * + * Renders only visible items plus a buffer for smooth scrolling. + * Critical for handling long transcriptions (4500+ segments) without UI lag. + * + * Usage: + * const { visibleItems, spacerBefore, spacerAfter, onScroll, scrollToIndex } = useVirtualScroll({ + * items: segmentsRef, + * itemHeight: 48, + * containerRef: scrollContainerRef, + * overscan: 5 + * }); + */ + +export function useVirtualScroll(options) { + const { ref, computed, watch, onMounted, onUnmounted } = Vue; + + const { + items, // Ref to the full array of items + itemHeight = 48, // Height of each item in pixels (fixed height mode) + containerRef, // Ref to the scrollable container element + overscan = 5, // Number of items to render outside viewport + keyField = null // Optional field to use for unique keys + } = options; + + // Internal state + const scrollTop = ref(0); + const containerHeight = ref(0); + const isInitialized = ref(false); + + // Calculate visible range based on scroll position + const visibleRange = computed(() => { + if (!isInitialized.value || !items.value) { + return { start: 0, end: Math.min(20, items.value?.length || 0) }; + } + + const totalItems = items.value.length; + if (totalItems === 0) { + return { start: 0, end: 0 }; + } + + // Calculate first visible item + const firstVisible = Math.floor(scrollTop.value / itemHeight); + + // Calculate number of items that fit in viewport + const visibleCount = Math.ceil(containerHeight.value / itemHeight); + + // Add overscan for smooth scrolling + const start = Math.max(0, firstVisible - overscan); + const end = Math.min(totalItems, firstVisible + visibleCount + overscan); + + return { start, end }; + }); + + // Slice of items to actually render + const visibleItems = computed(() => { + if (!items.value || items.value.length === 0) { + return []; + } + + const { start, end } = visibleRange.value; + + // Map items with their original indices for proper data binding + return items.value.slice(start, end).map((item, localIndex) => ({ + ...item, + _virtualIndex: start + localIndex, + _originalIndex: start + localIndex + })); + }); + + // Spacer height before visible items (for scroll position) + const spacerBefore = computed(() => { + return visibleRange.value.start * itemHeight; + }); + + // Spacer height after visible items + const spacerAfter = computed(() => { + if (!items.value) return 0; + const remainingItems = items.value.length - visibleRange.value.end; + return Math.max(0, remainingItems * itemHeight); + }); + + // Total height of all items (for scroll container) + const totalHeight = computed(() => { + if (!items.value) return 0; + return items.value.length * itemHeight; + }); + + // Handle scroll events + const onScroll = (event) => { + scrollTop.value = event.target.scrollTop; + }; + + // Initialize container height observer + let resizeObserver = null; + + const initializeContainer = () => { + if (!containerRef.value) return; + + // Get initial height + containerHeight.value = containerRef.value.clientHeight; + isInitialized.value = true; + + // Watch for container size changes + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + containerHeight.value = entry.contentRect.height; + } + }); + resizeObserver.observe(containerRef.value); + }; + + // Scroll to a specific index + const scrollToIndex = (index, behavior = 'smooth') => { + if (!containerRef.value || !items.value) return; + + const targetIndex = Math.max(0, Math.min(index, items.value.length - 1)); + const targetScrollTop = targetIndex * itemHeight; + + containerRef.value.scrollTo({ + top: targetScrollTop, + behavior + }); + }; + + // Scroll to make an index visible (centered if possible) + const scrollToIndexIfNeeded = (index) => { + if (!containerRef.value || !items.value) return; + + const { start, end } = visibleRange.value; + + // Check if index is already visible (with some margin) + if (index >= start + overscan && index < end - overscan) { + return; // Already visible + } + + // Center the index in the viewport + const targetIndex = Math.max(0, index - Math.floor(containerHeight.value / itemHeight / 2)); + scrollToIndex(targetIndex, 'smooth'); + }; + + // Reset scroll state (call when modal opens or items change completely) + const reset = () => { + scrollTop.value = 0; + isInitialized.value = false; + // Re-initialize after a tick to allow DOM to render + Vue.nextTick(() => { + if (containerRef.value) { + containerRef.value.scrollTop = 0; + initializeContainer(); + } + }); + }; + + // Watch for containerRef changes and initialize + watch(containerRef, (newRef) => { + if (newRef) { + initializeContainer(); + } + }, { immediate: true }); + + // Cleanup on unmount + onUnmounted(() => { + if (resizeObserver) { + resizeObserver.disconnect(); + } + }); + + return { + // Data + visibleItems, + visibleRange, + + // Spacer heights for virtual scroll container + spacerBefore, + spacerAfter, + totalHeight, + + // Event handlers + onScroll, + + // Navigation + scrollToIndex, + scrollToIndexIfNeeded, + + // Control + reset, + + // State (for debugging/testing) + scrollTop, + containerHeight, + isInitialized + }; +} + +/** + * Helper to generate a unique key for virtual scroll items + */ +export function getVirtualItemKey(item, prefix = 'vs') { + const index = item._originalIndex ?? item._virtualIndex ?? 0; + const time = item.startTime ?? item.start_time ?? ''; + return `${prefix}-${index}-${time}`; +} diff --git a/static/js/modules/computed/index.js b/static/js/modules/computed/index.js new file mode 100644 index 0000000..f9464da --- /dev/null +++ b/static/js/modules/computed/index.js @@ -0,0 +1,26 @@ +/** + * Computed properties module exports + * + * Note: These computed properties are defined inline in the main app.js + * due to their tight coupling with reactive state. This module serves as + * a placeholder for future extraction if needed. + */ + +// Computed properties that could be extracted: +// - filteredRecordings +// - groupedRecordings +// - highlightedTranscript +// - activeRecordingMetadata +// - identifiedSpeakers +// - processedTranscription +// - totalInQueue +// - completedInQueue +// - finishedFilesInQueue +// - isMobileScreen +// - datePresetOptions +// - languageOptions +// - tagsWithCustomPrompts +// - filteredAvailableTagsForModal +// - isMobileDevice + +export default {}; diff --git a/static/js/modules/db/failed-uploads.js b/static/js/modules/db/failed-uploads.js new file mode 100644 index 0000000..2b216e9 --- /dev/null +++ b/static/js/modules/db/failed-uploads.js @@ -0,0 +1,272 @@ +/** + * IndexedDB Failed Uploads Storage + * Handles storing and retrying failed uploads with background sync + */ + +const DB_NAME = 'SpeakrFailedUploads'; +const DB_VERSION = 1; +const STORE_NAME = 'failedUploads'; + +let dbInstance = null; + +/** + * Initialize IndexedDB + */ +export const initDB = () => { + return new Promise((resolve, reject) => { + if (dbInstance) { + resolve(dbInstance); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to open database:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + dbInstance = request.result; + console.log('[FailedUploadsDB] Database opened successfully'); + resolve(dbInstance); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create object store for failed uploads + if (!db.objectStoreNames.contains(STORE_NAME)) { + const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + objectStore.createIndex('timestamp', 'timestamp', { unique: false }); + objectStore.createIndex('clientId', 'clientId', { unique: false }); + console.log('[FailedUploadsDB] Object store created'); + } + }; + }); +}; + +/** + * Store a failed upload for later retry + */ +export const storeFailedUpload = async (uploadData) => { + try { + const db = await initDB(); + + // Convert File to ArrayBuffer BEFORE opening transaction. + // IDB transactions auto-close when inactive — the async arrayBuffer() + // call would cause the transaction to expire before add(). + let fileData = uploadData.fileData || null; + if (uploadData.file && !fileData) { + fileData = await uploadData.file.arrayBuffer(); + } + + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + const failedUpload = { + timestamp: Date.now(), + clientId: uploadData.clientId || `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + fileName: uploadData.file?.name || uploadData.fileName || 'unknown', + fileSize: uploadData.file?.size || uploadData.fileSize || 0, + notes: uploadData.notes || '', + tags: uploadData.tags || [], + asrOptions: uploadData.asrOptions || {}, + retryCount: uploadData.retryCount || 0, + lastError: uploadData.error || '', + fileData: fileData, + mimeType: uploadData.file?.type || uploadData.mimeType || 'audio/webm' + }; + + const request = objectStore.add(failedUpload); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + console.log('[FailedUploadsDB] Upload stored for retry:', failedUpload.fileName); + resolve(request.result); // Returns the ID + }; + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to store upload:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error storing failed upload:', error); + throw error; + } +}; + +/** + * Get all failed uploads waiting to retry + */ +export const getFailedUploads = async () => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.getAll(); + + request.onsuccess = () => { + console.log(`[FailedUploadsDB] Retrieved ${request.result.length} failed uploads`); + resolve(request.result); + }; + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to retrieve uploads:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error getting failed uploads:', error); + return []; + } +}; + +/** + * Get a specific failed upload by ID + */ +export const getFailedUpload = async (id) => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.get(id); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to get upload:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error getting failed upload:', error); + return null; + } +}; + +/** + * Update retry count for a failed upload + */ +export const updateRetryCount = async (id, retryCount, error = null) => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + const upload = await getFailedUpload(id); + if (!upload) { + console.warn('[FailedUploadsDB] Upload not found for retry count update'); + return; + } + + upload.retryCount = retryCount; + upload.lastRetry = Date.now(); + if (error) { + upload.lastError = error; + } + + return new Promise((resolve, reject) => { + const request = objectStore.put(upload); + + request.onsuccess = () => { + console.log(`[FailedUploadsDB] Updated retry count for upload ${id}: ${retryCount}`); + resolve(); + }; + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to update retry count:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error updating retry count:', error); + } +}; + +/** + * Delete a failed upload (after successful retry) + */ +export const deleteFailedUpload = async (id) => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.delete(id); + + request.onsuccess = () => { + console.log('[FailedUploadsDB] Deleted successful upload:', id); + resolve(); + }; + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to delete upload:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error deleting failed upload:', error); + } +}; + +/** + * Clear all failed uploads + */ +export const clearAllFailedUploads = async () => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.clear(); + + request.onsuccess = () => { + console.log('[FailedUploadsDB] Cleared all failed uploads'); + resolve(); + }; + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to clear uploads:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error clearing failed uploads:', error); + } +}; + +/** + * Get count of failed uploads + */ +export const getFailedUploadCount = async () => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = objectStore.count(); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + console.error('[FailedUploadsDB] Failed to count uploads:', request.error); + reject(request.error); + }; + }); + } catch (error) { + console.error('[FailedUploadsDB] Error counting failed uploads:', error); + return 0; + } +}; diff --git a/static/js/modules/db/incognito-storage.js b/static/js/modules/db/incognito-storage.js new file mode 100644 index 0000000..4f44f6f --- /dev/null +++ b/static/js/modules/db/incognito-storage.js @@ -0,0 +1,76 @@ +/** + * Incognito Mode storage utilities + * Uses sessionStorage for temporary storage that auto-clears when tab closes + */ + +const INCOGNITO_KEY = 'speakr_incognito_recording'; + +/** + * Save incognito recording data to sessionStorage + * @param {Object} data - Recording data including transcription, summary, title + */ +export function saveIncognitoRecording(data) { + try { + sessionStorage.setItem(INCOGNITO_KEY, JSON.stringify(data)); + console.log('[Incognito] Recording saved to sessionStorage'); + } catch (e) { + console.error('[Incognito] Failed to save recording:', e); + } +} + +/** + * Get incognito recording data from sessionStorage + * @returns {Object|null} Recording data or null if not found + */ +export function getIncognitoRecording() { + try { + const data = sessionStorage.getItem(INCOGNITO_KEY); + return data ? JSON.parse(data) : null; + } catch (e) { + console.error('[Incognito] Failed to retrieve recording:', e); + return null; + } +} + +/** + * Clear incognito recording from sessionStorage + */ +export function clearIncognitoRecording() { + try { + sessionStorage.removeItem(INCOGNITO_KEY); + console.log('[Incognito] Recording cleared from sessionStorage'); + } catch (e) { + console.error('[Incognito] Failed to clear recording:', e); + } +} + +/** + * Check if an incognito recording exists + * @returns {boolean} + */ +export function hasIncognitoRecording() { + try { + return sessionStorage.getItem(INCOGNITO_KEY) !== null; + } catch (e) { + return false; + } +} + +/** + * Update specific fields of the incognito recording + * @param {Object} updates - Fields to update + */ +export function updateIncognitoRecording(updates) { + try { + const existing = getIncognitoRecording(); + if (existing) { + const updated = { ...existing, ...updates }; + saveIncognitoRecording(updated); + return updated; + } + return null; + } catch (e) { + console.error('[Incognito] Failed to update recording:', e); + return null; + } +} diff --git a/static/js/modules/db/recording-persistence.js b/static/js/modules/db/recording-persistence.js new file mode 100644 index 0000000..ccfc119 --- /dev/null +++ b/static/js/modules/db/recording-persistence.js @@ -0,0 +1,267 @@ +/** + * IndexedDB Recording Persistence + * Handles saving recording chunks to IndexedDB for crash recovery + */ + +const DB_NAME = 'SpeakrRecordings'; +const DB_VERSION = 1; +const STORE_NAME = 'activeRecording'; + +let dbInstance = null; + +/** + * Helper to promisify IDBRequest + */ +const promisifyRequest = (request) => { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +}; + +/** + * Initialize IndexedDB + */ +export const initDB = () => { + return new Promise((resolve, reject) => { + if (dbInstance) { + resolve(dbInstance); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('[RecordingDB] Failed to open database:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + dbInstance = request.result; + console.log('[RecordingDB] Database opened successfully'); + resolve(dbInstance); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create object store for active recording + if (!db.objectStoreNames.contains(STORE_NAME)) { + const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + objectStore.createIndex('timestamp', 'timestamp', { unique: false }); + console.log('[RecordingDB] Object store created'); + } + }; + }); +}; + +/** + * Save recording metadata and initialize session + */ +export const startRecordingSession = async (recordingData) => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + const session = { + id: 'current', + timestamp: Date.now(), + startTime: new Date().toISOString(), + mode: recordingData.mode, + notes: recordingData.notes || '', + tags: recordingData.tags || [], + asrOptions: recordingData.asrOptions || {}, + chunks: [], + mimeType: recordingData.mimeType || 'audio/webm', + duration: 0 + }; + + await promisifyRequest(objectStore.put(session)); + console.log('[RecordingDB] Recording session started:', session.id); + return session; + } catch (error) { + console.error('[RecordingDB] Failed to start session:', error); + throw error; + } +}; + +/** + * Save a recording chunk to IndexedDB + */ +export const saveChunk = async (chunkBlob, chunkIndex) => { + try { + // Do async prep work BEFORE creating transaction to avoid auto-close + const db = await initDB(); + const arrayBuffer = await chunkBlob.arrayBuffer(); + + // Now create transaction and do all DB operations quickly + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + // Get current session + const session = await promisifyRequest(objectStore.get('current')); + + if (!session) { + console.warn('[RecordingDB] No active session found'); + return; + } + + // Add chunk to session + session.chunks.push({ + index: chunkIndex, + data: arrayBuffer, + size: chunkBlob.size, + timestamp: Date.now() + }); + + // Update session - must happen before transaction auto-closes + await promisifyRequest(objectStore.put(session)); + // Chunk saved silently to avoid spam (happens every 5 seconds) + } catch (error) { + console.error('[RecordingDB] Failed to save chunk:', error); + // Don't throw - recording should continue even if persistence fails + } +}; + +/** + * Update recording metadata (notes, duration, etc.) + */ +export const updateRecordingMetadata = async (updates) => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + const session = await promisifyRequest(objectStore.get('current')); + + if (!session) { + console.warn('[RecordingDB] No active session to update'); + return; + } + + // Merge updates + Object.assign(session, updates); + await promisifyRequest(objectStore.put(session)); + // Metadata updated silently to avoid spam (happens every 5 seconds) + } catch (error) { + console.error('[RecordingDB] Failed to update metadata:', error); + } +}; + +/** + * Check if there's a recoverable recording + */ +export const checkForRecoverableRecording = async () => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + const session = await promisifyRequest(objectStore.get('current')); + + if (!session || !session.chunks || session.chunks.length === 0) { + return null; + } + + // Calculate total size + const totalSize = session.chunks.reduce((sum, chunk) => sum + chunk.size, 0); + + // Calculate approximate duration (1 second chunks) + const duration = session.chunks.length; + + console.log('[RecordingDB] Found recoverable recording:', { + chunks: session.chunks.length, + size: totalSize, + duration: duration, + startTime: session.startTime + }); + + return { + ...session, + totalSize, + duration: duration + }; + } catch (error) { + console.error('[RecordingDB] Failed to check for recoverable recording:', error); + return null; + } +}; + +/** + * Recover recording from IndexedDB + */ +export const recoverRecording = async () => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + + const session = await promisifyRequest(objectStore.get('current')); + + if (!session || !session.chunks || session.chunks.length === 0) { + console.warn('[RecordingDB] No recording to recover'); + return null; + } + + // Convert chunks back to Blobs + const chunks = session.chunks.map(chunk => { + return new Blob([chunk.data], { type: session.mimeType }); + }); + + console.log(`[RecordingDB] Recovered ${chunks.length} chunks`); + + return { + chunks, + metadata: { + mode: session.mode, + notes: session.notes, + tags: session.tags, + asrOptions: session.asrOptions, + mimeType: session.mimeType, + duration: session.chunks.length, + startTime: session.startTime + } + }; + } catch (error) { + console.error('[RecordingDB] Failed to recover recording:', error); + return null; + } +}; + +/** + * Clear recording session (after successful upload or discard) + */ +export const clearRecordingSession = async () => { + try { + const db = await initDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + + await promisifyRequest(objectStore.delete('current')); + console.log('[RecordingDB] Recording session cleared'); + } catch (error) { + console.error('[RecordingDB] Failed to clear session:', error); + } +}; + +/** + * Get database size information + */ +export const getDatabaseSize = async () => { + try { + if (!navigator.storage || !navigator.storage.estimate) { + return null; + } + + const estimate = await navigator.storage.estimate(); + return { + usage: estimate.usage, + quota: estimate.quota, + percentage: ((estimate.usage / estimate.quota) * 100).toFixed(2) + }; + } catch (error) { + console.error('[RecordingDB] Failed to get database size:', error); + return null; + } +}; diff --git a/static/js/modules/state/audio.js b/static/js/modules/state/audio.js new file mode 100644 index 0000000..ff4a9f3 --- /dev/null +++ b/static/js/modules/state/audio.js @@ -0,0 +1,95 @@ +/** + * Audio recording state management + */ + +export function createAudioState(ref, computed) { + // --- Audio Recording State --- + const isRecording = ref(false); + const mediaRecorder = ref(null); + const audioChunks = ref([]); + const audioBlobURL = ref(null); + const recordingTime = ref(0); + const recordingInterval = ref(null); + const canRecordAudio = ref(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia); + const systemAudioSupported = ref(false); + const systemAudioError = ref(''); + const recordingNotes = ref(''); + const showSystemAudioHelp = ref(false); + + // ASR options for recording view + const asrLanguage = ref(''); + const asrMinSpeakers = ref(''); + const asrMaxSpeakers = ref(''); + + // Audio context and analyzers + const audioContext = ref(null); + const analyser = ref(null); + const micAnalyser = ref(null); + const systemAnalyser = ref(null); + const visualizer = ref(null); + const micVisualizer = ref(null); + const systemVisualizer = ref(null); + const animationFrameId = ref(null); + const recordingMode = ref('microphone'); + const activeStreams = ref([]); + + // --- Wake Lock and Background Recording --- + const wakeLock = ref(null); + const recordingNotification = ref(null); + const isPageVisible = ref(true); + + // --- Recording Size Monitoring --- + const estimatedFileSize = ref(0); + const fileSizeWarningShown = ref(false); + const recordingQuality = ref('optimized'); + const actualBitrate = ref(0); + const maxRecordingMB = ref(200); + const sizeCheckInterval = ref(null); + + return { + // Recording state + isRecording, + mediaRecorder, + audioChunks, + audioBlobURL, + recordingTime, + recordingInterval, + canRecordAudio, + canRecordSystemAudio, + systemAudioSupported, + systemAudioError, + recordingNotes, + showSystemAudioHelp, + + // ASR options + asrLanguage, + asrMinSpeakers, + asrMaxSpeakers, + + // Audio context + audioContext, + analyser, + micAnalyser, + systemAnalyser, + visualizer, + micVisualizer, + systemVisualizer, + animationFrameId, + recordingMode, + activeStreams, + + // Wake lock + wakeLock, + recordingNotification, + isPageVisible, + + // Size monitoring + estimatedFileSize, + fileSizeWarningShown, + recordingQuality, + actualBitrate, + maxRecordingMB, + sizeCheckInterval + }; +} diff --git a/static/js/modules/state/chat.js b/static/js/modules/state/chat.js new file mode 100644 index 0000000..e8cb411 --- /dev/null +++ b/static/js/modules/state/chat.js @@ -0,0 +1,23 @@ +/** + * Chat state management + */ + +export function createChatState(ref) { + const showChat = ref(false); + const isChatMaximized = ref(false); + const chatMessages = ref([]); + const chatInput = ref(''); + const isChatLoading = ref(false); + const chatMessagesRef = ref(null); + const chatInputRef = ref(null); + + return { + showChat, + isChatMaximized, + chatMessages, + chatInput, + isChatLoading, + chatMessagesRef, + chatInputRef + }; +} diff --git a/static/js/modules/state/core.js b/static/js/modules/state/core.js new file mode 100644 index 0000000..9150dae --- /dev/null +++ b/static/js/modules/state/core.js @@ -0,0 +1,164 @@ +/** + * Core application state + */ + +export function createCoreState(ref, computed) { + // --- Core State --- + const currentView = ref('upload'); + const dragover = ref(false); + const recordings = ref([]); + const selectedRecording = ref(null); + const selectedTab = ref('summary'); + const searchQuery = ref(''); + const isLoadingRecordings = ref(true); + const globalError = ref(null); + + // --- Pagination State --- + const currentPage = ref(1); + const perPage = ref(25); + const totalRecordings = ref(0); + const totalPages = ref(0); + const hasNextPage = ref(false); + const hasPrevPage = ref(false); + const isLoadingMore = ref(false); + const searchDebounceTimer = ref(null); + + // --- Enhanced Search & Organization State --- + const sortBy = ref('created_at'); + const selectedTagFilter = ref(null); + + // Advanced filter state + const showAdvancedFilters = ref(false); + const filterTags = ref([]); + const filterSpeakers = ref([]); // Array of speaker names for filtering + const filterDateRange = ref({ start: '', end: '' }); + const filterDatePreset = ref(''); + const filterTextQuery = ref(''); + const showArchivedRecordings = ref(false); + const showSharedWithMe = ref(false); + + // --- App Configuration --- + const useAsrEndpoint = ref(false); + const connectorSupportsDiarization = ref(false); // Connector capability for diarization UI + const connectorSupportsSpeakerCount = ref(false); // Connector capability for min/max speakers + const currentUserName = ref(''); + const canDeleteRecordings = ref(true); + const enableInternalSharing = ref(false); + const enableArchiveToggle = ref(false); + const showUsernamesInUI = ref(false); + + // --- Incognito Mode State --- + const enableIncognitoMode = ref(false); // Server config - whether feature is available + const incognitoMode = ref(false); // User toggle - whether to use incognito for current upload + const incognitoRecording = ref(null); + const incognitoProcessing = ref(false); + + // Tag Selection + const availableTags = ref([]); + const selectedTagIds = ref([]); + const uploadTagSearchFilter = ref(''); + + // Folder Selection + const availableFolders = ref([]); + const selectedFolderId = ref(null); + const foldersEnabled = ref(false); + const filterFolder = ref(''); // '' = all, 'none' = no folder, or folder id + + // Speaker Selection + const availableSpeakers = ref([]); + + const selectedTags = computed(() => { + return selectedTagIds.value.map(tagId => + availableTags.value.find(tag => tag.id == tagId) + ).filter(Boolean); + }); + + const filteredAvailableTagsForUpload = computed(() => { + const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id)); + if (!uploadTagSearchFilter.value) return availableForSelection; + + const filter = uploadTagSearchFilter.value.toLowerCase(); + return availableForSelection.filter(tag => + tag.name.toLowerCase().includes(filter) + ); + }); + + const filteredRecordings = computed(() => { + return recordings.value; + }); + + const setGlobalError = (message, duration = 7000) => { + globalError.value = message; + if (duration > 0) { + setTimeout(() => { if (globalError.value === message) globalError.value = null; }, duration); + } + }; + + return { + // Core + currentView, + dragover, + recordings, + selectedRecording, + selectedTab, + searchQuery, + isLoadingRecordings, + globalError, + setGlobalError, + + // Pagination + currentPage, + perPage, + totalRecordings, + totalPages, + hasNextPage, + hasPrevPage, + isLoadingMore, + searchDebounceTimer, + + // Search & Organization + sortBy, + selectedTagFilter, + showAdvancedFilters, + filterTags, + filterSpeakers, + filterDateRange, + filterDatePreset, + filterTextQuery, + showArchivedRecordings, + showSharedWithMe, + + // App Configuration + useAsrEndpoint, + connectorSupportsDiarization, + connectorSupportsSpeakerCount, + currentUserName, + canDeleteRecordings, + enableInternalSharing, + enableArchiveToggle, + showUsernamesInUI, + + // Tags + availableTags, + selectedTagIds, + uploadTagSearchFilter, + selectedTags, + filteredAvailableTagsForUpload, + filteredRecordings, + + // Folders + availableFolders, + selectedFolderId, + foldersEnabled, + filterFolder, + + // Speakers + availableSpeakers, + + // Incognito Mode + enableIncognitoMode, + incognitoMode, + incognitoRecording, + incognitoProcessing + }; +} diff --git a/static/js/modules/state/index.js b/static/js/modules/state/index.js new file mode 100644 index 0000000..fe5d0b4 --- /dev/null +++ b/static/js/modules/state/index.js @@ -0,0 +1,11 @@ +/** + * State module exports + */ + +export { createCoreState } from './core.js'; +export { createUIState } from './ui.js'; +export { createUploadState } from './upload.js'; +export { createAudioState } from './audio.js'; +export { createModalState } from './modals.js'; +export { createChatState } from './chat.js'; +export { createSharingState } from './sharing.js'; diff --git a/static/js/modules/state/modals.js b/static/js/modules/state/modals.js new file mode 100644 index 0000000..84e8b19 --- /dev/null +++ b/static/js/modules/state/modals.js @@ -0,0 +1,193 @@ +/** + * Modal state management + */ + +export function createModalState(ref, reactive) { + // --- Modal Visibility State --- + const showEditModal = ref(false); + const showDeleteModal = ref(false); + const showEditTagsModal = ref(false); + const showReprocessModal = ref(false); + const showResetModal = ref(false); + const showSpeakerModal = ref(false); + const speakerModalTab = ref('speakers'); // 'speakers' or 'transcript' for mobile view + const showShareModal = ref(false); + const showSharesListModal = ref(false); + const showTextEditorModal = ref(false); + const showAsrEditorModal = ref(false); + const showEditSpeakersModal = ref(false); + const showEditTextModal = ref(false); + const showAddSpeakerModal = ref(false); + const showShareDeleteModal = ref(false); + const showUnifiedShareModal = ref(false); + const showDateTimePicker = ref(false); + + // --- DateTime Picker State --- + const pickerMonth = ref(new Date().getMonth()); + const pickerYear = ref(new Date().getFullYear()); + const pickerHour = ref(12); + const pickerMinute = ref(0); + const pickerAmPm = ref('PM'); + const pickerSelectedDate = ref(null); + const dateTimePickerTarget = ref(null); // 'meeting_date' or other field name + const dateTimePickerCallback = ref(null); // callback function after applying + + // --- Modal Data State --- + const selectedNewTagId = ref(''); + const tagSearchFilter = ref(''); + const editingRecording = ref(null); + const editingTranscriptionContent = ref(''); + const editingSegments = ref([]); + const availableSpeakers = ref([]); + const editingSpeakersList = ref([]); + const databaseSpeakers = ref([]); + const editingSpeakerSuggestions = ref({}); + const recordingToDelete = ref(null); + const recordingToReset = ref(null); + const reprocessType = ref(null); + const reprocessRecording = ref(null); + const isAutoIdentifying = ref(false); + + const asrReprocessOptions = reactive({ + language: '', + min_speakers: null, + max_speakers: null + }); + + const summaryReprocessPromptSource = ref('default'); + const summaryReprocessSelectedTagId = ref(''); + const summaryReprocessCustomPrompt = ref(''); + const speakerMap = ref({}); + const regenerateSummaryAfterSpeakerUpdate = ref(true); + const speakerSuggestions = ref({}); + const loadingSuggestions = ref({}); + const activeSpeakerInput = ref(null); + const voiceSuggestions = ref({}); + const loadingVoiceSuggestions = ref(false); + + // --- Transcript Editing State --- + const editingSegmentIndex = ref(null); + const editingSpeakerIndex = ref(null); + const editedText = ref(''); + const newSpeakerName = ref(''); + const newSpeakerIsMe = ref(false); + const editedTranscriptData = ref(null); + + // --- Inline Editing State --- + const editingParticipants = ref(false); + const editingMeetingDate = ref(false); + const editingSummary = ref(false); + const editingNotes = ref(false); + const tempNotesContent = ref(''); + const tempSummaryContent = ref(''); + const autoSaveTimer = ref(null); + const autoSaveDelay = 2000; + + // --- Markdown Editor State --- + const notesMarkdownEditor = ref(null); + const markdownEditorInstance = ref(null); + const summaryMarkdownEditor = ref(null); + const summaryMarkdownEditorInstance = ref(null); + const recordingNotesEditor = ref(null); + const recordingMarkdownEditorInstance = ref(null); + + // --- Dropdown Positions --- + const dropdownPositions = ref({}); + const editSpeakerDropdownPositions = ref({}); + + // --- Single-ref dropdown tracking (performance optimization) --- + // Instead of each segment having showSuggestions property (O(n) to close all), + // track which dropdown is open with a single ref (O(1) operations) + const openAsrDropdownIndex = ref(null); + + return { + // Modal visibility + showEditModal, + showDeleteModal, + showEditTagsModal, + showReprocessModal, + showResetModal, + showSpeakerModal, + speakerModalTab, + showShareModal, + showSharesListModal, + showTextEditorModal, + showAsrEditorModal, + showEditSpeakersModal, + showEditTextModal, + showAddSpeakerModal, + showShareDeleteModal, + showUnifiedShareModal, + showDateTimePicker, + + // DateTime picker + pickerMonth, + pickerYear, + pickerHour, + pickerMinute, + pickerAmPm, + pickerSelectedDate, + dateTimePickerTarget, + dateTimePickerCallback, + + // Modal data + selectedNewTagId, + tagSearchFilter, + editingRecording, + editingTranscriptionContent, + editingSegments, + availableSpeakers, + editingSpeakersList, + databaseSpeakers, + editingSpeakerSuggestions, + recordingToDelete, + recordingToReset, + reprocessType, + reprocessRecording, + isAutoIdentifying, + asrReprocessOptions, + summaryReprocessPromptSource, + summaryReprocessSelectedTagId, + summaryReprocessCustomPrompt, + speakerMap, + regenerateSummaryAfterSpeakerUpdate, + speakerSuggestions, + loadingSuggestions, + activeSpeakerInput, + voiceSuggestions, + loadingVoiceSuggestions, + + // Transcript editing + editingSegmentIndex, + editingSpeakerIndex, + editedText, + newSpeakerName, + newSpeakerIsMe, + editedTranscriptData, + + // Inline editing + editingParticipants, + editingMeetingDate, + editingSummary, + editingNotes, + tempNotesContent, + tempSummaryContent, + autoSaveTimer, + autoSaveDelay, + + // Markdown editors + notesMarkdownEditor, + markdownEditorInstance, + summaryMarkdownEditor, + summaryMarkdownEditorInstance, + recordingNotesEditor, + recordingMarkdownEditorInstance, + + // Dropdown positions + dropdownPositions, + editSpeakerDropdownPositions, + + // Single-ref dropdown tracking + openAsrDropdownIndex + }; +} diff --git a/static/js/modules/state/pwa.js b/static/js/modules/state/pwa.js new file mode 100644 index 0000000..a8bfcb8 --- /dev/null +++ b/static/js/modules/state/pwa.js @@ -0,0 +1,39 @@ +/** + * PWA state management + */ + +export function createPWAState(ref) { + // --- Install Prompt --- + const deferredInstallPrompt = ref(null); + const showInstallButton = ref(false); + const isPWAInstalled = ref(false); + + // --- Notifications --- + const notificationPermission = ref('default'); + const pushSubscription = ref(null); + + // --- Badging --- + const appBadgeCount = ref(0); + + // --- Media Session --- + const currentMediaMetadata = ref(null); + const isMediaSessionActive = ref(false); + + return { + // Install prompt + deferredInstallPrompt, + showInstallButton, + isPWAInstalled, + + // Notifications + notificationPermission, + pushSubscription, + + // Badging + appBadgeCount, + + // Media session + currentMediaMetadata, + isMediaSessionActive + }; +} diff --git a/static/js/modules/state/sharing.js b/static/js/modules/state/sharing.js new file mode 100644 index 0000000..436b581 --- /dev/null +++ b/static/js/modules/state/sharing.js @@ -0,0 +1,66 @@ +/** + * Sharing state management + */ + +export function createSharingState(ref, reactive) { + // --- Public Sharing State --- + const recordingToShare = ref(null); + const shareOptions = reactive({ + share_summary: true, + share_notes: true, + }); + const generatedShareLink = ref(''); + const existingShareDetected = ref(false); + const userShares = ref([]); + const isLoadingShares = ref(false); + const shareToDelete = ref(null); + + // --- Internal Sharing State --- + const internalShareUserSearch = ref(''); + const internalShareSearchResults = ref([]); + const internalShareRecording = ref(null); + const internalSharePermissions = ref({ can_edit: false, can_reshare: false }); + const recordingInternalShares = ref([]); + const isLoadingInternalShares = ref(false); + const isSearchingUsers = ref(false); + const allUsers = ref([]); + const isLoadingAllUsers = ref(false); + + // --- Audio Player State --- + const playerVolume = ref(1.0); + const audioIsPlaying = ref(false); + const audioCurrentTime = ref(0); + const audioDuration = ref(0); + const audioIsMuted = ref(false); + const audioIsLoading = ref(false); + + return { + // Public sharing + recordingToShare, + shareOptions, + generatedShareLink, + existingShareDetected, + userShares, + isLoadingShares, + shareToDelete, + + // Internal sharing + internalShareUserSearch, + internalShareSearchResults, + internalShareRecording, + internalSharePermissions, + recordingInternalShares, + isLoadingInternalShares, + isSearchingUsers, + allUsers, + isLoadingAllUsers, + + // Audio player + playerVolume, + audioIsPlaying, + audioCurrentTime, + audioDuration, + audioIsMuted, + audioIsLoading + }; +} diff --git a/static/js/modules/state/ui.js b/static/js/modules/state/ui.js new file mode 100644 index 0000000..ec54df8 --- /dev/null +++ b/static/js/modules/state/ui.js @@ -0,0 +1,109 @@ +/** + * UI state management + */ + +export function createUIState(ref, computed) { + // --- UI State --- + const browser = ref('unknown'); + const isSidebarCollapsed = ref(false); + const searchTipsExpanded = ref(false); + const isUserMenuOpen = ref(false); + const isDarkMode = ref(false); + const currentColorScheme = ref('blue'); + const showColorSchemeModal = ref(false); + const windowWidth = ref(window.innerWidth); + const mobileTab = ref('transcript'); + const isMetadataExpanded = ref(false); + const expandedSection = ref('settings'); // 'notes' or 'settings' + + // --- i18n State --- + const currentLanguage = ref('en'); + const currentLanguageName = ref('English'); + const availableLanguages = ref([]); + const showLanguageMenu = ref(false); + + // --- Column Resizing State --- + const leftColumnWidth = ref(60); + const rightColumnWidth = ref(40); + const isResizing = ref(false); + + // --- Transcription State --- + const transcriptionViewMode = ref('simple'); + const legendExpanded = ref(false); + const highlightedSpeaker = ref(null); + const processingIndicatorMinimized = ref(false); + + // --- Virtual Scroll State --- + // For transcript panel virtual scrolling (performance optimization for long transcriptions) + const transcriptScrollTop = ref(0); + const transcriptContainerHeight = ref(0); + const transcriptItemHeight = 48; // Estimated height per segment in pixels + + // --- Computed Properties --- + const isMobileScreen = computed(() => { + return windowWidth.value < 1024; + }); + + // --- Color Scheme Definitions --- + const colorSchemes = { + light: [ + { id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', class: '' }, + { id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', class: 'theme-light-emerald' }, + { id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', class: 'theme-light-purple' }, + { id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', class: 'theme-light-rose' }, + { id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', class: 'theme-light-amber' }, + { id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', class: 'theme-light-teal' } + ], + dark: [ + { id: 'blue', name: 'Midnight Blue', description: 'Deep blue theme for focused work', class: '' }, + { id: 'emerald', name: 'Dark Forest', description: 'Rich green theme for comfortable viewing', class: 'theme-dark-emerald' }, + { id: 'purple', name: 'Deep Purple', description: 'Mysterious purple theme for creativity', class: 'theme-dark-purple' }, + { id: 'rose', name: 'Dark Rose', description: 'Muted pink theme with subtle warmth', class: 'theme-dark-rose' }, + { id: 'amber', name: 'Dark Amber', description: 'Warm brown theme for cozy sessions', class: 'theme-dark-amber' }, + { id: 'teal', name: 'Deep Teal', description: 'Dark teal theme for calm focus', class: 'theme-dark-teal' } + ] + }; + + return { + // UI + browser, + isSidebarCollapsed, + searchTipsExpanded, + isUserMenuOpen, + isDarkMode, + currentColorScheme, + showColorSchemeModal, + windowWidth, + mobileTab, + isMetadataExpanded, + expandedSection, + + // i18n + currentLanguage, + currentLanguageName, + availableLanguages, + showLanguageMenu, + + // Column Resizing + leftColumnWidth, + rightColumnWidth, + isResizing, + + // Transcription + transcriptionViewMode, + legendExpanded, + highlightedSpeaker, + processingIndicatorMinimized, + + // Virtual Scroll + transcriptScrollTop, + transcriptContainerHeight, + transcriptItemHeight, + + // Computed + isMobileScreen, + + // Constants + colorSchemes + }; +} diff --git a/static/js/modules/state/upload.js b/static/js/modules/state/upload.js new file mode 100644 index 0000000..f7fa276 --- /dev/null +++ b/static/js/modules/state/upload.js @@ -0,0 +1,77 @@ +/** + * Upload state management + */ + +export function createUploadState(ref, computed) { + // --- Upload State --- + const uploadQueue = ref([]); + const currentlyProcessingFile = ref(null); + const processingProgress = ref(0); + const processingMessage = ref(''); + const isProcessingActive = ref(false); + const pollInterval = ref(null); + const progressPopupMinimized = ref(false); + const progressPopupClosed = ref(false); + const maxFileSizeMB = ref(250); + const chunkingEnabled = ref(true); + const chunkingMode = ref('size'); + const chunkingLimit = ref(20); + const chunkingLimitDisplay = ref('20MB'); + const maxConcurrentUploads = ref(3); + const recordingDisclaimer = ref(''); + const showRecordingDisclaimerModal = ref(false); + const pendingRecordingMode = ref(null); + + // Advanced Options for ASR + const showAdvancedOptions = ref(false); + const uploadLanguage = ref(''); + const uploadMinSpeakers = ref(''); + const uploadMaxSpeakers = ref(''); + const uploadHotwords = ref(''); + const uploadInitialPrompt = ref(''); + + // --- Computed Properties --- + const totalInQueue = computed(() => uploadQueue.value.length); + const completedInQueue = computed(() => uploadQueue.value.filter(item => item.status === 'completed' || item.status === 'failed').length); + const finishedFilesInQueue = computed(() => uploadQueue.value.filter(item => ['completed', 'failed'].includes(item.status))); + + const clearCompletedUploads = () => { + uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status)); + }; + + return { + uploadQueue, + currentlyProcessingFile, + processingProgress, + processingMessage, + isProcessingActive, + pollInterval, + progressPopupMinimized, + progressPopupClosed, + maxFileSizeMB, + chunkingEnabled, + chunkingMode, + chunkingLimit, + chunkingLimitDisplay, + maxConcurrentUploads, + recordingDisclaimer, + showRecordingDisclaimerModal, + pendingRecordingMode, + + // Advanced Options + showAdvancedOptions, + uploadLanguage, + uploadMinSpeakers, + uploadMaxSpeakers, + uploadHotwords, + uploadInitialPrompt, + + // Computed + totalInQueue, + completedInQueue, + finishedFilesInQueue, + + // Methods + clearCompletedUploads + }; +} diff --git a/static/js/modules/utils/api.js b/static/js/modules/utils/api.js new file mode 100644 index 0000000..1976f1a --- /dev/null +++ b/static/js/modules/utils/api.js @@ -0,0 +1,61 @@ +/** + * API utility functions with CSRF token handling + */ + +export const createApiClient = (csrfToken) => { + const getHeaders = (contentType = 'application/json') => { + const headers = { + 'X-CSRFToken': csrfToken.value + }; + if (contentType) { + headers['Content-Type'] = contentType; + } + return headers; + }; + + return { + get: async (url) => { + const response = await fetch(url, { + headers: getHeaders() + }); + return response; + }, + + post: async (url, data = {}) => { + const response = await fetch(url, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + return response; + }, + + postFormData: async (url, formData) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken.value + }, + body: formData + }); + return response; + }, + + delete: async (url) => { + const response = await fetch(url, { + method: 'DELETE', + headers: getHeaders() + }); + return response; + }, + + put: async (url, data = {}) => { + const response = await fetch(url, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(data) + }); + return response; + } + }; +}; diff --git a/static/js/modules/utils/colors.js b/static/js/modules/utils/colors.js new file mode 100644 index 0000000..14ac47f --- /dev/null +++ b/static/js/modules/utils/colors.js @@ -0,0 +1,50 @@ +/** + * Color utility functions + */ + +/** + * Calculate the relative luminance of a color + * Based on WCAG contrast ratio formula + * @param {string} hexColor - Hex color code (e.g., "#RRGGBB" or "#RGB") + * @returns {number} Luminance value between 0 and 1 + */ +function calculateLuminance(hexColor) { + // Remove # if present + const hex = hexColor.replace('#', ''); + + // Convert 3-digit hex to 6-digit + const fullHex = hex.length === 3 + ? hex.split('').map(char => char + char).join('') + : hex; + + // Parse RGB values + const r = parseInt(fullHex.substr(0, 2), 16) / 255; + const g = parseInt(fullHex.substr(2, 2), 16) / 255; + const b = parseInt(fullHex.substr(4, 2), 16) / 255; + + // Calculate relative luminance using simplified formula + // (More accurate would use gamma correction, but this is sufficient) + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +/** + * Get the appropriate text color (black or white) for a given background color + * Ensures readable contrast based on background luminance + * @param {string} bgColor - Background color in hex format + * @returns {string} Either 'white' or 'black' + */ +export function getContrastTextColor(bgColor) { + if (!bgColor) { + return 'white'; // Default to white for undefined colors + } + + try { + const luminance = calculateLuminance(bgColor); + // Threshold of 0.65: only very light backgrounds get black text + // This ensures medium/dark colors like greens, blues still get white text + return luminance > 0.65 ? 'black' : 'white'; + } catch (e) { + console.warn('Failed to calculate contrast color for:', bgColor, e); + return 'white'; // Fallback to white + } +} diff --git a/static/js/modules/utils/dates.js b/static/js/modules/utils/dates.js new file mode 100644 index 0000000..768e93d --- /dev/null +++ b/static/js/modules/utils/dates.js @@ -0,0 +1,67 @@ +/** + * Date comparison utility functions + */ + +export const isSameDay = (date1, date2) => { + return date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate(); +}; + +export const isToday = (date) => { + const today = new Date(); + return isSameDay(date, today); +}; + +export const isYesterday = (date) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return isSameDay(date, yesterday); +}; + +export const isThisWeek = (date) => { + const now = new Date(); + const startOfWeek = new Date(now); + const day = now.getDay(); + const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week + startOfWeek.setDate(diff); + startOfWeek.setHours(0, 0, 0, 0); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); + endOfWeek.setHours(23, 59, 59, 999); + + return date >= startOfWeek && date <= endOfWeek; +}; + +export const isLastWeek = (date) => { + const now = new Date(); + const startOfLastWeek = new Date(now); + const day = now.getDay(); + const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday + startOfLastWeek.setDate(diff); + startOfLastWeek.setHours(0, 0, 0, 0); + + const endOfLastWeek = new Date(startOfLastWeek); + endOfLastWeek.setDate(startOfLastWeek.getDate() + 6); + endOfLastWeek.setHours(23, 59, 59, 999); + + return date >= startOfLastWeek && date <= endOfLastWeek; +}; + +export const isThisMonth = (date) => { + const now = new Date(); + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth(); +}; + +export const isLastMonth = (date) => { + const now = new Date(); + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return date.getFullYear() === lastMonth.getFullYear() && date.getMonth() === lastMonth.getMonth(); +}; + +export const getDateForSorting = (recording, sortBy) => { + const dateStr = sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at; + if (!dateStr) return null; + return new Date(dateStr); +}; diff --git a/static/js/modules/utils/errorDisplay.js b/static/js/modules/utils/errorDisplay.js new file mode 100644 index 0000000..6671c2d --- /dev/null +++ b/static/js/modules/utils/errorDisplay.js @@ -0,0 +1,271 @@ +/** + * Error Display Utility + * + * Parses and displays user-friendly error messages from the backend. + * Handles both JSON-formatted errors (ERROR_JSON:...) and plain text errors. + */ + +/** + * Parse a stored error message from the backend. + * @param {string} text - The stored transcription/error text + * @returns {Object|null} - Parsed error object or null if not an error + */ +export function parseStoredError(text) { + if (!text) return null; + + // Check for JSON-formatted error + if (text.startsWith('ERROR_JSON:')) { + try { + const jsonStr = text.substring(11); // Remove 'ERROR_JSON:' prefix + const data = JSON.parse(jsonStr); + return { + title: data.t || 'Error', + message: data.m || 'An error occurred', + guidance: data.g || '', + icon: data.i || 'fa-exclamation-circle', + type: data.y || 'unknown', + isKnown: data.k || false, + technical: data.d || '', + isFormattedError: true + }; + } catch (e) { + console.error('Failed to parse error JSON:', e); + } + } + + // Check for legacy error format (starts with common error prefixes) + const errorPrefixes = [ + 'Transcription failed:', + 'Processing failed:', + 'ASR processing failed:', + 'Audio extraction failed:', + 'Error:' + ]; + + for (const prefix of errorPrefixes) { + if (text.startsWith(prefix)) { + // Parse the error using pattern matching + return parseUnformattedError(text); + } + } + + return null; +} + +/** + * Parse an unformatted error message and try to make it user-friendly. + * @param {string} text - The raw error text + * @returns {Object} - Parsed error object + */ +function parseUnformattedError(text) { + const lowerText = text.toLowerCase(); + + // Known error patterns + const patterns = [ + { + patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], + title: 'File Too Large', + message: 'The audio file exceeds the maximum size allowed by the transcription service.', + guidance: 'Try enabling audio chunking in your settings, or compress the audio file before uploading.', + icon: 'fa-file-audio', + type: 'size_limit' + }, + { + patterns: ['timed out', 'timeout', 'deadline exceeded'], + title: 'Processing Timeout', + message: 'The transcription took too long to complete.', + guidance: 'This can happen with very long recordings. Try splitting the audio into smaller parts.', + icon: 'fa-clock', + type: 'timeout' + }, + { + patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], + title: 'Authentication Error', + message: 'The transcription service rejected the API credentials.', + guidance: 'Please check that the API key is correct and has not expired.', + icon: 'fa-key', + type: 'auth' + }, + { + patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], + title: 'Rate Limit Exceeded', + message: 'Too many requests were sent to the transcription service.', + guidance: 'Please wait a few minutes and try reprocessing.', + icon: 'fa-hourglass-half', + type: 'rate_limit' + }, + { + patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], + title: 'Connection Error', + message: 'Could not connect to the transcription service.', + guidance: 'Check your internet connection and ensure the service is available.', + icon: 'fa-wifi', + type: 'connection' + }, + { + patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], + title: 'Service Unavailable', + message: 'The transcription service is temporarily unavailable.', + guidance: 'This is usually temporary. Please try again in a few minutes.', + icon: 'fa-server', + type: 'service_error' + }, + { + patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt', 'not valid audio'], + title: 'Invalid Audio Format', + message: 'The audio file format is not supported or the file may be corrupted.', + guidance: 'Try converting the audio to MP3 or WAV format before uploading.', + icon: 'fa-file-audio', + type: 'format' + }, + { + patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], + title: 'Audio Extraction Failed', + message: 'Could not extract audio from the uploaded file.', + guidance: 'Try converting the file to a standard audio format (MP3, WAV) before uploading.', + icon: 'fa-file-video', + type: 'extraction' + } + ]; + + // Check patterns + for (const pattern of patterns) { + for (const p of pattern.patterns) { + if (lowerText.includes(p)) { + return { + title: pattern.title, + message: pattern.message, + guidance: pattern.guidance, + icon: pattern.icon, + type: pattern.type, + isKnown: true, + technical: text, + isFormattedError: true + }; + } + } + } + + // Unknown error - clean it up as best we can + let cleanMessage = text; + for (const prefix of ['Transcription failed:', 'Processing failed:', 'Error:', 'ASR processing failed:']) { + if (cleanMessage.startsWith(prefix)) { + cleanMessage = cleanMessage.substring(prefix.length).trim(); + } + } + + // Truncate if too long + if (cleanMessage.length > 200) { + cleanMessage = cleanMessage.substring(0, 200) + '...'; + } + + return { + title: 'Processing Error', + message: cleanMessage, + guidance: 'If this error persists, try reprocessing the recording.', + icon: 'fa-exclamation-circle', + type: 'unknown', + isKnown: false, + technical: text, + isFormattedError: true + }; +} + +/** + * Check if a transcription text is actually an error message. + * @param {string} text - The transcription text + * @returns {boolean} + */ +export function isErrorMessage(text) { + if (!text) return false; + + if (text.startsWith('ERROR_JSON:')) return true; + + const errorPrefixes = [ + 'Transcription failed:', + 'Processing failed:', + 'ASR processing failed:', + 'Audio extraction failed:' + ]; + + return errorPrefixes.some(prefix => text.startsWith(prefix)); +} + +/** + * Generate HTML for displaying an error nicely. + * @param {Object} error - Parsed error object from parseStoredError + * @param {boolean} showTechnical - Whether to show technical details + * @returns {string} - HTML string + */ +export function generateErrorHTML(error, showTechnical = false) { + if (!error) return ''; + + const typeColors = { + size_limit: 'amber', + timeout: 'orange', + auth: 'red', + rate_limit: 'yellow', + connection: 'blue', + service_error: 'purple', + format: 'pink', + extraction: 'indigo', + billing: 'red', + model: 'gray', + unknown: 'gray' + }; + + const color = typeColors[error.type] || 'gray'; + + let html = ` + <div class="error-display bg-${color}-500/10 border border-${color}-500/30 rounded-lg p-4"> + <div class="flex items-start gap-3"> + <div class="flex-shrink-0 w-10 h-10 rounded-full bg-${color}-500/20 flex items-center justify-center"> + <i class="fas ${error.icon} text-${color}-500"></i> + </div> + <div class="flex-1 min-w-0"> + <h3 class="text-lg font-semibold text-${color}-600 dark:text-${color}-400 mb-1"> + ${escapeHtml(error.title)} + </h3> + <p class="text-[var(--text-primary)] mb-2"> + ${escapeHtml(error.message)} + </p> + ${error.guidance ? ` + <div class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded p-2"> + <i class="fas fa-lightbulb text-yellow-500 mt-0.5"></i> + <span>${escapeHtml(error.guidance)}</span> + </div> + ` : ''} + </div> + </div> + ${showTechnical && error.technical ? ` + <details class="mt-3 text-xs"> + <summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]"> + Technical details + </summary> + <pre class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded overflow-x-auto text-[var(--text-muted)]">${escapeHtml(error.technical)}</pre> + </details> + ` : ''} + </div> + `; + + return html; +} + +/** + * Escape HTML special characters. + * @param {string} text + * @returns {string} + */ +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Export for use in Vue components +export default { + parseStoredError, + isErrorMessage, + generateErrorHTML +}; diff --git a/static/js/modules/utils/formatters.js b/static/js/modules/utils/formatters.js new file mode 100644 index 0000000..e5e6a85 --- /dev/null +++ b/static/js/modules/utils/formatters.js @@ -0,0 +1,139 @@ +/** + * Formatting utility functions + */ + +export const formatFileSize = (bytes) => { + if (bytes == null || bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes < 0) bytes = 0; + const i = bytes === 0 ? 0 : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k))); + const size = i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2)); + return size + ' ' + sizes[i]; +}; + +export const formatDisplayDate = (dateString) => { + if (!dateString) return ''; + try { + let date = new Date(dateString); + + if (isNaN(date.getTime())) { + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + date = new Date(dateString + 'T00:00:00'); + } else { + return dateString; + } + } + + if (isNaN(date.getTime())) { + return dateString; + } + + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); + } catch (e) { + console.error("Error formatting date:", e); + return dateString; + } +}; + +export const formatShortDate = (dateString) => { + if (!dateString) return ''; + try { + let date = new Date(dateString); + + if (isNaN(date.getTime())) { + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + date = new Date(dateString + 'T00:00:00'); + } else { + return dateString; + } + } + + if (isNaN(date.getTime())) { + return dateString; + } + + const now = new Date(); + const isCurrentYear = date.getFullYear() === now.getFullYear(); + + if (isCurrentYear) { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } else { + return date.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }); + } + } catch (e) { + console.error("Error formatting short date:", e); + return dateString; + } +}; + +export const formatStatus = (status, t) => { + if (!status || status === 'COMPLETED') return ''; + const statusMap = { + 'PENDING': t('status.queued'), + 'QUEUED': t('status.queued'), + 'PROCESSING': t('status.processing'), + 'TRANSCRIBING': t('status.transcribing'), + 'SUMMARIZING': t('status.summarizing'), + 'FAILED': t('status.failed'), + 'UPLOADING': t('status.uploading') + }; + return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +}; + +export const getStatusClass = (status) => { + switch(status) { + case 'PENDING': return 'status-pending'; + case 'QUEUED': return 'status-pending'; + case 'PROCESSING': return 'status-processing'; + case 'SUMMARIZING': return 'status-summarizing'; + case 'COMPLETED': return ''; + case 'FAILED': return 'status-failed'; + default: return 'status-pending'; + } +}; + +export const formatTime = (seconds) => { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +}; + +export const formatDuration = (totalSeconds) => { + if (totalSeconds == null || totalSeconds < 0) return 'N/A'; + + if (totalSeconds < 1) { + return `${totalSeconds.toFixed(2)} seconds`; + } + + totalSeconds = Math.round(totalSeconds); + + if (totalSeconds < 60) { + return `${totalSeconds} sec`; + } + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + let parts = []; + if (hours > 0) { + parts.push(`${hours} hr`); + } + if (minutes > 0) { + parts.push(`${minutes} min`); + } + if (hours === 0 && seconds > 0) { + parts.push(`${seconds} sec`); + } + + return parts.join(' '); +}; + +export const formatProcessingDuration = (seconds) => { + if (!seconds && seconds !== 0) return null; + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +}; diff --git a/static/js/modules/utils/index.js b/static/js/modules/utils/index.js new file mode 100644 index 0000000..ae21318 --- /dev/null +++ b/static/js/modules/utils/index.js @@ -0,0 +1,9 @@ +/** + * Utils module exports + */ + +export * from './formatters.js'; +export * from './dates.js'; +export { createApiClient } from './api.js'; +export { showToast } from './toast.js'; +export { getContrastTextColor } from './colors.js'; diff --git a/static/js/modules/utils/toast.js b/static/js/modules/utils/toast.js new file mode 100644 index 0000000..9c4ab60 --- /dev/null +++ b/static/js/modules/utils/toast.js @@ -0,0 +1,91 @@ +/** + * Toast notification utility + */ + +export const showToast = (message, iconClass = 'fa-info-circle', duration = 3000, type = 'info') => { + const container = document.getElementById('toastContainer'); + if (!container) { + console.warn('Toast container not found'); + return; + } + + // Determine colors and styles based on type + let bgColor, textColor, iconColor, borderColor; + + switch (type) { + case 'success': + bgColor = '#10b981'; // green-500 + textColor = '#ffffff'; + iconColor = '#ffffff'; + borderColor = '#059669'; // green-600 + break; + case 'error': + bgColor = '#ef4444'; // red-500 + textColor = '#ffffff'; + iconColor = '#ffffff'; + borderColor = '#dc2626'; // red-600 + break; + case 'warning': + bgColor = '#f59e0b'; // amber-500 + textColor = '#ffffff'; + iconColor = '#ffffff'; + borderColor = '#d97706'; // amber-600 + break; + case 'info': + default: + bgColor = '#3b82f6'; // blue-500 + textColor = '#ffffff'; + iconColor = '#ffffff'; + borderColor = '#2563eb'; // blue-600 + break; + } + + const toast = document.createElement('div'); + toast.className = 'toast-message px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 opacity-0 min-w-[300px]'; + toast.style.backgroundColor = bgColor; + toast.style.color = textColor; + toast.style.border = `1px solid ${borderColor}`; + + // Handle icon class - support both old format (just icon name) and new format (full class) + let fullIconClass = iconClass; + if (!iconClass.includes(' ')) { + // Old format: just the icon name like 'fa-check-circle' + fullIconClass = `fas ${iconClass}`; + } + + toast.innerHTML = ` + <i class="${fullIconClass}" style="color: ${iconColor}"></i> + <span class="flex-1">${message}</span> + `; + + // Make toast clickable to dismiss + toast.style.cursor = 'pointer'; + + container.appendChild(toast); + + // Trigger fly-in animation + requestAnimationFrame(() => { + toast.classList.remove('opacity-0'); + toast.classList.add('opacity-100', 'toast-show'); + }); + + // Function to dismiss the toast + const dismissToast = () => { + toast.classList.remove('opacity-100', 'toast-show'); + toast.classList.add('opacity-0'); + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + }; + + // Add click handler to dismiss toast + toast.addEventListener('click', () => { + clearTimeout(timeoutId); + dismissToast(); + }); + + // Auto-dismiss after duration + const timeoutId = setTimeout(dismissToast, duration); +}; diff --git a/static/js/shared-components.js b/static/js/shared-components.js new file mode 100644 index 0000000..6ad28da --- /dev/null +++ b/static/js/shared-components.js @@ -0,0 +1,262 @@ +// Shared UI Components and Functionality +// This file contains reusable Vue composition functions and utilities +// that can be used across multiple pages (index, inquire, admin, etc.) + +// Dark Mode Composition +function useDarkMode() { + const isDarkMode = Vue.ref(false); + + const toggleDarkMode = () => { + isDarkMode.value = !isDarkMode.value; + if (isDarkMode.value) { + document.documentElement.classList.add('dark'); + localStorage.setItem('darkMode', 'true'); + } else { + document.documentElement.classList.remove('dark'); + localStorage.setItem('darkMode', 'false'); + } + }; + + const initializeDarkMode = () => { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const savedMode = localStorage.getItem('darkMode'); + if (savedMode === 'true' || (savedMode === null && prefersDark)) { + isDarkMode.value = true; + document.documentElement.classList.add('dark'); + } else { + isDarkMode.value = false; + document.documentElement.classList.remove('dark'); + } + }; + + return { + isDarkMode, + toggleDarkMode, + initializeDarkMode + }; +} + +// Color Scheme Composition +function useColorScheme() { + const showColorSchemeModal = Vue.ref(false); + const currentColorScheme = Vue.ref('blue'); + const isDarkMode = Vue.ref(false); + + const colorSchemes = { + light: [ + { id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', accent: '#3b82f6', hover: '#2563eb' }, + { id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', accent: '#10b981', hover: '#059669' }, + { id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', accent: '#8b5cf6', hover: '#7c3aed' }, + { id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', accent: '#f43f5e', hover: '#e11d48' }, + { id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', accent: '#f59e0b', hover: '#d97706' }, + { id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', accent: '#06b6d4', hover: '#0891b2' } + ], + dark: [ + { id: 'blue', name: 'Midnight Blue', description: 'Deep blue for focused night work', accent: '#60a5fa', hover: '#3b82f6' }, + { id: 'emerald', name: 'Emerald Night', description: 'Rich green for comfortable viewing', accent: '#34d399', hover: '#10b981' }, + { id: 'purple', name: 'Deep Purple', description: 'Luxurious purple for creative sessions', accent: '#a78bfa', hover: '#8b5cf6' }, + { id: 'rose', name: 'Crimson', description: 'Bold red-pink for energetic work', accent: '#fb7185', hover: '#f43f5e' }, + { id: 'amber', name: 'Golden Hour', description: 'Warm amber for reduced eye strain', accent: '#fbbf24', hover: '#f59e0b' }, + { id: 'teal', name: 'Electric Cyan', description: 'Vibrant cyan for modern aesthetics', accent: '#22d3ee', hover: '#06b6d4' } + ] + }; + + const applyColorScheme = (schemeId) => { + const schemes = isDarkMode.value ? colorSchemes.dark : colorSchemes.light; + const scheme = schemes.find(s => s.id === schemeId); + if (scheme) { + // Remove all theme classes + const allThemeClasses = [ + ...colorSchemes.light.map(s => `theme-light-${s.id}`), + ...colorSchemes.dark.map(s => `theme-dark-${s.id}`) + ].filter(c => !c.includes('blue')); // blue is the default, no class needed + + document.documentElement.classList.remove(...allThemeClasses); + + // Apply new theme class if not blue (default) + if (schemeId !== 'blue') { + const themeClass = `theme-${isDarkMode.value ? 'dark' : 'light'}-${schemeId}`; + document.documentElement.classList.add(themeClass); + } + + // Don't set CSS variables - let the theme classes handle all colors + localStorage.setItem('colorScheme', schemeId); + currentColorScheme.value = schemeId; + } + }; + + const initializeColorScheme = (darkMode) => { + isDarkMode.value = darkMode; + const savedScheme = localStorage.getItem('colorScheme') || 'blue'; + currentColorScheme.value = savedScheme; + applyColorScheme(savedScheme); + }; + + // Watch for dark mode changes and reapply color scheme + Vue.watch(() => isDarkMode.value, (newValue) => { + applyColorScheme(currentColorScheme.value); + }); + + const openColorSchemeModal = () => { + showColorSchemeModal.value = true; + }; + + const closeColorSchemeModal = () => { + showColorSchemeModal.value = false; + }; + + const selectColorScheme = (schemeId) => { + applyColorScheme(schemeId); + const scheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === schemeId); + if (window.showToast && scheme) { + window.showToast(`Applied ${scheme.name} theme`, 'fa-palette'); + } + }; + + const resetColorScheme = () => { + applyColorScheme('blue'); + if (window.showToast) { + const defaultScheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === 'blue'); + window.showToast(`Reset to default ${defaultScheme?.name || 'Ocean Blue'} theme`, 'fa-undo'); + } + }; + + return { + showColorSchemeModal, + currentColorScheme, + colorSchemes, + openColorSchemeModal, + closeColorSchemeModal, + selectColorScheme, + resetColorScheme, + applyColorScheme, + initializeColorScheme + }; +} + +// Shared Transcripts Modal Composition +function useSharesModal() { + const showSharesListModal = Vue.ref(false); + const userShares = Vue.ref([]); + const isLoadingShares = Vue.ref(false); + + const openSharesList = async () => { + isLoadingShares.value = true; + showSharesListModal.value = true; + try { + const response = await fetch('/api/shares'); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to load shared items'); + userShares.value = data; + } catch (error) { + if (window.setGlobalError) { + window.setGlobalError(`Failed to load shared items: ${error.message}`); + } else { + console.error('Failed to load shared items:', error); + } + } finally { + isLoadingShares.value = false; + } + }; + + const closeSharesList = () => { + showSharesListModal.value = false; + }; + + const copyShareLink = async (shareId) => { + const url = `${window.location.origin}/share/${shareId}`; + try { + await navigator.clipboard.writeText(url); + if (window.showToast) { + window.showToast('Share link copied to clipboard', 'fa-link'); + } + } catch (err) { + if (window.setGlobalError) { + window.setGlobalError('Failed to copy link to clipboard'); + } + } + }; + + const deleteShare = async (shareId) => { + if (!confirm('Are you sure you want to delete this share?')) return; + + try { + const response = await fetch(`/api/shares/${shareId}`, { + method: 'DELETE', + headers: { + 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to delete share'); + } + + userShares.value = userShares.value.filter(share => share.id !== shareId); + if (window.showToast) { + window.showToast('Share deleted successfully', 'fa-trash'); + } + } catch (error) { + if (window.setGlobalError) { + window.setGlobalError(`Failed to delete share: ${error.message}`); + } + } + }; + + return { + showSharesListModal, + userShares, + isLoadingShares, + openSharesList, + closeSharesList, + copyShareLink, + deleteShare + }; +} + +// User Menu Composition +function useUserMenu() { + const isUserMenuOpen = Vue.ref(false); + + const toggleUserMenu = () => { + isUserMenuOpen.value = !isUserMenuOpen.value; + }; + + const closeUserMenu = () => { + isUserMenuOpen.value = false; + }; + + // Close menu when clicking outside + Vue.onMounted(() => { + const handleClickOutside = (e) => { + const userMenuButton = e.target.closest('button[class*="flex items-center gap"]'); + const userMenuDropdown = e.target.closest('div[class*="absolute right-0"]'); + const isUserMenuButtonClick = userMenuButton && userMenuButton.querySelector('i.fa-user-circle'); + + if (!isUserMenuButtonClick && !userMenuDropdown) { + isUserMenuOpen.value = false; + } + }; + + document.addEventListener('click', handleClickOutside); + + Vue.onUnmounted(() => { + document.removeEventListener('click', handleClickOutside); + }); + }); + + return { + isUserMenuOpen, + toggleUserMenu, + closeUserMenu + }; +} + +// Export for use in Vue components +window.SharedComponents = { + useDarkMode, + useColorScheme, + useSharesModal, + useUserMenu +}; \ No newline at end of file diff --git a/static/js/utils/apiClient.js b/static/js/utils/apiClient.js new file mode 100644 index 0000000..56b09c1 --- /dev/null +++ b/static/js/utils/apiClient.js @@ -0,0 +1,119 @@ +/** + * API client utilities for making HTTP requests + */ + +class APIError extends Error { + constructor(message, status, data) { + super(message); + this.name = 'APIError'; + this.status = status; + this.data = data; + } +} + +/** + * Safely parse JSON response, handling HTML error pages gracefully + */ +async function safeJsonParse(response) { + const contentType = response.headers.get('content-type') || ''; + + // If response is not JSON, extract useful error from HTML + if (!contentType.includes('application/json')) { + const text = await response.text(); + // Try to extract error message from HTML title or h1 + const titleMatch = text.match(/<title>([^<]+)<\/title>/i); + const h1Match = text.match(/<h1>([^<]+)<\/h1>/i); + const errorMsg = titleMatch?.[1] || h1Match?.[1] || + `Server returned non-JSON response (status ${response.status})`; + throw new APIError(errorMsg, response.status, { htmlResponse: true }); + } + + return response.json(); +} + +export async function apiRequest(url, options = {}) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + ...(csrfToken && { 'X-CSRFToken': csrfToken }) + } + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...options.headers + } + }; + + try { + const response = await fetch(url, mergedOptions); + const data = await safeJsonParse(response); + + if (!response.ok) { + throw new APIError( + data.error || 'Request failed', + response.status, + data + ); + } + + return data; + } catch (error) { + if (error instanceof APIError) { + throw error; + } + throw new APIError(error.message, 0, null); + } +} + +export async function uploadFile(url, file, onProgress = null) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const formData = new FormData(); + formData.append('audio_file', file); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + if (onProgress) { + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + onProgress(percentComplete); + } + }); + } + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + resolve(data); + } catch (e) { + reject(new Error('Invalid response format')); + } + } else { + try { + const error = JSON.parse(xhr.responseText); + reject(new APIError(error.error || 'Upload failed', xhr.status, error)); + } catch (e) { + reject(new APIError('Upload failed', xhr.status, null)); + } + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Network error')); + }); + + xhr.open('POST', url); + if (csrfToken) { + xhr.setRequestHeader('X-CSRFToken', csrfToken); + } + xhr.send(formData); + }); +} diff --git a/static/js/utils/audioUtils.js b/static/js/utils/audioUtils.js new file mode 100644 index 0000000..ce613c2 --- /dev/null +++ b/static/js/utils/audioUtils.js @@ -0,0 +1,61 @@ +/** + * Audio processing and visualization utilities + */ + +export function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +export function createAudioVisualizer(canvas, stream, color = '#3b82f6') { + if (!canvas || !stream) return null; + + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const analyser = audioContext.createAnalyser(); + const microphone = audioContext.createMediaStreamSource(stream); + + analyser.fftSize = 256; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + microphone.connect(analyser); + + const canvasCtx = canvas.getContext('2d'); + const WIDTH = canvas.width; + const HEIGHT = canvas.height; + + function draw() { + requestAnimationFrame(draw); + analyser.getByteFrequencyData(dataArray); + + canvasCtx.fillStyle = 'rgb(17, 24, 39)'; + canvasCtx.fillRect(0, 0, WIDTH, HEIGHT); + + const barWidth = (WIDTH / bufferLength) * 2.5; + let barHeight; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + barHeight = (dataArray[i] / 255) * HEIGHT; + canvasCtx.fillStyle = color; + canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight); + x += barWidth + 1; + } + } + + draw(); + + return { audioContext, analyser, stop: () => audioContext.close() }; +} + +export function detectBrowser() { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf('firefox') > -1) return 'firefox'; + if (userAgent.indexOf('chrome') > -1 && userAgent.indexOf('edge') === -1) return 'chrome'; + if (userAgent.indexOf('safari') > -1 && userAgent.indexOf('chrome') === -1) return 'safari'; + if (userAgent.indexOf('edge') > -1) return 'edge'; + return 'unknown'; +} diff --git a/static/js/utils/dateUtils.js b/static/js/utils/dateUtils.js new file mode 100644 index 0000000..ec8cd8c --- /dev/null +++ b/static/js/utils/dateUtils.js @@ -0,0 +1,83 @@ +/** + * Date utility functions for formatting and parsing dates + */ + +export function formatDateTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + } else if (diffDays === 1) { + return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + } else if (diffDays < 7) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); + return dayName + ' at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + } else { + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + } +} + +export function formatTimeAgo(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? 's' : ''} ago`; + + const diffYears = Math.floor(diffDays / 365); + return `${diffYears} year${diffYears > 1 ? 's' : ''} ago`; +} + +export function formatDuration(seconds) { + if (!seconds || seconds < 0) return '0:00'; + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hrs > 0) { + return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export function parseDateRange(preset) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (preset) { + case 'today': + return { start: today, end: new Date() }; + case 'yesterday': + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + return { start: yesterday, end: today }; + case 'week': + const weekStart = new Date(today); + weekStart.setDate(weekStart.getDate() - 7); + return { start: weekStart, end: new Date() }; + case 'month': + const monthStart = new Date(today); + monthStart.setMonth(monthStart.getMonth() - 1); + return { start: monthStart, end: new Date() }; + case 'year': + const yearStart = new Date(today); + yearStart.setFullYear(yearStart.getFullYear() - 1); + return { start: yearStart, end: new Date() }; + default: + return { start: null, end: null }; + } +} diff --git a/static/locales/de.json b/static/locales/de.json new file mode 100644 index 0000000..296e9aa --- /dev/null +++ b/static/locales/de.json @@ -0,0 +1,1505 @@ +{ + "aboutPage": { + "aiSummarization": "KI-Zusammenfassung", + "aiSummarizationDesc": "OpenRouter- und Ollama-Integration mit benutzerdefinierten Prompts", + "asrEnabled": "ASR Aktiviert", + "asrEndpoint": "ASR-Endpunkt", + "audioTranscription": "Audio-Transkription", + "audioTranscriptionDesc": "Whisper API und benutzerdefinierte ASR-Unterstützung mit hoher Genauigkeit", + "backend": "Backend", + "database": "Datenbank", + "deployment": "Bereitstellung", + "dockerDescription": "Offizielle Docker Images", + "dockerHub": "Docker Hub", + "documentation": "Dokumentation", + "documentationDescription": "Setup-Anleitungen und Benutzerhandbuch", + "endpoint": "Endpunkt", + "frontend": "Frontend", + "githubDescription": "Quellcode, Issues und Releases", + "githubRepository": "GitHub Repository", + "inquireMode": "Anfrage-Modus", + "inquireModeDesc": "Semantische Suche in all Ihren Aufnahmen", + "interactiveChat": "Interaktiver Chat", + "interactiveChatDesc": "Chatten Sie mit Ihren Transkriptionen mittels KI", + "keyFeatures": "Hauptfunktionen", + "largeLanguageModel": "Großes Sprachmodell", + "model": "Modell", + "projectDescription": "Verwandeln Sie Ihre Audioaufnahmen in organisierte, durchsuchbare Notizen mit KI-gestützten Funktionen für Transkription, Zusammenfassung und interaktiven Chat.", + "projectLinks": "Projekt-Links", + "sharingExport": "Teilen & Exportieren", + "sharingExportDesc": "Teilen Sie Aufnahmen und exportieren Sie in verschiedene Formate", + "speakerDiarization": "Sprecher-Diarisierung", + "speakerDiarizationDesc": "Automatische Identifikation und Kennzeichnung verschiedener Sprecher", + "speechRecognition": "Spracherkennung", + "systemConfiguration": "Systemkonfiguration", + "tagline": "KI-gestützte Audio-Transkription und Notizen-App", + "technologyStack": "Technologie-Stack", + "title": "Über", + "version": "Version", + "whisperApi": "Whisper API" + }, + "aboutPageDetails": { + "aiSummarizationDesc": "OpenRouter- und Ollama-Integration mit benutzerdefinierten Prompts", + "asrEnabled": "ASR Aktiviert", + "asrEndpoint": "ASR-Endpunkt", + "audioTranscriptionDesc": "Whisper-API und benutzerdefinierte ASR-Unterstützung mit hoher Genauigkeit", + "backend": "Backend", + "database": "Datenbank", + "deployment": "Bereitstellung", + "dockerDescription": "Offizielle Docker-Images", + "documentationDescription": "Setup-Anleitungen und Benutzerhandbuch", + "endpoint": "Endpunkt", + "frontend": "Frontend", + "githubDescription": "Quellcode, Issues und Releases", + "inquireModeDesc": "Semantische Suche in all Ihren Aufnahmen", + "interactiveChatDesc": "Chatten Sie mit Ihren Transkriptionen mithilfe von KI", + "model": "Modell", + "no": "Nein", + "sharingExportDesc": "Aufnahmen teilen und in verschiedene Formate exportieren", + "speakerDiarizationDesc": "Verschiedene Sprecher automatisch identifizieren und beschriften", + "whisperApi": "Whisper-API", + "yes": "Ja" + }, + "account": { + "accountActions": "Kontoaktionen", + "autoLabel": "Auto-label", + "autoSummarizationDisabled": "Auto-summarization disabled by admin", + "autoSummarize": "Auto-summarize", + "changePassword": "Passwort ändern", + "chooseLanguageForInterface": "Wählen Sie die Sprache für die Anwendungsoberfläche", + "companyOrganization": "Unternehmen / Organisation", + "completedRecordings": "Abgeschlossen", + "defaultHotwords": "Default Hotwords", + "defaultHotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote, SDRs", + "defaultInitialPrompt": "Default Initial Prompt", + "defaultInitialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools. The speakers discuss CTranslate2, PyAnnote, and SDRs.", + "email": "E-Mail", + "failedRecordings": "Fehlgeschlagen", + "fullName": "Vollständiger Name", + "goToRecordings": "Zu den Aufnahmen gehen", + "interfaceLanguage": "Interface-Sprache", + "jobTitle": "Berufsbezeichnung", + "languageForSummaries": "Sprache für Titel, Zusammenfassungen und Chat. Leer lassen für Standard (Standardverhalten Ihrer gewählten Modelle).", + "languagePreferences": "Spracheinstellungen", + "leaveBlankForAutoDetect": "Leer lassen für automatische Erkennung durch den Transkriptionsdienst", + "manageSpeakers": "Sprecher verwalten", + "personalFolder": "Personal Folder (Not Associated with a Group)", + "personalInfo": "Persönliche Informationen", + "personalTag": "Personal Tag (Not Associated with a Group)", + "preferredOutputLanguage": "Bevorzugte Sprache für Chatbot und Zusammenfassungen", + "processingRecordings": "In Bearbeitung", + "saveAllPreferences": "Alle Einstellungen speichern", + "ssoLinkAccount": "Link {{provider}} account", + "ssoLinked": "Linked", + "ssoNotLinked": "Not linked", + "ssoProvider": "Provider", + "ssoSetPasswordFirst": "To unlink SSO, you must first set a password.", + "ssoSubject": "Subject:", + "ssoUnlinkAccount": "Unlink {{provider}} account", + "ssoUnlinkConfirm": "Are you sure you want to unlink your SSO account? You will need to use your password to log in.", + "statistics": "Kontostatistiken", + "title": "Kontoinformationen", + "totalRecordings": "Gesamte Aufnahmen", + "transcriptionHints": "Transcription Hints", + "transcriptionHintsDesc": "These defaults are used when no tag or folder overrides are set. They help improve transcription accuracy for your specific use case.", + "transcriptionLanguage": "Transkriptionssprache", + "userDetails": "Benutzerdetails", + "username": "Benutzername" + }, + "accountTabs": { + "about": "Über", + "apiTokens": "API-Token", + "customPrompts": "Benutzerdefinierte Prompts", + "folderManagement": "Ordnerverwaltung", + "help": "Hilfe", + "namingTemplates": "Benennung", + "promptOptions": "Prompt-Optionen", + "sharedTranscripts": "Geteilte Transkripte", + "speakersManagement": "Sprecher-Verwaltung", + "tagManagement": "Tag-Verwaltung", + "templates": "Vorlagen", + "transcriptTemplates": "Transkript-Vorlagen" + }, + "admin": { + "title": "Administration", + "userMenu": "Benutzermenü" + }, + "adminDashboard": { + "aboutInquireMode": "Über den Nachfrage-Modus", + "actions": "AKTIONEN", + "addNewUser": "Neuen Benutzer Hinzufügen", + "addUser": "Benutzer hinzufügen", + "additionalContext": "Zusätzlicher Kontext", + "admin": "ADMIN", + "adminDefaultPrompt": "Admin Standard-Prompt", + "adminDefaultPromptDesc": "Der oben konfigurierte Prompt (auf dieser Seite angezeigt)", + "adminUser": "Administrator-Benutzer", + "allRecordingsProcessed": "Alle Aufnahmen sind verarbeitet!", + "allowed": "Allowed", + "available": "Verfügbar", + "blocked": "blockiert", + "budgetWarnings": "Budget-Warnungen", + "characters": "Zeichen", + "chunkSize": "Chunk-Größe", + "complete": "abgeschlossen", + "completedRecordings": "Abgeschlossen", + "confirmPasswordLabel": "Passwort Bestätigen", + "contextNotes": { + "jsonConversion": "JSON-Transkripte werden vor dem Senden in ein sauberes Textformat konvertiert", + "modelConfig": "Das verwendete Modell wird über die Umgebungsvariable TEXT_MODEL_NAME konfiguriert", + "tagPrompts": "Wenn mehrere Tag-Prompts existieren, werden sie in der Reihenfolge zusammengeführt, in der die Tags hinzugefügt wurden", + "transcriptLimit": "Transkripte sind auf eine konfigurierbare Zeichenanzahl begrenzt (Standard: 30.000)" + }, + "createFirstGroup": "Create Your First Group", + "created": "Created", + "currentUsage": "Aktuelle Nutzung", + "currentUsageMinutes": "Aktuelle Nutzung", + "defaultPromptInfo": "Dieser Standard-Prompt wird für alle Benutzer verwendet, die keinen eigenen benutzerdefinierten Prompt in ihren Kontoeinstellungen festgelegt haben.", + "defaultPrompts": "Standard-Prompts", + "defaultSummarizationPrompt": "Standard-Zusammenfassungsprompt", + "description": "Description", + "editUser": "Benutzer Bearbeiten", + "email": "E-MAIL", + "emailLabel": "E-Mail", + "embeddingModel": "Einbettungsmodell", + "embeddingsStatus": "Einbettungsstatus", + "errors": { + "failedToLoadPrompt": "Fehler beim Laden des Standard-Prompts", + "failedToSavePrompt": "Fehler beim Speichern des Standard-Prompts. Bitte versuchen Sie es erneut.", + "invalidFileSize": "Bitte geben Sie eine gültige Größe zwischen 1 und 10000 MB ein", + "invalidInteger": "Bitte geben Sie eine gültige Ganzzahl ein", + "invalidNumber": "Bitte geben Sie eine gültige Zahl ein", + "maxTimeout": "Timeout kann 10 Stunden (36000 Sekunden) nicht überschreiten", + "minCharacters": "Bitte geben Sie eine gültige Anzahl von mindestens 1000 Zeichen ein", + "minTimeout": "Timeout muss mindestens 60 Sekunden betragen" + }, + "failedRecordings": "Fehlgeschlagen", + "groupName": "Group Name", + "groupsTab": "Groups", + "id": "ID", + "inquireModeDescription": "Der Nachfrage-Modus ermöglicht es Benutzern, mit natürlichen Sprachfragen über mehrere Transkriptionen zu suchen. Es funktioniert, indem Transkriptionen in Chunks aufgeteilt und durchsuchbare Einbettungen mit KI-Modellen erstellt werden.", + "languagePreferenceNote": "Hinweis: Wenn der Benutzer eine Ausgabesprachpräferenz festgelegt hat, wird \" Stellen Sie sicher, dass Ihre Antwort in {Sprache} ist.\" hinzugefügt.", + "lastUpdated": "Zuletzt aktualisiert", + "maxFileSizeHelp": "Maximale Dateigröße in Megabyte (1-10000 MB)", + "megabytes": "MB", + "members": "Members", + "membersCount": "members", + "minutes": "Minuten", + "monthlyCost": "Monatliche Kosten", + "monthlyTokenBudget": "Monatliches Token-Budget", + "monthlyTranscriptionBudget": "Monatliches Transkriptionsbudget (Minuten)", + "needProcessing": "Benötigt Verarbeitung", + "never": "Nie", + "newPasswordLabel": "Neues Passwort (leer lassen, um das aktuelle zu behalten)", + "no": "Nein", + "noData": "Keine Daten", + "noDescription": "No description", + "noDescriptionAvailable": "Keine Beschreibung verfügbar", + "noGroupsAdmin": "You are not an admin of any groups yet", + "noGroupsCreated": "No groups created yet", + "noLimit": "Keine Begrenzung", + "noMembersYet": "No members yet", + "noTranscriptionData": "Keine Transkriptionsnutzungsdaten verfügbar", + "none": "Keine", + "notSet": "Nicht festgelegt", + "overlap": "Überlappung", + "passwordLabel": "Passwort", + "passwordsDoNotMatch": "Passwords do not match", + "pendingRecordings": "Ausstehend", + "percentUsed": "verwendet", + "placeholdersNote": "Platzhalter werden beim Verarbeiten einer Aufnahme durch tatsächliche Werte ersetzt.", + "processAllRecordings": "Alle Aufnahmen Verarbeiten", + "processNext10": "Nächste 10 Verarbeiten", + "processedForInquire": "Für Nachfrage Verarbeitet", + "processing": "Verarbeitung läuft...", + "processingActions": "Verarbeitungsaktionen", + "processingProgress": "Verarbeitungsfortschritt", + "processingRecordings": "In Bearbeitung", + "promptDescription": "Dieser Prompt wird verwendet, um Zusammenfassungen für alle Aufnahmen zu generieren, wenn Benutzer keinen eigenen Prompt festgelegt haben.", + "promptHierarchy": "Prompt-Hierarchie", + "promptPriorityDescription": "Das System verwendet die folgende Prioritätsreihenfolge bei der Auswahl des zu verwendenden Prompts:", + "promptResetMessage": "Prompt auf Systemstandard zurückgesetzt. Klicken Sie auf \"Änderungen speichern\", um anzuwenden.", + "promptSavedSuccessfully": "Standard-Prompt erfolgreich gespeichert!", + "publicShare": "Public Share", + "recordingStatusDistribution": "Verteilung des Aufnahmestatus", + "recordings": "AUFNAHMEN", + "recordingsNeedProcessing": "Es gibt {{count}} Aufnahmen, die für den Nachfrage-Modus verarbeitet werden müssen.", + "refreshStatus": "Status Aktualisieren", + "resetToDefault": "Auf Standard zurücksetzen", + "saving": "Speichern...", + "searchUsers": "Benutzer suchen...", + "seconds": "Sekunden", + "settings": { + "asrTimeoutDesc": "Maximale Zeit in Sekunden, um auf den Abschluss der ASR-Transkription zu warten. Standard ist 1800 Sekunden (30 Minuten).", + "defaultSummaryPromptDesc": "Standard-Zusammenfassungsprompt, der verwendet wird, wenn Benutzer keinen eigenen Prompt festgelegt haben. Dies dient als Basis-Prompt für alle Benutzer.", + "maxFileSizeDesc": "Maximale Dateigröße für Audio-Uploads in Megabyte (MB).", + "recordingDisclaimerDesc": "Rechtlicher Hinweis, der Benutzern vor Beginn der Aufnahme angezeigt wird. Unterstützt Markdown-Formatierung. Leer lassen zum Deaktivieren.", + "uploadDisclaimerDesc": "Rechtlicher Hinweis, der vor dem Hochladen von Dateien angezeigt wird. Unterstützt Markdown. Leer lassen zum Deaktivieren.", + "customBannerDesc": "Benutzerdefiniertes Banner oben auf der Seite. Unterstützt Markdown. Leer lassen zum Deaktivieren.", + "transcriptLengthLimitDesc": "Maximale Anzahl von Zeichen, die vom Transkript an das LLM für Zusammenfassung und Chat gesendet werden. Verwenden Sie -1 für kein Limit." + }, + "storageUsed": "GENUTZTER SPEICHER", + "summarizationInstructions": "Zusammenfassungsanweisungen", + "systemFallback": "System-Fallback", + "systemFallbackDesc": "Ein hartcodierter Standard, wenn keiner der oben genannten festgelegt ist", + "systemPrompt": "System-Prompt", + "systemSettings": "Systemeinstellungen", + "systemStatistics": "Systemstatistiken", + "tagCustomPrompt": "Tag Benutzerdefinierter Prompt", + "tagCustomPromptDesc": "Wenn eine Aufnahme Tags mit benutzerdefinierten Prompts hat", + "textSearchOnly": "Nur Textsuche", + "thisMonth": "Dieser Monat", + "timeoutRecommendation": "Empfohlen: 30-120 Minuten für lange Audiodateien", + "title": "Administrator-Dashboard", + "todaysMinutes": "Heutige Minuten", + "tokenBudgetExceeded": "Monatliches Token-Budget überschritten. Bitte kontaktieren Sie Ihren Administrator.", + "tokenBudgetHelp": "Legen Sie ein monatliches Token-Limit für KI-Funktionen fest. Leer lassen für unbegrenzt.", + "tokenBudgetPlaceholder": "Leer lassen für unbegrenzt", + "tokenUsage": "Token-Nutzung", + "tokens": "Token", + "topUsers": "Top-Benutzer", + "topUsersByStorage": "Top-Benutzer nach Speicher", + "total": "Gesamt", + "totalChunks": "Gesamte Chunks", + "totalQueries": "Gesamte Abfragen", + "totalRecordings": "Gesamte Aufnahmen", + "totalStorage": "Gesamter Speicher", + "totalUsers": "Gesamte Benutzer", + "transcriptionBudgetHelp": "Begrenzen Sie die Transkriptionsminuten pro Monat. Leer lassen für unbegrenzt.", + "transcriptionBudgetPlaceholder": "Leer lassen für unbegrenzt", + "transcriptionUsage": "Transkriptionsnutzung", + "updateUser": "Benutzer Aktualisieren", + "userCustomPrompt": "Benutzerdefinierter Prompt", + "userCustomPromptDesc": "Wenn der Benutzer seinen eigenen Prompt in den Kontoeinstellungen festgelegt hat", + "userManagement": "Benutzerverwaltung", + "userMessageTemplate": "Benutzernachricht-Vorlage", + "userTranscriptionUsage": "Benutzer-Transkriptionsnutzung (Dieser Monat)", + "username": "BENUTZERNAME", + "usernameLabel": "Benutzername", + "vectorDimensions": "Vektordimensionen", + "vectorStore": "Vektorspeicher", + "vectorStoreManagement": "Vektorspeicher-Verwaltung", + "vectorStoreUpToDate": "Der Vektorspeicher ist auf dem neuesten Stand.", + "viewFullPromptStructure": "Vollständige LLM-Prompt-Struktur anzeigen", + "warning": "Warnung", + "yes": "Ja" + }, + "apiTokens": { + "activeTokens": "Aktive Token", + "createFirstToken": "Erstellen Sie Ihr erstes API-Token, um programmgesteuerten Zugriff zu ermöglichen", + "createToken": "Token erstellen", + "created": "Erstellt", + "description": "Erstellen und verwalten Sie API-Token für den programmgesteuerten Zugriff auf Ihr Konto", + "expires": "Läuft ab", + "lastUsed": "Zuletzt verwendet", + "neverUsed": "Nie verwendet", + "noExpiration": "Kein Ablaufdatum", + "noTokens": "Keine API-Token", + "securityNotice": "Sicherheitshinweis", + "securityWarning": "Behandeln Sie API-Token wie Passwörter. Sie bieten vollen Zugriff auf Ihr Konto. Teilen Sie Token niemals in öffentlich zugänglichen Bereichen wie GitHub, clientseitigem Code oder Logs", + "title": "API-Token", + "usageExamples": "Verwendungsbeispiele" + }, + "buttons": { + "addSegment": "Segment Hinzufügen", + "addSpeaker": "Sprecher hinzufügen", + "cancel": "Cancel", + "clearAllFilters": "Alle Filter löschen", + "clearCompleted": "Abgeschlossene Uploads löschen", + "clearSearchText": "Suchtext löschen", + "close": "Close", + "copy": "Kopieren", + "copyMessage": "Nachricht kopieren", + "copyNotes": "Notizen kopieren", + "copySummary": "Zusammenfassung kopieren", + "copyToClipboard": "In Zwischenablage kopieren", + "createTag": "Create Tag", + "deleteAll": "Delete All", + "deleteSpeaker": "Sprecher löschen", + "deleteTag": "Tag löschen", + "deleteUser": "Benutzer löschen", + "downloadAsWord": "Als Word herunterladen", + "downloadAudio": "Audio herunterladen", + "downloadChat": "Chat als Word-Dokument herunterladen", + "downloadNotes": "Notizen als Word-Dokument herunterladen", + "downloadSummary": "Zusammenfassung als Word-Dokument herunterladen", + "editNotes": "Notizen bearbeiten", + "editSetting": "Einstellung bearbeiten", + "editSpeakers": "Sprecher bearbeiten...", + "editSummary": "Zusammenfassung bearbeiten", + "editTag": "Tag bearbeiten", + "editTags": "Tags bearbeiten", + "editTranscription": "Transkription bearbeiten", + "editUser": "Benutzer bearbeiten", + "exportCalendar": "In Kalender exportieren", + "help": "Hilfe", + "identifySpeakers": "Sprecher identifizieren", + "next": "Weiter", + "previous": "Zurück", + "refresh": "Refresh", + "reprocessSummary": "Zusammenfassung neu verarbeiten", + "reprocessTranscription": "Transkription neu verarbeiten", + "reprocessWithAsr": "Mit ASR neu verarbeiten", + "resetStuckProcessing": "Blockierte Verarbeitung zurücksetzen", + "saveChanges": "Änderungen speichern", + "saveCustomPrompt": "Save Custom Prompt", + "saveNames": "Namen speichern", + "saveSettings": "Einstellungen Speichern", + "shareRecording": "Aufnahme teilen", + "toggleTheme": "Design umschalten", + "updateTag": "Update Tag" + }, + "changePasswordModal": { + "confirmPassword": "Neues Passwort Bestätigen", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "passwordRequirement": "Das Passwort muss mindestens 8 Zeichen lang sein", + "title": "Passwort Ändern" + }, + "chat": { + "availableAfterTranscription": "Chat wird verfügbar sein, sobald die Transkription abgeschlossen ist", + "cannotChatTranscriptionFailed": "Cannot chat: transcription failed. Please reprocess the transcription first.", + "chatWithTranscription": "Mit Transkription chatten", + "clearChat": "Chat löschen", + "cleared": "Chat cleared", + "downloadFailed": "Failed to download chat", + "downloadSuccess": "Chat downloaded successfully!", + "error": "Nachricht konnte nicht gesendet werden", + "noMessages": "Noch keine Nachrichten", + "noMessagesToDownload": "No chat messages to download.", + "placeholder": "Eine Frage zu dieser Aufnahme stellen...", + "placeholderWithHint": "Eine Frage zu dieser Aufnahme stellen... (Enter zum Senden, Strg+Enter für neue Zeile)", + "send": "Senden", + "suggestedQuestions": "Vorgeschlagene Fragen", + "thinking": "Denke nach...", + "title": "Chat", + "whatAreActionItems": "Was sind die Aktionspunkte?", + "whatAreKeyPoints": "Was sind die wichtigsten Punkte?", + "whatWasDiscussed": "Was wurde besprochen?", + "whoSaidWhat": "Wer hat was gesagt?" + }, + "colorScheme": { + "chooseRecording": "Wählen Sie eine Aufnahme aus der Seitenleiste, um ihre Transkription und Zusammenfassung zu sehen", + "darkThemes": "Dunkle Themen", + "descriptions": { + "blue": "Classic blue theme with a clean, professional feel", + "emerald": "Nature-inspired green theme for a calming experience", + "purple": "Rich purple theme with an elegant, modern look", + "rose": "Warm rose theme with a soft, inviting aesthetic", + "amber": "Warm amber tones for a cozy, productive feel", + "teal": "Cool teal theme with a refreshing, modern vibe" + }, + "lightThemes": "Helle Themen", + "names": { + "blue": "Ocean Blue", + "emerald": "Forest Green", + "purple": "Royal Purple", + "rose": "Coral Rose", + "amber": "Golden Amber", + "teal": "Arctic Teal" + }, + "resetToDefault": "Auf Standard zurücksetzen", + "selectRecording": "Eine Aufnahme auswählen", + "subtitle": "Passen Sie Ihre Benutzeroberfläche mit schönen Farbthemen an", + "themes": { + "light": { + "blue": { + "name": "Ozeanblau", + "description": "Klassisches Blau-Thema mit professioneller Anmutung" + }, + "emerald": { + "name": "Waldsmaragd", + "description": "Frisches grünes Thema für natürliches Gefühl" + }, + "purple": { + "name": "Königslila", + "description": "Elegantes Lila-Thema mit Raffinesse" + }, + "rose": { + "name": "Sonnenuntergang-Rose", + "description": "Warmes Rosa-Thema mit sanfter Energie" + }, + "amber": { + "name": "Goldenes Bernstein", + "description": "Warmes Gelb-Thema für Helligkeit" + }, + "teal": { + "name": "Ozean-Türkis", + "description": "Kühles Türkis-Thema für Ruhe" + } + }, + "dark": { + "blue": { + "name": "Mitternachtsblau", + "description": "Tiefes Blau-Thema für konzentriertes Arbeiten" + }, + "emerald": { + "name": "Dunkler Wald", + "description": "Sattes grünes Thema für angenehmes Betrachten" + }, + "purple": { + "name": "Tiefes Lila", + "description": "Mysteriöses Lila-Thema für Kreativität" + }, + "rose": { + "name": "Dunkle Rose", + "description": "Gedämpftes Rosa-Thema mit subtiler Wärme" + }, + "amber": { + "name": "Dunkles Bernstein", + "description": "Warmes Braun-Thema für gemütliche Sitzungen" + }, + "teal": { + "name": "Tiefes Türkis", + "description": "Dunkles Türkis-Thema für ruhigen Fokus" + } + } + }, + "title": "Farbschema wählen" + }, + "common": { + "back": "Zurück", + "cancel": "Abbrechen", + "changesSaved": "Änderungen gespeichert", + "close": "Schließen", + "confirm": "Bestätigen", + "delete": "Löschen", + "deselectAll": "Alle abwählen", + "download": "Herunterladen", + "edit": "Bearbeiten", + "error": "Fehler", + "failed": "Fehlgeschlagen", + "filter": "Filter", + "info": "Info", + "loading": "Lädt...", + "new": "Neu", + "next": "Weiter", + "no": "Nein", + "noResults": "Keine Ergebnisse gefunden", + "ok": "OK", + "or": "Oder", + "previous": "Vorherige", + "processing": "Verarbeite...", + "refresh": "Aktualisieren", + "retry": "Wiederholen", + "save": "Speichern", + "search": "Suchen", + "selectAll": "Alle auswählen", + "sort": "Sortieren", + "success": "Erfolgreich", + "untitled": "Unbenannt", + "upload": "Hochladen", + "warning": "Warnung", + "yes": "Ja" + }, + "customPrompts": { + "currentDefaultPrompt": "Aktueller Standard-Prompt (Wird verwendet, wenn Sie das obige Feld leer lassen)", + "promptDescription": "Dieser Prompt wird verwendet, um Zusammenfassungen Ihrer Transkriptionen zu generieren. Er überschreibt den Standardprompt des Administrators.", + "promptPlaceholder": "Beschreiben Sie, wie Ihre Zusammenfassungen strukturiert sein sollen. Leer lassen, um den Standard-Prompt des Administrators zu verwenden.", + "summaryGeneration": "Zusammenfassungsgenerierungs-Prompt", + "tip1": "Seien Sie spezifisch bezüglich der Abschnitte, die Sie in Ihrer Zusammenfassung wünschen", + "tip2": "Verwenden Sie klare Formatierungsanweisungen (z.B. \"Verwenden Sie Aufzählungszeichen\", \"Erstellen Sie nummerierte Listen\")", + "tip3": "Geben Sie an, wenn Sie möchten, dass bestimmte Informationen priorisiert werden", + "tip4": "Das System wird automatisch den Transkriptionsinhalt an die KI weiterleiten", + "tip5": "Ihre Ausgabesprachen-Präferenz (falls gesetzt) wird automatisch angewendet", + "tipsTitle": "Tipps für das Schreiben Effektiver Prompts", + "yourCustomPrompt": "Ihr Benutzerdefinierter Zusammenfassungs-Prompt" + }, + "deleteAllSpeakersModal": { + "confirmMessage": "Sind Sie sicher, dass Sie alle gespeicherten Sprecher löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "title": "Alle Sprecher Löschen" + }, + "dialogs": { + "deleteRecording": { + "cancel": "Abbrechen", + "confirm": "Löschen", + "message": "Sind Sie sicher, dass Sie diese Aufnahme löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "title": "Aufnahme löschen" + }, + "deleteShare": { + "message": "Sind Sie sicher, dass Sie diese Freigabe löschen möchten? Dadurch wird der Zugriff auf den öffentlichen Link widerrufen.", + "title": "Freigabe löschen" + }, + "reprocessTranscription": { + "cancel": "Abbrechen", + "confirm": "Neu verarbeiten", + "message": "Sind Sie sicher, dass Sie diese Transkription neu verarbeiten möchten? Die aktuelle Transkription wird ersetzt.", + "title": "Transkription neu verarbeiten" + }, + "unsavedChanges": { + "cancel": "Abbrechen", + "discard": "Verwerfen", + "message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese vor dem Verlassen speichern?", + "save": "Speichern", + "title": "Ungespeicherte Änderungen" + } + }, + "duration": { + "hours": "{{count}} Stunde", + "hoursPlural": "{{count}} Stunden", + "minutes": "{{count}} Minute", + "minutesPlural": "{{count}} Minuten", + "seconds": "{{count}} Sekunde", + "secondsPlural": "{{count}} Sekunden" + }, + "editTagModal": { + "asrDefaultSettings": "ASR-Standard-Einstellungen", + "asrSettingsDescription": "Diese Einstellungen werden standardmäßig angewendet, wenn dieser Tag mit ASR-Transkription verwendet wird", + "color": "Farbe", + "colorDescription": "Wählen Sie eine Farbe zur einfachen Identifikation", + "createTitle": "Tag Erstellen", + "customPrompt": "Benutzerdefinierter Zusammenfassungs-Prompt", + "customPromptPlaceholder": "Optional: Benutzerdefinierter Prompt für die Generierung von Zusammenfassungen für Aufnahmen mit diesem Tag", + "defaultLanguage": "Standard-Sprache", + "defaultPromptPlaceholder": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden", + "exportTemplate": "Exportvorlage", + "exportTemplateHint": "Wählen Sie eine Exportvorlage für den Export von Aufnahmen mit diesem Tag", + "leaveBlankPrompt": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden", + "maxSpeakers": "Maximale Sprecheranzahl", + "minSpeakers": "Minimale Sprecheranzahl", + "namingTemplate": "Benennungsvorlage", + "namingTemplateHint": "Wählen Sie eine Benennungsvorlage, um Titel für Aufnahmen mit diesem Tag automatisch zu formatieren", + "noExportTemplate": "Keine Vorlage (Benutzerstandard verwenden)", + "noNamingTemplate": "Keine Vorlage (Standard oder KI-Titel verwenden)", + "tagName": "Tag-Name *", + "tagNamePlaceholder": "z.B. Meetings, Interviews", + "title": "Tag Bearbeiten" + }, + "errors": { + "audioExtractionFailed": "Audio Extraction Failed", + "audioExtractionFailedGuidance": "Try converting the file to a standard audio format (MP3, WAV) before uploading.", + "audioExtractionFailedMessage": "Could not extract audio from the uploaded file.", + "audioRecordingFailed": "音频录制失败。请检查您的麦克风。", + "authenticationError": "Authentication Error", + "authenticationErrorGuidance": "Please check that the API key is correct and has not expired.", + "authenticationErrorMessage": "The transcription service rejected the API credentials.", + "checkApiKeyGuidance": "Check the API key in settings", + "checkNetworkGuidance": "Check network connection", + "connectionError": "Connection Error", + "connectionErrorGuidance": "Check your internet connection and ensure the service is available.", + "connectionErrorMessage": "Could not connect to the transcription service.", + "convertFormatGuidance": "Convert to MP3 or WAV before uploading", + "convertStandardGuidance": "Convert to standard audio format", + "enableChunkingGuidance": "Enable chunking in settings or compress the file", + "fallbackMessage": "An error occurred", + "fallbackTitle": "Error", + "fileTooLarge": "文件太大", + "fileTooLargeGuidance": "Try enabling audio chunking in your settings, or compress the audio file before uploading.", + "fileTooLargeMaxSize": "File too large. Max: {{size}} MB.", + "fileTooLargeMessage": "The audio file exceeds the maximum size allowed by the transcription service.", + "fileTooLargeTitle": "File Too Large", + "generic": "发生错误", + "invalidAudioFormat": "Invalid Audio Format", + "invalidAudioFormatGuidance": "Try converting the audio to MP3 or WAV format before uploading.", + "invalidAudioFormatMessage": "The audio file format is not supported or the file may be corrupted.", + "loadingShares": "Fehler beim Laden der Freigaben", + "networkError": "网络错误。请检查您的连接。", + "networkErrorDuringUpload": "Network error during upload", + "notFound": "未找到", + "permissionDenied": "权限被拒绝", + "processingError": "Processing Error", + "processingErrorFallbackGuidance": "Try reprocessing the recording", + "processingErrorGuidance": "If this error persists, try reprocessing the recording.", + "processingErrorMessage": "An error occurred during processing.", + "processingFailedOnServer": "Processing failed on server.", + "processingFailedWithStatus": "Processing failed with status {{status}}", + "processingTimeout": "Processing Timeout", + "processingTimeoutGuidance": "This can happen with very long recordings. Try splitting the audio into smaller parts.", + "processingTimeoutMessage": "The transcription took too long to complete.", + "quotaExceeded": "存储配额已超出", + "rateLimitExceeded": "Rate Limit Exceeded", + "rateLimitExceededGuidance": "Please wait a few minutes and try reprocessing.", + "rateLimitExceededMessage": "Too many requests were sent to the transcription service.", + "serverError": "服务器错误。请稍后重试。", + "serverErrorStatus": "Server error ({{status}})", + "serviceUnavailable": "Service Unavailable", + "serviceUnavailableGuidance": "This is usually temporary. Please try again in a few minutes.", + "serviceUnavailableMessage": "The transcription service is temporarily unavailable.", + "splitAudioGuidance": "Try splitting the audio into smaller parts", + "summaryFailed": "摘要生成失败。请重试。", + "transcriptionFailed": "转录失败。请重试。", + "tryAgainLaterGuidance": "Try again in a few minutes", + "unauthorized": "未授权", + "unexpectedResponse": "Unexpected success response from server after upload.", + "unsupportedFormat": "不支持的文件格式", + "uploadFailed": "上传失败。请重试。", + "uploadFailedWithStatus": "Upload failed with status {{status}}", + "uploadTimedOut": "Upload timed out", + "validationError": "验证错误", + "waitAndRetryGuidance": "Wait a few minutes and try again" + }, + "eventExtraction": { + "description": "Wenn aktiviert, identifiziert die KI Meetings, Termine und Fristen in Ihren Aufnahmen und erstellt herunterladbare Kalenderereignisse.", + "enableLabel": "Automatische Ereignisextraktion aus Transkripten aktivieren", + "info": "Extrahierte Ereignisse erscheinen im 'Ereignisse'-Tab bei Aufnahmen, in denen Kalenderelemente erkannt werden.", + "title": "Ereignisextraktion" + }, + "events": { + "add": "Hinzufügen", + "addToCalendar": "Zum Kalender Hinzufügen", + "attendees": "Teilnehmer", + "confirmDelete": "Ereignis \"{title}\" löschen?", + "delete": "Löschen", + "deleteFailed": "Ereignis konnte nicht gelöscht werden", + "deleted": "Ereignis gelöscht", + "end": "Ende", + "location": "Ort", + "noEvents": "Keine Ereignisse in dieser Aufnahme erkannt", + "start": "Beginn", + "title": "Ereignisse" + }, + "exportLabels": { + "created": "Erstellt", + "date": "Datum", + "fileSize": "Dateigröße", + "footer": "Erstellt mit [DictIA](https://gitea.innova-ai.ca/Innova-AI/dictia)", + "metadata": "Metadaten", + "notes": "Notizen", + "originalFile": "Originaldatei", + "participants": "Teilnehmer", + "summarizationTime": "Zusammenfassungszeit", + "summary": "Zusammenfassung", + "tags": "Tags", + "transcription": "Transkription", + "transcriptionTime": "Transkriptionszeit" + }, + "exportTemplates": { + "availableLabels": "Lokalisierte Bezeichnungen (automatisch übersetzt)", + "availableTemplates": "Verfügbare Vorlagen", + "availableVars": "Verfügbare Variablen", + "cancel": "Abbrechen", + "conditionals": "Bedingungen", + "conditionalsHint": "Verwenden Sie {{#if variable}}...{{/if}}, um Inhalte bedingt einzuschließen", + "contentSections": "Inhaltsbereiche", + "createDefaults": "Standardvorlage erstellen", + "createNew": "Vorlage erstellen", + "default": "Standard", + "delete": "Löschen", + "description": "Passen Sie an, wie Aufnahmen in Markdown-Dateien exportiert werden.", + "recordingData": "Aufnahmedaten", + "save": "Speichern", + "selectOrCreate": "Wählen Sie eine Vorlage zum Bearbeiten oder erstellen Sie eine neue", + "setDefault": "Als Standardvorlage festlegen", + "tabTitle": "Export", + "template": "Vorlage", + "templateDescription": "Beschreibung", + "templateName": "Vorlagenname", + "title": "Export-Vorlagen", + "viewGuide": "Vorlagen-Leitfaden anzeigen" + }, + "fileSize": { + "bytes": "{{count}} B", + "gigabytes": "{{count}} GB", + "kilobytes": "{{count}} KB", + "megabytes": "{{count}} MB" + }, + "folderManagement": { + "allFolders": "Alle Ordner", + "asrDefaults": "ASR-Standardwerte", + "autoShareOnApply": "Automatisch mit Gruppenmitgliedern teilen", + "autoShareOnApplyHelp": "Aufnahmen automatisch mit allen Gruppenmitgliedern teilen, wenn sie diesem Ordner hinzugefügt werden", + "confirmDelete": "Möchten Sie diesen Ordner wirklich löschen? Aufnahmen werden aus dem Ordner entfernt, aber nicht gelöscht.", + "createFolder": "Ordner erstellen", + "customPrompt": "Benutzerdefinierter Prompt", + "defaultLanguage": "Standardsprache", + "deleteFolder": "Ordner löschen", + "description": "Organisieren Sie Ihre Aufnahmen in Ordnern. Im Gegensatz zu Tags kann eine Aufnahme nur einem Ordner zugeordnet werden. Ordner-Prompts werden vor Benutzer-Prompts, aber nach Tag-Prompts angewendet.", + "editFolder": "Ordner bearbeiten", + "filterByFolder": "Nach Ordner filtern", + "folderColor": "Ordnerfarbe", + "folderName": "Ordnername", + "groupSettings": "Gruppeneinstellungen", + "maxSpeakers": "Max. Sprecher", + "minSpeakers": "Min. Sprecher", + "moveToFolder": "In Ordner verschieben", + "namingTemplate": "Benennungsvorlage", + "noFolder": "Kein Ordner", + "noFolders": "Noch keine Ordner erstellt", + "noFoldersDescription": "Erstellen Sie Ihren ersten Ordner, um Ihre Aufnahmen zu organisieren", + "protectFromDeletion": "Vor Löschung schützen", + "protectFromDeletionHelp": "Aufnahmen in diesem Ordner vor automatischer Löschung schützen", + "recordings": "Aufnahmen", + "removeFromFolder": "Aus Ordner entfernen", + "retentionDays": "Aufbewahrungstage", + "retentionDaysHelp": "Aufnahmen in diesem Ordner werden nach dieser Anzahl von Tagen gelöscht. Leer lassen für globale Aufbewahrungsregel.", + "retentionSettings": "Aufbewahrungseinstellungen", + "selectNamingTemplate": "Benennungsvorlage auswählen...", + "shareWithGroupLead": "Mit Gruppenadministratoren teilen", + "shareWithGroupLeadHelp": "Aufnahmen mit Gruppenadministratoren teilen, wenn sie diesem Ordner hinzugefügt werden", + "title": "Ordnerverwaltung" + }, + "form": { + "auto": "Auto", + "autoDetect": "Automatisch erkennen", + "dateFrom": "Von", + "dateTo": "Bis", + "enterNotesMarkdown": "Notizen im Markdown-Format eingeben...", + "enterSummaryMarkdown": "Zusammenfassung im Markdown-Format eingeben...", + "folder": "Folder", + "hotwords": "Hotwords", + "hotwordsHelp": "Comma-separated words to improve recognition of domain-specific terms", + "hotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote", + "initialPrompt": "Initial Prompt", + "initialPromptHelp": "Context to steer the transcription model's style and vocabulary", + "initialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools.", + "language": "Sprache", + "maxSpeakers": "Max. Sprecher", + "meetingDate": "Meeting-Datum", + "minSpeakers": "Min. Sprecher", + "minutes": "Minuten", + "notes": "Notizen", + "notesPlaceholder": "Geben Sie Ihre Notizen im Markdown-Format ein...", + "optional": "Optional", + "participantNamePlaceholder": "Participant name...", + "participants": "Teilnehmer", + "placeholderAuto": "Auto", + "placeholderCharacterLimit": "Zeichenlimit eingeben (z.B. 30000)", + "placeholderMinutes": "Minuten", + "placeholderOptional": "Optional", + "placeholderSeconds": "Sekunden", + "placeholderSizeMB": "Größe in MB eingeben", + "searchSpeakers": "Sprecher suchen...", + "searchTags": "Tags suchen...", + "seconds": "Sekunden", + "shareNotes": "Notizen teilen", + "shareSummary": "Zusammenfassung teilen", + "shareableLink": "Teilbarer Link", + "summaryPromptPlaceholder": "Standard-Zusammenfassungsprompt eingeben...", + "title": "Titel", + "transcriptionLanguage": "Transcription Language", + "yourFullName": "Ihr vollständiger Name" + }, + "groups": { + "addMembers": "Mitglieder hinzufügen...", + "autoShare": "Automatisch teilen", + "autoShareNote": "Hinweis: Wenn beide aktiviert sind, haben alle Team-Mitglieder Zugriff. Wenn nur \"Team-Administratoren\" aktiviert ist, haben nur Teamleiter Zugriff.", + "autoShareTeam": "Aufnahmen automatisch mit allen Team-Mitgliedern teilen, wenn dieser Tag angewendet wird", + "autoSharesWithTeam": "Teilt automatisch mit allen Team-Mitgliedern", + "confirmDelete": "Sind Sie sicher, dass Sie dieses Team löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "createTeam": "Team erstellen", + "createTeamTag": "Neuen Team-Tag erstellen", + "dayRetention": "Tage Aufbewahrung", + "deleteTeam": "Team löschen", + "deletionExempt": "Von Löschung ausgenommen", + "deletionExemptHelp": "Aufnahmen mit diesem Tag werden von der automatischen Löschung ausgenommen, auch wenn sie die Aufbewahrungsdauer überschreiten.", + "editTeam": "Team bearbeiten", + "editTeamTag": "Team-Tag bearbeiten", + "globalRetention": "Globale Aufbewahrung", + "members": "Mitglieder", + "noMembers": "Keine Mitglieder in diesem Team", + "noTeamTags": "Noch keine Team-Tags erstellt", + "noTeams": "Noch keine Teams erstellt", + "retentionHelp": "Aufnahmen mit diesem Tag werden nach dieser Anzahl von Tagen gelöscht. Leer lassen für globale Aufbewahrung ({{days}} Tage).", + "retentionPeriod": "Aufbewahrungsdauer (Tage)", + "retentionPlaceholder": "Leer lassen für globale Aufbewahrung", + "searchUsers": "Benutzer suchen...", + "selectTeamLead": "Teamleiter auswählen...", + "shareWithAdmins": "Aufnahmen mit Team-Administratoren teilen, wenn dieser Tag angewendet wird", + "sharesWithAdminsOnly": "Teilt nur mit Team-Administratoren", + "syncComplete": "Team-Freigaben erfolgreich synchronisiert", + "syncTeamShares": "Team-Freigaben synchronisieren", + "syncTeamSharesDescription": "Dadurch werden alle Aufnahmen mit Team-Tags rückwirkend mit den entsprechenden Team-Mitgliedern gemäß den Freigabeeinstellungen des Tags geteilt.", + "teamLead": "Teamleiter", + "teamName": "Team-Name", + "teamNamePlaceholder": "Team-Name eingeben", + "teamTags": "Team-Tags", + "title": "Gruppenverwaltung", + "updateTeam": "Team aktualisieren" + }, + "help": { + "actions": "Aktionen", + "activeFilters": "Aktive Filter", + "addSegmentBelow": "Segment darunter hinzufügen", + "advancedAsrOptions": "Erweiterte ASR-Optionen", + "allRecordingsLoaded": "Alle Aufnahmen geladen", + "allTagsSelected": "All tags selected", + "appliedSuggestedNames": "{{count}} vorgeschlagener Name angewendet", + "appliedSuggestedNamesPlural": "{{count}} vorgeschlagene Namen angewendet", + "applySuggested": "Vorschläge anwenden", + "applySuggestedMobile": "Vorschlag", + "approachingLimit": "Nähert sich {{limit}}MB-Limit", + "askAboutTranscription": "Fragen zu dieser Transkription stellen", + "audioDeletedDescription": "Die Audiodatei für diese Aufnahme wurde gelöscht, aber die Transkription ist weiterhin verfügbar.", + "audioDeletedMessage": "Audiodatei wurde archiviert und ist nicht mehr zur Wiedergabe verfügbar.", + "audioDeletedTitle": "Audiodatei gelöscht", + "audioPlayer": "Audio-Player", + "autoIdentify": "Automatisch identifizieren", + "autoIdentifyMobile": "Auto", + "bothAudioDesc": "Nimmt Ihre Stimme + Meeting-Teilnehmer auf (empfohlen für Online-Meetings)", + "clearFilters": "Filter löschen", + "clickToAddNotes": "Klicken um Notizen hinzuzufügen...", + "colorRepeats": "Farbe wiederholt sich ab Sprecher {{number}}", + "completedFiles": "Abgeschlossene Dateien", + "confirmReprocessingTitle": "Neu-Verarbeitung bestätigen", + "copyMessage": "Nachricht kopieren", + "createFolders": "Create folders", + "createPublicLink": "Einen öffentlichen Link erstellen, um diese Aufnahme zu teilen. Teilen ist nur bei sicheren (HTTPS) Verbindungen verfügbar.", + "createTags": "Tags erstellen", + "defaultHotwordsHelp": "Comma-separated words or phrases that the transcription model should prioritize recognizing (brand names, acronyms, technical terms).", + "defaultInitialPromptHelp": "Context to steer the transcription model's style and vocabulary. Describe the topic or expected content of your recordings.", + "deleteSegment": "Segment löschen", + "discard": "Verwerfen", + "dragToReorder": "Drag to reorder", + "endTime": "Ende", + "enterNameFor": "Namen eingeben für", + "enterSpeakerName": "Sprechername eingeben...", + "entireScreen": "Gesamter Bildschirm", + "errorChangingSpeaker": "Fehler beim Wechseln des Sprechers", + "errorOpeningTextEditor": "Fehler beim Öffnen des Texteditors", + "errorSavingText": "Fehler beim Speichern des Textes", + "estimatedSize": "Geschätzte Größe", + "firstTagAsrSettings": "First tag's ASR settings will be applied:", + "firstTagDefaultsApplied": "First tag's defaults applied", + "folderHasCustomPrompt": "This folder has a custom summary prompt", + "generatingSummary": "Generiere Zusammenfassung...", + "groups": "Gruppen", + "howToRecordSystemAudio": "Wie man System-Audio aufnimmt", + "identifyAllSpeakers": "Alle Sprecher identifizieren", + "identifying": "Identifiziere...", + "importantNote": "Wichtiger Hinweis", + "internalSharingDesc": "Mit bestimmten Benutzern in Ihrer Organisation teilen", + "lines": "{{count}} Zeilen", + "loadingMore": "Lade weitere Aufnahmen...", + "loadingRecordings": "Lade Aufnahmen...", + "me": "Ich", + "microphoneDesc": "Nimmt nur Ihre Stimme auf", + "modelReasoning": "Modell-Begründung", + "moreSpeakersThanColors": "Mehr Sprecher als verfügbare Farben", + "navigate": "Navigiere", + "noDateSet": "Kein Datum gesetzt", + "noMatchingTags": "No matching tags", + "noParticipants": "Keine Teilnehmer", + "noRecordingSelected": "Keine Aufnahme ausgewählt.", + "noSpeakersIdentified": "Es konnten keine Sprecher aus dem Kontext identifiziert werden.", + "noSuggestionsToApply": "Keine Vorschläge zum Anwenden", + "noTagsCreated": "Noch keine Tags erstellt.", + "of": "von", + "playFromHere": "Von hier abspielen", + "pleaseEnterSpeakerName": "Bitte geben Sie einen Sprechernamen ein", + "processingTime": "Verarbeitungszeit", + "processingTimeDescription": "Dies kann einige Minuten dauern. Sie können die App während der Verarbeitung weiter verwenden.", + "processingTranscription": "Verarbeite Transkription...", + "publicLinkDesc": "Jeder mit diesem Link kann auf die Aufnahme zugreifen", + "recordSystemSteps1": "Klicken Sie auf \"System-Audio aufnehmen\" oder \"Beides aufnehmen\".", + "recordSystemSteps2": "Im Popup wählen Sie", + "recordSystemSteps3": "Stellen Sie sicher, dass Sie das Kästchen ankreuzen, das sagt", + "recordingFinished": "Aufnahme beendet", + "recordingInProgress": "Aufnahme läuft...", + "regenerateSummaryAfterNames": "Zusammenfassung nach Namens-Update neu generieren", + "saved": "Gespeichert!", + "savingProgress": "Wird gespeichert...", + "selectedTagsCustomPrompts": "Selected tags include custom summary prompts", + "sentence": "Satz", + "shareSystemAudio": "System-Audio teilen", + "shareTabAudio": "Tab-Audio teilen", + "sharedOn": "Geteilt am", + "sharingWindowNoAudio": "Das Teilen eines \"Fensters\" nimmt kein Audio auf.", + "speakerAdded": "Sprecher erfolgreich hinzugefügt", + "speakerCount": "Sprecher", + "speakerName": "Sprechername", + "speakerNamesUpdated": "Sprechernamen erfolgreich aktualisiert!", + "speakers": "Sprecher", + "speakersIdentified": "{{count}} Sprecher erfolgreich identifiziert!", + "speakersIdentifiedPlural": "{{count}} Sprecher erfolgreich identifiziert!", + "speakersUpdatedSaveToApply": "Sprecher aktualisiert! Speichern Sie die Transkription, um die Änderungen zu übernehmen.", + "specificBrowserTab": "spezifischen Browser-Tab", + "startTime": "Start", + "startingAutoIdentification": "Automatische Sprecheridentifikation wird gestartet...", + "summaryGenerationFailed": "Zusammenfassungsgenerierung fehlgeschlagen", + "summaryGenerationTimedOut": "Zeitüberschreitung bei der Zusammenfassungsgenerierung", + "summaryRegenerationStarted": "Neugenerierung der Zusammenfassung gestartet", + "summaryUpdated": "Zusammenfassung aktualisiert!", + "systemAudioDesc": "Nimmt Meeting-Teilnehmer und Systemklänge auf", + "tagManagement": "Tag-Verwaltung", + "thisActionCannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden.", + "toCaptureAudioFromMeetings": "Um Audio von Besprechungen oder anderen Apps aufzunehmen, müssen Sie Ihren Bildschirm oder einen Browser-Tab freigeben.", + "toOrganizeRecordings": "to organize your recordings", + "transcriptUpdated": "Transkription erfolgreich aktualisiert!", + "troubleshooting": "Fehlerbehebung", + "tryAdjustingSearch": "Versuchen Sie Ihre Suche anzupassen oder", + "unsupportedBrowser": "Nicht unterstützter Browser", + "untitled": "Unbenannte Aufnahme", + "uploadRecordingNotes": "Aufnahme und Notizen hochladen", + "whatWillHappen": "Was wird passieren?", + "whyNotWorking": "Warum funktioniert es nicht?", + "youHaveXSpeakers": "Sie haben {{count}} Sprecher, aber nur 16 eindeutige Farben sind verfügbar. Die Farben wiederholen sich nach dem 16. Sprecher." + }, + "incognito": { + "audioNotStored": "Audio not stored in incognito mode", + "discardConfirm": "This will permanently discard your incognito recording. Continue?", + "mode": "Incognito Mode", + "notSavedToAccount": "Not saved to account", + "oneFileAtATime": "Incognito mode supports one file at a time", + "processInIncognito": "Process in Incognito", + "processWithoutSaving": "Process without saving", + "processing": "Processing...", + "processingComplete": "Processing complete!", + "processingInProgress": "Processing in incognito mode...", + "recordingDiscarded": "Incognito recording discarded", + "recordingProcessed": "Incognito recording processed - data will be lost when tab closes", + "recordingReady": "Incognito recording ready!", + "recordingTitle": "Incognito Recording", + "selectExactlyOneFile": "Select exactly 1 file", + "sessionOnly": "Session only", + "uploadingFile": "Uploading file for incognito processing..." + }, + "inquire": { + "activeFilters": "Aktive Filter:", + "askQuestions": "Fragen Sie zu Ihren Transkriptionen", + "clearAll": "Alle löschen", + "dateRange": "Datumsbereich", + "dateRangeActive": "Datumsbereich aktiv", + "exampleQuestion1": "\"Welche Aktionspunkte wurden besprochen?\"", + "exampleQuestion2": "\"Wann haben wir beschlossen, den Zeitplan zu ändern?\"", + "exampleQuestion3": "\"Welche Bedenken wurden bezüglich des Budgets geäußert?\"", + "exampleQuestion4": "\"Wer war für die Marketing-Aufgaben verantwortlich?\"", + "exampleQuestions": "Beispielfragen:", + "filters": "Filter", + "filtersActive": "Filter aktiv", + "from": "Von", + "noSpeakerData": "Keine Sprecherdaten verfügbar", + "placeholder": "Stellen Sie Fragen zu Ihren gefilterten Transkriptionen...", + "selectFilters": "Wählen Sie Filter auf der linken Seite aus, um Ihre Transkriptionen einzugrenzen, und stellen Sie dann Fragen, um Einblicke aus Ihren Aufnahmen zu erhalten.", + "sendHint": "Enter zum Senden • Strg+Enter für neue Zeile", + "speakerRequirement": "Sprecheridentifikation erfordert Aufnahmen mit mehreren Sprechern", + "speakers": "Sprecher", + "speakersCount": "Sprecher", + "tags": "Tags", + "tagsCount": "Tags", + "title": "Nachfragen", + "to": "Bis" + }, + "languages": { + "ar": "Arabisch", + "de": "Deutsch", + "en": "Englisch", + "es": "Spanisch", + "fr": "Französisch", + "hi": "Hindi", + "it": "Italienisch", + "ja": "Japanisch", + "ko": "Koreanisch", + "nl": "Niederländisch", + "pt": "Portugiesisch", + "ru": "Russisch", + "zh": "Chinesisch" + }, + "manageSpeakersModal": { + "created": "Erstellt", + "description": "Verwalten Sie Ihre gespeicherten Sprecher. Diese werden automatisch gespeichert, wenn Sie Sprechernamen in Ihren Aufnahmen verwenden.", + "failedToLoad": "Fehler beim Laden der Sprecher", + "lastUsed": "Zuletzt verwendet", + "loadingSpeakers": "Sprecher werden geladen...", + "noSpeakersYet": "Noch keine Sprecher gespeichert", + "speakersSaved": "{{count}} Sprecher gespeichert", + "speakersWillAppear": "Sprecher erscheinen hier, wenn Sie Sprechernamen in Ihren Aufnahmen verwenden", + "times": "mal", + "title": "Sprecher Verwalten", + "used": "Verwendet" + }, + "messages": { + "colorSchemeApplied": "Color scheme applied", + "colorSchemeReset": "Color scheme reset to default", + "copiedSuccessfully": "Copied to clipboard!", + "copiedToClipboard": "In Zwischenablage kopiert", + "copyFailed": "Failed to copy", + "copyNotSupported": "Copy failed. Your browser may not support this feature.", + "downloadStarted": "Download gestartet", + "errorRecoveringRecording": "Error recovering recording", + "eventDownloadFailed": "Failed to download event", + "eventDownloadSuccess": "Event \"{{title}}\" downloaded. Open the file to add to your calendar.", + "eventsExportFailed": "Failed to export events", + "eventsExportSuccess": "Exported {{count}} events", + "failedToDeleteJob": "Failed to delete job", + "failedToRecoverRecording": "Failed to recover recording", + "failedToRetryJob": "Failed to retry job", + "failedToSave": "Failed to save: {{error}}", + "failedToSaveParticipants": "Failed to save participants", + "followPlayerDisabled": "Follow player mode disabled", + "followPlayerEnabled": "Follow player mode enabled", + "invalidEventData": "Invalid event data", + "jobQueuedForRetry": "Job queued for retry", + "noEventsToExport": "No events to export", + "noNotesAvailableDownload": "No notes available to download.", + "noNotesToCopy": "No notes available to copy.", + "noPermissionToEdit": "You do not have permission to edit this recording", + "noSummaryToCopy": "No summary available to copy.", + "noSummaryToDownload": "No summary available to download.", + "noTranscriptionToCopy": "No transcription available to copy.", + "noTranscriptionToDownload": "No transcription available to download.", + "notesCopied": "Notes copied to clipboard!", + "notesDownloadFailed": "Failed to download notes", + "notesDownloadSuccess": "Notes downloaded successfully!", + "notesUpdated": "Notizen erfolgreich aktualisiert", + "passwordChanged": "Passwort erfolgreich geändert", + "profileUpdated": "Profil erfolgreich aktualisiert", + "recordingDeleted": "Aufnahme erfolgreich gelöscht", + "recordingDiscarded": "Recording discarded", + "recordingRecovered": "Recording recovered successfully", + "recordingSaved": "Aufnahme erfolgreich gespeichert", + "saveParticipantsFailed": "Save failed: {{error}}", + "settingsSaved": "Einstellungen erfolgreich gespeichert", + "summaryCopied": "Summary copied to clipboard!", + "summaryDownloadFailed": "Failed to download summary", + "summaryDownloadSuccess": "Summary downloaded successfully!", + "summaryGenerated": "Zusammenfassung erfolgreich generiert", + "tagAdded": "Tag erfolgreich hinzugefügt", + "tagRemoved": "Tag erfolgreich entfernt", + "transcriptDownloadFailed": "Failed to download transcript", + "transcriptDownloadSuccess": "Transcript downloaded successfully!", + "transcriptionCopied": "Transcription copied to clipboard!", + "transcriptionUpdated": "Transkription erfolgreich aktualisiert" + }, + "metadata": { + "cancelEdit": "Abbrechen", + "createdAt": "Erstellt", + "duration": "Dauer", + "editMetadata": "Metadaten bearbeiten", + "fileName": "Dateiname", + "fileSize": "Dateigröße", + "language": "Sprache", + "meetingDate": "Meeting-Datum", + "processingTime": "Verarbeitungszeit", + "saveMetadata": "Speichern", + "status": "Status", + "title": "Metadaten", + "updatedAt": "Aktualisiert", + "wordCount": "Wortanzahl" + }, + "modal": { + "addSpeaker": "Neuen Sprecher Hinzufügen", + "colorScheme": "Farbschema", + "deleteRecording": "Aufnahme löschen", + "editAsrTranscription": "ASR-Transkription bearbeiten", + "editParticipants": "Teilnehmer Bearbeiten", + "editRecording": "Aufnahme bearbeiten", + "editSpeakers": "Sprecher Bearbeiten", + "editTags": "Aufnahme-Tags bearbeiten", + "editTranscription": "Transkription bearbeiten", + "identifySpeakers": "Sprecher identifizieren", + "recordingNotice": "Aufnahme-Hinweis", + "reprocessSummary": "Zusammenfassung neu verarbeiten", + "reprocessTranscription": "Transkription neu verarbeiten", + "resetStatus": "Aufnahme-Status zurücksetzen?", + "shareRecording": "Aufnahme teilen", + "sharedTranscripts": "Meine geteilten Transkripte", + "systemAudioHelp": "System-Audio Hilfe", + "uploadFiles": "Dateien hochladen", + "uploadNotice": "Upload-Hinweis" + }, + "namingTemplates": { + "addPattern": "Muster hinzufügen", + "availableTemplates": "Verfügbare Vorlagen", + "availableVars": "Verfügbare Variablen", + "cancel": "Abbrechen", + "createDefaults": "Standardvorlagen erstellen", + "createNew": "Vorlage erstellen", + "customVarsHint": "Definieren Sie unten Regex-Muster, um benutzerdefinierte Variablen aus Dateinamen zu extrahieren.", + "delete": "Löschen", + "description": "Definieren Sie, wie Aufnahmetitel aus Dateinamen und Transkriptionsinhalt generiert werden.", + "descriptionLabel": "Beschreibung", + "noDefault": "Kein Standard (nur KI-Titel)", + "regexHint": "Daten aus Dateinamen extrahieren. Verwenden Sie Erfassungsgruppen () zur Angabe der Übereinstimmung.", + "regexPatterns": "Regex-Muster (Optional)", + "result": "Ergebnis:", + "save": "Speichern", + "selectOrCreate": "Wählen Sie eine Vorlage zum Bearbeiten oder erstellen Sie eine neue", + "tabTitle": "Benennung", + "template": "Vorlage", + "templateName": "Vorlagenname", + "test": "Testen", + "testTemplate": "Vorlage testen", + "title": "Benennungsvorlagen", + "userDefault": "Standardvorlage", + "userDefaultHint": "Wird angewendet, wenn kein Tag eine Benennungsvorlage hat." + }, + "nav": { + "account": "Konto", + "accountSettings": "Kontoeinstellungen", + "admin": "Administration", + "adminDashboard": "Admin-Dashboard", + "darkMode": "Dunkler Modus", + "groupManagement": "Gruppenverwaltung", + "home": "Startseite", + "language": "Sprache", + "lightMode": "Heller Modus", + "newRecording": "Neue Aufnahme", + "recording": "Aufnahme", + "settings": "Einstellungen", + "signOut": "Abmelden", + "teamManagement": "Gruppenverwaltung", + "upload": "Hochladen", + "userProfile": "Benutzerprofil" + }, + "notes": { + "cancelEdit": "Bearbeitung abbrechen", + "characterCount": "{{count}} Zeichen", + "characterCountPlural": "{{count}} Zeichen", + "editNotes": "Notizen bearbeiten", + "lastUpdated": "Zuletzt aktualisiert", + "placeholder": "Ihre Notizen hier hinzufügen...", + "saveNotes": "Notizen speichern", + "title": "Notizen" + }, + "pwa": { + "installApp": "App installieren", + "installed": "Erfolgreich installiert", + "installing": "Installiere...", + "notificationPermissionDenied": "Benachrichtigungsberechtigung verweigert", + "notificationsEnabled": "Benachrichtigungen aktiviert", + "offline": "Sie sind offline", + "screenAwake": "Bildschirm bleibt während der Aufnahme wach", + "screenAwakeFailed": "Bildschirm konnte nicht wach gehalten werden", + "updateAvailable": "Update verfügbar" + }, + "recording": { + "acceptDisclaimer": "Ich akzeptiere", + "cancelRecording": "Abbrechen", + "discardRecovery": "Verwerfen", + "disclaimer": "Aufnahme-Hinweis", + "duration": "Dauer", + "micPlusSys": "Mic + Sys", + "microphone": "Mikrofon", + "microphoneAndSystem": "Mikrofon + System", + "microphonePermissionDenied": "Mikrofon-Berechtigung verweigert", + "modeBoth": "Mikrofon + System", + "modeMicrophone": "Mikrofon", + "modeSystem": "System-Audio", + "notes": "Notizen", + "notesPlaceholder": "Notizen zu dieser Aufnahme hinzufügen...", + "pauseRecording": "Pause", + "recordingFailed": "Aufnahme fehlgeschlagen", + "recordingInProgress": "Aufnahme läuft...", + "recordingMode": "Aufnahmemodus", + "recordingSize": "Geschätzte Größe", + "recordingStopped": "Aufnahme gestoppt", + "recordingTime": "Aufnahmezeit", + "recoveryDescription": "Wir haben eine unvollständige Aufnahme aus einer früheren Sitzung gefunden. Möchten Sie diese wiederherstellen?", + "recoveryFound": "Nicht gespeicherte Aufnahme gefunden", + "recoveryTitle": "Aufnahme wiederherstellen", + "restoreRecording": "Wiederherstellen", + "resumeRecording": "Fortsetzen", + "saveRecording": "Aufnahme speichern", + "size": "Größe", + "startRecording": "Aufnahme starten", + "startedAt": "Gestartet am", + "stopRecording": "Aufnahme stoppen", + "systemAudio": "System-Audio", + "systemAudioNotSupported": "System-Audio-Aufnahme wird in diesem Browser nicht unterstützt", + "title": "Audio-Aufnahme" + }, + "reprocessModal": { + "audioReTranscribedFromScratch": "Das Audio wird von Grund auf neu transkribiert. Dies wird auch den Titel und die Zusammenfassung basierend auf der neuen Transkription neu generieren.", + "audioReTranscribedWithAsr": "Das Audio wird mit dem ASR-Endpoint neu transkribiert. Dies umfasst Diarisierung und wird Titel und Zusammenfassung neu generieren.", + "manualEditsOverwritten": "Alle manuellen Bearbeitungen der Transkription, des Titels oder der Zusammenfassung werden überschrieben.", + "manualEditsOverwrittenSummary": "Alle manuellen Bearbeitungen des Titels oder der Zusammenfassung werden überschrieben.", + "newTitleAndSummary": "Ein neuer Titel und eine neue Zusammenfassung werden basierend auf der vorhandenen Transkription generiert." + }, + "settings": { + "apiKeys": "API-Schlüssel", + "appearance": "Erscheinungsbild", + "changePassword": "Passwort ändern", + "dataExport": "Datenexport", + "deleteAccount": "Konto löschen", + "integrations": "Integrationen", + "language": "Sprache", + "notifications": "Benachrichtigungen", + "preferences": "Einstellungen", + "privacy": "Datenschutz", + "profile": "Profil", + "security": "Sicherheit", + "theme": "Design", + "title": "Einstellungen", + "twoFactorAuth": "Zwei-Faktor-Authentifizierung" + }, + "sharedTranscripts": { + "noSharedTranscripts": "Sie haben noch keine Transkripte geteilt.", + "shareNotes": "Notizen Teilen", + "shareSummary": "Zusammenfassung Teilen", + "sharedOn": "Geteilt am" + }, + "sharedTranscriptsPage": { + "noSharedTranscripts": "Sie haben noch keine Transkripte geteilt." + }, + "sharing": { + "canEdit": "Kann bearbeiten", + "canReshare": "Kann weitergeben", + "internalSharing": "Interne Freigabe", + "notSharedYet": "Noch nicht geteilt", + "publicBadge": "Öffentlich", + "publicLink": "Öffentlicher Link", + "publicLinks": "öffentliche(r) Link(s)", + "publicLinksGenerated": "öffentliche(r) Link(s) generiert", + "searchUsers": "Benutzer suchen...", + "sharedBadge": "Geteilt", + "sharedBy": "Geteilt von", + "sharedWith": "Geteilt mit", + "teamBadge": "Group", + "teamRecording": "Team-Aufnahme", + "unknown": "Unbekannt", + "users": "Benutzer" + }, + "sidebar": { + "advancedSearch": "Erweiterte Suche", + "archived": "Archiviert", + "archivedRecordings": "Archivierte Aufnahmen", + "dateRange": "Datumsbereich", + "filters": "Filter", + "highlighted": "Hervorgehoben", + "inbox": "Posteingang", + "lastMonth": "Letzten Monat", + "lastWeek": "Letzte Woche", + "loadMore": "Mehr laden", + "markAsRead": "Als gelesen markieren", + "moveToInbox": "In Posteingang verschieben", + "noRecordings": "Keine Aufnahmen gefunden", + "older": "Älter", + "removeFromHighlighted": "Von Favoriten entfernen", + "searchRecordings": "Aufnahmen suchen...", + "sharedWithMe": "Mit mir geteilt", + "sortBy": "Sortieren nach", + "sortByDate": "Erstellungsdatum", + "sortByMeetingDate": "Meeting-Datum", + "starred": "Starred", + "tags": "Tags", + "thisMonth": "Diesen Monat", + "thisWeek": "Diese Woche", + "today": "Heute", + "totalRecordings": "{{count}} Aufnahme", + "totalRecordingsPlural": "{{count}} Aufnahmen", + "upcoming": "Bevorstehend", + "yesterday": "Gestern" + }, + "speakers": { + "filterBySpeaker": "Nach Sprecher filtern", + "noMatchingSpeakers": "Keine passenden Sprecher", + "searchSpeakers": "Suchen..." + }, + "speakersManagement": { + "added": "Hinzugefügt", + "confidence": "Zuverlässigkeit", + "confidenceHigh": "hoch", + "confidenceLow": "niedrig", + "confidenceMedium": "mittel", + "created": "Erstellt", + "description": "Verwalten Sie Ihre gespeicherten Sprecher. Diese werden automatisch gespeichert, wenn Sie Sprechernamen in Ihren Aufnahmen verwenden.", + "failedToLoad": "Fehler beim Laden der Sprecher", + "failedToLoadSnippets": "Fehler beim Laden der Ausschnitte", + "keepThisSpeaker": "Diesen Sprecher behalten (andere werden damit zusammengeführt):", + "last": "Zuletzt", + "lastUsed": "Zuletzt verwendet", + "loadingSpeakers": "Sprecher werden geladen...", + "match": "Übereinstimmung", + "mergeDescription": "Kombinieren Sie mehrere Sprecherprofile zu einem. Alle Einbettungen, Ausschnitte und Nutzungsdaten werden zusammengeführt.", + "mergeFailed": "Fehler beim Zusammenführen der Sprecher", + "mergeNSpeakers": "{{count}} Sprecher Zusammenführen", + "mergeSpeakers": "Sprecher Zusammenführen", + "mergeSuccess": "Sprecher erfolgreich zusammengeführt", + "noSnippetsAvailable": "Keine Ausschnitte verfügbar", + "noSpeakersYet": "Noch keine Sprecher gespeichert", + "sample": "Probe", + "samples": "Proben", + "selectToMerge": "Wählen Sie 2+ zum Zusammenführen", + "speakersToMerge": "Zusammenzuführende Sprecher:", + "speakersWillAppear": "Sprecher erscheinen hier, wenn Sie Sprechernamen in Ihren Aufnahmen verwenden", + "targetWillReceive": "Der ausgewählte Sprecher erhält alle Stimmdaten und Ausschnitte der anderen.", + "time": "Mal", + "times": "Mal", + "totalSpeakers": "Sprecher gespeichert", + "used": "Verwendet", + "usedTimes": "Verwendet", + "viewSnippets": "Ausschnitte Anzeigen", + "voiceMatchSuggestions": "Stimmübereinstimmungs-Vorschläge", + "voiceProfile": "Stimmprofil" + }, + "status": { + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "processing": "Verarbeitung", + "queued": "In Warteschlange", + "stuck": "Blockierte Verarbeitung zurücksetzen", + "summarizing": "Zusammenfassung", + "transcribing": "Transkribierung", + "uploading": "Upload" + }, + "summary": { + "actionItems": "Aktionspunkte", + "cancelEdit": "Bearbeitung abbrechen", + "decisions": "Entscheidungen", + "editSummary": "Zusammenfassung bearbeiten", + "generateSummary": "Zusammenfassung generieren", + "keyPoints": "Wichtige Punkte", + "noSummary": "Keine Zusammenfassung verfügbar", + "participants": "Teilnehmer", + "regenerateSummary": "Zusammenfassung neu generieren", + "saveSummary": "Zusammenfassung speichern", + "summaryFailed": "Generierung der Zusammenfassung fehlgeschlagen", + "summaryInProgress": "Zusammenfassung wird generiert...", + "title": "Zusammenfassung" + }, + "tagManagement": { + "asrDefaults": "ASR-Standards", + "createTag": "Tag Erstellen", + "customPrompt": "Benutzerdefinierter Prompt", + "description": "Organisieren Sie Ihre Aufnahmen mit benutzerdefinierten Tags. Jeder Tag kann seinen eigenen Zusammenfassungs-Prompt und Standard-ASR-Einstellungen haben.", + "maxSpeakers": "Max", + "minSpeakers": "Min", + "noTags": "Sie haben noch keine Tags erstellt." + }, + "tags": { + "addTag": "Tag hinzufügen", + "clearTagFilter": "Filter löschen", + "createTag": "Tag erstellen", + "currentTags": "Aktuelle Tags", + "filterByTag": "Nach Tag filtern", + "manageAllTags": "Alle Tags verwalten", + "noAvailableTags": "Keine verfügbaren Tags", + "noMatchingTags": "Keine passenden Tags", + "noTags": "Keine Tags", + "removeTag": "Tag entfernen", + "searchTags": "Suchen...", + "tagColor": "Tag-Farbe", + "tagName": "Tag-Name", + "title": "Tags" + }, + "tagsModal": { + "addTags": "Tags hinzufügen", + "currentTags": "Aktuelle Tags", + "done": "Fertig", + "noTagsAssigned": "Dieser Aufnahme sind keine Tags zugewiesen", + "searchTags": "Tags suchen..." + }, + "time": { + "dayAgo": "Vor 1 Tag", + "daysAgo": "Vor {{count}} Tagen", + "hourAgo": "Vor 1 Stunde", + "hoursAgo": "Vor {{count}} Stunden", + "justNow": "Gerade eben", + "minuteAgo": "Vor 1 Minute", + "minutesAgo": "Vor {{count}} Minuten", + "monthAgo": "Vor 1 Monat", + "monthsAgo": "Vor {{count}} Monaten", + "weekAgo": "Vor 1 Woche", + "weeksAgo": "Vor {{count}} Wochen", + "yearAgo": "Vor 1 Jahr", + "yearsAgo": "Vor {{count}} Jahren" + }, + "tooltips": { + "changeSpeaker": "Sprecher ändern", + "clearChat": "Chat leeren", + "copyTranscript": "Transkript kopieren", + "deleteTeam": "Gruppe Löschen", + "doubleClickToEdit": "Doppelklicken zum Bearbeiten", + "downloadTranscriptWithTemplate": "Transkript mit Vorlage herunterladen", + "editTeam": "Gruppe Bearbeiten", + "editText": "Text bearbeiten", + "editTitle": "Titel bearbeiten", + "editTranscript": "Transkript bearbeiten", + "exitFullscreen": "Exit fullscreen", + "expand": "Erweitern", + "followPlayerDisabled": "Automatisches Scrollen aktivieren - Transkript folgt der Audiowiedergabe", + "followPlayerEnabled": "Automatisches Scrollen deaktivieren - Transkript bleibt an Ort und Stelle", + "fullscreenVideo": "Fullscreen video", + "grantPublicSharing": "Öffentliche Freigabeberechtigung erteilen", + "hideVideo": "Hide video", + "highlight": "Hervorheben", + "makeAdmin": "Zum Admin Machen", + "manageMembers": "Mitglieder Verwalten", + "manageTeamTags": "Gruppen-Tags Verwalten", + "markAsRead": "Als gelesen markieren", + "maximizeChat": "Chat maximieren", + "minimize": "Minimieren", + "moveToInbox": "In Posteingang verschieben", + "mute": "Stumm", + "pause": "Pause", + "play": "Abspielen", + "playbackSpeed": "Wiedergabegeschwindigkeit", + "removeAdmin": "Admin Entfernen", + "removeFromQueue": "Aus Warteschlange entfernen", + "removeFromTeam": "Aus Team entfernen", + "removeHighlight": "Hervorhebung entfernen", + "reprocessTranscription": "Transkript neu verarbeiten", + "reprocessWithAsr": "Mit ASR neu verarbeiten", + "restoreChat": "Chat wiederherstellen", + "revokePublicSharing": "Öffentliche Freigabeberechtigung widerrufen", + "shareWithUsers": "Mit Benutzern teilen", + "showVideo": "Show video", + "switchToDarkMode": "Zum Dunkelmodus Wechseln", + "switchToLightMode": "Zum Hellmodus Wechseln", + "unmute": "Ton an" + }, + "transcriptTemplates": { + "availableTemplates": "Verfügbare Vorlagen", + "availableVars": "Verfügbare Variablen", + "cancel": "Abbrechen", + "chooseTemplate": "Vorlage wählen...", + "createDefaults": "Standardvorlagen Erstellen", + "createNew": "Vorlage Erstellen", + "default": "Standard", + "delete": "Löschen", + "description": "Anpassen, wie Transkripte für Download und Export formatiert werden.", + "downloadDefault": "Standard herunterladen", + "downloadWithoutTemplate": "Ohne Vorlage Herunterladen", + "filters": "Filter: |upper für Großbuchstaben, |srt für Untertitelformat", + "save": "Speichern", + "selectOrCreate": "Wählen Sie eine Vorlage zum Bearbeiten oder erstellen Sie eine neue", + "selectTemplate": "Vorlage Auswählen", + "setDefault": "Als Standardvorlage festlegen", + "tabTitle": "Transkript", + "template": "Vorlage", + "templateName": "Vorlagenname", + "title": "Transkript-Vorlagen", + "viewGuide": "Vorlagen-Leitfaden Anzeigen" + }, + "transcription": { + "autoIdentifySpeakers": "Sprecher automatisch identifizieren", + "bubble": "Blase", + "cancelEdit": "Bearbeitung abbrechen", + "copy": "Kopieren", + "copyToClipboard": "In Zwischenablage kopieren", + "download": "Herunterladen", + "downloadTranscript": "Transkript herunterladen", + "edit": "Bearbeiten", + "editSpeakers": "Sprecher bearbeiten", + "editTranscription": "Transkription bearbeiten", + "highlightSearchResults": "Suchergebnisse hervorheben", + "noTranscription": "Keine Transkription verfügbar", + "regenerateTranscription": "Transkription neu generieren", + "saveTranscription": "Transkription speichern", + "searchInTranscript": "In Transkript suchen...", + "simple": "Einfach", + "speaker": "Sprecher {{number}}", + "speakerLabels": "Sprecher-Labels", + "title": "Transkription", + "unknownSpeaker": "Unbekannter Sprecher" + }, + "upload": { + "chunking": "Große Dateien werden automatisch für die Verarbeitung aufgeteilt", + "completed": "Abgeschlossen", + "copies": "Kopien dieser Datei", + "dropzone": "Audio-Dateien hier hineinziehen oder klicken zum Durchsuchen", + "duplicateDetected": "Diese Datei scheint ein Duplikat von \"{{existingName}}\" zu sein (hochgeladen am {{existingDate}})", + "duplicateFile": "Doppelte Datei", + "failed": "Fehlgeschlagen", + "fileExceedsMaxSize": "File \"{{name}}\" exceeds the maximum size of {{size}} MB and was skipped.", + "fileRemovedFromQueue": "File removed from queue", + "filesToUpload": "Files to Upload", + "invalidFileType": "Invalid file type \"{{name}}\". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.", + "maxFileSize": "Maximale Dateigröße", + "queued": "In Warteschlange", + "selectFiles": "Dateien auswählen", + "settingsApplyToAll": "Settings apply to all files in this session", + "summarizing": "Zusammenfasse...", + "supportedFormats": "Unterstützt MP3, WAV, M4A, MP4, MOV, AVI, AMR und mehr", + "title": "Audio hochladen", + "transcribing": "Transkribiere...", + "untitled": "Unbenannte Aufnahme", + "uploadNFiles": "Upload {{count}} File(s)", + "uploadProgress": "Upload-Fortschritt", + "videoRetained": "Video für Wiedergabe beibehalten", + "willAutoSummarize": "Wird nach der Transkription automatisch zusammengefasst" + }, + "uploadProgress": { + "title": "Upload-Fortschritt" + } +} \ No newline at end of file diff --git a/static/locales/en.json b/static/locales/en.json new file mode 100644 index 0000000..7b14490 --- /dev/null +++ b/static/locales/en.json @@ -0,0 +1,1506 @@ +{ + "aboutPage": { + "aiSummarization": "AI Summarization", + "aiSummarizationDesc": "OpenRouter and Ollama integration with custom prompts", + "asrEnabled": "ASR Enabled", + "asrEndpoint": "ASR Endpoint", + "audioTranscription": "Audio Transcription", + "audioTranscriptionDesc": "Whisper API and custom ASR support with high accuracy", + "backend": "Backend", + "database": "Database", + "deployment": "Deployment", + "dockerDescription": "Official Docker images", + "dockerHub": "Docker Hub", + "documentation": "Documentation", + "documentationDescription": "User guide and tutorials", + "endpoint": "Endpoint", + "frontend": "Frontend", + "githubDescription": "Source code, issues, and releases", + "githubRepository": "GitHub Repository", + "inquireMode": "Inquire Mode", + "inquireModeDesc": "Semantic search across all your recordings", + "interactiveChat": "Interactive Chat", + "interactiveChatDesc": "Chat with your transcriptions using AI", + "keyFeatures": "Key Features", + "largeLanguageModel": "Large Language Model", + "model": "Model", + "projectDescription": "Transform your audio recordings into organized, searchable notes with AI-powered transcription, summarization, and interactive chat features.", + "projectLinks": "Project Links", + "sharingExport": "Sharing & Export", + "sharingExportDesc": "Share recordings and export to various formats", + "speakerDiarization": "Speaker Diarization", + "speakerDiarizationDesc": "Identify and label different speakers automatically", + "speechRecognition": "Speech Recognition", + "systemConfiguration": "System Configuration", + "tagline": "AI-Powered Audio Transcription & Note-Taking", + "technologyStack": "Technology Stack", + "title": "About", + "version": "Version", + "whisperApi": "Whisper API" + }, + "aboutPageDetails": { + "aiSummarizationDesc": "OpenRouter and Ollama integration with custom prompts", + "asrEnabled": "ASR Enabled", + "asrEndpoint": "ASR Endpoint", + "audioTranscriptionDesc": "Whisper API and custom ASR support with high accuracy", + "backend": "Backend", + "database": "Database", + "deployment": "Deployment", + "dockerDescription": "Official Docker images", + "documentationDescription": "Setup guides and user manual", + "endpoint": "Endpoint", + "frontend": "Frontend", + "githubDescription": "Source code, issues, and releases", + "inquireModeDesc": "Semantic search across all your recordings", + "interactiveChatDesc": "Chat with your transcriptions using AI", + "model": "Model", + "no": "No", + "sharingExportDesc": "Share recordings and export to various formats", + "speakerDiarizationDesc": "Identify and label different speakers automatically", + "whisperApi": "Whisper API", + "yes": "Yes" + }, + "account": { + "accountActions": "Account Actions", + "autoLabel": "Auto-label", + "autoSummarizationDisabled": "Auto-summarization disabled by admin", + "autoSummarize": "Auto-summarize", + "changePassword": "Change Password", + "chooseLanguageForInterface": "Choose the language for the application interface", + "companyOrganization": "Company / Organization", + "completedRecordings": "Completed", + "defaultHotwords": "Default Hotwords", + "defaultHotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote, SDRs", + "defaultInitialPrompt": "Default Initial Prompt", + "defaultInitialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools. The speakers discuss CTranslate2, PyAnnote, and SDRs.", + "email": "Email", + "failedRecordings": "Failed", + "fullName": "Full Name", + "goToRecordings": "Go to Recordings", + "interfaceLanguage": "Interface Language", + "jobTitle": "Job Title", + "languageForSummaries": "Language for titles, summaries, and chat. Leave blank for default (default behavior of your chosen models).", + "languagePreferences": "Language Preferences", + "leaveBlankForAutoDetect": "Leave blank for auto-detection by transcription service", + "manageSpeakers": "Manage Speakers", + "personalFolder": "Personal Folder (Not Associated with a Group)", + "personalInfo": "Personal Information", + "personalTag": "Personal Tag (Not Associated with a Group)", + "preferredOutputLanguage": "Preferred Chatbot and Summarization Language", + "processingRecordings": "Processing", + "saveAllPreferences": "Save All Preferences", + "ssoLinkAccount": "Link {{provider}} account", + "ssoLinked": "Linked", + "ssoNotLinked": "Not linked", + "ssoProvider": "Provider", + "ssoSetPasswordFirst": "To unlink SSO, you must first set a password.", + "ssoSubject": "Subject:", + "ssoUnlinkAccount": "Unlink {{provider}} account", + "ssoUnlinkConfirm": "Are you sure you want to unlink your SSO account? You will need to use your password to log in.", + "statistics": "Account Statistics", + "title": "Account Information", + "totalRecordings": "Total Recordings", + "transcriptionHints": "Transcription Hints", + "transcriptionHintsDesc": "These defaults are used when no tag or folder overrides are set. They help improve transcription accuracy for your specific use case.", + "transcriptionLanguage": "Transcription Language", + "userDetails": "User Details", + "username": "Username" + }, + "accountTabs": { + "about": "About", + "apiTokens": "API Tokens", + "customPrompts": "Custom Prompts", + "folderManagement": "Folder Management", + "help": "Help", + "namingTemplates": "Naming", + "promptOptions": "Prompt Options", + "sharedTranscripts": "Shared Transcripts", + "speakersManagement": "Speakers Management", + "tagManagement": "Tag Management", + "templates": "Templates", + "transcriptTemplates": "Transcript Templates" + }, + "admin": { + "title": "Admin", + "userMenu": "User Menu" + }, + "adminDashboard": { + "aboutInquireMode": "About Inquire Mode", + "actions": "ACTIONS", + "addNewUser": "Add New User", + "addUser": "Add User", + "additionalContext": "Additional Context", + "admin": "ADMIN", + "adminDefaultPrompt": "Admin Default Prompt", + "adminDefaultPromptDesc": "The prompt configured above (shown on this page)", + "adminUser": "Admin User", + "allRecordingsProcessed": "All recordings are processed!", + "allowed": "Allowed", + "available": "Available", + "blocked": "blocked", + "budgetWarnings": "Budget Warnings", + "characters": "characters", + "chunkSize": "Chunk Size", + "complete": "complete", + "completedRecordings": "Completed", + "confirmPasswordLabel": "Confirm Password", + "contextNotes": { + "jsonConversion": "JSON transcripts are converted to clean text format before sending", + "modelConfig": "The model used is configured via TEXT_MODEL_NAME environment variable", + "tagPrompts": "If multiple tag prompts exist, they are merged in the order tags were added", + "transcriptLimit": "Transcripts are limited to a configurable character count (default: 30,000)" + }, + "createFirstGroup": "Create Your First Group", + "created": "Created", + "currentUsage": "Current usage", + "currentUsageMinutes": "Current Usage", + "defaultPromptInfo": "This default prompt will be used for all users who haven't set their own custom prompt in their account settings.", + "defaultPrompts": "Default Prompts", + "defaultSummarizationPrompt": "Default Summarization Prompt", + "description": "Description", + "editUser": "Edit User", + "email": "EMAIL", + "emailLabel": "Email", + "embeddingModel": "Embedding Model", + "embeddingsStatus": "Embeddings Status", + "errors": { + "failedToLoadPrompt": "Failed to load default prompt", + "failedToSavePrompt": "Failed to save default prompt. Please try again.", + "invalidFileSize": "Please enter a valid size between 1 and 10000 MB", + "invalidInteger": "Please enter a valid integer", + "invalidNumber": "Please enter a valid number", + "maxTimeout": "Timeout cannot exceed 10 hours (36000 seconds)", + "minCharacters": "Please enter a valid number of at least 1000 characters", + "minTimeout": "Timeout must be at least 60 seconds" + }, + "failedRecordings": "Failed", + "groupName": "Group Name", + "groupsTab": "Groups", + "id": "ID", + "inquireModeDescription": "Inquire Mode allows users to search across multiple transcriptions using natural language questions. It works by breaking transcriptions into chunks and creating searchable embeddings using AI models.", + "languagePreferenceNote": "Note: If the user has set an output language preference, \" Ensure your response is in {language}.\" will be added.", + "lastUpdated": "Last updated", + "maxFileSizeHelp": "Maximum file size in megabytes (1-10000 MB)", + "megabytes": "MB", + "members": "Members", + "membersCount": "members", + "minutes": "minutes", + "monthlyCost": "Monthly Cost", + "monthlyTokenBudget": "Monthly Token Budget", + "monthlyTranscriptionBudget": "Monthly Transcription Budget (minutes)", + "needProcessing": "Need Processing", + "never": "Never", + "newPasswordLabel": "New Password (leave blank to keep current)", + "no": "No", + "noData": "No data", + "noDescription": "No description", + "noDescriptionAvailable": "No description available", + "noGroupsAdmin": "You are not an admin of any groups yet", + "noGroupsCreated": "No groups created yet", + "noLimit": "No Limit", + "noMembersYet": "No members yet", + "noTranscriptionData": "No transcription usage data available", + "none": "None", + "notSet": "Not set", + "overlap": "Overlap", + "passwordLabel": "Password", + "passwordsDoNotMatch": "Passwords do not match", + "pendingRecordings": "Pending", + "percentUsed": "used", + "placeholdersNote": "Placeholders are replaced with actual values when processing a recording.", + "processAllRecordings": "Process All Recordings", + "processNext10": "Process Next 10", + "processedForInquire": "Processed for Inquire", + "processing": "Processing...", + "processingActions": "Processing Actions", + "processingProgress": "Processing Progress", + "processingRecordings": "Processing", + "promptDescription": "This prompt will be used to generate summaries for all recordings when users haven't set their own prompt.", + "promptHierarchy": "Prompt Hierarchy", + "promptPriorityDescription": "The system uses the following priority order when selecting which prompt to use:", + "promptResetMessage": "Prompt reset to system default. Click \"Save Changes\" to apply.", + "promptSavedSuccessfully": "Default prompt saved successfully!", + "publicShare": "Public Share", + "recordingStatusDistribution": "Recording Status Distribution", + "recordings": "RECORDINGS", + "recordingsNeedProcessing": "There are {{count}} recordings that need to be processed for Inquire Mode.", + "refreshStatus": "Refresh Status", + "resetToDefault": "Reset to Default", + "saving": "Saving...", + "searchUsers": "Search users...", + "seconds": "seconds", + "settings": { + "asrTimeoutDesc": "Maximum time in seconds to wait for ASR transcription to complete. Default is 1800 seconds (30 minutes).", + "defaultSummaryPromptDesc": "Default summarization prompt used when users have not set their own prompt. This serves as the base prompt for all users.", + "maxFileSizeDesc": "Maximum file size allowed for audio uploads in megabytes (MB).", + "recordingDisclaimerDesc": "Legal disclaimer shown to users before recording starts. Supports Markdown formatting. Leave empty to disable.", + "uploadDisclaimerDesc": "Legal disclaimer shown before file uploads. Supports Markdown. Leave empty to disable.", + "customBannerDesc": "Custom banner shown at the top of the page. Supports Markdown. Leave empty to disable.", + "transcriptLengthLimitDesc": "Maximum number of characters to send from transcript to LLM for summarization and chat. Use -1 for no limit." + }, + "storageUsed": "STORAGE USED", + "summarizationInstructions": "Summarization Instructions", + "systemFallback": "System Fallback", + "systemFallbackDesc": "A hardcoded default if none of the above are set", + "systemPrompt": "System Prompt", + "systemSettings": "System Settings", + "systemStatistics": "System Statistics", + "tagCustomPrompt": "Tag Custom Prompt", + "tagCustomPromptDesc": "If a recording has tags with custom prompts", + "textSearchOnly": "Text Search Only", + "thisMonth": "This Month", + "timeoutRecommendation": "Recommended: 30-120 minutes for long audio files", + "title": "Admin Dashboard", + "todaysMinutes": "Today's Minutes", + "tokenBudgetExceeded": "Monthly token budget exceeded. Please contact your administrator.", + "tokenBudgetHelp": "Set a monthly token limit for AI features. Leave empty for unlimited.", + "tokenBudgetPlaceholder": "Leave empty for unlimited", + "tokenUsage": "Token usage", + "tokens": "tokens", + "topUsers": "Top Users", + "topUsersByStorage": "Top Users by Storage", + "total": "Total", + "totalChunks": "Total Chunks", + "totalQueries": "Total Queries", + "totalRecordings": "Total Recordings", + "totalStorage": "Total Storage", + "totalUsers": "Total Users", + "transcriptionBudgetHelp": "Limit transcription minutes per month. Leave empty for unlimited.", + "transcriptionBudgetPlaceholder": "Leave empty for unlimited", + "transcriptionUsage": "Transcription Usage", + "updateUser": "Update User", + "userCustomPrompt": "User Custom Prompt", + "userCustomPromptDesc": "If the user has set their own prompt in account settings", + "userManagement": "User Management", + "userMessageTemplate": "User Message Template", + "userTranscriptionUsage": "User Transcription Usage (This Month)", + "username": "USERNAME", + "usernameLabel": "Username", + "vectorDimensions": "Vector Dimensions", + "vectorStore": "Vector Store", + "vectorStoreManagement": "Vector Store Management", + "vectorStoreUpToDate": "The vector store is up to date.", + "viewFullPromptStructure": "View Full LLM Prompt Structure", + "warning": "warning", + "yes": "Yes" + }, + "apiTokens": { + "activeTokens": "Active Tokens", + "createFirstToken": "Create your first API token to enable programmatic access", + "createToken": "Create Token", + "created": "Created", + "description": "Create and manage API tokens for programmatic access to your account", + "expires": "Expires", + "lastUsed": "Last used", + "neverUsed": "Never used", + "noExpiration": "No expiration", + "noTokens": "No API Tokens", + "securityNotice": "Security Notice", + "securityWarning": "Treat API tokens like passwords. They provide full access to your account. Never share tokens in publicly accessible areas such as GitHub, client-side code, or logs", + "title": "API Tokens", + "usageExamples": "Usage Examples" + }, + "buttons": { + "addSegment": "Add Segment", + "addSpeaker": "Add Speaker", + "cancel": "Cancel", + "clearAllFilters": "Clear all filters", + "clearCompleted": "Clear completed uploads", + "clearSearchText": "Clear search text", + "close": "Close", + "copy": "Copy", + "copyMessage": "Copy message", + "copyNotes": "Copy notes", + "copySummary": "Copy summary", + "copyToClipboard": "Copy to clipboard", + "createTag": "Create Tag", + "deleteAll": "Delete All", + "deleteSpeaker": "Delete speaker", + "deleteTag": "Delete tag", + "deleteUser": "Delete User", + "downloadAsWord": "Download as Word", + "downloadAudio": "Download audio", + "downloadChat": "Download chat as Word document", + "downloadNotes": "Download Notes as Word Document", + "downloadSummary": "Download Summary as Word Document", + "editNotes": "Edit notes", + "editSetting": "Edit Setting", + "editSpeakers": "Edit Speakers...", + "editSummary": "Edit summary", + "editTag": "Edit tag", + "editTags": "Edit Tags", + "editTranscription": "Edit transcription", + "editUser": "Edit User", + "exportCalendar": "Export to Calendar", + "help": "Help", + "identifySpeakers": "Identify speakers", + "next": "Next", + "previous": "Previous", + "refresh": "Refresh", + "reprocessSummary": "Reprocess summary", + "reprocessTranscription": "Reprocess transcription", + "reprocessWithAsr": "Reprocess with ASR", + "resetStuckProcessing": "Reset stuck processing", + "saveChanges": "Save Changes", + "saveCustomPrompt": "Save Custom Prompt", + "saveNames": "Save Names", + "saveSettings": "Save Settings", + "shareRecording": "Share recording", + "toggleTheme": "Toggle theme", + "updateTag": "Update Tag" + }, + "changePasswordModal": { + "confirmPassword": "Confirm New Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "passwordRequirement": "Password must be at least 8 characters long", + "title": "Change Password" + }, + "chat": { + "availableAfterTranscription": "Chat will be available once transcription is complete", + "cannotChatTranscriptionFailed": "Cannot chat: transcription failed. Please reprocess the transcription first.", + "chatWithTranscription": "Chat with Transcription", + "clearChat": "Clear Chat", + "cleared": "Chat cleared", + "downloadFailed": "Failed to download chat", + "downloadSuccess": "Chat downloaded successfully!", + "error": "Failed to send message", + "noMessages": "No messages yet", + "noMessagesToDownload": "No chat messages to download.", + "placeholder": "Ask a question about this recording...", + "placeholderWithHint": "Ask a question about this recording... (Enter to send, Ctrl+Enter for new line)", + "send": "Send", + "suggestedQuestions": "Suggested Questions", + "thinking": "Thinking...", + "title": "Chat", + "whatAreActionItems": "What are the action items?", + "whatAreKeyPoints": "What are the key points?", + "whatWasDiscussed": "What was discussed?", + "whoSaidWhat": "Who said what?" + }, + "colorScheme": { + "chooseRecording": "Choose a recording from the sidebar to view its transcription and summary", + "darkThemes": "Dark Themes", + "descriptions": { + "blue": "Classic blue theme with a clean, professional feel", + "emerald": "Nature-inspired green theme for a calming experience", + "purple": "Rich purple theme with an elegant, modern look", + "rose": "Warm rose theme with a soft, inviting aesthetic", + "amber": "Warm amber tones for a cozy, productive feel", + "teal": "Cool teal theme with a refreshing, modern vibe" + }, + "lightThemes": "Light Themes", + "names": { + "blue": "Ocean Blue", + "emerald": "Forest Green", + "purple": "Royal Purple", + "rose": "Coral Rose", + "amber": "Golden Amber", + "teal": "Arctic Teal" + }, + "resetToDefault": "Reset to Default", + "selectRecording": "Select a Recording", + "subtitle": "Customize your interface with beautiful color themes", + "themes": { + "light": { + "blue": { + "name": "Ocean Blue", + "description": "Classic blue theme with professional appeal" + }, + "emerald": { + "name": "Forest Emerald", + "description": "Fresh green theme for a natural feel" + }, + "purple": { + "name": "Royal Purple", + "description": "Elegant purple theme with sophistication" + }, + "rose": { + "name": "Sunset Rose", + "description": "Warm pink theme with gentle energy" + }, + "amber": { + "name": "Golden Amber", + "description": "Warm yellow theme for brightness" + }, + "teal": { + "name": "Ocean Teal", + "description": "Cool teal theme for tranquility" + } + }, + "dark": { + "blue": { + "name": "Midnight Blue", + "description": "Deep blue theme for focused work" + }, + "emerald": { + "name": "Dark Forest", + "description": "Rich green theme for comfortable viewing" + }, + "purple": { + "name": "Deep Purple", + "description": "Mysterious purple theme for creativity" + }, + "rose": { + "name": "Dark Rose", + "description": "Muted pink theme with subtle warmth" + }, + "amber": { + "name": "Dark Amber", + "description": "Warm brown theme for cozy sessions" + }, + "teal": { + "name": "Deep Teal", + "description": "Dark teal theme for calm focus" + } + } + }, + "title": "Choose Color Scheme" + }, + "common": { + "back": "Back", + "cancel": "Cancel", + "changesSaved": "Changes saved", + "close": "Close", + "confirm": "Confirm", + "delete": "Delete", + "deselectAll": "Deselect All", + "download": "Download", + "edit": "Edit", + "error": "Error", + "failed": "Failed", + "filter": "Filter", + "info": "Info", + "loading": "Loading...", + "new": "New", + "next": "Next", + "no": "No", + "noResults": "No results found", + "ok": "OK", + "or": "Or", + "previous": "Previous", + "processing": "Processing...", + "refresh": "Refresh", + "retry": "Retry", + "save": "Save", + "search": "Search", + "selectAll": "Select All", + "sort": "Sort", + "success": "Success", + "untitled": "Untitled", + "upload": "Upload", + "warning": "Warning", + "yes": "Yes" + }, + "customPrompts": { + "currentDefaultPrompt": "Current Default Prompt (Used if you leave the above blank)", + "promptDescription": "This prompt will be used to generate summaries for your transcriptions. It overrides the admin's default prompt.", + "promptPlaceholder": "Describe how you want your summaries structured. Leave blank to use the admin's default prompt.", + "summaryGeneration": "Summary Generation Prompt", + "tip1": "Be specific about the sections you want in your summary", + "tip2": "Use clear formatting instructions (e.g., \"Use bullet points\", \"Create numbered lists\")", + "tip3": "Specify if you want certain information prioritized", + "tip4": "The system will automatically provide the transcript content to the AI", + "tip5": "Your output language preference (if set) will be applied automatically", + "tipsTitle": "Tips for Writing Effective Prompts", + "yourCustomPrompt": "Your Custom Summary Prompt" + }, + "deleteAllSpeakersModal": { + "confirmMessage": "Are you sure you want to delete all saved speakers? This action cannot be undone.", + "title": "Delete All Speakers" + }, + "dialogs": { + "deleteRecording": { + "cancel": "Cancel", + "confirm": "Delete", + "message": "Are you sure you want to delete this recording? This action cannot be undone.", + "title": "Delete Recording" + }, + "deleteShare": { + "message": "Are you sure you want to delete this share? This will revoke access to the public link.", + "title": "Delete Share" + }, + "reprocessTranscription": { + "cancel": "Cancel", + "confirm": "Reprocess", + "message": "Are you sure you want to reprocess this transcription? The current transcription will be replaced.", + "title": "Reprocess Transcription" + }, + "unsavedChanges": { + "cancel": "Cancel", + "discard": "Discard", + "message": "You have unsaved changes. Do you want to save them before leaving?", + "save": "Save", + "title": "Unsaved Changes" + } + }, + "duration": { + "hours": "{{count}} hour", + "hoursPlural": "{{count}} hours", + "minutes": "{{count}} minute", + "minutesPlural": "{{count}} minutes", + "seconds": "{{count}} second", + "secondsPlural": "{{count}} seconds" + }, + "editTagModal": { + "asrDefaultSettings": "ASR Default Settings", + "asrSettingsDescription": "These settings will be applied by default when using this tag with ASR transcription", + "color": "Color", + "colorDescription": "Choose a color for easy identification", + "createTitle": "Create Tag", + "customPrompt": "Custom Summary Prompt", + "customPromptPlaceholder": "Optional: Custom prompt for generating summaries for recordings with this tag", + "defaultLanguage": "Default Language", + "defaultPromptPlaceholder": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden", + "exportTemplate": "Export Template", + "exportTemplateHint": "Select an export template to use when exporting recordings with this tag", + "leaveBlankPrompt": "Leave blank to use your default summary prompt", + "maxSpeakers": "Max Speakers", + "minSpeakers": "Min Speakers", + "namingTemplate": "Naming Template", + "namingTemplateHint": "Select a naming template to automatically format titles for recordings with this tag", + "noExportTemplate": "No template (use user default)", + "noNamingTemplate": "No template (use user default or AI title)", + "tagName": "Tag Name *", + "tagNamePlaceholder": "e.g., Meetings, Interviews", + "title": "Edit Tag" + }, + "errors": { + "audioExtractionFailed": "Audio Extraction Failed", + "audioExtractionFailedGuidance": "Try converting the file to a standard audio format (MP3, WAV) before uploading.", + "audioExtractionFailedMessage": "Could not extract audio from the uploaded file.", + "audioRecordingFailed": "音频录制失败。请检查您的麦克风。", + "authenticationError": "Authentication Error", + "authenticationErrorGuidance": "Please check that the API key is correct and has not expired.", + "authenticationErrorMessage": "The transcription service rejected the API credentials.", + "checkApiKeyGuidance": "Check the API key in settings", + "checkNetworkGuidance": "Check network connection", + "connectionError": "Connection Error", + "connectionErrorGuidance": "Check your internet connection and ensure the service is available.", + "connectionErrorMessage": "Could not connect to the transcription service.", + "convertFormatGuidance": "Convert to MP3 or WAV before uploading", + "convertStandardGuidance": "Convert to standard audio format", + "enableChunkingGuidance": "Enable chunking in settings or compress the file", + "fallbackMessage": "An error occurred", + "fallbackTitle": "Error", + "fileTooLarge": "文件太大", + "fileTooLargeGuidance": "Try enabling audio chunking in your settings, or compress the audio file before uploading.", + "fileTooLargeMaxSize": "File too large. Max: {{size}} MB.", + "fileTooLargeMessage": "The audio file exceeds the maximum size allowed by the transcription service.", + "fileTooLargeTitle": "File Too Large", + "generic": "发生错误", + "invalidAudioFormat": "Invalid Audio Format", + "invalidAudioFormatGuidance": "Try converting the audio to MP3 or WAV format before uploading.", + "invalidAudioFormatMessage": "The audio file format is not supported or the file may be corrupted.", + "loadingShares": "Error loading shares", + "networkError": "网络错误。请检查您的连接。", + "networkErrorDuringUpload": "Network error during upload", + "notFound": "未找到", + "permissionDenied": "权限被拒绝", + "processingError": "Processing Error", + "processingErrorFallbackGuidance": "Try reprocessing the recording", + "processingErrorGuidance": "If this error persists, try reprocessing the recording.", + "processingErrorMessage": "An error occurred during processing.", + "processingFailedOnServer": "Processing failed on server.", + "processingFailedWithStatus": "Processing failed with status {{status}}", + "processingTimeout": "Processing Timeout", + "processingTimeoutGuidance": "This can happen with very long recordings. Try splitting the audio into smaller parts.", + "processingTimeoutMessage": "The transcription took too long to complete.", + "quotaExceeded": "存储配额已超出", + "rateLimitExceeded": "Rate Limit Exceeded", + "rateLimitExceededGuidance": "Please wait a few minutes and try reprocessing.", + "rateLimitExceededMessage": "Too many requests were sent to the transcription service.", + "serverError": "Server error ({{status}}): Response was not JSON", + "serverErrorStatus": "Server error ({{status}})", + "serviceUnavailable": "Service Unavailable", + "serviceUnavailableGuidance": "This is usually temporary. Please try again in a few minutes.", + "serviceUnavailableMessage": "The transcription service is temporarily unavailable.", + "splitAudioGuidance": "Try splitting the audio into smaller parts", + "summaryFailed": "摘要生成失败。请重试。", + "transcriptionFailed": "转录失败。请重试。", + "tryAgainLaterGuidance": "Try again in a few minutes", + "unauthorized": "未授权", + "unexpectedResponse": "Unexpected success response from server after upload.", + "unsupportedFormat": "不支持的文件格式", + "uploadFailed": "上传失败。请重试。", + "uploadFailedWithStatus": "Upload failed with status {{status}}", + "uploadTimedOut": "Upload timed out", + "validationError": "验证错误", + "waitAndRetryGuidance": "Wait a few minutes and try again" + }, + "eventExtraction": { + "description": "When enabled, the AI will identify meetings, appointments, and deadlines mentioned in your recordings and create downloadable calendar events.", + "enableLabel": "Enable automatic event extraction from transcripts", + "info": "Extracted events will appear in an 'Events' tab on recordings where calendar items are detected.", + "title": "Event Extraction" + }, + "events": { + "add": "Add", + "addToCalendar": "Add to Calendar", + "attendees": "Attendees", + "confirmDelete": "Delete event \"{title}\"?", + "delete": "Delete", + "deleteFailed": "Failed to delete event", + "deleted": "Event deleted", + "end": "End", + "location": "Location", + "noEvents": "No events detected in this recording", + "start": "Start", + "title": "Events" + }, + "exportLabels": { + "created": "Created", + "date": "Date", + "fileSize": "File Size", + "footer": "Generated with [DictIA](https://gitea.innova-ai.ca/Innova-AI/dictia)", + "metadata": "Metadata", + "notes": "Notes", + "originalFile": "Original File", + "participants": "Participants", + "summarizationTime": "Summarization Time", + "summary": "Summary", + "tags": "Tags", + "transcription": "Transcription", + "transcriptionTime": "Transcription Time" + }, + "exportTemplates": { + "availableLabels": "Localized Labels (auto-translated)", + "availableTemplates": "Available Templates", + "availableVars": "Available Variables", + "cancel": "Cancel", + "conditionals": "Conditionals", + "conditionalsHint": "Use {{#if variable}}...{{/if}} to conditionally include content", + "contentSections": "Content Sections", + "createDefaults": "Create Default Template", + "createNew": "Create Template", + "default": "Default", + "delete": "Delete", + "description": "Customize how recordings are exported to markdown files.", + "recordingData": "Recording Data", + "save": "Save", + "selectOrCreate": "Select a template to edit or create a new one", + "setDefault": "Set as default template", + "tabTitle": "Export", + "template": "Template", + "templateDescription": "Description", + "templateName": "Template Name", + "title": "Export Templates", + "viewGuide": "View Template Guide" + }, + "fileSize": { + "bytes": "{{count}} B", + "gigabytes": "{{count}} GB", + "kilobytes": "{{count}} KB", + "megabytes": "{{count}} MB" + }, + "folderManagement": { + "allFolders": "All Folders", + "asrDefaults": "ASR Defaults", + "autoShareOnApply": "Auto-share with group members", + "autoShareOnApplyHelp": "Auto-share recordings with all group members when added to this folder", + "confirmDelete": "Are you sure you want to delete this folder? Recordings will be removed from this folder but not deleted.", + "createFolder": "Create Folder", + "customPrompt": "Custom Prompt", + "defaultLanguage": "Default Language", + "deleteFolder": "Delete Folder", + "description": "Organize your recordings into folders. Unlike tags, a recording can only belong to one folder. Folder prompts are applied before user prompts but after tag prompts.", + "editFolder": "Edit Folder", + "filterByFolder": "Filter by Folder", + "folderColor": "Folder Color", + "folderName": "Folder Name", + "groupSettings": "Group Settings", + "maxSpeakers": "Max Speakers", + "minSpeakers": "Min Speakers", + "moveToFolder": "Move to Folder", + "namingTemplate": "Naming Template", + "noFolder": "No Folder", + "noFolders": "No folders created yet", + "noFoldersDescription": "Create your first folder to organize your recordings", + "protectFromDeletion": "Protect from Deletion", + "protectFromDeletionHelp": "Protect recordings in this folder from auto-deletion", + "recordings": "recordings", + "removeFromFolder": "Remove from folder", + "retentionDays": "Retention Days", + "retentionDaysHelp": "Recordings in this folder will be deleted after this many days. Leave empty to use global retention.", + "retentionSettings": "Retention Settings", + "retentionDisabledWarning": "Auto-deletion is currently disabled. These settings will take effect when enabled by an admin.", + "selectNamingTemplate": "Select naming template...", + "shareWithGroupLead": "Share with group admins", + "shareWithGroupLeadHelp": "Share recordings with group admins when added to this folder", + "title": "Folder Management" + }, + "form": { + "auto": "Auto", + "autoDetect": "Auto-detect", + "dateFrom": "From", + "dateTo": "To", + "enterNotesMarkdown": "Enter notes in Markdown format...", + "enterSummaryMarkdown": "Enter summary in Markdown format...", + "folder": "Folder", + "hotwords": "Hotwords", + "hotwordsHelp": "Comma-separated words to improve recognition of domain-specific terms", + "hotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote", + "initialPrompt": "Initial Prompt", + "initialPromptHelp": "Context to steer the transcription model's style and vocabulary", + "initialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools.", + "language": "Language", + "maxSpeakers": "Max Speakers", + "meetingDate": "Meeting Date", + "minSpeakers": "Min Speakers", + "minutes": "Minutes", + "notes": "Notes", + "notesPlaceholder": "Type your notes in Markdown format...", + "optional": "Optional", + "participantNamePlaceholder": "Participant name...", + "participants": "Participants", + "placeholderAuto": "Auto", + "placeholderCharacterLimit": "Enter character limit (e.g., 30000)", + "placeholderMinutes": "Minutes", + "placeholderOptional": "Optional", + "placeholderSeconds": "Seconds", + "placeholderSizeMB": "Enter size in MB", + "searchSpeakers": "Search speakers...", + "searchTags": "Search tags...", + "seconds": "Seconds", + "shareNotes": "Share Notes", + "shareSummary": "Share Summary", + "shareableLink": "Shareable Link", + "summaryPromptPlaceholder": "Enter the default summarization prompt...", + "title": "Title", + "transcriptionLanguage": "Transcription Language", + "yourFullName": "Your full name" + }, + "groups": { + "addMembers": "Add members...", + "autoShare": "Auto-share", + "autoShareNote": "Note: If both are enabled, all team members will have access. If only \"team admins\" is enabled, only group leads will have access.", + "autoShareTeam": "Auto-share recordings with all team members when this tag is applied", + "autoSharesWithTeam": "Auto-shares with all team members", + "confirmDelete": "Are you sure you want to delete this group? This action cannot be undone.", + "createTeam": "Create Group", + "createTeamTag": "Create New Group Tag", + "dayRetention": "day retention", + "deleteTeam": "Delete Group", + "deletionExempt": "Exempt from deletion", + "deletionExemptHelp": "Recordings with this tag will be exempt from automatic deletion, even if they exceed the retention period.", + "editTeam": "Edit Group", + "editTeamTag": "Edit Group Tag", + "globalRetention": "Global retention", + "members": "Members", + "noMembers": "No members in this group", + "noTeamTags": "No group tags created yet", + "noTeams": "No teams created yet", + "retentionHelp": "Recordings with this tag will be deleted after this many days. Leave empty to use global retention ({{days}} days).", + "retentionPeriod": "Retention Period (days)", + "retentionPlaceholder": "Leave empty to use global retention", + "searchUsers": "Search users...", + "selectTeamLead": "Select group lead...", + "shareWithAdmins": "Share recordings with team admins when this tag is applied", + "sharesWithAdminsOnly": "Shares with team admins only", + "syncComplete": "Group shares synchronized successfully", + "syncTeamShares": "Sync Group Shares", + "syncTeamSharesDescription": "This will retroactively share all recordings that have group tags with the appropriate group members based on the tag's sharing settings.", + "teamLead": "Group Lead", + "teamName": "Group Name", + "teamNamePlaceholder": "Enter group name", + "teamTags": "Group Tags", + "title": "Group Management", + "updateTeam": "Update Group" + }, + "help": { + "actions": "Actions", + "activeFilters": "Active filters", + "addSegmentBelow": "Add segment below", + "advancedAsrOptions": "Advanced ASR Options", + "allRecordingsLoaded": "All recordings loaded", + "allTagsSelected": "All tags selected", + "appliedSuggestedNames": "Applied {{count}} suggested name", + "appliedSuggestedNamesPlural": "Applied {{count}} suggested names", + "applySuggested": "Apply Suggested", + "applySuggestedMobile": "Suggest", + "approachingLimit": "Approaching {{limit}}MB limit", + "askAboutTranscription": "Ask questions about this transcription", + "audioDeletedDescription": "The audio file for this recording has been deleted, but the transcription remains available.", + "audioDeletedMessage": "Audio file has been archived and is no longer available for playback.", + "audioDeletedTitle": "Audio file deleted", + "audioPlayer": "Audio Player", + "autoIdentify": "Auto Identify", + "autoIdentifyMobile": "Auto", + "bothAudioDesc": "Records your voice + meeting participants (recommended for online meetings)", + "clearFilters": "clear filters", + "clickToAddNotes": "Click to add notes...", + "colorRepeats": "Color repeats from speaker {{number}}", + "completedFiles": "Completed Files", + "confirmReprocessingTitle": "Confirm Reprocessing", + "copyMessage": "Copy message", + "createFolders": "Create folders", + "createPublicLink": "Create a public link to share this recording. Sharing is only available on secure (HTTPS) connections.", + "createTags": "Create tags", + "defaultHotwordsHelp": "Comma-separated words or phrases that the transcription model should prioritize recognizing (brand names, acronyms, technical terms).", + "defaultInitialPromptHelp": "Context to steer the transcription model's style and vocabulary. Describe the topic or expected content of your recordings.", + "deleteSegment": "Delete segment", + "discard": "Discard", + "dragToReorder": "Drag to reorder", + "endTime": "End", + "enterNameFor": "Enter name for", + "enterSpeakerName": "Enter speaker name...", + "entireScreen": "Entire Screen", + "errorChangingSpeaker": "Error changing speaker", + "errorOpeningTextEditor": "Error opening text editor", + "errorSavingText": "Error saving text", + "estimatedSize": "Estimated size", + "firstTagAsrSettings": "First tag's ASR settings will be applied:", + "firstTagDefaultsApplied": "First tag's defaults applied", + "folderHasCustomPrompt": "This folder has a custom summary prompt", + "generatingSummary": "Generating summary...", + "groups": "groups", + "howToRecordSystemAudio": "How to Record System Audio", + "identifyAllSpeakers": "Identify all speakers", + "identifying": "Identifying...", + "importantNote": "Important note", + "internalSharingDesc": "Share with specific users in your organization", + "lines": "{{count}} lines", + "loadingMore": "Loading more recordings...", + "loadingRecordings": "Loading recordings...", + "me": "Me", + "microphoneDesc": "Records your voice only", + "modelReasoning": "Model Reasoning", + "moreSpeakersThanColors": "More speakers than available colors", + "navigate": "Navigate", + "noDateSet": "No date set", + "noMatchingTags": "No matching tags", + "noParticipants": "No participants", + "noRecordingSelected": "No recording selected.", + "noSpeakersIdentified": "No speakers could be identified from the context.", + "noSuggestionsToApply": "No suggestions to apply", + "noTagsCreated": "No tags created yet.", + "of": "of", + "playFromHere": "Play from here", + "pleaseEnterSpeakerName": "Please enter a speaker name", + "processingTime": "Processing time", + "processingTimeDescription": "This may take a few minutes to complete. You can continue using the app while processing.", + "processingTranscription": "Processing transcription...", + "publicLinkDesc": "Anyone with this link can access the recording", + "recordSystemSteps1": "Click \"Record System Audio\" or \"Record Both\".", + "recordSystemSteps2": "In the popup, choose", + "recordSystemSteps3": "Make sure to check the box that says", + "recordingFinished": "Recording finished", + "recordingInProgress": "Recording in progress...", + "regenerateSummaryAfterNames": "Regenerate summary after updating names", + "saved": "Saved!", + "savingProgress": "Saving...", + "selectedTagsCustomPrompts": "Selected tags include custom summary prompts", + "sentence": "Sentence", + "shareSystemAudio": "Share system audio", + "shareTabAudio": "Share tab audio", + "sharedOn": "Shared on", + "sharingWindowNoAudio": "Sharing a \"Window\" will not capture audio.", + "speakerAdded": "Speaker added successfully", + "speakerCount": "Speaker", + "speakerName": "Speaker Name", + "speakerNamesUpdated": "Speaker names updated successfully!", + "speakers": "Speakers", + "speakersIdentified": "{{count}} speaker identified successfully!", + "speakersIdentifiedPlural": "{{count}} speakers identified successfully!", + "speakersUpdatedSaveToApply": "Speakers updated! Save the transcript to apply changes.", + "specificBrowserTab": "specific browser tab", + "startTime": "Start", + "startingAutoIdentification": "Starting automatic speaker identification...", + "summaryGenerationFailed": "Summary generation failed", + "summaryGenerationTimedOut": "Summary generation timed out", + "summaryRegenerationStarted": "Summary regeneration started", + "summaryUpdated": "Summary updated!", + "systemAudioDesc": "Records meeting participants and system sounds", + "tagManagement": "Tag Management", + "thisActionCannotBeUndone": "This action cannot be undone.", + "toCaptureAudioFromMeetings": "To capture audio from meetings or other apps, you must share your screen or a browser tab.", + "toOrganizeRecordings": "to organize your recordings", + "transcriptUpdated": "Transcript updated successfully!", + "troubleshooting": "Troubleshooting", + "tryAdjustingSearch": "Try adjusting your search or", + "unsupportedBrowser": "Unsupported Browser", + "untitled": "Untitled Recording", + "uploadRecordingNotes": "Upload Recording & Notes", + "whatWillHappen": "What will happen?", + "whyNotWorking": "Why isn't it working?", + "youHaveXSpeakers": "You have {{count}} speakers, but only 16 unique colors are available. Colors will repeat after the 16th speaker." + }, + "incognito": { + "audioNotStored": "Audio not stored in incognito mode", + "discardConfirm": "This will permanently discard your incognito recording. Continue?", + "mode": "Incognito Mode", + "notSavedToAccount": "Not saved to account", + "oneFileAtATime": "Incognito mode supports one file at a time", + "processInIncognito": "Process in Incognito", + "processWithoutSaving": "Process without saving", + "processing": "Processing...", + "processingComplete": "Processing complete!", + "processingInProgress": "Processing in incognito mode...", + "recordingDiscarded": "Incognito recording discarded", + "recordingProcessed": "Incognito recording processed - data will be lost when tab closes", + "recordingReady": "Incognito recording ready!", + "recordingTitle": "Incognito Recording", + "selectExactlyOneFile": "Select exactly 1 file", + "sessionOnly": "Session only", + "uploadingFile": "Uploading file for incognito processing..." + }, + "inquire": { + "activeFilters": "Active Filters:", + "askQuestions": "Ask Questions About Your Transcriptions", + "clearAll": "Clear All", + "dateRange": "Date Range", + "dateRangeActive": "Date range active", + "exampleQuestion1": "\"What action items were discussed?\"", + "exampleQuestion2": "\"When did we decide to change the timeline?\"", + "exampleQuestion3": "\"What concerns were raised about the budget?\"", + "exampleQuestion4": "\"Who was responsible for the marketing tasks?\"", + "exampleQuestions": "Example Questions:", + "filters": "Filters", + "filtersActive": "Filters active", + "from": "From", + "noSpeakerData": "No speaker data available", + "placeholder": "Ask questions about your filtered transcriptions...", + "selectFilters": "Select filters on the left to narrow down your transcriptions, then ask questions to get insights from your recordings.", + "sendHint": "Press Enter to send • Ctrl+Enter for new line", + "speakerRequirement": "Speaker identification requires recordings with multiple speakers", + "speakers": "Speakers", + "speakersCount": "speakers", + "tags": "Tags", + "tagsCount": "tags", + "title": "Inquire", + "to": "To" + }, + "languages": { + "ar": "Arabic", + "de": "German", + "en": "English", + "es": "Spanish", + "fr": "French", + "hi": "Hindi", + "it": "Italian", + "ja": "Japanese", + "ko": "Korean", + "nl": "Dutch", + "pt": "Portuguese", + "ru": "Russian", + "zh": "Chinese" + }, + "manageSpeakersModal": { + "created": "Created", + "description": "Manage your saved speakers. These are automatically saved when you use speaker names in your recordings.", + "failedToLoad": "Failed to load speakers", + "lastUsed": "Last used", + "loadingSpeakers": "Loading speakers...", + "noSpeakersYet": "No speakers saved yet", + "speakersSaved": "{{count}} speakers saved", + "speakersWillAppear": "Speakers will appear here when you use speaker names in your recordings", + "times": "times", + "title": "Manage Speakers", + "used": "Used" + }, + "messages": { + "colorSchemeApplied": "Color scheme applied", + "colorSchemeReset": "Color scheme reset to default", + "copiedSuccessfully": "Copied to clipboard!", + "copiedToClipboard": "Copied to clipboard", + "copyFailed": "Failed to copy", + "copyNotSupported": "Copy failed. Your browser may not support this feature.", + "downloadStarted": "Download started", + "errorRecoveringRecording": "Error recovering recording", + "eventDownloadFailed": "Failed to download event", + "eventDownloadSuccess": "Event \"{{title}}\" downloaded. Open the file to add to your calendar.", + "eventsExportFailed": "Failed to export events", + "eventsExportSuccess": "Exported {{count}} events", + "failedToDeleteJob": "Failed to delete job", + "failedToRecoverRecording": "Failed to recover recording", + "failedToRetryJob": "Failed to retry job", + "failedToSave": "Failed to save: {{error}}", + "failedToSaveParticipants": "Failed to save participants", + "followPlayerDisabled": "Follow player mode disabled", + "followPlayerEnabled": "Follow player mode enabled", + "invalidEventData": "Invalid event data", + "jobQueuedForRetry": "Job queued for retry", + "noEventsToExport": "No events to export", + "noNotesAvailableDownload": "No notes available to download.", + "noNotesToCopy": "No notes available to copy.", + "noPermissionToEdit": "You do not have permission to edit this recording", + "noSummaryToCopy": "No summary available to copy.", + "noSummaryToDownload": "No summary available to download.", + "noTranscriptionToCopy": "No transcription available to copy.", + "noTranscriptionToDownload": "No transcription available to download.", + "notesCopied": "Notes copied to clipboard!", + "notesDownloadFailed": "Failed to download notes", + "notesDownloadSuccess": "Notes downloaded successfully!", + "notesUpdated": "Notes updated successfully", + "passwordChanged": "Password changed successfully", + "profileUpdated": "Profile updated successfully", + "recordingDeleted": "Recording deleted successfully", + "recordingDiscarded": "Recording discarded", + "recordingRecovered": "Recording recovered successfully", + "recordingSaved": "Recording saved successfully", + "saveParticipantsFailed": "Save failed: {{error}}", + "settingsSaved": "Settings saved successfully", + "summaryCopied": "Summary copied to clipboard!", + "summaryDownloadFailed": "Failed to download summary", + "summaryDownloadSuccess": "Summary downloaded successfully!", + "summaryGenerated": "Summary generated successfully", + "tagAdded": "Tag added successfully", + "tagRemoved": "Tag removed successfully", + "transcriptDownloadFailed": "Failed to download transcript", + "transcriptDownloadSuccess": "Transcript downloaded successfully!", + "transcriptionCopied": "Transcription copied to clipboard!", + "transcriptionUpdated": "Transcription updated successfully" + }, + "metadata": { + "cancelEdit": "Cancel", + "createdAt": "Created", + "duration": "Duration", + "editMetadata": "Edit Metadata", + "fileName": "File Name", + "fileSize": "File Size", + "language": "Language", + "meetingDate": "Meeting Date", + "processingTime": "Processing Time", + "saveMetadata": "Save", + "status": "Status", + "title": "Metadata", + "updatedAt": "Updated", + "wordCount": "Word Count" + }, + "modal": { + "addSpeaker": "Add New Speaker", + "colorScheme": "Color Scheme", + "deleteRecording": "Delete Recording", + "editAsrTranscription": "Edit ASR Transcription", + "editParticipants": "Edit Participants", + "editRecording": "Edit Recording", + "editSpeakers": "Edit Speakers", + "editTags": "Edit Recording Tags", + "editTranscription": "Edit Transcription", + "identifySpeakers": "Identify Speakers", + "recordingNotice": "Recording Notice", + "reprocessSummary": "Reprocess Summary", + "reprocessTranscription": "Reprocess Transcription", + "resetStatus": "Reset Recording Status?", + "shareRecording": "Share Recording", + "sharedTranscripts": "Shared Transcripts", + "systemAudioHelp": "System Audio Help", + "uploadFiles": "Upload Files", + "uploadNotice": "Upload Notice" + }, + "namingTemplates": { + "addPattern": "Add Pattern", + "availableTemplates": "Available Templates", + "availableVars": "Built-in Variables", + "cancel": "Cancel", + "createDefaults": "Create Default Templates", + "createNew": "Create Template", + "customVarsHint": "Define regex patterns below to extract custom variables from filenames.", + "delete": "Delete", + "description": "Define how recording titles are generated from filenames and transcription content.", + "descriptionLabel": "Description", + "noDefault": "No default (AI title only)", + "regexHint": "Extract data from filenames. Use capture groups () to specify the match. Example: (\\d{10}) for phone numbers.", + "regexPatterns": "Regex Patterns (Optional)", + "result": "Result:", + "save": "Save", + "selectOrCreate": "Select a template to edit or create a new one", + "tabTitle": "Naming", + "template": "Template", + "templateName": "Template Name", + "test": "Test", + "testTemplate": "Test Template", + "title": "Naming Templates", + "userDefault": "Default Template", + "userDefaultHint": "Applied when no tag has a naming template set." + }, + "nav": { + "account": "Account", + "accountSettings": "Account Settings", + "admin": "Admin", + "adminDashboard": "Admin Dashboard", + "darkMode": "Dark Mode", + "groupManagement": "Group Management", + "home": "Home", + "language": "Language", + "lightMode": "Light Mode", + "newRecording": "New Recording", + "recording": "Recording", + "settings": "Settings", + "signOut": "Sign Out", + "teamManagement": "Group Management", + "upload": "Upload", + "userProfile": "User Profile" + }, + "notes": { + "cancelEdit": "Cancel Edit", + "characterCount": "{{count}} character", + "characterCountPlural": "{{count}} characters", + "editNotes": "Edit Notes", + "lastUpdated": "Last updated", + "placeholder": "Add your notes here...", + "saveNotes": "Save Notes", + "title": "Notes" + }, + "pwa": { + "installApp": "Install App", + "installed": "Installed successfully", + "installing": "Installing...", + "notificationPermissionDenied": "Notification permission denied", + "notificationsEnabled": "Notifications enabled", + "offline": "You're offline", + "screenAwake": "Screen will stay awake during recording", + "screenAwakeFailed": "Could not keep screen awake", + "updateAvailable": "Update available" + }, + "recording": { + "acceptDisclaimer": "I Accept", + "cancelRecording": "Cancel", + "discardRecovery": "Discard", + "disclaimer": "Recording Disclaimer", + "duration": "Duration", + "micPlusSys": "Mic + Sys", + "microphone": "Microphone", + "microphoneAndSystem": "Microphone + System", + "microphonePermissionDenied": "Microphone permission denied", + "modeBoth": "Microphone + System", + "modeMicrophone": "Microphone", + "modeSystem": "System Audio", + "notes": "Notes", + "notesPlaceholder": "Add notes about this recording...", + "pauseRecording": "Pause", + "recordingFailed": "Recording failed", + "recordingInProgress": "Recording in progress...", + "recordingMode": "Recording Mode", + "recordingSize": "Estimated Size", + "recordingStopped": "Recording stopped", + "recordingTime": "Recording Time", + "recoveryDescription": "We found an unfinished recording from a previous session. Would you like to restore it?", + "recoveryFound": "Unsaved Recording Detected", + "recoveryTitle": "Recover Recording", + "restoreRecording": "Restore", + "resumeRecording": "Resume", + "saveRecording": "Save Recording", + "size": "Size", + "startRecording": "Start Recording", + "startedAt": "Started At", + "stopRecording": "Stop Recording", + "systemAudio": "System Audio", + "systemAudioNotSupported": "System audio recording is not supported in this browser", + "title": "Audio Recording" + }, + "reprocessModal": { + "audioReTranscribedFromScratch": "The audio will be re-transcribed from scratch. This will also regenerate the title and summary based on the new transcription.", + "audioReTranscribedWithAsr": "The audio will be re-transcribed using the ASR endpoint. This includes diarization and will regenerate the title and summary.", + "manualEditsOverwritten": "Any manual edits to the transcription, title, or summary will be overwritten.", + "manualEditsOverwrittenSummary": "Any manual edits to the title or summary will be overwritten.", + "newTitleAndSummary": "A new title and summary will be generated based on the existing transcription." + }, + "settings": { + "apiKeys": "API Keys", + "appearance": "Appearance", + "changePassword": "Change Password", + "dataExport": "Data Export", + "deleteAccount": "Delete Account", + "integrations": "Integrations", + "language": "Language", + "notifications": "Notifications", + "preferences": "Preferences", + "privacy": "Privacy", + "profile": "Profile", + "security": "Security", + "theme": "Theme", + "title": "Settings", + "twoFactorAuth": "Two-Factor Authentication" + }, + "sharedTranscripts": { + "noSharedTranscripts": "You haven't shared any transcripts yet.", + "shareNotes": "Share Notes", + "shareSummary": "Share Summary", + "sharedOn": "Shared on" + }, + "sharedTranscriptsPage": { + "noSharedTranscripts": "You have not shared any transcripts yet." + }, + "sharing": { + "canEdit": "Can edit", + "canReshare": "Can reshare", + "internalSharing": "Internal Sharing", + "notSharedYet": "Not shared yet", + "publicBadge": "Public", + "publicLink": "Public Link", + "publicLinks": "public link(s)", + "publicLinksGenerated": "public link(s) generated", + "searchUsers": "Search users...", + "sharedBadge": "Shared", + "sharedBy": "Shared by", + "sharedWith": "Shared with", + "teamBadge": "Group", + "teamRecording": "Group recording", + "unknown": "Unknown", + "users": "user(s)" + }, + "sidebar": { + "advancedSearch": "Advanced Search", + "archived": "Archived", + "archivedRecordings": "Archived Recordings", + "dateRange": "Date Range", + "filters": "Filters", + "highlighted": "Highlighted", + "inbox": "Inbox", + "lastMonth": "Last Month", + "lastWeek": "Last Week", + "loadMore": "Load More", + "markAsRead": "Mark as read", + "moveToInbox": "Move to Inbox", + "noRecordings": "No recordings found", + "older": "Older", + "removeFromHighlighted": "Remove from starred", + "searchRecordings": "Search recordings...", + "sharedWithMe": "Shared with Me", + "sortBy": "Sort By", + "sortByDate": "Created Date", + "sortByMeetingDate": "Meeting Date", + "starred": "Starred", + "tags": "Tags", + "thisMonth": "This Month", + "thisWeek": "This Week", + "today": "Today", + "totalRecordings": "{{count}} recording", + "totalRecordingsPlural": "{{count}} recordings", + "upcoming": "Upcoming", + "yesterday": "Yesterday" + }, + "speakers": { + "filterBySpeaker": "Filter by speaker", + "noMatchingSpeakers": "No matching speakers", + "searchSpeakers": "Search..." + }, + "speakersManagement": { + "added": "Added", + "confidence": "Confidence", + "confidenceHigh": "high", + "confidenceLow": "low", + "confidenceMedium": "medium", + "created": "Created", + "description": "Manage your saved speakers. These are automatically saved when you use speaker names in your recordings.", + "failedToLoad": "Failed to load speakers", + "failedToLoadSnippets": "Failed to load snippets", + "keepThisSpeaker": "Keep this speaker (others will be merged into it):", + "last": "Last", + "lastUsed": "Last used", + "loadingSpeakers": "Loading speakers...", + "match": "match", + "mergeDescription": "Combine multiple speaker profiles into one. All embeddings, snippets, and usage data will be merged.", + "mergeFailed": "Failed to merge speakers", + "mergeNSpeakers": "Merge {{count}} Speakers", + "mergeSpeakers": "Merge Speakers", + "mergeSuccess": "Speakers merged successfully", + "noSnippetsAvailable": "No snippets available", + "noSpeakersYet": "No speakers saved yet", + "sample": "sample", + "samples": "samples", + "selectToMerge": "Select 2+ to Merge", + "speakersToMerge": "Speakers to merge:", + "speakersWillAppear": "Speakers will appear here when you use speaker names in your recordings", + "targetWillReceive": "The selected speaker will receive all voice data and snippets from the others.", + "time": "time", + "times": "times", + "totalSpeakers": "speakers saved", + "used": "Used", + "usedTimes": "Used", + "viewSnippets": "View Snippets", + "voiceMatchSuggestions": "Voice Match Suggestions", + "voiceProfile": "Voice Profile" + }, + "status": { + "completed": "Completed", + "failed": "Failed", + "processing": "Processing", + "queued": "Queued", + "stuck": "Reset stuck processing", + "summarizing": "Summarizing", + "transcribing": "Transcribing", + "uploading": "Uploading" + }, + "summary": { + "actionItems": "Action Items", + "cancelEdit": "Cancel Edit", + "decisions": "Decisions", + "editSummary": "Edit Summary", + "generateSummary": "Generate Summary", + "keyPoints": "Key Points", + "noSummary": "No summary available", + "participants": "Participants", + "regenerateSummary": "Regenerate Summary", + "saveSummary": "Save Summary", + "summaryFailed": "Summary generation failed", + "summaryInProgress": "Summary generation in progress...", + "title": "Summary" + }, + "tagManagement": { + "asrDefaults": "ASR Defaults", + "createTag": "Create Tag", + "customPrompt": "Custom Prompt", + "description": "Organize your recordings with custom tags. Each tag can have its own summary prompt and default ASR settings.", + "maxSpeakers": "Max", + "minSpeakers": "Min", + "noTags": "You haven't created any tags yet." + }, + "tags": { + "addTag": "Add Tag", + "clearTagFilter": "Clear filter", + "createTag": "Create Tag", + "currentTags": "Current Tags", + "filterByTag": "Filter by tag", + "manageAllTags": "Manage All Tags", + "noAvailableTags": "No available tags", + "noMatchingTags": "No matching tags", + "noTags": "No tags", + "removeTag": "Remove Tag", + "searchTags": "Search...", + "tagColor": "Tag Color", + "tagName": "Tag Name", + "title": "Tags" + }, + "tagsModal": { + "addTags": "Add Tags", + "currentTags": "Current Tags", + "done": "Done", + "noTagsAssigned": "No tags assigned to this recording", + "searchTags": "Search tags..." + }, + "time": { + "dayAgo": "1 day ago", + "daysAgo": "{{count}} days ago", + "hourAgo": "1 hour ago", + "hoursAgo": "{{count}} hours ago", + "justNow": "Just now", + "minuteAgo": "1 minute ago", + "minutesAgo": "{{count}} minutes ago", + "monthAgo": "1 month ago", + "monthsAgo": "{{count}} months ago", + "weekAgo": "1 week ago", + "weeksAgo": "{{count}} weeks ago", + "yearAgo": "1 year ago", + "yearsAgo": "{{count}} years ago" + }, + "tooltips": { + "changeSpeaker": "Change speaker", + "clearChat": "Clear chat", + "copyTranscript": "Copy transcript", + "deleteTeam": "Delete Group", + "doubleClickToEdit": "Double-click to edit", + "downloadTranscriptWithTemplate": "Download transcript with template", + "editTeam": "Edit Group", + "editText": "Edit text", + "editTitle": "Edit title", + "editTranscript": "Edit transcript", + "exitFullscreen": "Exit fullscreen", + "expand": "Expand", + "followPlayerDisabled": "Enable auto-scroll - transcript follows audio playback", + "followPlayerEnabled": "Disable auto-scroll - transcript stays in place", + "fullscreenVideo": "Fullscreen video", + "grantPublicSharing": "Grant public sharing permission", + "hideVideo": "Hide video", + "highlight": "Highlight", + "makeAdmin": "Make Admin", + "manageMembers": "Manage Members", + "manageTeamTags": "Manage Group Tags", + "markAsRead": "Mark as read", + "maximizeChat": "Maximize chat", + "minimize": "Minimize", + "moveToInbox": "Move to inbox", + "mute": "Mute", + "pause": "Pause", + "play": "Play", + "playbackSpeed": "Playback speed", + "removeAdmin": "Remove Admin", + "removeFromQueue": "Remove from queue", + "removeFromTeam": "Remove from team", + "removeHighlight": "Remove highlight", + "reprocessTranscription": "Reprocess transcription", + "reprocessWithAsr": "Reprocess with ASR", + "restoreChat": "Restore chat", + "revokePublicSharing": "Revoke public sharing permission", + "shareWithUsers": "Share with users", + "showVideo": "Show video", + "switchToDarkMode": "Switch to Dark Mode", + "switchToLightMode": "Switch to Light Mode", + "unmute": "Unmute" + }, + "transcriptTemplates": { + "availableTemplates": "Available Templates", + "availableVars": "Available Variables", + "cancel": "Cancel", + "chooseTemplate": "Choose template...", + "createDefaults": "Create Default Templates", + "createNew": "Create Template", + "default": "Default", + "delete": "Delete", + "description": "Customize how transcripts are formatted for download and export.", + "downloadDefault": "Download default", + "downloadWithoutTemplate": "Download without template", + "filters": "Filters: |upper for uppercase, |srt for subtitle time format", + "save": "Save", + "selectOrCreate": "Select a template to edit or create a new one", + "selectTemplate": "Select Template", + "setDefault": "Set as default template", + "tabTitle": "Transcript", + "template": "Template", + "templateName": "Template Name", + "title": "Transcript Templates", + "viewGuide": "View Template Guide" + }, + "transcription": { + "autoIdentifySpeakers": "Auto-identify Speakers", + "bubble": "Bubble", + "cancelEdit": "Cancel Edit", + "copy": "Copy", + "copyToClipboard": "Copy to Clipboard", + "download": "Download", + "downloadTranscript": "Download Transcript", + "edit": "Edit", + "editSpeakers": "Edit Speakers", + "editTranscription": "Edit Transcription", + "highlightSearchResults": "Highlight search results", + "noTranscription": "No transcription available", + "regenerateTranscription": "Regenerate Transcription", + "saveTranscription": "Save Transcription", + "searchInTranscript": "Search in transcript...", + "simple": "Simple", + "speaker": "Speaker {{number}}", + "speakerLabels": "Speaker Labels", + "title": "Transcription", + "unknownSpeaker": "Unknown Speaker" + }, + "upload": { + "chunking": "Large files will be automatically chunked for processing", + "completed": "Completed", + "copies": "copies of this file", + "dropzone": "Drag and drop audio files here, or click to browse", + "duplicateDetected": "This file appears to be a duplicate of \"{{existingName}}\" (uploaded {{existingDate}})", + "duplicateFile": "Duplicate file", + "failed": "Failed", + "fileExceedsMaxSize": "File \"{{name}}\" exceeds the maximum size of {{size}} MB and was skipped.", + "fileRemovedFromQueue": "File removed from queue", + "filesToUpload": "Files to Upload", + "invalidFileType": "Invalid file type \"{{name}}\". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.", + "maxFileSize": "Maximum file size", + "queued": "Queued", + "selectFiles": "Select Files", + "settingsApplyToAll": "Settings apply to all files in this session", + "summarizing": "Summarizing...", + "supportedFormats": "Supports MP3, WAV, M4A, MP4, MOV, AVI, AMR, and more", + "title": "Upload Audio", + "transcribing": "Transcribing...", + "untitled": "Untitled Recording", + "uploadNFiles": "Upload {{count}} File(s)", + "uploadProgress": "Upload Progress", + "videoRetained": "Video preserved for playback", + "willAutoSummarize": "Will auto-summarize after transcription" + }, + "uploadProgress": { + "title": "Upload Progress" + } +} \ No newline at end of file diff --git a/static/locales/es.json b/static/locales/es.json new file mode 100644 index 0000000..9865d6f --- /dev/null +++ b/static/locales/es.json @@ -0,0 +1,1505 @@ +{ + "aboutPage": { + "aiSummarization": "Resumen con IA", + "aiSummarizationDesc": "Integración de OpenRouter y Ollama con prompts personalizados", + "asrEnabled": "ASR Habilitado", + "asrEndpoint": "Punto de Acceso ASR", + "audioTranscription": "Transcripción de Audio", + "audioTranscriptionDesc": "API de Whisper y soporte ASR personalizado con alta precisión", + "backend": "Backend", + "database": "Base de Datos", + "deployment": "Despliegue", + "dockerDescription": "Imágenes oficiales de Docker", + "dockerHub": "Docker Hub", + "documentation": "Documentación", + "documentationDescription": "Guías de configuración y manual del usuario", + "endpoint": "Punto de Acceso", + "frontend": "Frontend", + "githubDescription": "Código fuente, problemas y versiones", + "githubRepository": "Repositorio de GitHub", + "inquireMode": "Modo de Consulta", + "inquireModeDesc": "Búsqueda semántica en todas tus grabaciones", + "interactiveChat": "Chat Interactivo", + "interactiveChatDesc": "Chatea con tus transcripciones usando IA", + "keyFeatures": "Características Principales", + "largeLanguageModel": "Modelo de Lenguaje Grande", + "model": "Modelo", + "projectDescription": "Transforma tus grabaciones de audio en notas organizadas y buscables con funciones de transcripción, resumen y chat interactivo impulsadas por IA.", + "projectLinks": "Enlaces del Proyecto", + "sharingExport": "Compartir y Exportar", + "sharingExportDesc": "Comparte grabaciones y exporta a varios formatos", + "speakerDiarization": "Diarización de Hablantes", + "speakerDiarizationDesc": "Identifica y etiqueta diferentes hablantes automáticamente", + "speechRecognition": "Reconocimiento de Voz", + "systemConfiguration": "Configuración del Sistema", + "tagline": "Transcripción de Audio y Toma de Notas Impulsada por IA", + "technologyStack": "Pila Tecnológica", + "title": "Acerca de", + "version": "Versión", + "whisperApi": "API de Whisper" + }, + "aboutPageDetails": { + "aiSummarizationDesc": "Integración con OpenRouter y Ollama con prompts personalizados", + "asrEnabled": "ASR Habilitado", + "asrEndpoint": "Punto de Acceso ASR", + "audioTranscriptionDesc": "API de Whisper y soporte ASR personalizado con alta precisión", + "backend": "Backend", + "database": "Base de Datos", + "deployment": "Implementación", + "dockerDescription": "Imágenes oficiales de Docker", + "documentationDescription": "Guías de configuración y manual de usuario", + "endpoint": "Punto de Acceso", + "frontend": "Frontend", + "githubDescription": "Código fuente, problemas y versiones", + "inquireModeDesc": "Búsqueda semántica en todas tus grabaciones", + "interactiveChatDesc": "Chatea con tus transcripciones usando IA", + "model": "Modelo", + "no": "No", + "sharingExportDesc": "Comparte grabaciones y exporta a varios formatos", + "speakerDiarizationDesc": "Identifica y etiqueta diferentes oradores automáticamente", + "whisperApi": "API de Whisper", + "yes": "Sí" + }, + "account": { + "accountActions": "Acciones de la Cuenta", + "autoLabel": "Auto-label", + "autoSummarizationDisabled": "Auto-summarization disabled by admin", + "autoSummarize": "Auto-summarize", + "changePassword": "Cambiar Contraseña", + "chooseLanguageForInterface": "Elige el idioma para la interfaz de la aplicación", + "companyOrganization": "Empresa / Organización", + "completedRecordings": "Completadas", + "defaultHotwords": "Default Hotwords", + "defaultHotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote, SDRs", + "defaultInitialPrompt": "Default Initial Prompt", + "defaultInitialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools. The speakers discuss CTranslate2, PyAnnote, and SDRs.", + "email": "Correo Electrónico", + "failedRecordings": "Fallidas", + "fullName": "Nombre Completo", + "goToRecordings": "Ir a Grabaciones", + "interfaceLanguage": "Idioma de la Interfaz", + "jobTitle": "Cargo", + "languageForSummaries": "Idioma para títulos, resúmenes y chat. Dejar en blanco para el predeterminado (comportamiento predeterminado de sus modelos elegidos).", + "languagePreferences": "Preferencias de Idioma", + "leaveBlankForAutoDetect": "Dejar en blanco para detección automática por el servicio de transcripción", + "manageSpeakers": "Gestionar Hablantes", + "personalFolder": "Personal Folder (Not Associated with a Group)", + "personalInfo": "Información Personal", + "personalTag": "Personal Tag (Not Associated with a Group)", + "preferredOutputLanguage": "Idioma Preferido para Chatbot y Resúmenes", + "processingRecordings": "En Procesamiento", + "saveAllPreferences": "Guardar Todas las Preferencias", + "ssoLinkAccount": "Link {{provider}} account", + "ssoLinked": "Linked", + "ssoNotLinked": "Not linked", + "ssoProvider": "Provider", + "ssoSetPasswordFirst": "To unlink SSO, you must first set a password.", + "ssoSubject": "Subject:", + "ssoUnlinkAccount": "Unlink {{provider}} account", + "ssoUnlinkConfirm": "Are you sure you want to unlink your SSO account? You will need to use your password to log in.", + "statistics": "Estadísticas de la Cuenta", + "title": "Información de la Cuenta", + "totalRecordings": "Total de Grabaciones", + "transcriptionHints": "Transcription Hints", + "transcriptionHintsDesc": "These defaults are used when no tag or folder overrides are set. They help improve transcription accuracy for your specific use case.", + "transcriptionLanguage": "Idioma de Transcripción", + "userDetails": "Detalles del Usuario", + "username": "Nombre de Usuario" + }, + "accountTabs": { + "about": "Acerca de", + "apiTokens": "Tokens de API", + "customPrompts": "Prompts Personalizados", + "folderManagement": "Gestión de carpetas", + "help": "Ayuda", + "namingTemplates": "Nombrado", + "promptOptions": "Opciones de Prompt", + "sharedTranscripts": "Transcripciones Compartidas", + "speakersManagement": "Gestión de Hablantes", + "tagManagement": "Gestión de Etiquetas", + "templates": "Plantillas", + "transcriptTemplates": "Plantillas de Transcripción" + }, + "admin": { + "title": "Administración", + "userMenu": "Menú de Usuario" + }, + "adminDashboard": { + "aboutInquireMode": "Acerca del Modo Consulta", + "actions": "ACCIONES", + "addNewUser": "Agregar Nuevo Usuario", + "addUser": "Agregar Usuario", + "additionalContext": "Contexto Adicional", + "admin": "ADMIN", + "adminDefaultPrompt": "Prompt Predeterminado del Administrador", + "adminDefaultPromptDesc": "El prompt configurado arriba (mostrado en esta página)", + "adminUser": "Usuario Administrador", + "allRecordingsProcessed": "¡Todas las grabaciones están procesadas!", + "allowed": "Allowed", + "available": "Disponible", + "blocked": "bloqueado", + "budgetWarnings": "Advertencias de Presupuesto", + "characters": "caracteres", + "chunkSize": "Tamaño del Fragmento", + "complete": "completado", + "completedRecordings": "Completadas", + "confirmPasswordLabel": "Confirmar contraseña", + "contextNotes": { + "jsonConversion": "Las transcripciones JSON se convierten a formato de texto limpio antes de enviar", + "modelConfig": "El modelo utilizado se configura a través de la variable de entorno TEXT_MODEL_NAME", + "tagPrompts": "Si existen múltiples indicaciones de etiquetas, se fusionan en el orden en que se agregaron las etiquetas", + "transcriptLimit": "Las transcripciones están limitadas a un número configurable de caracteres (predeterminado: 30,000)" + }, + "createFirstGroup": "Create Your First Group", + "created": "Created", + "currentUsage": "Uso actual", + "currentUsageMinutes": "Uso Actual", + "defaultPromptInfo": "Este prompt predeterminado se utilizará para todos los usuarios que no hayan establecido su propio prompt personalizado en la configuración de su cuenta.", + "defaultPrompts": "Prompts por Defecto", + "defaultSummarizationPrompt": "Prompt de Resumen Predeterminado", + "description": "Description", + "editUser": "Editar Usuario", + "email": "E-MAIL", + "emailLabel": "Correo electrónico", + "embeddingModel": "Modelo de Incrustación", + "embeddingsStatus": "Estado de Incrustaciones", + "errors": { + "failedToLoadPrompt": "Error al cargar el prompt predeterminado", + "failedToSavePrompt": "Error al guardar el prompt predeterminado. Por favor, inténtalo de nuevo.", + "invalidFileSize": "Por favor ingrese un tamaño válido entre 1 y 10000 MB", + "invalidInteger": "Por favor ingrese un número entero válido", + "invalidNumber": "Por favor ingrese un número válido", + "maxTimeout": "El tiempo de espera no puede exceder 10 horas (36000 segundos)", + "minCharacters": "Por favor ingrese un número válido de al menos 1000 caracteres", + "minTimeout": "El tiempo de espera debe ser de al menos 60 segundos" + }, + "failedRecordings": "Fallidas", + "groupName": "Group Name", + "groupsTab": "Groups", + "id": "ID", + "inquireModeDescription": "El Modo Inquire permite a los usuarios buscar en múltiples transcripciones usando preguntas en lenguaje natural. Funciona dividiendo las transcripciones en fragmentos y creando incrustaciones buscables usando modelos de IA.", + "languagePreferenceNote": "Nota: Si el usuario ha establecido una preferencia de idioma de salida, se agregará \" Asegúrese de que su respuesta esté en {idioma}.\".", + "lastUpdated": "Última actualización", + "maxFileSizeHelp": "Tamaño máximo de archivo en megabytes (1-10000 MB)", + "megabytes": "MB", + "members": "Members", + "membersCount": "members", + "minutes": "minutos", + "monthlyCost": "Costo Mensual", + "monthlyTokenBudget": "Presupuesto Mensual de Tokens", + "monthlyTranscriptionBudget": "Presupuesto Mensual de Transcripción (minutos)", + "needProcessing": "Necesita Procesamiento", + "never": "Nunca", + "newPasswordLabel": "Nueva Contraseña (dejar en blanco para mantener la actual)", + "no": "No", + "noData": "Sin datos", + "noDescription": "No description", + "noDescriptionAvailable": "Sin descripción disponible", + "noGroupsAdmin": "You are not an admin of any groups yet", + "noGroupsCreated": "No groups created yet", + "noLimit": "Sin Límite", + "noMembersYet": "No members yet", + "noTranscriptionData": "No hay datos de uso de transcripción disponibles", + "none": "Ninguno", + "notSet": "No establecido", + "overlap": "Superposición", + "passwordLabel": "Contraseña", + "passwordsDoNotMatch": "Passwords do not match", + "pendingRecordings": "Pendientes", + "percentUsed": "usado", + "placeholdersNote": "Los marcadores de posición se reemplazan con valores reales al procesar una grabación.", + "processAllRecordings": "Procesar Todas las Grabaciones", + "processNext10": "Procesar Próximas 10", + "processedForInquire": "Procesado para Consulta", + "processing": "Procesando...", + "processingActions": "Acciones de Procesamiento", + "processingProgress": "Progreso de Procesamiento", + "processingRecordings": "En Procesamiento", + "promptDescription": "Este prompt se utilizará para generar resúmenes para todas las grabaciones cuando los usuarios no hayan establecido su propio prompt.", + "promptHierarchy": "Jerarquía de Prompts", + "promptPriorityDescription": "El sistema utiliza el siguiente orden de prioridad al seleccionar qué prompt usar:", + "promptResetMessage": "Prompt restablecido al predeterminado del sistema. Haz clic en \"Guardar Cambios\" para aplicar.", + "promptSavedSuccessfully": "¡Prompt predeterminado guardado con éxito!", + "publicShare": "Public Share", + "recordingStatusDistribution": "Distribución del Estado de Grabaciones", + "recordings": "GRABACIONES", + "recordingsNeedProcessing": "Hay {{count}} grabaciones que necesitan ser procesadas para el Modo Inquire.", + "refreshStatus": "Actualizar Estado", + "resetToDefault": "Restablecer a Predeterminado", + "saving": "Guardando...", + "searchUsers": "Buscar usuarios...", + "seconds": "segundos", + "settings": { + "asrTimeoutDesc": "Tiempo máximo en segundos para esperar a que se complete la transcripción ASR. El valor predeterminado es 1800 segundos (30 minutos).", + "defaultSummaryPromptDesc": "Indicación de resumen predeterminada utilizada cuando los usuarios no han establecido su propia indicación. Esto sirve como la indicación base para todos los usuarios.", + "maxFileSizeDesc": "Tamaño máximo de archivo permitido para cargas de audio en megabytes (MB).", + "recordingDisclaimerDesc": "Aviso legal mostrado a los usuarios antes de comenzar la grabación. Admite formato Markdown. Dejar vacío para desactivar.", + "uploadDisclaimerDesc": "Aviso legal mostrado antes de subir archivos. Admite Markdown. Dejar vacío para desactivar.", + "customBannerDesc": "Banner personalizado mostrado en la parte superior de la página. Admite Markdown. Dejar vacío para desactivar.", + "transcriptLengthLimitDesc": "Número máximo de caracteres a enviar desde la transcripción al LLM para resumen y chat. Use -1 para sin límite." + }, + "storageUsed": "ALMACENAMIENTO USADO", + "summarizationInstructions": "Instrucciones de Resumen", + "systemFallback": "Respaldo del Sistema", + "systemFallbackDesc": "Un valor predeterminado codificado si ninguno de los anteriores está establecido", + "systemPrompt": "Prompt del Sistema", + "systemSettings": "Configuración del Sistema", + "systemStatistics": "Estadísticas del Sistema", + "tagCustomPrompt": "Prompt Personalizado de Etiqueta", + "tagCustomPromptDesc": "Si una grabación tiene etiquetas con prompts personalizados", + "textSearchOnly": "Solo Búsqueda de Texto", + "thisMonth": "Este Mes", + "timeoutRecommendation": "Recomendado: 30-120 minutos para archivos de audio largos", + "title": "Panel de Administración", + "todaysMinutes": "Minutos de Hoy", + "tokenBudgetExceeded": "Presupuesto mensual de tokens excedido. Por favor contacte a su administrador.", + "tokenBudgetHelp": "Establezca un límite mensual de tokens para funciones de IA. Deje vacío para ilimitado.", + "tokenBudgetPlaceholder": "Deje vacío para ilimitado", + "tokenUsage": "Uso de tokens", + "tokens": "tokens", + "topUsers": "Usuarios Principales", + "topUsersByStorage": "Principales Usuarios por Almacenamiento", + "total": "Total", + "totalChunks": "Total de Fragmentos", + "totalQueries": "Total de Consultas", + "totalRecordings": "Total de Grabaciones", + "totalStorage": "Almacenamiento Total", + "totalUsers": "Total de Usuarios", + "transcriptionBudgetHelp": "Limite los minutos de transcripción por mes. Deje vacío para ilimitado.", + "transcriptionBudgetPlaceholder": "Deje vacío para ilimitado", + "transcriptionUsage": "Uso de Transcripción", + "updateUser": "Actualizar Usuario", + "userCustomPrompt": "Prompt Personalizado del Usuario", + "userCustomPromptDesc": "Si el usuario ha establecido su propio prompt en la configuración de la cuenta", + "userManagement": "Gestión de Usuarios", + "userMessageTemplate": "Plantilla de Mensaje del Usuario", + "userTranscriptionUsage": "Uso de Transcripción por Usuario (Este Mes)", + "username": "NOMBRE DE USUARIO", + "usernameLabel": "Nombre de usuario", + "vectorDimensions": "Dimensiones del Vector", + "vectorStore": "Almacén Vectorial", + "vectorStoreManagement": "Gestión del Almacén Vectorial", + "vectorStoreUpToDate": "El almacén de vectores está actualizado.", + "viewFullPromptStructure": "Ver Estructura Completa del Prompt LLM", + "warning": "advertencia", + "yes": "Sí" + }, + "apiTokens": { + "activeTokens": "Tokens activos", + "createFirstToken": "Crea tu primer token de API para habilitar el acceso programático", + "createToken": "Crear token", + "created": "Creado", + "description": "Crea y gestiona tokens de API para acceso programático a tu cuenta", + "expires": "Expira", + "lastUsed": "Último uso", + "neverUsed": "Nunca usado", + "noExpiration": "Sin expiración", + "noTokens": "Sin tokens de API", + "securityNotice": "Aviso de seguridad", + "securityWarning": "Trata los tokens de API como contraseñas. Proporcionan acceso completo a tu cuenta. Nunca compartas tokens en áreas públicas como GitHub, código del lado del cliente o logs", + "title": "Tokens de API", + "usageExamples": "Ejemplos de uso" + }, + "buttons": { + "addSegment": "Agregar Segmento", + "addSpeaker": "Añadir Hablante", + "cancel": "Cancel", + "clearAllFilters": "Limpiar todos los filtros", + "clearCompleted": "Limpiar cargas completadas", + "clearSearchText": "Limpiar texto de búsqueda", + "close": "Close", + "copy": "Copiar", + "copyMessage": "Copiar mensaje", + "copyNotes": "Copiar Notas", + "copySummary": "Copiar Resumen", + "copyToClipboard": "Copiar al Portapapeles", + "createTag": "Create Tag", + "deleteAll": "Delete All", + "deleteSpeaker": "Eliminar hablante", + "deleteTag": "Eliminar etiqueta", + "deleteUser": "Eliminar usuario", + "downloadAsWord": "Descargar como Word", + "downloadAudio": "Descargar audio", + "downloadChat": "Descargar chat como documento de Word", + "downloadNotes": "Descargar notas como documento de Word", + "downloadSummary": "Descargar resumen como documento de Word", + "editNotes": "Editar Notas", + "editSetting": "Editar configuración", + "editSpeakers": "Editar Hablantes...", + "editSummary": "Editar Resumen", + "editTag": "Editar etiqueta", + "editTags": "Editar etiquetas", + "editTranscription": "Editar Transcripción", + "editUser": "Editar usuario", + "exportCalendar": "Exportar al Calendario", + "help": "Ayuda", + "identifySpeakers": "Identificar Hablantes", + "next": "Siguiente", + "previous": "Anterior", + "refresh": "Refresh", + "reprocessSummary": "Reprocesar resumen", + "reprocessTranscription": "Reprocesar transcripción", + "reprocessWithAsr": "Reprocesar con ASR", + "resetStuckProcessing": "Restablecer procesamiento bloqueado", + "saveChanges": "Guardar Cambios", + "saveCustomPrompt": "Guardar Prompt Personalizado", + "saveNames": "Guardar Nombres", + "saveSettings": "Guardar Configuración", + "shareRecording": "Compartir Grabación", + "toggleTheme": "Cambiar tema", + "updateTag": "Update Tag" + }, + "changePasswordModal": { + "confirmPassword": "Confirmar Nueva Contraseña", + "currentPassword": "Contraseña Actual", + "newPassword": "Nueva Contraseña", + "passwordRequirement": "La contraseña debe tener al menos 8 caracteres", + "title": "Cambiar Contraseña" + }, + "chat": { + "availableAfterTranscription": "El chat estará disponible una vez que se complete la transcripción", + "cannotChatTranscriptionFailed": "Cannot chat: transcription failed. Please reprocess the transcription first.", + "chatWithTranscription": "Chatear con la Transcripción", + "clearChat": "Limpiar Chat", + "cleared": "Chat cleared", + "downloadFailed": "Failed to download chat", + "downloadSuccess": "Chat downloaded successfully!", + "error": "Error al enviar el mensaje", + "noMessages": "No hay mensajes aún", + "noMessagesToDownload": "No chat messages to download.", + "placeholder": "Haz una pregunta sobre esta grabación...", + "placeholderWithHint": "Haz una pregunta sobre esta grabación... (Enter para enviar, Ctrl+Enter para nueva línea)", + "send": "Enviar", + "suggestedQuestions": "Preguntas Sugeridas", + "thinking": "Pensando...", + "title": "Chat", + "whatAreActionItems": "¿Cuáles son los elementos de acción?", + "whatAreKeyPoints": "¿Cuáles son los puntos clave?", + "whatWasDiscussed": "¿Qué se discutió?", + "whoSaidWhat": "¿Quién dijo qué?" + }, + "colorScheme": { + "chooseRecording": "Elige una grabación de la barra lateral para ver su transcripción y resumen", + "darkThemes": "Temas Oscuros", + "descriptions": { + "blue": "Classic blue theme with a clean, professional feel", + "emerald": "Nature-inspired green theme for a calming experience", + "purple": "Rich purple theme with an elegant, modern look", + "rose": "Warm rose theme with a soft, inviting aesthetic", + "amber": "Warm amber tones for a cozy, productive feel", + "teal": "Cool teal theme with a refreshing, modern vibe" + }, + "lightThemes": "Temas Claros", + "names": { + "blue": "Ocean Blue", + "emerald": "Forest Green", + "purple": "Royal Purple", + "rose": "Coral Rose", + "amber": "Golden Amber", + "teal": "Arctic Teal" + }, + "resetToDefault": "Restablecer por Defecto", + "selectRecording": "Seleccionar una Grabación", + "subtitle": "Personaliza tu interfaz con hermosos temas de color", + "themes": { + "light": { + "blue": { + "name": "Azul Océano", + "description": "Tema azul clásico con atractivo profesional" + }, + "emerald": { + "name": "Esmeralda Bosque", + "description": "Tema verde fresco para sensación natural" + }, + "purple": { + "name": "Púrpura Real", + "description": "Tema púrpura elegante con sofisticación" + }, + "rose": { + "name": "Rosa Atardecer", + "description": "Tema rosa cálido con energía suave" + }, + "amber": { + "name": "Ámbar Dorado", + "description": "Tema amarillo cálido para luminosidad" + }, + "teal": { + "name": "Turquesa Océano", + "description": "Tema turquesa fresco para tranquilidad" + } + }, + "dark": { + "blue": { + "name": "Azul Medianoche", + "description": "Tema azul profundo para trabajo enfocado" + }, + "emerald": { + "name": "Bosque Oscuro", + "description": "Tema verde rico para visualización cómoda" + }, + "purple": { + "name": "Púrpura Profundo", + "description": "Tema púrpura misterioso para creatividad" + }, + "rose": { + "name": "Rosa Oscuro", + "description": "Tema rosa tenue con calidez sutil" + }, + "amber": { + "name": "Ámbar Oscuro", + "description": "Tema marrón cálido para sesiones acogedoras" + }, + "teal": { + "name": "Turquesa Profundo", + "description": "Tema turquesa oscuro para enfoque tranquilo" + } + } + }, + "title": "Elegir Esquema de Color" + }, + "common": { + "back": "Atrás", + "cancel": "Cancelar", + "changesSaved": "Cambios guardados", + "close": "Cerrar", + "confirm": "Confirmar", + "delete": "Eliminar", + "deselectAll": "Deseleccionar Todo", + "download": "Descargar", + "edit": "Editar", + "error": "Error", + "failed": "Fallido", + "filter": "Filtrar", + "info": "Información", + "loading": "Cargando...", + "new": "Nuevo", + "next": "Siguiente", + "no": "No", + "noResults": "No se encontraron resultados", + "ok": "OK", + "or": "Or", + "previous": "Anterior", + "processing": "Procesando...", + "refresh": "Actualizar", + "retry": "Reintentar", + "save": "Guardar", + "search": "Buscar", + "selectAll": "Seleccionar Todo", + "sort": "Ordenar", + "success": "Éxito", + "untitled": "Grabación sin título", + "upload": "Subir", + "warning": "Advertencia", + "yes": "Sí" + }, + "customPrompts": { + "currentDefaultPrompt": "Prompt Predeterminado Actual (Se usa si dejas lo anterior en blanco)", + "promptDescription": "Este prompt se utilizará para generar resúmenes de tus transcripciones. Anula el prompt predeterminado del administrador.", + "promptPlaceholder": "Describe cómo quieres que estén estructurados tus resúmenes. Deja en blanco para usar el prompt predeterminado del administrador.", + "summaryGeneration": "Prompt de Generación de Resúmenes", + "tip1": "Sé específico sobre las secciones que quieres en tu resumen", + "tip2": "Usa instrucciones de formato claras (ej. \"Usa viñetas\", \"Crea listas numeradas\")", + "tip3": "Especifica si quieres que cierta información sea priorizada", + "tip4": "El sistema proporcionará automáticamente el contenido de la transcripción a la IA", + "tip5": "Tu preferencia de idioma de salida (si está configurada) se aplicará automáticamente", + "tipsTitle": "Consejos para Escribir Prompts Efectivos", + "yourCustomPrompt": "Tu Prompt Personalizado de Resumen" + }, + "deleteAllSpeakersModal": { + "confirmMessage": "¿Estás seguro de que quieres eliminar todos los oradores guardados? Esta acción no se puede deshacer.", + "title": "Eliminar Todos los Oradores" + }, + "dialogs": { + "deleteRecording": { + "cancel": "Cancelar", + "confirm": "Eliminar", + "message": "¿Estás seguro de que deseas eliminar esta grabación? Esta acción no se puede deshacer.", + "title": "Eliminar Grabación" + }, + "deleteShare": { + "message": "¿Estás seguro de que quieres eliminar este enlace compartido? Esto revocará el acceso al enlace público.", + "title": "Eliminar enlace compartido" + }, + "reprocessTranscription": { + "cancel": "Cancelar", + "confirm": "Reprocesar", + "message": "¿Estás seguro de que deseas reprocesar esta transcripción? La transcripción actual será reemplazada.", + "title": "Reprocesar Transcripción" + }, + "unsavedChanges": { + "cancel": "Cancelar", + "discard": "Descartar", + "message": "Tienes cambios sin guardar. ¿Deseas guardarlos antes de salir?", + "save": "Guardar", + "title": "Cambios Sin Guardar" + } + }, + "duration": { + "hours": "{{count}} hora", + "hoursPlural": "{{count}} horas", + "minutes": "{{count}} minuto", + "minutesPlural": "{{count}} minutos", + "seconds": "{{count}} segundo", + "secondsPlural": "{{count}} segundos" + }, + "editTagModal": { + "asrDefaultSettings": "Configuraciones Predeterminadas de ASR", + "asrSettingsDescription": "Estas configuraciones se aplicarán por defecto al usar esta etiqueta con transcripción ASR", + "color": "Color", + "colorDescription": "Elige un color para fácil identificación", + "createTitle": "Crear Etiqueta", + "customPrompt": "Prompt de Resumen Personalizado", + "customPromptPlaceholder": "Opcional: Prompt personalizado para generar resúmenes de grabaciones con esta etiqueta", + "defaultLanguage": "Idioma Predeterminado", + "defaultPromptPlaceholder": "Deja en blanco para usar tu prompt de resumen predeterminado", + "exportTemplate": "Plantilla de exportación", + "exportTemplateHint": "Selecciona una plantilla de exportación para usar al exportar grabaciones con esta etiqueta", + "leaveBlankPrompt": "Deja en blanco para usar tu prompt de resumen predeterminado", + "maxSpeakers": "Máximo de Oradores", + "minSpeakers": "Mínimo de Oradores", + "namingTemplate": "Plantilla de nombrado", + "namingTemplateHint": "Selecciona una plantilla de nombrado para formatear automáticamente los títulos de las grabaciones con esta etiqueta", + "noExportTemplate": "Sin plantilla (usar predeterminado del usuario)", + "noNamingTemplate": "Sin plantilla (usar predeterminado o título IA)", + "tagName": "Nombre de la Etiqueta *", + "tagNamePlaceholder": "ej., Reuniones, Entrevistas", + "title": "Editar Etiqueta" + }, + "errors": { + "audioExtractionFailed": "Audio Extraction Failed", + "audioExtractionFailedGuidance": "Try converting the file to a standard audio format (MP3, WAV) before uploading.", + "audioExtractionFailedMessage": "Could not extract audio from the uploaded file.", + "audioRecordingFailed": "音频录制失败。请检查您的麦克风。", + "authenticationError": "Authentication Error", + "authenticationErrorGuidance": "Please check that the API key is correct and has not expired.", + "authenticationErrorMessage": "The transcription service rejected the API credentials.", + "checkApiKeyGuidance": "Check the API key in settings", + "checkNetworkGuidance": "Check network connection", + "connectionError": "Connection Error", + "connectionErrorGuidance": "Check your internet connection and ensure the service is available.", + "connectionErrorMessage": "Could not connect to the transcription service.", + "convertFormatGuidance": "Convert to MP3 or WAV before uploading", + "convertStandardGuidance": "Convert to standard audio format", + "enableChunkingGuidance": "Enable chunking in settings or compress the file", + "fallbackMessage": "An error occurred", + "fallbackTitle": "Error", + "fileTooLarge": "文件太大", + "fileTooLargeGuidance": "Try enabling audio chunking in your settings, or compress the audio file before uploading.", + "fileTooLargeMaxSize": "File too large. Max: {{size}} MB.", + "fileTooLargeMessage": "The audio file exceeds the maximum size allowed by the transcription service.", + "fileTooLargeTitle": "File Too Large", + "generic": "发生错误", + "invalidAudioFormat": "Invalid Audio Format", + "invalidAudioFormatGuidance": "Try converting the audio to MP3 or WAV format before uploading.", + "invalidAudioFormatMessage": "The audio file format is not supported or the file may be corrupted.", + "loadingShares": "Error al cargar los compartidos", + "networkError": "网络错误。请检查您的连接。", + "networkErrorDuringUpload": "Network error during upload", + "notFound": "未找到", + "permissionDenied": "权限被拒绝", + "processingError": "Processing Error", + "processingErrorFallbackGuidance": "Try reprocessing the recording", + "processingErrorGuidance": "If this error persists, try reprocessing the recording.", + "processingErrorMessage": "An error occurred during processing.", + "processingFailedOnServer": "Processing failed on server.", + "processingFailedWithStatus": "Processing failed with status {{status}}", + "processingTimeout": "Processing Timeout", + "processingTimeoutGuidance": "This can happen with very long recordings. Try splitting the audio into smaller parts.", + "processingTimeoutMessage": "The transcription took too long to complete.", + "quotaExceeded": "存储配额已超出", + "rateLimitExceeded": "Rate Limit Exceeded", + "rateLimitExceededGuidance": "Please wait a few minutes and try reprocessing.", + "rateLimitExceededMessage": "Too many requests were sent to the transcription service.", + "serverError": "服务器错误。请稍后重试。", + "serverErrorStatus": "Server error ({{status}})", + "serviceUnavailable": "Service Unavailable", + "serviceUnavailableGuidance": "This is usually temporary. Please try again in a few minutes.", + "serviceUnavailableMessage": "The transcription service is temporarily unavailable.", + "splitAudioGuidance": "Try splitting the audio into smaller parts", + "summaryFailed": "摘要生成失败。请重试。", + "transcriptionFailed": "转录失败。请重试。", + "tryAgainLaterGuidance": "Try again in a few minutes", + "unauthorized": "未授权", + "unexpectedResponse": "Unexpected success response from server after upload.", + "unsupportedFormat": "不支持的文件格式", + "uploadFailed": "上传失败。请重试。", + "uploadFailedWithStatus": "Upload failed with status {{status}}", + "uploadTimedOut": "Upload timed out", + "validationError": "验证错误", + "waitAndRetryGuidance": "Wait a few minutes and try again" + }, + "eventExtraction": { + "description": "Cuando está habilitado, la IA identificará reuniones, citas y plazos mencionados en tus grabaciones y creará eventos de calendario descargables.", + "enableLabel": "Habilitar extracción automática de eventos de las transcripciones", + "info": "Los eventos extraídos aparecerán en la pestaña 'Eventos' en las grabaciones donde se detecten elementos de calendario.", + "title": "Extracción de Eventos" + }, + "events": { + "add": "Agregar", + "addToCalendar": "Agregar al Calendario", + "attendees": "Asistentes", + "confirmDelete": "¿Eliminar evento \"{title}\"?", + "delete": "Eliminar", + "deleteFailed": "Error al eliminar el evento", + "deleted": "Evento eliminado", + "end": "Fin", + "location": "Ubicación", + "noEvents": "No se detectaron eventos en esta grabación", + "start": "Inicio", + "title": "Eventos" + }, + "exportLabels": { + "created": "Creado", + "date": "Fecha", + "fileSize": "Tamaño del archivo", + "footer": "Generado con [DictIA](https://gitea.innova-ai.ca/Innova-AI/dictia)", + "metadata": "Metadatos", + "notes": "Notas", + "originalFile": "Archivo original", + "participants": "Participantes", + "summarizationTime": "Tiempo de resumen", + "summary": "Resumen", + "tags": "Etiquetas", + "transcription": "Transcripción", + "transcriptionTime": "Tiempo de transcripción" + }, + "exportTemplates": { + "availableLabels": "Etiquetas localizadas (traducidas automáticamente)", + "availableTemplates": "Plantillas disponibles", + "availableVars": "Variables disponibles", + "cancel": "Cancelar", + "conditionals": "Condicionales", + "conditionalsHint": "Usa {{#if variable}}...{{/if}} para incluir contenido condicionalmente", + "contentSections": "Secciones de contenido", + "createDefaults": "Crear plantilla predeterminada", + "createNew": "Crear plantilla", + "default": "Predeterminada", + "delete": "Eliminar", + "description": "Personaliza cómo se exportan las grabaciones a archivos markdown.", + "recordingData": "Datos de grabación", + "save": "Guardar", + "selectOrCreate": "Selecciona una plantilla para editar o crea una nueva", + "setDefault": "Establecer como plantilla predeterminada", + "tabTitle": "Exportación", + "template": "Plantilla", + "templateDescription": "Descripción", + "templateName": "Nombre de la plantilla", + "title": "Plantillas de exportación", + "viewGuide": "Ver guía de plantillas" + }, + "fileSize": { + "bytes": "{{count}} B", + "gigabytes": "{{count}} GB", + "kilobytes": "{{count}} KB", + "megabytes": "{{count}} MB" + }, + "folderManagement": { + "allFolders": "Todas las carpetas", + "asrDefaults": "Valores ASR predeterminados", + "autoShareOnApply": "Compartir automáticamente con miembros del grupo", + "autoShareOnApplyHelp": "Compartir grabaciones automáticamente con todos los miembros del grupo al añadirlas a esta carpeta", + "confirmDelete": "¿Estás seguro de que quieres eliminar esta carpeta? Las grabaciones se eliminarán de la carpeta pero no se borrarán.", + "createFolder": "Crear carpeta", + "customPrompt": "Prompt personalizado", + "defaultLanguage": "Idioma predeterminado", + "deleteFolder": "Eliminar carpeta", + "description": "Organiza tus grabaciones en carpetas. A diferencia de las etiquetas, una grabación solo puede pertenecer a una carpeta. Los prompts de carpeta se aplican antes que los del usuario pero después de los de etiqueta.", + "editFolder": "Editar carpeta", + "filterByFolder": "Filtrar por carpeta", + "folderColor": "Color de carpeta", + "folderName": "Nombre de carpeta", + "groupSettings": "Configuración de grupo", + "maxSpeakers": "Máx. hablantes", + "minSpeakers": "Mín. hablantes", + "moveToFolder": "Mover a carpeta", + "namingTemplate": "Plantilla de nombrado", + "noFolder": "Sin carpeta", + "noFolders": "Aún no se han creado carpetas", + "noFoldersDescription": "Crea tu primera carpeta para organizar tus grabaciones", + "protectFromDeletion": "Proteger contra eliminación", + "protectFromDeletionHelp": "Proteger las grabaciones en esta carpeta contra la eliminación automática", + "recordings": "grabaciones", + "removeFromFolder": "Quitar de carpeta", + "retentionDays": "Días de retención", + "retentionDaysHelp": "Las grabaciones en esta carpeta se eliminarán después de estos días. Dejar vacío para usar la retención global.", + "retentionSettings": "Configuración de retención", + "selectNamingTemplate": "Seleccionar plantilla de nombrado...", + "shareWithGroupLead": "Compartir con administradores del grupo", + "shareWithGroupLeadHelp": "Compartir grabaciones con administradores del grupo al añadirlas a esta carpeta", + "title": "Gestión de carpetas" + }, + "form": { + "auto": "Auto", + "autoDetect": "Detección automática", + "dateFrom": "Desde", + "dateTo": "Hasta", + "enterNotesMarkdown": "Ingrese notas en formato Markdown...", + "enterSummaryMarkdown": "Ingrese resumen en formato Markdown...", + "folder": "Folder", + "hotwords": "Hotwords", + "hotwordsHelp": "Comma-separated words to improve recognition of domain-specific terms", + "hotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote", + "initialPrompt": "Initial Prompt", + "initialPromptHelp": "Context to steer the transcription model's style and vocabulary", + "initialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools.", + "language": "Idioma", + "maxSpeakers": "Hablantes Máximos", + "meetingDate": "Fecha de Reunión", + "minSpeakers": "Hablantes Mínimos", + "minutes": "Minutos", + "notes": "Notas", + "notesPlaceholder": "Escriba sus notas en formato Markdown...", + "optional": "Opcional", + "participantNamePlaceholder": "Participant name...", + "participants": "Participantes", + "placeholderAuto": "Auto", + "placeholderCharacterLimit": "Ingrese límite de caracteres (ej. 30000)", + "placeholderMinutes": "Minutos", + "placeholderOptional": "Opcional", + "placeholderSeconds": "Segundos", + "placeholderSizeMB": "Ingrese tamaño en MB", + "searchSpeakers": "Buscar hablantes...", + "searchTags": "Buscar etiquetas...", + "seconds": "Segundos", + "shareNotes": "Compartir Notas", + "shareSummary": "Compartir Resumen", + "shareableLink": "Enlace Compartible", + "summaryPromptPlaceholder": "Ingrese el prompt predeterminado de resumen...", + "title": "Título", + "transcriptionLanguage": "Transcription Language", + "yourFullName": "Su nombre completo" + }, + "groups": { + "addMembers": "Añadir miembros...", + "autoShare": "Compartir automáticamente", + "autoShareNote": "Nota: Si ambos están habilitados, todos los miembros del equipo tendrán acceso. Si solo \"administradores del equipo\" está habilitado, solo los líderes del equipo tendrán acceso.", + "autoShareTeam": "Compartir automáticamente grabaciones con todos los miembros del equipo cuando se aplique esta etiqueta", + "autoSharesWithTeam": "Comparte automáticamente con todos los miembros del equipo", + "confirmDelete": "¿Estás seguro de que quieres eliminar este equipo? Esta acción no se puede deshacer.", + "createTeam": "Crear Equipo", + "createTeamTag": "Crear Nueva Etiqueta de Equipo", + "dayRetention": "días de retención", + "deleteTeam": "Eliminar Equipo", + "deletionExempt": "Exento de eliminación", + "deletionExemptHelp": "Las grabaciones con esta etiqueta estarán exentas de eliminación automática, incluso si exceden el período de retención.", + "editTeam": "Editar Equipo", + "editTeamTag": "Editar Etiqueta de Equipo", + "globalRetention": "Retención global", + "members": "Miembros", + "noMembers": "No hay miembros en este equipo", + "noTeamTags": "Aún no se han creado etiquetas de equipo", + "noTeams": "Aún no se han creado equipos", + "retentionHelp": "Las grabaciones con esta etiqueta serán eliminadas después de estos días. Dejar vacío para usar retención global ({{days}} días).", + "retentionPeriod": "Período de Retención (días)", + "retentionPlaceholder": "Dejar vacío para usar retención global", + "searchUsers": "Buscar usuarios...", + "selectTeamLead": "Seleccionar líder del equipo...", + "shareWithAdmins": "Compartir grabaciones con administradores del equipo cuando se aplique esta etiqueta", + "sharesWithAdminsOnly": "Comparte solo con administradores del equipo", + "syncComplete": "Compartidos del equipo sincronizados exitosamente", + "syncTeamShares": "Sincronizar Compartidos del Equipo", + "syncTeamSharesDescription": "Esto compartirá retroactivamente todas las grabaciones con etiquetas de equipo con los miembros apropiados según la configuración de compartición de la etiqueta.", + "teamLead": "Líder del Equipo", + "teamName": "Nombre del Equipo", + "teamNamePlaceholder": "Ingrese nombre del equipo", + "teamTags": "Etiquetas del Equipo", + "title": "Gestión de Grupos", + "updateTeam": "Actualizar Equipo" + }, + "help": { + "actions": "Acciones", + "activeFilters": "Filtros activos", + "addSegmentBelow": "Añadir segmento debajo", + "advancedAsrOptions": "Opciones ASR Avanzadas", + "allRecordingsLoaded": "Todas las grabaciones cargadas", + "allTagsSelected": "All tags selected", + "appliedSuggestedNames": "{{count}} nombre sugerido aplicado", + "appliedSuggestedNamesPlural": "{{count}} nombres sugeridos aplicados", + "applySuggested": "Aplicar sugeridos", + "applySuggestedMobile": "Sugerir", + "approachingLimit": "Acercándose al límite de {{limit}}MB", + "askAboutTranscription": "Haz preguntas sobre esta transcripción", + "audioDeletedDescription": "El archivo de audio de esta grabación ha sido eliminado, pero la transcripción sigue disponible.", + "audioDeletedMessage": "El archivo de audio ha sido archivado y ya no está disponible para reproducción.", + "audioDeletedTitle": "Archivo de audio eliminado", + "audioPlayer": "Reproductor de Audio", + "autoIdentify": "Identificar Automáticamente", + "autoIdentifyMobile": "Auto", + "bothAudioDesc": "Graba tu voz + participantes de reunión (recomendado para reuniones en línea)", + "clearFilters": "limpiar filtros", + "clickToAddNotes": "Haz clic para añadir notas...", + "colorRepeats": "Color se repite desde el hablante {{number}}", + "completedFiles": "Archivos Completados", + "confirmReprocessingTitle": "Confirmar Reprocesamiento", + "copyMessage": "Copiar mensaje", + "createFolders": "Create folders", + "createPublicLink": "Crear un enlace público para compartir esta grabación. Compartir solo está disponible en conexiones seguras (HTTPS).", + "createTags": "Crear etiquetas", + "defaultHotwordsHelp": "Comma-separated words or phrases that the transcription model should prioritize recognizing (brand names, acronyms, technical terms).", + "defaultInitialPromptHelp": "Context to steer the transcription model's style and vocabulary. Describe the topic or expected content of your recordings.", + "deleteSegment": "Eliminar segmento", + "discard": "Descartar", + "dragToReorder": "Drag to reorder", + "endTime": "Fin", + "enterNameFor": "Ingresa nombre para", + "enterSpeakerName": "Ingrese nombre del orador...", + "entireScreen": "Pantalla Completa", + "errorChangingSpeaker": "Error al cambiar hablante", + "errorOpeningTextEditor": "Error al abrir el editor de texto", + "errorSavingText": "Error al guardar texto", + "estimatedSize": "Tamaño estimado", + "firstTagAsrSettings": "First tag's ASR settings will be applied:", + "firstTagDefaultsApplied": "First tag's defaults applied", + "folderHasCustomPrompt": "This folder has a custom summary prompt", + "generatingSummary": "Generando resumen...", + "groups": "grupos", + "howToRecordSystemAudio": "Cómo Grabar Audio del Sistema", + "identifyAllSpeakers": "Identificar todos los hablantes", + "identifying": "Identificando...", + "importantNote": "Nota importante", + "internalSharingDesc": "Compartir con usuarios específicos de su organización", + "lines": "{{count}} líneas", + "loadingMore": "Cargando más grabaciones...", + "loadingRecordings": "Cargando grabaciones...", + "me": "Yo", + "microphoneDesc": "Graba solo tu voz", + "modelReasoning": "Razonamiento del Modelo", + "moreSpeakersThanColors": "Más hablantes que colores disponibles", + "navigate": "Navegar", + "noDateSet": "Sin fecha establecida", + "noMatchingTags": "No matching tags", + "noParticipants": "Sin participantes", + "noRecordingSelected": "No se seleccionó ninguna grabación.", + "noSpeakersIdentified": "No se pudieron identificar hablantes a partir del contexto.", + "noSuggestionsToApply": "No hay sugerencias para aplicar", + "noTagsCreated": "Aún no se han creado etiquetas.", + "of": "de", + "playFromHere": "Reproducir desde aquí", + "pleaseEnterSpeakerName": "Por favor ingresa un nombre de hablante", + "processingTime": "Tiempo de procesamiento", + "processingTimeDescription": "Esto puede tardar unos minutos en completarse. Puedes continuar usando la aplicación mientras se procesa.", + "processingTranscription": "Procesando transcripción...", + "publicLinkDesc": "Cualquiera con este enlace puede acceder a la grabación", + "recordSystemSteps1": "Haz clic en \"Grabar Audio del Sistema\" o \"Grabar Ambos\".", + "recordSystemSteps2": "En la ventana emergente, elige", + "recordSystemSteps3": "Asegúrate de marcar la casilla que dice", + "recordingFinished": "Grabación terminada", + "recordingInProgress": "Grabación en progreso...", + "regenerateSummaryAfterNames": "Regenerar resumen después de actualizar nombres", + "saved": "¡Guardado!", + "savingProgress": "Guardando...", + "selectedTagsCustomPrompts": "Selected tags include custom summary prompts", + "sentence": "Oración", + "shareSystemAudio": "Compartir audio del sistema", + "shareTabAudio": "Compartir audio de pestaña", + "sharedOn": "Compartido el", + "sharingWindowNoAudio": "Compartir una \"Ventana\" no capturará audio.", + "speakerAdded": "Hablante añadido correctamente", + "speakerCount": "Hablante", + "speakerName": "Nombre del hablante", + "speakerNamesUpdated": "¡Nombres de hablantes actualizados correctamente!", + "speakers": "Hablantes", + "speakersIdentified": "{{count}} hablante identificado correctamente!", + "speakersIdentifiedPlural": "{{count}} hablantes identificados correctamente!", + "speakersUpdatedSaveToApply": "¡Hablantes actualizados! Guarda la transcripción para aplicar los cambios.", + "specificBrowserTab": "pestaña específica del navegador", + "startTime": "Inicio", + "startingAutoIdentification": "Iniciando identificación automática de hablantes...", + "summaryGenerationFailed": "Error al generar el resumen", + "summaryGenerationTimedOut": "La generación del resumen expiró", + "summaryRegenerationStarted": "Regeneración del resumen iniciada", + "summaryUpdated": "¡Resumen actualizado!", + "systemAudioDesc": "Graba participantes de reunión y sonidos del sistema", + "tagManagement": "Gestión de Etiquetas", + "thisActionCannotBeUndone": "Esta acción no se puede deshacer.", + "toCaptureAudioFromMeetings": "Para capturar audio de reuniones u otras aplicaciones, debes compartir tu pantalla o una pestaña del navegador.", + "toOrganizeRecordings": "to organize your recordings", + "transcriptUpdated": "¡Transcripción actualizada correctamente!", + "troubleshooting": "Solución de Problemas", + "tryAdjustingSearch": "Intenta ajustar tu búsqueda o", + "unsupportedBrowser": "Navegador No Soportado", + "untitled": "Grabación Sin Título", + "uploadRecordingNotes": "Subir Grabación y Notas", + "whatWillHappen": "¿Qué pasará?", + "whyNotWorking": "¿Por qué no funciona?", + "youHaveXSpeakers": "Tiene {{count}} oradores, pero solo hay 16 colores únicos disponibles. Los colores se repetirán después del decimosexto orador." + }, + "incognito": { + "audioNotStored": "Audio not stored in incognito mode", + "discardConfirm": "This will permanently discard your incognito recording. Continue?", + "mode": "Incognito Mode", + "notSavedToAccount": "Not saved to account", + "oneFileAtATime": "Incognito mode supports one file at a time", + "processInIncognito": "Process in Incognito", + "processWithoutSaving": "Process without saving", + "processing": "Processing...", + "processingComplete": "Processing complete!", + "processingInProgress": "Processing in incognito mode...", + "recordingDiscarded": "Incognito recording discarded", + "recordingProcessed": "Incognito recording processed - data will be lost when tab closes", + "recordingReady": "Incognito recording ready!", + "recordingTitle": "Incognito Recording", + "selectExactlyOneFile": "Select exactly 1 file", + "sessionOnly": "Session only", + "uploadingFile": "Uploading file for incognito processing..." + }, + "inquire": { + "activeFilters": "Filtros Activos:", + "askQuestions": "Haga Preguntas Sobre Sus Transcripciones", + "clearAll": "Limpiar Todo", + "dateRange": "Rango de Fechas", + "dateRangeActive": "Rango de fechas activo", + "exampleQuestion1": "\"¿Qué elementos de acción se discutieron?\"", + "exampleQuestion2": "\"¿Cuándo decidimos cambiar la cronología?\"", + "exampleQuestion3": "\"¿Qué preocupaciones se plantearon sobre el presupuesto?\"", + "exampleQuestion4": "\"¿Quién fue responsable de las tareas de marketing?\"", + "exampleQuestions": "Preguntas de Ejemplo:", + "filters": "Filtros", + "filtersActive": "Filtros activos", + "from": "Desde", + "noSpeakerData": "No hay datos de oradores disponibles", + "placeholder": "Haga preguntas sobre sus transcripciones filtradas...", + "selectFilters": "Seleccione filtros a la izquierda para reducir sus transcripciones, luego haga preguntas para obtener información de sus grabaciones.", + "sendHint": "Presione Enter para enviar • Ctrl+Enter para nueva línea", + "speakerRequirement": "La identificación de oradores requiere grabaciones con múltiples oradores", + "speakers": "Oradores", + "speakersCount": "oradores", + "tags": "Etiquetas", + "tagsCount": "etiquetas", + "title": "Consultar", + "to": "Hasta" + }, + "languages": { + "ar": "Árabe", + "de": "Alemán", + "en": "Inglés", + "es": "Español", + "fr": "Francés", + "hi": "Hindi", + "it": "Italiano", + "ja": "Japonés", + "ko": "Coreano", + "nl": "Holandés", + "pt": "Portugués", + "ru": "Ruso", + "zh": "Chino" + }, + "manageSpeakersModal": { + "created": "Creado", + "description": "Gestiona tus oradores guardados. Estos se guardan automáticamente cuando usas nombres de oradores en tus grabaciones.", + "failedToLoad": "Error al cargar oradores", + "lastUsed": "Último uso", + "loadingSpeakers": "Cargando oradores...", + "noSpeakersYet": "Aún no hay oradores guardados", + "speakersSaved": "{{count}} oradores guardados", + "speakersWillAppear": "Los oradores aparecerán aquí cuando uses nombres de oradores en tus grabaciones", + "times": "veces", + "title": "Gestionar Oradores", + "used": "Usado" + }, + "messages": { + "colorSchemeApplied": "Color scheme applied", + "colorSchemeReset": "Color scheme reset to default", + "copiedSuccessfully": "Copied to clipboard!", + "copiedToClipboard": "Copiado al portapapeles", + "copyFailed": "Failed to copy", + "copyNotSupported": "Copy failed. Your browser may not support this feature.", + "downloadStarted": "Descarga iniciada", + "errorRecoveringRecording": "Error recovering recording", + "eventDownloadFailed": "Failed to download event", + "eventDownloadSuccess": "Event \"{{title}}\" downloaded. Open the file to add to your calendar.", + "eventsExportFailed": "Failed to export events", + "eventsExportSuccess": "Exported {{count}} events", + "failedToDeleteJob": "Failed to delete job", + "failedToRecoverRecording": "Failed to recover recording", + "failedToRetryJob": "Failed to retry job", + "failedToSave": "Failed to save: {{error}}", + "failedToSaveParticipants": "Failed to save participants", + "followPlayerDisabled": "Follow player mode disabled", + "followPlayerEnabled": "Follow player mode enabled", + "invalidEventData": "Invalid event data", + "jobQueuedForRetry": "Job queued for retry", + "noEventsToExport": "No events to export", + "noNotesAvailableDownload": "No notes available to download.", + "noNotesToCopy": "No notes available to copy.", + "noPermissionToEdit": "You do not have permission to edit this recording", + "noSummaryToCopy": "No summary available to copy.", + "noSummaryToDownload": "No summary available to download.", + "noTranscriptionToCopy": "No transcription available to copy.", + "noTranscriptionToDownload": "No transcription available to download.", + "notesCopied": "Notes copied to clipboard!", + "notesDownloadFailed": "Failed to download notes", + "notesDownloadSuccess": "Notes downloaded successfully!", + "notesUpdated": "Notas actualizadas exitosamente", + "passwordChanged": "Contraseña cambiada exitosamente", + "profileUpdated": "Perfil actualizado exitosamente", + "recordingDeleted": "Grabación eliminada exitosamente", + "recordingDiscarded": "Recording discarded", + "recordingRecovered": "Recording recovered successfully", + "recordingSaved": "Grabación guardada exitosamente", + "saveParticipantsFailed": "Save failed: {{error}}", + "settingsSaved": "Configuración guardada exitosamente", + "summaryCopied": "Summary copied to clipboard!", + "summaryDownloadFailed": "Failed to download summary", + "summaryDownloadSuccess": "Summary downloaded successfully!", + "summaryGenerated": "Resumen generado exitosamente", + "tagAdded": "Etiqueta añadida exitosamente", + "tagRemoved": "Etiqueta eliminada exitosamente", + "transcriptDownloadFailed": "Failed to download transcript", + "transcriptDownloadSuccess": "Transcript downloaded successfully!", + "transcriptionCopied": "Transcription copied to clipboard!", + "transcriptionUpdated": "Transcripción actualizada exitosamente" + }, + "metadata": { + "cancelEdit": "Cancelar", + "createdAt": "Creado", + "duration": "Duración", + "editMetadata": "Editar Metadatos", + "fileName": "Nombre del Archivo", + "fileSize": "Tamaño del Archivo", + "language": "Idioma", + "meetingDate": "Fecha de Reunión", + "processingTime": "Tiempo de Procesamiento", + "saveMetadata": "Guardar", + "status": "Estado", + "title": "Metadatos", + "updatedAt": "Actualizado", + "wordCount": "Recuento de Palabras" + }, + "modal": { + "addSpeaker": "Agregar Nuevo Orador", + "colorScheme": "Esquema de Color", + "deleteRecording": "Eliminar Grabación", + "editAsrTranscription": "Editar Transcripción ASR", + "editParticipants": "Editar Participantes", + "editRecording": "Editar Grabación", + "editSpeakers": "Editar Oradores", + "editTags": "Editar Etiquetas de Grabación", + "editTranscription": "Editar Transcripción", + "identifySpeakers": "Identificar Hablantes", + "recordingNotice": "Aviso de Grabación", + "reprocessSummary": "Reprocesar Resumen", + "reprocessTranscription": "Reprocesar Transcripción", + "resetStatus": "¿Restablecer Estado de Grabación?", + "shareRecording": "Compartir Grabación", + "sharedTranscripts": "Mis Transcripciones Compartidas", + "systemAudioHelp": "Ayuda de Audio del Sistema", + "uploadFiles": "Subir Archivos", + "uploadNotice": "Aviso de Carga" + }, + "namingTemplates": { + "addPattern": "Añadir Patrón", + "availableTemplates": "Plantillas Disponibles", + "availableVars": "Variables Disponibles", + "cancel": "Cancelar", + "createDefaults": "Crear Plantillas Predeterminadas", + "createNew": "Crear Plantilla", + "customVarsHint": "Define patrones regex para extraer variables personalizadas de nombres de archivo.", + "delete": "Eliminar", + "description": "Define cómo se generan los títulos de las grabaciones a partir de nombres de archivo y contenido de transcripción.", + "descriptionLabel": "Descripción", + "noDefault": "Sin predeterminado (solo título IA)", + "regexHint": "Extrae datos de nombres de archivo. Usa grupos de captura () para especificar la coincidencia.", + "regexPatterns": "Patrones Regex (Opcional)", + "result": "Resultado:", + "save": "Guardar", + "selectOrCreate": "Selecciona una plantilla para editar o crea una nueva", + "tabTitle": "Nombrado", + "template": "Plantilla", + "templateName": "Nombre de Plantilla", + "test": "Probar", + "testTemplate": "Probar Plantilla", + "title": "Plantillas de Nombrado", + "userDefault": "Plantilla Predeterminada", + "userDefaultHint": "Se aplica cuando ninguna etiqueta tiene una plantilla de nombrado." + }, + "nav": { + "account": "Cuenta", + "accountSettings": "Configuración de Cuenta", + "admin": "Administración", + "adminDashboard": "Panel de Administración", + "darkMode": "Modo Oscuro", + "groupManagement": "Gestión de Grupos", + "home": "Inicio", + "language": "Idioma", + "lightMode": "Modo Claro", + "newRecording": "Nueva Grabación", + "recording": "Grabación", + "settings": "Configuración", + "signOut": "Cerrar Sesión", + "teamManagement": "Gestión de Grupos", + "upload": "Subir", + "userProfile": "Perfil de Usuario" + }, + "notes": { + "cancelEdit": "Cancelar Edición", + "characterCount": "{{count}} carácter", + "characterCountPlural": "{{count}} caracteres", + "editNotes": "Editar Notas", + "lastUpdated": "Última actualización", + "placeholder": "Añade tus notas aquí...", + "saveNotes": "Guardar Notas", + "title": "Notas" + }, + "pwa": { + "installApp": "Instalar App", + "installed": "Instalado exitosamente", + "installing": "Instalando...", + "notificationPermissionDenied": "Permiso de notificación denegado", + "notificationsEnabled": "Notificaciones habilitadas", + "offline": "Estás desconectado", + "screenAwake": "La pantalla permanecerá activa durante la grabación", + "screenAwakeFailed": "No se pudo mantener la pantalla activa", + "updateAvailable": "Actualización disponible" + }, + "recording": { + "acceptDisclaimer": "Acepto", + "cancelRecording": "Cancelar", + "discardRecovery": "Descartar", + "disclaimer": "Aviso de Grabación", + "duration": "Duración", + "micPlusSys": "Mic + Sys", + "microphone": "Micrófono", + "microphoneAndSystem": "Micrófono + Sistema", + "microphonePermissionDenied": "Permiso del micrófono denegado", + "modeBoth": "Micrófono + Sistema", + "modeMicrophone": "Micrófono", + "modeSystem": "Audio del sistema", + "notes": "Notas", + "notesPlaceholder": "Añade notas sobre esta grabación...", + "pauseRecording": "Pausar", + "recordingFailed": "La grabación falló", + "recordingInProgress": "Grabación en progreso...", + "recordingMode": "Modo de grabación", + "recordingSize": "Tamaño Estimado", + "recordingStopped": "Grabación detenida", + "recordingTime": "Tiempo de Grabación", + "recoveryDescription": "Encontramos una grabación sin terminar de una sesión anterior. ¿Deseas restaurarla?", + "recoveryFound": "Grabación no guardada detectada", + "recoveryTitle": "Recuperar grabación", + "restoreRecording": "Restaurar", + "resumeRecording": "Reanudar", + "saveRecording": "Guardar Grabación", + "size": "Tamaño", + "startRecording": "Iniciar Grabación", + "startedAt": "Iniciado el", + "stopRecording": "Detener Grabación", + "systemAudio": "Audio del Sistema", + "systemAudioNotSupported": "La grabación de audio del sistema no es compatible con este navegador", + "title": "Grabación de Audio" + }, + "reprocessModal": { + "audioReTranscribedFromScratch": "El audio será re-transcrito desde cero. Esto también regenerará el título y resumen basado en la nueva transcripción.", + "audioReTranscribedWithAsr": "El audio será re-transcrito usando el endpoint ASR. Esto incluye diarización y regenerará el título y resumen.", + "manualEditsOverwritten": "Cualquier edición manual de la transcripción, título o resumen será sobrescrita.", + "manualEditsOverwrittenSummary": "Cualquier edición manual del título o resumen será sobrescrita.", + "newTitleAndSummary": "Se generará un nuevo título y resumen basado en la transcripción existente." + }, + "settings": { + "apiKeys": "Claves API", + "appearance": "Apariencia", + "changePassword": "Cambiar Contraseña", + "dataExport": "Exportación de Datos", + "deleteAccount": "Eliminar Cuenta", + "integrations": "Integraciones", + "language": "Idioma", + "notifications": "Notificaciones", + "preferences": "Preferencias", + "privacy": "Privacidad", + "profile": "Perfil", + "security": "Seguridad", + "theme": "Tema", + "title": "Configuración", + "twoFactorAuth": "Autenticación de Dos Factores" + }, + "sharedTranscripts": { + "noSharedTranscripts": "Aún no has compartido ninguna transcripción.", + "shareNotes": "Compartir Notas", + "shareSummary": "Compartir Resumen", + "sharedOn": "Compartido el" + }, + "sharedTranscriptsPage": { + "noSharedTranscripts": "Aún no has compartido ninguna transcripción." + }, + "sharing": { + "canEdit": "Puede editar", + "canReshare": "Puede recompartir", + "internalSharing": "Compartir Interno", + "notSharedYet": "Aún no compartido", + "publicBadge": "Público", + "publicLink": "Enlace Público", + "publicLinks": "enlace(s) público(s)", + "publicLinksGenerated": "enlace(s) público(s) generado(s)", + "searchUsers": "Buscar usuarios...", + "sharedBadge": "Compartido", + "sharedBy": "Compartido por", + "sharedWith": "Compartido con", + "teamBadge": "Equipo", + "teamRecording": "Grabación del equipo", + "unknown": "Desconocido", + "users": "usuario(s)" + }, + "sidebar": { + "advancedSearch": "Búsqueda Avanzada", + "archived": "Archivado", + "archivedRecordings": "Grabaciones Archivadas", + "dateRange": "Rango de Fechas", + "filters": "Filtros", + "highlighted": "Destacado", + "inbox": "Bandeja de Entrada", + "lastMonth": "Mes Pasado", + "lastWeek": "Semana Pasada", + "loadMore": "Cargar Más", + "markAsRead": "Marcar como leído", + "moveToInbox": "Mover a bandeja de entrada", + "noRecordings": "No se encontraron grabaciones", + "older": "Más Antiguo", + "removeFromHighlighted": "Quitar de destacados", + "searchRecordings": "Buscar grabaciones...", + "sharedWithMe": "Compartido Conmigo", + "sortBy": "Ordenar Por", + "sortByDate": "Fecha de Creación", + "sortByMeetingDate": "Fecha de Reunión", + "starred": "Starred", + "tags": "Etiquetas", + "thisMonth": "Este Mes", + "thisWeek": "Esta Semana", + "today": "Hoy", + "totalRecordings": "{{count}} grabación", + "totalRecordingsPlural": "{{count}} grabaciones", + "upcoming": "Próximo", + "yesterday": "Ayer" + }, + "speakers": { + "filterBySpeaker": "Filtrar por orador", + "noMatchingSpeakers": "No hay oradores coincidentes", + "searchSpeakers": "Buscar..." + }, + "speakersManagement": { + "added": "Añadido", + "confidence": "Confianza", + "confidenceHigh": "alta", + "confidenceLow": "baja", + "confidenceMedium": "media", + "created": "Creado", + "description": "Gestiona tus hablantes guardados. Se guardan automáticamente cuando usas nombres de hablantes en tus grabaciones.", + "failedToLoad": "Error al cargar hablantes", + "failedToLoadSnippets": "Error al cargar fragmentos", + "keepThisSpeaker": "Mantener este hablante (los demás se fusionarán en él):", + "last": "Último", + "lastUsed": "Último uso", + "loadingSpeakers": "Cargando hablantes...", + "match": "coincidencia", + "mergeDescription": "Combinar varios perfiles de hablante en uno. Todos los embeddings, fragmentos y datos de uso se fusionarán.", + "mergeFailed": "Error al fusionar hablantes", + "mergeNSpeakers": "Fusionar {{count}} hablantes", + "mergeSpeakers": "Fusionar hablantes", + "mergeSuccess": "Hablantes fusionados correctamente", + "noSnippetsAvailable": "Sin fragmentos disponibles", + "noSpeakersYet": "Aún no hay hablantes guardados", + "sample": "muestra", + "samples": "muestras", + "selectToMerge": "Seleccionar 2+ para fusionar", + "speakersToMerge": "Hablantes a fusionar:", + "speakersWillAppear": "Los hablantes aparecerán aquí cuando uses nombres de hablantes en tus grabaciones", + "targetWillReceive": "El hablante seleccionado recibirá todos los datos de voz y fragmentos de los demás.", + "time": "vez", + "times": "veces", + "totalSpeakers": "hablantes guardados", + "used": "Usado", + "usedTimes": "Usado", + "viewSnippets": "Ver fragmentos", + "voiceMatchSuggestions": "Sugerencias de coincidencia de voz", + "voiceProfile": "Perfil de voz" + }, + "status": { + "completed": "Completado", + "failed": "Falló", + "processing": "Procesando", + "queued": "En cola", + "stuck": "Restablecer procesamiento bloqueado", + "summarizing": "Resumiendo", + "transcribing": "Transcribiendo", + "uploading": "Subiendo" + }, + "summary": { + "actionItems": "Elementos de Acción", + "cancelEdit": "Cancelar Edición", + "decisions": "Decisiones", + "editSummary": "Editar Resumen", + "generateSummary": "Generar Resumen", + "keyPoints": "Puntos Clave", + "noSummary": "No hay resumen disponible", + "participants": "Participantes", + "regenerateSummary": "Regenerar Resumen", + "saveSummary": "Guardar Resumen", + "summaryFailed": "La generación del resumen falló", + "summaryInProgress": "Generación de resumen en progreso...", + "title": "Resumen" + }, + "tagManagement": { + "asrDefaults": "Configuraciones ASR Predeterminadas", + "createTag": "Crear Etiqueta", + "customPrompt": "Prompt Personalizado", + "description": "Organiza tus grabaciones con etiquetas personalizadas. Cada etiqueta puede tener su propio prompt de resumen y configuraciones ASR predeterminadas.", + "maxSpeakers": "Máx", + "minSpeakers": "Mín", + "noTags": "Aún no has creado ninguna etiqueta." + }, + "tags": { + "addTag": "Añadir Etiqueta", + "clearTagFilter": "Limpiar filtro", + "createTag": "Crear Etiqueta", + "currentTags": "Etiquetas Actuales", + "filterByTag": "Filtrar por etiqueta", + "manageAllTags": "Gestionar Todas las Etiquetas", + "noAvailableTags": "No hay etiquetas disponibles", + "noMatchingTags": "No hay etiquetas coincidentes", + "noTags": "Sin etiquetas", + "removeTag": "Eliminar Etiqueta", + "searchTags": "Buscar...", + "tagColor": "Color de Etiqueta", + "tagName": "Nombre de Etiqueta", + "title": "Etiquetas" + }, + "tagsModal": { + "addTags": "Añadir Etiquetas", + "currentTags": "Etiquetas Actuales", + "done": "Listo", + "noTagsAssigned": "No hay etiquetas asignadas a esta grabación", + "searchTags": "Buscar etiquetas..." + }, + "time": { + "dayAgo": "Hace 1 día", + "daysAgo": "Hace {{count}} días", + "hourAgo": "Hace 1 hora", + "hoursAgo": "Hace {{count}} horas", + "justNow": "Justo ahora", + "minuteAgo": "Hace 1 minuto", + "minutesAgo": "Hace {{count}} minutos", + "monthAgo": "Hace 1 mes", + "monthsAgo": "Hace {{count}} meses", + "weekAgo": "Hace 1 semana", + "weeksAgo": "Hace {{count}} semanas", + "yearAgo": "Hace 1 año", + "yearsAgo": "Hace {{count}} años" + }, + "tooltips": { + "changeSpeaker": "Cambiar orador", + "clearChat": "Limpiar chat", + "copyTranscript": "Copiar transcripción", + "deleteTeam": "Eliminar Grupo", + "doubleClickToEdit": "Doble clic para editar", + "downloadTranscriptWithTemplate": "Descargar transcripción con plantilla", + "editTeam": "Editar Grupo", + "editText": "Editar texto", + "editTitle": "Editar título", + "editTranscript": "Editar transcripción", + "exitFullscreen": "Exit fullscreen", + "expand": "Expandir", + "followPlayerDisabled": "Activar desplazamiento automático - la transcripción sigue la reproducción de audio", + "followPlayerEnabled": "Desactivar desplazamiento automático - la transcripción permanece en su lugar", + "fullscreenVideo": "Fullscreen video", + "grantPublicSharing": "Otorgar permiso para compartir públicamente", + "hideVideo": "Hide video", + "highlight": "Resaltar", + "makeAdmin": "Hacer Administrador", + "manageMembers": "Administrar Miembros", + "manageTeamTags": "Administrar Etiquetas de Grupo", + "markAsRead": "Marcar como leído", + "maximizeChat": "Maximizar chat", + "minimize": "Minimizar", + "moveToInbox": "Mover a la bandeja de entrada", + "mute": "Silenciar", + "pause": "Pausa", + "play": "Reproducir", + "playbackSpeed": "Velocidad de reproducción", + "removeAdmin": "Remover Administrador", + "removeFromQueue": "Eliminar de la cola", + "removeFromTeam": "Eliminar del equipo", + "removeHighlight": "Quitar resaltado", + "reprocessTranscription": "Reprocesar transcripción", + "reprocessWithAsr": "Reprocesar con ASR", + "restoreChat": "Restaurar chat", + "revokePublicSharing": "Revocar permiso para compartir públicamente", + "shareWithUsers": "Compartir con usuarios", + "showVideo": "Show video", + "switchToDarkMode": "Cambiar a Modo Oscuro", + "switchToLightMode": "Cambiar a Modo Claro", + "unmute": "Activar sonido" + }, + "transcriptTemplates": { + "availableTemplates": "Plantillas Disponibles", + "availableVars": "Variables Disponibles", + "cancel": "Cancelar", + "chooseTemplate": "Elegir plantilla...", + "createDefaults": "Crear Plantillas Predeterminadas", + "createNew": "Crear Plantilla", + "default": "Predeterminado", + "delete": "Eliminar", + "description": "Personaliza cómo se formatean las transcripciones para descarga y exportación.", + "downloadDefault": "Descargar predeterminado", + "downloadWithoutTemplate": "Descargar sin plantilla", + "filters": "Filtros: |upper para mayúsculas, |srt para formato de subtítulos", + "save": "Guardar", + "selectOrCreate": "Selecciona una plantilla para editar o crea una nueva", + "selectTemplate": "Seleccionar Plantilla", + "setDefault": "Establecer como plantilla predeterminada", + "tabTitle": "Transcripción", + "template": "Plantilla", + "templateName": "Nombre de Plantilla", + "title": "Plantillas de Transcripción", + "viewGuide": "Ver Guía de Plantillas" + }, + "transcription": { + "autoIdentifySpeakers": "Identificar Hablantes Automáticamente", + "bubble": "Burbuja", + "cancelEdit": "Cancelar Edición", + "copy": "Copiar", + "copyToClipboard": "Copiar al Portapapeles", + "download": "Descargar", + "downloadTranscript": "Descargar Transcripción", + "edit": "Editar", + "editSpeakers": "Editar Hablantes", + "editTranscription": "Editar Transcripción", + "highlightSearchResults": "Resaltar resultados de búsqueda", + "noTranscription": "No hay transcripción disponible", + "regenerateTranscription": "Regenerar Transcripción", + "saveTranscription": "Guardar Transcripción", + "searchInTranscript": "Buscar en la transcripción...", + "simple": "Simple", + "speaker": "Hablante {{number}}", + "speakerLabels": "Etiquetas de Hablante", + "title": "Transcripción", + "unknownSpeaker": "Hablante Desconocido" + }, + "upload": { + "chunking": "Los archivos grandes se dividirán automáticamente para su procesamiento", + "completed": "Completado", + "copies": "copias de este archivo", + "dropzone": "Arrastra y suelta archivos de audio aquí, o haz clic para explorar", + "duplicateDetected": "Este archivo parece ser un duplicado de \"{{existingName}}\" (subido el {{existingDate}})", + "duplicateFile": "Archivo duplicado", + "failed": "Falló", + "fileExceedsMaxSize": "File \"{{name}}\" exceeds the maximum size of {{size}} MB and was skipped.", + "fileRemovedFromQueue": "File removed from queue", + "filesToUpload": "Files to Upload", + "invalidFileType": "Invalid file type \"{{name}}\". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.", + "maxFileSize": "Tamaño máximo de archivo", + "queued": "En cola", + "selectFiles": "Seleccionar Archivos", + "settingsApplyToAll": "Settings apply to all files in this session", + "summarizing": "Resumiendo...", + "supportedFormats": "Soporta MP3, WAV, M4A, MP4, MOV, AVI, AMR, y más", + "title": "Subir Audio", + "transcribing": "Transcribiendo...", + "untitled": "Grabación Sin Título", + "uploadNFiles": "Upload {{count}} File(s)", + "uploadProgress": "Progreso de Carga", + "videoRetained": "Video conservado para reproducción", + "willAutoSummarize": "Se resumirá automáticamente después de la transcripción" + }, + "uploadProgress": { + "title": "Progreso de Carga" + } +} \ No newline at end of file diff --git a/static/locales/fr.json b/static/locales/fr.json new file mode 100644 index 0000000..4cf797b --- /dev/null +++ b/static/locales/fr.json @@ -0,0 +1,1532 @@ +{ + "aboutPage": { + "aiSummarization": "Résumé par IA", + "aiSummarizationDesc": "Intégration OpenRouter et Ollama avec invites personnalisées", + "asrEnabled": "ASR Activé", + "asrEndpoint": "Point de Terminaison ASR", + "audioTranscription": "Transcription Audio", + "audioTranscriptionDesc": "API Whisper et support ASR personnalisé avec haute précision", + "backend": "Backend", + "database": "Base de Données", + "deployment": "Déploiement", + "dockerDescription": "Images Docker officielles", + "dockerHub": "Docker Hub", + "documentation": "Documentation", + "documentationDescription": "Guides de configuration et manuel utilisateur", + "endpoint": "Point de Terminaison", + "frontend": "Frontend", + "githubDescription": "Code source, problèmes et versions", + "githubRepository": "Référentiel GitHub", + "inquireMode": "Mode Enquête", + "inquireModeDesc": "Recherche sémantique dans tous vos enregistrements", + "interactiveChat": "Chat Interactif", + "interactiveChatDesc": "Discutez avec vos transcriptions en utilisant l'IA", + "keyFeatures": "Fonctionnalités Principales", + "largeLanguageModel": "Grand Modèle de Langage", + "model": "Modèle", + "projectDescription": "Transformez vos enregistrements audio en notes organisées et consultables avec des fonctionnalités de transcription, de résumé et de chat interactif pilotées par IA.", + "projectLinks": "Liens du Projet", + "sharingExport": "Partage et Exportation", + "sharingExportDesc": "Partagez des enregistrements et exportez vers divers formats", + "speakerDiarization": "Diarisation des Orateurs", + "speakerDiarizationDesc": "Identifie et étiquette automatiquement différents orateurs", + "speechRecognition": "Reconnaissance Vocale", + "systemConfiguration": "Configuration du Système", + "tagline": "Transcription Audio et Prise de Notes Pilotées par IA", + "technologyStack": "Pile Technologique", + "title": "À Propos", + "version": "Version", + "whisperApi": "API Whisper" + }, + "aboutPageDetails": { + "aiSummarizationDesc": "Intégration OpenRouter et Ollama avec invites personnalisées", + "asrEnabled": "ASR Activé", + "asrEndpoint": "Point de Terminaison ASR", + "audioTranscriptionDesc": "API Whisper et support ASR personnalisé avec haute précision", + "backend": "Backend", + "database": "Base de Données", + "deployment": "Déploiement", + "dockerDescription": "Images Docker officielles", + "documentationDescription": "Guides de configuration et manuel utilisateur", + "endpoint": "Point de Terminaison", + "frontend": "Frontend", + "githubDescription": "Code source, problèmes et versions", + "inquireModeDesc": "Recherche sémantique dans tous vos enregistrements", + "interactiveChatDesc": "Chattez avec vos transcriptions en utilisant l'IA", + "model": "Modèle", + "no": "Non", + "sharingExportDesc": "Partagez des enregistrements et exportez vers divers formats", + "speakerDiarizationDesc": "Identifier et étiqueter automatiquement les différents intervenants", + "whisperApi": "API Whisper", + "yes": "Oui" + }, + "account": { + "accountActions": "Actions du Compte", + "autoLabel": "Étiquetage automatique", + "autoSummarizationDisabled": "Résumé automatique désactivé par l'administrateur", + "autoSummarize": "Résumer automatiquement", + "changePassword": "Changer le Mot de Passe", + "chooseLanguageForInterface": "Choisissez la langue pour l'interface de l'application", + "companyOrganization": "Entreprise / Organisation", + "completedRecordings": "Terminés", + "defaultHotwords": "Mots-clés par défaut", + "defaultHotwordsPlaceholder": "ex., DictIA, CTranslate2, PyAnnote, SDRs", + "defaultInitialPrompt": "Invite initiale par défaut", + "defaultInitialPromptPlaceholder": "ex., C'est une réunion sur les outils de transcription IA. Les intervenants discutent de CTranslate2, PyAnnote et SDRs.", + "email": "E-mail", + "failedRecordings": "Échoués", + "fullName": "Nom Complet", + "goToRecordings": "Aller aux Enregistrements", + "interfaceLanguage": "Langue de l'Interface", + "jobTitle": "Titre du Poste", + "languageForSummaries": "Langue pour les titres, résumés et chat. Laisser vide pour le défaut (comportement par défaut de vos modèles choisis).", + "languagePreferences": "Préférences Linguistiques", + "leaveBlankForAutoDetect": "Laisser vide pour la détection automatique par le service de transcription", + "manageSpeakers": "Gérer les Orateurs", + "personalFolder": "Dossier Personnel (Non associé à un groupe)", + "personalInfo": "Informations Personnelles", + "personalTag": "Étiquette Personnelle (Non associée à un groupe)", + "preferredOutputLanguage": "Langue Préférée pour Chatbot et Résumés", + "processingRecordings": "En Traitement", + "saveAllPreferences": "Sauvegarder Toutes les Préférences", + "ssoLinkAccount": "Lier le compte {{provider}}", + "ssoLinked": "Lié", + "ssoNotLinked": "Non lié", + "ssoProvider": "Fournisseur", + "ssoSetPasswordFirst": "Pour dissocier l'authentification unique, vous devez d'abord définir un mot de passe.", + "ssoSubject": "Sujet :", + "ssoUnlinkAccount": "Dissocier le compte {{provider}}", + "ssoUnlinkConfirm": "Êtes-vous sûr de vouloir dissocier votre compte d'authentification unique ? Vous devrez utiliser votre mot de passe pour vous connecter.", + "statistics": "Statistiques du Compte", + "title": "Informations du Compte", + "totalRecordings": "Total d'Enregistrements", + "transcriptionHints": "Conseils de transcription", + "transcriptionHintsDesc": "Ces paramètres par défaut s'appliquent quand aucune étiquette ou dossier ne définit ses propres réglages. Ils améliorent la précision de la transcription pour votre contexte spécifique.", + "transcriptionLanguage": "Langue de Transcription", + "userDetails": "Détails de l'Utilisateur", + "username": "Nom d'Utilisateur" + }, + "accountTabs": { + "about": "À Propos", + "apiTokens": "Jetons API", + "customPrompts": "Invites Personnalisées", + "folderManagement": "Gestion des dossiers", + "help": "Aide", + "namingTemplates": "Nommage", + "promptOptions": "Options de Prompt", + "sharedTranscripts": "Transcriptions Partagées", + "speakersManagement": "Gestion des Locuteurs", + "tagManagement": "Gestion des Étiquettes", + "templates": "Modèles", + "transcriptTemplates": "Modèles de Transcription" + }, + "admin": { + "title": "Administration", + "userMenu": "Menu Utilisateur" + }, + "adminDashboard": { + "aboutInquireMode": "À Propos du Mode Enquête", + "actions": "ACTIONS", + "addNewUser": "Ajouter un Nouvel Utilisateur", + "addUser": "Ajouter un Utilisateur", + "additionalContext": "Contexte Supplémentaire", + "admin": "ADMIN", + "adminDefaultPrompt": "Invite par Défaut de l'Admin", + "adminDefaultPromptDesc": "L'invite configurée ci-dessus (affichée sur cette page)", + "adminUser": "Utilisateur Administrateur", + "allRecordingsProcessed": "Tous les enregistrements sont traités !", + "allowed": "Autorisé", + "available": "Disponible", + "blocked": "bloqué", + "budgetWarnings": "Alertes de Budget", + "characters": "caractères", + "chunkSize": "Taille du Morceau", + "complete": "terminé", + "completedRecordings": "Terminés", + "confirmPasswordLabel": "Confirmer le mot de passe", + "contextNotes": { + "jsonConversion": "Les transcriptions JSON sont converties en format texte brut avant l'envoi", + "modelConfig": "Le modèle utilisé est configuré via la variable d'environnement TEXT_MODEL_NAME", + "tagPrompts": "Si plusieurs invites d'étiquettes existent, elles sont fusionnées dans l'ordre où les étiquettes ont été ajoutées", + "transcriptLimit": "Les transcriptions sont limitées à un nombre configurable de caractères (par défaut : 30 000)" + }, + "createFirstGroup": "Créer votre premier groupe", + "created": "Créé", + "currentUsage": "Utilisation actuelle", + "currentUsageMinutes": "Utilisation Actuelle", + "defaultPromptInfo": "Cette invite par défaut sera utilisée pour tous les utilisateurs qui n'ont pas défini leur propre invite personnalisée dans les paramètres de leur compte.", + "defaultPrompts": "Invites par Défaut", + "defaultSummarizationPrompt": "Invite de Résumé par Défaut", + "description": "Description", + "editUser": "Modifier l'Utilisateur", + "email": "E-MAIL", + "emailLabel": "E-mail", + "embeddingModel": "Modèle d'Intégration", + "embeddingsStatus": "Statut des Intégrations", + "errors": { + "failedToLoadPrompt": "Échec du chargement de l'invite par défaut", + "failedToSavePrompt": "Échec de la sauvegarde de l'invite par défaut. Veuillez réessayer.", + "invalidFileSize": "Veuillez entrer une taille valide entre 1 et 10000 Mo", + "invalidInteger": "Veuillez entrer un nombre entier valide", + "invalidNumber": "Veuillez entrer un nombre valide", + "maxTimeout": "Le délai d'attente ne peut pas dépasser 10 heures (36000 secondes)", + "minCharacters": "Veuillez entrer un nombre valide d'au moins 1000 caractères", + "minTimeout": "Le délai d'attente doit être d'au moins 60 secondes" + }, + "failedRecordings": "Échoués", + "groupName": "Nom du groupe", + "groupsTab": "Groupes", + "id": "ID", + "inquireModeDescription": "Le mode Enquête permet aux utilisateurs de rechercher dans plusieurs transcriptions en utilisant des questions en langage naturel. Il fonctionne en divisant les transcriptions en morceaux et en créant des intégrations consultables à l'aide de modèles IA.", + "languagePreferenceNote": "Note: Si l'utilisateur a défini une préférence de langue de sortie, \" Assurez-vous que votre réponse est en {langue}.\" sera ajouté.", + "lastUpdated": "Dernière mise à jour", + "maxFileSizeHelp": "Taille maximale du fichier en mégaoctets (1-10000 Mo)", + "megabytes": "Mo", + "members": "Membres", + "membersCount": "membres", + "minutes": "minutes", + "monthlyCost": "Coût Mensuel", + "monthlyTokenBudget": "Budget Mensuel de Tokens", + "monthlyTranscriptionBudget": "Budget Mensuel de Transcription (minutes)", + "needProcessing": "Nécessite un Traitement", + "never": "Jamais", + "newPasswordLabel": "Nouveau Mot de Passe (laisser vide pour conserver l'actuel)", + "no": "Non", + "noData": "Aucune donnée", + "noDescription": "Aucune description", + "noDescriptionAvailable": "Aucune description disponible", + "noGroupsAdmin": "Vous n'êtes encore administrateur d'aucun groupe", + "noGroupsCreated": "Aucun groupe créé pour le moment", + "noLimit": "Aucune Limite", + "noMembersYet": "Aucun membre pour le moment", + "noTranscriptionData": "Aucune donnée d'utilisation de transcription disponible", + "none": "Aucun", + "notSet": "Non défini", + "overlap": "Chevauchement", + "passwordLabel": "Mot de passe", + "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", + "pendingRecordings": "En Attente", + "percentUsed": "utilisé", + "placeholdersNote": "Les espaces réservés sont remplacés par des valeurs réelles lors du traitement d'un enregistrement.", + "processAllRecordings": "Traiter Tous les Enregistrements", + "processNext10": "Traiter les 10 Prochains", + "processedForInquire": "Traité pour Enquête", + "processing": "Traitement en cours...", + "processingActions": "Actions de Traitement", + "processingProgress": "Progression du Traitement", + "processingRecordings": "En Traitement", + "promptDescription": "Cette invite sera utilisée pour générer des résumés pour tous les enregistrements lorsque les utilisateurs n'ont pas défini leur propre invite.", + "promptHierarchy": "Hiérarchie des Invites", + "promptPriorityDescription": "Le système utilise l'ordre de priorité suivant lors de la sélection de l'invite à utiliser:", + "promptResetMessage": "Invite réinitialisée par défaut du système. Cliquez sur \"Enregistrer les modifications\" pour appliquer.", + "promptSavedSuccessfully": "Invite par défaut enregistrée avec succès !", + "publicShare": "Partage public", + "recordingStatusDistribution": "Répartition du Statut des Enregistrements", + "recordings": "ENREGISTREMENTS", + "recordingsNeedProcessing": "Il y a {{count}} enregistrements qui doivent être traités pour le mode Enquête.", + "refreshStatus": "Actualiser le Statut", + "resetToDefault": "Réinitialiser par Défaut", + "saving": "Enregistrement...", + "searchUsers": "Rechercher des utilisateurs...", + "seconds": "secondes", + "settings": { + "asrTimeoutDesc": "Temps maximal en secondes pour attendre la fin de la transcription ASR. La valeur par défaut est 1800 secondes (30 minutes).", + "defaultSummaryPromptDesc": "Invite de résumé par défaut utilisée lorsque les utilisateurs n'ont pas défini leur propre invite. Cela sert d'invite de base pour tous les utilisateurs.", + "maxFileSizeDesc": "Taille maximale de fichier autorisée pour les téléchargements audio en mégaoctets (Mo).", + "recordingDisclaimerDesc": "Avis légal affiché aux utilisateurs avant le début de l'enregistrement. Prend en charge le formatage Markdown. Laisser vide pour désactiver.", + "uploadDisclaimerDesc": "Avis légal affiché avant le téléchargement de fichiers. Prend en charge Markdown. Laisser vide pour désactiver.", + "customBannerDesc": "Bannière personnalisée affichée en haut de la page. Prend en charge Markdown. Laisser vide pour désactiver.", + "transcriptLengthLimitDesc": "Nombre maximal de caractères à envoyer de la transcription au LLM pour le résumé et le chat. Utilisez -1 pour aucune limite." + }, + "storageUsed": "STOCKAGE UTILISÉ", + "summarizationInstructions": "Instructions de Résumé", + "systemFallback": "Repli Système", + "systemFallbackDesc": "Une valeur par défaut codée en dur si aucune des précédentes n'est définie", + "systemPrompt": "Invite Système", + "systemSettings": "Paramètres du Système", + "systemStatistics": "Statistiques du Système", + "tagCustomPrompt": "Invite Personnalisée de l'Étiquette", + "tagCustomPromptDesc": "Si un enregistrement a des étiquettes avec des invites personnalisées", + "textSearchOnly": "Recherche de Texte Uniquement", + "thisMonth": "Ce Mois", + "timeoutRecommendation": "Recommandé : 30-120 minutes pour les longs fichiers audio", + "title": "Tableau de Bord Administrateur", + "todaysMinutes": "Minutes Aujourd'hui", + "tokenBudgetExceeded": "Budget mensuel de tokens dépassé. Veuillez contacter votre administrateur.", + "tokenBudgetHelp": "Définissez une limite mensuelle de tokens pour les fonctionnalités IA. Laissez vide pour illimité.", + "tokenBudgetPlaceholder": "Laissez vide pour illimité", + "tokenUsage": "Utilisation des tokens", + "tokens": "tokens", + "topUsers": "Meilleurs Utilisateurs", + "topUsersByStorage": "Principaux Utilisateurs par Stockage", + "total": "Total", + "totalChunks": "Total des Segments", + "totalQueries": "Total des Requêtes", + "totalRecordings": "Total des Enregistrements", + "totalStorage": "Stockage Total", + "totalUsers": "Total des Utilisateurs", + "transcriptionBudgetHelp": "Limitez les minutes de transcription par mois. Laissez vide pour illimité.", + "transcriptionBudgetPlaceholder": "Laissez vide pour illimité", + "transcriptionUsage": "Utilisation de la Transcription", + "updateUser": "Mettre à jour l'Utilisateur", + "userCustomPrompt": "Invite Personnalisée de l'Utilisateur", + "userCustomPromptDesc": "Si l'utilisateur a défini sa propre invite dans les paramètres du compte", + "userManagement": "Gestion des Utilisateurs", + "userMessageTemplate": "Modèle de Message Utilisateur", + "userTranscriptionUsage": "Utilisation de Transcription par Utilisateur (Ce Mois)", + "username": "NOM D'UTILISATEUR", + "usernameLabel": "Nom d'utilisateur", + "vectorDimensions": "Dimensions du Vecteur", + "vectorStore": "Magasin Vectoriel", + "vectorStoreManagement": "Gestion du Magasin Vectoriel", + "vectorStoreUpToDate": "Le magasin de vecteurs est à jour.", + "viewFullPromptStructure": "Voir la Structure Complète de l'Invite LLM", + "warning": "avertissement", + "yes": "Oui" + }, + "apiTokens": { + "activeTokens": "Jetons actifs", + "createFirstToken": "Créez votre premier jeton API pour activer l'accès programmatique", + "createToken": "Créer un jeton", + "created": "Créé", + "description": "Créez et gérez des jetons API pour l'accès programmatique à votre compte", + "expires": "Expire", + "lastUsed": "Dernière utilisation", + "neverUsed": "Jamais utilisé", + "noExpiration": "Pas d'expiration", + "noTokens": "Aucun jeton API", + "securityNotice": "Avis de sécurité", + "securityWarning": "Traitez les jetons API comme des mots de passe. Ils fournissent un accès complet à votre compte. Ne partagez jamais les jetons dans des zones accessibles au public comme GitHub, le code côté client ou les logs", + "title": "Jetons API", + "createTitle": "Créer un Jeton API", + "expiration": "Expiration", + "expirationHelp": "Date d'expiration de ce jeton", + "onceShownWarning": "Le jeton ne sera affiché qu'une seule fois après sa création. Assurez-vous de le copier et de le sauvegarder de façon sécurisée.", + "tokenName": "Nom du Jeton *", + "tokenNameHelp": "Un nom descriptif pour identifier ce jeton", + "tokenNamePlaceholder": "ex., automatisation n8n, accès CLI", + "usageExamples": "Exemples d'utilisation" + }, + "buttons": { + "addSegment": "Ajouter un Segment", + "addSpeaker": "Ajouter un orateur", + "cancel": "Annuler", + "clearAllFilters": "Effacer tous les filtres", + "clearCompleted": "Effacer les téléchargements terminés", + "clearSearchText": "Effacer le texte de recherche", + "close": "Fermer", + "copy": "Copier", + "copyMessage": "Copier le message", + "copyNotes": "Copier les Notes", + "copySummary": "Copier le Résumé", + "copyToClipboard": "Copier dans le Presse-papiers", + "createTag": "Créer une Étiquette", + "deleteAll": "Tout supprimer", + "deleteSpeaker": "Supprimer l'intervenant", + "deleteTag": "Supprimer l'étiquette", + "deleteUser": "Supprimer l'utilisateur", + "downloadAsWord": "Télécharger en Word", + "downloadAudio": "Télécharger l'audio", + "downloadChat": "Télécharger la conversation en document Word", + "downloadNotes": "Télécharger les notes en document Word", + "downloadSummary": "Télécharger le résumé en document Word", + "editNotes": "Modifier les Notes", + "editSetting": "Éditer le paramètre", + "editSpeakers": "Éditer les orateurs...", + "editSummary": "Modifier le Résumé", + "editTag": "Éditer l'étiquette", + "editTags": "Éditer les étiquettes", + "editTranscription": "Modifier la Transcription", + "editUser": "Éditer l'utilisateur", + "exportCalendar": "Exporter vers le calendrier", + "help": "Aide", + "identifySpeakers": "Identifier les Orateurs", + "next": "Suivant", + "previous": "Précédent", + "refresh": "Actualiser", + "reprocessSummary": "Retraiter le résumé", + "reprocessTranscription": "Retraiter la transcription", + "reprocessWithAsr": "Retraiter avec ASR", + "resetStuckProcessing": "Réinitialiser le traitement bloqué", + "saveChanges": "Enregistrer les Modifications", + "saveCustomPrompt": "Enregistrer l'invite personnalisée", + "saveNames": "Enregistrer les noms", + "saveSettings": "Enregistrer les Paramètres", + "shareRecording": "Partager l'Enregistrement", + "toggleTheme": "Changer de thème", + "updateTag": "Mettre à jour l'Étiquette" + }, + "changePasswordModal": { + "confirmPassword": "Confirmer le Nouveau Mot de Passe", + "currentPassword": "Mot de Passe Actuel", + "newPassword": "Nouveau Mot de Passe", + "passwordRequirement": "Le mot de passe doit comporter au moins 8 caractères", + "title": "Changer le Mot de Passe" + }, + "chat": { + "availableAfterTranscription": "Le chat sera disponible une fois la transcription terminée", + "cannotChatTranscriptionFailed": "Impossible de discuter : la transcription a échoué. Veuillez retraiter la transcription d'abord.", + "chatWithTranscription": "Discuter avec la Transcription", + "clearChat": "Effacer la Discussion", + "cleared": "Discussion effacée", + "downloadFailed": "Échec du téléchargement de la discussion", + "downloadSuccess": "Discussion téléchargée avec succès !", + "error": "Échec de l'envoi du message", + "noMessages": "Pas encore de messages", + "noMessagesToDownload": "Aucun message de discussion à télécharger.", + "placeholder": "Posez une question sur cet enregistrement...", + "placeholderWithHint": "Posez une question sur cet enregistrement... (Entrée pour envoyer, Ctrl+Entrée pour nouvelle ligne)", + "send": "Envoyer", + "suggestedQuestions": "Questions Suggérées", + "thinking": "Réflexion...", + "title": "Discussion", + "whatAreActionItems": "Quelles sont les actions à mener?", + "whatAreKeyPoints": "Quels sont les points clés?", + "whatWasDiscussed": "Qu'est-ce qui a été discuté?", + "whoSaidWhat": "Qui a dit quoi?" + }, + "colorScheme": { + "chooseRecording": "Choisissez un enregistrement dans la barre latérale pour voir sa transcription et son résumé", + "darkThemes": "Thèmes Sombres", + "descriptions": { + "blue": "Thème bleu classique avec un aspect professionnel et épuré", + "emerald": "Thème vert inspiré de la nature pour une expérience apaisante", + "purple": "Thème violet riche avec un look élégant et moderne", + "rose": "Thème rose chaud avec une esthétique douce et accueillante", + "amber": "Tons ambrés chauds pour une ambiance cosy et productive", + "teal": "Thème sarcelle frais avec une ambiance rafraîchissante et moderne" + }, + "lightThemes": "Thèmes Clairs", + "names": { + "blue": "Bleu Océan", + "emerald": "Forêt Verte", + "purple": "Violet Royal", + "rose": "Rose Corail", + "amber": "Ambre Doré", + "teal": "Sarcelle Arctique" + }, + "resetToDefault": "Réinitialiser par Défaut", + "selectRecording": "Sélectionner un Enregistrement", + "subtitle": "Personnalisez votre interface avec de magnifiques thèmes de couleur", + "themes": { + "light": { + "blue": { + "name": "Bleu Océan", + "description": "Thème bleu classique avec attrait professionnel" + }, + "emerald": { + "name": "Émeraude Forêt", + "description": "Thème vert frais pour une sensation naturelle" + }, + "purple": { + "name": "Violet Royal", + "description": "Thème violet élégant avec sophistication" + }, + "rose": { + "name": "Rose Coucher de Soleil", + "description": "Thème rose chaud avec énergie douce" + }, + "amber": { + "name": "Ambre Doré", + "description": "Thème jaune chaud pour la luminosité" + }, + "teal": { + "name": "Turquoise Océan", + "description": "Thème turquoise frais pour la tranquillité" + } + }, + "dark": { + "blue": { + "name": "Bleu Minuit", + "description": "Thème bleu profond pour travail concentré" + }, + "emerald": { + "name": "Forêt Sombre", + "description": "Thème vert riche pour visualisation confortable" + }, + "purple": { + "name": "Violet Profond", + "description": "Thème violet mystérieux pour la créativité" + }, + "rose": { + "name": "Rose Sombre", + "description": "Thème rose atténué avec chaleur subtile" + }, + "amber": { + "name": "Ambre Sombre", + "description": "Thème marron chaud pour sessions confortables" + }, + "teal": { + "name": "Turquoise Profond", + "description": "Thème turquoise sombre pour concentration calme" + } + } + }, + "title": "Choisir le Schéma de Couleurs" + }, + "common": { + "back": "Retour", + "cancel": "Annuler", + "changesSaved": "Modifications enregistrées", + "close": "Fermer", + "confirm": "Confirmer", + "delete": "Supprimer", + "deselectAll": "Tout Désélectionner", + "download": "Télécharger", + "edit": "Modifier", + "error": "Erreur", + "failed": "Échoué", + "filter": "Filtrer", + "info": "Information", + "loading": "Chargement...", + "new": "Nouveau", + "next": "Suivant", + "no": "Non", + "noResults": "Aucun résultat trouvé", + "ok": "OK", + "or": "Or", + "previous": "Précédent", + "processing": "Traitement...", + "refresh": "Actualiser", + "retry": "Réessayer", + "save": "Enregistrer", + "search": "Rechercher", + "selectAll": "Tout Sélectionner", + "sort": "Trier", + "success": "Succès", + "untitled": "Sans titre", + "upload": "Télécharger", + "warning": "Avertissement", + "yes": "Oui" + }, + "customPrompts": { + "currentDefaultPrompt": "Invite Par Défaut Actuelle (Utilisée si vous laissez le champ ci-dessus vide)", + "promptDescription": "Cette invite sera utilisée pour générer des résumés de vos transcriptions. Elle remplace l'invite par défaut de l'administrateur.", + "promptPlaceholder": "Décrivez comment vous voulez que vos résumés soient structurés. Laissez vide pour utiliser l'invite par défaut de l'administrateur.", + "summaryGeneration": "Invite de Génération de Résumé", + "tip1": "Soyez spécifique sur les sections que vous voulez dans votre résumé", + "tip2": "Utilisez des instructions de formatage claires (ex. \"Utilisez des puces\", \"Créez des listes numérotées\")", + "tip3": "Spécifiez si vous voulez que certaines informations soient prioritaires", + "tip4": "Le système fournira automatiquement le contenu de la transcription à l'IA", + "tip5": "Votre préférence de langue de sortie (si définie) sera appliquée automatiquement", + "tipsTitle": "Conseils pour Rédiger des Invites Efficaces", + "yourCustomPrompt": "Votre Invite Personnalisée de Résumé" + }, + "deleteAllSpeakersModal": { + "confirmMessage": "Êtes-vous sûr de vouloir supprimer tous les intervenants sauvegardés ? Cette action ne peut pas être annulée.", + "title": "Supprimer Tous les Intervenants" + }, + "dialogs": { + "deleteRecording": { + "cancel": "Annuler", + "confirm": "Supprimer", + "message": "Êtes-vous sûr de vouloir supprimer cet enregistrement? Cette action ne peut pas être annulée.", + "title": "Supprimer l'Enregistrement" + }, + "deleteShare": { + "message": "Êtes-vous sûr de vouloir supprimer ce partage ? Cela révoquera l'accès au lien public.", + "title": "Supprimer le partage" + }, + "reprocessTranscription": { + "cancel": "Annuler", + "confirm": "Retraiter", + "message": "Êtes-vous sûr de vouloir retraiter cette transcription? La transcription actuelle sera remplacée.", + "title": "Retraiter la Transcription" + }, + "unsavedChanges": { + "cancel": "Annuler", + "discard": "Ignorer", + "message": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer avant de partir?", + "save": "Enregistrer", + "title": "Modifications Non Enregistrées" + } + }, + "duration": { + "hours": "{{count}} heure", + "hoursPlural": "{{count}} heures", + "minutes": "{{count}} minute", + "minutesPlural": "{{count}} minutes", + "seconds": "{{count}} seconde", + "secondsPlural": "{{count}} secondes" + }, + "editTagModal": { + "asrDefaultSettings": "Paramètres ASR par Défaut", + "asrSettingsDescription": "Ces paramètres seront appliqués par défaut lors de l'utilisation de cette étiquette avec la transcription ASR", + "color": "Couleur", + "colorDescription": "Choisissez une couleur pour faciliter l'identification", + "createTitle": "Créer une Étiquette", + "customPrompt": "Invite de Résumé Personnalisée", + "customPromptPlaceholder": "Optionnel : Invite personnalisée pour générer des résumés pour les enregistrements avec cette étiquette", + "defaultLanguage": "Langue par Défaut", + "defaultPromptPlaceholder": "Laissez vide pour utiliser votre invite de résumé par défaut", + "exportTemplate": "Modèle d'exportation", + "exportTemplateHint": "Sélectionnez un modèle d'exportation à utiliser lors de l'exportation des enregistrements avec cette étiquette", + "leaveBlankPrompt": "Laissez vide pour utiliser votre invite de résumé par défaut", + "maxSpeakers": "Maximum d'Intervenants", + "minSpeakers": "Minimum d'Intervenants", + "namingTemplate": "Modèle de nommage", + "namingTemplateHint": "Sélectionnez un modèle de nommage pour formater automatiquement les titres des enregistrements avec cette étiquette", + "noExportTemplate": "Aucun modèle (utiliser le défaut de l'utilisateur)", + "noNamingTemplate": "Pas de modèle (utiliser le défaut ou le titre IA)", + "tagName": "Nom de l'Étiquette *", + "tagNamePlaceholder": "ex., Réunions, Entretiens", + "title": "Modifier l'Étiquette" + }, + "errors": { + "audioExtractionFailed": "Échec de l'extraction audio", + "audioExtractionFailedGuidance": "Essayez de convertir le fichier en format audio standard (MP3, WAV) avant de télécharger.", + "audioExtractionFailedMessage": "Impossible d'extraire l'audio du fichier téléchargé.", + "audioRecordingFailed": "L'enregistrement audio a échoué. Vérifiez votre microphone.", + "authenticationError": "Erreur d'authentification", + "authenticationErrorGuidance": "Vérifiez que la clé API est correcte et n'a pas expiré.", + "authenticationErrorMessage": "Le service de transcription a rejeté les identifiants API.", + "checkApiKeyGuidance": "Vérifiez la clé API dans les paramètres", + "checkNetworkGuidance": "Vérifiez la connexion réseau", + "connectionError": "Erreur de connexion", + "connectionErrorGuidance": "Vérifiez votre connexion internet et assurez-vous que le service est disponible.", + "connectionErrorMessage": "Impossible de se connecter au service de transcription.", + "convertFormatGuidance": "Convertir en MP3 ou WAV avant de télécharger", + "convertStandardGuidance": "Convertir en format audio standard", + "enableChunkingGuidance": "Activez le découpage dans les paramètres ou compressez le fichier", + "fallbackMessage": "Une erreur s'est produite", + "fallbackTitle": "Erreur", + "fileTooLarge": "Fichier trop volumineux", + "fileTooLargeGuidance": "Essayez d'activer le découpage audio dans vos paramètres, ou compressez le fichier audio avant de télécharger.", + "fileTooLargeMaxSize": "Fichier trop volumineux. Max : {{size}} Mo.", + "fileTooLargeMessage": "Le fichier audio dépasse la taille maximale autorisée par le service de transcription.", + "fileTooLargeTitle": "Fichier Trop Volumineux", + "generic": "Une erreur s'est produite", + "invalidAudioFormat": "Format audio invalide", + "invalidAudioFormatGuidance": "Essayez de convertir l'audio en format MP3 ou WAV avant de télécharger.", + "invalidAudioFormatMessage": "Le format du fichier audio n'est pas pris en charge ou le fichier est peut-être corrompu.", + "loadingShares": "Erreur lors du chargement des partages", + "networkError": "Erreur réseau. Vérifiez votre connexion.", + "networkErrorDuringUpload": "Erreur réseau lors du téléchargement", + "notFound": "Introuvable", + "permissionDenied": "Permission refusée", + "processingError": "Erreur de traitement", + "processingErrorFallbackGuidance": "Essayez de retraiter l'enregistrement", + "processingErrorGuidance": "Si cette erreur persiste, essayez de retraiter l'enregistrement.", + "processingErrorMessage": "Une erreur s'est produite lors du traitement.", + "processingFailedOnServer": "Le traitement a échoué sur le serveur.", + "processingFailedWithStatus": "Le traitement a échoué avec le statut {{status}}", + "processingTimeout": "Délai de traitement dépassé", + "processingTimeoutGuidance": "Cela peut arriver avec des enregistrements très longs. Essayez de diviser l'audio en parties plus petites.", + "processingTimeoutMessage": "La transcription a pris trop de temps.", + "quotaExceeded": "Quota de stockage dépassé", + "rateLimitExceeded": "Limite de requêtes dépassée", + "rateLimitExceededGuidance": "Veuillez attendre quelques minutes et réessayer.", + "rateLimitExceededMessage": "Trop de requêtes ont été envoyées au service de transcription.", + "serverError": "Erreur serveur. Veuillez réessayer plus tard.", + "serverErrorStatus": "Erreur serveur ({{status}})", + "serviceUnavailable": "Service indisponible", + "serviceUnavailableGuidance": "C'est généralement temporaire. Veuillez réessayer dans quelques minutes.", + "serviceUnavailableMessage": "Le service de transcription est temporairement indisponible.", + "splitAudioGuidance": "Essayez de diviser l'audio en parties plus petites", + "summaryFailed": "La génération du résumé a échoué. Veuillez réessayer.", + "transcriptionFailed": "La transcription a échoué. Veuillez réessayer.", + "tryAgainLaterGuidance": "Réessayez dans quelques minutes", + "unauthorized": "Non autorisé", + "unexpectedResponse": "Réponse inattendue du serveur après le téléchargement.", + "unsupportedFormat": "Format de fichier non pris en charge", + "uploadFailed": "Le téléchargement a échoué. Veuillez réessayer.", + "uploadFailedWithStatus": "Le téléchargement a échoué avec le statut {{status}}", + "uploadTimedOut": "Délai de téléchargement dépassé", + "validationError": "Erreur de validation", + "waitAndRetryGuidance": "Attendez quelques minutes et réessayez" + }, + "eventExtraction": { + "description": "Lorsqu'activé, l'IA identifiera les réunions, rendez-vous et échéances mentionnés dans vos enregistrements et créera des événements de calendrier téléchargeables.", + "enableLabel": "Activer l'extraction automatique d'événements des transcriptions", + "info": "Les événements extraits apparaîtront dans l'onglet 'Événements' sur les enregistrements où des éléments de calendrier sont détectés.", + "title": "Extraction d'Événements" + }, + "events": { + "add": "Ajouter", + "addToCalendar": "Ajouter au Calendrier", + "attendees": "Participants", + "confirmDelete": "Supprimer l'événement « {title} » ?", + "delete": "Supprimer", + "deleteFailed": "Échec de la suppression de l'événement", + "deleted": "Événement supprimé", + "end": "Fin", + "location": "Lieu", + "noEvents": "Aucun événement détecté dans cet enregistrement", + "start": "Début", + "title": "Événements" + }, + "exportLabels": { + "created": "Créé", + "date": "Date", + "fileSize": "Taille du fichier", + "footer": "Généré avec [DictIA](https://gitea.innova-ai.ca/Innova-AI/dictia)", + "metadata": "Métadonnées", + "notes": "Notes", + "originalFile": "Fichier original", + "participants": "Participants", + "summarizationTime": "Temps de résumé", + "summary": "Résumé", + "tags": "Tags", + "transcription": "Transcription", + "transcriptionTime": "Temps de transcription" + }, + "exportTemplates": { + "availableLabels": "Libellés localisés (traduits automatiquement)", + "availableTemplates": "Modèles disponibles", + "availableVars": "Variables disponibles", + "cancel": "Annuler", + "conditionals": "Conditions", + "conditionalsHint": "Utilisez {{#if variable}}...{{/if}} pour inclure conditionnellement du contenu", + "contentSections": "Sections de contenu", + "createDefaults": "Créer un modèle par défaut", + "createNew": "Créer un modèle", + "default": "Par défaut", + "delete": "Supprimer", + "description": "Personnalisez la façon dont les enregistrements sont exportés vers des fichiers markdown.", + "recordingData": "Données d'enregistrement", + "save": "Enregistrer", + "selectOrCreate": "Sélectionnez un modèle à modifier ou créez-en un nouveau", + "setDefault": "Définir comme modèle par défaut", + "tabTitle": "Exportation", + "template": "Modèle", + "templateDescription": "Description", + "templateName": "Nom du modèle", + "title": "Modèles d'exportation", + "viewGuide": "Voir le guide des modèles" + }, + "fileSize": { + "bytes": "{{count}} B", + "gigabytes": "{{count}} GB", + "kilobytes": "{{count}} KB", + "megabytes": "{{count}} MB" + }, + "folderManagement": { + "allFolders": "Tous les dossiers", + "asrDefaults": "Paramètres ASR par défaut", + "autoShareOnApply": "Partager automatiquement avec les membres du groupe", + "autoShareOnApplyHelp": "Partager automatiquement les enregistrements avec tous les membres du groupe lors de l'ajout à ce dossier", + "confirmDelete": "Êtes-vous sûr de vouloir supprimer ce dossier ? Les enregistrements seront retirés du dossier mais pas supprimés.", + "createFolder": "Créer un dossier", + "customPrompt": "Prompt personnalisé", + "defaultLanguage": "Langue par défaut", + "deleteFolder": "Supprimer le dossier", + "description": "Organisez vos enregistrements dans des dossiers. Contrairement aux étiquettes, un enregistrement ne peut appartenir qu'à un seul dossier. Les prompts de dossier sont appliqués avant les prompts utilisateur mais après les prompts d'étiquette.", + "editFolder": "Modifier le dossier", + "filterByFolder": "Filtrer par dossier", + "folderColor": "Couleur du dossier", + "folderName": "Nom du dossier", + "groupSettings": "Paramètres de groupe", + "maxSpeakers": "Orateurs max.", + "minSpeakers": "Orateurs min.", + "moveToFolder": "Déplacer vers le dossier", + "namingTemplate": "Modèle de nommage", + "noFolder": "Aucun dossier", + "noFolders": "Aucun dossier créé pour le moment", + "noFoldersDescription": "Créez votre premier dossier pour organiser vos enregistrements", + "protectFromDeletion": "Protéger contre la suppression", + "protectFromDeletionHelp": "Protéger les enregistrements de ce dossier contre la suppression automatique", + "recordings": "enregistrements", + "removeFromFolder": "Retirer du dossier", + "retentionDays": "Jours de rétention", + "retentionDaysHelp": "Les enregistrements de ce dossier seront supprimés après ce nombre de jours. Laisser vide pour utiliser la rétention globale.", + "retentionPeriod": "Période de rétention (jours)", + "retentionPlaceholder": "Laisser vide pour utiliser la rétention globale", + "retentionSettings": "Paramètres de rétention", + "retentionDisabledWarning": "La suppression automatique est actuellement désactivée. Ces paramètres prendront effet lorsqu'un administrateur l'activera.", + "selectNamingTemplate": "Sélectionner un modèle de nommage...", + "shareWithGroupLead": "Partager avec les administrateurs du groupe", + "shareWithGroupLeadHelp": "Partager les enregistrements avec les administrateurs du groupe lors de l'ajout à ce dossier", + "title": "Gestion des dossiers", + "folderNamePlaceholder": "ex., Appels bureau, Entretiens", + "tabTranscription": "Transcription", + "tabSummaryTemplates": "Résumé et Modèles", + "tabSharing": "Partage", + "tagPriorityNote": "Les paramètres d'étiquette ont priorité sur les paramètres de dossier", + "defaultLanguagePlaceholder": "ex., fr, en, es", + "customPromptPlaceholder": "Optionnel : Invite personnalisée pour les résumés des enregistrements de ce dossier", + "customPromptHelp": "Laisser vide pour utiliser votre invite par défaut. Les invites d'étiquette ont priorité sur les invites de dossier.", + "namingTemplateHint": "Sélectionnez un modèle de nommage pour formater automatiquement les titres des enregistrements dans ce dossier", + "exportTemplateHint": "Sélectionnez un modèle d'exportation à utiliser lors de l'exportation des enregistrements dans ce dossier", + "groupAssignment": "Affectation de groupe (Optionnel)", + "groupAssignmentHelp": "Affecter ce dossier à un groupe pour activer le partage automatique lors de l'ajout d'enregistrements", + "groupSharingSettings": "Paramètres de partage de groupe", + "autoShareAllHelp": "Tous les membres du groupe auront automatiquement accès aux enregistrements de ce dossier", + "shareAdminsOnlyHelp": "Seuls les administrateurs du groupe auront accès (pas tous les membres)", + "bothEnabledNote": "Note : Si les deux sont activés, tous les membres du groupe auront accès.", + "saveFolder": "Enregistrer le dossier" + }, + "form": { + "auto": "Auto", + "autoDetect": "Détection automatique", + "dateFrom": "De", + "dateTo": "À", + "enterNotesMarkdown": "Entrez des notes au format Markdown...", + "enterSummaryMarkdown": "Entrez un résumé au format Markdown...", + "folder": "Dossier", + "hotwords": "Mots-clés", + "hotwordsHelp": "Mots séparés par des virgules pour améliorer la reconnaissance des termes spécifiques à votre domaine", + "hotwordsPlaceholder": "ex., DictIA, CTranslate2, PyAnnote", + "initialPrompt": "Invite initiale", + "initialPromptHelp": "Contexte pour orienter le style et le vocabulaire du modèle de transcription", + "initialPromptPlaceholder": "ex., C'est une réunion sur les outils de transcription IA.", + "language": "Langue", + "maxSpeakers": "Orateurs Maximum", + "meetingDate": "Date de Réunion", + "minSpeakers": "Orateurs Minimum", + "minutes": "Minutes", + "notes": "Notes", + "notesPlaceholder": "Tapez vos notes au format Markdown...", + "optional": "Optionnel", + "participantNamePlaceholder": "Nom du participant...", + "participants": "Participants", + "placeholderAuto": "Auto", + "placeholderCharacterLimit": "Entrez la limite de caractères (ex. 30000)", + "placeholderMinutes": "Minutes", + "placeholderOptional": "Optionnel", + "placeholderSeconds": "Secondes", + "placeholderSizeMB": "Entrez la taille en Mo", + "searchSpeakers": "Rechercher des intervenants...", + "searchTags": "Rechercher des étiquettes...", + "seconds": "Secondes", + "shareNotes": "Partager les Notes", + "shareSummary": "Partager le Résumé", + "shareableLink": "Lien Partageable", + "summaryPromptPlaceholder": "Entrez l'invite de résumé par défaut...", + "title": "Titre", + "transcriptionLanguage": "Langue de Transcription", + "yourFullName": "Votre nom complet" + }, + "groups": { + "addMembers": "Ajouter des membres...", + "autoShare": "Partage automatique", + "autoShareNote": "Note : Si les deux sont activés, tous les membres de l'équipe auront accès. Si seulement \"administrateurs de l'équipe\" est activé, seuls les chefs d'équipe auront accès.", + "autoShareTeam": "Partager automatiquement les enregistrements avec tous les membres de l'équipe lorsque cette étiquette est appliquée", + "autoSharesWithTeam": "Partage automatiquement avec tous les membres de l'équipe", + "confirmDelete": "Êtes-vous sûr de vouloir supprimer cette équipe ? Cette action ne peut pas être annulée.", + "createTeam": "Créer une Équipe", + "createTeamTag": "Créer une Nouvelle Étiquette d'Équipe", + "dayRetention": "jours de rétention", + "deleteTeam": "Supprimer l'Équipe", + "deletionExempt": "Exempté de suppression", + "deletionExemptHelp": "Les enregistrements avec cette étiquette seront exemptés de suppression automatique, même s'ils dépassent la période de rétention.", + "editTeam": "Modifier l'Équipe", + "editTeamTag": "Modifier l'Étiquette d'Équipe", + "globalRetention": "Rétention globale", + "members": "Membres", + "noMembers": "Aucun membre dans cette équipe", + "noTeamTags": "Aucune étiquette d'équipe créée", + "noTeams": "Aucune équipe créée", + "retentionHelp": "Les enregistrements avec cette étiquette seront supprimés après ce nombre de jours. Laisser vide pour utiliser la rétention globale ({{days}} jours).", + "retentionPeriod": "Période de Rétention (jours)", + "retentionPlaceholder": "Laisser vide pour utiliser la rétention globale", + "searchUsers": "Rechercher des utilisateurs...", + "selectTeamLead": "Sélectionner le chef d'équipe...", + "shareWithAdmins": "Partager les enregistrements avec les administrateurs de l'équipe lorsque cette étiquette est appliquée", + "sharesWithAdminsOnly": "Partage uniquement avec les administrateurs de l'équipe", + "syncComplete": "Partages d'équipe synchronisés avec succès", + "syncTeamShares": "Synchroniser les Partages d'Équipe", + "syncTeamSharesDescription": "Cela partagera rétroactivement tous les enregistrements avec des étiquettes d'équipe avec les membres appropriés selon les paramètres de partage de l'étiquette.", + "teamLead": "Chef d'Équipe", + "teamName": "Nom de l'Équipe", + "teamNamePlaceholder": "Entrez le nom de l'équipe", + "teamTags": "Étiquettes d'Équipe", + "title": "Gestion de Groupe", + "updateTeam": "Mettre à jour l'Équipe" + }, + "help": { + "actions": "Actions", + "activeFilters": "Filtres actifs", + "addSegmentBelow": "Ajouter un segment en dessous", + "advancedAsrOptions": "Options ASR Avancées", + "allRecordingsLoaded": "Tous les enregistrements chargés", + "allTagsSelected": "Toutes les étiquettes sélectionnées", + "appliedSuggestedNames": "{{count}} nom suggéré appliqué", + "appliedSuggestedNamesPlural": "{{count}} noms suggérés appliqués", + "applySuggested": "Appliquer les suggestions", + "applySuggestedMobile": "Suggérer", + "approachingLimit": "Approche de la limite de {{limit}}MB", + "askAboutTranscription": "Posez des questions sur cette transcription", + "audioDeletedDescription": "Le fichier audio de cet enregistrement a été supprimé, mais la transcription reste disponible.", + "audioDeletedMessage": "Le fichier audio a été archivé et n'est plus disponible pour la lecture.", + "audioDeletedTitle": "Fichier audio supprimé", + "audioPlayer": "Lecteur Audio", + "autoIdentify": "Identifier Automatiquement", + "autoIdentifyMobile": "Auto", + "bothAudioDesc": "Enregistre votre voix + les participants à la réunion (recommandé pour les réunions en ligne)", + "clearFilters": "effacer les filtres", + "clickToAddNotes": "Cliquez pour ajouter des notes...", + "colorRepeats": "La couleur se répète à partir de l'orateur {{number}}", + "completedFiles": "Fichiers Terminés", + "confirmReprocessingTitle": "Confirmer le Retraitement", + "copyMessage": "Copier le message", + "createFolders": "Créer des dossiers", + "createPublicLink": "Créer un lien public pour partager cet enregistrement. Le partage n'est disponible que sur les connexions sécurisées (HTTPS).", + "createTags": "Créer des étiquettes", + "defaultHotwordsHelp": "Mots ou expressions séparés par des virgules que le modèle de transcription doit prioriser (noms de marques, acronymes, termes techniques).", + "defaultInitialPromptHelp": "Contexte pour orienter le style et le vocabulaire du modèle de transcription. Décrivez le sujet ou le contenu attendu de vos enregistrements.", + "deleteSegment": "Supprimer le segment", + "discard": "Ignorer", + "dragToReorder": "Glisser pour réordonner", + "endTime": "Fin", + "enterNameFor": "Entrez le nom pour", + "enterSpeakerName": "Entrez le nom du locuteur...", + "entireScreen": "Écran Entier", + "errorChangingSpeaker": "Erreur lors du changement d'orateur", + "errorOpeningTextEditor": "Erreur lors de l'ouverture de l'éditeur de texte", + "errorSavingText": "Erreur lors de la sauvegarde du texte", + "estimatedSize": "Taille estimée", + "firstTagAsrSettings": "Les paramètres ASR de la première étiquette seront appliqués :", + "firstTagDefaultsApplied": "Paramètres par défaut de la première étiquette appliqués", + "folderHasCustomPrompt": "Ce dossier a une invite de résumé personnalisée", + "generatingSummary": "Génération du résumé...", + "groups": "groupes", + "howToRecordSystemAudio": "Comment Enregistrer l'Audio Système", + "identifyAllSpeakers": "Identifier tous les orateurs", + "identifying": "Identification...", + "importantNote": "Note importante", + "internalSharingDesc": "Partager avec des utilisateurs spécifiques de votre organisation", + "lines": "{{count}} lignes", + "loadingMore": "Chargement de plus d'enregistrements...", + "loadingRecordings": "Chargement des enregistrements...", + "me": "Moi", + "microphoneDesc": "Enregistre uniquement votre voix", + "modelReasoning": "Raisonnement du Modèle", + "moreSpeakersThanColors": "Plus d'orateurs que de couleurs disponibles", + "navigate": "Naviguer", + "noDateSet": "Aucune date définie", + "noMatchingTags": "Aucune étiquette correspondante", + "noParticipants": "Aucun participant", + "noRecordingSelected": "Aucun enregistrement sélectionné.", + "noSpeakersIdentified": "Aucun orateur n'a pu être identifié à partir du contexte.", + "noSuggestionsToApply": "Aucune suggestion à appliquer", + "noTagsCreated": "Aucune étiquette créée pour le moment.", + "of": "sur", + "playFromHere": "Lire à partir d'ici", + "pleaseEnterSpeakerName": "Veuillez entrer un nom d'orateur", + "processingTime": "Temps de traitement", + "processingTimeDescription": "Cela peut prendre quelques minutes. Vous pouvez continuer à utiliser l'application pendant le traitement.", + "processingTranscription": "Traitement de la transcription...", + "publicLinkDesc": "Toute personne disposant de ce lien peut accéder à l'enregistrement", + "recordSystemSteps1": "Cliquez sur \"Enregistrer l'Audio Système\" ou \"Enregistrer les Deux\".", + "recordSystemSteps2": "Dans la fenêtre contextuelle, choisissez", + "recordSystemSteps3": "Assurez-vous de cocher la case qui dit", + "recordingFinished": "Enregistrement terminé", + "recordingInProgress": "Enregistrement en cours...", + "regenerateSummaryAfterNames": "Régénérer le résumé après la mise à jour des noms", + "saved": "Sauvegardé !", + "savingProgress": "Sauvegarde en cours...", + "selectedTagsCustomPrompts": "Les étiquettes sélectionnées incluent des invites de résumé personnalisées", + "sentence": "Phrase", + "shareSystemAudio": "Partager l'audio du système", + "shareTabAudio": "Partager l'audio de l'onglet", + "sharedOn": "Partagé le", + "sharingWindowNoAudio": "Partager une \"Fenêtre\" ne capturera pas l'audio.", + "speakerAdded": "Orateur ajouté avec succès", + "speakerCount": "Orateur", + "speakerName": "Nom de l'orateur", + "speakerNamesUpdated": "Noms des orateurs mis à jour avec succès !", + "speakers": "Orateurs", + "speakersIdentified": "{{count}} orateur identifié avec succès !", + "speakersIdentifiedPlural": "{{count}} orateurs identifiés avec succès !", + "speakersUpdatedSaveToApply": "Orateurs mis à jour ! Sauvegardez la transcription pour appliquer les modifications.", + "specificBrowserTab": "onglet de navigateur spécifique", + "startTime": "Début", + "startingAutoIdentification": "Identification automatique des orateurs en cours...", + "summaryGenerationFailed": "Échec de la génération du résumé", + "summaryGenerationTimedOut": "La génération du résumé a expiré", + "summaryRegenerationStarted": "Régénération du résumé lancée", + "summaryUpdated": "Résumé mis à jour !", + "systemAudioDesc": "Enregistre les participants à la réunion et les sons du système", + "tagManagement": "Gestion des Étiquettes", + "thisActionCannotBeUndone": "Cette action ne peut pas être annulée.", + "toCaptureAudioFromMeetings": "Pour capturer l'audio des réunions ou d'autres applications, vous devez partager votre écran ou un onglet du navigateur.", + "toOrganizeRecordings": "pour organiser vos enregistrements", + "transcriptUpdated": "Transcription mise à jour avec succès !", + "troubleshooting": "Dépannage", + "tryAdjustingSearch": "Essayez d'ajuster votre recherche ou", + "unsupportedBrowser": "Navigateur Non Pris en Charge", + "untitled": "Enregistrement Sans Titre", + "uploadRecordingNotes": "Télécharger l'Enregistrement et les Notes", + "whatWillHappen": "Que va-t-il se passer?", + "whyNotWorking": "Pourquoi ça ne marche pas?", + "youHaveXSpeakers": "Vous avez {{count}} locuteurs, mais seulement 16 couleurs uniques sont disponibles. Les couleurs se répéteront après le 16ème locuteur." + }, + "incognito": { + "audioNotStored": "Audio non conservé en mode incognito", + "discardConfirm": "Cela supprimera définitivement votre enregistrement incognito. Continuer ?", + "mode": "Mode Incognito", + "notSavedToAccount": "Non sauvegardé dans le compte", + "oneFileAtATime": "Le mode incognito traite un seul fichier à la fois", + "processInIncognito": "Traiter en incognito", + "processWithoutSaving": "Traiter sans sauvegarder", + "processing": "Traitement...", + "processingComplete": "Traitement terminé !", + "processingInProgress": "Traitement en cours en mode incognito...", + "recordingDiscarded": "Enregistrement incognito supprimé", + "recordingProcessed": "Enregistrement incognito traité - les données seront perdues à la fermeture de l'onglet", + "recordingReady": "Enregistrement incognito prêt !", + "recordingTitle": "Enregistrement Incognito", + "selectExactlyOneFile": "Sélectionner exactement 1 fichier", + "sessionOnly": "Session uniquement", + "uploadingFile": "Téléchargement du fichier pour le traitement incognito..." + }, + "inquire": { + "activeFilters": "Filtres Actifs :", + "askQuestions": "Posez des Questions sur Vos Transcriptions", + "clearAll": "Effacer Tout", + "dateRange": "Plage de Dates", + "dateRangeActive": "Plage de dates active", + "exampleQuestion1": "\"Quels éléments d'action ont été discutés ?\"", + "exampleQuestion2": "\"Quand avons-nous décidé de changer le calendrier ?\"", + "exampleQuestion3": "\"Quelles préoccupations ont été soulevées concernant le budget ?\"", + "exampleQuestion4": "\"Qui était responsable des tâches de marketing ?\"", + "exampleQuestions": "Exemples de Questions :", + "filters": "Filtres", + "filtersActive": "Filtres actifs", + "from": "De", + "noSpeakerData": "Aucune donnée d'intervenant disponible", + "placeholder": "Posez des questions sur vos transcriptions filtrées...", + "selectFilters": "Sélectionnez les filtres à gauche pour affiner vos transcriptions, puis posez des questions pour obtenir des informations à partir de vos enregistrements.", + "sendHint": "Appuyez sur Entrée pour envoyer • Ctrl+Entrée pour nouvelle ligne", + "speakerRequirement": "L'identification des intervenants nécessite des enregistrements avec plusieurs intervenants", + "speakers": "Intervenants", + "speakersCount": "intervenants", + "tags": "Étiquettes", + "tagsCount": "étiquettes", + "title": "Enquêter", + "to": "À" + }, + "languages": { + "ar": "Arabe", + "de": "Allemand", + "en": "Anglais", + "es": "Espagnol", + "fr": "Français", + "hi": "Hindi", + "it": "Italien", + "ja": "Japonais", + "ko": "Coréen", + "nl": "Néerlandais", + "pt": "Portugais", + "ru": "Russe", + "zh": "Chinois" + }, + "manageSpeakersModal": { + "created": "Créé", + "description": "Gérez vos intervenants sauvegardés. Ils sont automatiquement sauvegardés lorsque vous utilisez des noms d'intervenants dans vos enregistrements.", + "failedToLoad": "Échec du chargement des intervenants", + "lastUsed": "Dernière utilisation", + "loadingSpeakers": "Chargement des intervenants...", + "noSpeakersYet": "Aucun intervenant sauvegardé pour le moment", + "speakersSaved": "{{count}} intervenants sauvegardés", + "speakersWillAppear": "Les intervenants apparaîtront ici lorsque vous utiliserez des noms d'intervenants dans vos enregistrements", + "times": "fois", + "title": "Gérer les Intervenants", + "used": "Utilisé" + }, + "messages": { + "colorSchemeApplied": "Schéma de couleurs appliqué", + "colorSchemeReset": "Schéma de couleurs réinitialisé par défaut", + "copiedSuccessfully": "Copié dans le presse-papiers !", + "copiedToClipboard": "Copié dans le presse-papiers", + "copyFailed": "Échec de la copie", + "copyNotSupported": "Échec de la copie. Votre navigateur ne prend peut-être pas en charge cette fonctionnalité.", + "downloadStarted": "Téléchargement commencé", + "errorRecoveringRecording": "Erreur lors de la récupération de l'enregistrement", + "eventDownloadFailed": "Échec du téléchargement de l'événement", + "eventDownloadSuccess": "Événement \"{{title}}\" téléchargé. Ouvrez le fichier pour l'ajouter à votre calendrier.", + "eventsExportFailed": "Échec de l'export des événements", + "eventsExportSuccess": "{{count}} événements exportés", + "failedToDeleteJob": "Échec de la suppression de la tâche", + "failedToRecoverRecording": "Échec de la récupération de l'enregistrement", + "failedToRetryJob": "Échec de la nouvelle tentative de tâche", + "failedToSave": "Échec de l'enregistrement : {{error}}", + "failedToSaveParticipants": "Échec de l'enregistrement des participants", + "followPlayerDisabled": "Mode suivi du lecteur désactivé", + "followPlayerEnabled": "Mode suivi du lecteur activé", + "invalidEventData": "Données d'événement invalides", + "jobQueuedForRetry": "Tâche mise en file d'attente pour nouvelle tentative", + "noEventsToExport": "Aucun événement à exporter", + "noNotesAvailableDownload": "Aucune note disponible au téléchargement.", + "noNotesToCopy": "Aucune note disponible à copier.", + "noPermissionToEdit": "Vous n'avez pas la permission de modifier cet enregistrement", + "noSummaryToCopy": "Aucun résumé disponible à copier.", + "noSummaryToDownload": "Aucun résumé disponible au téléchargement.", + "noTranscriptionToCopy": "Aucune transcription disponible à copier.", + "noTranscriptionToDownload": "Aucune transcription disponible au téléchargement.", + "notesCopied": "Notes copiées dans le presse-papiers !", + "notesDownloadFailed": "Échec du téléchargement des notes", + "notesDownloadSuccess": "Notes téléchargées avec succès !", + "notesUpdated": "Notes mises à jour avec succès", + "passwordChanged": "Mot de passe changé avec succès", + "profileUpdated": "Profil mis à jour avec succès", + "recordingDeleted": "Enregistrement supprimé avec succès", + "recordingDiscarded": "Enregistrement supprimé", + "recordingRecovered": "Enregistrement récupéré avec succès", + "recordingSaved": "Enregistrement sauvegardé avec succès", + "saveParticipantsFailed": "Échec de l'enregistrement : {{error}}", + "settingsSaved": "Paramètres enregistrés avec succès", + "summaryCopied": "Résumé copié dans le presse-papiers !", + "summaryDownloadFailed": "Échec du téléchargement du résumé", + "summaryDownloadSuccess": "Résumé téléchargé avec succès !", + "summaryGenerated": "Résumé généré avec succès", + "tagAdded": "Étiquette ajoutée avec succès", + "tagRemoved": "Étiquette supprimée avec succès", + "transcriptDownloadFailed": "Échec du téléchargement de la transcription", + "transcriptDownloadSuccess": "Transcription téléchargée avec succès !", + "transcriptionCopied": "Transcription copiée dans le presse-papiers !", + "transcriptionUpdated": "Transcription mise à jour avec succès" + }, + "metadata": { + "cancelEdit": "Annuler", + "createdAt": "Créé", + "duration": "Durée", + "editMetadata": "Modifier les Métadonnées", + "fileName": "Nom du Fichier", + "fileSize": "Taille du Fichier", + "language": "Langue", + "meetingDate": "Date de Réunion", + "processingTime": "Temps de Traitement", + "saveMetadata": "Enregistrer", + "status": "Statut", + "title": "Métadonnées", + "updatedAt": "Mis à jour", + "wordCount": "Nombre de Mots" + }, + "modal": { + "addSpeaker": "Ajouter un Nouveau Locuteur", + "colorScheme": "Schéma de Couleurs", + "deleteRecording": "Supprimer l'Enregistrement", + "editAsrTranscription": "Modifier la Transcription ASR", + "editParticipants": "Modifier les Participants", + "editRecording": "Modifier l'Enregistrement", + "editSpeakers": "Modifier les Locuteurs", + "editTags": "Modifier les Étiquettes d'Enregistrement", + "editTranscription": "Modifier la Transcription", + "identifySpeakers": "Identifier les Orateurs", + "recordingNotice": "Avis d'Enregistrement", + "reprocessSummary": "Retraiter le Résumé", + "reprocessTranscription": "Retraiter la Transcription", + "resetStatus": "Réinitialiser le Statut de l'Enregistrement?", + "shareRecording": "Partager l'Enregistrement", + "sharedTranscripts": "Mes Transcriptions Partagées", + "systemAudioHelp": "Aide Audio Système", + "uploadFiles": "Télécharger les fichiers", + "uploadNotice": "Avis de Téléchargement" + }, + "namingTemplates": { + "addPattern": "Ajouter un Motif", + "availableTemplates": "Modèles Disponibles", + "availableVars": "Variables Disponibles", + "cancel": "Annuler", + "createDefaults": "Créer des Modèles par Défaut", + "createNew": "Créer un Modèle", + "customVarsHint": "Définissez des motifs regex pour extraire des variables personnalisées des noms de fichiers.", + "delete": "Supprimer", + "description": "Définissez comment les titres d'enregistrement sont générés à partir des noms de fichiers et du contenu de transcription.", + "descriptionLabel": "Description", + "noDefault": "Pas de défaut (titre IA uniquement)", + "regexHint": "Extraire des données des noms de fichiers. Utilisez des groupes de capture () pour spécifier la correspondance.", + "regexPatterns": "Motifs Regex (Optionnel)", + "result": "Résultat:", + "save": "Enregistrer", + "selectOrCreate": "Sélectionnez un modèle à modifier ou créez-en un nouveau", + "tabTitle": "Nommage", + "template": "Modèle", + "templateName": "Nom du Modèle", + "test": "Tester", + "testTemplate": "Tester le Modèle", + "title": "Modèles de Nommage", + "userDefault": "Modèle par Défaut", + "userDefaultHint": "Appliqué quand aucune étiquette n'a de modèle de nommage." + }, + "nav": { + "account": "Compte", + "accountSettings": "Paramètres du Compte", + "admin": "Administration", + "adminDashboard": "Tableau de Bord Admin", + "darkMode": "Mode Sombre", + "groupManagement": "Gestion de Groupe", + "home": "Accueil", + "language": "Langue", + "lightMode": "Mode Clair", + "newRecording": "Nouvel Enregistrement", + "recording": "Enregistrement", + "settings": "Paramètres", + "signOut": "Déconnexion", + "teamManagement": "Gestion de Groupe", + "upload": "Télécharger", + "userProfile": "Profil Utilisateur" + }, + "notes": { + "cancelEdit": "Annuler la Modification", + "characterCount": "{{count}} caractère", + "characterCountPlural": "{{count}} caractères", + "editNotes": "Modifier les Notes", + "lastUpdated": "Dernière mise à jour", + "placeholder": "Ajoutez vos notes ici...", + "saveNotes": "Enregistrer les Notes", + "title": "Notes" + }, + "pwa": { + "installApp": "Installer l'App", + "installed": "Installé avec succès", + "installing": "Installation...", + "notificationPermissionDenied": "Permission de notification refusée", + "notificationsEnabled": "Notifications activées", + "offline": "Vous êtes hors ligne", + "screenAwake": "L'écran restera actif pendant l'enregistrement", + "screenAwakeFailed": "Impossible de garder l'écran actif", + "updateAvailable": "Mise à jour disponible" + }, + "recording": { + "acceptDisclaimer": "J'accepte", + "cancelRecording": "Annuler", + "discardRecovery": "Ignorer", + "disclaimer": "Avertissement d'Enregistrement", + "duration": "Durée", + "micPlusSys": "Mic + Sys", + "microphone": "Microphone", + "microphoneAndSystem": "Microphone + Système", + "microphonePermissionDenied": "Permission du microphone refusée", + "modeBoth": "Microphone + Système", + "modeMicrophone": "Microphone", + "modeSystem": "Audio système", + "notes": "Notes", + "notesPlaceholder": "Ajouter des notes sur cet enregistrement...", + "pauseRecording": "Pause", + "recordingFailed": "L'enregistrement a échoué", + "recordingInProgress": "Enregistrement en cours...", + "recordingMode": "Mode d'enregistrement", + "recordingSize": "Taille Estimée", + "recordingStopped": "Enregistrement arrêté", + "recordingTime": "Temps d'Enregistrement", + "recoveryDescription": "Nous avons trouvé un enregistrement inachevé d'une session précédente. Souhaitez-vous le restaurer ?", + "recoveryFound": "Enregistrement non sauvegardé détecté", + "recoveryTitle": "Récupérer l'enregistrement", + "restoreRecording": "Restaurer", + "resumeRecording": "Reprendre", + "saveRecording": "Enregistrer l'Enregistrement", + "size": "Taille", + "startRecording": "Démarrer l'Enregistrement", + "startedAt": "Commencé le", + "stopRecording": "Arrêter l'Enregistrement", + "systemAudio": "Audio Système", + "systemAudioNotSupported": "L'enregistrement audio système n'est pas pris en charge dans ce navigateur", + "title": "Enregistrement Audio" + }, + "reprocessModal": { + "audioReTranscribedFromScratch": "L'audio sera re-transcrit à partir de zéro. Cela régénérera également le titre et le résumé basés sur la nouvelle transcription.", + "audioReTranscribedWithAsr": "L'audio sera re-transcrit en utilisant l'endpoint ASR. Cela inclut la diarisation et régénérera le titre et le résumé.", + "manualEditsOverwritten": "Toute modification manuelle de la transcription, du titre ou du résumé sera écrasée.", + "manualEditsOverwrittenSummary": "Toute modification manuelle du titre ou du résumé sera écrasée.", + "newTitleAndSummary": "Un nouveau titre et résumé seront générés basés sur la transcription existante." + }, + "settings": { + "apiKeys": "Clés API", + "appearance": "Apparence", + "changePassword": "Changer le Mot de Passe", + "dataExport": "Export de Données", + "deleteAccount": "Supprimer le Compte", + "integrations": "Intégrations", + "language": "Langue", + "notifications": "Notifications", + "preferences": "Préférences", + "privacy": "Confidentialité", + "profile": "Profil", + "security": "Sécurité", + "theme": "Thème", + "title": "Paramètres", + "twoFactorAuth": "Authentification à Deux Facteurs" + }, + "sharedTranscripts": { + "noSharedTranscripts": "Vous n'avez encore partagé aucune transcription.", + "shareNotes": "Partager les Notes", + "shareSummary": "Partager le Résumé", + "sharedOn": "Partagé le" + }, + "sharedTranscriptsPage": { + "noSharedTranscripts": "Vous n'avez pas encore partagé de transcriptions." + }, + "sharing": { + "canEdit": "Peut modifier", + "canReshare": "Peut repartager", + "internalSharing": "Partage Interne", + "notSharedYet": "Pas encore partagé", + "publicBadge": "Public", + "publicLink": "Lien Public", + "publicLinks": "lien(s) public(s)", + "publicLinksGenerated": "lien(s) public(s) généré(s)", + "searchUsers": "Rechercher des utilisateurs...", + "sharedBadge": "Partagé", + "sharedBy": "Partagé par", + "sharedWith": "Partagé avec", + "teamBadge": "Équipe", + "teamRecording": "Enregistrement d'équipe", + "unknown": "Inconnu", + "users": "utilisateur(s)" + }, + "sidebar": { + "advancedSearch": "Recherche Avancée", + "archived": "Archivé", + "archivedRecordings": "Enregistrements Archivés", + "dateRange": "Plage de Dates", + "filters": "Filtres", + "highlighted": "Mis en Évidence", + "inbox": "Boîte de Réception", + "lastMonth": "Mois Dernier", + "lastWeek": "Semaine Dernière", + "loadMore": "Charger Plus", + "markAsRead": "Marquer comme lu", + "moveToInbox": "Déplacer vers la boîte de réception", + "noRecordings": "Aucun enregistrement trouvé", + "older": "Plus Ancien", + "removeFromHighlighted": "Retirer des favoris", + "searchRecordings": "Rechercher des enregistrements...", + "sharedWithMe": "Partagé avec Moi", + "sortBy": "Trier Par", + "sortByDate": "Date de Création", + "sortByMeetingDate": "Date de Réunion", + "starred": "Favoris", + "tags": "Étiquettes", + "thisMonth": "Ce Mois", + "thisWeek": "Cette Semaine", + "today": "Aujourd'hui", + "totalRecordings": "{{count}} enregistrement", + "totalRecordingsPlural": "{{count}} enregistrements", + "upcoming": "À venir", + "yesterday": "Hier" + }, + "speakers": { + "filterBySpeaker": "Filtrer par intervenant", + "noMatchingSpeakers": "Aucun intervenant correspondant", + "searchSpeakers": "Rechercher..." + }, + "speakersManagement": { + "added": "Ajouté", + "confidence": "Confiance", + "confidenceHigh": "élevée", + "confidenceLow": "faible", + "confidenceMedium": "moyenne", + "created": "Créé", + "description": "Gérez vos orateurs sauvegardés. Ils sont automatiquement enregistrés lorsque vous utilisez des noms d'orateurs dans vos enregistrements.", + "failedToLoad": "Échec du chargement des orateurs", + "failedToLoadSnippets": "Échec du chargement des extraits", + "keepThisSpeaker": "Garder cet orateur (les autres seront fusionnés) :", + "last": "Dernier", + "lastUsed": "Dernière utilisation", + "loadingSpeakers": "Chargement des orateurs...", + "match": "correspondance", + "mergeDescription": "Combiner plusieurs profils d'orateurs en un seul. Tous les embeddings, extraits et données d'utilisation seront fusionnés.", + "mergeFailed": "Échec de la fusion des orateurs", + "mergeNSpeakers": "Fusionner {{count}} orateurs", + "mergeSpeakers": "Fusionner les orateurs", + "mergeSuccess": "Orateurs fusionnés avec succès", + "noSnippetsAvailable": "Aucun extrait disponible", + "noSpeakersYet": "Aucun orateur sauvegardé pour le moment", + "sample": "échantillon", + "samples": "échantillons", + "selectToMerge": "Sélectionner 2+ pour fusionner", + "speakersToMerge": "Orateurs à fusionner :", + "speakersWillAppear": "Les orateurs apparaîtront ici lorsque vous utiliserez des noms d'orateurs dans vos enregistrements", + "targetWillReceive": "L'orateur sélectionné recevra toutes les données vocales et les extraits des autres.", + "time": "fois", + "times": "fois", + "totalSpeakers": "orateurs sauvegardés", + "used": "Utilisé", + "usedTimes": "Utilisé", + "viewSnippets": "Voir les extraits", + "voiceMatchSuggestions": "Suggestions de correspondance vocale", + "voiceProfile": "Profil vocal" + }, + "status": { + "completed": "Terminé", + "failed": "Échec", + "processing": "Traitement", + "queued": "En file d'attente", + "stuck": "Réinitialiser le traitement bloqué", + "summarizing": "Résumé", + "transcribing": "Transcription", + "uploading": "Téléchargement" + }, + "summary": { + "actionItems": "Actions à Mener", + "cancelEdit": "Annuler la Modification", + "decisions": "Décisions", + "editSummary": "Modifier le Résumé", + "generateSummary": "Générer un Résumé", + "keyPoints": "Points Clés", + "noSummary": "Aucun résumé disponible", + "participants": "Participants", + "regenerateSummary": "Régénérer le Résumé", + "saveSummary": "Enregistrer le Résumé", + "summaryFailed": "La génération du résumé a échoué", + "summaryInProgress": "Génération du résumé en cours...", + "title": "Résumé" + }, + "tagManagement": { + "asrDefaults": "Paramètres ASR par Défaut", + "createTag": "Créer une Étiquette", + "customPrompt": "Invite Personnalisée", + "description": "Organisez vos enregistrements avec des étiquettes personnalisées. Chaque étiquette peut avoir sa propre invite de résumé et ses paramètres ASR par défaut.", + "maxSpeakers": "Max", + "minSpeakers": "Min", + "noTags": "Vous n'avez pas encore créé d'étiquettes." + }, + "tags": { + "addTag": "Ajouter une Étiquette", + "clearTagFilter": "Effacer le filtre", + "createTag": "Créer une Étiquette", + "currentTags": "Étiquettes Actuelles", + "filterByTag": "Filtrer par étiquette", + "manageAllTags": "Gérer Toutes les Étiquettes", + "noAvailableTags": "Aucune étiquette disponible", + "noMatchingTags": "Aucune étiquette correspondante", + "noTags": "Aucune étiquette", + "removeTag": "Supprimer l'Étiquette", + "searchTags": "Rechercher...", + "tagColor": "Couleur de l'Étiquette", + "tagName": "Nom de l'Étiquette", + "title": "Étiquettes" + }, + "tagsModal": { + "addTags": "Ajouter des Étiquettes", + "currentTags": "Étiquettes Actuelles", + "done": "Terminé", + "noTagsAssigned": "Aucune étiquette assignée à cet enregistrement", + "searchTags": "Rechercher des étiquettes..." + }, + "time": { + "dayAgo": "Il y a 1 jour", + "daysAgo": "Il y a {{count}} jours", + "hourAgo": "Il y a 1 heure", + "hoursAgo": "Il y a {{count}} heures", + "justNow": "À l'instant", + "minuteAgo": "Il y a 1 minute", + "minutesAgo": "Il y a {{count}} minutes", + "monthAgo": "Il y a 1 mois", + "monthsAgo": "Il y a {{count}} mois", + "weekAgo": "Il y a 1 semaine", + "weeksAgo": "Il y a {{count}} semaines", + "yearAgo": "Il y a 1 an", + "yearsAgo": "Il y a {{count}} ans" + }, + "tooltips": { + "changeSpeaker": "Changer le locuteur", + "clearChat": "Effacer le chat", + "copyTranscript": "Copier la transcription", + "deleteTeam": "Supprimer le Groupe", + "doubleClickToEdit": "Double-cliquez pour modifier", + "downloadTranscriptWithTemplate": "Télécharger la transcription avec modèle", + "editTeam": "Modifier le Groupe", + "editText": "Modifier le texte", + "editTitle": "Modifier le titre", + "editTranscript": "Modifier la transcription", + "exitFullscreen": "Quitter le plein écran", + "expand": "Développer", + "followPlayerDisabled": "Activer le défilement automatique - la transcription suit la lecture audio", + "followPlayerEnabled": "Désactiver le défilement automatique - la transcription reste en place", + "fullscreenVideo": "Vidéo en plein écran", + "grantPublicSharing": "Accorder la permission de partage public", + "hideVideo": "Masquer la vidéo", + "highlight": "Surligner", + "makeAdmin": "Nommer Administrateur", + "manageMembers": "Gérer les Membres", + "manageTeamTags": "Gérer les Étiquettes de Groupe", + "markAsRead": "Marquer comme lu", + "maximizeChat": "Maximiser le chat", + "minimize": "Réduire", + "moveToInbox": "Déplacer vers la boîte de réception", + "mute": "Muet", + "pause": "Pause", + "play": "Lecture", + "playbackSpeed": "Vitesse de lecture", + "removeAdmin": "Retirer l'Administrateur", + "removeFromQueue": "Retirer de la file d'attente", + "removeFromTeam": "Retirer de l'équipe", + "removeHighlight": "Retirer le surlignage", + "reprocessTranscription": "Retraiter la transcription", + "reprocessWithAsr": "Retraiter avec ASR", + "restoreChat": "Restaurer le chat", + "revokePublicSharing": "Révoquer la permission de partage public", + "shareWithUsers": "Partager avec des utilisateurs", + "showVideo": "Afficher la vidéo", + "switchToDarkMode": "Passer en Mode Sombre", + "switchToLightMode": "Passer en Mode Clair", + "unmute": "Activer le son" + }, + "transcriptTemplates": { + "availableTemplates": "Modèles Disponibles", + "availableVars": "Variables Disponibles", + "cancel": "Annuler", + "chooseTemplate": "Choisir un modèle...", + "createDefaults": "Créer des Modèles par Défaut", + "createNew": "Créer un Modèle", + "default": "Par Défaut", + "delete": "Supprimer", + "description": "Personnalisez le formatage des transcriptions pour le téléchargement et l'exportation.", + "downloadDefault": "Télécharger par défaut", + "downloadWithoutTemplate": "Télécharger sans modèle", + "filters": "Filtres: |upper pour majuscules, |srt pour format sous-titres", + "save": "Enregistrer", + "selectOrCreate": "Sélectionnez un modèle à éditer ou créez-en un nouveau", + "selectTemplate": "Sélectionner un Modèle", + "setDefault": "Définir comme modèle par défaut", + "tabTitle": "Transcription", + "template": "Modèle", + "templateName": "Nom du Modèle", + "title": "Modèles de Transcription", + "viewGuide": "Voir le Guide des Modèles" + }, + "transcription": { + "autoIdentifySpeakers": "Identifier Automatiquement les Orateurs", + "bubble": "Bulle", + "cancelEdit": "Annuler la Modification", + "copy": "Copier", + "copyToClipboard": "Copier dans le Presse-papiers", + "download": "Télécharger", + "downloadTranscript": "Télécharger la Transcription", + "edit": "Éditer", + "editSpeakers": "Modifier les Orateurs", + "editTranscription": "Modifier la Transcription", + "highlightSearchResults": "Surligner les résultats de recherche", + "noTranscription": "Aucune transcription disponible", + "regenerateTranscription": "Régénérer la Transcription", + "saveTranscription": "Enregistrer la Transcription", + "searchInTranscript": "Rechercher dans la transcription...", + "simple": "Simple", + "speaker": "Orateur {{number}}", + "speakerLabels": "Étiquettes d'Orateur", + "title": "Transcription", + "unknownSpeaker": "Orateur Inconnu" + }, + "upload": { + "chunking": "Les gros fichiers seront automatiquement divisés pour le traitement", + "completed": "Terminé", + "copies": "copies de ce fichier", + "dropzone": "Glissez et déposez des fichiers audio ici, ou cliquez pour parcourir", + "duplicateDetected": "Ce fichier semble être un doublon de \"{{existingName}}\" (importé le {{existingDate}})", + "duplicateFile": "Fichier en double", + "failed": "Échec", + "fileExceedsMaxSize": "Le fichier \"{{name}}\" dépasse la taille maximale de {{size}} Mo et a été ignoré.", + "fileRemovedFromQueue": "Fichier retiré de la file d'attente", + "filesToUpload": "Fichiers à télécharger", + "invalidFileType": "Type de fichier invalide \"{{name}}\". Seuls les fichiers audio et les conteneurs vidéo avec audio (MP3, WAV, MP4, MOV, AVI, etc.) sont acceptés. Fichier ignoré.", + "maxFileSize": "Taille maximale du fichier", + "queued": "En file d'attente", + "selectFiles": "Sélectionner des Fichiers", + "settingsApplyToAll": "Les paramètres s'appliquent à tous les fichiers de cette session", + "summarizing": "Résumé...", + "supportedFormats": "Supports MP3, WAV, M4A, MP4, MOV, AVI, AMR, et plus", + "title": "Télécharger Audio", + "transcribing": "Transcription...", + "untitled": "Enregistrement Sans Titre", + "uploadNFiles": "Télécharger {{count}} fichier(s)", + "uploadProgress": "Progression du Téléchargement", + "videoRetained": "Vidéo conservée pour la lecture", + "willAutoSummarize": "Résumé automatique après la transcription" + }, + "uploadProgress": { + "title": "Progression du Téléchargement" + } +} \ No newline at end of file diff --git a/static/locales/ru.json b/static/locales/ru.json new file mode 100644 index 0000000..844d91e --- /dev/null +++ b/static/locales/ru.json @@ -0,0 +1,1505 @@ +{ + "aboutPage": { + "aiSummarization": "ИИ-резюмирование", + "aiSummarizationDesc": "Интеграция OpenRouter и Ollama с настраиваемыми запросами", + "asrEnabled": "ASR включён", + "asrEndpoint": "ASR-эндпоинт", + "audioTranscription": "Транскрибирование аудио", + "audioTranscriptionDesc": "Поддержка Whisper API и пользовательских ASR с высокой точностью", + "backend": "Бэкенд", + "database": "База данных", + "deployment": "Развёртывание", + "dockerDescription": "Официальные Docker-образы", + "dockerHub": "Docker Hub", + "documentation": "Документация", + "documentationDescription": "Руководство пользователя и учебные материалы", + "endpoint": "Эндпоинт", + "frontend": "Фронтенд", + "githubDescription": "Исходный код, задачи и релизы", + "githubRepository": "Репозиторий GitHub", + "inquireMode": "Режим Inquire", + "inquireModeDesc": "Семантический поиск по всем вашим записям", + "interactiveChat": "Интерактивный чат", + "interactiveChatDesc": "Общайтесь с транскрипциями при помощи ИИ", + "keyFeatures": "Ключевые возможности", + "largeLanguageModel": "Большая языковая модель", + "model": "Модель", + "projectDescription": "Преобразуйте аудиозаписи в организованные заметки с поддержкой поиска. ИИ-транскрибирование, резюмирование и интерактивный чат.", + "projectLinks": "Ссылки на проект", + "sharingExport": "Обмен и экспорт", + "sharingExportDesc": "Делитесь записями и экспортируйте в разные форматы", + "speakerDiarization": "Диаризация спикеров", + "speakerDiarizationDesc": "Автоматическое определение и маркировка разных спикеров", + "speechRecognition": "Распознавание речи", + "systemConfiguration": "Конфигурация системы", + "tagline": "ИИ-транскрибирование аудио и создание заметок", + "technologyStack": "Технологический стек", + "title": "О продукте", + "version": "Версия", + "whisperApi": "Whisper API" + }, + "aboutPageDetails": { + "aiSummarizationDesc": "Интеграция OpenRouter и Ollama с настраиваемыми запросами", + "asrEnabled": "ASR включён", + "asrEndpoint": "ASR-эндпоинт", + "audioTranscriptionDesc": "Поддержка Whisper API и пользовательских ASR с высокой точностью", + "backend": "Бэкенд", + "database": "База данных", + "deployment": "Развёртывание", + "dockerDescription": "Официальные Docker-образы", + "documentationDescription": "Инструкции по настройке и руководство пользователя", + "endpoint": "Эндпоинт", + "frontend": "Фронтенд", + "githubDescription": "Исходный код, задачи и релизы", + "inquireModeDesc": "Семантический поиск по всем вашим записям", + "interactiveChatDesc": "Общайтесь с транскрипциями при помощи ИИ", + "model": "Модель", + "no": "Нет", + "sharingExportDesc": "Делитесь записями и экспортируйте в разные форматы", + "speakerDiarizationDesc": "Автоматическое определение и маркировка разных спикеров", + "whisperApi": "Whisper API", + "yes": "Да" + }, + "account": { + "accountActions": "Действия с аккаунтом", + "autoLabel": "Auto-label", + "autoSummarizationDisabled": "Auto-summarization disabled by admin", + "autoSummarize": "Auto-summarize", + "changePassword": "Изменить пароль", + "chooseLanguageForInterface": "Выберите язык интерфейса приложения", + "companyOrganization": "Компания / Организация", + "completedRecordings": "Завершено", + "defaultHotwords": "Default Hotwords", + "defaultHotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote, SDRs", + "defaultInitialPrompt": "Default Initial Prompt", + "defaultInitialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools. The speakers discuss CTranslate2, PyAnnote, and SDRs.", + "email": "Email", + "failedRecordings": "С ошибкой", + "fullName": "Полное имя", + "goToRecordings": "Перейти к записям", + "interfaceLanguage": "Язык интерфейса", + "jobTitle": "Должность", + "languageForSummaries": "Язык для заголовков, резюме и чата. Оставьте пустым для использования поведения по умолчанию выбранных моделей.", + "languagePreferences": "Языковые настройки", + "leaveBlankForAutoDetect": "Оставьте пустым для автоматического определения сервисом транскрибирования", + "manageSpeakers": "Управление спикерами", + "personalFolder": "Personal Folder (Not Associated with a Group)", + "personalInfo": "Личная информация", + "personalTag": "Personal Tag (Not Associated with a Group)", + "preferredOutputLanguage": "Предпочитаемый язык чат-бота и резюмирования", + "processingRecordings": "В обработке", + "saveAllPreferences": "Сохранить все настройки", + "ssoLinkAccount": "Link {{provider}} account", + "ssoLinked": "Linked", + "ssoNotLinked": "Not linked", + "ssoProvider": "Provider", + "ssoSetPasswordFirst": "To unlink SSO, you must first set a password.", + "ssoSubject": "Subject:", + "ssoUnlinkAccount": "Unlink {{provider}} account", + "ssoUnlinkConfirm": "Are you sure you want to unlink your SSO account? You will need to use your password to log in.", + "statistics": "Статистика аккаунта", + "title": "Информация об аккаунте", + "totalRecordings": "Всего записей", + "transcriptionHints": "Transcription Hints", + "transcriptionHintsDesc": "These defaults are used when no tag or folder overrides are set. They help improve transcription accuracy for your specific use case.", + "transcriptionLanguage": "Язык транскрибирования", + "userDetails": "Данные пользователя", + "username": "Имя пользователя" + }, + "accountTabs": { + "about": "О продукте", + "apiTokens": "API-токены", + "customPrompts": "Пользовательские запросы", + "folderManagement": "Управление папками", + "help": "Помощь", + "namingTemplates": "Именование", + "promptOptions": "Настройки запросов", + "sharedTranscripts": "Общие транскрипты", + "speakersManagement": "Управление спикерами", + "tagManagement": "Управление тегами", + "templates": "Шаблоны", + "transcriptTemplates": "Шаблоны транскриптов" + }, + "admin": { + "title": "Администратор", + "userMenu": "Меню пользователя" + }, + "adminDashboard": { + "aboutInquireMode": "О режиме Inquire", + "actions": "ДЕЙСТВИЯ", + "addNewUser": "Добавить нового пользователя", + "addUser": "Добавить пользователя", + "additionalContext": "Дополнительный контекст", + "admin": "АДМИНИСТРАТОР", + "adminDefaultPrompt": "Системный запрос по умолчанию", + "adminDefaultPromptDesc": "Запрос, настроенный выше (показан на этой странице)", + "adminUser": "Администратор", + "allRecordingsProcessed": "Все записи обработаны!", + "allowed": "Allowed", + "available": "Доступно", + "blocked": "заблокировано", + "budgetWarnings": "Предупреждения о бюджете", + "characters": "символов", + "chunkSize": "Размер фрагмента", + "complete": "завершено", + "completedRecordings": "Завершено", + "confirmPasswordLabel": "Подтвердите пароль", + "contextNotes": { + "jsonConversion": "JSON-транскрипты конвертируются в чистый текст перед отправкой", + "modelConfig": "Используемая модель задаётся через переменную окружения TEXT_MODEL_NAME", + "tagPrompts": "Если существует несколько запросов тегов, они объединяются в порядке добавления тегов", + "transcriptLimit": "Транскрипты ограничены настраиваемым количеством символов (по умолчанию: 30 000)" + }, + "createFirstGroup": "Create Your First Group", + "created": "Created", + "currentUsage": "Текущее использование", + "currentUsageMinutes": "Текущее использование", + "defaultPromptInfo": "Этот запрос по умолчанию будет использоваться для всех пользователей, которые не настроили свой собственный в настройках аккаунта.", + "defaultPrompts": "Запросы по умолчанию", + "defaultSummarizationPrompt": "Запрос резюмирования по умолчанию", + "description": "Description", + "editUser": "Редактировать пользователя", + "email": "EMAIL", + "emailLabel": "Email", + "embeddingModel": "Модель эмбеддингов", + "embeddingsStatus": "Статус эмбеддингов", + "errors": { + "failedToLoadPrompt": "Не удалось загрузить запрос по умолчанию", + "failedToSavePrompt": "Не удалось сохранить запрос по умолчанию. Попробуйте снова.", + "invalidFileSize": "Введите корректный размер от 1 до 10000 МБ", + "invalidInteger": "Введите корректное целое число", + "invalidNumber": "Введите корректное число", + "maxTimeout": "Время ожидания не может превышать 10 часов (36000 секунд)", + "minCharacters": "Введите корректное число, не менее 1000 символов", + "minTimeout": "Время ожидания должно быть не менее 60 секунд" + }, + "failedRecordings": "С ошибкой", + "groupName": "Group Name", + "groupsTab": "Groups", + "id": "ID", + "inquireModeDescription": "Режим Inquire позволяет пользователям искать в нескольких транскрипциях, используя вопросы на естественном языке. Работает путём разбиения транскрипций на фрагменты и создания эмбеддингов для поиска с помощью ИИ-моделей.", + "languagePreferenceNote": "Примечание: если у пользователя установлен предпочитаемый язык вывода, будет добавлено \"Ensure your response is in {language}.\"", + "lastUpdated": "Последнее обновление", + "maxFileSizeHelp": "Максимальный размер файла в мегабайтах (1-10000 МБ)", + "megabytes": "МБ", + "members": "Members", + "membersCount": "members", + "minutes": "минут", + "monthlyCost": "Месячная стоимость", + "monthlyTokenBudget": "Месячный лимит токенов", + "monthlyTranscriptionBudget": "Месячный бюджет транскрипции (минуты)", + "needProcessing": "Требуют обработки", + "never": "Никогда", + "newPasswordLabel": "Новый пароль (оставьте пустым, чтобы сохранить текущий)", + "no": "Нет", + "noData": "Нет данных", + "noDescription": "No description", + "noDescriptionAvailable": "Описание недоступно", + "noGroupsAdmin": "You are not an admin of any groups yet", + "noGroupsCreated": "No groups created yet", + "noLimit": "Без ограничений", + "noMembersYet": "No members yet", + "noTranscriptionData": "Данные об использовании транскрипции недоступны", + "none": "Нет", + "notSet": "Не задано", + "overlap": "Перекрытие", + "passwordLabel": "Пароль", + "passwordsDoNotMatch": "Passwords do not match", + "pendingRecordings": "В ожидании", + "percentUsed": "использовано", + "placeholdersNote": "Заполнители заменяются фактическими значениями при обработке записи.", + "processAllRecordings": "Обработать все записи", + "processNext10": "Обработать следующие 10", + "processedForInquire": "Обработано для Inquire", + "processing": "Обработка...", + "processingActions": "Действия обработки", + "processingProgress": "Прогресс обработки", + "processingRecordings": "В обработке", + "promptDescription": "Этот запрос будет использоваться для генерации резюме всех записей, когда пользователи не установили свой собственный запрос.", + "promptHierarchy": "Иерархия запросов", + "promptPriorityDescription": "Система использует следующий порядок приоритетов при выборе запроса:", + "promptResetMessage": "Запрос сброшен на системный по умолчанию. Нажмите \"Сохранить изменения\" для применения.", + "promptSavedSuccessfully": "Запрос по умолчанию успешно сохранён!", + "publicShare": "Public Share", + "recordingStatusDistribution": "Распределение статусов записей", + "recordings": "ЗАПИСИ", + "recordingsNeedProcessing": "{{count}} записей требуют обработки для режима Inquire.", + "refreshStatus": "Обновить статус", + "resetToDefault": "Сбросить на стандартный", + "saving": "Сохранение...", + "searchUsers": "Поиск пользователей...", + "seconds": "секунд", + "settings": { + "asrTimeoutDesc": "Максимальное время ожидания завершения ASR-транскрибирования в секундах. По умолчанию 1800 секунд (30 минут).", + "defaultSummaryPromptDesc": "Запрос резюмирования по умолчанию, используемый когда пользователи не установили свой собственный. Служит базовым запросом для всех пользователей.", + "maxFileSizeDesc": "Максимальный допустимый размер файла для загрузки аудио в мегабайтах (МБ).", + "recordingDisclaimerDesc": "Юридическое предупреждение, показываемое пользователям перед началом записи. Поддерживает форматирование Markdown. Оставьте пустым, чтобы отключить.", + "uploadDisclaimerDesc": "Юридическое предупреждение, показываемое перед загрузкой файлов. Поддерживает Markdown. Оставьте пустым, чтобы отключить.", + "customBannerDesc": "Пользовательский баннер в верхней части страницы. Поддерживает Markdown. Оставьте пустым, чтобы отключить.", + "transcriptLengthLimitDesc": "Максимальное количество символов транскрипта для отправки в LLM для резюмирования и чата. Используйте -1 для отсутствия ограничений." + }, + "storageUsed": "ИСПОЛЬЗОВАНО ХРАНИЛИЩА", + "summarizationInstructions": "Инструкции для резюмирования", + "systemFallback": "Системный резервный", + "systemFallbackDesc": "Жёстко заданный по умолчанию, если ничего из вышеперечисленного не установлено", + "systemPrompt": "Системный запрос", + "systemSettings": "Системные настройки", + "systemStatistics": "Системная статистика", + "tagCustomPrompt": "Пользовательский запрос тега", + "tagCustomPromptDesc": "Если у записи есть теги с пользовательскими запросами", + "textSearchOnly": "Только текстовый поиск", + "thisMonth": "Этот месяц", + "timeoutRecommendation": "Рекомендуется: 30-120 минут для длинных аудиофайлов", + "title": "Панель администратора", + "todaysMinutes": "Минуты сегодня", + "tokenBudgetExceeded": "Месячный лимит токенов превышен. Пожалуйста, свяжитесь с администратором.", + "tokenBudgetHelp": "Установите месячный лимит токенов для функций ИИ. Оставьте пустым для неограниченного использования.", + "tokenBudgetPlaceholder": "Оставьте пустым для неограниченного", + "tokenUsage": "Использование токенов", + "tokens": "токенов", + "topUsers": "Топ пользователей", + "topUsersByStorage": "Топ пользователей по использованию хранилища", + "total": "Всего", + "totalChunks": "Всего фрагментов", + "totalQueries": "Всего запросов", + "totalRecordings": "Всего записей", + "totalStorage": "Общее хранилище", + "totalUsers": "Всего пользователей", + "transcriptionBudgetHelp": "Ограничьте минуты транскрипции в месяц. Оставьте пустым для неограниченного.", + "transcriptionBudgetPlaceholder": "Оставьте пустым для неограниченного", + "transcriptionUsage": "Использование транскрипции", + "updateUser": "Обновить пользователя", + "userCustomPrompt": "Пользовательский запрос", + "userCustomPromptDesc": "Если пользователь установил свой собственный запрос в настройках аккаунта", + "userManagement": "Управление пользователями", + "userMessageTemplate": "Шаблон сообщения пользователя", + "userTranscriptionUsage": "Использование транскрипции пользователями (Этот месяц)", + "username": "ИМЯ ПОЛЬЗОВАТЕЛЯ", + "usernameLabel": "Имя пользователя", + "vectorDimensions": "Размерность векторов", + "vectorStore": "Векторное хранилище", + "vectorStoreManagement": "Управление векторным хранилищем", + "vectorStoreUpToDate": "Векторное хранилище актуально.", + "viewFullPromptStructure": "Показать полную структуру запросов LLM", + "warning": "предупреждение", + "yes": "Да" + }, + "apiTokens": { + "activeTokens": "Активные токены", + "createFirstToken": "Создайте свой первый API-токен для программного доступа", + "createToken": "Создать токен", + "created": "Создан", + "description": "Создавайте и управляйте API-токенами для программного доступа к вашему аккаунту", + "expires": "Истекает", + "lastUsed": "Последнее использование", + "neverUsed": "Не использовался", + "noExpiration": "Без срока истечения", + "noTokens": "Нет API-токенов", + "securityNotice": "Уведомление о безопасности", + "securityWarning": "Относитесь к API-токенам как к паролям. Они предоставляют полный доступ к вашему аккаунту. Никогда не публикуйте токены в общедоступных местах, таких как GitHub, клиентский код или логи", + "title": "API-токены", + "usageExamples": "Примеры использования" + }, + "buttons": { + "addSegment": "Добавить сегмент", + "addSpeaker": "Добавить спикера", + "cancel": "Отмена", + "clearAllFilters": "Сбросить все фильтры", + "clearCompleted": "Очистить завершённые загрузки", + "clearSearchText": "Очистить текст поиска", + "close": "Закрыть", + "copy": "Копировать", + "copyMessage": "Копировать сообщение", + "copyNotes": "Копировать заметки", + "copySummary": "Копировать резюме", + "copyToClipboard": "Скопировать в буфер обмена", + "createTag": "Создать тег", + "deleteAll": "Удалить всё", + "deleteSpeaker": "Удалить спикера", + "deleteTag": "Удалить тег", + "deleteUser": "Удалить пользователя", + "downloadAsWord": "Скачать как Word", + "downloadAudio": "Скачать аудио", + "downloadChat": "Скачать чат как документ Word", + "downloadNotes": "Скачать заметки как документ Word", + "downloadSummary": "Скачать резюме как документ Word", + "editNotes": "Редактировать заметки", + "editSetting": "Редактировать настройку", + "editSpeakers": "Редактировать спикеров...", + "editSummary": "Редактировать резюме", + "editTag": "Редактировать тег", + "editTags": "Редактировать теги", + "editTranscription": "Редактировать транскрипцию", + "editUser": "Редактировать пользователя", + "exportCalendar": "Экспортировать в календарь", + "help": "Справка", + "identifySpeakers": "Определить спикеров", + "next": "Далее", + "previous": "Назад", + "refresh": "Обновить", + "reprocessSummary": "Перегенерировать резюме", + "reprocessTranscription": "Перегенерировать транскрипцию", + "reprocessWithAsr": "Перегенерировать через ASR", + "resetStuckProcessing": "Сбросить зависшую обработку", + "saveChanges": "Сохранить изменения", + "saveCustomPrompt": "Сохранить пользовательский запрос", + "saveNames": "Сохранить имена", + "saveSettings": "Сохранить настройки", + "shareRecording": "Поделиться записью", + "toggleTheme": "Переключить тему", + "updateTag": "Обновить тег" + }, + "changePasswordModal": { + "confirmPassword": "Подтвердите новый пароль", + "currentPassword": "Текущий пароль", + "newPassword": "Новый пароль", + "passwordRequirement": "Пароль должен содержать не менее 8 символов", + "title": "Изменение пароля" + }, + "chat": { + "availableAfterTranscription": "Чат будет доступен после завершения транскрибирования", + "cannotChatTranscriptionFailed": "Cannot chat: transcription failed. Please reprocess the transcription first.", + "chatWithTranscription": "Чат с транскрипцией", + "clearChat": "Очистить чат", + "cleared": "Chat cleared", + "downloadFailed": "Failed to download chat", + "downloadSuccess": "Chat downloaded successfully!", + "error": "Не удалось отправить сообщение", + "noMessages": "Сообщений пока нет", + "noMessagesToDownload": "No chat messages to download.", + "placeholder": "Задайте вопрос об этой записи...", + "placeholderWithHint": "Задайте вопрос об этой записи... (Enter для отправки, Ctrl+Enter для новой строки)", + "send": "Отправить", + "suggestedQuestions": "Предлагаемые вопросы", + "thinking": "Обдумываю...", + "title": "Чат", + "whatAreActionItems": "Какие есть задачи к выполнению?", + "whatAreKeyPoints": "Каковы ключевые моменты?", + "whatWasDiscussed": "Что обсуждалось?", + "whoSaidWhat": "Кто что сказал?" + }, + "colorScheme": { + "chooseRecording": "Выберите запись на боковой панели, чтобы увидеть её транскрипцию и резюме", + "darkThemes": "Тёмные темы", + "descriptions": { + "blue": "Classic blue theme with a clean, professional feel", + "emerald": "Nature-inspired green theme for a calming experience", + "purple": "Rich purple theme with an elegant, modern look", + "rose": "Warm rose theme with a soft, inviting aesthetic", + "amber": "Warm amber tones for a cozy, productive feel", + "teal": "Cool teal theme with a refreshing, modern vibe" + }, + "lightThemes": "Светлые темы", + "names": { + "blue": "Ocean Blue", + "emerald": "Forest Green", + "purple": "Royal Purple", + "rose": "Coral Rose", + "amber": "Golden Amber", + "teal": "Arctic Teal" + }, + "resetToDefault": "Сбросить на стандартную", + "selectRecording": "Выберите запись", + "subtitle": "Настройте интерфейс с помощью красивых цветовых тем", + "themes": { + "light": { + "blue": { + "name": "Океанический синий", + "description": "Классическая синяя тема с профессиональным видом" + }, + "emerald": { + "name": "Лесной изумруд", + "description": "Свежая зелёная тема для естественного ощущения" + }, + "purple": { + "name": "Королевский пурпур", + "description": "Элегантная фиолетовая тема с изысканностью" + }, + "rose": { + "name": "Закат в розовых тонах", + "description": "Тёплая розовая тема с мягкой энергией" + }, + "amber": { + "name": "Золотистый янтарь", + "description": "Тёплая жёлтая тема для яркости" + }, + "teal": { + "name": "Океанский бирюзовый", + "description": "Прохладная бирюзовая тема для спокойствия" + } + }, + "dark": { + "blue": { + "name": "Полуночный синий", + "description": "Глубокий синий для сосредоточенной работы" + }, + "emerald": { + "name": "Тёмный лес", + "description": "Насыщенный зелёный для комфортного просмотра" + }, + "purple": { + "name": "Глубокий пурпур", + "description": "Таинственный фиолетовый для творчества" + }, + "rose": { + "name": "Тёмная роза", + "description": "Приглушённый розовый с едва заметным теплом" + }, + "amber": { + "name": "Тёмный янтарь", + "description": "Тёплый коричневый для уютных сессий" + }, + "teal": { + "name": "Глубокий бирюзовый", + "description": "Тёмный бирюзовый для спокойной сосредоточенности" + } + } + }, + "title": "Выбор цветовой схемы" + }, + "common": { + "back": "Назад", + "cancel": "Отмена", + "changesSaved": "Изменения сохранены", + "close": "Закрыть", + "confirm": "Подтвердить", + "delete": "Удалить", + "deselectAll": "Снять выделение со всех", + "download": "Скачать", + "edit": "Редактировать", + "error": "Ошибка", + "failed": "Не удалось", + "filter": "Фильтр", + "info": "Информация", + "loading": "Загрузка...", + "new": "Новый", + "next": "Далее", + "no": "Нет", + "noResults": "Результатов не найдено", + "ok": "ОК", + "or": "Или", + "previous": "Назад", + "processing": "Обработка...", + "refresh": "Обновить", + "retry": "Повторить", + "save": "Сохранить", + "search": "Поиск", + "selectAll": "Выбрать всё", + "sort": "Сортировать", + "success": "Успешно", + "untitled": "Без названия", + "upload": "Загрузить", + "warning": "Предупреждение", + "yes": "Да" + }, + "customPrompts": { + "currentDefaultPrompt": "Текущий запрос по умолчанию (используется, если поле выше пустое)", + "promptDescription": "Этот запрос будет использоваться для генерации резюме ваших транскрипций. Он переопределяет запрос администратора по умолчанию.", + "promptPlaceholder": "Опишите, как вы хотите структурировать резюме. Оставьте пустым, чтобы использовать запрос администратора по умолчанию.", + "summaryGeneration": "Запрос для генерации резюме", + "tip1": "Будьте конкретны в отношении разделов, которые вы хотите видеть в резюме", + "tip2": "Используйте чёткие инструкции по форматированию (например, \"Используйте маркированные списки\", \"Создавайте нумерованные списки\")", + "tip3": "Укажите, если хотите приоритизировать определённую информацию", + "tip4": "Система автоматически предоставит содержимое транскрипта ИИ", + "tip5": "Ваше предпочтение по языку вывода (если установлено) будет применено автоматически", + "tipsTitle": "Советы по написанию эффективных запросов", + "yourCustomPrompt": "Ваш пользовательский запрос для резюме" + }, + "deleteAllSpeakersModal": { + "confirmMessage": "Вы уверены, что хотите удалить всех сохранённых спикеров? Это действие нельзя отменить.", + "title": "Удалить всех спикеров" + }, + "dialogs": { + "deleteRecording": { + "cancel": "Отмена", + "confirm": "Удалить", + "message": "Вы уверены, что хотите удалить эту запись? Это действие нельзя отменить.", + "title": "Удаление записи" + }, + "deleteShare": { + "message": "Вы уверены, что хотите удалить этот общий доступ? Это отзовёт доступ к публичной ссылке.", + "title": "Удалить общий доступ" + }, + "reprocessTranscription": { + "cancel": "Отмена", + "confirm": "Перегенерировать", + "message": "Вы уверены, что хотите перегенерировать эту транскрипцию? Текущая транскрипция будет заменена.", + "title": "Перегенерация транскрипции" + }, + "unsavedChanges": { + "cancel": "Отмена", + "discard": "Не сохранять", + "message": "У вас есть несохранённые изменения. Хотите сохранить их перед уходом?", + "save": "Сохранить", + "title": "Несохранённые изменения" + } + }, + "duration": { + "hours": "{{count}} час", + "hoursPlural": "{{count}} часов", + "minutes": "{{count}} минута", + "minutesPlural": "{{count}} минут", + "seconds": "{{count}} секунда", + "secondsPlural": "{{count}} секунд" + }, + "editTagModal": { + "asrDefaultSettings": "Настройки ASR по умолчанию", + "asrSettingsDescription": "Эти настройки будут применяться по умолчанию при использовании этого тега с ASR-транскрибированием", + "color": "Цвет", + "colorDescription": "Выберите цвет для лёгкой идентификации", + "createTitle": "Создать тег", + "customPrompt": "Пользовательский запрос для резюме", + "customPromptPlaceholder": "Необязательно: пользовательский запрос для генерации резюме записей с этим тегом", + "defaultLanguage": "Язык по умолчанию", + "defaultPromptPlaceholder": "Оставьте пустым, чтобы использовать ваш запрос резюмирования по умолчанию", + "exportTemplate": "Шаблон экспорта", + "exportTemplateHint": "Выберите шаблон экспорта для использования при экспорте записей с этим тегом", + "leaveBlankPrompt": "Оставьте пустым, чтобы использовать ваш запрос резюмирования по умолчанию", + "maxSpeakers": "Макс. спикеров", + "minSpeakers": "Мин. спикеров", + "namingTemplate": "Шаблон именования", + "namingTemplateHint": "Выберите шаблон именования для автоматического форматирования заголовков записей с этим тегом", + "noExportTemplate": "Без шаблона (использовать пользовательский по умолчанию)", + "noNamingTemplate": "Без шаблона (использовать по умолчанию или ИИ-заголовок)", + "tagName": "Название тега *", + "tagNamePlaceholder": "например, Встречи, Интервью", + "title": "Редактирование тега" + }, + "errors": { + "audioExtractionFailed": "Audio Extraction Failed", + "audioExtractionFailedGuidance": "Try converting the file to a standard audio format (MP3, WAV) before uploading.", + "audioExtractionFailedMessage": "Could not extract audio from the uploaded file.", + "audioRecordingFailed": "Не удалось записать аудио. Пожалуйста, проверьте ваш микрофон.", + "authenticationError": "Authentication Error", + "authenticationErrorGuidance": "Please check that the API key is correct and has not expired.", + "authenticationErrorMessage": "The transcription service rejected the API credentials.", + "checkApiKeyGuidance": "Check the API key in settings", + "checkNetworkGuidance": "Check network connection", + "connectionError": "Connection Error", + "connectionErrorGuidance": "Check your internet connection and ensure the service is available.", + "connectionErrorMessage": "Could not connect to the transcription service.", + "convertFormatGuidance": "Convert to MP3 or WAV before uploading", + "convertStandardGuidance": "Convert to standard audio format", + "enableChunkingGuidance": "Enable chunking in settings or compress the file", + "fallbackMessage": "An error occurred", + "fallbackTitle": "Error", + "fileTooLarge": "Файл слишком большой", + "fileTooLargeGuidance": "Try enabling audio chunking in your settings, or compress the audio file before uploading.", + "fileTooLargeMaxSize": "File too large. Max: {{size}} MB.", + "fileTooLargeMessage": "The audio file exceeds the maximum size allowed by the transcription service.", + "fileTooLargeTitle": "File Too Large", + "generic": "Произошла ошибка", + "invalidAudioFormat": "Invalid Audio Format", + "invalidAudioFormatGuidance": "Try converting the audio to MP3 or WAV format before uploading.", + "invalidAudioFormatMessage": "The audio file format is not supported or the file may be corrupted.", + "loadingShares": "Ошибка загрузки общих доступов", + "networkError": "Ошибка сети. Пожалуйста, проверьте ваше подключение.", + "networkErrorDuringUpload": "Network error during upload", + "notFound": "Не найдено", + "permissionDenied": "Доступ запрещён", + "processingError": "Processing Error", + "processingErrorFallbackGuidance": "Try reprocessing the recording", + "processingErrorGuidance": "If this error persists, try reprocessing the recording.", + "processingErrorMessage": "An error occurred during processing.", + "processingFailedOnServer": "Processing failed on server.", + "processingFailedWithStatus": "Processing failed with status {{status}}", + "processingTimeout": "Processing Timeout", + "processingTimeoutGuidance": "This can happen with very long recordings. Try splitting the audio into smaller parts.", + "processingTimeoutMessage": "The transcription took too long to complete.", + "quotaExceeded": "Превышена квота хранилища", + "rateLimitExceeded": "Rate Limit Exceeded", + "rateLimitExceededGuidance": "Please wait a few minutes and try reprocessing.", + "rateLimitExceededMessage": "Too many requests were sent to the transcription service.", + "serverError": "Ошибка сервера. Пожалуйста, попробуйте позже.", + "serverErrorStatus": "Server error ({{status}})", + "serviceUnavailable": "Service Unavailable", + "serviceUnavailableGuidance": "This is usually temporary. Please try again in a few minutes.", + "serviceUnavailableMessage": "The transcription service is temporarily unavailable.", + "splitAudioGuidance": "Try splitting the audio into smaller parts", + "summaryFailed": "Не удалось сгенерировать резюме. Пожалуйста, попробуйте снова.", + "transcriptionFailed": "Не удалось выполнить транскрибирование. Пожалуйста, попробуйте снова.", + "tryAgainLaterGuidance": "Try again in a few minutes", + "unauthorized": "Не авторизовано", + "unexpectedResponse": "Unexpected success response from server after upload.", + "unsupportedFormat": "Неподдерживаемый формат файла", + "uploadFailed": "Не удалось загрузить. Пожалуйста, попробуйте снова.", + "uploadFailedWithStatus": "Upload failed with status {{status}}", + "uploadTimedOut": "Upload timed out", + "validationError": "Ошибка валидации", + "waitAndRetryGuidance": "Wait a few minutes and try again" + }, + "eventExtraction": { + "description": "При включении ИИ будет определять встречи, назначения и дедлайны, упомянутые в ваших записях, и создавать загружаемые календарные события.", + "enableLabel": "Включить автоматическое извлечение событий из транскриптов", + "info": "Извлечённые события появятся на вкладке \"События\" в записях, где обнаружены элементы календаря.", + "title": "Извлечение событий" + }, + "events": { + "add": "Добавить", + "addToCalendar": "Добавить в календарь", + "attendees": "Участники", + "confirmDelete": "Удалить событие «{title}»?", + "delete": "Удалить", + "deleteFailed": "Не удалось удалить событие", + "deleted": "Событие удалено", + "end": "Конец", + "location": "Место", + "noEvents": "В этой записи не обнаружено событий", + "start": "Начало", + "title": "События" + }, + "exportLabels": { + "created": "Создано", + "date": "Дата", + "fileSize": "Размер файла", + "footer": "Создано с помощью [DictIA](https://gitea.innova-ai.ca/Innova-AI/dictia)", + "metadata": "Метаданные", + "notes": "Заметки", + "originalFile": "Исходный файл", + "participants": "Участники", + "summarizationTime": "Время резюмирования", + "summary": "Резюме", + "tags": "Теги", + "transcription": "Транскрипция", + "transcriptionTime": "Время транскрипции" + }, + "exportTemplates": { + "availableLabels": "Локализованные метки (переводятся автоматически)", + "availableTemplates": "Доступные шаблоны", + "availableVars": "Доступные переменные", + "cancel": "Отмена", + "conditionals": "Условия", + "conditionalsHint": "Используйте {{#if variable}}...{{/if}} для условного включения содержимого", + "contentSections": "Разделы содержимого", + "createDefaults": "Создать шаблон по умолчанию", + "createNew": "Создать шаблон", + "default": "По умолчанию", + "delete": "Удалить", + "description": "Настройте, как записи экспортируются в файлы markdown.", + "recordingData": "Данные записи", + "save": "Сохранить", + "selectOrCreate": "Выберите шаблон для редактирования или создайте новый", + "setDefault": "Установить как шаблон по умолчанию", + "tabTitle": "Экспорт", + "template": "Шаблон", + "templateDescription": "Описание", + "templateName": "Название шаблона", + "title": "Шаблоны экспорта", + "viewGuide": "Просмотреть руководство по шаблонам" + }, + "fileSize": { + "bytes": "{{count}} Б", + "gigabytes": "{{count}} ГБ", + "kilobytes": "{{count}} КБ", + "megabytes": "{{count}} МБ" + }, + "folderManagement": { + "allFolders": "Все папки", + "asrDefaults": "Настройки ASR по умолчанию", + "autoShareOnApply": "Автоматически делиться с участниками группы", + "autoShareOnApplyHelp": "Автоматически делиться записями со всеми участниками группы при добавлении в эту папку", + "confirmDelete": "Вы уверены, что хотите удалить эту папку? Записи будут удалены из папки, но не удалены.", + "createFolder": "Создать папку", + "customPrompt": "Пользовательский промпт", + "defaultLanguage": "Язык по умолчанию", + "deleteFolder": "Удалить папку", + "description": "Организуйте записи в папках. В отличие от тегов, запись может принадлежать только одной папке. Промпты папки применяются перед пользовательскими, но после промптов тегов.", + "editFolder": "Редактировать папку", + "filterByFolder": "Фильтр по папке", + "folderColor": "Цвет папки", + "folderName": "Название папки", + "groupSettings": "Настройки группы", + "maxSpeakers": "Макс. спикеров", + "minSpeakers": "Мин. спикеров", + "moveToFolder": "Переместить в папку", + "namingTemplate": "Шаблон именования", + "noFolder": "Без папки", + "noFolders": "Папки ещё не созданы", + "noFoldersDescription": "Создайте первую папку для организации записей", + "protectFromDeletion": "Защитить от удаления", + "protectFromDeletionHelp": "Защитить записи в этой папке от автоматического удаления", + "recordings": "записей", + "removeFromFolder": "Убрать из папки", + "retentionDays": "Дней хранения", + "retentionDaysHelp": "Записи в этой папке будут удалены через указанное количество дней. Оставьте пустым для глобального правила.", + "retentionSettings": "Настройки хранения", + "selectNamingTemplate": "Выбрать шаблон именования...", + "shareWithGroupLead": "Поделиться с администраторами группы", + "shareWithGroupLeadHelp": "Делиться записями с администраторами группы при добавлении в эту папку", + "title": "Управление папками" + }, + "form": { + "auto": "Автоматически", + "autoDetect": "Автоопределение", + "dateFrom": "С", + "dateTo": "По", + "enterNotesMarkdown": "Введите заметки в формате Markdown...", + "enterSummaryMarkdown": "Введите резюме в формате Markdown...", + "folder": "Folder", + "hotwords": "Hotwords", + "hotwordsHelp": "Comma-separated words to improve recognition of domain-specific terms", + "hotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote", + "initialPrompt": "Initial Prompt", + "initialPromptHelp": "Context to steer the transcription model's style and vocabulary", + "initialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools.", + "language": "Язык", + "maxSpeakers": "Макс. спикеров", + "meetingDate": "Дата встречи", + "minSpeakers": "Мин. спикеров", + "minutes": "Минуты", + "notes": "Заметки", + "notesPlaceholder": "Введите ваши заметки в формате Markdown...", + "optional": "Необязательно", + "participantNamePlaceholder": "Participant name...", + "participants": "Участники", + "placeholderAuto": "Автоматически", + "placeholderCharacterLimit": "Введите лимит символов (например, 30000)", + "placeholderMinutes": "Минуты", + "placeholderOptional": "Необязательно", + "placeholderSeconds": "Секунды", + "placeholderSizeMB": "Введите размер в МБ", + "searchSpeakers": "Поиск спикеров...", + "searchTags": "Поиск тегов...", + "seconds": "Секунды", + "shareNotes": "Поделиться заметками", + "shareSummary": "Поделиться резюме", + "shareableLink": "Ссылка для общего доступа", + "summaryPromptPlaceholder": "Введите запрос резюмирования по умолчанию...", + "title": "Заголовок", + "transcriptionLanguage": "Transcription Language", + "yourFullName": "Ваше полное имя" + }, + "groups": { + "addMembers": "Добавить участников...", + "autoShare": "Автоматический общий доступ", + "autoShareNote": "Примечание: если включены оба варианта, доступ будут иметь все участники группы. Если включен только \"администраторы группы\", доступ будут иметь только руководители группы.", + "autoShareTeam": "Автоматически предоставлять общий доступ к записям всем участникам группы при применении этого тега", + "autoSharesWithTeam": "Автоматический общий доступ для всех участников группы", + "confirmDelete": "Вы уверены, что хотите удалить эту группу? Это действие нельзя отменить.", + "createTeam": "Создать группу", + "createTeamTag": "Создать новый групповой тег", + "dayRetention": "дней хранения", + "deleteTeam": "Удалить группу", + "deletionExempt": "Исключить из удаления", + "deletionExemptHelp": "Записи с этим тегом будут освобождены от автоматического удаления, даже если они превышают период хранения.", + "editTeam": "Редактировать группу", + "editTeamTag": "Редактировать групповой тег", + "globalRetention": "Глобальное хранение", + "members": "Участники", + "noMembers": "В этой группе нет участников", + "noTeamTags": "Групповые теги ещё не созданы", + "noTeams": "Группы ещё не созданы", + "retentionHelp": "Записи с этим тегом будут удалены через указанное количество дней. Оставьте пустым, чтобы использовать глобальный период хранения ({{days}} дней).", + "retentionPeriod": "Период хранения (дни)", + "retentionPlaceholder": "Оставьте пустым, чтобы использовать глобальный период хранения", + "searchUsers": "Поиск пользователей...", + "selectTeamLead": "Выберите руководителя группы...", + "shareWithAdmins": "Предоставлять общий доступ к записям администраторам группы при применении этого тега", + "sharesWithAdminsOnly": "Общий доступ только для администраторов", + "syncComplete": "Общий доступ группы успешно синхронизирован", + "syncTeamShares": "Синхронизировать общий доступ группы", + "syncTeamSharesDescription": "Это ретроактивно предоставит общий доступ ко всем записям с групповыми тегами соответствующим участникам группы на основе настроек общего доступа тега.", + "teamLead": "Руководитель группы", + "teamName": "Название группы", + "teamNamePlaceholder": "Введите название группы", + "teamTags": "Групповые теги", + "title": "Управление группами", + "updateTeam": "Обновить группу" + }, + "help": { + "actions": "Действия", + "activeFilters": "Активные фильтры", + "addSegmentBelow": "Добавить сегмент ниже", + "advancedAsrOptions": "Расширенные параметры ASR", + "allRecordingsLoaded": "Все записи загружены", + "allTagsSelected": "All tags selected", + "appliedSuggestedNames": "Применено {{count}} предложенное имя", + "appliedSuggestedNamesPlural": "Применено {{count}} предложенных имён", + "applySuggested": "Применить предложенные", + "applySuggestedMobile": "Предложить", + "approachingLimit": "Приближается лимит в {{limit}} МБ", + "askAboutTranscription": "Задавайте вопросы об этой транскрипции", + "audioDeletedDescription": "Аудиофайл для этой записи был удалён, но транскрипция остаётся доступной.", + "audioDeletedMessage": "Аудиофайл был архивирован и больше не доступен для воспроизведения.", + "audioDeletedTitle": "Аудиофайл удалён", + "audioPlayer": "Аудиоплеер", + "autoIdentify": "Автоопределение", + "autoIdentifyMobile": "Авто", + "bothAudioDesc": "Записывает ваш голос + участников встречи (рекомендуется для онлайн-встреч)", + "clearFilters": "сбросить фильтры", + "clickToAddNotes": "Нажмите, чтобы добавить заметки...", + "colorRepeats": "Цвет повторяется начиная со спикера {{number}}", + "completedFiles": "Завершённые файлы", + "confirmReprocessingTitle": "Подтвердите перегенерацию", + "copyMessage": "Копировать сообщение", + "createFolders": "Create folders", + "createPublicLink": "Создайте публичную ссылку для общего доступа к этой записи. Общий доступ доступен только при безопасных (HTTPS) соединениях.", + "createTags": "Создайте теги", + "defaultHotwordsHelp": "Comma-separated words or phrases that the transcription model should prioritize recognizing (brand names, acronyms, technical terms).", + "defaultInitialPromptHelp": "Context to steer the transcription model's style and vocabulary. Describe the topic or expected content of your recordings.", + "deleteSegment": "Удалить сегмент", + "discard": "Отклонить", + "dragToReorder": "Drag to reorder", + "endTime": "Конец", + "enterNameFor": "Введите имя для", + "enterSpeakerName": "Введите имя спикера...", + "entireScreen": "Весь экран", + "errorChangingSpeaker": "Ошибка при смене спикера", + "errorOpeningTextEditor": "Ошибка при открытии текстового редактора", + "errorSavingText": "Ошибка при сохранении текста", + "estimatedSize": "Предполагаемый размер", + "firstTagAsrSettings": "First tag's ASR settings will be applied:", + "firstTagDefaultsApplied": "First tag's defaults applied", + "folderHasCustomPrompt": "This folder has a custom summary prompt", + "generatingSummary": "Генерация резюме...", + "groups": "группы", + "howToRecordSystemAudio": "Как записать системный звук", + "identifyAllSpeakers": "Определить всех спикеров", + "identifying": "Определение...", + "importantNote": "Важное примечание", + "internalSharingDesc": "Поделиться с конкретными пользователями в вашей организации", + "lines": "{{count}} строк", + "loadingMore": "Загрузка дополнительных записей...", + "loadingRecordings": "Загрузка записей...", + "me": "Я", + "microphoneDesc": "Записывает только ваш голос", + "modelReasoning": "Рассуждения модели", + "moreSpeakersThanColors": "Спикеров больше, чем доступных цветов", + "navigate": "Навигация", + "noDateSet": "Дата не установлена", + "noMatchingTags": "No matching tags", + "noParticipants": "Нет участников", + "noRecordingSelected": "Запись не выбрана.", + "noSpeakersIdentified": "Не удалось определить спикеров из контекста.", + "noSuggestionsToApply": "Нет предложений для применения", + "noTagsCreated": "Теги ещё не созданы.", + "of": "из", + "playFromHere": "Воспроизвести отсюда", + "pleaseEnterSpeakerName": "Пожалуйста, введите имя спикера", + "processingTime": "Время обработки", + "processingTimeDescription": "Это может занять несколько минут. Вы можете продолжать использовать приложение во время обработки.", + "processingTranscription": "Обработка транскрипции...", + "publicLinkDesc": "Любой, у кого есть эта ссылка, может получить доступ к записи", + "recordSystemSteps1": "Нажмите \"Записать системный звук\" или \"Записать оба\".", + "recordSystemSteps2": "Во всплывающем окне выберите", + "recordSystemSteps3": "Убедитесь, что установлен флажок", + "recordingFinished": "Запись завершена", + "recordingInProgress": "Идёт запись...", + "regenerateSummaryAfterNames": "Перегенерируйте резюме после обновления имён", + "saved": "Сохранено!", + "savingProgress": "Сохранение...", + "selectedTagsCustomPrompts": "Selected tags include custom summary prompts", + "sentence": "Предложение", + "shareSystemAudio": "Поделиться системным звуком", + "shareTabAudio": "Поделиться звуком вкладки", + "sharedOn": "Опубликовано", + "sharingWindowNoAudio": "При демонстрации \"Окна\" звук не захватывается.", + "speakerAdded": "Спикер успешно добавлен", + "speakerCount": "Спикер", + "speakerName": "Имя спикера", + "speakerNamesUpdated": "Имена спикеров успешно обновлены!", + "speakers": "Спикеры", + "speakersIdentified": "{{count}} спикер успешно определён!", + "speakersIdentifiedPlural": "{{count}} спикеров успешно определено!", + "speakersUpdatedSaveToApply": "Спикеры обновлены! Сохраните транскрипцию, чтобы применить изменения.", + "specificBrowserTab": "конкретную вкладку браузера", + "startTime": "Начало", + "startingAutoIdentification": "Запуск автоматического определения спикеров...", + "summaryGenerationFailed": "Ошибка генерации резюме", + "summaryGenerationTimedOut": "Превышено время генерации резюме", + "summaryRegenerationStarted": "Перегенерация резюме запущена", + "summaryUpdated": "Резюме обновлено!", + "systemAudioDesc": "Записывает участников встречи и системные звуки", + "tagManagement": "Управление тегами", + "thisActionCannotBeUndone": "Это действие нельзя отменить.", + "toCaptureAudioFromMeetings": "Чтобы захватить звук со встреч или других приложений, вы должны поделиться экраном или вкладкой браузера.", + "toOrganizeRecordings": "to organize your recordings", + "transcriptUpdated": "Транскрипция успешно обновлена!", + "troubleshooting": "Устранение неполадок", + "tryAdjustingSearch": "Попробуйте скорректировать поиск или", + "unsupportedBrowser": "Неподдерживаемый браузер", + "untitled": "Запись без названия", + "uploadRecordingNotes": "Загрузить запись и заметки", + "whatWillHappen": "Что произойдёт?", + "whyNotWorking": "Почему не работает?", + "youHaveXSpeakers": "У вас {{count}} спикеров, но доступно только 16 уникальных цветов. Цвета будут повторяться после 16-го спикера." + }, + "incognito": { + "audioNotStored": "Audio not stored in incognito mode", + "discardConfirm": "This will permanently discard your incognito recording. Continue?", + "mode": "Incognito Mode", + "notSavedToAccount": "Not saved to account", + "oneFileAtATime": "Incognito mode supports one file at a time", + "processInIncognito": "Process in Incognito", + "processWithoutSaving": "Process without saving", + "processing": "Processing...", + "processingComplete": "Processing complete!", + "processingInProgress": "Processing in incognito mode...", + "recordingDiscarded": "Incognito recording discarded", + "recordingProcessed": "Incognito recording processed - data will be lost when tab closes", + "recordingReady": "Incognito recording ready!", + "recordingTitle": "Incognito Recording", + "selectExactlyOneFile": "Select exactly 1 file", + "sessionOnly": "Session only", + "uploadingFile": "Uploading file for incognito processing..." + }, + "inquire": { + "activeFilters": "Активные фильтры:", + "askQuestions": "Задавайте вопросы о ваших транскрипциях", + "clearAll": "Сбросить всё", + "dateRange": "Диапазон дат", + "dateRangeActive": "Диапазон дат активен", + "exampleQuestion1": "\"Какие задачи к выполнению были обсуждены?\"", + "exampleQuestion2": "\"Когда мы решили изменить сроки?\"", + "exampleQuestion3": "\"Какие опасения были высказаны по поводу бюджета?\"", + "exampleQuestion4": "\"Кто отвечал за маркетинговые задачи?\"", + "exampleQuestions": "Примеры вопросов:", + "filters": "Фильтры", + "filtersActive": "Фильтры активны", + "from": "С", + "noSpeakerData": "Нет данных о спикерах", + "placeholder": "Задавайте вопросы о ваших отфильтрованных транскрипциях...", + "selectFilters": "Выберите фильтры слева, чтобы сузить круг транскрипций, затем задавайте вопросы для получения информации из ваших записей.", + "sendHint": "Нажмите Enter для отправки • Ctrl+Enter для новой строки", + "speakerRequirement": "Идентификация спикеров требует записей с несколькими спикерами", + "speakers": "Спикеры", + "speakersCount": "спикеров", + "tags": "Теги", + "tagsCount": "тегов", + "title": "Inquire", + "to": "По" + }, + "languages": { + "ar": "Арабский", + "de": "Немецкий", + "en": "Английский", + "es": "Испанский", + "fr": "Французский", + "hi": "Хинди", + "it": "Итальянский", + "ja": "Японский", + "ko": "Корейский", + "nl": "Нидерландский", + "pt": "Португальский", + "ru": "Русский", + "zh": "Китайский" + }, + "manageSpeakersModal": { + "created": "Создан", + "description": "Управляйте сохранёнными спикерами. Они сохраняются автоматически при использовании имён спикеров в ваших записях.", + "failedToLoad": "Не удалось загрузить спикеров", + "lastUsed": "Последнее использование", + "loadingSpeakers": "Загрузка спикеров...", + "noSpeakersYet": "Спикеров пока не сохранено", + "speakersSaved": "{{count}} спикеров сохранено", + "speakersWillAppear": "Спикеры появятся здесь, когда вы будете использовать имена спикеров в ваших записях", + "times": "раз", + "title": "Управление спикерами", + "used": "Использовано" + }, + "messages": { + "colorSchemeApplied": "Color scheme applied", + "colorSchemeReset": "Color scheme reset to default", + "copiedSuccessfully": "Copied to clipboard!", + "copiedToClipboard": "Скопировано в буфер обмена", + "copyFailed": "Failed to copy", + "copyNotSupported": "Copy failed. Your browser may not support this feature.", + "downloadStarted": "Загрузка начата", + "errorRecoveringRecording": "Error recovering recording", + "eventDownloadFailed": "Failed to download event", + "eventDownloadSuccess": "Event \"{{title}}\" downloaded. Open the file to add to your calendar.", + "eventsExportFailed": "Failed to export events", + "eventsExportSuccess": "Exported {{count}} events", + "failedToDeleteJob": "Failed to delete job", + "failedToRecoverRecording": "Failed to recover recording", + "failedToRetryJob": "Failed to retry job", + "failedToSave": "Failed to save: {{error}}", + "failedToSaveParticipants": "Failed to save participants", + "followPlayerDisabled": "Follow player mode disabled", + "followPlayerEnabled": "Follow player mode enabled", + "invalidEventData": "Invalid event data", + "jobQueuedForRetry": "Job queued for retry", + "noEventsToExport": "No events to export", + "noNotesAvailableDownload": "No notes available to download.", + "noNotesToCopy": "No notes available to copy.", + "noPermissionToEdit": "You do not have permission to edit this recording", + "noSummaryToCopy": "No summary available to copy.", + "noSummaryToDownload": "No summary available to download.", + "noTranscriptionToCopy": "No transcription available to copy.", + "noTranscriptionToDownload": "No transcription available to download.", + "notesCopied": "Notes copied to clipboard!", + "notesDownloadFailed": "Failed to download notes", + "notesDownloadSuccess": "Notes downloaded successfully!", + "notesUpdated": "Заметки успешно обновлены", + "passwordChanged": "Пароль успешно изменён", + "profileUpdated": "Профиль успешно обновлён", + "recordingDeleted": "Запись успешно удалена", + "recordingDiscarded": "Recording discarded", + "recordingRecovered": "Recording recovered successfully", + "recordingSaved": "Запись успешно сохранена", + "saveParticipantsFailed": "Save failed: {{error}}", + "settingsSaved": "Настройки успешно сохранены", + "summaryCopied": "Summary copied to clipboard!", + "summaryDownloadFailed": "Failed to download summary", + "summaryDownloadSuccess": "Summary downloaded successfully!", + "summaryGenerated": "Резюме успешно сгенерировано", + "tagAdded": "Тег успешно добавлен", + "tagRemoved": "Тег успешно удалён", + "transcriptDownloadFailed": "Failed to download transcript", + "transcriptDownloadSuccess": "Transcript downloaded successfully!", + "transcriptionCopied": "Transcription copied to clipboard!", + "transcriptionUpdated": "Транскрипция успешно обновлена" + }, + "metadata": { + "cancelEdit": "Отмена", + "createdAt": "Создано", + "duration": "Длительность", + "editMetadata": "Редактировать метаданные", + "fileName": "Имя файла", + "fileSize": "Размер файла", + "language": "Язык", + "meetingDate": "Дата встречи", + "processingTime": "Время обработки", + "saveMetadata": "Сохранить", + "status": "Статус", + "title": "Метаданные", + "updatedAt": "Обновлено", + "wordCount": "Количество слов" + }, + "modal": { + "addSpeaker": "Добавить нового спикера", + "colorScheme": "Цветовая схема", + "deleteRecording": "Удалить запись", + "editAsrTranscription": "Редактировать ASR-транскрипцию", + "editParticipants": "Редактировать участников", + "editRecording": "Редактировать запись", + "editSpeakers": "Редактировать спикеров", + "editTags": "Редактировать теги записи", + "editTranscription": "Редактировать транскрипцию", + "identifySpeakers": "Определить спикеров", + "recordingNotice": "Уведомление о записи", + "reprocessSummary": "Перегенерировать резюме", + "reprocessTranscription": "Перегенерировать транскрипцию", + "resetStatus": "Сбросить статус записи?", + "shareRecording": "Поделиться записью", + "sharedTranscripts": "Общие транскрипты", + "systemAudioHelp": "Справка по системному звуку", + "uploadFiles": "Загрузить файлы", + "uploadNotice": "Уведомление о загрузке" + }, + "namingTemplates": { + "addPattern": "Добавить шаблон", + "availableTemplates": "Доступные шаблоны", + "availableVars": "Доступные переменные", + "cancel": "Отмена", + "createDefaults": "Создать стандартные шаблоны", + "createNew": "Создать шаблон", + "customVarsHint": "Определите regex-шаблоны для извлечения пользовательских переменных из имён файлов.", + "delete": "Удалить", + "description": "Определите, как генерируются заголовки записей из имён файлов и содержимого транскрипции.", + "descriptionLabel": "Описание", + "noDefault": "Без умолчания (только ИИ-заголовок)", + "regexHint": "Извлекайте данные из имён файлов. Используйте группы захвата () для указания совпадения.", + "regexPatterns": "Regex-шаблоны (необязательно)", + "result": "Результат:", + "save": "Сохранить", + "selectOrCreate": "Выберите шаблон для редактирования или создайте новый", + "tabTitle": "Именование", + "template": "Шаблон", + "templateName": "Название шаблона", + "test": "Тест", + "testTemplate": "Тестировать шаблон", + "title": "Шаблоны именования", + "userDefault": "Шаблон по умолчанию", + "userDefaultHint": "Применяется, когда ни один тег не имеет шаблона именования." + }, + "nav": { + "account": "Аккаунт", + "accountSettings": "Настройки аккаунта", + "admin": "Администратор", + "adminDashboard": "Панель администратора", + "darkMode": "Тёмная тема", + "groupManagement": "Управление группами", + "home": "Главная", + "language": "Язык", + "lightMode": "Светлая тема", + "newRecording": "Новая запись", + "recording": "Запись", + "settings": "Настройки", + "signOut": "Выйти", + "teamManagement": "Управление группами", + "upload": "Загрузить", + "userProfile": "Профиль пользователя" + }, + "notes": { + "cancelEdit": "Отменить редактирование", + "characterCount": "{{count}} символ", + "characterCountPlural": "{{count}} символов", + "editNotes": "Редактировать заметки", + "lastUpdated": "Последнее обновление", + "placeholder": "Добавьте ваши заметки здесь...", + "saveNotes": "Сохранить заметки", + "title": "Заметки" + }, + "pwa": { + "installApp": "Установить приложение", + "installed": "Успешно установлено", + "installing": "Установка...", + "notificationPermissionDenied": "Доступ к уведомлениям запрещён", + "notificationsEnabled": "Уведомления включены", + "offline": "Вы не в сети", + "screenAwake": "Экран будет оставаться активным во время записи", + "screenAwakeFailed": "Не удалось сохранить экран активным", + "updateAvailable": "Доступно обновление" + }, + "recording": { + "acceptDisclaimer": "Принимаю", + "cancelRecording": "Отмена", + "discardRecovery": "Отклонить", + "disclaimer": "Предупреждение о записи", + "duration": "Длительность", + "micPlusSys": "Mic + Sys", + "microphone": "Микрофон", + "microphoneAndSystem": "Микрофон + система", + "microphonePermissionDenied": "Доступ к микрофону запрещён", + "modeBoth": "Микрофон + система", + "modeMicrophone": "Микрофон", + "modeSystem": "Системный звук", + "notes": "Заметки", + "notesPlaceholder": "Добавьте заметки об этой записи...", + "pauseRecording": "Пауза", + "recordingFailed": "Запись не удалась", + "recordingInProgress": "Идёт запись...", + "recordingMode": "Режим записи", + "recordingSize": "Предполагаемый размер", + "recordingStopped": "Запись остановлена", + "recordingTime": "Время записи", + "recoveryDescription": "Мы обнаружили незавершённую запись из предыдущей сессии. Хотите восстановить её?", + "recoveryFound": "Обнаружена несохранённая запись", + "recoveryTitle": "Восстановить запись", + "restoreRecording": "Восстановить", + "resumeRecording": "Возобновить", + "saveRecording": "Сохранить запись", + "size": "Размер", + "startRecording": "Начать запись", + "startedAt": "Начато в", + "stopRecording": "Остановить запись", + "systemAudio": "Системный звук", + "systemAudioNotSupported": "Запись системного звука не поддерживается в этом браузере", + "title": "Аудиозапись" + }, + "reprocessModal": { + "audioReTranscribedFromScratch": "Аудио будет транскрибировано заново с нуля. Это также перегенерирует заголовок и резюме на основе новой транскрипции.", + "audioReTranscribedWithAsr": "Аудио будет транскрибировано заново с использованием ASR-эндпоинта. Это включает диаризацию и перегенерирует заголовок и резюме.", + "manualEditsOverwritten": "Любые ручные правки транскрипции, заголовка или резюме будут перезаписаны.", + "manualEditsOverwrittenSummary": "Любые ручные правки заголовка или резюме будут перезаписаны.", + "newTitleAndSummary": "Новый заголовок и резюме будут сгенерированы на основе существующей транскрипции." + }, + "settings": { + "apiKeys": "API-ключи", + "appearance": "Внешний вид", + "changePassword": "Изменить пароль", + "dataExport": "Экспорт данных", + "deleteAccount": "Удалить аккаунт", + "integrations": "Интеграции", + "language": "Язык", + "notifications": "Уведомления", + "preferences": "Предпочтения", + "privacy": "Конфиденциальность", + "profile": "Профиль", + "security": "Безопасность", + "theme": "Тема", + "title": "Настройки", + "twoFactorAuth": "Двухфакторная аутентификация" + }, + "sharedTranscripts": { + "noSharedTranscripts": "Вы пока не делились транскриптами.", + "shareNotes": "Поделиться заметками", + "shareSummary": "Поделиться резюме", + "sharedOn": "Опубликовано" + }, + "sharedTranscriptsPage": { + "noSharedTranscripts": "Вы пока не делились транскриптами." + }, + "sharing": { + "canEdit": "Может редактировать", + "canReshare": "Может поделиться повторно", + "internalSharing": "Внутренний общий доступ", + "notSharedYet": "Пока не опубликовано", + "publicBadge": "Публичный", + "publicLink": "Публичная ссылка", + "publicLinks": "публичных ссылок", + "publicLinksGenerated": "публичных ссылок создано", + "searchUsers": "Поиск пользователей...", + "sharedBadge": "Общий", + "sharedBy": "Опубликовал", + "sharedWith": "Доступ есть у", + "teamBadge": "Группа", + "teamRecording": "Групповая запись", + "unknown": "Неизвестно", + "users": "пользователей" + }, + "sidebar": { + "advancedSearch": "Расширенный поиск", + "archived": "Архивировано", + "archivedRecordings": "Архивные записи", + "dateRange": "Диапазон дат", + "filters": "Фильтры", + "highlighted": "Избранное", + "inbox": "Входящие", + "lastMonth": "Прошлый месяц", + "lastWeek": "Прошлая неделя", + "loadMore": "Загрузить ещё", + "markAsRead": "Отметить как прочитанное", + "moveToInbox": "Переместить во входящие", + "noRecordings": "Записей не найдено", + "older": "Более старые", + "removeFromHighlighted": "Убрать из избранного", + "searchRecordings": "Поиск записей...", + "sharedWithMe": "Доступные мне", + "sortBy": "Сортировать по", + "sortByDate": "Дата создания", + "sortByMeetingDate": "Дата встречи", + "starred": "Избранное", + "tags": "Теги", + "thisMonth": "Этот месяц", + "thisWeek": "Эта неделя", + "today": "Сегодня", + "totalRecordings": "{{count}} запись", + "totalRecordingsPlural": "{{count}} записей", + "upcoming": "Предстоящие", + "yesterday": "Вчера" + }, + "speakers": { + "filterBySpeaker": "Фильтр по спикеру", + "noMatchingSpeakers": "Нет подходящих спикеров", + "searchSpeakers": "Поиск..." + }, + "speakersManagement": { + "added": "Добавлен", + "confidence": "Уверенность", + "confidenceHigh": "высокая", + "confidenceLow": "низкая", + "confidenceMedium": "средняя", + "created": "Создан", + "description": "Управляйте сохранёнными спикерами. Они сохраняются автоматически при использовании имён спикеров в ваших записях.", + "failedToLoad": "Не удалось загрузить спикеров", + "failedToLoadSnippets": "Не удалось загрузить фрагменты", + "keepThisSpeaker": "Оставить этого спикера (остальные будут объединены с ним):", + "last": "Последний", + "lastUsed": "Последнее использование", + "loadingSpeakers": "Загрузка спикеров...", + "match": "совпадение", + "mergeDescription": "Объедините несколько профилей спикеров в один. Все эмбеддинги, фрагменты и данные об использовании будут объединены.", + "mergeFailed": "Не удалось объединить спикеров", + "mergeNSpeakers": "Объединить {{count}} спикеров", + "mergeSpeakers": "Объединить спикеров", + "mergeSuccess": "Спикеры успешно объединены", + "noSnippetsAvailable": "Фрагменты недоступны", + "noSpeakersYet": "Спикеров пока не сохранено", + "sample": "образец", + "samples": "образцов", + "selectToMerge": "Выберите 2+ для объединения", + "speakersToMerge": "Спикеры для объединения:", + "speakersWillAppear": "Спикеры появятся здесь, когда вы будете использовать имена спикеров в ваших записях", + "targetWillReceive": "Выбранный спикер получит все голосовые данные и фрагменты от остальных.", + "time": "раз", + "times": "раз", + "totalSpeakers": "спикеров сохранено", + "used": "Использовано", + "usedTimes": "Использовано", + "viewSnippets": "Просмотреть фрагменты", + "voiceMatchSuggestions": "Предложения совпадений голоса", + "voiceProfile": "Голосовой профиль" + }, + "status": { + "completed": "Завершено", + "failed": "Не удалось", + "processing": "Обработка", + "queued": "В очереди", + "stuck": "Сбросить зависшую обработку", + "summarizing": "Резюмирование", + "transcribing": "Транскрибирование", + "uploading": "Загрузка" + }, + "summary": { + "actionItems": "Задачи к выполнению", + "cancelEdit": "Отменить редактирование", + "decisions": "Решения", + "editSummary": "Редактировать резюме", + "generateSummary": "Сгенерировать резюме", + "keyPoints": "Ключевые моменты", + "noSummary": "Резюме недоступно", + "participants": "Участники", + "regenerateSummary": "Перегенерировать резюме", + "saveSummary": "Сохранить резюме", + "summaryFailed": "Не удалось сгенерировать резюме", + "summaryInProgress": "Генерация резюме...", + "title": "Резюме" + }, + "tagManagement": { + "asrDefaults": "Настройки ASR по умолчанию", + "createTag": "Создать тег", + "customPrompt": "Пользовательский запрос", + "description": "Организуйте ваши записи с помощью пользовательских тегов. Каждый тег может иметь свой собственный запрос для резюме и настройки ASR по умолчанию.", + "maxSpeakers": "Макс.", + "minSpeakers": "Мин.", + "noTags": "Вы ещё не создали теги." + }, + "tags": { + "addTag": "Добавить тег", + "clearTagFilter": "Сбросить фильтр", + "createTag": "Создать тег", + "currentTags": "Текущие теги", + "filterByTag": "Фильтр по тегу", + "manageAllTags": "Управлять всеми тегами", + "noAvailableTags": "Нет доступных тегов", + "noMatchingTags": "Нет подходящих тегов", + "noTags": "Нет тегов", + "removeTag": "Удалить тег", + "searchTags": "Поиск...", + "tagColor": "Цвет тега", + "tagName": "Название тега", + "title": "Теги" + }, + "tagsModal": { + "addTags": "Добавить теги", + "currentTags": "Текущие теги", + "done": "Готово", + "noTagsAssigned": "К этой записи не назначены теги", + "searchTags": "Поиск тегов..." + }, + "time": { + "dayAgo": "1 день назад", + "daysAgo": "{{count}} дней назад", + "hourAgo": "1 час назад", + "hoursAgo": "{{count}} часов назад", + "justNow": "Только что", + "minuteAgo": "1 минуту назад", + "minutesAgo": "{{count}} минут назад", + "monthAgo": "1 месяц назад", + "monthsAgo": "{{count}} месяцев назад", + "weekAgo": "1 неделю назад", + "weeksAgo": "{{count}} недель назад", + "yearAgo": "1 год назад", + "yearsAgo": "{{count}} лет назад" + }, + "tooltips": { + "changeSpeaker": "Изменить спикера", + "clearChat": "Очистить чат", + "copyTranscript": "Копировать транскрипт", + "deleteTeam": "Удалить группу", + "doubleClickToEdit": "Двойной щелчок для редактирования", + "downloadTranscriptWithTemplate": "Скачать транскрипт с шаблоном", + "editTeam": "Редактировать группу", + "editText": "Редактировать текст", + "editTitle": "Редактировать заголовок", + "editTranscript": "Редактировать транскрипт", + "exitFullscreen": "Exit fullscreen", + "expand": "Развернуть", + "followPlayerDisabled": "Включить автопрокрутку - транскрипт следует за воспроизведением аудио", + "followPlayerEnabled": "Отключить автопрокрутку - транскрипт остаётся на месте", + "fullscreenVideo": "Fullscreen video", + "grantPublicSharing": "Предоставить разрешение на публичный доступ", + "hideVideo": "Hide video", + "highlight": "Добавить в избранное", + "makeAdmin": "Сделать администратором", + "manageMembers": "Управлять участниками", + "manageTeamTags": "Управлять групповыми тегами", + "markAsRead": "Отметить как прочитанное", + "maximizeChat": "Развернуть чат", + "minimize": "Свернуть", + "moveToInbox": "Переместить во входящие", + "mute": "Без звука", + "pause": "Пауза", + "play": "Воспроизвести", + "playbackSpeed": "Скорость воспроизведения", + "removeAdmin": "Убрать права администратора", + "removeFromQueue": "Удалить из очереди", + "removeFromTeam": "Удалить из группы", + "removeHighlight": "Убрать из избранного", + "reprocessTranscription": "Перегенерировать транскрипцию", + "reprocessWithAsr": "Перегенерировать через ASR", + "restoreChat": "Восстановить чат", + "revokePublicSharing": "Отозвать разрешение на публичный доступ", + "shareWithUsers": "Поделиться с пользователями", + "showVideo": "Show video", + "switchToDarkMode": "Переключить на тёмную тему", + "switchToLightMode": "Переключить на светлую тему", + "unmute": "Включить звук" + }, + "transcriptTemplates": { + "availableTemplates": "Доступные шаблоны", + "availableVars": "Доступные переменные", + "cancel": "Отмена", + "chooseTemplate": "Выберите шаблон...", + "createDefaults": "Создать стандартные шаблоны", + "createNew": "Создать шаблон", + "default": "По умолчанию", + "delete": "Удалить", + "description": "Настройте форматирование транскриптов для скачивания и экспорта.", + "downloadDefault": "Скачать с шаблоном по умолчанию", + "downloadWithoutTemplate": "Скачать без шаблона", + "filters": "Фильтры: |upper для верхнего регистра, |srt для формата временных меток субтитров", + "save": "Сохранить", + "selectOrCreate": "Выберите шаблон для редактирования или создайте новый", + "selectTemplate": "Выберите шаблон", + "setDefault": "Сделать шаблоном по умолчанию", + "tabTitle": "Транскрипт", + "template": "Шаблон", + "templateName": "Название шаблона", + "title": "Шаблоны транскриптов", + "viewGuide": "Посмотреть руководство по шаблонам" + }, + "transcription": { + "autoIdentifySpeakers": "Автоопределение спикеров", + "bubble": "Пузыри", + "cancelEdit": "Отменить редактирование", + "copy": "Копировать", + "copyToClipboard": "Копировать в буфер обмена", + "download": "Скачать", + "downloadTranscript": "Скачать транскрипт", + "edit": "Редактировать", + "editSpeakers": "Редактировать спикеров", + "editTranscription": "Редактировать транскрипцию", + "highlightSearchResults": "Подсветить результаты поиска", + "noTranscription": "Транскрипция недоступна", + "regenerateTranscription": "Перегенерировать транскрипцию", + "saveTranscription": "Сохранить транскрипцию", + "searchInTranscript": "Поиск в транскрипте...", + "simple": "Простой", + "speaker": "Спикер {{number}}", + "speakerLabels": "Метки спикеров", + "title": "Транскрипция", + "unknownSpeaker": "Неизвестный спикер" + }, + "upload": { + "chunking": "Большие файлы будут автоматически разделены на части для обработки", + "completed": "Завершено", + "copies": "копий этого файла", + "dropzone": "Перетащите аудиофайлы сюда или нажмите для выбора", + "duplicateDetected": "Этот файл является дубликатом \"{{existingName}}\" (загружен {{existingDate}})", + "duplicateFile": "Дубликат файла", + "failed": "Не удалось", + "fileExceedsMaxSize": "File \"{{name}}\" exceeds the maximum size of {{size}} MB and was skipped.", + "fileRemovedFromQueue": "File removed from queue", + "filesToUpload": "Files to Upload", + "invalidFileType": "Invalid file type \"{{name}}\". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.", + "maxFileSize": "Максимальный размер файла", + "queued": "В очереди", + "selectFiles": "Выбрать файлы", + "settingsApplyToAll": "Settings apply to all files in this session", + "summarizing": "Резюмирование...", + "supportedFormats": "Поддерживаются форматы MP3, WAV, M4A, MP4, MOV, AVI, AMR и другие", + "title": "Загрузка аудио", + "transcribing": "Транскрибирование...", + "untitled": "Запись без названия", + "uploadNFiles": "Upload {{count}} File(s)", + "uploadProgress": "Прогресс загрузки", + "videoRetained": "Видео сохранено для воспроизведения", + "willAutoSummarize": "Автоматически создаст резюме после транскрибирования" + }, + "uploadProgress": { + "title": "Прогресс загрузки" + } +} \ No newline at end of file diff --git a/static/locales/zh.json b/static/locales/zh.json new file mode 100644 index 0000000..264fac6 --- /dev/null +++ b/static/locales/zh.json @@ -0,0 +1,1656 @@ +{ + "aboutPage": { + "aiSummarization": "AI 摘要生成", + "aiSummarizationDesc": "集成 OpenRouter 和 Ollama,支持自定义提示词", + "asrEnabled": "ASR 已启用", + "asrEndpoint": "ASR 端点", + "audioTranscription": "音频转录", + "audioTranscriptionDesc": "支持 Whisper API 和自定义 ASR,准确率高", + "backend": "后端", + "database": "数据库", + "deployment": "部署", + "dockerDescription": "官方 Docker 镜像", + "dockerHub": "Docker Hub", + "documentation": "文档", + "documentationDescription": "设置指南和用户手册", + "endpoint": "端点", + "frontend": "前端", + "githubDescription": "源代码、问题和发布", + "githubRepository": "GitHub 仓库", + "inquireMode": "查询模式", + "inquireModeDesc": "在所有录音中进行语义搜索", + "interactiveChat": "交互式聊天", + "interactiveChatDesc": "使用 AI 与您的转录内容对话", + "keyFeatures": "主要功能", + "largeLanguageModel": "大语言模型", + "model": "模型", + "projectDescription": "通过 AI 驱动的转录、摘要和交互式聊天功能,将您的音频录音转换为有组织、可搜索的笔记。", + "projectLinks": "项目链接", + "sharingExport": "分享和导出", + "sharingExportDesc": "分享录音并导出为各种格式", + "speakerDiarization": "说话人分离", + "speakerDiarizationDesc": "自动识别和标记不同的说话人", + "speechRecognition": "语音识别", + "systemConfiguration": "系统配置", + "systemInformation": "系统信息", + "tagline": "AI 驱动的音频转录和笔记工具", + "techStack": "技术栈", + "technologyStack": "技术栈", + "title": "关于", + "version": "版本", + "whisperApi": "Whisper API", + "whisperModel": "Whisper 模型" + }, + "aboutPageDetails": { + "aiSummarizationDesc": "集成 OpenRouter 和 Ollama,支持自定义提示词", + "asrEnabled": "ASR 已启用", + "asrEndpoint": "ASR 端点", + "audioTranscriptionDesc": "支持 Whisper API 和自定义 ASR,准确率高", + "backend": "后端", + "database": "数据库", + "deployment": "部署", + "dockerDescription": "官方 Docker 镜像", + "dockerHub": "Docker Hub", + "documentation": "文档", + "documentationDescription": "设置指南和用户手册", + "endpoint": "端点", + "frontend": "前端", + "githubDescription": "源代码、问题和发布", + "githubRepository": "GitHub 仓库", + "inquireModeDesc": "在所有录音中进行语义搜索", + "interactiveChatDesc": "使用 AI 与您的转录内容对话", + "largeLanguageModel": "大语言模型", + "model": "模型", + "no": "否", + "projectDescription": "通过 AI 驱动的转录、摘要和交互式聊天功能,将您的音频录音转换为有组织、可搜索的笔记。", + "sharingExportDesc": "分享录音并导出为各种格式", + "speakerDiarizationDesc": "自动识别和标记不同的说话人", + "whisperApi": "Whisper API", + "whisperModel": "Whisper 模型", + "yes": "是" + }, + "account": { + "about": "关于", + "accountActions": "账户操作", + "accountSettings": "账户设置", + "autoLabel": "Auto-label", + "autoSummarizationDisabled": "Auto-summarization disabled by admin", + "autoSummarize": "Auto-summarize", + "changePassword": "更改密码", + "chooseLanguageForInterface": "选择应用程序界面语言", + "companyOrganization": "公司/组织", + "completedRecordings": "已完成", + "customPrompts": "自定义提示", + "defaultHotwords": "Default Hotwords", + "defaultHotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote, SDRs", + "defaultInitialPrompt": "Default Initial Prompt", + "defaultInitialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools. The speakers discuss CTranslate2, PyAnnote, and SDRs.", + "email": "邮箱", + "failedRecordings": "失败", + "fullName": "全名", + "goToRecordings": "前往录音", + "interfaceLanguage": "界面语言", + "jobTitle": "职位", + "languageForSummaries": "标题、摘要和聊天的语言。留空使用默认值(您所选模型的默认行为)", + "languagePreferences": "语言偏好", + "leaveBlankForAutoDetect": "留空以自动检测语言", + "manageSpeakers": "管理发言人", + "mySharedTranscripts": "我的共享转录", + "name": "姓名", + "outputLanguage": "输出语言", + "personalFolder": "Personal Folder (Not Associated with a Group)", + "personalInfo": "个人信息", + "personalTag": "Personal Tag (Not Associated with a Group)", + "preferredOutputLanguage": "聊天机器人和摘要的首选语言", + "processingRecordings": "处理中", + "profile": "个人资料", + "saveAllPreferences": "保存所有偏好设置", + "sharedTranscripts": "共享转录", + "ssoLinkAccount": "Link {{provider}} account", + "ssoLinked": "Linked", + "ssoNotLinked": "Not linked", + "ssoProvider": "Provider", + "ssoSetPasswordFirst": "To unlink SSO, you must first set a password.", + "ssoSubject": "Subject:", + "ssoUnlinkAccount": "Unlink {{provider}} account", + "ssoUnlinkConfirm": "Are you sure you want to unlink your SSO account? You will need to use your password to log in.", + "statistics": "账户统计", + "tagManagement": "标签管理", + "title": "账户信息", + "totalRecordings": "录音总数", + "transcriptionHints": "Transcription Hints", + "transcriptionHintsDesc": "These defaults are used when no tag or folder overrides are set. They help improve transcription accuracy for your specific use case.", + "transcriptionLanguage": "转录语言", + "userDetails": "用户详情", + "username": "用户名" + }, + "accountTabs": { + "about": "关于", + "apiTokens": "API 令牌", + "customPrompts": "自定义提示", + "folderManagement": "文件夹管理", + "help": "帮助", + "namingTemplates": "命名", + "promptOptions": "提示选项", + "sharedTranscripts": "共享转录", + "speakersManagement": "发言人管理", + "tagManagement": "标签管理", + "templates": "模板", + "transcriptTemplates": "转录模板" + }, + "admin": { + "admin": "管理员", + "adminPanel": "管理面板", + "colorScheme": "配色方案", + "logout": "退出登录", + "mySharedTranscripts": "我的共享转录", + "settings": "设置", + "tagManagement": "标签管理", + "title": "管理员", + "userMenu": "用户菜单" + }, + "adminDashboard": { + "aboutInquireMode": "关于查询模式", + "actions": "操作", + "addNewUser": "添加新用户", + "addUser": "添加用户", + "additionalContext": "附加上下文", + "admin": "管理员", + "adminDefaultPrompt": "管理员默认提示", + "adminDefaultPromptDesc": "上面配置的提示(显示在此页面)", + "adminUser": "管理员用户", + "allJobsCompleted": "所有任务已完成", + "allRecordingsProcessed": "所有录音已处理完成!", + "allowed": "Allowed", + "asrConfigurationTitle": "ASR 配置", + "asrTimeout": "ASR 超时", + "asrTimeoutSeconds": "ASR 超时(秒)", + "asrTimeoutSecondsDesc": "ASR 处理的最大超时时间(秒)", + "available": "可用", + "blackHoleDirectory": "黑洞目录", + "blocked": "已阻止", + "budgetWarnings": "预算警告", + "buildVectorStore": "构建向量存储", + "buildingVectorStore": "构建向量存储", + "cancelAll": "取消全部", + "characters": "字符", + "charactersWithLimit": "{{count}} 字符", + "chunkSize": "块大小", + "clearBlackHoleJobs": "清除黑洞任务", + "complete": "完成", + "completedRecordings": "已完成", + "confirmDeleteUser": "确认删除用户", + "confirmDeleteUserMessage": "您确定要删除用户 {{username}} 吗?", + "confirmPassword": "确认密码", + "confirmPasswordLabel": "确认密码", + "contextNotes": { + "jsonConversion": "JSON 转录在发送前会转换为纯文本格式", + "modelConfig": "使用的模型通过 TEXT_MODEL_NAME 环境变量配置", + "tagPrompts": "如果存在多个标签提示,它们将按添加标签的顺序合并", + "transcriptLimit": "转录限制为可配置的字符数(默认:30,000)" + }, + "createFirstGroup": "Create Your First Group", + "created": "Created", + "currentUsage": "当前使用量", + "currentUsageMinutes": "当前使用量", + "customPrompts": "自定义提示", + "defaultPrompt": "默认提示", + "defaultPromptInfo": "此默认提示将用于所有未在账户设置中设置自定义提示的用户。", + "defaultPrompts": "默认提示", + "defaultSummarizationPrompt": "默认摘要提示", + "defaultSummaryPrompt": "默认摘要提示", + "deleteUser": "删除用户", + "description": "描述", + "editPrompt": "编辑提示", + "editSetting": "编辑设置", + "editUser": "编辑用户", + "email": "邮箱", + "emailLabel": "邮箱", + "embeddingModel": "嵌入模型", + "embeddingsInfo": "嵌入信息", + "embeddingsStatus": "嵌入状态", + "enableBlackHole": "启用黑洞", + "errors": { + "failedToLoadPrompt": "加载默认提示失败", + "failedToSavePrompt": "保存默认提示失败,请重试。", + "invalidFileSize": "请输入 1 到 10000 MB 之间的有效大小", + "invalidInteger": "请输入有效的整数", + "invalidNumber": "请输入有效的数字", + "maxTimeout": "超时不能超过 10 小时(36000 秒)", + "minCharacters": "请输入至少 1000 个字符", + "minTimeout": "超时必须至少为 60 秒" + }, + "failedJobs": "失败的任务", + "failedRecordings": "失败", + "groupName": "Group Name", + "groupsTab": "Groups", + "id": "ID", + "inquireMode": "查询模式", + "inquireModeDescription": "查询模式允许用户使用自然语言问题搜索多个转录。它通过将转录分成块并使用 AI 模型创建可搜索的嵌入来工作。", + "inquireModeSettings": "查询模式设置", + "isActive": "已激活", + "isAdmin": "是管理员", + "jobStatus": "任务状态", + "languagePreferenceNote": "注意:如果用户设置了输出语言偏好,将添加\"确保您的回复使用 {language}\"。", + "lastUpdated": "最后更新", + "llmPromptTemplate": "LLM 提示模板", + "loadingInquireStatus": "加载查询状态...", + "maxFileSizeDesc": "最大文件大小(MB)", + "maxFileSizeHelp": "最大文件大小(兆字节)(1-10000 MB)", + "maxFileSizeMb": "最大文件大小 MB", + "megabytes": "MB", + "members": "Members", + "membersCount": "members", + "minutes": "分钟", + "monitorBlackHoleDirectory": "监控黑洞目录", + "monthlyCost": "月度成本", + "monthlyTokenBudget": "每月令牌预算", + "monthlyTranscriptionBudget": "每月转录预算(分钟)", + "name": "名称", + "needProcessing": "需要处理", + "never": "从未", + "newPasswordLabel": "新密码(留空保持不变)", + "no": "否", + "noData": "无数据", + "noDescription": "No description", + "noDescriptionAvailable": "无描述", + "noGroupsAdmin": "You are not an admin of any groups yet", + "noGroupsCreated": "No groups created yet", + "noLimit": "无限制", + "noMembersYet": "No members yet", + "noTranscriptionData": "暂无转录使用数据", + "none": "无", + "notSet": "未设置", + "overlap": "重叠", + "password": "密码", + "passwordLabel": "密码", + "passwordsDoNotMatch": "Passwords do not match", + "pendingJobs": "待处理任务", + "pendingRecordings": "待处理", + "percentUsed": "已使用", + "placeholdersNote": "处理录音时,占位符会被实际值替换。", + "processAllRecordings": "处理所有录音", + "processNext10": "处理接下来的 10 个", + "processedForInquire": "已为查询处理", + "processing": "处理中...", + "processingActions": "处理操作", + "processingJobs": "处理中任务", + "processingProgress": "处理进度", + "processingRecordings": "处理中", + "promptDescription": "当用户未设置自己的提示时,此提示将用于生成所有录音的摘要。", + "promptHierarchy": "提示优先级", + "promptPriorityDescription": "系统在选择使用哪个提示时使用以下优先级顺序:", + "promptResetMessage": "提示已重置为系统默认值。点击\"保存更改\"应用。", + "promptSavedSuccessfully": "默认提示保存成功!", + "publicShare": "Public Share", + "rebuildVectorStore": "重建向量存储", + "recordingDisclaimer": "录音免责声明", + "recordingStatusDistribution": "录音状态分布", + "recordings": "录音", + "recordingsNeedProcessing": "{{count}} 个录音需要处理", + "recordingsProcessed": "{{count}} 个录音已处理", + "refreshStatus": "刷新状态", + "resetToDefault": "重置为默认", + "saveDefaultPrompt": "保存默认提示", + "saveInquireSettings": "保存查询设置", + "saveSetting": "保存设置", + "saveUser": "保存用户", + "saving": "保存中...", + "searchUsers": "搜索用户...", + "seconds": "秒", + "setting": "设置", + "settings": { + "asrTimeoutDesc": "等待 ASR 转录完成的最大时间(秒)。默认为 1800 秒(30 分钟)。", + "defaultSummaryPromptDesc": "用户未设置自己的提示时使用的默认摘要提示。这作为所有用户的基础提示。", + "maxFileSizeDesc": "允许的音频上传最大文件大小(兆字节)。", + "recordingDisclaimerDesc": "录音开始前向用户显示的法律免责声明。支持 Markdown 格式。留空以禁用。", + "uploadDisclaimerDesc": "文件上传前显示的法律免责声明。支持 Markdown。留空以禁用。", + "customBannerDesc": "页面顶部显示的自定义横幅。支持 Markdown。留空以禁用。", + "transcriptLengthLimitDesc": "从转录发送到 LLM 进行摘要和聊天的最大字符数。使用 -1 表示无限制。" + }, + "settingsManagement": "设置管理", + "storageUsed": "已用存储", + "summarizationInstructions": "摘要说明", + "systemFallback": "系统回退", + "systemFallbackDesc": "如果以上都未设置,则使用硬编码默认值", + "systemPrompt": "系统提示", + "systemSettings": "系统设置", + "systemStatistics": "系统统计", + "tagCustomPrompt": "标签自定义提示", + "tagCustomPromptDesc": "如果录音有带自定义提示的标签", + "textSearchOnly": "仅文本搜索", + "thisMonth": "本月", + "timeoutRecommendation": "推荐:长音频文件使用 30-120 分钟", + "title": "管理仪表板", + "todaysMinutes": "今日分钟数", + "tokenBudgetExceeded": "每月令牌预算已超出。请联系您的管理员。", + "tokenBudgetHelp": "设置 AI 功能的每月令牌限制。留空表示无限制。", + "tokenBudgetPlaceholder": "留空表示无限制", + "tokenUsage": "令牌使用量", + "tokens": "令牌", + "topUsers": "顶级用户", + "topUsersByStorage": "按存储排序的顶级用户", + "total": "总计", + "totalChunks": "总块数", + "totalQueries": "总查询数", + "totalRecordings": "总录音数", + "totalStorage": "总存储", + "totalUsers": "总用户数", + "transcriptLengthLimit": "转录长度限制", + "transcriptLengthLimitDesc": "转录的最大字符数限制", + "transcriptionBudgetHelp": "限制每月转录分钟数。留空表示无限制。", + "transcriptionBudgetPlaceholder": "留空表示无限制", + "transcriptionUsage": "转录使用情况", + "updateUser": "更新用户", + "userCustomPrompt": "用户自定义提示", + "userCustomPromptDesc": "如果用户在账户设置中设置了自己的提示", + "userManagement": "用户管理", + "userMessageTemplate": "用户消息模板", + "userTranscriptionUsage": "用户转录使用情况(本月)", + "username": "用户名", + "usernameLabel": "用户名", + "users": "用户", + "value": "值", + "vectorDimensions": "向量维度", + "vectorStore": "向量存储", + "vectorStoreInfo": "向量存储信息", + "vectorStoreManagement": "向量存储管理", + "vectorStoreStatus": "向量存储状态", + "vectorStoreUpToDate": "向量存储是最新的", + "viewFullPromptStructure": "查看完整的 LLM 提示结构", + "warning": "警告", + "yes": "是" + }, + "apiTokens": { + "activeTokens": "活跃令牌", + "createFirstToken": "创建您的第一个 API 令牌以启用编程访问", + "createToken": "创建令牌", + "created": "创建于", + "description": "创建和管理 API 令牌以编程方式访问您的账户", + "expires": "过期时间", + "lastUsed": "最后使用", + "neverUsed": "从未使用", + "noExpiration": "永不过期", + "noTokens": "没有 API 令牌", + "securityNotice": "安全提示", + "securityWarning": "请像密码一样对待 API 令牌。它们提供对您账户的完全访问权限。切勿在公共区域分享令牌", + "title": "API 令牌", + "usageExamples": "使用示例" + }, + "buttons": { + "addSegment": "添加片段", + "addSpeaker": "添加发言人", + "cancel": "取消", + "clearAllFilters": "清除所有筛选", + "clearCompleted": "清除已完成的上传", + "clearSearchText": "清除搜索文本", + "close": "关闭", + "copy": "复制", + "copyMessage": "复制消息", + "copyNotes": "复制笔记", + "copySummary": "复制摘要", + "copyToClipboard": "复制到剪贴板", + "createTag": "创建标签", + "deleteAll": "删除全部", + "deleteSpeaker": "删除发言人", + "deleteTag": "删除标签", + "deleteUser": "删除用户", + "downloadAsWord": "下载为Word文档", + "downloadAudio": "下载音频", + "downloadChat": "下载聊天记录为Word文档", + "downloadNotes": "下载笔记为Word文档", + "downloadSummary": "下载摘要为Word文档", + "editNotes": "编辑笔记", + "editSetting": "编辑设置", + "editSpeakers": "编辑发言人...", + "editSummary": "编辑摘要", + "editTag": "编辑标签", + "editTags": "编辑标签", + "editTranscription": "编辑转录", + "editUser": "编辑用户", + "exportCalendar": "导出到日历", + "help": "帮助", + "identifySpeakers": "识别发言人", + "next": "下一个", + "previous": "上一个", + "refresh": "刷新", + "reprocessSummary": "重新处理摘要", + "reprocessTranscription": "重新处理转录", + "reprocessWithAsr": "使用ASR重新处理", + "resetStuckProcessing": "重置卡住的处理", + "saveChanges": "保存更改", + "saveCustomPrompt": "保存自定义提示", + "saveNames": "保存名称", + "saveSettings": "保存设置", + "shareRecording": "分享录音", + "toggleTheme": "切换主题", + "updateTag": "更新标签" + }, + "changePasswordModal": { + "confirmPassword": "确认新密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "passwordRequirement": "密码必须至少 8 个字符", + "title": "更改密码" + }, + "chat": { + "availableAfterTranscription": "转录完成后聊天功能将可用", + "cannotChatTranscriptionFailed": "Cannot chat: transcription failed. Please reprocess the transcription first.", + "chatWithTranscription": "与转录内容对话", + "clearChat": "清除聊天", + "cleared": "Chat cleared", + "downloadFailed": "Failed to download chat", + "downloadSuccess": "Chat downloaded successfully!", + "error": "发送消息失败", + "noMessages": "暂无消息", + "noMessagesToDownload": "No chat messages to download.", + "placeholder": "询问有关此录音的问题...", + "placeholderWithHint": "询问有关此录音的问题...(按回车发送,Ctrl+回车换行)", + "send": "发送", + "suggestedQuestions": "建议问题", + "thinking": "思考中...", + "title": "聊天", + "whatAreActionItems": "有哪些行动项?", + "whatAreKeyPoints": "关键要点是什么?", + "whatWasDiscussed": "讨论了什么?", + "whoSaidWhat": "谁说了什么?" + }, + "colorScheme": { + "chooseRecording": "从侧边栏选择一个录音以查看其转录和摘要", + "darkThemes": "深色主题", + "descriptions": { + "blue": "Classic blue theme with a clean, professional feel", + "emerald": "Nature-inspired green theme for a calming experience", + "purple": "Rich purple theme with an elegant, modern look", + "rose": "Warm rose theme with a soft, inviting aesthetic", + "amber": "Warm amber tones for a cozy, productive feel", + "teal": "Cool teal theme with a refreshing, modern vibe" + }, + "lightThemes": "浅色主题", + "names": { + "blue": "Ocean Blue", + "emerald": "Forest Green", + "purple": "Royal Purple", + "rose": "Coral Rose", + "amber": "Golden Amber", + "teal": "Arctic Teal" + }, + "resetToDefault": "重置为默认", + "selectRecording": "选择录音", + "subtitle": "使用美观的颜色主题自定义您的界面", + "themes": { + "light": { + "blue": { + "name": "海洋蓝", + "description": "专业风格的经典蓝色主题" + }, + "emerald": { + "name": "森林翠绿", + "description": "清新自然的绿色主题" + }, + "purple": { + "name": "皇家紫", + "description": "优雅精致的紫色主题" + }, + "rose": { + "name": "日落玫瑰", + "description": "温暖柔和的粉色主题" + }, + "amber": { + "name": "金色琥珀", + "description": "明亮温暖的黄色主题" + }, + "teal": { + "name": "海洋青", + "description": "清凉宁静的青色主题" + } + }, + "dark": { + "blue": { + "name": "午夜蓝", + "description": "专注工作的深蓝主题" + }, + "emerald": { + "name": "暗夜森林", + "description": "舒适观看的浓绿主题" + }, + "purple": { + "name": "深紫", + "description": "激发创意的神秘紫色主题" + }, + "rose": { + "name": "暗玫瑰", + "description": "微妙温暖的柔粉主题" + }, + "amber": { + "name": "暗琥珀", + "description": "舒适温馨的棕色主题" + }, + "teal": { + "name": "深青", + "description": "平静专注的深青主题" + } + } + }, + "title": "选择配色方案" + }, + "common": { + "back": "返回", + "cancel": "取消", + "changesSaved": "更改已保存", + "close": "关闭", + "confirm": "确认", + "delete": "删除", + "deselectAll": "取消全选", + "download": "下载", + "edit": "编辑", + "error": "错误", + "failed": "失败", + "filter": "筛选", + "info": "信息", + "loading": "加载中...", + "new": "新建", + "next": "下一步", + "no": "否", + "noResults": "未找到结果", + "ok": "确定", + "or": "或", + "previous": "上一步", + "processing": "处理中...", + "refresh": "刷新", + "retry": "重试", + "save": "保存", + "search": "搜索", + "selectAll": "全选", + "sort": "排序", + "success": "成功", + "untitled": "无标题", + "upload": "上传", + "warning": "警告", + "yes": "是" + }, + "customPrompts": { + "currentDefaultPrompt": "当前默认提示(如果上面留空则使用此提示)", + "promptDescription": "此提示将用于生成转录的摘要。它将覆盖管理员的默认提示。", + "promptPlaceholder": "描述您希望摘要的结构。留空以使用管理员的默认提示。", + "summaryGeneration": "摘要生成提示", + "tip1": "具体说明您希望在摘要中包含的部分", + "tip2": "使用清晰的格式说明(例如,\"使用项目符号\",\"创建编号列表\")", + "tip3": "指定您是否希望优先处理某些信息", + "tip4": "系统将自动向 AI 提供转录内容", + "tip5": "您的输出语言偏好(如果设置)将自动应用", + "tipsTitle": "编写有效提示的技巧", + "yourCustomPrompt": "您的自定义摘要提示" + }, + "deleteAllSpeakersModal": { + "confirmMessage": "您确定要删除所有保存的发言人吗?此操作无法撤销。", + "title": "删除所有发言人" + }, + "dialogs": { + "deleteRecording": { + "cancel": "取消", + "confirm": "删除", + "message": "您确定要删除此录音吗?此操作无法撤销。", + "title": "删除录音" + }, + "deleteShare": { + "message": "您确定要删除此共享吗?这将撤销对公共链接的访问。", + "title": "删除共享" + }, + "reprocessTranscription": { + "cancel": "取消", + "confirm": "重新处理", + "message": "您确定要重新处理此转录吗?当前转录将被替换。", + "title": "重新处理转录" + }, + "unsavedChanges": { + "cancel": "取消", + "discard": "放弃", + "message": "您有未保存的更改。在离开之前是否要保存?", + "save": "保存", + "title": "未保存的更改" + } + }, + "duration": { + "hours": "{{count}} 小时", + "hoursPlural": "{{count}} 小时", + "minutes": "{{count}} 分钟", + "minutesPlural": "{{count}} 分钟", + "seconds": "{{count}} 秒", + "secondsPlural": "{{count}} 秒" + }, + "editTagModal": { + "asrDefaultSettings": "ASR 默认设置", + "asrSettingsDescription": "使用此标签进行 ASR 转录时,这些设置将默认应用", + "color": "颜色", + "colorDescription": "选择颜色以便识别", + "createTitle": "创建标签", + "customPrompt": "自定义摘要提示", + "customPromptPlaceholder": "可选:为带有此标签的录音生成摘要的自定义提示", + "defaultLanguage": "默认语言", + "defaultPromptPlaceholder": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden", + "exportTemplate": "导出模板", + "exportTemplateHint": "选择导出带有此标签的录音时使用的导出模板", + "leaveBlankPrompt": "留空以使用您的默认摘要提示", + "maxSpeakers": "最大发言人数", + "minSpeakers": "最小发言人数", + "namingTemplate": "命名模板", + "namingTemplateHint": "选择命名模板以自动格式化带有此标签的录音标题", + "noExportTemplate": "无模板(使用用户默认)", + "noNamingTemplate": "无模板(使用用户默认或 AI 标题)", + "tagName": "标签名称 *", + "tagNamePlaceholder": "例如,会议、访谈", + "title": "编辑标签" + }, + "errors": { + "audioExtractionFailed": "Audio Extraction Failed", + "audioExtractionFailedGuidance": "Try converting the file to a standard audio format (MP3, WAV) before uploading.", + "audioExtractionFailedMessage": "Could not extract audio from the uploaded file.", + "audioRecordingFailed": "音频录制失败。请检查您的麦克风。", + "authenticationError": "Authentication Error", + "authenticationErrorGuidance": "Please check that the API key is correct and has not expired.", + "authenticationErrorMessage": "The transcription service rejected the API credentials.", + "checkApiKeyGuidance": "Check the API key in settings", + "checkNetworkGuidance": "Check network connection", + "connectionError": "Connection Error", + "connectionErrorGuidance": "Check your internet connection and ensure the service is available.", + "connectionErrorMessage": "Could not connect to the transcription service.", + "convertFormatGuidance": "Convert to MP3 or WAV before uploading", + "convertStandardGuidance": "Convert to standard audio format", + "enableChunkingGuidance": "Enable chunking in settings or compress the file", + "fallbackMessage": "An error occurred", + "fallbackTitle": "Error", + "fileTooLarge": "文件太大", + "fileTooLargeGuidance": "Try enabling audio chunking in your settings, or compress the audio file before uploading.", + "fileTooLargeMaxSize": "File too large. Max: {{size}} MB.", + "fileTooLargeMessage": "The audio file exceeds the maximum size allowed by the transcription service.", + "fileTooLargeTitle": "File Too Large", + "generic": "发生错误", + "invalidAudioFormat": "Invalid Audio Format", + "invalidAudioFormatGuidance": "Try converting the audio to MP3 or WAV format before uploading.", + "invalidAudioFormatMessage": "The audio file format is not supported or the file may be corrupted.", + "loadingShares": "加载共享时出错", + "networkError": "网络错误。请检查您的连接。", + "networkErrorDuringUpload": "Network error during upload", + "notFound": "未找到", + "permissionDenied": "权限被拒绝", + "processingError": "Processing Error", + "processingErrorFallbackGuidance": "Try reprocessing the recording", + "processingErrorGuidance": "If this error persists, try reprocessing the recording.", + "processingErrorMessage": "An error occurred during processing.", + "processingFailedOnServer": "Processing failed on server.", + "processingFailedWithStatus": "Processing failed with status {{status}}", + "processingTimeout": "Processing Timeout", + "processingTimeoutGuidance": "This can happen with very long recordings. Try splitting the audio into smaller parts.", + "processingTimeoutMessage": "The transcription took too long to complete.", + "quotaExceeded": "存储配额已超出", + "rateLimitExceeded": "Rate Limit Exceeded", + "rateLimitExceededGuidance": "Please wait a few minutes and try reprocessing.", + "rateLimitExceededMessage": "Too many requests were sent to the transcription service.", + "serverError": "服务器错误。请稍后重试。", + "serverErrorStatus": "Server error ({{status}})", + "serviceUnavailable": "Service Unavailable", + "serviceUnavailableGuidance": "This is usually temporary. Please try again in a few minutes.", + "serviceUnavailableMessage": "The transcription service is temporarily unavailable.", + "splitAudioGuidance": "Try splitting the audio into smaller parts", + "summaryFailed": "摘要生成失败。请重试。", + "transcriptionFailed": "转录失败。请重试。", + "tryAgainLaterGuidance": "Try again in a few minutes", + "unauthorized": "未授权", + "unexpectedResponse": "Unexpected success response from server after upload.", + "unsupportedFormat": "不支持的文件格式", + "uploadFailed": "上传失败。请重试。", + "uploadFailedWithStatus": "Upload failed with status {{status}}", + "uploadTimedOut": "Upload timed out", + "validationError": "验证错误", + "waitAndRetryGuidance": "Wait a few minutes and try again" + }, + "eventExtraction": { + "description": "启用后,AI将识别录音中提到的会议、约会和截止日期,并创建可下载的日历事件。", + "enableLabel": "启用从转录中自动提取事件", + "info": "提取的事件将显示在检测到日历项目的录音的\"事件\"选项卡中。", + "title": "事件提取" + }, + "events": { + "add": "添加", + "addToCalendar": "添加到日历", + "attendees": "参与者", + "confirmDelete": "删除事件“{title}”?", + "delete": "删除", + "deleteFailed": "删除事件失败", + "deleted": "事件已删除", + "end": "结束", + "location": "地点", + "noEvents": "此录音中未检测到事件", + "start": "开始", + "title": "事件" + }, + "exportLabels": { + "created": "创建时间", + "date": "日期", + "fileSize": "文件大小", + "footer": "由 [DictIA](https://gitea.innova-ai.ca/Innova-AI/dictia) 生成", + "metadata": "元数据", + "notes": "笔记", + "originalFile": "原始文件", + "participants": "参与者", + "summarizationTime": "摘要时间", + "summary": "摘要", + "tags": "标签", + "transcription": "转录", + "transcriptionTime": "转录时间" + }, + "exportTemplates": { + "availableLabels": "本地化标签(自动翻译)", + "availableTemplates": "可用模板", + "availableVars": "可用变量", + "cancel": "取消", + "conditionals": "条件", + "conditionalsHint": "使用 {{#if variable}}...{{/if}} 来有条件地包含内容", + "contentSections": "内容部分", + "createDefaults": "创建默认模板", + "createNew": "创建模板", + "default": "默认", + "delete": "删除", + "description": "自定义录音导出为 markdown 文件的方式。", + "recordingData": "录音数据", + "save": "保存", + "selectOrCreate": "选择一个模板进行编辑或创建新模板", + "setDefault": "设为默认模板", + "tabTitle": "导出", + "template": "模板", + "templateDescription": "描述", + "templateName": "模板名称", + "title": "导出模板", + "viewGuide": "查看模板指南" + }, + "fileSize": { + "bytes": "{{count}} B", + "gigabytes": "{{count}} GB", + "kilobytes": "{{count}} KB", + "megabytes": "{{count}} MB" + }, + "folderManagement": { + "allFolders": "所有文件夹", + "asrDefaults": "ASR 默认设置", + "autoShareOnApply": "自动与群组成员共享", + "autoShareOnApplyHelp": "添加到此文件夹时自动与所有群组成员共享录音", + "confirmDelete": "确定要删除此文件夹吗?录音将从文件夹中移除但不会被删除。", + "createFolder": "创建文件夹", + "customPrompt": "自定义提示词", + "defaultLanguage": "默认语言", + "deleteFolder": "删除文件夹", + "description": "将录音整理到文件夹中。与标签不同,录音只能属于一个文件夹。文件夹提示词在用户提示词之前、标签提示词之后应用。", + "editFolder": "编辑文件夹", + "filterByFolder": "按文件夹筛选", + "folderColor": "文件夹颜色", + "folderName": "文件夹名称", + "groupSettings": "群组设置", + "maxSpeakers": "最大发言人数", + "minSpeakers": "最小发言人数", + "moveToFolder": "移动到文件夹", + "namingTemplate": "命名模板", + "noFolder": "无文件夹", + "noFolders": "尚未创建文件夹", + "noFoldersDescription": "创建您的第一个文件夹来整理录音", + "protectFromDeletion": "防止删除", + "protectFromDeletionHelp": "保护此文件夹中的录音不被自动删除", + "recordings": "录音", + "removeFromFolder": "从文件夹中移除", + "retentionDays": "保留天数", + "retentionDaysHelp": "此文件夹中的录音将在指定天数后删除。留空使用全局保留设置。", + "retentionSettings": "保留设置", + "selectNamingTemplate": "选择命名模板...", + "shareWithGroupLead": "与群组管理员共享", + "shareWithGroupLeadHelp": "添加到此文件夹时与群组管理员共享录音", + "title": "文件夹管理" + }, + "form": { + "auto": "自动", + "autoDetect": "自动检测", + "dateFrom": "从", + "dateTo": "至", + "enterNotesMarkdown": "以Markdown格式输入笔记...", + "enterSummaryMarkdown": "以Markdown格式输入摘要...", + "folder": "Folder", + "hotwords": "Hotwords", + "hotwordsHelp": "Comma-separated words to improve recognition of domain-specific terms", + "hotwordsPlaceholder": "e.g., Speakr, CTranslate2, PyAnnote", + "initialPrompt": "Initial Prompt", + "initialPromptHelp": "Context to steer the transcription model's style and vocabulary", + "initialPromptPlaceholder": "e.g., This is a meeting about AI transcription tools.", + "language": "语言", + "maxSpeakers": "最多发言人数", + "meetingDate": "会议日期", + "minSpeakers": "最少发言人数", + "minutes": "分钟", + "notes": "笔记", + "notesPlaceholder": "以Markdown格式输入您的笔记...", + "optional": "可选", + "participantNamePlaceholder": "Participant name...", + "participants": "参与者", + "placeholderAuto": "自动", + "placeholderCharacterLimit": "输入字符限制(例如:30000)", + "placeholderMinutes": "分钟", + "placeholderOptional": "可选", + "placeholderSeconds": "秒", + "placeholderSizeMB": "输入大小(MB)", + "searchSpeakers": "搜索发言人...", + "searchTags": "搜索标签...", + "seconds": "秒", + "shareNotes": "分享笔记", + "shareSummary": "分享摘要", + "shareableLink": "可分享链接", + "summaryPromptPlaceholder": "输入默认摘要提示...", + "title": "标题", + "transcriptionLanguage": "Transcription Language", + "yourFullName": "您的全名" + }, + "groups": { + "addMembers": "添加成员...", + "autoShare": "自动分享", + "autoShareNote": "注意:如果两者都启用,所有团队成员都将有访问权限。如果只启用\"团队管理员\",则只有团队负责人有访问权限。", + "autoShareTeam": "应用此标签时自动与所有团队成员分享录音", + "autoSharesWithTeam": "自动与所有团队成员分享", + "confirmDelete": "确定要删除此团队吗?此操作无法撤销。", + "createTeam": "创建团队", + "createTeamTag": "创建新团队标签", + "dayRetention": "天保留期限", + "deleteTeam": "删除团队", + "deletionExempt": "免于删除", + "deletionExemptHelp": "带有此标签的录音将免于自动删除,即使超过保留期限。", + "editTeam": "编辑团队", + "editTeamTag": "编辑团队标签", + "globalRetention": "全局保留期限", + "members": "成员", + "noMembers": "此团队暂无成员", + "noTeamTags": "尚未创建团队标签", + "noTeams": "尚未创建团队", + "retentionHelp": "带有此标签的录音将在此天数后删除。留空则使用全局保留期限({{days}}天)。", + "retentionPeriod": "保留期限(天)", + "retentionPlaceholder": "留空则使用全局保留期限", + "searchUsers": "搜索用户...", + "selectTeamLead": "选择团队负责人...", + "shareWithAdmins": "应用此标签时与团队管理员分享录音", + "sharesWithAdminsOnly": "仅与团队管理员分享", + "syncComplete": "团队分享同步成功", + "syncTeamShares": "同步团队分享", + "syncTeamSharesDescription": "这将根据标签的分享设置,追溯性地将所有带有团队标签的录音分享给相应的团队成员。", + "teamLead": "团队负责人", + "teamName": "团队名称", + "teamNamePlaceholder": "输入团队名称", + "teamTags": "团队标签", + "title": "群组管理", + "updateTeam": "更新团队" + }, + "help": { + "actions": "操作", + "activeFilters": "活动筛选器", + "addSegmentBelow": "在下方添加片段", + "advancedAsrOptions": "高级 ASR 选项", + "allRecordingsLoaded": "已加载所有录音", + "allTagsSelected": "All tags selected", + "appliedSuggestedNames": "已应用 {{count}} 个建议名称", + "appliedSuggestedNamesPlural": "已应用 {{count}} 个建议名称", + "applySuggested": "应用建议", + "applySuggestedMobile": "建议", + "approachingLimit": "接近 {{limit}}MB 限制", + "askAboutTranscription": "询问有关此转录的问题", + "audioDeletedDescription": "此录音的音频文件已被删除,但转录内容仍然可用。", + "audioDeletedMessage": "音频文件已归档,无法播放。", + "audioDeletedTitle": "音频文件已删除", + "audioPlayer": "音频播放器", + "autoIdentify": "自动识别", + "autoIdentifyMobile": "自动", + "bothAudioDesc": "录制您的声音 + 会议参与者(推荐用于在线会议)", + "clearFilters": "清除筛选", + "clickToAddNotes": "点击添加笔记...", + "colorRepeats": "颜色从发言人 {{number}} 开始重复", + "completedFiles": "已完成文件", + "confirmReprocessingTitle": "确认重新处理", + "copyMessage": "复制消息", + "createFolders": "Create folders", + "createPublicLink": "创建公共链接以分享此录音。分享仅在安全(HTTPS)连接上可用。", + "createTags": "创建标签", + "defaultHotwordsHelp": "Comma-separated words or phrases that the transcription model should prioritize recognizing (brand names, acronyms, technical terms).", + "defaultInitialPromptHelp": "Context to steer the transcription model's style and vocabulary. Describe the topic or expected content of your recordings.", + "deleteSegment": "删除片段", + "discard": "放弃", + "dragToReorder": "Drag to reorder", + "endTime": "结束", + "enterNameFor": "输入名称", + "enterSpeakerName": "输入发言人姓名...", + "entireScreen": "整个屏幕", + "errorChangingSpeaker": "更改发言人时出错", + "errorOpeningTextEditor": "打开文本编辑器时出错", + "errorSavingText": "保存文本时出错", + "estimatedSize": "预计大小", + "expandAll": "全部展开", + "failedReprocess": "重新处理失败", + "filterByTag": "按标签筛选", + "filterOptions": "筛选选项", + "finishDrop": "完成拖放以上传文件", + "firstTagAsrSettings": "First tag's ASR settings will be applied:", + "firstTagDefaultsApplied": "First tag's defaults applied", + "folderHasCustomPrompt": "This folder has a custom summary prompt", + "generatingSummary": "生成摘要中...", + "groups": "组", + "hideAll": "全部隐藏", + "highlighted": "已标记", + "howToRecordSystemAudio": "如何录制系统音频", + "identifyAllSpeakers": "识别所有发言人", + "identifying": "识别中...", + "importantNote": "重要提示", + "inProgress": "进行中", + "internalSharingDesc": "与您组织中的特定用户分享", + "lines": "{{count}} 行", + "loadingMore": "加载更多录音中...", + "loadingRecordings": "加载录音中...", + "markdownEditor": "Markdown 编辑器", + "me": "Me", + "meetingDate": "会议日期", + "meetingParticipants": "会议参与者", + "microphoneDesc": "仅录制您的声音", + "microphoneOnly": "仅麦克风", + "microphoneOnlyDesc": "仅录制您的声音(用于独自工作或面对面会议)", + "minutes": "分钟", + "modelReasoning": "模型推理", + "moreSpeakersThanColors": "发言人数超过可用颜色数", + "navigate": "导航", + "newTranscriptions": "新转录", + "noDateSet": "未设置日期", + "noMatchingTags": "No matching tags", + "noParticipants": "无参与者", + "noRecordingSelected": "未选择录音。", + "noRecordingsFound": "未找到录音", + "noSpeakersIdentified": "无法从上下文中识别发言人。", + "noSuggestionsToApply": "没有可应用的建议", + "noTagsCreated": "尚未创建标签。", + "notes": "笔记", + "of": "/", + "or": "或", + "participants": "参与者", + "playFromHere": "从此处播放", + "playPause": "播放/暂停", + "pleaseEnterSpeakerName": "请输入发言人名称", + "processingTime": "处理时间", + "processingTimeDescription": "这可能需要几分钟完成。处理期间您可以继续使用应用。", + "processingTranscription": "处理转录中...", + "publicLinkDesc": "任何拥有此链接的人都可以访问录音", + "recordSystemSteps1": "点击\"录制系统音频\"或\"录制两者\"。", + "recordSystemSteps2": "在弹出窗口中,选择", + "recordSystemSteps3": "确保勾选显示的框", + "recordingFinished": "录音完成", + "recordingInProgress": "录音进行中...", + "recordingSettings": "录音设置", + "recordingStatus": "录音状态", + "regenerateSummaryAfterNames": "更新名称后重新生成摘要", + "reprocessed": "已重新处理", + "saved": "已保存!", + "savingProgress": "保存中...", + "seconds": "秒", + "selectedTagsCustomPrompts": "Selected tags include custom summary prompts", + "sentence": "句子", + "shareSystemAudio": "共享系统音频", + "shareTabAudio": "共享标签页音频", + "shareableLink": "可分享链接", + "sharedOn": "共享于", + "sharingWindowNoAudio": "共享\"窗口\"不会捕获音频。", + "showAll": "显示全部", + "showLess": "显示更少", + "showMore": "显示更多", + "speaker": "发言人", + "speakerAdded": "发言人添加成功", + "speakerCount": "发言人", + "speakerName": "发言人名称", + "speakerNamesUpdated": "发言人名称更新成功!", + "speakers": "发言人", + "speakersIdentified": "成功识别 {{count}} 位发言人!", + "speakersIdentifiedPlural": "成功识别 {{count}} 位发言人!", + "speakersUpdatedSaveToApply": "发言人已更新!保存转录以应用更改。", + "specificBrowserTab": "特定浏览器标签页", + "startRecording": "开始录音", + "startTime": "开始", + "startingAutoIdentification": "正在启动自动发言人识别...", + "stopRecording": "停止录音", + "summary": "摘要", + "summaryGenerationFailed": "摘要生成失败", + "summaryGenerationTimedOut": "摘要生成超时", + "summaryRegenerationStarted": "摘要重新生成已开始", + "summaryUpdated": "摘要已更新!", + "systemAudioBoth": "系统音频 + 麦克风", + "systemAudioDesc": "录制会议参与者和系统声音", + "tagManagement": "标签管理", + "tags": "标签", + "tapToAddNotes": "点击添加笔记...", + "thisActionCannotBeUndone": "此操作无法撤销。", + "title": "标题", + "toCaptureAudioFromMeetings": "要从会议或其他应用捕获音频,您必须共享屏幕或浏览器标签页。", + "toOrganizeRecordings": "to organize your recordings", + "totalDuration": "总时长", + "transcriptUpdated": "转录更新成功!", + "transcription": "转录", + "transcriptionSaved": "转录已保存", + "troubleshooting": "故障排除", + "tryAdjustingSearch": "尝试调整您的搜索或", + "unsupportedBrowser": "不支持的浏览器", + "untitled": "无标题录音", + "updateSpeakerNames": "更新发言人名称", + "uploadComplete": "上传完成", + "uploadFailed": "上传失败", + "uploadFiles": "上传文件", + "uploadRecordingNotes": "上传录音和笔记", + "uploading": "上传中", + "viewDetails": "查看详情", + "waitingToUpload": "等待上传", + "whatWillHappen": "会发生什么?", + "whyNotWorking": "为什么不工作?", + "youHaveXSpeakers": "您有 {{count}} 位发言人,但只有16种独特的颜色可用。颜色将在第16位发言人之后重复。" + }, + "incognito": { + "audioNotStored": "Audio not stored in incognito mode", + "discardConfirm": "This will permanently discard your incognito recording. Continue?", + "mode": "Incognito Mode", + "notSavedToAccount": "Not saved to account", + "oneFileAtATime": "Incognito mode supports one file at a time", + "processInIncognito": "Process in Incognito", + "processWithoutSaving": "Process without saving", + "processing": "Processing...", + "processingComplete": "Processing complete!", + "processingInProgress": "Processing in incognito mode...", + "recordingDiscarded": "Incognito recording discarded", + "recordingProcessed": "Incognito recording processed - data will be lost when tab closes", + "recordingReady": "Incognito recording ready!", + "recordingTitle": "Incognito Recording", + "selectExactlyOneFile": "Select exactly 1 file", + "sessionOnly": "Session only", + "uploadingFile": "Uploading file for incognito processing..." + }, + "inquire": { + "activeFilters": "活动筛选器:", + "askQuestions": "询问您的转录内容", + "clearAll": "清除全部", + "dateRange": "日期范围", + "dateRangeActive": "日期范围已激活", + "exampleQuestion1": "\"讨论了哪些行动项目?\"", + "exampleQuestion2": "\"我们什么时候决定更改时间表?\"", + "exampleQuestion3": "\"对预算提出了什么担忧?\"", + "exampleQuestion4": "\"谁负责营销任务?\"", + "exampleQuestions": "示例问题:", + "filters": "筛选器", + "filtersActive": "筛选器已激活", + "from": "开始日期", + "noSpeakerData": "无发言人数据", + "placeholder": "询问关于您筛选的转录内容...", + "selectFilters": "在左侧选择筛选器以缩小转录范围,然后提问以从录音中获得见解。", + "sendHint": "按Enter发送 • Ctrl+Enter换行", + "speakerRequirement": "发言人识别需要包含多个发言人的录音", + "speakers": "发言人", + "speakersCount": "个发言人", + "tags": "标签", + "tagsCount": "个标签", + "title": "查询", + "to": "结束日期" + }, + "languages": { + "ar": "阿拉伯语", + "de": "德语", + "en": "英语", + "es": "西班牙语", + "fr": "法语", + "hi": "印地语", + "it": "意大利语", + "ja": "日语", + "ko": "韩语", + "nl": "荷兰语", + "pt": "葡萄牙语", + "ru": "俄语", + "zh": "中文" + }, + "manageSpeakersModal": { + "created": "创建时间", + "description": "管理您保存的发言人。当您在录音中使用发言人名称时,这些会自动保存。", + "failedToLoad": "加载发言人失败", + "lastUsed": "最后使用", + "loadingSpeakers": "加载发言人中...", + "noSpeakersYet": "尚未保存发言人", + "speakersSaved": "已保存 {{count}} 位发言人", + "speakersWillAppear": "当您在录音中使用发言人名称时,发言人将显示在这里", + "times": "次", + "title": "管理发言人", + "used": "已使用" + }, + "messages": { + "colorSchemeApplied": "Color scheme applied", + "colorSchemeReset": "Color scheme reset to default", + "copiedSuccessfully": "Copied to clipboard!", + "copiedToClipboard": "已复制到剪贴板", + "copyFailed": "Failed to copy", + "copyNotSupported": "Copy failed. Your browser may not support this feature.", + "downloadStarted": "下载已开始", + "errorRecoveringRecording": "Error recovering recording", + "eventDownloadFailed": "Failed to download event", + "eventDownloadSuccess": "Event \"{{title}}\" downloaded. Open the file to add to your calendar.", + "eventsExportFailed": "Failed to export events", + "eventsExportSuccess": "Exported {{count}} events", + "failedToDeleteJob": "Failed to delete job", + "failedToRecoverRecording": "Failed to recover recording", + "failedToRetryJob": "Failed to retry job", + "failedToSave": "Failed to save: {{error}}", + "failedToSaveParticipants": "Failed to save participants", + "followPlayerDisabled": "Follow player mode disabled", + "followPlayerEnabled": "Follow player mode enabled", + "invalidEventData": "Invalid event data", + "jobQueuedForRetry": "Job queued for retry", + "noEventsToExport": "No events to export", + "noNotesAvailableDownload": "No notes available to download.", + "noNotesToCopy": "No notes available to copy.", + "noPermissionToEdit": "You do not have permission to edit this recording", + "noSummaryToCopy": "No summary available to copy.", + "noSummaryToDownload": "No summary available to download.", + "noTranscriptionToCopy": "No transcription available to copy.", + "noTranscriptionToDownload": "No transcription available to download.", + "notesCopied": "Notes copied to clipboard!", + "notesDownloadFailed": "Failed to download notes", + "notesDownloadSuccess": "Notes downloaded successfully!", + "notesUpdated": "笔记更新成功", + "passwordChanged": "密码更改成功", + "profileUpdated": "个人资料更新成功", + "recordingDeleted": "录音删除成功", + "recordingDiscarded": "Recording discarded", + "recordingRecovered": "Recording recovered successfully", + "recordingSaved": "录音保存成功", + "saveParticipantsFailed": "Save failed: {{error}}", + "settingsSaved": "设置保存成功", + "summaryCopied": "Summary copied to clipboard!", + "summaryDownloadFailed": "Failed to download summary", + "summaryDownloadSuccess": "Summary downloaded successfully!", + "summaryGenerated": "摘要生成成功", + "tagAdded": "标签添加成功", + "tagRemoved": "标签删除成功", + "transcriptDownloadFailed": "Failed to download transcript", + "transcriptDownloadSuccess": "Transcript downloaded successfully!", + "transcriptionCopied": "Transcription copied to clipboard!", + "transcriptionUpdated": "转录更新成功" + }, + "metadata": { + "cancelEdit": "取消", + "createdAt": "创建时间", + "duration": "时长", + "editMetadata": "编辑元数据", + "fileName": "文件名", + "fileSize": "文件大小", + "language": "语言", + "meetingDate": "会议日期", + "processingTime": "处理时间", + "saveMetadata": "保存", + "status": "状态", + "title": "元数据", + "updatedAt": "更新时间", + "wordCount": "字数" + }, + "modal": { + "addSpeaker": "添加新发言人", + "colorScheme": "配色方案", + "deleteRecording": "删除录音", + "editAsrTranscription": "编辑 ASR 转录", + "editParticipants": "编辑参与者", + "editRecording": "编辑录音", + "editSpeakers": "编辑发言人", + "editTags": "编辑录音标签", + "editTranscription": "编辑转录", + "identifySpeakers": "识别发言人", + "recordingNotice": "录音提示", + "reprocessSummary": "重新处理摘要", + "reprocessTranscription": "重新处理转录", + "resetStatus": "重置录音状态?", + "shareRecording": "分享录音", + "sharedTranscripts": "我的共享转录", + "systemAudioHelp": "系统音频帮助", + "uploadFiles": "上传文件", + "uploadNotice": "上传提示" + }, + "namingTemplates": { + "addPattern": "添加模式", + "availableTemplates": "可用模板", + "availableVars": "可用变量", + "cancel": "取消", + "createDefaults": "创建默认模板", + "createNew": "创建模板", + "customVarsHint": "定义正则表达式模式以从文件名中提取自定义变量。", + "delete": "删除", + "description": "定义如何从文件名和转录内容生成录音标题。", + "descriptionLabel": "描述", + "noDefault": "无默认(仅 AI 标题)", + "regexHint": "从文件名中提取数据。使用捕获组 () 指定匹配项。", + "regexPatterns": "正则表达式模式(可选)", + "result": "结果:", + "save": "保存", + "selectOrCreate": "选择要编辑的模板或创建新模板", + "tabTitle": "命名", + "template": "模板", + "templateName": "模板名称", + "test": "测试", + "testTemplate": "测试模板", + "title": "命名模板", + "userDefault": "默认模板", + "userDefaultHint": "当没有标签设置命名模板时应用。" + }, + "nav": { + "account": "账户", + "accountSettings": "账户设置", + "admin": "管理", + "adminDashboard": "管理面板", + "darkMode": "深色模式", + "groupManagement": "群组管理", + "home": "主页", + "language": "语言", + "lightMode": "浅色模式", + "newRecording": "新录音", + "recording": "录音", + "settings": "设置", + "signOut": "退出登录", + "teamManagement": "群组管理", + "upload": "上传", + "userProfile": "用户资料" + }, + "notes": { + "cancelEdit": "取消编辑", + "characterCount": "{{count}} 个字符", + "characterCountPlural": "{{count}} 个字符", + "editNotes": "编辑笔记", + "lastUpdated": "最后更新", + "placeholder": "在此添加您的笔记...", + "saveNotes": "保存笔记", + "title": "笔记" + }, + "pwa": { + "installApp": "安装应用", + "installed": "安装成功", + "installing": "安装中...", + "notificationPermissionDenied": "通知权限被拒绝", + "notificationsEnabled": "通知已启用", + "offline": "您处于离线状态", + "screenAwake": "录音时屏幕将保持唤醒", + "screenAwakeFailed": "无法保持屏幕唤醒", + "updateAvailable": "有可用更新" + }, + "recording": { + "acceptDisclaimer": "我接受", + "cancelRecording": "取消", + "discardRecovery": "丢弃", + "disclaimer": "录音免责声明", + "duration": "时长", + "micPlusSys": "Mic + Sys", + "microphone": "麦克风", + "microphoneAndSystem": "麦克风 + 系统", + "microphonePermissionDenied": "麦克风权限被拒绝", + "modeBoth": "麦克风 + 系统", + "modeMicrophone": "麦克风", + "modeSystem": "系统音频", + "notes": "笔记", + "notesPlaceholder": "添加关于此录音的笔记...", + "pauseRecording": "暂停", + "recordingFailed": "录音失败", + "recordingInProgress": "录音进行中...", + "recordingMode": "录音模式", + "recordingSize": "预计大小", + "recordingStopped": "录音已停止", + "recordingTime": "录音时长", + "recoveryDescription": "我们发现了上次会话中未完成的录音。是否要恢复?", + "recoveryFound": "检测到未保存的录音", + "recoveryTitle": "恢复录音", + "restoreRecording": "恢复", + "resumeRecording": "恢复", + "saveRecording": "保存录音", + "size": "大小", + "startRecording": "开始录音", + "startedAt": "开始时间", + "stopRecording": "停止录音", + "systemAudio": "系统音频", + "systemAudioNotSupported": "此浏览器不支持系统音频录制", + "title": "音频录制" + }, + "recordingView": { + "audioPlayer": "音频播放器", + "bubble": "气泡", + "chat": "聊天", + "copy": "复制", + "download": "下载", + "edit": "编辑", + "minutes": "分钟", + "notes": "笔记", + "participants": "参与者", + "save": "保存", + "share": "分享", + "simple": "简单", + "speakers": "发言人", + "summary": "摘要", + "transcription": "转录" + }, + "reprocessModal": { + "audioReTranscribedFromScratch": "音频将从头重新转录。这也将基于新转录重新生成标题和摘要。", + "audioReTranscribedWithAsr": "音频将使用 ASR 端点重新转录。这包括说话人分离,并将重新生成标题和摘要。", + "manualEditsOverwritten": "对转录、标题或摘要的任何手动编辑都将被覆盖。", + "manualEditsOverwrittenSummary": "对标题或摘要的任何手动编辑都将被覆盖。", + "newTitleAndSummary": "将基于现有转录生成新的标题和摘要。" + }, + "settings": { + "apiKeys": "API密钥", + "appearance": "外观", + "changePassword": "更改密码", + "dataExport": "数据导出", + "deleteAccount": "删除账户", + "integrations": "集成", + "language": "语言", + "notifications": "通知", + "preferences": "偏好设置", + "privacy": "隐私", + "profile": "个人资料", + "security": "安全", + "theme": "主题", + "title": "设置", + "twoFactorAuth": "双因素认证" + }, + "sharedTranscripts": { + "noSharedTranscripts": "您尚未分享任何转录。", + "shareNotes": "分享笔记", + "shareSummary": "分享摘要", + "sharedOn": "分享于" + }, + "sharedTranscriptsPage": { + "noSharedTranscripts": "您尚未分享任何转录。" + }, + "sharing": { + "canEdit": "可以编辑", + "canReshare": "可以转发", + "internalSharing": "内部分享", + "notSharedYet": "尚未分享", + "publicBadge": "公开", + "publicLink": "公开链接", + "publicLinks": "个公开链接", + "publicLinksGenerated": "个公开链接已生成", + "searchUsers": "搜索用户...", + "sharedBadge": "已分享", + "sharedBy": "分享者", + "sharedWith": "分享给", + "teamBadge": "团队", + "teamRecording": "团队录音", + "unknown": "未知", + "users": "位用户" + }, + "sidebar": { + "advancedSearch": "高级搜索", + "allRecordings": "所有录音", + "archived": "已归档", + "archivedRecordings": "已归档录音", + "clearSearch": "清除搜索", + "dateRange": "日期范围", + "filterByDate": "按日期筛选", + "filterByStatus": "按状态筛选", + "filterByTags": "按标签筛选", + "filters": "筛选器", + "highlighted": "已标记", + "inbox": "收件箱", + "inquireMode": "查询模式", + "lastMonth": "上月", + "lastWeek": "上周", + "loadMore": "加载更多", + "markAsRead": "标记为已读", + "moveToInbox": "移至收件箱", + "newRecording": "新录音", + "noRecordings": "没有录音", + "older": "更早", + "recordings": "录音", + "removeFromHighlighted": "取消标记", + "searchRecordings": "搜索录音", + "sharedWithMe": "分享给我", + "sortBy": "排序方式", + "sortByDate": "按日期排序", + "sortByDuration": "按时长排序", + "sortByMeetingDate": "会议日期", + "sortByName": "按名称排序", + "starred": "Starred", + "tags": "标签", + "thisMonth": "本月", + "thisWeek": "本周", + "today": "今天", + "totalRecordings": "{{count}} 个录音", + "totalRecordingsPlural": "{{count}} 个录音", + "upcoming": "即将到来", + "uploadFiles": "上传文件", + "yesterday": "昨天" + }, + "speakers": { + "filterBySpeaker": "按发言人筛选", + "noMatchingSpeakers": "没有匹配的发言人", + "searchSpeakers": "搜索..." + }, + "speakersManagement": { + "added": "添加时间", + "confidence": "置信度", + "confidenceHigh": "高", + "confidenceLow": "低", + "confidenceMedium": "中", + "created": "创建时间", + "description": "管理您保存的发言人。当您在录音中使用发言人姓名时,这些会自动保存。", + "failedToLoad": "加载发言人失败", + "failedToLoadSnippets": "加载片段失败", + "keepThisSpeaker": "保留此发言人(其他将合并到其中):", + "last": "上次", + "lastUsed": "上次使用", + "loadingSpeakers": "加载发言人...", + "match": "匹配", + "mergeDescription": "将多个发言人档案合并为一个。所有嵌入、片段和使用数据将被合并。", + "mergeFailed": "合并发言人失败", + "mergeNSpeakers": "合并 {{count}} 个发言人", + "mergeSpeakers": "合并发言人", + "mergeSuccess": "发言人合并成功", + "noSnippetsAvailable": "无可用片段", + "noSpeakersYet": "尚未保存发言人", + "sample": "个样本", + "samples": "个样本", + "selectToMerge": "选择2个以上进行合并", + "speakersToMerge": "要合并的发言人:", + "speakersWillAppear": "当您在录音中使用发言人姓名时,发言人将出现在这里", + "targetWillReceive": "选定的发言人将接收所有其他人的声音数据和片段。", + "time": "次", + "times": "次", + "totalSpeakers": "个已保存发言人", + "used": "已使用", + "usedTimes": "已使用", + "viewSnippets": "查看片段", + "voiceMatchSuggestions": "声音匹配建议", + "voiceProfile": "声音档案" + }, + "status": { + "completed": "已完成", + "failed": "失败", + "processing": "处理中", + "queued": "排队中", + "stuck": "重置卡住的处理", + "summarizing": "生成摘要中", + "transcribing": "转录中", + "uploading": "上传中" + }, + "summary": { + "actionItems": "行动事项", + "cancelEdit": "取消编辑", + "decisions": "决定", + "editSummary": "编辑摘要", + "generateSummary": "生成摘要", + "keyPoints": "关键要点", + "noSummary": "无可用摘要", + "participants": "参与者", + "regenerateSummary": "重新生成摘要", + "saveSummary": "保存摘要", + "summaryFailed": "摘要生成失败", + "summaryInProgress": "摘要生成中...", + "title": "摘要" + }, + "tagManagement": { + "asrDefaults": "ASR 默认值", + "createTag": "创建标签", + "customPrompt": "自定义提示", + "description": "使用自定义标签组织您的录音。每个标签可以有自己的摘要提示和默认 ASR 设置。", + "maxSpeakers": "最大", + "minSpeakers": "最小", + "noTags": "您尚未创建任何标签。" + }, + "tags": { + "addTag": "添加标签", + "clearTagFilter": "清除筛选", + "createTag": "创建标签", + "currentTags": "当前标签", + "filterByTag": "按标签筛选", + "manageAllTags": "管理所有标签", + "noAvailableTags": "没有可用标签", + "noMatchingTags": "没有匹配的标签", + "noTags": "无标签", + "removeTag": "删除标签", + "searchTags": "搜索...", + "tagColor": "标签颜色", + "tagName": "标签名称", + "title": "标签" + }, + "tagsModal": { + "addTags": "添加标签", + "currentTags": "当前标签", + "done": "完成", + "noTagsAssigned": "此录音未分配标签", + "searchTags": "搜索标签..." + }, + "time": { + "dayAgo": "1 天前", + "daysAgo": "{{count}} 天前", + "hourAgo": "1 小时前", + "hoursAgo": "{{count}} 小时前", + "justNow": "刚刚", + "minuteAgo": "1 分钟前", + "minutesAgo": "{{count}} 分钟前", + "monthAgo": "1 个月前", + "monthsAgo": "{{count}} 个月前", + "weekAgo": "1 周前", + "weeksAgo": "{{count}} 周前", + "yearAgo": "1 年前", + "yearsAgo": "{{count}} 年前" + }, + "tooltips": { + "changeSpeaker": "更改发言人", + "clearChat": "清空聊天", + "copyTranscript": "复制转录", + "deleteTeam": "删除群组", + "doubleClickToEdit": "双击编辑", + "downloadTranscriptWithTemplate": "使用模板下载转录", + "editTeam": "编辑群组", + "editText": "编辑文本", + "editTitle": "编辑标题", + "editTranscript": "编辑转录", + "exitFullscreen": "Exit fullscreen", + "expand": "展开", + "followPlayerDisabled": "启用自动滚动 - 文字记录跟随音频播放", + "followPlayerEnabled": "禁用自动滚动 - 文字记录保持位置", + "fullscreenVideo": "Fullscreen video", + "grantPublicSharing": "授予公开分享权限", + "hideVideo": "Hide video", + "highlight": "高亮", + "makeAdmin": "设为管理员", + "manageMembers": "管理成员", + "manageTeamTags": "管理群组标签", + "markAsRead": "标记为已读", + "maximizeChat": "最大化聊天", + "minimize": "最小化", + "moveToInbox": "移至收件箱", + "mute": "静音", + "pause": "暂停", + "play": "播放", + "playbackSpeed": "播放速度", + "removeAdmin": "移除管理员", + "removeFromQueue": "从队列中移除", + "removeFromTeam": "从团队中移除", + "removeHighlight": "移除高亮", + "reprocessTranscription": "重新处理转录", + "reprocessWithAsr": "使用ASR重新处理", + "restoreChat": "恢复聊天", + "revokePublicSharing": "撤销公开分享权限", + "shareWithUsers": "与用户分享", + "showVideo": "Show video", + "switchToDarkMode": "切换到深色模式", + "switchToLightMode": "切换到浅色模式", + "unmute": "取消静音" + }, + "transcriptTemplates": { + "availableTemplates": "可用模板", + "availableVars": "可用变量", + "cancel": "取消", + "chooseTemplate": "选择模板...", + "createDefaults": "创建默认模板", + "createNew": "创建模板", + "default": "默认", + "delete": "删除", + "description": "自定义转录的下载和导出格式。", + "downloadDefault": "下载默认", + "downloadWithoutTemplate": "不使用模板下载", + "filters": "过滤器:|upper 转大写,|srt 字幕格式", + "save": "保存", + "selectOrCreate": "选择要编辑的模板或创建新模板", + "selectTemplate": "选择模板", + "setDefault": "设为默认模板", + "tabTitle": "转录", + "template": "模板", + "templateName": "模板名称", + "title": "转录模板", + "viewGuide": "查看模板指南" + }, + "transcription": { + "autoIdentifySpeakers": "自动识别发言人", + "bubble": "气泡", + "cancelEdit": "取消编辑", + "copy": "复制", + "copyToClipboard": "复制到剪贴板", + "download": "下载", + "downloadTranscript": "下载转录文本", + "edit": "编辑", + "editSpeakers": "编辑发言人", + "editTranscription": "编辑转录", + "highlightSearchResults": "高亮搜索结果", + "noTranscription": "无可用转录", + "regenerateTranscription": "重新生成转录", + "saveTranscription": "保存转录", + "searchInTranscript": "在转录中搜索...", + "simple": "简单", + "speaker": "发言人 {{number}}", + "speakerLabels": "发言人标签", + "title": "转录", + "unknownSpeaker": "未知发言人" + }, + "upload": { + "chunking": "大文件将自动分块处理", + "completed": "已完成", + "copies": "份此文件的副本", + "dropzone": "拖拽音频文件到此处,或点击浏览", + "duplicateDetected": "此文件似乎与 \"{{existingName}}\" 重复(上传于 {{existingDate}})", + "duplicateFile": "重复文件", + "failed": "失败", + "fileExceedsMaxSize": "File \"{{name}}\" exceeds the maximum size of {{size}} MB and was skipped.", + "fileRemovedFromQueue": "File removed from queue", + "filesToUpload": "Files to Upload", + "invalidFileType": "Invalid file type \"{{name}}\". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.", + "maxFileSize": "最大文件大小", + "queued": "排队中", + "selectFiles": "选择文件", + "settingsApplyToAll": "Settings apply to all files in this session", + "summarizing": "生成摘要中...", + "supportedFormats": "支持 MP3, WAV, M4A, MP4, MOV, AVI, AMR 等格式", + "title": "上传音频", + "transcribing": "转录中...", + "untitled": "无标题录音", + "uploadNFiles": "Upload {{count}} File(s)", + "uploadProgress": "上传进度", + "videoRetained": "视频已保留供播放", + "willAutoSummarize": "转录后将自动生成摘要" + }, + "uploadProgress": { + "title": "上传进度" + } +} \ No newline at end of file diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..ba9c764 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,122 @@ +{ + "id": "ca.innova-ai.dictia", + "name": "DictIA", + "short_name": "DictIA", + "description": "DictIA - Transcription audio par IA", + "start_url": "/", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "any", + "prefer_related_applications": false, + "categories": ["productivity", "utilities", "business"], + "lang": "en", + "dir": "ltr", + "icons": [ + { + "src": "/static/img/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/img/icon-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/static/img/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/img/icon-180x180.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any" + } + ], + "screenshots": [ + { + "src": "/static/img/screenshots/main-view.png", + "sizes": "1920x1080", + "type": "image/png", + "form_factor": "wide", + "label": "Main recording gallery view" + }, + { + "src": "/static/img/screenshots/recording-interface.png", + "sizes": "1920x1080", + "type": "image/png", + "form_factor": "wide", + "label": "Live recording interface with notes" + }, + { + "src": "/static/img/screenshots/transcript-view.png", + "sizes": "1920x1080", + "type": "image/png", + "form_factor": "wide", + "label": "Transcript view with speaker identification" + }, + { + "src": "/static/img/screenshots/mobile-view.png", + "sizes": "390x844", + "type": "image/png", + "form_factor": "narrow", + "label": "Mobile recording view" + } + ], + "shortcuts": [ + { + "name": "New Recording", + "short_name": "New", + "description": "Upload or record new audio", + "url": "/#upload", + "icons": [{ "src": "/static/img/icon-192x192.png", "sizes": "192x192" }] + }, + { + "name": "View Gallery", + "short_name": "Gallery", + "description": "Access your recordings gallery", + "url": "/#gallery", + "icons": [{ "src": "/static/img/icon-192x192.png", "sizes": "192x192" }] + } + ], + "share_target": { + "action": "/#upload", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "shared_audio", + "accept": ["audio/*"] + } + ] + } + }, + "edge_side_panel": { + "preferred_width": 480 + }, + "file_handlers": [ + { + "action": "/", + "accept": { + "audio/*": [".mp3", ".wav", ".m4a", ".ogg", ".webm", ".flac", ".aac", ".wma", ".opus"] + } + } + ], + "protocol_handlers": [ + { + "protocol": "web+dictia", + "url": "/?audio=%s" + } + ] +} diff --git a/static/offline.html b/static/offline.html new file mode 100644 index 0000000..59ca87f --- /dev/null +++ b/static/offline.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Offline - DictIA + + + + +
+ DictIA +

You're Offline

+

It looks like you're not connected to the internet. Please check your connection and try again.

+

Some content may be unavailable until you're back online.

+
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..e19eb7a --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,65 @@ +# DictIA - Block all web crawlers and search engines +# This application contains private user data and should not be indexed + +User-agent: * +Disallow: / + +# Specific directives for major search engines +User-agent: Googlebot +Disallow: / + +User-agent: Googlebot-Image +Disallow: / + +User-agent: Bingbot +Disallow: / + +User-agent: Slurp +Disallow: / + +User-agent: DuckDuckBot +Disallow: / + +User-agent: Baiduspider +Disallow: / + +User-agent: YandexBot +Disallow: / + +User-agent: ia_archiver +Disallow: / + +# AI Crawlers +User-agent: GPTBot +Disallow: / + +User-agent: ChatGPT-User +Disallow: / + +User-agent: CCBot +Disallow: / + +User-agent: anthropic-ai +Disallow: / + +User-agent: Claude-Web +Disallow: / + +User-agent: cohere-ai +Disallow: / + +# Social Media Crawlers +User-agent: facebookexternalhit +Disallow: / + +User-agent: Twitterbot +Disallow: / + +User-agent: LinkedInBot +Disallow: / + +User-agent: Slackbot +Disallow: / + +User-agent: Discordbot +Disallow: / diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..644e8b2 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,541 @@ +const CACHE_NAME = 'DictIA-cache-v4'; +const ASSETS_TO_CACHE = [ + '/', + '/static/offline.html', + '/static/manifest.json', + '/static/css/styles.css', + '/static/js/app.modular.js', + '/static/js/i18n.js', + '/static/js/csrf-refresh.js', + '/static/img/icon-192x192.png', + '/static/img/icon-512x512.png', + '/static/img/favicon.ico', + // Local vendor assets (no external CDN dependencies) + '/static/vendor/js/tailwind.min.js', + '/static/vendor/js/vue.global.js', + '/static/vendor/js/marked.min.js', + '/static/vendor/js/easymde.min.js', + '/static/vendor/css/fontawesome.min.css', + '/static/vendor/css/easymde.min.css' +]; + +// Function to update shortcuts (structure from your example) +// The actual `lists` data would need to be sent from your client-side app.js +const updateShortcuts = async (lists) => { + if (!self.registration || !('shortcuts' in self.registration)) { + console.log('Shortcuts API not supported or registration not available.'); + return; + } + + try { + let shortcuts = [ + { + name: "New Recording", + short_name: "New", + description: "Upload or record new audio", + url: "/#upload", // Or your direct upload page route + icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }] + }, + { + name: "View Gallery", + short_name: "Gallery", + description: "Access your recordings gallery", + url: "/#gallery", // Or your direct gallery page route + icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }] + } + ]; + + // Example: If you had dynamic lists to add as shortcuts + if (Array.isArray(lists) && lists.length > 0) { + const dynamicShortcuts = lists.slice(0, 2).map(list => { // Max 2 dynamic, total 4 + if (list && list.id && list.title) { + return { + name: list.title, + short_name: list.title.length > 10 ? list.title.substring(0, 9) + '…' : list.title, + description: `View ${list.title}`, + url: `/list/${list.id}`, // Example dynamic URL + icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }] + }; + } + return null; + }).filter(Boolean); + shortcuts = [...shortcuts, ...dynamicShortcuts]; + } + + await self.registration.shortcuts.set(shortcuts); + console.log('PWA shortcuts updated successfully:', shortcuts); + } catch (error) { + console.error('Error updating PWA shortcuts:', error); + } +}; + + +// Cache first strategy: Respond from cache if available, otherwise fetch from network and cache. +const cacheFirst = async (request) => { + const responseFromCache = await caches.match(request); + if (responseFromCache) { + return responseFromCache; + } + try { + const responseFromNetwork = await fetch(request); + // Check if the response is valid before caching + if (responseFromNetwork && responseFromNetwork.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, responseFromNetwork.clone()); + } + return responseFromNetwork; + } catch (error) { + console.error('CacheFirst: Network request failed for:', request.url, error); + // For assets, returning a generic error or specific offline asset might be better than network error. + // However, if it's a critical asset not found, this indicates an issue. + return new Response('Network error trying to fetch asset.', { + status: 408, + headers: { 'Content-Type': 'text/plain' }, + }); + } +}; + +// Stale-while-revalidate strategy: Respond from cache immediately if available, +// then update the cache with a fresh response from the network. +const staleWhileRevalidate = async (request) => { + const cache = await caches.open(CACHE_NAME); + const cachedResponsePromise = cache.match(request); + const networkResponsePromise = fetch(request).then(networkResponse => { + if (networkResponse && networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + }).catch(error => { + console.error('StaleWhileRevalidate: Network request failed for:', request.url, error); + // If network fails, we still might have a cached response. + // If not, this error will propagate. + return new Response('API request failed and no cache available.', { + status: 503, // Service Unavailable + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'Service temporarily unavailable. Please try again later.' }) + }); + }); + + return (await cachedResponsePromise) || networkResponsePromise; +}; + +// Network first strategy: Try to fetch from network first. +// If network fails, fall back to cache. If cache also fails, serve offline page for navigation. +const networkFirst = async (request) => { + try { + const networkResponse = await fetch(request); + if (networkResponse && networkResponse.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + console.warn('NetworkFirst: Network request failed for:', request.url, error); + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + // For navigation requests, fall back to the offline page if both network and cache fail. + if (request.mode === 'navigate') { + const offlinePage = await caches.match('/static/offline.html'); + if (offlinePage) return offlinePage; + } + // For other types of requests, or if offline page isn't cached, re-throw or return error. + return new Response('Network error and no cache available.', { + status: 408, + headers: { 'Content-Type': 'text/plain' }, + }); + } +}; + +self.addEventListener('install', (event) => { + self.skipWaiting(); // Activate new service worker immediately + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + console.log('Service Worker: Caching app shell'); + return cache.addAll(ASSETS_TO_CACHE.map(url => new Request(url, { cache: 'reload' }))) // Force reload from network for app shell + .catch(error => { + console.error('Failed to cache app shell during install:', error); + // You might want to log which specific asset failed + ASSETS_TO_CACHE.forEach(url => { + cache.add(new Request(url, { cache: 'reload' })).catch(err => console.warn(`Failed to cache: ${url}`, err)); + }); + }); + }) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => { + console.log('Service Worker: Deleting old cache', name); + return caches.delete(name); + }) + ); + }).then(() => { + console.log('Service Worker: Activated and old caches cleared.'); + return self.clients.claim(); // Take control of all open clients + }) + ); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Skip non-GET requests from caching strategies (they should pass through) + if (request.method !== 'GET') { + // event.respondWith(fetch(request)); // Let non-GET requests pass through to the network + return; // Or simply return to let the browser handle it + } + + // Serve API calls from /api/ with stale-while-revalidate + // (excluding auth-related endpoints) + if (url.pathname.startsWith('/api/')) { + if (url.pathname.includes('/login') || url.pathname.includes('/logout') || url.pathname.includes('/auth')) { + // For auth, always go to network, don't cache + event.respondWith(fetch(request)); + return; + } + event.respondWith(staleWhileRevalidate(request)); + return; + } + + // Serve /audio/ requests with cache-first, then network. + // These are media files and can be large, so cache-first is good. + if (url.pathname.startsWith('/audio/')) { + event.respondWith(cacheFirst(request)); + return; + } + + // Handle navigation requests (HTML pages) with network-first, then cache, then offline page. + if (request.mode === 'navigate') { + event.respondWith(networkFirst(request)); + return; + } + + // For static assets listed in ASSETS_TO_CACHE, use cache-first. + // This ensures that if an asset path is directly requested, it's served from cache if possible. + // We need to match against the origin + pathname for ASSETS_TO_CACHE. + const requestPath = url.origin === self.origin ? url.pathname : request.url; + if (ASSETS_TO_CACHE.includes(requestPath)) { + event.respondWith(cacheFirst(request)); + return; + } + + // Default strategy for other GET requests: try cache, then network. + // This is a good general fallback for other static assets not explicitly listed + // or for assets from other origins if not handled by ASSETS_TO_CACHE. + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + return fetch(request).then(networkResponse => { + // Optionally cache other successful GET responses here if desired + // if (networkResponse && networkResponse.ok) { + // const cache = await caches.open(CACHE_NAME); + // cache.put(request, networkResponse.clone()); + // } + return networkResponse; + }).catch(() => { + // If network fails for a non-navigation, non-API, non-explicitly-cached asset + // there isn't much we can do other than return an error or nothing. + // For simplicity, let the browser handle the error. + }); + }) + ); +}); + +// Listen for messages from the client (e.g., to update shortcuts) +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'UPDATE_SHORTCUTS') { + console.log('Service Worker: Received UPDATE_SHORTCUTS message:', event.data.lists); + // updateShortcuts(event.data.lists); // Call if you implement dynamic shortcuts based on client data + } + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +// Background sync for failed uploads +self.addEventListener('sync', (event) => { + console.log('[Service Worker] Background sync triggered:', event.tag); + + if (event.tag === 'sync-uploads') { + event.waitUntil(syncFailedUploads()); + } +}); + +// IndexedDB helper for failed uploads +async function openFailedUploadsDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('SpeakrFailedUploads', 1); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('failedUploads')) { + const objectStore = db.createObjectStore('failedUploads', { keyPath: 'id', autoIncrement: true }); + objectStore.createIndex('timestamp', 'timestamp', { unique: false }); + objectStore.createIndex('clientId', 'clientId', { unique: false }); + } + }; + }); +} + +// Get all failed uploads from IndexedDB +async function getFailedUploads(db) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(['failedUploads'], 'readonly'); + const objectStore = transaction.objectStore('failedUploads'); + const request = objectStore.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// Delete a failed upload after successful retry +async function deleteFailedUpload(db, id) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(['failedUploads'], 'readwrite'); + const objectStore = transaction.objectStore('failedUploads'); + const request = objectStore.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +// Update retry count for a failed upload +async function updateRetryCount(db, id, retryCount, error) { + return new Promise(async (resolve, reject) => { + try { + const transaction = db.transaction(['failedUploads'], 'readwrite'); + const objectStore = transaction.objectStore('failedUploads'); + const getRequest = objectStore.get(id); + + getRequest.onsuccess = () => { + const upload = getRequest.result; + if (!upload) { + reject(new Error('Upload not found')); + return; + } + + upload.retryCount = retryCount; + upload.lastRetry = Date.now(); + if (error) { + upload.lastError = error; + } + + const putRequest = objectStore.put(upload); + putRequest.onsuccess = () => resolve(); + putRequest.onerror = () => reject(putRequest.error); + }; + + getRequest.onerror = () => reject(getRequest.error); + } catch (error) { + reject(error); + } + }); +} + +// Retry uploading a failed upload +async function retryUpload(upload) { + const formData = new FormData(); + + // Reconstruct File from ArrayBuffer + const file = new File([upload.fileData], upload.fileName, { type: upload.mimeType }); + formData.append('file', file); + + if (upload.notes) { + formData.append('notes', upload.notes); + } + + if (upload.tags && upload.tags.length > 0) { + upload.tags.forEach(tag => { + formData.append('tags[]', JSON.stringify(tag)); + }); + } + + if (upload.asrOptions) { + if (upload.asrOptions.language) { + formData.append('asr_language', upload.asrOptions.language); + } + if (upload.asrOptions.min_speakers) { + formData.append('asr_min_speakers', upload.asrOptions.min_speakers); + } + if (upload.asrOptions.max_speakers) { + formData.append('asr_max_speakers', upload.asrOptions.max_speakers); + } + } + + // Get CSRF token from cookies + const csrfToken = getCookie('csrf_access_token'); + const headers = csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}; + + const response = await fetch('/upload', { + method: 'POST', + headers: headers, + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +// Get cookie value +function getCookie(name) { + const value = `; ${self.cookies || ''}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; +} + +// Sync failed uploads from IndexedDB +async function syncFailedUploads() { + console.log('[Service Worker] Syncing failed uploads'); + + try { + const db = await openFailedUploadsDB(); + const failedUploads = await getFailedUploads(db); + + if (failedUploads.length === 0) { + console.log('[Service Worker] No failed uploads to retry'); + return Promise.resolve(); + } + + console.log(`[Service Worker] Found ${failedUploads.length} failed uploads to retry`); + + // Notify that sync started + await self.registration.showNotification('DictIA Upload Sync', { + body: `Retrying ${failedUploads.length} failed upload(s)...`, + icon: '/static/img/icon-192x192.png', + badge: '/static/img/icon-192x192.png', + tag: 'upload-sync', + requireInteraction: false + }); + + let successCount = 0; + let failCount = 0; + + for (const upload of failedUploads) { + try { + // Limit retries to 3 attempts + if (upload.retryCount >= 3) { + console.log(`[Service Worker] Upload ${upload.id} exceeded retry limit (${upload.retryCount})`); + failCount++; + continue; + } + + console.log(`[Service Worker] Retrying upload ${upload.id} (attempt ${upload.retryCount + 1})`); + + await retryUpload(upload); + + // Success - delete from IndexedDB + await deleteFailedUpload(db, upload.id); + successCount++; + + console.log(`[Service Worker] Successfully retried upload ${upload.id}`); + } catch (error) { + // Update retry count + await updateRetryCount(db, upload.id, upload.retryCount + 1, error.message); + failCount++; + + console.error(`[Service Worker] Failed to retry upload ${upload.id}:`, error); + } + } + + // Show final notification + await self.registration.showNotification('DictIA Upload Sync Complete', { + body: `${successCount} succeeded, ${failCount} failed`, + icon: '/static/img/icon-192x192.png', + badge: '/static/img/icon-192x192.png', + tag: 'upload-sync-complete', + requireInteraction: false + }); + + return Promise.resolve(); + } catch (error) { + console.error('[Service Worker] Failed to sync uploads:', error); + + await self.registration.showNotification('DictIA Upload Sync Failed', { + body: 'Could not sync failed uploads. Will retry later.', + icon: '/static/img/icon-192x192.png', + badge: '/static/img/icon-192x192.png', + tag: 'upload-sync-error', + requireInteraction: false + }); + + return Promise.reject(error); + } +} + +// Push notification handler +self.addEventListener('push', (event) => { + console.log('[Service Worker] Push notification received'); + + const options = { + icon: '/static/img/icon-192x192.png', + badge: '/static/img/icon-192x192.png', + vibrate: [200, 100, 200], + data: { + dateOfArrival: Date.now(), + primaryKey: 1 + } + }; + + if (event.data) { + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title || 'DictIA Notification', { + body: data.body || 'You have a new notification', + ...options, + data: data + }) + ); + } else { + event.waitUntil( + self.registration.showNotification('DictIA Notification', { + body: 'You have a new notification', + ...options + }) + ); + } +}); + +// Notification click handler +self.addEventListener('notificationclick', (event) => { + console.log('[Service Worker] Notification clicked:', event.notification.tag); + event.notification.close(); + + // Handle different notification types + const urlToOpen = event.notification.data?.url || '/'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Check if there's already a window open + for (const client of clientList) { + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + // If no window is open, open a new one + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) + ); +}); diff --git a/templates/account.html b/templates/account.html new file mode 100644 index 0000000..300bfc1 --- /dev/null +++ b/templates/account.html @@ -0,0 +1,7207 @@ + + + + + + + + {{ title }} - DictIA + + + + + + + + + + {% include 'includes/loading_overlay.html' %} + + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+ + +
+
+
+ +
+
+ + +
+
+

Account Information

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+
+
+ +

Personal Information

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Préférences Linguistiques

+
+
+ + +

Choose the language for the application interface.

+
+
+ + +

Sélectionner détection automatique pour laisser le service de transcription déterminer la langue.

+
+
+ + +

Langue pour les titres, résumés et chat. Laisser vide pour le défaut (comportement par défaut de vos modèles choisis).

+
+
+
+ + +
+
+ + +
+
+

Account Statistics

+
+
+
+ {{ current_user.recordings|length }} + Total Recordings +
+ +
+ + {% set completed_count = current_user.recordings|selectattr('status', 'equalto', 'COMPLETED')|list|length %} + {{ completed_count }} + + Completed +
+ +
+ + {% set processing_count = current_user.recordings|selectattr('status', 'in', ['PENDING', 'PROCESSING', 'SUMMARIZING'])|list|length %} + {{ processing_count }} + + Processing +
+ +
+ + {% set failed_count = current_user.recordings|selectattr('status', 'equalto', 'FAILED')|list|length %} + {{ failed_count }} + + Failed +
+
+
+
+ +
+

User Details

+
+
+ Username + {{ current_user.username }} +
+
+ Email + {{ current_user.email }} +
+
+
+ + {% if sso_enabled %} +
+

Single Sign-On

+
+
+
+ Provider + {{ sso_provider_name }} +
+ {% if sso_linked %} + Linked + {% else %} + Not linked + {% endif %} +
+ + {% if sso_linked %} +
+ Subject: {{ sso_subject }} +
+ {% if has_password %} +
+ + +
+ {% elif not password_login_disabled or current_user.is_admin %} +
+ To unlink SSO, you must first set a password. +
+ {% endif %} + {% else %} +
+ + +
+ {% endif %} +
+
+ {% endif %} + +
+

Account Actions

+
+ + Go to Recordings + + {% if not password_login_disabled or current_user.is_admin %} + + {% endif %} +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + {% include 'components/dictia/help-tab.html' %} + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..b03f9ce --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,3689 @@ + + + + + + + + {% if is_group_admin_only %}Group Management{% else %}Admin Dashboard{% endif %} - DictIA + + + {% include 'includes/loading_overlay.html' %} + + + + + + + + + + + + + + + +
+
+

+ + DictIA + DictIA + +

+
+ + +
+
+ +
+
+

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + +
+
+ +
+ +
+
+ +
+ +
+
+ + +
+
+

${t('adminDashboard.userManagement')}

+ +
+ + +
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
${t('adminDashboard.id')}${t('adminDashboard.username')}${t('adminDashboard.email')}${t('adminDashboard.admin')}${t('adminDashboard.publicShare')}${t('adminDashboard.recordings')}${t('adminDashboard.storageUsed')}${t('adminDashboard.actions')}
${ user.id }${ user.username }${ user.email } + Yes + No + + + ${ user.recordings_count }${ formatFileSize(user.storage_used) } +
+ + + +
+
+
+ Loading users... +
+
+ No users found. +
+
+
+
+ + +
+

${t('adminDashboard.systemStatistics')}

+ + +
+
+
+
+ +
+
+

${t('adminDashboard.totalUsers')}

+

${ stats.total_users }

+
+
+
+ +
+
+
+ +
+
+

${t('adminDashboard.totalRecordings')}

+

${ stats.total_recordings }

+
+
+
+ +
+
+
+ +
+
+

${t('adminDashboard.totalStorage')}

+

${ formatFileSize(stats.total_storage) }

+
+
+
+ +
+
+
+ +
+
+

${t('adminDashboard.totalQueries')}

+

${ stats.total_queries }

+
+
+
+
+ + +
+
+

${t('adminDashboard.recordingStatusDistribution')}

+
+
+ ${ stats.completed_recordings } + ${t('adminDashboard.completedRecordings')} +
+
+ ${ stats.processing_recordings } + ${t('adminDashboard.processingRecordings')} +
+
+ ${ stats.pending_recordings } + ${t('adminDashboard.pendingRecordings')} +
+
+ ${ stats.failed_recordings } + ${t('adminDashboard.failedRecordings')} +
+
+
+ +
+

${t('adminDashboard.topUsersByStorage')}

+
+
+
+ + ${ user.username } +
+
+ ${ formatFileSize(user.storage_used) } + (${ user.recordings_count} recordings) +
+
+
+ No data available +
+
+
+
+ + +
+

+ Token Usage Statistics +

+ + +
+ +
+
Usage Summary
+
+
+
+
+ +
+
+

Today

+

${ tokenStats.today?.tokens?.toLocaleString() || 0 }

+
+
+
+
+
+
+ +
+
+

This Month

+

${ tokenStats.current_month?.tokens?.toLocaleString() || 0 }

+
+
+
+
+
+
+ +
+
+

Monthly Cost

+

$${ (tokenStats.current_month?.cost || 0).toFixed(4) }

+
+
+
+
+
+
+ +
+
+

Warnings

+

+ ${ tokenStats.users_at_100_percent } blocked + ${ tokenStats.users_over_80_percent } warning + None +

+
+
+
+
+
+ + +
+
User Token Usage (This Month)
+
+
+
+
+ + ${ user.username } +
+
+ ${ user.current_usage?.toLocaleString() || 0 } + / ${ user.monthly_budget?.toLocaleString() } + (unlimited) +
+
+
+
+
+
+
+
+
+
+ No token usage data available +
+
+
+
+ + +
+ +
+
Daily Token Usage (Last 30 Days)
+
+ +
+
+ + +
+
Monthly Token Usage (Last 12 Months)
+
+ +
+
+
+
+ + +
+

+ ${t('adminDashboard.transcriptionUsage')} +

+ + +
+ +
+
Usage Summary
+
+
+
+
+ +
+
+

${t('adminDashboard.todaysMinutes')}

+

${ transcriptionStats.today?.minutes || 0 }

+
+
+
+
+
+
+ +
+
+

${t('adminDashboard.thisMonth')}

+

${ transcriptionStats.current_month?.minutes || 0 } min

+
+
+
+
+
+
+ +
+
+

${t('adminDashboard.monthlyCost')}

+

$${ (transcriptionStats.current_month?.cost || 0).toFixed(4) }

+
+
+
+
+
+
+ +
+
+

${t('adminDashboard.budgetWarnings')}

+

+ ${ transcriptionStats.users_at_100_percent } ${t('adminDashboard.blocked')} + ${ transcriptionStats.users_over_80_percent } ${t('adminDashboard.warning')} + ${t('adminDashboard.none')} +

+
+
+
+
+
+ + +
+
${t('adminDashboard.userTranscriptionUsage')}
+
+
+
+
+ + ${ user.username } +
+
+ ${ user.current_usage_minutes || 0 } min + / ${ user.monthly_budget_minutes } min + (unlimited) +
+
+
+
+
+
+
+
+
+
+ ${t('adminDashboard.noTranscriptionData')} +
+
+
+
+
+
+ + +
+
+

${t('adminDashboard.systemSettings')}

+ +
+ + +
+
+
+
+

${ setting.key }

+

${ getSettingDescription(setting) }

+
+ ${ setting.setting_type } + ${t('adminDashboard.lastUpdated')}: ${ formatDate(setting.updated_at) } +
+
+
+
+
+ + + Enabled + + + Disabled + + + + ${t('adminDashboard.noLimit')} + + + ${ Number(setting.value).toLocaleString() } ${t('adminDashboard.characters')} + + + ${ Number(setting.value).toLocaleString() } ${t('adminDashboard.megabytes')} + + + ${ Number(setting.value).toLocaleString() } ${t('adminDashboard.seconds')} + + ${ setting.value.substring(0, 80) }... + ${ setting.value || t('adminDashboard.notSet') } +
+
+ +
+
+
+ +
+
+ Loading settings... +
+
+ No system settings found. +
+
+
+
+ + +
+
+

${t('adminDashboard.defaultPrompts')}

+
+ + ${t('adminDashboard.defaultPromptInfo')} +
+
+ +
+
+ +

+ ${t('adminDashboard.promptDescription')} +

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ ${ promptSaveMessage } +
+
+
+ +
+

${t('adminDashboard.promptHierarchy')}

+

+ ${t('adminDashboard.promptPriorityDescription')} +

+
    +
  1. ${t('adminDashboard.tagCustomPrompt')}: ${t('adminDashboard.tagCustomPromptDesc')}
  2. +
  3. Folder Custom Prompt: Applied when recording is in a folder with a custom prompt (if Folders feature enabled)
  4. +
  5. ${t('adminDashboard.userCustomPrompt')}: ${t('adminDashboard.userCustomPromptDesc')}
  6. +
  7. ${t('adminDashboard.adminDefaultPrompt')}: ${t('adminDashboard.adminDefaultPromptDesc')}
  8. +
  9. ${t('adminDashboard.systemFallback')}: ${t('adminDashboard.systemFallbackDesc')}
  10. +
+
+ + +
+ + +
+
+
+
${t('adminDashboard.systemPrompt')}:
+
+
You are an AI assistant that generates comprehensive summaries for meeting transcripts. Respond only with the summary in Markdown format. Do NOT use markdown code blocks (```markdown). Provide raw markdown content directly.
+
+Context:
+- Current date: {current_date}
+- Tags applied to this transcript by the user: {tag_names} (if tags exist)
+- Information about the user: Name: {name}, Job title: {job_title}, Company: {company} (if provided)
+
+Language Requirement: You MUST generate the entire summary in {user_output_language}. This is mandatory. (if language preference is set)
+
+
+ +
+
${t('adminDashboard.userMessageTemplate')}:
+
+
Transcription:
+"""
+{transcript_text}
+"""
+
+Summarization Instructions:
+/* This section is dynamically replaced with one of the following (in order of priority):
+   1. Combined tag prompts (if tags with custom prompts are selected)
+   2. User's personal summarization prompt (if set in account settings)
+   3. Admin default prompt (shown below)
+   4. System fallback prompt */
+${ defaultSummaryPrompt || originalDefaultPrompt }
+
+{language_directive} (e.g., "Ensure your response is in {language}." if language is set)
+
+

+ + ${t('adminDashboard.placeholdersNote')} +

+
+ +
+
${t('adminDashboard.additionalContext')}:
+
+
    +
  • ${t('adminDashboard.contextNotes.transcriptLimit')}
  • +
  • ${t('adminDashboard.contextNotes.jsonConversion')}
  • +
  • ${t('adminDashboard.contextNotes.tagPrompts')}
  • +
  • ${t('adminDashboard.contextNotes.modelConfig')}
  • +
+
+
+
+
+
+
+ + +
+
+

${t('adminDashboard.vectorStoreManagement')}

+ +
+ + +
+

+ ${t('adminDashboard.aboutInquireMode')} +

+

+ ${t('adminDashboard.inquireModeDescription')} +

+
+
+ ${t('adminDashboard.chunkSize')}: + 500 ${t('adminDashboard.characters')} +
+
+ ${t('adminDashboard.overlap')}: + 50 ${t('adminDashboard.characters')} +
+
+ ${t('adminDashboard.embeddingModel')}: + all-MiniLM-L6-v2 +
+
+ ${t('adminDashboard.vectorDimensions')}: + 384 +
+
+
+ + +
+
+
+
+

${t('adminDashboard.totalRecordings')}

+

${ inquireStatus.total_completed_recordings || 0 }

+
+ +
+
+ +
+
+
+

${t('adminDashboard.processedForInquire')}

+

${ inquireStatus.processed_for_inquire || 0 }

+
+ +
+
+ +
+
+
+

${t('adminDashboard.needProcessing')}

+

+ ${ inquireStatus.need_processing || 0 } +

+
+ +
+
+ +
+
+
+

${t('adminDashboard.totalChunks')}

+

${ inquireStatus.total_chunks || 0 }

+
+ +
+
+ +
+
+
+

${t('adminDashboard.embeddingsStatus')}

+

+ + ${ inquireStatus.embeddings_available ? t('adminDashboard.available') : t('adminDashboard.textSearchOnly') } +

+
+ +
+
+ +
+
+
+

${t('adminDashboard.processingProgress')}

+
+
+
+
+

${ getProcessingProgress() }% ${t('adminDashboard.complete')}

+
+
+ +
+
+
+ + +
+

+ ${t('adminDashboard.processingActions')} +

+ +
+

+ ${t('adminDashboard.recordingsNeedProcessing').replace('{{count}}', inquireStatus.need_processing)} +

+ +
+ + + +
+ +
+

${ processingResult.message }

+
+

Failed recordings:

+
    +
  • + ID ${ fail.id }: ${ fail.title } - ${ fail.reason } +
  • +
+
+
+
+ +
+ +

${t('adminDashboard.allRecordingsProcessed')}

+

${t('adminDashboard.vectorStoreUpToDate')}

+
+
+
+ + +
+
+

Group Management

+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + +
${t('adminDashboard.groupName')}${t('adminDashboard.description')}${t('adminDashboard.members')}${t('adminDashboard.created')}${t('adminDashboard.actions')}
+
${ group.name }
+
+
${ group.description || t('adminDashboard.noDescription') }
+
+
+ + ${ group.member_count || 0 } ${t('adminDashboard.membersCount')} +
+
+ ${ new Date(group.created_at).toLocaleDateString() } + + + + + +
+
+
+ +

+ +
+
+
+ + +
+
+

+ Journal d'audit +

+
+ +
+
+ + +
+
+ +
+

Journal d'audit désactivé

+

Ajoutez ENABLE_AUDIT_LOG=true dans le fichier .env pour activer la traçabilité Loi 25.

+
+
+
+ + +
+ + +
+ + +
+ + + +
+ + +
+ +

Chargement des journaux...

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
DateUtilisateurActionRessourceStatutIP
${ formatDate(log.timestamp) }${ log.username || 'Anonyme' } + + ${ auditActionLabel(log.action) } + + + ${ auditResourceLabel(log.resource_type) } + #${ log.resource_id } + + + ${ log.status } + + ${ log.ip_address || '-' }
+
+ +

Aucun log d'accès enregistré

+
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
DateUtilisateurActionIPDétails
${ formatDate(log.timestamp) }${ log.username || '-' } + + ${ auditActionLabel(log.action) } + + ${ log.ip_address || '-' }${ log.details ? JSON.stringify(log.details) : '-' }
+
+ +

Aucun log d'authentification enregistré

+
+
+ + +
+

+ Page ${ auditPage } de ${ auditTotalPages } + (${ auditSubTab === 'access' ? auditAccessTotal : auditAuthTotal } entrées) +

+
+ + +
+
+
+ +
+
+ + + + +
+
+
+

${t('adminDashboard.addNewUser')}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

${t('adminDashboard.tokenBudgetHelp')}

+
+
+ + +

${t('adminDashboard.transcriptionBudgetHelp')}

+
+
+ + +
+
+
+
+ + +
+
+
+

${t('adminDashboard.editUser')}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

${t('adminDashboard.tokenBudgetHelp')}

+
+
+ ${t('adminDashboard.currentUsage')}: ${ editingUser.current_token_usage?.toLocaleString() || 0 } ${t('adminDashboard.tokens')} + ${ editingUser.token_usage_percentage || 0 }% +
+
+
+
+
+
+
+ + +

${t('adminDashboard.transcriptionBudgetHelp')}

+
+
+ ${t('adminDashboard.currentUsageMinutes')}: ${ editingUser.current_transcription_usage_minutes || 0 } ${t('adminDashboard.minutes')} + ${ editingUser.transcription_usage_percentage || 0 }% +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+

Confirm Delete

+ +
+

Are you sure you want to delete the user ${ userToDelete?.username }? This action cannot be undone.

+
+ + +
+
+
+ + +
+
+
+

Edit System Setting

+ +
+ +
+
+ +
+ ${ editingSetting.key } +
+
+ +
+ +
+ ${ editingSetting.description || 'No description available' } +
+
+ +
+ + + ${ editingSetting.setting_type } + +
+ +
+ +
+ + ${t('adminDashboard.noLimit')} + + + ${ Number(editingSetting.value).toLocaleString() } ${t('adminDashboard.characters')} + + + ${ Number(editingSetting.value).toLocaleString() } ${t('adminDashboard.megabytes')} + + + ${ Number(editingSetting.value).toLocaleString() } ${t('adminDashboard.seconds')} (${ Math.round(Number(editingSetting.value) / 60) } ${t('adminDashboard.minutes')}) + + ${ editingSetting.value || t('adminDashboard.notSet') } +
+
+ +
+ + + +
+ + +
+ + +
+ +
+
+ + +
+ + Recommended: 30000-50000 characters for most LLMs +
+ + +
+ + ${t('adminDashboard.maxFileSizeHelp')} +
+ + +
+
+
+ + ${t('adminDashboard.minutes')} +
+
+ + ${t('adminDashboard.seconds')} +
+
+
+ ${t('adminDashboard.total')}: ${ (settingTimeoutMinutes * 60 + settingTimeoutSeconds).toLocaleString() } ${t('adminDashboard.seconds')} +
+ ${t('adminDashboard.timeoutRecommendation')} +
+ + + +
+ + + +
+ +
+ ${ settingError } +
+
+ +
+ + +
+
+
+ + +
+
+
+

${ editingTeam ? 'Edit Group' : 'Create Group' }

+ +
+
+
+
+ + +
+
+ + +
+
+ ${ teamError } +
+
+
+ + +
+
+
+
+ + +
+
+
+

Manage Group: ${ currentTeam?.name }

+ +
+ + +
+

Add Member

+
+
+ +
+
+ +
+ +
+
+ + +
+

Current Members (${ teamMembers.length })

+
+
+
+ +
+
${ member.username }
+
${ member.email }
+
+
+
+ + +
+
+
+
+ ${t('adminDashboard.noMembersYet')} +
+
+ +
+ ${ teamMemberError } +
+ +
+ + +
+
+
+ + +
+
+
+

Sync Group Shares

+ +
+

+ This will create shares for all recordings with group tags that have auto-sharing enabled. +

+

+ + Only missing shares will be created - existing shares won't be duplicated. +

+
+ + +
+
+
+ + +
+
+
+

+ Sync Complete +

+ +
+
+
+ Shares created: + ${ syncResults.shares_created } +
+
+ Recordings processed: + ${ syncResults.recordings_processed } +
+
+
+ +
+
+
+ + +
+
+
+

Delete Group

+ +
+

Are you sure you want to delete the group ${ teamToDelete?.name }? This will remove all members from the group. This action cannot be undone.

+
+ + +
+
+
+ + +
+
+
+

Manage Tags for ${ currentTeam?.name }

+ +
+ + +
+
+

+ ${ editingTeamTagId ? 'Edit Group Tag' : 'Create New Group Tag' } +

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +

+ Recordings with this tag will use this prompt for AI summaries and chat. +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +

+ Les enregistrements avec cette étiquette seront supprimés après ce nombre de jours. Laisser vide pour la rétention globale (${ globalRetentionDays } jours). +

+
+
+ + +
+
+
+ + +
+
+ + +
+

+ Note : Si les deux sont activés, tous les membres du groupe auront accès. Si seulement « administrateurs du groupe » est activé, seuls les responsables du groupe y auront accès. +

+
+
${ teamTagError }
+ +
+
+ + +
+

Group Tags

+
+
+
+
+ +
+
${ tag.name }
+
+ + ${ tag.retention_days } day retention + + + Global retention + + + Protected + +
+
+
+
+ + +
+
+ + +
+
+ Custom prompt configured +
+
+ Language: ${ tag.default_language.toUpperCase() } +
+
+ Speakers: + ${ tag.default_min_speakers }-${ tag.default_max_speakers }auto +
+
+
+
+
+ +

No group tags created yet

+
+
+ +
+
+
+ + + + diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html new file mode 100644 index 0000000..8962962 --- /dev/null +++ b/templates/auth/check_email.html @@ -0,0 +1,127 @@ + + + + + + + {{ title }} - DictIA + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+ +
+
+ + {% if action == 'verification' %} +

Check Your Email

+

We've sent a verification link to:

+

{{ email }}

+

+ Click the link in the email to verify your account. The link will expire in 24 hours. +

+ {% elif action == 'verification_required' %} +

Email Verification Required

+

Please verify your email address:

+

{{ email }}

+

+ Check your inbox for a verification email. If you haven't received it, you can request a new one. +

+ {% elif action == 'password_reset' %} +

Check Your Email

+

If an account exists with this email:

+

{{ email }}

+

+ We've sent a password reset link. The link will expire in 1 hour. +

+ {% endif %} + + {% if show_resend and (action == 'verification' or action == 'verification_required') %} +
+
+ + + +
+
+ {% endif %} + + +
+
+
+ + +
+ + + + diff --git a/templates/auth/forgot_password.html b/templates/auth/forgot_password.html new file mode 100644 index 0000000..80941fb --- /dev/null +++ b/templates/auth/forgot_password.html @@ -0,0 +1,105 @@ + + + + + + + {{ title }} - DictIA + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+
+

Forgot Password

+

+ Enter your email address and we'll send you a link to reset your password. +

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ +
+ + +
+ Remember your password? + Back to Login +
+
+
+
+
+ + +
+ + + + diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html new file mode 100644 index 0000000..c3dcdf7 --- /dev/null +++ b/templates/auth/reset_password.html @@ -0,0 +1,114 @@ + + + + + + + {{ title }} - DictIA + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+
+

Reset Password

+

+ Enter your new password below. +

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +

Password must be at least 8 characters long.

+
+ +
+ + +
+ +
+ + + +
+
+
+
+ + +
+ + + + diff --git a/templates/auth/verify_success.html b/templates/auth/verify_success.html new file mode 100644 index 0000000..86084b0 --- /dev/null +++ b/templates/auth/verify_success.html @@ -0,0 +1,85 @@ + + + + + + + {{ title }} - DictIA + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+
+
+
+
+ +
+
+ +

Email Verified!

+

+ Your email address has been successfully verified. You can now log in to your account. +

+ + + Continue to Login + +
+
+
+ + +
+ + + + diff --git a/templates/components/banner.html b/templates/components/banner.html new file mode 100644 index 0000000..25b6bca --- /dev/null +++ b/templates/components/banner.html @@ -0,0 +1,11 @@ + +
+ + + +
diff --git a/templates/components/detail-view.html b/templates/components/detail-view.html new file mode 100644 index 0000000..cdd1cb1 --- /dev/null +++ b/templates/components/detail-view.html @@ -0,0 +1,57 @@ + +
+ +
+
+
+
+ +
+
+ Incognito + + • Session only • Not saved to account + +
+
+ +
+
+ + +
+ {% include 'components/detail/mobile-header.html' %} + {% include 'components/detail/audio-player.html' %} + {% include 'components/detail/tab-navigation.html' %} + + +
+ {% include 'components/detail/mobile-transcript-panel.html' %} + {% include 'components/detail/mobile-summary-panel.html' %} + {% include 'components/detail/mobile-notes-panel.html' %} + {% include 'components/detail/mobile-chat-panel.html' %} + {% include 'components/detail/mobile-events-panel.html' %} +
+
+ + +
+ {% include 'components/detail/desktop-header.html' %} + + +
+ {% include 'components/detail/desktop-transcription-panel.html' %} + + +
+ + {% include 'components/detail/desktop-right-panel.html' %} +
+
+
+ +{% include 'components/detail/empty-state.html' %} diff --git a/templates/components/detail/audio-player.html b/templates/components/detail/audio-player.html new file mode 100644 index 0000000..27c1b76 --- /dev/null +++ b/templates/components/detail/audio-player.html @@ -0,0 +1,236 @@ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ ${ currentSubtitle.speaker }: + ${ currentSubtitle.text } +
+ + +
+ + +
+
+
+
+
+
+
+
+ + +
+ + + + +
+ + +
+ + +
+ ${ formatAudioTime(displayCurrentTime) } + / + ${ formatAudioTime(audioDuration) } +
+ +
+ + + + + + +
+
+
+
+ + +
+ +
+ +
+ +
+
+ +
+
+ + +
+ + + + +
+ ${ formatAudioTime(displayCurrentTime) } + / + ${ formatAudioTime(audioDuration) } +
+ + +
+ + + +
+
+ +
+
+
+
+ + +
+ + +
+ + +
+
+ ${ Math.round(playerVolume * 100) } + +
+
+
+ + + + + + + + + + + +
+
+
+
diff --git a/templates/components/detail/desktop-chat-section.html b/templates/components/detail/desktop-chat-section.html new file mode 100644 index 0000000..263ac14 --- /dev/null +++ b/templates/components/detail/desktop-chat-section.html @@ -0,0 +1,115 @@ + +
+ +
+ +
+ + + +
+
+ + +
+ + + + +
+
+ +

+
+ +
+ + + +
+ +
+
${message.thinking}
+
+
+ +
+
${message.content}
+
+ +
+ + ${ t('chat.thinking') } +
+
+ + +
+
+ + +
+

+ + ${ t('chat.cannotChatTranscriptionFailed') } +

+

+ ${ t('chat.availableAfterTranscription') } +

+
+
+
diff --git a/templates/components/detail/desktop-events-tab.html b/templates/components/detail/desktop-events-tab.html new file mode 100644 index 0000000..cfa7d27 --- /dev/null +++ b/templates/components/detail/desktop-events-tab.html @@ -0,0 +1,65 @@ + +
+
+
+
+
+

+ + ${ event.title } +

+

+ ${ event.description } +

+
+
+ + +
+
+ +
+
+ + + ${ formatEventDateTime(event.start_datetime) } + +
+
+ + + ${ formatEventDateTime(event.end_datetime) } + +
+
+ + + ${ event.location } + +
+
+ +
+ + ${ event.attendees.join(', ') } +
+
+
+
+ +
+ +

+
+
+
diff --git a/templates/components/detail/desktop-header.html b/templates/components/detail/desktop-header.html new file mode 100644 index 0000000..991669d --- /dev/null +++ b/templates/components/detail/desktop-header.html @@ -0,0 +1,220 @@ + +
+
+
+
+
+

+ ${selectedRecording.title || 'Untitled Recording'} +

+ + + + + + + ${formatStatus(selectedRecording.status)} + +
+
+ + +
+ + + + + +
+
+ + +
+ +
+ + + + ${ getFolderName(selectedRecording.folder_id) } + + + + + + + + + + + + + + + + + + + + + + + + + ${tag.name} + +
+ + +
+ + + ${selectedRecording.participants || t('help.noParticipants')} + +
+ + +
+ + + ${selectedRecording.owner_username || t('sharing.unknown')} + +
+ + +
+ + + ${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'} + +
+ + +
+ + + ${item.text} + +
+ + + +
+
+
diff --git a/templates/components/detail/desktop-notes-tab.html b/templates/components/detail/desktop-notes-tab.html new file mode 100644 index 0000000..0aed304 --- /dev/null +++ b/templates/components/detail/desktop-notes-tab.html @@ -0,0 +1,57 @@ + +
+
+
+ + + +
+ +
+
+
+
+ ${selectedRecording.notes} +
+
+ ${ t('help.clickToAddNotes') } +
+
+ +
+
+ +
+
+ + +
+
+
+
diff --git a/templates/components/detail/desktop-right-panel.html b/templates/components/detail/desktop-right-panel.html new file mode 100644 index 0000000..6055f3b --- /dev/null +++ b/templates/components/detail/desktop-right-panel.html @@ -0,0 +1,272 @@ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ ${ currentSubtitle.speaker }: + ${ currentSubtitle.text } +
+ + +
+ + +
+
+
+
+
+
+
+
+ + +
+ + + + +
+ + +
+ + +
+ ${ formatAudioTime(displayCurrentTime) } + / + ${ formatAudioTime(audioDuration) } +
+ +
+ + + + + + +
+
+
+
+ + +
+ +
+ +
+ +
+
+ +
+
+ + +
+ + + + +
+ ${ formatAudioTime(displayCurrentTime) } + / + ${ formatAudioTime(audioDuration) } +
+ + +
+ + + +
+
+ +
+
+
+
+ + +
+ + + + + + + + + + + + + + +
+
+
+
+ + +
+ +
+ + + +
+ + +
+ {% include 'components/detail/desktop-summary-tab.html' %} + {% include 'components/detail/desktop-notes-tab.html' %} + {% include 'components/detail/desktop-events-tab.html' %} +
+
+ + + {% include 'components/detail/desktop-chat-section.html' %} +
diff --git a/templates/components/detail/desktop-summary-tab.html b/templates/components/detail/desktop-summary-tab.html new file mode 100644 index 0000000..a0684e7 --- /dev/null +++ b/templates/components/detail/desktop-summary-tab.html @@ -0,0 +1,71 @@ + +
+
+ +

+
+ +
+ +

+ +

+ + Cannot generate summary: transcription failed +

+ +
+ +
+
+ + + +
+ +
+
+
+
+ +
+
+ + +
+
+
+
diff --git a/templates/components/detail/desktop-transcription-panel.html b/templates/components/detail/desktop-transcription-panel.html new file mode 100644 index 0000000..95dbdc2 --- /dev/null +++ b/templates/components/detail/desktop-transcription-panel.html @@ -0,0 +1,263 @@ + +
+ +
+

+ + +

+
+ + + + +
+ + +
+ + + + +
+ + +
+ + + +
+
+ + +
+
+ + +
+ +
+ + +
+ + +
+ +

+
+ + +
+
+
+
+ +
+
+

+ ${processedTranscription.error.title} +

+

+ ${processedTranscription.error.message} +

+
+ + ${processedTranscription.error.guidance} +
+
+
+ +
+ +
+ + Technical details + +
${processedTranscription.error.technical}
+
+
+
+
+ + +
+
+
+ + + (${processedTranscription.speakers.length}) +
+ +
+
+
+ ${speaker.name} +
+
+
+ + +
+ +
+
+
+
+ ${segment.speaker} +
+
+ ${segment.sentence} +
+
+
+
+
+
+ ${segment.sentence} +
+
+
+ ${processedTranscription.content} +
+
+
+ + +
+ +
+
+
+
diff --git a/templates/components/detail/empty-state.html b/templates/components/detail/empty-state.html new file mode 100644 index 0000000..69565e0 --- /dev/null +++ b/templates/components/detail/empty-state.html @@ -0,0 +1,12 @@ + +
+
+ +

+

+ +
+
diff --git a/templates/components/detail/mobile-chat-panel.html b/templates/components/detail/mobile-chat-panel.html new file mode 100644 index 0000000..809fc37 --- /dev/null +++ b/templates/components/detail/mobile-chat-panel.html @@ -0,0 +1,59 @@ + +
+ + + +
+
+ +

+
+
+ + + +
+ +
+
${message.thinking}
+
+
+ +
+
${message.content}
+
+
+ ${ t('chat.thinking') } +
+
+
+
+ + +
+

+ + ${ t('chat.cannotChatTranscriptionFailed') } +

+
+
diff --git a/templates/components/detail/mobile-events-panel.html b/templates/components/detail/mobile-events-panel.html new file mode 100644 index 0000000..93e0cc4 --- /dev/null +++ b/templates/components/detail/mobile-events-panel.html @@ -0,0 +1,65 @@ + +
+
+ +
+
+
+
+
+
+

+ + ${ event.title } +

+

+ ${ event.description } +

+
+
+ + +
+
+
+
+ + + ${ formatEventDateTime(event.start_datetime) } + + +
+
+ + ${ event.location } +
+
+ +
+ + ${ attendee } + +
+
+
+
+
+
+
diff --git a/templates/components/detail/mobile-header.html b/templates/components/detail/mobile-header.html new file mode 100644 index 0000000..0a21c39 --- /dev/null +++ b/templates/components/detail/mobile-header.html @@ -0,0 +1,153 @@ + +
+
+
+
+
+

+ ${selectedRecording.title || 'Untitled Recording'} +

+ + + + + + + ${formatStatus(selectedRecording.status)} + +
+

+ ${selectedRecording.participants || t('help.noParticipants')} +

+ +
+ + + + ${ getFolderName(selectedRecording.folder_id) } + + + + Shared + + + ${selectedRecording.shared_with_count} + + + ${selectedRecording.public_share_count} + +
+
+ + +
+
+ +
+
+
+
+ + ${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'} +
+
+ + + ${selectedRecording.owner_username || t('sharing.unknown')} + +
+ +
+ + +
+ +
+ + + + + +
+
+
diff --git a/templates/components/detail/mobile-notes-panel.html b/templates/components/detail/mobile-notes-panel.html new file mode 100644 index 0000000..5215246 --- /dev/null +++ b/templates/components/detail/mobile-notes-panel.html @@ -0,0 +1,26 @@ + +
+
+ + + +
+
+
+
+ +
+
+ + +
+
+
+
+ +

+
+
+
+
+
diff --git a/templates/components/detail/mobile-summary-panel.html b/templates/components/detail/mobile-summary-panel.html new file mode 100644 index 0000000..0a3f33e --- /dev/null +++ b/templates/components/detail/mobile-summary-panel.html @@ -0,0 +1,45 @@ + +
+
+ + + +
+
+
+
+ +
+
+ + +
+
+
+
+ +

+
+
+ +

+ +

+ + Cannot generate summary: transcription failed +

+ +
+
+
+
+
diff --git a/templates/components/detail/mobile-transcript-panel.html b/templates/components/detail/mobile-transcript-panel.html new file mode 100644 index 0000000..4e7214c --- /dev/null +++ b/templates/components/detail/mobile-transcript-panel.html @@ -0,0 +1,130 @@ + +
+
+ + +
+
+ + +
+ + + +
+
+
+ +
+ + +
+ + +
+ +

+
+ + +
+
+
+
+ +
+
+

+ ${processedTranscription.error.title} +

+

+ ${processedTranscription.error.message} +

+
+ + ${processedTranscription.error.guidance} +
+
+
+
+ +
+
+
+ + +
+
+
+
+
${segment.speaker}
+
${segment.sentence}
+
+
+
+
+
${segment.sentence}
+
+
${processedTranscription.content}
+
+
+
+ +
+
+
+
diff --git a/templates/components/detail/tab-navigation.html b/templates/components/detail/tab-navigation.html new file mode 100644 index 0000000..5269281 --- /dev/null +++ b/templates/components/detail/tab-navigation.html @@ -0,0 +1,20 @@ + +
+ + + + + +
diff --git a/templates/components/dictia/help-tab.html b/templates/components/dictia/help-tab.html new file mode 100644 index 0000000..1bf5f27 --- /dev/null +++ b/templates/components/dictia/help-tab.html @@ -0,0 +1,91 @@ + diff --git a/templates/components/header.html b/templates/components/header.html new file mode 100644 index 0000000..c4fd0cd --- /dev/null +++ b/templates/components/header.html @@ -0,0 +1,99 @@ + +
+ +
+ + + + +
+ DictIA +

DictIA

+
+
+ + +
+ {% include 'components/token_budget_indicator.html' %} + + + {% if inquire_mode_enabled %} + + + + + {% endif %} + + + + + + + + + {% if current_user.is_authenticated %} +
+ + + +
+ + + + + + + + {% if current_user.is_admin %} + + + + + {% elif is_group_admin %} + + + + + {% endif %} +
+ + +
+ + + +
+
+ {% endif %} +
+
diff --git a/templates/components/progress-popup.html b/templates/components/progress-popup.html new file mode 100644 index 0000000..f8a6879 --- /dev/null +++ b/templates/components/progress-popup.html @@ -0,0 +1,189 @@ + +
+
+ +
+
+
+ + Processing Queue + + ${totalProcessingCount} + +
+
+ + + +
+
+ +
+ + ${activeProgressItems.filter(i => i.status === 'uploading').length} uploading, + + + ${activeProgressItems.filter(i => i.status === 'transcribing').length} transcribing, + + + ${activeProgressItems.filter(i => i.status === 'summarizing').length} summarizing, + + + ${activeProgressItems.filter(i => i.status === 'queued').length} queued + +
+
+ + +
+ + + + +
+
+ + Failed (${failedProgressItems.length}) +
+
+
+ + ${item.title} +
+ + +
+
+ +
+ ${item.friendlyError.title} + ${item.friendlyError.guidance} +
+ + ${item.errorMessage} +
+
+ + +
+
+ + Completed (${completedProgressItems.length}) +
+
+ + ${item.title} + + Done +
+
+ + +
+ +

No items in queue

+
+
+
+
diff --git a/templates/components/recording-view.html b/templates/components/recording-view.html new file mode 100644 index 0000000..841604d --- /dev/null +++ b/templates/components/recording-view.html @@ -0,0 +1,403 @@ + +
+
+ +
+
+ +
+
+
+ +
+

+
+
+
+ +
+

+
+
+ +
+
+ +
+

${recordingMode}

+
+
+
+
+ +
+
+ +
+
${formatTime(recordingTime)}
+

${ isRecording ? 'Recording in progress...' : 'Recording finished' }

+ +
+

+ Estimated size: ${formatFileSize(estimatedFileSize)} + + (${Math.round(actualBitrate / 1000)}kbps) + +

+ +
+
+ + +
+
+
+ + +
+
+ +
+
+ Keep this app visible! + Recording pauses if minimized or screen locked. +
+
+
+
+
+
+ + +
+ + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+
+
diff --git a/templates/components/sidebar.html b/templates/components/sidebar.html new file mode 100644 index 0000000..7ba3fe3 --- /dev/null +++ b/templates/components/sidebar.html @@ -0,0 +1,604 @@ + +
+
+ + + + + + +
+ +
+ ${ selectedCount } +
+ + + + + + + + + + + +
+ + + + +
+
+ + +
+
+
+
+

+ + Move to Folder +

+ +
+

+ ${ selectedCount } recording(s) selected +

+
+
+ + + + +
+ + + + + +

+ No folders created. Create folders in your account settings. +

+
+
+ +
+
+
diff --git a/templates/components/token_budget_indicator.html b/templates/components/token_budget_indicator.html new file mode 100644 index 0000000..edbebc2 --- /dev/null +++ b/templates/components/token_budget_indicator.html @@ -0,0 +1,17 @@ + + + diff --git a/templates/components/upload-view.html b/templates/components/upload-view.html new file mode 100644 index 0000000..660eaa0 --- /dev/null +++ b/templates/components/upload-view.html @@ -0,0 +1,404 @@ + +
+
+ +
+

+ + +

+
+ +
+ +
+ +

+

+
+ + + +
+
+

+ + ${ t('upload.filesToUpload') } (${pendingQueueFiles.length}) +

+
+
+
+
+ + ${item.file.name} + (${formatFileSize(item.file.size)}) +
+ +
+
+ + +
+ +
+ + +
+ + + + + + +

+ + ${ t('incognito.oneFileAtATime') } +

+
+
+ + +
+
+ +
+
+ + +
+

+ + + + + +
+ + + + + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + ${ t('recording.systemAudioNotSupported') } + +
+
+
+ + +
+ +
+ + + +
+

+ + ${ t('help.folderHasCustomPrompt') } +

+

+ + ${ t('help.createFolders') } ${ t('help.toOrganizeRecordings') } +

+
+ + +
+ + + +
+
+
+ + ${index + 1}. + + ${tag.group_name}: + ${tag.name} + + +
+
+

+ + ${ t('help.dragToReorder') } • ${ t('help.firstTagDefaultsApplied') } +

+
+ + +
+ +
+
+ + + +
+
+ + +
+
+ +
+
+
+

+ + ${ t('help.noMatchingTags') } +

+
+
+ +
+

+ + +

+
+
+

+ + ${ t('help.allTagsSelected') } +

+
+ +

+ + ${ t('help.selectedTagsCustomPrompts') } +

+

+ + ${ t('help.firstTagAsrSettings') } ${selectedTags[0].name} +

+
+ + +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +

${ t('form.hotwordsHelp') }

+
+ + +
+ + +

${ t('form.initialPromptHelp') }

+
+ +

+ + ${ t('upload.settingsApplyToAll') } +

+
+
+
+
+
diff --git a/templates/group-admin.html b/templates/group-admin.html new file mode 100644 index 0000000..34c4503 --- /dev/null +++ b/templates/group-admin.html @@ -0,0 +1,1617 @@ + + + + + + + + Group Management - DictIA + + + {% include 'includes/loading_overlay.html' %} + + + + + + + + + + + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+ + +
+
+
+ +
+
+

Group Management

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + + +
+ +
+
+

Group Management

+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + +
Group NameDescriptionMembersCreatedActions
+
${ group.name }
+
+
${ group.description || 'No description' }
+
+
+ + ${ group.member_count || 0 } members +
+
+ ${ new Date(group.created_at).toLocaleDateString() } + + + + + + +
+
+
+ +

+ +
+
+
+
+
+ + +
+
+
+

${ editingTeam ? 'Edit Group' : 'Create Group' }

+ +
+
+
+
+ + +
+
+ + +
+
+ ${ teamError } +
+
+
+ + +
+
+
+
+ + +
+
+
+

Manage Group: ${ currentTeam?.name }

+ +
+ + +
+

Add Member

+
+
+ +
+
+ +
+ +
+
+ + +
+

Current Members (${ teamMembers.length })

+
+
+
+ +
+
${ member.username }
+
${ member.email }
+
+
+
+ + +
+
+
+
+ No members yet +
+
+ +
+ ${ teamMemberError } +
+ +
+ + +
+
+
+ + +
+
+
+

Sync Group Shares

+ +
+

+ This will create shares for all recordings with group tags that have auto-sharing enabled. +

+

+ + Only missing shares will be created - existing shares won't be duplicated. +

+
+ + +
+
+
+ + +
+
+
+

+ Sync Complete +

+ +
+
+
+ Shares created: + ${ syncResults.shares_created } +
+
+ Recordings processed: + ${ syncResults.recordings_processed } +
+
+
+ +
+
+
+ + +
+
+
+

Delete Group

+ +
+

Are you sure you want to delete the group ${ teamToDelete?.name }? This will remove all members from the group. This action cannot be undone.

+
+ + +
+
+
+ + +
+
+
+

Manage Tags for ${ currentTeam?.name }

+ +
+ + +
+
+

+ ${ editingTeamTagId ? 'Edit Group Tag' : 'Create New Group Tag' } +

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +

+ Recordings with this tag will use this prompt for AI summaries and chat. +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +

+ Recordings with this tag will be deleted after this many days. Leave empty to use global retention (${ globalRetentionDays } days). +

+
+
+ + +
+
+
+ + +
+
+ + +
+

+ Note: If both are enabled, all group members will have access. If only "group admins" is enabled, only group leads will have access. +

+
+
${ teamTagError }
+ +
+
+ + +
+

Group Tags

+
+
+
+
+ +
+
${ tag.name }
+
+ + ${ tag.retention_days } day retention + + + Global retention + + + Protected + +
+
+
+
+ + +
+
+ + +
+
+ Custom prompt configured +
+
+ Language: ${ tag.default_language.toUpperCase() } +
+
+ Speakers: + ${ tag.default_min_speakers }-${ tag.default_max_speakers }auto +
+
+
+
+
+ +

No group tags created yet

+
+
+
+
+ + +
+
+
+

+ + Manage Folders for ${ currentTeam?.name } +

+ +
+ + +
+
+

+ ${ editingTeamFolderId ? 'Edit Group Folder' : 'Create New Group Folder' } +

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +

+ Recordings in this folder will use this prompt (unless overridden by a tag). +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +

+ Recordings in this folder will be deleted after this many days. Leave empty to use global retention (${ globalRetentionDays } days). +

+
+
+ + +
+
+
+ + +
+
+ + +
+

+ Note: If both are enabled, all group members will have access. If only "group admins" is enabled, only group leads will have access. +

+
+
${ teamFolderError }
+ +
+
+ + +
+

Group Folders

+
+
+
+
+ +
+
${ folder.name }
+
+ + ${ folder.retention_days } day retention + + + Global retention + + + Protected + + + ${ folder.recording_count || 0 } recordings + +
+
+
+
+ + +
+
+ + +
+
+ Custom prompt configured +
+
+ Language: ${ folder.default_language.toUpperCase() } +
+
+ Speakers: + ${ folder.default_min_speakers }-${ folder.default_max_speakers }auto +
+
+
+
+
+ +

No group folders created yet

+
+
+
+
+
+ + + + diff --git a/templates/includes/loading_overlay.html b/templates/includes/loading_overlay.html new file mode 100644 index 0000000..bbdd5a0 --- /dev/null +++ b/templates/includes/loading_overlay.html @@ -0,0 +1,69 @@ + + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6e24e81 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,171 @@ + + + + + + + + DictIA - Transcription Audio par IA + {% include 'includes/loading_overlay.html' %} + + + + + + + + + + + + + + + + + +
+
Loading...
+
+
+ + + {% include 'components/header.html' %} + +
+ +
+
+ + + {% include 'components/sidebar.html' %} + + +
+ + {% include 'components/banner.html' %} + + {% include 'components/upload-view.html' %} + + + {% include 'components/recording-view.html' %} + + + {% include 'components/detail-view.html' %} +
+
+ + + {% include 'components/progress-popup.html' %} + + + {% include 'modals/edit-modal.html' %} + {% include 'modals/delete-modal.html' %} + {% include 'modals/edit-tags-modal.html' %} + {% include 'modals/datetime-picker-modal.html' %} + {% include 'modals/shares-list-modal.html' %} + {% include 'modals/reset-modal.html' %} + {% include 'modals/reprocess-modal.html' %} + {% include 'modals/speaker-modal.html' %} + {% include 'modals/add-speaker-modal.html' %} + {% include 'modals/edit-text-modal.html' %} + {% include 'modals/text-editor-modal.html' %} + {% include 'modals/asr-editor-modal.html' %} + {% include 'modals/edit-speakers-modal.html' %} + {% include 'modals/edit-participants-modal.html' %} + {% include 'modals/color-scheme-modal.html' %} + {% include 'modals/system-audio-help-modal.html' %} + {% include 'modals/recording-disclaimer-modal.html' %} + {% include 'modals/upload-disclaimer-modal.html' %} + {% include 'modals/recording-recovery-modal.html' %} + {% include 'modals/unified-share-modal.html' %} + {% include 'modals/share-delete-modal.html' %} + {% include 'modals/duplicates-modal.html' %} + {% include 'modals/global-error.html' %} + {% include 'modals/toast-container.html' %} + + + {% include 'modals/bulk-delete-modal.html' %} + {% include 'modals/bulk-tag-modal.html' %} + {% include 'modals/bulk-reprocess-modal.html' %} + + + +
+ + + + + + + + + + + + + + + + diff --git a/templates/inquire.html b/templates/inquire.html new file mode 100644 index 0000000..0aa5abb --- /dev/null +++ b/templates/inquire.html @@ -0,0 +1,969 @@ + + + + + + + + Inquire Mode - DictIA + + + + + + + + + + + + + + + {% include 'includes/loading_overlay.html' %} + + + + + +
+ + +
+ + + + +
+ {% include 'components/token_budget_indicator.html' %} + + + + + + + + + {% if current_user.is_authenticated %} +
+ + + +
+ + + + + + + + + + + {% if current_user.is_admin or is_group_admin %} + + + + + {% endif %} +
+ + +
+ + + +
+
+ {% endif %} +
+
+ + +
+ +
+ + +
+ +
+

${t('inquire.filters')}

+ +
+ +
+
+
+ + +
+ + +
+

+ ${t('inquire.tags')} +

+ +
+ +
+
+ +
+
+ + +
+

+ ${t('inquire.speakers')} +

+
+ +
+ +
+
+ +
+
+ + +
+ +

${t('inquire.noSpeakerData')}

+

${t('inquire.speakerRequirement')}

+
+
+ + +
+

+ ${t('inquire.dateRange')} +

+
+
+ + +
+
+ + +
+
+
+ + + + + +
+

+ ${t('inquire.activeFilters')} +

+
+
+ ${inquireFilters.selectedTags.length} ${t('inquire.tagsCount')} +
+
+ ${inquireFilters.selectedSpeakers.length} ${t('inquire.speakersCount')} +
+
+ ${t('inquire.dateRangeActive')} +
+
+
+
+
+
+ + +
+ +
+ +
+
+ + + +
+

${t('inquire.askQuestions')}

+

+ ${t('inquire.selectFilters')} +

+
+

${t('inquire.exampleQuestions')}

+
    +
  • ${t('inquire.exampleQuestion1')}
  • +
  • ${t('inquire.exampleQuestion2')}
  • +
  • ${t('inquire.exampleQuestion3')}
  • +
  • ${t('inquire.exampleQuestion4')}
  • +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ ${chatProcessingStatus} + Analyzing transcriptions... +
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ ${t('inquire.sendHint')} + + ${t('inquire.filtersActive')} + +
+
+
+
+ + +
+ + +
+
+
+ + ${ globalError } +
+ +
+
+ +
+
+
+

+ + +

+

+
+ +
+
+

+ + +

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+

+ +
+
+
+ +
+
+ +
+
+
+
+
+

${share.recording_title}

+

${ t('help.sharedOn') }: ${share.created_at}

+
+ +
+
+ + +
+
+
+
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a7084f7 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,178 @@ + + + + + + + {{ title }} - DictIA + + + + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+
+

Connexion

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% if sso_enabled %} + + {% endif %} + + {% if password_login_disabled %} +
+ +
+ + {% else %} +
+ {{ form.hidden_tag() }} + +
+ {{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} + {% if form.email.errors %} + {{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} +
+ {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} + {% endif %} +
+ +
+ {{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} + {% if form.password.errors %} + {{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} +
+ {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} + {% endif %} +
+ +
+
+ {{ form.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }} + {{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }} +
+ Mot de passe oublié ? +
+ +
+ {{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }} + +
+ Pas encore de compte ? + S'inscrire +
+
+
+ {% endif %} +
+
+ + +
+ + + + diff --git a/templates/modals/add-speaker-modal.html b/templates/modals/add-speaker-modal.html new file mode 100644 index 0000000..6a19611 --- /dev/null +++ b/templates/modals/add-speaker-modal.html @@ -0,0 +1,77 @@ + +
+
+
+
+

+ +
+
+
+ + +
+ +
+ + +
+ + + + +
+ +
+ + +
+
+
+
+
${ suggestion.name }
+
+ Used ${ suggestion.use_count } time${ suggestion.use_count !== 1 ? 's' : '' } + + • Last: ${ new Date(suggestion.last_used).toLocaleDateString() } + +
+
+ +
+
+
+
+ + +
+ + +
+
+
+
diff --git a/templates/modals/asr-editor-modal.html b/templates/modals/asr-editor-modal.html new file mode 100644 index 0000000..97e6a3c --- /dev/null +++ b/templates/modals/asr-editor-modal.html @@ -0,0 +1,154 @@ + +
+
+
+

+ +
+ + +
+ +
+ + +
+ +
+ + Audio not stored in incognito mode +
+ +
+ + + + +
+ ${ formatAudioTime(modalAudioCurrentTime) } + ${ formatAudioTime(modalAudioDuration) } +
+ +
+
+
+
+ +
+ + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+ + +
+
${speaker}
+
+ +
+
+
+
+ + + + + + +
+ + + +
+
+
+ +
+
+
+ + +
+
+
diff --git a/templates/modals/bulk-action-bar.html b/templates/modals/bulk-action-bar.html new file mode 100644 index 0000000..572433c --- /dev/null +++ b/templates/modals/bulk-action-bar.html @@ -0,0 +1,79 @@ + + +
+
+ +
+ +
+ + ${ selectedCount } selected + +
+ + +
+
+ + +
+ + + + + + + + + +
+
+
+ + +
+
+ + Processing... +
+
+
+
diff --git a/templates/modals/bulk-delete-modal.html b/templates/modals/bulk-delete-modal.html new file mode 100644 index 0000000..e332652 --- /dev/null +++ b/templates/modals/bulk-delete-modal.html @@ -0,0 +1,48 @@ + +
+
+
+
+
+
+ +
+
+

Delete ${ selectedCount } Recording${ selectedCount !== 1 ? 's' : '' }

+

This action cannot be undone

+
+
+ +
+ +
+

+ You are about to permanently delete: +

+
    +
  • + + ${ recording.title || 'Untitled' } +
  • +
  • + ...and ${ selectedRecordings.length - 5 } more +
  • +
+
+ +
+ + +
+
+
+
diff --git a/templates/modals/bulk-reprocess-modal.html b/templates/modals/bulk-reprocess-modal.html new file mode 100644 index 0000000..66d01df --- /dev/null +++ b/templates/modals/bulk-reprocess-modal.html @@ -0,0 +1,89 @@ + +
+
+
+
+
+
+ +
+
+

Bulk Reprocess

+

${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } selected

+
+
+ +
+
+ +
+ +
+ +
+ + +
+
+ + +
+
+ +
+

Note

+

+ This will overwrite any manual edits to titles and summaries. +

+

+ This will overwrite all transcriptions, titles, and summaries. Manual edits will be lost. +

+
+
+
+ + +
+ + + ${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } will be queued for summary regeneration. + + + ${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } will be queued for full reprocessing. + +
+
+ +
+ + +
+
+
diff --git a/templates/modals/bulk-tag-modal.html b/templates/modals/bulk-tag-modal.html new file mode 100644 index 0000000..028f0a6 --- /dev/null +++ b/templates/modals/bulk-tag-modal.html @@ -0,0 +1,101 @@ + +
+
+
+
+
+
+ +
+
+

Bulk Tag Update

+

${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } selected

+
+
+ +
+
+ +
+ +
+ + +
+ + +
+ +
+ +

+ No tags available. Create tags in settings. +

+
+
+ + +
+ + + The selected tag will be added to all ${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' }. + + + The selected tag will be removed from all ${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } that have it. + +
+
+ +
+ + +
+
+
diff --git a/templates/modals/color-scheme-modal.html b/templates/modals/color-scheme-modal.html new file mode 100644 index 0000000..82dded7 --- /dev/null +++ b/templates/modals/color-scheme-modal.html @@ -0,0 +1,51 @@ + +
+
+
+
+

+ + +

+ +
+

+
+ +
+
+

+ + +

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
diff --git a/templates/modals/datetime-picker-modal.html b/templates/modals/datetime-picker-modal.html new file mode 100644 index 0000000..da79bbf --- /dev/null +++ b/templates/modals/datetime-picker-modal.html @@ -0,0 +1,132 @@ + +
+
+ +
+
+

+ + Select Date & Time +

+ +
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+ ${day} +
+
+ + +
+ +
+ + +
+ +
+ + : + + +
+
+ + +
+ + + + + + + + + +
+ + +
+ + + +
+
+ + +
+
+ + ${formatPickerPreview()} + + No date selected +
+
+ + +
+
+
+
diff --git a/templates/modals/delete-modal.html b/templates/modals/delete-modal.html new file mode 100644 index 0000000..679d1e7 --- /dev/null +++ b/templates/modals/delete-modal.html @@ -0,0 +1,32 @@ + +
+
+
+
+
+ +
+

+

+
+
+ +
+

+ Are you sure you want to delete "${recordingToDelete?.title || 'this recording'}"? +

+
+ + +
+
+
+
diff --git a/templates/modals/duplicates-modal.html b/templates/modals/duplicates-modal.html new file mode 100644 index 0000000..fb035a1 --- /dev/null +++ b/templates/modals/duplicates-modal.html @@ -0,0 +1,40 @@ + +
+
+
+
+
+
+ +
+

+ ${ duplicatesModalData.total_copies } ${ t('upload.copies') || 'copies' } +

+
+ +
+
+ +
+
+
+
diff --git a/templates/modals/edit-modal.html b/templates/modals/edit-modal.html new file mode 100644 index 0000000..e9a53f4 --- /dev/null +++ b/templates/modals/edit-modal.html @@ -0,0 +1,50 @@ + +
+
+
+

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + ${editingRecording.meeting_date ? formatDisplayDate(editingRecording.meeting_date) : 'Select date and time...'} + + +
+
+
+ + +
+
+
+ + +
+
+
diff --git a/templates/modals/edit-participants-modal.html b/templates/modals/edit-participants-modal.html new file mode 100644 index 0000000..e38cd86 --- /dev/null +++ b/templates/modals/edit-participants-modal.html @@ -0,0 +1,61 @@ + +
+
+
+
+

+ +
+
+
+

Manage participants for this recording.

+ +
+
+
+
+ +
+ + +
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/templates/modals/edit-speakers-modal.html b/templates/modals/edit-speakers-modal.html new file mode 100644 index 0000000..4a150c5 --- /dev/null +++ b/templates/modals/edit-speakers-modal.html @@ -0,0 +1,63 @@ + +
+
+
+
+

+ +
+
+
+

Rename speakers in the transcript.

+ +
+
+
+ ${speaker.original || 'New'} + +
+ +
+ + +
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/templates/modals/edit-tags-modal.html b/templates/modals/edit-tags-modal.html new file mode 100644 index 0000000..31e49ba --- /dev/null +++ b/templates/modals/edit-tags-modal.html @@ -0,0 +1,85 @@ + +
+
+
+
+

+ +
+
+
+ +
+ +
+ + ${index + 1}. + + ${ tag.group_name }: ${ tag.name } + + +
+

+

+ + ${ t('help.dragToReorder') } • ${ t('help.firstTagDefaultsApplied') } +

+
+ + +
+ + + +
+ + +
+ + +
+ +
+

+
+
+
+ +
+
+
diff --git a/templates/modals/edit-text-modal.html b/templates/modals/edit-text-modal.html new file mode 100644 index 0000000..e4bf55d --- /dev/null +++ b/templates/modals/edit-text-modal.html @@ -0,0 +1,38 @@ + +
+
+
+
+

Edit Transcript Text

+ +
+
+
+ + +
+ + +
+ + +
+ + +
+
+
+
diff --git a/templates/modals/global-error.html b/templates/modals/global-error.html new file mode 100644 index 0000000..5126bc0 --- /dev/null +++ b/templates/modals/global-error.html @@ -0,0 +1,18 @@ + + diff --git a/templates/modals/recording-disclaimer-modal.html b/templates/modals/recording-disclaimer-modal.html new file mode 100644 index 0000000..bbed85f --- /dev/null +++ b/templates/modals/recording-disclaimer-modal.html @@ -0,0 +1,36 @@ + +
+
+
+
+
+ +

+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
diff --git a/templates/modals/recording-recovery-modal.html b/templates/modals/recording-recovery-modal.html new file mode 100644 index 0000000..4007b2c --- /dev/null +++ b/templates/modals/recording-recovery-modal.html @@ -0,0 +1,64 @@ + +
+
+
+
+
+ +

+
+ +
+
+ +
+
+
+ +
+

${ t('recording.recoveryFound') }

+

${ t('recording.recoveryDescription') }

+
+
+
+ +
+
+ ${ t('recording.recordingMode') }: + ${ formatRecordingMode(recoverableRecording.mode) } +
+
+ ${ t('recording.duration') }: + ${ formatTime(recoverableRecording.duration) } +
+
+ ${ t('recording.size') }: + ${ formatFileSize(recoverableRecording.totalSize) } +
+
+ ${ t('recording.startedAt') }: + ${ formatDateTime(recoverableRecording.startTime) } +
+
+ ${ t('recording.notes') }: +

${ recoverableRecording.notes }

+
+
+
+ +
+ + +
+
+
diff --git a/templates/modals/reprocess-modal.html b/templates/modals/reprocess-modal.html new file mode 100644 index 0000000..ba6b2a2 --- /dev/null +++ b/templates/modals/reprocess-modal.html @@ -0,0 +1,184 @@ + +
+
+ +
+
+
+
+ +
+
+

+

${ reprocessType } reprocessing

+
+
+ +
+
+ + + + + +
+ + +
+
+
diff --git a/templates/modals/reset-modal.html b/templates/modals/reset-modal.html new file mode 100644 index 0000000..6acba42 --- /dev/null +++ b/templates/modals/reset-modal.html @@ -0,0 +1,31 @@ + +
+
+
+
+
+
+ +
+
+

+
+
+ +
+

+ This will mark the recording as 'Failed'. This is useful if processing is stuck. You will be able to reprocess it afterwards. +

+
+
+ + +
+
+
diff --git a/templates/modals/share-delete-modal.html b/templates/modals/share-delete-modal.html new file mode 100644 index 0000000..1f54397 --- /dev/null +++ b/templates/modals/share-delete-modal.html @@ -0,0 +1,52 @@ + +
+
+ +
+
+
+
+ +
+
+

Delete Shared Link

+

This action cannot be undone

+
+
+ +
+
+ + +
+

+ Are you sure you want to delete the shared link for: +

+
+

${shareToDelete.recording_title}

+

Shared on: ${shareToDelete.created_at}

+
+
+
+ +

+ The share link will be permanently deleted and anyone with this link will no longer be able to access the recording. +

+
+
+
+ + +
+ + +
+
+
diff --git a/templates/modals/share-modal.html b/templates/modals/share-modal.html new file mode 100644 index 0000000..1a4b8cf --- /dev/null +++ b/templates/modals/share-modal.html @@ -0,0 +1,82 @@ + +
+
+
+

+ +
+
+

+ + +
+ + +
+ + + + + +
+ +

Loading share links...

+
+ + +
+

+ Existing Share Links (${recordingPublicShares.length}) +

+
+
+
+

${ t('help.sharedOn') }: ${share.created_at}

+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +

+

Click the button above to create one

+
+
+
+ +
+
+
diff --git a/templates/modals/shares-list-modal.html b/templates/modals/shares-list-modal.html new file mode 100644 index 0000000..b765e51 --- /dev/null +++ b/templates/modals/shares-list-modal.html @@ -0,0 +1,46 @@ + +
+
+
+

+ +
+
+
+ +
+
+ +
+
+
+
+
+

${share.recording_title}

+

${ t('help.sharedOn') }: ${share.created_at}

+
+ +
+
+ + +
+
+ + +
+
+
+
+
+
diff --git a/templates/modals/speaker-modal.html b/templates/modals/speaker-modal.html new file mode 100644 index 0000000..02d1fe0 --- /dev/null +++ b/templates/modals/speaker-modal.html @@ -0,0 +1,353 @@ + +
+
+ +
+
+

+ +
+ +
+
+ +
+

+

+

+
+
+
+
+ + +
+ + +
+ + + + + +
+ +
+ + +
+ +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+ + +
+
+
+
diff --git a/templates/modals/system-audio-help-modal.html b/templates/modals/system-audio-help-modal.html new file mode 100644 index 0000000..4751881 --- /dev/null +++ b/templates/modals/system-audio-help-modal.html @@ -0,0 +1,53 @@ + +
+
+
+
+

+ +
+
+
+
+
+ +
+

Compatibilité des navigateurs

+

+ L'enregistrement audio système fonctionne mieux dans Chrome, Edge et Brave. Firefox est supporté mais nécessite que l'onglet soit en train de jouer de l'audio. Non supporté sur Safari ou les appareils mobiles. +

+
+
+
+ +
+

Comment enregistrer l'audio système :

+
    +
  1. Cliquez sur le bouton "Audio Système" ou "Micro + Système"
  2. +
  3. Une fenêtre de partage d'écran apparaîtra
  4. +
  5. Sélectionnez un onglet ou une fenêtre qui joue de l'audio activement
  6. +
  7. Assurez-vous que la case "Partager l'audio" est cochée
  8. +
  9. Cliquez sur "Partager" pour démarrer l'enregistrement
  10. +
+
+ +
+

Dépannage :

+
    +
  • Important : L'onglet/fenêtre doit jouer de l'audio au moment du partage
  • +
  • Vérifiez que la case "Partager l'audio" est cochée dans la fenêtre de partage
  • +
  • Sur Firefox, lancez d'abord la lecture audio, puis cliquez sur enregistrer
  • +
  • Certains contenus peuvent avoir une protection DRM qui bloque l'enregistrement
  • +
  • Non supporté sur Safari ou les navigateurs mobiles
  • +
+
+
+
+ +
+
+
diff --git a/templates/modals/text-editor-modal.html b/templates/modals/text-editor-modal.html new file mode 100644 index 0000000..00d71b4 --- /dev/null +++ b/templates/modals/text-editor-modal.html @@ -0,0 +1,32 @@ + +
+
+
+
+

+ +
+
+
+
+ + +
+
+
+ + +
+
+
diff --git a/templates/modals/toast-container.html b/templates/modals/toast-container.html new file mode 100644 index 0000000..1c977e6 --- /dev/null +++ b/templates/modals/toast-container.html @@ -0,0 +1,2 @@ + +
diff --git a/templates/modals/unified-share-modal.html b/templates/modals/unified-share-modal.html new file mode 100644 index 0000000..769718a --- /dev/null +++ b/templates/modals/unified-share-modal.html @@ -0,0 +1,281 @@ + +
+
+
+
+
+

+

${internalShareRecording?.title}

+
+ +
+
+ +
+ +
+

+ + +

+ +

+ +
+ +
+ + +
+ + + + + +
+ +

Loading share links...

+
+ + +
+
+ Existing Share Links (${recordingPublicShares.length}) +
+
+
+
+ + Created: ${share.created_at} + + Summary + + + Notes + +
+ +
+
+ + +
+
+
+ + +
+ +

No public share links yet

+

Click the button above to create one

+
+
+
+ + +
+

+ + +

+ +

+ + +
+
+ + Share with User +
+ + +
+ +
+ + +
+ + +
+ +

Loading users...

+
+
+ +

No users found

+
+
+
+
+
+ ${user.username ? user.username.charAt(0).toUpperCase() : '?'} +
+
+

${user.username}

+

${user.email}

+
+ +
+
+
+
+ + +
+

Enter the exact username to share with

+
+ + +
+

+ + You must enter the exact username. If the user exists, they will be added to the share list. +

+
+ + +
+

Share Permissions

+
+ + +
+
+ + +
+
+
+ + +
+
+ + + Already Shared With + + + ${recordingInternalShares.length} user(s) + +
+ +
+ +

Loading shares...

+
+
+ +

Not shared with anyone yet

+

Select a user above to share this recording

+
+
+
+
+
+ ${share.username ? share.username.charAt(0).toUpperCase() : '#'} +
+
+

${share.username || 'User #' + share.user_id}

+

${share.is_owner ? 'Recording Owner' : formatShareDate(share.created_at)}

+
+ + Owner + + +
+
+ +
+
+
+
+
+
+ + +
+
+ +

Sharing Not Available

+

+ This recording was shared with you, but you don't have permission to share it with others. +

+
+
+ +
+ +
+
+
diff --git a/templates/modals/upload-disclaimer-modal.html b/templates/modals/upload-disclaimer-modal.html new file mode 100644 index 0000000..554e798 --- /dev/null +++ b/templates/modals/upload-disclaimer-modal.html @@ -0,0 +1,36 @@ + +
+
+
+
+
+ +

+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..fc405ef --- /dev/null +++ b/templates/register.html @@ -0,0 +1,164 @@ + + + + + + + {{ title }} - DictIA + + + + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+
+

+ + DictIA + DictIA + +

+
+ +
+
+

Create an Account

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} + {% if form.username.errors %} + {{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} +
+ {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} + {% endif %} +
+ +
+ {{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} + {% if form.email.errors %} + {{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} +
+ {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} + {% endif %} +
+ +
+ {{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} + {% if form.password.errors %} + {{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} +
+ {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} + {% endif %} +

Password must be at least 8 characters long.

+
+ +
+ {{ form.confirm_password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} + {% if form.confirm_password.errors %} + {{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} +
+ {% for error in form.confirm_password.errors %} + {{ error }} + {% endfor %} +
+ {% else %} + {{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} + {% endif %} +
+ +
+ {{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }} + +
+ Already have an account? + Login here +
+
+
+
+
+ + +
+ + + + diff --git a/templates/share.html b/templates/share.html new file mode 100644 index 0000000..ebc375b --- /dev/null +++ b/templates/share.html @@ -0,0 +1,898 @@ + + + + + + + Shared Recording - {{ recording.title }} + + + + + + + + + + + + {% include 'includes/loading_overlay.html' %} + + + + +
+ +
+
+ DictIA +
+

${ recording.title }

+

Shared Recording

+
+
+
+ +
+
+ + +
+ +
+
+ +
+ + Audio file has been archived and is no longer available for playback. +
+ +
+ + +
+ + + +
+ ${ formatAudioTime(audioCurrentTime) } + ${ formatAudioTime(audioDuration) } +
+ +
+ + +
+
+ +
+
+
+ +
+
+
+
+ +
+ + +
+
+
+
+
+ + +
+
+
+ + {% if recording.summary %} + + {% endif %} + {% if recording.notes %} + + {% endif %} +
+
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + {% if readable_mode and transcript %} + + + {% if transcript.has_speakers and transcript.speakers %} +
+
+
+ + Speakers + ({{ transcript.speakers|length }}) +
+ +
+
+ {% for speaker in transcript.speakers %} +
+ {{ speaker.name }} +
+ {% endfor %} +
+
+ {% endif %} + + +
+ {% if transcript.is_json and transcript.segments %} + +
+ {% for segment in transcript.segments %} +
+ {% if segment.show_speaker and segment.speaker %} +
{{ segment.speaker }}
+ {% endif %} +
{{ segment.text }}
+
+ {% endfor %} +
+ + +
+ {% set ns = namespace(last_speaker=None) %} + {% for segment in transcript.segments %} + {% if segment.speaker != ns.last_speaker %} + {% if not loop.first %}
{% endif %} +
+ {% endif %} +
+
{{ segment.text }}
+
+ {% set ns.last_speaker = segment.speaker %} + {% endfor %} + {% if transcript.segments %}
{% endif %} +
+ {% else %} + +
{{ transcript.plain_text }}
+ {% endif %} +
+ {% else %} + + +
+
+
+ + Speakers + (${processedTranscription.speakers.length}) +
+ +
+
+
+ ${speaker.name} +
+
+
+ + +
+ +
+
+
+ ${segment.speaker} +
+
+ ${segment.sentence} +
+
+
+ + +
+
+
+
+ ${bubble.sentence} +
+
+
+
+ + +
+ ${processedTranscription.content} +
+
+ {% endif %} +
+ + + {% if recording.summary %} +
+
+ +
+
+ {{ recording.summary|safe }} +
+
+ {% endif %} + + + {% if recording.notes %} +
+
+ +
+
+ {{ recording.notes|safe }} +
+
+ {% endif %} +
+
+ + + + + + +
+ + + + + diff --git a/tests/test_api_v1_speakers.py b/tests/test_api_v1_speakers.py new file mode 100644 index 0000000..2b616c9 --- /dev/null +++ b/tests/test_api_v1_speakers.py @@ -0,0 +1,971 @@ +#!/usr/bin/env python3 +""" +Test suite for Speaker API v1 endpoints. + +Covers: + - PUT /recordings//speakers/assign (17 tests) + - POST /recordings//speakers/identify (10 tests) + - PUT /settings/auto-summarization (5 tests) + - Regression for GET /speakers and GET /recordings//speakers (2 tests) + +Pattern follows tests/test_api_v1_upload.py — standalone, no pytest fixtures. +""" + +import json +import secrets +import sys +import os +from unittest.mock import patch, MagicMock + +# Add parent directory so we can import the app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.app import app, db +from src.models import User, APIToken, Recording, Speaker +from src.utils.token_auth import hash_token + +# --------------------------------------------------------------------------- +# Test data constants +# --------------------------------------------------------------------------- + +SAMPLE_TRANSCRIPTION_JSON = json.dumps([ + {"speaker": "SPEAKER_00", "sentence": "Hi, I'm Alice."}, + {"speaker": "SPEAKER_01", "sentence": "Hello Alice, I'm Bob."}, + {"speaker": "SPEAKER_00", "sentence": "Nice to meet you, Bob."}, +]) + +SAMPLE_TRANSCRIPTION_TEXT = ( + "[SPEAKER_00]: Hi, I'm Alice.\n" + "[SPEAKER_01]: Hello Alice, I'm Bob.\n" + "[SPEAKER_00]: Nice to meet you, Bob." +) + +SAMPLE_EMBEDDINGS = json.dumps({ + "SPEAKER_00": [0.1] * 256, + "SPEAKER_01": [0.2] * 256, +}) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_or_create_test_user(suffix=""): + """Get or create a test user. Returns (user, created_bool).""" + username = f"speaker_test_user{suffix}" + user = User.query.filter_by(username=username).first() + created = False + if not user: + user = User( + username=username, + email=f"{username}@local.test", + name="Test User" if not suffix else None, + ) + db.session.add(user) + db.session.commit() + created = True + return user, created + + +def _create_api_token(user): + """Create a fresh API token. Returns (token_record, plaintext).""" + plaintext = f"test-token-{secrets.token_urlsafe(16)}" + token = APIToken( + user_id=user.id, + token_hash=hash_token(plaintext), + name="test-api-token", + ) + db.session.add(token) + db.session.commit() + return token, plaintext + + +def _create_test_recording(user, transcription=None, speaker_embeddings=None, status="COMPLETED"): + """Create a Recording owned by *user*.""" + rec = Recording( + user_id=user.id, + title="Test Recording", + status=status, + transcription=transcription, + speaker_embeddings=speaker_embeddings, + ) + db.session.add(rec) + db.session.commit() + return rec + + +def _create_test_speaker(user, name="Alice"): + """Create a Speaker owned by *user*.""" + speaker = Speaker(name=name, user_id=user.id) + db.session.add(speaker) + db.session.commit() + return speaker + + +def _cleanup(*objects): + """Delete DB objects in reverse order, committing once.""" + for obj in reversed(objects): + try: + db.session.delete(obj) + except Exception: + db.session.rollback() + try: + merged = db.session.merge(obj) + db.session.delete(merged) + except Exception: + pass + db.session.commit() + + +# ========================================================================= +# Group 1: PUT /recordings//speakers/assign (17 tests) +# ========================================================================= + + +def test_assign_no_auth(): + """No token -> 302 redirect (Flask-Login).""" + with app.app_context(): + user, cu = _get_or_create_test_user() + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + json={"speaker_map": {}}) + assert resp.status_code in (302, 401), f"Expected 302/401, got {resp.status_code}" + return True + finally: + _cleanup(rec) + if cu: + _cleanup(user) + + +def test_assign_recording_not_found(): + """Nonexistent recording ID -> 404.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + client = app.test_client() + try: + resp = client.put("/api/v1/recordings/999999/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {}}) + assert resp.status_code == 404, f"Expected 404, got {resp.status_code}" + return True + finally: + _cleanup(token_rec) + if cu: + _cleanup(user) + + +def test_assign_wrong_user_recording(): + """Other user's recording -> 403.""" + with app.app_context(): + owner, co = _get_or_create_test_user("_owner") + other, cu = _get_or_create_test_user("_other") + token_rec, token = _create_api_token(other) + rec = _create_test_recording(owner, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": "Alice"}}) + assert resp.status_code == 403, f"Expected 403, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(other) + if co: + _cleanup(owner) + + +def test_assign_missing_speaker_map(): + """Body {} -> 400 'speaker_map is required'.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + body = resp.get_json() + assert "speaker_map" in body.get("error", "").lower(), f"Unexpected error: {body}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_invalid_speaker_map_type(): + """speaker_map: 'string' -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": "not a dict"}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_string_value_json_transcript(): + """Happy path: string names update JSON segments + participants.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": "Alice", "SPEAKER_01": "Bob"}}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("success") is True + # Verify participants + participants = body["recording"]["participants"] + assert "Alice" in participants and "Bob" in participants + # Verify transcription was updated + db.session.refresh(rec) + segments = json.loads(rec.transcription) + assert segments[0]["speaker"] == "Alice" + assert segments[1]["speaker"] == "Bob" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_object_value_with_name(): + """Happy path: {name, isMe} object format.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": { + "SPEAKER_00": {"name": "Alice", "isMe": False}, + }}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + db.session.refresh(rec) + segments = json.loads(rec.transcription) + assert segments[0]["speaker"] == "Alice" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_is_me_flag_with_user_name(): + """isMe: true resolves to user.name.""" + with app.app_context(): + user, cu = _get_or_create_test_user() # user.name == "Test User" + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": { + "SPEAKER_00": {"name": "", "isMe": True}, + }}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + db.session.refresh(rec) + segments = json.loads(rec.transcription) + assert segments[0]["speaker"] == "Test User", f"Got {segments[0]['speaker']}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_is_me_flag_without_user_name(): + """isMe: true falls back to 'Me' when user.name is None.""" + with app.app_context(): + user, cu = _get_or_create_test_user("_noname") + # Ensure user.name is None + user.name = None + db.session.commit() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": { + "SPEAKER_00": {"name": "", "isMe": True}, + }}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + db.session.refresh(rec) + segments = json.loads(rec.transcription) + assert segments[0]["speaker"] == "Me", f"Got {segments[0]['speaker']}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_plain_text_transcript(): + """Replaces [SPEAKER_XX] in plain text format.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_TEXT) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": "Alice", "SPEAKER_01": "Bob"}}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + db.session.refresh(rec) + assert "[Alice]" in rec.transcription + assert "[Bob]" in rec.transcription + assert "[SPEAKER_00]" not in rec.transcription + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_speaker_xx_filtered_from_participants(): + """Unresolved SPEAKER_XX labels excluded from participants.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + # Only assign one speaker - SPEAKER_01 stays unresolved + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": "Alice"}}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + participants = body["recording"]["participants"] + assert "SPEAKER_01" not in participants, f"SPEAKER_01 should be filtered: {participants}" + assert "Alice" in participants + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_invalid_value_type(): + """Array value -> 400 'Invalid value type'.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": [1, 2, 3]}}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + body = resp.get_json() + assert "invalid value type" in body.get("error", "").lower(), f"Unexpected: {body}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_empty_speaker_map(): + """Empty speaker_map {} -> 200 with no changes.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {}}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("success") is True + # Transcription should be unchanged + db.session.refresh(rec) + segments = json.loads(rec.transcription) + assert segments[0]["speaker"] == "SPEAKER_00" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_regenerate_summary(): + """regenerate_summary: true -> job_queue.enqueue called, summary_queued: true.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + mock_jq = MagicMock() + mock_jq.enqueue = MagicMock(return_value="job-123") + with patch("src.services.job_queue.job_queue", mock_jq): + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={ + "speaker_map": {"SPEAKER_00": "Alice"}, + "regenerate_summary": True, + }) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("summary_queued") is True + mock_jq.enqueue.assert_called_once() + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_embeddings_updated(): + """With speaker_embeddings -> update_speaker_embedding called, counts returned.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording( + user, + transcription=SAMPLE_TRANSCRIPTION_JSON, + speaker_embeddings=SAMPLE_EMBEDDINGS, + ) + speaker = _create_test_speaker(user, "Alice") + client = app.test_client() + try: + mock_update = MagicMock() + mock_snippets = MagicMock(return_value=2) + with patch("src.services.speaker_embedding_matcher.update_speaker_embedding", mock_update), \ + patch("src.services.speaker_snippets.create_speaker_snippets", mock_snippets): + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": "Alice"}}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("embeddings_updated") >= 1, f"embeddings_updated: {body}" + mock_update.assert_called() + return True + finally: + _cleanup(rec, speaker, token_rec) + if cu: + _cleanup(user) + + +def test_assign_no_transcription(): + """Recording without transcription -> speakers applied to empty content gracefully.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=None) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": "Alice"}}) + # Should succeed (or at least not 500) + assert resp.status_code in (200, 400), f"Expected 200/400, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_assign_whitespace_name_trimmed(): + """Names with leading/trailing whitespace get trimmed.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign", + headers={"X-API-Token": token}, + json={"speaker_map": {"SPEAKER_00": " Alice "}}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + db.session.refresh(rec) + segments = json.loads(rec.transcription) + assert segments[0]["speaker"] == "Alice", f"Name not trimmed: '{segments[0]['speaker']}'" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +# ========================================================================= +# Group 2: POST /recordings//speakers/identify (10 tests) +# ========================================================================= + + +def test_identify_no_auth(): + """No token -> 302.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify") + assert resp.status_code in (302, 401), f"Expected 302/401, got {resp.status_code}" + return True + finally: + _cleanup(rec) + if cu: + _cleanup(user) + + +def test_identify_recording_not_found(): + """Nonexistent ID -> 404.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + client = app.test_client() + try: + resp = client.post("/api/v1/recordings/999999/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 404, f"Expected 404, got {resp.status_code}" + return True + finally: + _cleanup(token_rec) + if cu: + _cleanup(user) + + +def test_identify_wrong_user_recording(): + """Other user's recording -> 403.""" + with app.app_context(): + owner, co = _get_or_create_test_user("_id_owner") + other, cu = _get_or_create_test_user("_id_other") + token_rec, token = _create_api_token(other) + rec = _create_test_recording(owner, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 403, f"Expected 403, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(other) + if co: + _cleanup(owner) + + +def test_identify_no_transcription(): + """No transcription -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=None) + client = app.test_client() + try: + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_identify_non_json_transcription(): + """Plain text -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_TEXT) + client = app.test_client() + try: + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_identify_json_but_not_list(): + """Dict JSON -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=json.dumps({"key": "value"})) + client = app.test_client() + try: + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_identify_happy_path(): + """Mock LLM returns names -> 200 with speaker_map.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + # Build a mock LLM completion response + mock_completion = MagicMock() + mock_completion.choices = [MagicMock()] + mock_completion.choices[0].message.content = json.dumps({ + "SPEAKER_00": "Alice", + "SPEAKER_01": "Bob", + }) + + with patch("src.services.llm.call_llm_completion", return_value=mock_completion), \ + patch("src.models.system.SystemSetting") as mock_ss: + mock_ss.get_setting.return_value = 30000 + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("success") is True + sm = body.get("speaker_map", {}) + assert sm.get("SPEAKER_00") == "Alice" + assert sm.get("SPEAKER_01") == "Bob" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_identify_post_processing_unknown_values(): + """'Unknown'/'N/A' cleared to ''.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + mock_completion = MagicMock() + mock_completion.choices = [MagicMock()] + mock_completion.choices[0].message.content = json.dumps({ + "SPEAKER_00": "Unknown", + "SPEAKER_01": "N/A", + }) + + with patch("src.services.llm.call_llm_completion", return_value=mock_completion), \ + patch("src.models.system.SystemSetting") as mock_ss: + mock_ss.get_setting.return_value = 30000 + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + sm = body.get("speaker_map", {}) + assert sm.get("SPEAKER_00") == "", f"Expected empty, got {sm.get('SPEAKER_00')}" + assert sm.get("SPEAKER_01") == "", f"Expected empty, got {sm.get('SPEAKER_01')}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_identify_no_speakers_in_transcript(): + """Segments without speaker field -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + no_speakers = json.dumps([{"sentence": "Hello"}, {"sentence": "World"}]) + rec = _create_test_recording(user, transcription=no_speakers) + client = app.test_client() + try: + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +def test_identify_llm_error(): + """LLM raises exception -> 500.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + with patch("src.services.llm.call_llm_completion", + side_effect=RuntimeError("LLM down")), \ + patch("src.models.system.SystemSetting") as mock_ss: + mock_ss.get_setting.return_value = 30000 + resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify", + headers={"X-API-Token": token}) + assert resp.status_code == 500, f"Expected 500, got {resp.status_code}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +# ========================================================================= +# Group 3: PUT /settings/auto-summarization (5 tests) +# ========================================================================= + + +def test_auto_summarization_no_auth(): + """No token -> 302.""" + with app.app_context(): + client = app.test_client() + resp = client.put("/api/v1/settings/auto-summarization", + json={"enabled": True}) + assert resp.status_code in (302, 401), f"Expected 302/401, got {resp.status_code}" + return True + + +def test_auto_summarization_missing_enabled(): + """Body {} -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + client = app.test_client() + try: + resp = client.put("/api/v1/settings/auto-summarization", + headers={"X-API-Token": token}, + json={}) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + body = resp.get_json() + assert "enabled" in body.get("error", "").lower(), f"Unexpected: {body}" + return True + finally: + _cleanup(token_rec) + if cu: + _cleanup(user) + + +def test_auto_summarization_invalid_json(): + """Non-JSON body -> 400.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + client = app.test_client() + try: + resp = client.put("/api/v1/settings/auto-summarization", + headers={"X-API-Token": token, + "Content-Type": "application/json"}, + data="not valid json") + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + return True + finally: + _cleanup(token_rec) + if cu: + _cleanup(user) + + +def test_auto_summarization_enable(): + """enabled: true -> updates user, returns true.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + user.auto_summarization = False + db.session.commit() + token_rec, token = _create_api_token(user) + client = app.test_client() + try: + resp = client.put("/api/v1/settings/auto-summarization", + headers={"X-API-Token": token}, + json={"enabled": True}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("auto_summarization") is True + db.session.refresh(user) + assert user.auto_summarization is True + return True + finally: + _cleanup(token_rec) + if cu: + _cleanup(user) + + +def test_auto_summarization_disable(): + """enabled: false -> updates user, returns false.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + user.auto_summarization = True + db.session.commit() + token_rec, token = _create_api_token(user) + client = app.test_client() + try: + resp = client.put("/api/v1/settings/auto-summarization", + headers={"X-API-Token": token}, + json={"enabled": False}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + assert body.get("auto_summarization") is False + db.session.refresh(user) + assert user.auto_summarization is False + return True + finally: + _cleanup(token_rec) + if cu: + _cleanup(user) + + +# ========================================================================= +# Group 4: Regression tests (2 tests) +# ========================================================================= + + +def test_regression_get_speakers_list(): + """GET /speakers still returns user's speakers.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + speaker = _create_test_speaker(user, "Regression Speaker") + client = app.test_client() + try: + resp = client.get("/api/v1/speakers", + headers={"X-API-Token": token}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + names = [s["name"] for s in body.get("speakers", [])] + assert "Regression Speaker" in names, f"Speaker not found: {names}" + return True + finally: + _cleanup(speaker, token_rec) + if cu: + _cleanup(user) + + +def test_regression_get_recording_speakers(): + """GET /recordings//speakers still returns transcript speakers.""" + with app.app_context(): + user, cu = _get_or_create_test_user() + token_rec, token = _create_api_token(user) + rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON) + client = app.test_client() + try: + with patch("src.services.speaker_embedding_matcher.find_matching_speakers", return_value={}): + resp = client.get(f"/api/v1/recordings/{rec.id}/speakers", + headers={"X-API-Token": token}) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + body = resp.get_json() + labels = [s["label"] for s in body.get("speakers", [])] + assert "SPEAKER_00" in labels and "SPEAKER_01" in labels, f"Labels: {labels}" + return True + finally: + _cleanup(rec, token_rec) + if cu: + _cleanup(user) + + +# ========================================================================= +# Runner +# ========================================================================= + +ALL_TESTS = [ + # Group 1: assign + test_assign_no_auth, + test_assign_recording_not_found, + test_assign_wrong_user_recording, + test_assign_missing_speaker_map, + test_assign_invalid_speaker_map_type, + test_assign_string_value_json_transcript, + test_assign_object_value_with_name, + test_assign_is_me_flag_with_user_name, + test_assign_is_me_flag_without_user_name, + test_assign_plain_text_transcript, + test_assign_speaker_xx_filtered_from_participants, + test_assign_invalid_value_type, + test_assign_empty_speaker_map, + test_assign_regenerate_summary, + test_assign_embeddings_updated, + test_assign_no_transcription, + test_assign_whitespace_name_trimmed, + # Group 2: identify + test_identify_no_auth, + test_identify_recording_not_found, + test_identify_wrong_user_recording, + test_identify_no_transcription, + test_identify_non_json_transcription, + test_identify_json_but_not_list, + test_identify_happy_path, + test_identify_post_processing_unknown_values, + test_identify_no_speakers_in_transcript, + test_identify_llm_error, + # Group 3: auto-summarization + test_auto_summarization_no_auth, + test_auto_summarization_missing_enabled, + test_auto_summarization_invalid_json, + test_auto_summarization_enable, + test_auto_summarization_disable, + # Group 4: regression + test_regression_get_speakers_list, + test_regression_get_recording_speakers, +] + + +def main(): + print(f"Running {len(ALL_TESTS)} Speaker API tests...\n") + passed = 0 + failed = 0 + errors = [] + + for test_fn in ALL_TESTS: + name = test_fn.__name__ + try: + result = test_fn() + if result: + print(f" PASS {name}") + passed += 1 + else: + print(f" FAIL {name} (returned False)") + failed += 1 + errors.append(name) + except Exception as e: + print(f" ERROR {name}: {e}") + failed += 1 + errors.append(name) + + print(f"\n{'=' * 60}") + print(f"Results: {passed} passed, {failed} failed out of {len(ALL_TESTS)}") + if errors: + print("Failed tests:") + for e in errors: + print(f" - {e}") + print('=' * 60) + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_api_v1_upload.py b/tests/test_api_v1_upload.py new file mode 100644 index 0000000..d003780 --- /dev/null +++ b/tests/test_api_v1_upload.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Integration test for API v1 recording upload endpoint. + +Validates API token authentication and expected 400 response when no file is provided. +""" + +import secrets +import sys +import os + +# Add the parent directory to the path to import app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.app import app, db +from src.models import User, APIToken +from src.utils.token_auth import hash_token + + +def _get_or_create_test_user(): + user = User.query.filter_by(username="api_test_user").first() + created = False + if not user: + user = User(username="api_test_user", email="api_test_user@local.test") + db.session.add(user) + db.session.commit() + created = True + return user, created + + +def _create_api_token(user): + plaintext = f"test-token-{secrets.token_urlsafe(16)}" + token = APIToken( + user_id=user.id, + token_hash=hash_token(plaintext), + name="test-api-token" + ) + db.session.add(token) + db.session.commit() + return token, plaintext + + +def test_upload_requires_file(): + with app.app_context(): + user, created_user = _get_or_create_test_user() + token_record, token = _create_api_token(user) + client = app.test_client() + + try: + response = client.post( + "/api/v1/recordings/upload", + headers={"X-API-Token": token} + ) + + if response.status_code != 400: + print(f"❌ Expected 400, got {response.status_code}") + return False + + payload = response.get_json(silent=True) or {} + if payload.get("error") != "No file provided": + print(f"❌ Unexpected error payload: {payload}") + return False + + print("✅ Token auth works and missing file returns 400 as expected") + return True + finally: + db.session.delete(token_record) + db.session.commit() + if created_user: + db.session.delete(user) + db.session.commit() + + +def main(): + print("🚀 Running API v1 upload test...\n") + ok = test_upload_requires_file() + print("\n" + ("✅ PASS" if ok else "❌ FAIL")) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..cc96177 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +""" +Tests for the Loi 25 audit system. + +Covers: +- audit_access(): adds to session, does NOT commit +- audit_login(): commits independently +- audit_failed_login(): commits, uses email_hash (not plain email) +- _is_recent_duplicate(): deduplication window +- get_access_logs() / get_auth_logs(): pagination and filters +- Admin API endpoints: /api/admin/audit/status, /access, /auth +""" + +import os +import sys +import hashlib + +# Add the parent directory to the path to import app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:') +os.environ.setdefault('ENABLE_AUDIT_LOG', 'true') +os.environ['ENABLE_AUDIT_LOG'] = 'true' + +from src.app import app, db +from src.models import User +from src.models.access_log import AccessLog +from src.models.auth_log import AuthLog + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_user(username, is_admin=False): + user = User(username=username, email=f"{username}@test.local", is_admin=is_admin) + user.set_password("TestPass1!") + db.session.add(user) + db.session.commit() + return user + + +def _login_client(client, user): + """Push a real Flask-Login session for a user via test request context.""" + from flask_login import login_user + with client.session_transaction() as sess: + pass + # Use the test client's post to login + with app.test_request_context(): + login_user(user) + # Directly inject the user_id into the session cookie + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + +# --------------------------------------------------------------------------- +# Service-level tests +# --------------------------------------------------------------------------- + +def test_audit_access_no_commit(): + """audit_access() adds to session but does NOT commit.""" + with app.app_context(): + db.create_all() + user = _make_user("audit_no_commit") + try: + initial_count = AccessLog.query.count() + + from src.services.audit import audit_access + with app.test_request_context(): + from flask_login import login_user + login_user(user) + log = audit_access('edit', 'recording', 1, user_id=user.id) + + # Log object returned but not yet in DB (no commit happened) + assert log is not None, "audit_access should return a log object" + # The session hasn't been committed, so count is still the same + # (SQLite in :memory: — flush to verify it's in session) + db.session.flush() + assert AccessLog.query.count() == initial_count + 1, "Log should be in session after flush" + db.session.rollback() # undo — simulates caller rollback + assert AccessLog.query.count() == initial_count, "Log should be gone after rollback" + + print("✅ audit_access() does not commit") + return True + finally: + db.session.delete(user) + db.session.commit() + + +def test_audit_login_commits(): + """audit_login() commits its own transaction.""" + with app.app_context(): + db.create_all() + user = _make_user("audit_login_user") + try: + initial_count = AuthLog.query.count() + + from src.services.audit import audit_login + with app.test_request_context(): + audit_login(user.id) + + # Should be committed — visible in a fresh query + assert AuthLog.query.count() == initial_count + 1, "auth log should be committed" + log = AuthLog.query.order_by(AuthLog.id.desc()).first() + assert log.action == 'login' + assert log.user_id == user.id + + print("✅ audit_login() commits independently") + return True + finally: + AuthLog.query.filter_by(user_id=user.id).delete() + db.session.delete(user) + db.session.commit() + + +def test_audit_failed_login_uses_email_hash(): + """audit_failed_login() stores email_hash, not plain email.""" + with app.app_context(): + db.create_all() + initial_count = AuthLog.query.count() + email = "attacker-target@example.com" + email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16] + + from src.services.audit import audit_failed_login + with app.test_request_context(): + audit_failed_login(details={'email_hash': email_hash, 'reason': 'wrong_password'}) + + assert AuthLog.query.count() == initial_count + 1 + log = AuthLog.query.order_by(AuthLog.id.desc()).first() + assert log.action == 'failed_login' + assert log.details is not None + assert 'email_hash' in log.details, "Should store email_hash, not plain email" + assert 'email' not in log.details, "Should NOT store plain email" + assert log.details['email_hash'] == email_hash + + # Cleanup + db.session.delete(log) + db.session.commit() + + print("✅ audit_failed_login() stores email_hash, not plain email") + return True + + +def test_audit_view_deduplication(): + """audit_view() on the same resource within 5 min creates only one log entry.""" + with app.app_context(): + db.create_all() + user = _make_user("audit_dedup_user") + try: + initial_count = AccessLog.query.count() + + from src.services.audit import audit_view + with app.test_request_context(): + from flask_login import login_user + login_user(user) + # First view — should log + log1 = audit_view('recording', 42, user_id=user.id) + db.session.commit() + # Second view within 5 min — should be deduped + log2 = audit_view('recording', 42, user_id=user.id) + if log2 is not None: + db.session.commit() + + count_after = AccessLog.query.filter_by( + user_id=user.id, action='view', resource_type='recording', resource_id=42 + ).count() + assert count_after == 1, f"Expected 1 log entry, got {count_after} (dedup failed)" + + print("✅ audit_view() deduplication works (5-min window)") + return True + finally: + AccessLog.query.filter_by(user_id=user.id).delete() + db.session.delete(user) + db.session.commit() + + +def test_get_access_logs_pagination(): + """get_access_logs() returns paginated results.""" + with app.app_context(): + db.create_all() + user = _make_user("audit_pag_user") + try: + # Create 5 access log entries + for i in range(5): + log = AccessLog.log_access( + action='view', resource_type='recording', resource_id=i, + user_id=user.id, status='success', + ) + db.session.add(log) + db.session.commit() + + from src.services.audit import get_access_logs + page1 = get_access_logs(page=1, per_page=3, user_id=user.id) + assert page1.total >= 5 + assert len(page1.items) == 3 + + page2 = get_access_logs(page=2, per_page=3, user_id=user.id) + assert len(page2.items) >= 2 + + print("✅ get_access_logs() pagination works") + return True + finally: + AccessLog.query.filter_by(user_id=user.id).delete() + db.session.delete(user) + db.session.commit() + + +# --------------------------------------------------------------------------- +# Admin API endpoint tests +# --------------------------------------------------------------------------- + +def test_audit_status_requires_admin(): + """GET /api/admin/audit/status: 401 anon, 403 non-admin, 200 admin.""" + with app.app_context(): + db.create_all() + regular = _make_user("audit_status_regular") + admin = _make_user("audit_status_admin", is_admin=True) + client = app.test_client() + + try: + # Anonymous — should redirect to login (302) or 401 + resp = client.get('/api/admin/audit/status') + assert resp.status_code in (401, 302), f"Expected 401/302 for anon, got {resp.status_code}" + + # Regular user — should get 403 + _login_client(client, regular) + resp = client.get('/api/admin/audit/status') + assert resp.status_code == 403, f"Expected 403 for non-admin, got {resp.status_code}" + + # Admin — should get 200 + _login_client(client, admin) + resp = client.get('/api/admin/audit/status') + assert resp.status_code == 200, f"Expected 200 for admin, got {resp.status_code}" + data = resp.get_json() + assert 'enabled' in data + + print("✅ /api/admin/audit/status access control works") + return True + finally: + db.session.delete(regular) + db.session.delete(admin) + db.session.commit() + + +def test_audit_access_logs_endpoint(): + """GET /api/admin/audit/access returns paginated logs for admin.""" + with app.app_context(): + db.create_all() + admin = _make_user("audit_access_ep_admin", is_admin=True) + client = app.test_client() + + try: + _login_client(client, admin) + resp = client.get('/api/admin/audit/access?per_page=10') + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + data = resp.get_json() + assert 'logs' in data + assert 'total' in data + assert 'page' in data + + print("✅ /api/admin/audit/access returns correct structure") + return True + finally: + db.session.delete(admin) + db.session.commit() + + +def test_audit_auth_logs_endpoint(): + """GET /api/admin/audit/auth returns paginated auth logs for admin.""" + with app.app_context(): + db.create_all() + admin = _make_user("audit_auth_ep_admin", is_admin=True) + client = app.test_client() + + try: + _login_client(client, admin) + resp = client.get('/api/admin/audit/auth?per_page=10') + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + data = resp.get_json() + assert 'logs' in data + assert 'total' in data + + print("✅ /api/admin/audit/auth returns correct structure") + return True + finally: + db.session.delete(admin) + db.session.commit() + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +def main(): + print("🚀 Running audit system tests...\n") + tests = [ + test_audit_access_no_commit, + test_audit_login_commits, + test_audit_failed_login_uses_email_hash, + test_audit_view_deduplication, + test_get_access_logs_pagination, + test_audit_status_requires_admin, + test_audit_access_logs_endpoint, + test_audit_auth_logs_endpoint, + ] + + passed = 0 + failed = 0 + for test in tests: + try: + result = test() + if result: + passed += 1 + else: + print(f"❌ {test.__name__} returned False") + failed += 1 + except Exception as e: + print(f"❌ {test.__name__} raised: {e}") + import traceback + traceback.print_exc() + failed += 1 + + print(f"\n{'='*40}") + print(f"Results: {passed} passed, {failed} failed") + print("✅ ALL PASS" if failed == 0 else "❌ SOME FAILED") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_bugfixes.py b/tests/test_bugfixes.py new file mode 100644 index 0000000..1737c1f --- /dev/null +++ b/tests/test_bugfixes.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Tests for specific bug fixes. + +- Issue #230: Bulk delete crash when recordings have speaker_snippets +- Issue #223: File monitor stability time env var +""" + +import json +import os +import sys +import time +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.app import app, db +from src.models import User, Recording +from src.models.speaker_snippet import SpeakerSnippet + +# Disable CSRF for testing +app.config['WTF_CSRF_ENABLED'] = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_or_create_user(): + user = User.query.filter_by(username="bugfix_test_user").first() + if not user: + user = User(username="bugfix_test_user", email="bugfix@local.test") + db.session.add(user) + db.session.commit() + return user + + +def _create_recording_with_snippets(user): + """Create a recording that has speaker_snippet records attached.""" + rec = Recording( + user_id=user.id, + title="Recording with snippets", + status="COMPLETED", + transcription=json.dumps([ + {"speaker": "SPEAKER_00", "sentence": "Hello there."}, + ]), + ) + db.session.add(rec) + db.session.commit() + + # We need a speaker to attach snippets to + from src.models import Speaker + speaker = Speaker.query.filter_by(user_id=user.id, name="BugfixTestSpeaker").first() + if not speaker: + speaker = Speaker(name="BugfixTestSpeaker", user_id=user.id) + db.session.add(speaker) + db.session.commit() + + snippet = SpeakerSnippet( + speaker_id=speaker.id, + recording_id=rec.id, + segment_index=0, + text_snippet="Hello there.", + ) + db.session.add(snippet) + db.session.commit() + + return rec, speaker, snippet + + +# --------------------------------------------------------------------------- +# Issue #230: Deleting recordings with speaker_snippets +# --------------------------------------------------------------------------- + +class TestIssue230BulkDeleteCascade: + """Verify that deleting a recording with speaker_snippets doesn't crash.""" + + def test_single_delete_with_snippets(self): + """Single DELETE /recording/ should succeed when snippets exist.""" + with app.app_context(): + user = _get_or_create_user() + rec, speaker, snippet = _create_recording_with_snippets(user) + rec_id = rec.id + snippet_id = snippet.id + + with app.test_client() as client: + # Login + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + resp = client.delete(f'/recording/{rec_id}') + assert resp.status_code == 200, f"Delete failed: {resp.get_json()}" + data = resp.get_json() + assert data.get('success') is True + + # Verify snippet was also deleted + orphan = db.session.get(SpeakerSnippet, snippet_id) + assert orphan is None, "Speaker snippet should have been deleted with recording" + + # Cleanup speaker + db.session.delete(speaker) + db.session.commit() + + def test_bulk_delete_with_snippets(self): + """DELETE /api/recordings/bulk should succeed when snippets exist.""" + with app.app_context(): + user = _get_or_create_user() + rec, speaker, snippet = _create_recording_with_snippets(user) + rec_id = rec.id + snippet_id = snippet.id + + with app.test_client() as client: + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + resp = client.delete( + '/api/recordings/bulk', + json={'recording_ids': [rec_id]}, + content_type='application/json', + ) + assert resp.status_code == 200, f"Bulk delete failed: {resp.get_json()}" + data = resp.get_json() + assert data.get('success') is True + assert rec_id in data.get('deleted_ids', []) + + # Verify snippet was also deleted + orphan = db.session.get(SpeakerSnippet, snippet_id) + assert orphan is None, "Speaker snippet should have been deleted with recording" + + # Cleanup speaker + db.session.delete(speaker) + db.session.commit() + + def test_bulk_delete_multiple_with_snippets(self): + """Bulk deleting multiple recordings (some with snippets) should succeed.""" + with app.app_context(): + user = _get_or_create_user() + rec1, speaker, snippet = _create_recording_with_snippets(user) + rec2 = Recording(user_id=user.id, title="No snippets", status="COMPLETED") + db.session.add(rec2) + db.session.commit() + + rec1_id, rec2_id = rec1.id, rec2.id + + with app.test_client() as client: + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + resp = client.delete( + '/api/recordings/bulk', + json={'recording_ids': [rec1_id, rec2_id]}, + content_type='application/json', + ) + assert resp.status_code == 200, f"Bulk delete failed: {resp.get_json()}" + data = resp.get_json() + assert data.get('deleted_count') == 2 + + # Cleanup speaker + db.session.delete(speaker) + db.session.commit() + + +# --------------------------------------------------------------------------- +# Issue #223: File monitor stability time +# --------------------------------------------------------------------------- + +class TestIssue223StabilityTime: + """Verify AUTO_PROCESS_STABILITY_TIME env var is respected.""" + + def test_default_stability_time(self): + """Without env var, stability_time defaults to 5.""" + from src.file_monitor import FileMonitor + monitor = FileMonitor.__new__(FileMonitor) + monitor.logger = MagicMock() + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + f.write(b'fake audio data') + tmp_path = Path(f.name) + + try: + with patch.dict(os.environ, {}, clear=False): + # Remove the env var if it exists + os.environ.pop('AUTO_PROCESS_STABILITY_TIME', None) + with patch('time.sleep') as mock_sleep: + monitor._is_file_stable(tmp_path) + mock_sleep.assert_called_once_with(5) + finally: + tmp_path.unlink(missing_ok=True) + + def test_custom_stability_time(self): + """AUTO_PROCESS_STABILITY_TIME=15 should sleep for 15 seconds.""" + from src.file_monitor import FileMonitor + monitor = FileMonitor.__new__(FileMonitor) + monitor.logger = MagicMock() + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + f.write(b'fake audio data') + tmp_path = Path(f.name) + + try: + with patch.dict(os.environ, {'AUTO_PROCESS_STABILITY_TIME': '15'}): + with patch('time.sleep') as mock_sleep: + # _is_file_stable uses the default param, but the caller reads env + # So we test the caller path via _scan_user_directory indirectly + # or just call with explicit value + stability_time = int(os.environ.get('AUTO_PROCESS_STABILITY_TIME', '5')) + monitor._is_file_stable(tmp_path, stability_time) + mock_sleep.assert_called_once_with(15) + finally: + tmp_path.unlink(missing_ok=True) + + def test_no_hardcoded_cap(self): + """Stability time should NOT be capped at 2 seconds anymore.""" + from src.file_monitor import FileMonitor + monitor = FileMonitor.__new__(FileMonitor) + monitor.logger = MagicMock() + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + f.write(b'fake audio data') + tmp_path = Path(f.name) + + try: + with patch('time.sleep') as mock_sleep: + monitor._is_file_stable(tmp_path, stability_time=30) + # Should sleep for 30, NOT min(30, 2) = 2 + mock_sleep.assert_called_once_with(30) + finally: + tmp_path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import pytest + sys.exit(pytest.main([__file__, "-v"])) diff --git a/tests/test_connector_architecture.py b/tests/test_connector_architecture.py new file mode 100644 index 0000000..028e5e2 --- /dev/null +++ b/tests/test_connector_architecture.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +""" +Test script for the transcription connector architecture. + +This script tests: +1. Connector auto-detection from environment variables +2. Backwards compatibility with legacy config +3. Connector specifications and capabilities +4. Chunking logic (connector-aware) +5. Codec handling per connector +6. Request/Response data types + +Run with: docker exec speakr-dev python /app/tests/test_connector_architecture.py +""" + +import os +import sys +import io +import json +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Test results tracking +PASSED = 0 +FAILED = 0 +ERRORS = [] + + +def run_test(name, func): + """Run a test function and track results.""" + global PASSED, FAILED, ERRORS + try: + func() + print(f" ✓ {name}") + PASSED += 1 + except AssertionError as e: + print(f" ✗ {name}: {e}") + FAILED += 1 + ERRORS.append((name, str(e))) + except Exception as e: + print(f" ✗ {name}: EXCEPTION - {e}") + FAILED += 1 + ERRORS.append((name, f"Exception: {e}")) + + +def clear_env(): + """Clear all transcription-related environment variables.""" + keys_to_clear = [ + 'TRANSCRIPTION_CONNECTOR', 'TRANSCRIPTION_API_KEY', 'TRANSCRIPTION_BASE_URL', + 'TRANSCRIPTION_MODEL', 'WHISPER_MODEL', 'USE_ASR_ENDPOINT', 'ASR_BASE_URL', + 'ASR_DIARIZE', 'ASR_RETURN_SPEAKER_EMBEDDINGS', 'ASR_TIMEOUT', + 'ASR_MIN_SPEAKERS', 'ASR_MAX_SPEAKERS', 'ENABLE_CHUNKING', 'CHUNK_LIMIT', + 'CHUNK_OVERLAP_SECONDS', 'AUDIO_UNSUPPORTED_CODECS', + ] + for key in keys_to_clear: + os.environ.pop(key, None) + + +def reset_registry(): + """Reset the connector registry singleton.""" + from src.services.transcription import registry + registry._registry = None + registry.ConnectorRegistry._instance = None + registry.ConnectorRegistry._initialized = False + registry.ConnectorRegistry._active_connector = None + registry.ConnectorRegistry._connector_name = "" + + +# ============================================================================= +# TEST SECTION 1: Base Classes and Data Types +# ============================================================================= + +def test_base_classes(): + """Test base classes and data types.""" + print("\n=== Testing Base Classes ===") + + from src.services.transcription.base import ( + TranscriptionCapability, ConnectorSpecifications, TranscriptionRequest, + TranscriptionResponse, TranscriptionSegment, + ) + + def t1(): + assert TranscriptionCapability.DIARIZATION is not None + assert TranscriptionCapability.TIMESTAMPS is not None + assert TranscriptionCapability.SPEAKER_COUNT_CONTROL is not None + run_test("TranscriptionCapability enum has expected values", t1) + + def t2(): + specs = ConnectorSpecifications() + assert specs.max_file_size_bytes is None + assert specs.handles_chunking_internally is False + assert specs.recommended_chunk_seconds == 600 + run_test("ConnectorSpecifications has correct defaults", t2) + + def t3(): + specs = ConnectorSpecifications( + max_file_size_bytes=25 * 1024 * 1024, + handles_chunking_internally=True, + unsupported_codecs=frozenset({'opus'}) + ) + assert specs.max_file_size_bytes == 25 * 1024 * 1024 + assert 'opus' in specs.unsupported_codecs + run_test("ConnectorSpecifications with custom values", t3) + + def t4(): + audio = io.BytesIO(b"fake audio data") + request = TranscriptionRequest(audio_file=audio, filename="test.wav", diarize=True) + assert request.filename == "test.wav" + assert request.diarize is True + run_test("TranscriptionRequest creation", t4) + + def t5(): + segments = [ + TranscriptionSegment(text="Hello", speaker="SPEAKER_00", start_time=0.0, end_time=1.0), + TranscriptionSegment(text="World", speaker="SPEAKER_01", start_time=1.0, end_time=2.0), + ] + response = TranscriptionResponse(text="Hello World", segments=segments, provider="test") + storage = response.to_storage_format() + data = json.loads(storage) + assert len(data) == 2 + assert data[0]['speaker'] == "SPEAKER_00" + run_test("TranscriptionResponse to_storage_format", t5) + + def t6(): + segments = [TranscriptionSegment(text="Hello", speaker="SPEAKER_00")] + response = TranscriptionResponse(text="Hello", segments=segments) + assert response.has_diarization() is True + + response2 = TranscriptionResponse(text="Hello", segments=None) + assert response2.has_diarization() is False + run_test("TranscriptionResponse has_diarization", t6) + + +# ============================================================================= +# TEST SECTION 2: Connector Auto-Detection +# ============================================================================= + +def test_auto_detection(): + """Test connector auto-detection from environment variables.""" + print("\n=== Testing Connector Auto-Detection ===") + + from src.services.transcription.registry import get_registry + + def t1(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_CONNECTOR'] = 'openai_whisper' + os.environ['TRANSCRIPTION_API_KEY'] = 'test-key' + os.environ['ASR_BASE_URL'] = 'http://should-be-ignored:9000' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'openai_whisper' + run_test("Explicit TRANSCRIPTION_CONNECTOR takes priority", t1) + + def t2(): + clear_env() + reset_registry() + os.environ['ASR_BASE_URL'] = 'http://whisperx:9000' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'asr_endpoint' + run_test("ASR_BASE_URL auto-detects asr_endpoint", t2) + + def t3(): + clear_env() + reset_registry() + os.environ['USE_ASR_ENDPOINT'] = 'true' + os.environ['ASR_BASE_URL'] = 'http://whisperx:9000' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'asr_endpoint' + run_test("Legacy USE_ASR_ENDPOINT=true still works", t3) + + def t4(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_API_KEY'] = 'test-key' + os.environ['TRANSCRIPTION_MODEL'] = 'gpt-4o-transcribe-diarize' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'openai_transcribe' + run_test("gpt-4o model auto-detects openai_transcribe", t4) + + def t5(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_API_KEY'] = 'test-key' + os.environ['TRANSCRIPTION_MODEL'] = 'whisper-1' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'openai_whisper' + run_test("whisper-1 model uses openai_whisper", t5) + + def t6(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_API_KEY'] = 'test-key' + os.environ['WHISPER_MODEL'] = 'whisper-1' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'openai_whisper' + run_test("Legacy WHISPER_MODEL still works", t6) + + def t7(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_API_KEY'] = 'test-key' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'openai_whisper' + run_test("Default falls back to openai_whisper", t7) + + +# ============================================================================= +# TEST SECTION 3: Connector Specifications +# ============================================================================= + +def test_connector_specifications(): + """Test connector specifications are correctly defined.""" + print("\n=== Testing Connector Specifications ===") + + from src.services.transcription.connectors.openai_whisper import OpenAIWhisperConnector + from src.services.transcription.connectors.openai_transcribe import OpenAITranscribeConnector + from src.services.transcription.connectors.asr_endpoint import ASREndpointConnector + from src.services.transcription.base import TranscriptionCapability + + def t1(): + specs = OpenAIWhisperConnector.SPECIFICATIONS + assert specs.max_file_size_bytes == 25 * 1024 * 1024 + assert specs.handles_chunking_internally is False + run_test("OpenAI Whisper has 25MB limit", t1) + + def t2(): + specs = OpenAIWhisperConnector.SPECIFICATIONS + assert specs.unsupported_codecs is not None + assert 'opus' in specs.unsupported_codecs + run_test("OpenAI Whisper declares opus as unsupported", t2) + + def t3(): + specs = OpenAITranscribeConnector.SPECIFICATIONS + assert specs.handles_chunking_internally is True + assert specs.requires_chunking_param is True + run_test("OpenAI Transcribe has internal chunking", t3) + + def t4(): + specs = ASREndpointConnector.SPECIFICATIONS + assert specs.max_file_size_bytes is None + assert specs.handles_chunking_internally is True + run_test("ASR Endpoint has no limits (handles internally)", t4) + + def t5(): + assert TranscriptionCapability.DIARIZATION not in OpenAIWhisperConnector.CAPABILITIES + run_test("OpenAI Whisper does NOT support diarization", t5) + + def t6(): + # Diarization is added dynamically based on model at instance level + connector = OpenAITranscribeConnector({'api_key': 'test', 'model': 'gpt-4o-transcribe-diarize'}) + assert TranscriptionCapability.DIARIZATION in connector.CAPABILITIES + assert connector.supports_diarization is True + run_test("OpenAI Transcribe with diarize model supports diarization", t6) + + def t7(): + assert TranscriptionCapability.DIARIZATION in ASREndpointConnector.CAPABILITIES + assert TranscriptionCapability.SPEAKER_COUNT_CONTROL in ASREndpointConnector.CAPABILITIES + run_test("ASR Endpoint supports diarization and speaker count control", t7) + + def t8(): + assert TranscriptionCapability.SPEAKER_COUNT_CONTROL not in OpenAIWhisperConnector.CAPABILITIES + assert TranscriptionCapability.SPEAKER_COUNT_CONTROL not in OpenAITranscribeConnector.CAPABILITIES + run_test("OpenAI connectors do NOT support speaker count control", t8) + + +# ============================================================================= +# TEST SECTION 4: Chunking Logic +# ============================================================================= + +def test_chunking_logic(): + """Test connector-aware chunking logic.""" + print("\n=== Testing Chunking Logic ===") + + from src.audio_chunking import get_effective_chunking_config + from src.services.transcription.base import ConnectorSpecifications + + def t1(): + clear_env() + os.environ['ENABLE_CHUNKING'] = 'true' + os.environ['CHUNK_LIMIT'] = '20MB' + specs = ConnectorSpecifications(handles_chunking_internally=True) + config = get_effective_chunking_config(specs) + assert config.enabled is False + assert config.source == 'connector_internal' + run_test("Connector with internal chunking disables app chunking", t1) + + def t2(): + clear_env() + os.environ['ENABLE_CHUNKING'] = 'true' + os.environ['CHUNK_LIMIT'] = '15MB' + os.environ['CHUNK_OVERLAP_SECONDS'] = '5' + specs = ConnectorSpecifications(handles_chunking_internally=False) + config = get_effective_chunking_config(specs) + assert config.enabled is True + assert config.source == 'env' + assert config.mode == 'size' + assert config.limit_value == 15.0 + run_test("Connector without internal chunking uses ENV settings", t2) + + def t3(): + clear_env() + os.environ['ENABLE_CHUNKING'] = 'false' + specs = ConnectorSpecifications(handles_chunking_internally=False) + config = get_effective_chunking_config(specs) + assert config.enabled is False + assert config.source == 'disabled' + run_test("ENABLE_CHUNKING=false disables chunking", t3) + + def t4(): + clear_env() + os.environ['ENABLE_CHUNKING'] = 'true' + os.environ['CHUNK_LIMIT'] = '10m' + specs = ConnectorSpecifications(handles_chunking_internally=False) + config = get_effective_chunking_config(specs) + assert config.enabled is True + assert config.mode == 'duration' + assert config.limit_value == 600.0 + run_test("Duration-based chunk limit parsing (10m = 600s)", t4) + + +# ============================================================================= +# TEST SECTION 5: Codec Handling +# ============================================================================= + +def test_codec_handling(): + """Test codec handling with connector specifications.""" + print("\n=== Testing Codec Handling ===") + + from src.services.transcription.base import ConnectorSpecifications + + def reload_audio_module(): + """Properly reload audio_conversion module with fresh env vars.""" + import sys + # Remove relevant modules from cache to force fresh import + # app_config reads AUDIO_UNSUPPORTED_CODECS at import time + for mod_name in list(sys.modules.keys()): + if mod_name.startswith('src.utils') or mod_name.startswith('src.config'): + del sys.modules[mod_name] + from src.utils import audio_conversion + return audio_conversion + + def t1(): + clear_env() + mod = reload_audio_module() + codecs = mod.get_supported_codecs() + assert 'mp3' in codecs + assert 'flac' in codecs + run_test("Default supported codecs include common formats", t1) + + def t2(): + clear_env() + mod = reload_audio_module() + specs = ConnectorSpecifications(unsupported_codecs=frozenset({'opus', 'vorbis'})) + codecs = mod.get_supported_codecs(connector_specs=specs) + assert 'opus' not in codecs + assert 'vorbis' not in codecs + assert 'mp3' in codecs + run_test("Connector unsupported_codecs removes from defaults", t2) + + def t3(): + clear_env() + os.environ['AUDIO_UNSUPPORTED_CODECS'] = 'aac,opus' + mod = reload_audio_module() + codecs = mod.get_supported_codecs() + assert 'aac' not in codecs, f"aac should not be in {codecs}" + assert 'opus' not in codecs, f"opus should not be in {codecs}" + run_test("AUDIO_UNSUPPORTED_CODECS env var still works", t3) + + def t4(): + clear_env() + os.environ['AUDIO_UNSUPPORTED_CODECS'] = 'aac' + mod = reload_audio_module() + specs = ConnectorSpecifications(unsupported_codecs=frozenset({'opus'})) + codecs = mod.get_supported_codecs(connector_specs=specs) + assert 'aac' not in codecs, f"aac should not be in {codecs}" + assert 'opus' not in codecs, f"opus should not be in {codecs}" + assert 'mp3' in codecs + run_test("Both connector specs and ENV var work together", t4) + + +# ============================================================================= +# TEST SECTION 6: Connector Capabilities +# ============================================================================= + +def test_connector_capabilities(): + """Test connector capabilities are exposed correctly.""" + print("\n=== Testing Connector Capabilities ===") + + from src.services.transcription.connectors.asr_endpoint import ASREndpointConnector + from src.services.transcription.connectors.openai_transcribe import OpenAITranscribeConnector + from src.services.transcription.base import TranscriptionCapability + + def t1(): + connector = ASREndpointConnector({'base_url': 'http://test:9000'}) + assert connector.supports_diarization is True + run_test("ASR connector supports_diarization property", t1) + + def t2(): + connector = ASREndpointConnector({'base_url': 'http://test:9000'}) + assert connector.supports_speaker_count_control is True + run_test("ASR connector supports_speaker_count_control property", t2) + + def t3(): + connector = OpenAITranscribeConnector({'api_key': 'test-key', 'model': 'gpt-4o-transcribe-diarize'}) + assert connector.supports_diarization is True + assert connector.supports_speaker_count_control is False + run_test("OpenAI Transcribe supports diarization but not speaker_count_control", t3) + + def t4(): + connector = ASREndpointConnector({'base_url': 'http://test:9000'}) + assert connector.supports(TranscriptionCapability.DIARIZATION) is True + assert connector.supports(TranscriptionCapability.STREAMING) is False + run_test("supports() method works correctly", t4) + + +# ============================================================================= +# TEST SECTION 7: Registry Operations +# ============================================================================= + +def test_registry_operations(): + """Test registry listing and connector info.""" + print("\n=== Testing Registry Operations ===") + + from src.services.transcription.registry import get_registry + + def t1(): + clear_env() + reset_registry() + registry = get_registry() + connectors = registry.list_connectors() + names = [c['name'] for c in connectors] + assert 'openai_whisper' in names + assert 'openai_transcribe' in names + assert 'asr_endpoint' in names + run_test("Registry lists all built-in connectors", t1) + + def t2(): + clear_env() + reset_registry() + registry = get_registry() + connectors = registry.list_connectors() + asr = next(c for c in connectors if c['name'] == 'asr_endpoint') + assert 'DIARIZATION' in asr['capabilities'] + assert 'SPEAKER_COUNT_CONTROL' in asr['capabilities'] + run_test("Connector info includes capabilities", t2) + + def t3(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_API_KEY'] = 'test-key' + os.environ['TRANSCRIPTION_MODEL'] = 'whisper-1' + registry = get_registry() + registry.initialize_from_env() + assert registry.get_active_connector_name() == 'openai_whisper' + + os.environ['TRANSCRIPTION_MODEL'] = 'gpt-4o-transcribe-diarize' + registry.reinitialize() + assert registry.get_active_connector_name() == 'openai_transcribe' + run_test("reinitialize() resets the active connector", t3) + + +# ============================================================================= +# TEST SECTION 8: Edge Cases +# ============================================================================= + +def test_edge_cases(): + """Test edge cases and error handling.""" + print("\n=== Testing Edge Cases ===") + + from src.services.transcription.registry import get_registry + from src.services.transcription.exceptions import ConfigurationError + from src.services.transcription.base import TranscriptionResponse, TranscriptionSegment + + def t1(): + # Empty segments list returns the text (empty string), not "[]" + response = TranscriptionResponse(text="", segments=[], provider="test") + assert response.to_storage_format() == "" + assert response.has_diarization() is False + run_test("Empty transcription response handling", t1) + + def t2(): + segments = [TranscriptionSegment(text="Hello", speaker=None)] + response = TranscriptionResponse(text="Hello", segments=segments) + storage = response.to_storage_format() + data = json.loads(storage) + assert data[0]['speaker'] == 'Unknown Speaker' + run_test("Transcription with unknown speaker handling", t2) + + def t3(): + clear_env() + reset_registry() + os.environ['TRANSCRIPTION_CONNECTOR'] = 'nonexistent_connector' + registry = get_registry() + try: + registry.initialize_from_env() + assert False, "Should have raised ConfigurationError" + except ConfigurationError as e: + assert 'Unknown connector' in str(e) + run_test("Invalid connector name raises ConfigurationError", t3) + + def t4(): + from src.services.transcription.connectors.asr_endpoint import ASREndpointConnector + try: + ASREndpointConnector({}) + assert False, "Should have raised ConfigurationError" + except ConfigurationError as e: + assert 'base_url' in str(e) + run_test("ASR connector validates base_url is required", t4) + + def t5(): + clear_env() + reset_registry() + os.environ['ASR_BASE_URL'] = 'http://whisperx:9000 # This is a comment' + registry = get_registry() + connector = registry.initialize_from_env() + assert connector.base_url == 'http://whisperx:9000' + run_test("ASR_BASE_URL with trailing comment is handled", t5) + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + """Run all tests.""" + global PASSED, FAILED, ERRORS + + print("=" * 60) + print("Transcription Connector Architecture Tests") + print("=" * 60) + + test_base_classes() + test_auto_detection() + test_connector_specifications() + test_chunking_logic() + test_codec_handling() + test_connector_capabilities() + test_registry_operations() + test_edge_cases() + + print("\n" + "=" * 60) + print(f"RESULTS: {PASSED} passed, {FAILED} failed") + print("=" * 60) + + if ERRORS: + print("\nFailed tests:") + for name, error in ERRORS: + print(f" - {name}: {error}") + + clear_env() + return 0 if FAILED == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_ffprobe_codec_detection.py b/tests/test_ffprobe_codec_detection.py new file mode 100644 index 0000000..be7f70b --- /dev/null +++ b/tests/test_ffprobe_codec_detection.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +Test script for ffprobe codec detection functionality. + +This script tests the new codec-based detection system to ensure it correctly +identifies audio codecs, video files, and lossless formats. +""" + +import os +import sys +import tempfile +import subprocess +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.utils.ffprobe import ( + get_codec_info, + is_video_file, + is_audio_file, + get_audio_codec, + needs_audio_conversion, + is_lossless_audio, + get_duration, + FFProbeError +) + + +def create_test_audio_file(codec, output_path, duration=1.0): + """Create a test audio file with specific codec.""" + codec_map = { + 'mp3': ['ffmpeg', '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', '-acodec', 'libmp3lame', '-b:a', '128k', output_path], + 'aac': ['ffmpeg', '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', '-acodec', 'aac', '-b:a', '128k', output_path], + 'opus': ['ffmpeg', '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', '-acodec', 'libopus', '-b:a', '64k', output_path], + 'flac': ['ffmpeg', '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', '-acodec', 'flac', output_path], + 'pcm_s16le': ['ffmpeg', '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', '-acodec', 'pcm_s16le', '-ar', '44100', output_path], + 'vorbis': ['ffmpeg', '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', '-acodec', 'libvorbis', '-b:a', '128k', output_path], + } + + if codec not in codec_map: + raise ValueError(f"Unknown codec: {codec}") + + subprocess.run(codec_map[codec], check=True, capture_output=True) + + +def create_test_video_file(output_path, duration=1.0): + """Create a test video file with audio.""" + subprocess.run([ + 'ffmpeg', '-f', 'lavfi', '-i', f'testsrc=duration={duration}:size=320x240:rate=1', + '-f', 'lavfi', '-i', f'sine=frequency=440:duration={duration}', + '-acodec', 'aac', '-vcodec', 'libx264', '-pix_fmt', 'yuv420p', + output_path + ], check=True, capture_output=True) + + +def test_codec_detection(): + """Test basic codec detection.""" + print("\n=== Testing Codec Detection ===\n") + + with tempfile.TemporaryDirectory() as tmpdir: + test_files = { + 'mp3': 'test.mp3', + 'aac': 'test.m4a', + 'opus': 'test.opus', + 'flac': 'test.flac', + 'pcm_s16le': 'test.wav', + 'vorbis': 'test.ogg', + } + + for codec, filename in test_files.items(): + filepath = os.path.join(tmpdir, filename) + try: + print(f"Creating test file: {filename} with codec {codec}...") + create_test_audio_file(codec, filepath) + + print(f" Probing {filename}...") + codec_info = get_codec_info(filepath) + + detected_codec = codec_info['audio_codec'] + print(f" ✓ Detected codec: {detected_codec}") + print(f" Has audio: {codec_info['has_audio']}") + print(f" Has video: {codec_info['has_video']}") + print(f" Format: {codec_info['format_name']}") + print(f" Duration: {codec_info['duration']:.2f}s" if codec_info['duration'] else " Duration: N/A") + + if detected_codec != codec: + print(f" ⚠️ Warning: Expected {codec}, got {detected_codec}") + + print() + + except Exception as e: + print(f" ✗ Failed to test {codec}: {e}\n") + + +def test_video_detection(): + """Test video file detection.""" + print("\n=== Testing Video Detection ===\n") + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, 'test_video.mp4') + audio_path = os.path.join(tmpdir, 'test_audio.mp3') + + try: + print("Creating test video file...") + create_test_video_file(video_path) + + print("Creating test audio file...") + create_test_audio_file('mp3', audio_path) + + print(f"\nProbing video file...") + codec_info = get_codec_info(video_path) + print(f" Audio codec: {codec_info['audio_codec']}") + print(f" Video codec: {codec_info['video_codec']}") + print(f" Has audio: {codec_info['has_audio']}") + print(f" Has video: {codec_info['has_video']}") + + is_video = is_video_file(video_path) + print(f" is_video_file(): {is_video}") + + if not is_video: + print(" ✗ Video file not detected as video!") + else: + print(" ✓ Video file correctly detected") + + print(f"\nProbing audio file...") + codec_info = get_codec_info(audio_path) + print(f" Audio codec: {codec_info['audio_codec']}") + print(f" Video codec: {codec_info['video_codec']}") + print(f" Has audio: {codec_info['has_audio']}") + print(f" Has video: {codec_info['has_video']}") + + is_video = is_video_file(audio_path) + print(f" is_video_file(): {is_video}") + + if is_video: + print(" ✗ Audio file incorrectly detected as video!") + else: + print(" ✓ Audio file correctly identified as audio-only") + + print() + + except Exception as e: + print(f"✗ Failed to test video detection: {e}\n") + + +def test_lossless_detection(): + """Test lossless audio detection.""" + print("\n=== Testing Lossless Detection ===\n") + + with tempfile.TemporaryDirectory() as tmpdir: + test_cases = { + 'pcm_s16le': ('test.wav', True), + 'flac': ('test.flac', True), + 'mp3': ('test.mp3', False), + 'aac': ('test.m4a', False), + 'opus': ('test.opus', False), + } + + for codec, (filename, expected_lossless) in test_cases.items(): + filepath = os.path.join(tmpdir, filename) + try: + print(f"Creating {filename} with codec {codec}...") + create_test_audio_file(codec, filepath) + + is_lossless = is_lossless_audio(filepath) + status = "✓" if is_lossless == expected_lossless else "✗" + + print(f" {status} {codec}: is_lossless={is_lossless} (expected {expected_lossless})") + + except Exception as e: + print(f" ✗ Failed to test {codec}: {e}") + + print() + + +def test_conversion_check(): + """Test conversion requirement detection.""" + print("\n=== Testing Conversion Check ===\n") + + with tempfile.TemporaryDirectory() as tmpdir: + # Supported codecs for direct transcription + supported_codecs = ['pcm_s16le', 'mp3', 'flac', 'opus', 'aac'] + + test_cases = { + 'mp3': ('test.mp3', False), # Supported, no conversion needed + 'aac': ('test.m4a', False), # Supported, no conversion needed + 'opus': ('test.opus', False), # Supported, no conversion needed + 'vorbis': ('test.ogg', True), # Not in supported list, needs conversion + } + + for codec, (filename, should_convert) in test_cases.items(): + filepath = os.path.join(tmpdir, filename) + try: + print(f"Creating {filename} with codec {codec}...") + create_test_audio_file(codec, filepath) + + needs_conversion, detected_codec = needs_audio_conversion(filepath, supported_codecs) + status = "✓" if needs_conversion == should_convert else "✗" + + print(f" {status} {codec}: needs_conversion={needs_conversion} (expected {should_convert})") + print(f" Detected codec: {detected_codec}") + + except Exception as e: + print(f" ✗ Failed to test {codec}: {e}") + + print() + + +def test_misnamed_file(): + """Test detection of files with wrong extensions.""" + print("\n=== Testing Misnamed File Detection ===\n") + + with tempfile.TemporaryDirectory() as tmpdir: + # Create an MP3 file but name it .wav + wrong_name_path = os.path.join(tmpdir, 'actually_mp3.wav') + + try: + print("Creating MP3 file with .wav extension...") + create_test_audio_file('mp3', wrong_name_path) + + codec_info = get_codec_info(wrong_name_path) + detected_codec = codec_info['audio_codec'] + + print(f" Filename: actually_mp3.wav") + print(f" Detected codec: {detected_codec}") + + if detected_codec == 'mp3': + print(" ✓ Correctly detected MP3 codec despite .wav extension") + else: + print(f" ✗ Incorrectly detected as {detected_codec}") + + # Create a FLAC file but name it .mp3 + wrong_name_path2 = os.path.join(tmpdir, 'actually_flac.mp3') + print("\nCreating FLAC file with .mp3 extension...") + create_test_audio_file('flac', wrong_name_path2) + + codec_info = get_codec_info(wrong_name_path2) + detected_codec = codec_info['audio_codec'] + + print(f" Filename: actually_flac.mp3") + print(f" Detected codec: {detected_codec}") + + if detected_codec == 'flac': + print(" ✓ Correctly detected FLAC codec despite .mp3 extension") + else: + print(f" ✗ Incorrectly detected as {detected_codec}") + + print() + + except Exception as e: + print(f"✗ Failed to test misnamed files: {e}\n") + + +def test_duration(): + """Test duration extraction.""" + print("\n=== Testing Duration Extraction ===\n") + + with tempfile.TemporaryDirectory() as tmpdir: + durations = [1.0, 2.5, 5.0] + + for expected_duration in durations: + filepath = os.path.join(tmpdir, f'test_{expected_duration}s.mp3') + try: + print(f"Creating {expected_duration}s audio file...") + create_test_audio_file('mp3', filepath, duration=expected_duration) + + detected_duration = get_duration(filepath) + + # Allow 0.1s tolerance for encoding variations + if detected_duration and abs(detected_duration - expected_duration) < 0.1: + print(f" ✓ Duration: {detected_duration:.2f}s (expected {expected_duration}s)") + else: + print(f" ✗ Duration: {detected_duration:.2f}s (expected {expected_duration}s)") + + except Exception as e: + print(f" ✗ Failed to test duration: {e}") + + print() + + +def main(): + """Run all tests.""" + print("=" * 60) + print("FFProbe Codec Detection Test Suite") + print("=" * 60) + + # Check if ffmpeg/ffprobe are available + try: + subprocess.run(['ffprobe', '-version'], capture_output=True, check=True) + subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError): + print("\n✗ Error: ffmpeg/ffprobe not found. Please install ffmpeg to run tests.\n") + return 1 + + try: + test_codec_detection() + test_video_detection() + test_lossless_detection() + test_conversion_check() + test_misnamed_file() + test_duration() + + print("=" * 60) + print("All tests completed!") + print("=" * 60) + print() + + return 0 + + except Exception as e: + print(f"\n✗ Test suite failed with error: {e}\n") + import traceback + traceback.print_exc() + return 1 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/tests/test_hotwords.sh b/tests/test_hotwords.sh new file mode 100755 index 0000000..88cb69b --- /dev/null +++ b/tests/test_hotwords.sh @@ -0,0 +1,241 @@ +#!/bin/bash +# test_hotwords.sh - Test hotwords and initial_prompt features +# +# Usage: +# ./tests/test_hotwords.sh +# ./tests/test_hotwords.sh http://localhost:9000 temp/Recording\ 4.flac +# +# The audio should contain domain-specific words that Whisper tends to +# misspell (brand names, acronyms, unusual proper nouns). The script runs +# three transcriptions and compares the results. + +set -euo pipefail + +ASR_URL="${1:-http://localhost:9000}" +AUDIO_FILE="${2:-temp/Recording 4.flac}" +OUTPUT_DIR="temp/hotwords_test_results" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Configurable test values - adjust these for your audio +HOTWORDS="Speakr,CTranslate2,PyAnnote,WhisperX" +INITIAL_PROMPT="This is a meeting about AI-powered audio transcription tools including Speakr, CTranslate2, and PyAnnote." + +echo -e "${CYAN}============================================${NC}" +echo -e "${CYAN} Hotwords & Initial Prompt Test Suite${NC}" +echo -e "${CYAN}============================================${NC}" +echo "" +echo -e "ASR URL: ${YELLOW}${ASR_URL}${NC}" +echo -e "Audio file: ${YELLOW}${AUDIO_FILE}${NC}" +echo -e "Hotwords: ${YELLOW}${HOTWORDS}${NC}" +echo -e "Initial prompt: ${YELLOW}${INITIAL_PROMPT}${NC}" +echo "" + +# Verify audio file exists +if [ ! -f "$AUDIO_FILE" ]; then + echo -e "${RED}ERROR: Audio file not found: ${AUDIO_FILE}${NC}" + exit 1 +fi + +# Verify ASR endpoint is reachable +echo -n "Checking ASR endpoint... " +if curl -sf "${ASR_URL}/" > /dev/null 2>&1 || curl -sf "${ASR_URL}/health" > /dev/null 2>&1; then + echo -e "${GREEN}OK${NC}" +else + echo -e "${RED}FAILED${NC}" + echo "Cannot reach ASR endpoint at ${ASR_URL}" + exit 1 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# ============================================================== +# Test 1: Baseline (no hints) +# ============================================================== +echo "" +echo -e "${CYAN}--- Test 1: Baseline (no hotwords, no initial_prompt) ---${NC}" +echo -n "Transcribing... " + +BASELINE_FILE="$OUTPUT_DIR/1_baseline.json" +curl -sS -X POST "${ASR_URL}/asr?output=json&task=transcribe" \ + -F "audio_file=@${AUDIO_FILE}" \ + -o "$BASELINE_FILE" + +BASELINE_TEXT=$(python3 -c " +import json +d=json.load(open('$BASELINE_FILE')) +t=d.get('text','') +if isinstance(t, list): + t=' '.join(seg.get('text','') for seg in t) +print(t[:500]) +" 2>/dev/null || echo "PARSE_ERROR") +echo -e "${GREEN}Done${NC}" +echo -e "Preview: ${BASELINE_TEXT:0:200}..." +echo "" + +# ============================================================== +# Test 2: With hotwords only +# ============================================================== +echo -e "${CYAN}--- Test 2: With hotwords ---${NC}" +echo -n "Transcribing... " + +HOTWORDS_FILE="$OUTPUT_DIR/2_with_hotwords.json" +curl -sS -X POST "${ASR_URL}/asr?output=json&task=transcribe&hotwords=${HOTWORDS}" \ + -F "audio_file=@${AUDIO_FILE}" \ + -o "$HOTWORDS_FILE" + +HOTWORDS_TEXT=$(python3 -c " +import json +d=json.load(open('$HOTWORDS_FILE')) +t=d.get('text','') +if isinstance(t, list): + t=' '.join(seg.get('text','') for seg in t) +print(t[:500]) +" 2>/dev/null || echo "PARSE_ERROR") +echo -e "${GREEN}Done${NC}" +echo -e "Preview: ${HOTWORDS_TEXT:0:200}..." +echo "" + +# ============================================================== +# Test 3: With hotwords + initial_prompt +# ============================================================== +echo -e "${CYAN}--- Test 3: With hotwords + initial_prompt ---${NC}" +echo -n "Transcribing... " + +BOTH_FILE="$OUTPUT_DIR/3_with_both.json" +ENCODED_PROMPT=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$INITIAL_PROMPT'))") +curl -sS -X POST "${ASR_URL}/asr?output=json&task=transcribe&hotwords=${HOTWORDS}&initial_prompt=${ENCODED_PROMPT}" \ + -F "audio_file=@${AUDIO_FILE}" \ + -o "$BOTH_FILE" + +BOTH_TEXT=$(python3 -c " +import json +d=json.load(open('$BOTH_FILE')) +t=d.get('text','') +if isinstance(t, list): + t=' '.join(seg.get('text','') for seg in t) +print(t[:500]) +" 2>/dev/null || echo "PARSE_ERROR") +echo -e "${GREEN}Done${NC}" +echo -e "Preview: ${BOTH_TEXT:0:200}..." +echo "" + +# ============================================================== +# Test 4: With initial_prompt only +# ============================================================== +echo -e "${CYAN}--- Test 4: With initial_prompt only ---${NC}" +echo -n "Transcribing... " + +PROMPT_FILE="$OUTPUT_DIR/4_with_initial_prompt.json" +curl -sS -X POST "${ASR_URL}/asr?output=json&task=transcribe&initial_prompt=${ENCODED_PROMPT}" \ + -F "audio_file=@${AUDIO_FILE}" \ + -o "$PROMPT_FILE" + +PROMPT_TEXT=$(python3 -c " +import json +d=json.load(open('$PROMPT_FILE')) +t=d.get('text','') +if isinstance(t, list): + t=' '.join(seg.get('text','') for seg in t) +print(t[:500]) +" 2>/dev/null || echo "PARSE_ERROR") +echo -e "${GREEN}Done${NC}" +echo -e "Preview: ${PROMPT_TEXT:0:200}..." +echo "" + +# ============================================================== +# Comparison +# ============================================================== +echo -e "${CYAN}============================================${NC}" +echo -e "${CYAN} Comparison Results${NC}" +echo -e "${CYAN}============================================${NC}" +echo "" + +# Check if hotwords appear in outputs +python3 << 'PYEOF' +import json +import os + +def extract_text(data): + """Extract full text from ASR response, handling both string and segment list formats.""" + text = data.get("text", "") + if isinstance(text, list): + return " ".join(seg.get("text", "") for seg in text) + return text + +hotwords = ["Speakr", "CTranslate2", "PyAnnote", "WhisperX"] +output_dir = os.environ.get("OUTPUT_DIR", "temp/hotwords_test_results") +test_files = { + "1. Baseline": f"{output_dir}/1_baseline.json", + "2. Hotwords only": f"{output_dir}/2_with_hotwords.json", + "3. Hotwords + prompt": f"{output_dir}/3_with_both.json", + "4. Initial prompt only": f"{output_dir}/4_with_initial_prompt.json", +} + +print(f"{'Test':<25} | {'Hotword Matches':<20} | {'Words Found'}") +print("-" * 75) + +for label, filepath in test_files.items(): + try: + with open(filepath) as f: + data = json.load(f) + text = extract_text(data) + found = [] + for hw in hotwords: + if hw.lower() in text.lower(): + found.append(hw) + match_str = f"{len(found)}/{len(hotwords)}" + found_str = ", ".join(found) if found else "(none)" + print(f"{label:<25} | {match_str:<20} | {found_str}") + except Exception as e: + print(f"{label:<25} | ERROR: {e}") + +print() +print("Full outputs saved to: " + output_dir) +PYEOF + +echo "" +echo -e "${CYAN}============================================${NC}" +echo -e "${CYAN} Precedence Test via Speakr API${NC}" +echo -e "${CYAN}============================================${NC}" +echo "" +echo -e "${YELLOW}To test the full precedence chain (user → folder → tag → upload form),${NC}" +echo -e "${YELLOW}use the Speakr web API with authentication:${NC}" +echo "" +echo -e "1. Set user-level defaults in Account Settings → Prompt Options" +echo -e "2. Create a tag with different hotwords/initial_prompt" +echo -e "3. Create a folder with different hotwords/initial_prompt" +echo -e "4. Upload via API and check server logs for resolved values:" +echo "" +cat << 'EXAMPLE' +# Upload with user defaults only (no tag, no folder) +curl -X POST "https://your-speakr/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@test.flac" +# → Should use user defaults + +# Upload with a tag that has hotwords set +curl -X POST "https://your-speakr/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@test.flac" \ + -F "tags=TAG_ID_WITH_HOTWORDS" +# → Should use tag defaults (overrides user) + +# Upload with explicit form values (highest priority) +curl -X POST "https://your-speakr/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@test.flac" \ + -F "tags=TAG_ID_WITH_HOTWORDS" \ + -F "hotwords=FormOverride1,FormOverride2" \ + -F "initial_prompt=Form level prompt" +# → Should use form values (overrides tag and user) +EXAMPLE + +echo "" +echo -e "${GREEN}Test complete!${NC}" diff --git a/tests/test_inquire_mode.py b/tests/test_inquire_mode.py new file mode 100644 index 0000000..539766f --- /dev/null +++ b/tests/test_inquire_mode.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Test script for Inquire Mode functionality +""" +import os +import sys + +# Add the parent directory to the path to import app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.app import app, db, User, Recording, TranscriptChunk, InquireSession, Tag + +def test_database_models(): + """Test that the new database models work correctly.""" + with app.app_context(): + print("🔍 Testing Inquire Mode Database Models...") + + # Test that tables exist + from sqlalchemy import inspect + inspector = inspect(db.engine) + tables = inspector.get_table_names() + + required_tables = ['transcript_chunk', 'inquire_session'] + for table in required_tables: + if table in tables: + print(f"✅ Table '{table}' exists") + else: + print(f"❌ Table '{table}' missing") + return False + + # Test creating sample data + try: + # Create a test user (or get existing one) + user = User.query.first() + if not user: + print("❌ No users found. Please create a user first.") + return False + + print(f"📝 Using test user: {user.username}") + + # Create a test recording if none exist + recording = Recording.query.filter_by(user_id=user.id).first() + if not recording: + print("❌ No recordings found. Please create a recording first.") + return False + + print(f"🎵 Using test recording: {recording.title}") + + # Test TranscriptChunk creation + chunk = TranscriptChunk( + recording_id=recording.id, + user_id=user.id, + chunk_index=0, + content="This is a test transcription chunk.", + start_time=0.0, + end_time=5.0, + speaker_name="Test Speaker" + ) + + db.session.add(chunk) + + # Test InquireSession creation + session = InquireSession( + user_id=user.id, + session_name="Test Session", + filter_tags='[]', + filter_speakers='["Test Speaker"]' + ) + + db.session.add(session) + db.session.commit() + + print("✅ Successfully created test TranscriptChunk and InquireSession") + + # Clean up test data + db.session.delete(chunk) + db.session.delete(session) + db.session.commit() + + print("✅ Test data cleaned up") + + except Exception as e: + print(f"❌ Error testing models: {e}") + return False + + return True + +def test_chunking_functions(): + """Test the chunking and embedding functions.""" + with app.app_context(): + print("🔧 Testing Chunking Functions...") + + try: + from src.app import chunk_transcription, generate_embeddings, serialize_embedding, deserialize_embedding + + # Test chunking + test_text = "This is a test sentence. This is another sentence for testing. And here's a third sentence to make sure chunking works properly with longer text that should be split into multiple chunks." + chunks = chunk_transcription(test_text, max_chunk_length=100, overlap=20) + + if len(chunks) > 1: + print(f"✅ Chunking works: {len(chunks)} chunks created") + else: + print("✅ Text too short for chunking (expected behavior)") + + # Test embeddings (will only work if sentence-transformers is installed) + try: + embeddings = generate_embeddings(["test sentence", "another test"]) + if len(embeddings) == 2: + print("✅ Embedding generation works") + + # Test serialization + if embeddings[0] is not None: + serialized = serialize_embedding(embeddings[0]) + deserialized = deserialize_embedding(serialized) + if deserialized is not None and len(deserialized) > 0: + print("✅ Embedding serialization/deserialization works") + else: + print("❌ Embedding serialization/deserialization failed") + else: + print("❌ Embedding generation returned wrong number of embeddings") + + except Exception as e: + print(f"⚠️ Embedding test skipped (sentence-transformers may not be installed): {e}") + + except Exception as e: + print(f"❌ Error testing chunking functions: {e}") + return False + + return True + +def test_api_imports(): + """Test that all API endpoints can be imported.""" + print("🔌 Testing API Endpoint Imports...") + + try: + from src.app import ( + get_inquire_sessions, create_inquire_session, inquire_search, + inquire_chat, get_available_filters, process_recording_chunks_endpoint + ) + print("✅ All inquire mode API endpoints imported successfully") + return True + except ImportError as e: + print(f"❌ Failed to import API endpoints: {e}") + return False + +def main(): + """Run all tests.""" + print("🚀 Starting Inquire Mode Tests...\n") + + tests = [ + ("Database Models", test_database_models), + ("Chunking Functions", test_chunking_functions), + ("API Imports", test_api_imports) + ] + + results = [] + for test_name, test_func in tests: + print(f"\n--- {test_name} ---") + try: + success = test_func() + results.append((test_name, success)) + except Exception as e: + print(f"❌ {test_name} failed with exception: {e}") + results.append((test_name, False)) + + print("\n" + "="*50) + print("📊 Test Results Summary:") + print("="*50) + + all_passed = True + for test_name, success in results: + status = "✅ PASS" if success else "❌ FAIL" + print(f"{status} - {test_name}") + if not success: + all_passed = False + + print("\n" + "="*50) + if all_passed: + print("🎉 All tests passed! Inquire Mode is ready to use.") + else: + print("⚠️ Some tests failed. Please check the output above.") + + return all_passed + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/tests/test_job_queue_race_condition.py b/tests/test_job_queue_race_condition.py new file mode 100644 index 0000000..dee5811 --- /dev/null +++ b/tests/test_job_queue_race_condition.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +Test script for job queue race condition fix. + +This script verifies that the atomic job claiming mechanism prevents +multiple workers from claiming the same job simultaneously. + +The fix uses an atomic UPDATE with WHERE clause to ensure only one +worker can claim a job, even with multiple processes/threads. +""" + +import os +import sys +import threading +import time +from pathlib import Path +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def test_atomic_job_claiming(): + """ + Test that only one worker can claim a job even with concurrent attempts. + + This simulates the race condition where multiple workers try to claim + the same job simultaneously. + """ + print("\n=== Testing Atomic Job Claiming ===\n") + + # Import Flask app and models + from src.app import app + from src.database import db + from src.models import ProcessingJob, User, Recording + from sqlalchemy import update + + with app.app_context(): + # Use the first existing user for testing, or create a minimal test user + test_user = User.query.first() + if not test_user: + test_user = User( + username='test_race_condition_user', + email='test_race@example.com', + password='not_used' # Password not needed for this test + ) + db.session.add(test_user) + db.session.commit() + + # Create a test recording + test_recording = Recording( + user_id=test_user.id, + title='Test Race Condition Recording', + audio_path='/tmp/test_audio.mp3', + status='QUEUED' + ) + db.session.add(test_recording) + db.session.commit() + + # Create a test job in 'queued' status + test_job = ProcessingJob( + recording_id=test_recording.id, + user_id=test_user.id, + job_type='transcribe', + status='queued' + ) + db.session.add(test_job) + db.session.commit() + + job_id = test_job.id + print(f"Created test job {job_id} with status 'queued'") + + # Track which threads successfully claimed the job + successful_claims = [] + claim_lock = threading.Lock() + + def attempt_claim(worker_id): + """Simulate a worker attempting to claim the job.""" + with app.app_context(): + try: + # This is the atomic claim logic from the fix + claim_time = datetime.utcnow() + result = db.session.execute( + update(ProcessingJob) + .where( + ProcessingJob.id == job_id, + ProcessingJob.status == 'queued' + ) + .values(status='processing', started_at=claim_time) + ) + + if result.rowcount == 1: + db.session.commit() + with claim_lock: + successful_claims.append(worker_id) + return f"Worker {worker_id}: Successfully claimed job" + else: + db.session.rollback() + return f"Worker {worker_id}: Job already claimed (rowcount=0)" + + except Exception as e: + db.session.rollback() + return f"Worker {worker_id}: Error - {e}" + + # Spawn multiple threads to claim simultaneously + num_workers = 10 + print(f"\nSpawning {num_workers} workers to claim job {job_id} simultaneously...") + + # Use a barrier to ensure all threads start at the same time + barrier = threading.Barrier(num_workers) + + def worker_with_barrier(worker_id): + barrier.wait() # Wait for all threads to be ready + return attempt_claim(worker_id) + + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = {executor.submit(worker_with_barrier, i): i for i in range(num_workers)} + + for future in as_completed(futures): + result = future.result() + print(f" {result}") + + # Verify results + print(f"\n=== Results ===") + print(f"Total workers: {num_workers}") + print(f"Successful claims: {len(successful_claims)}") + print(f"Workers that claimed: {successful_claims}") + + # Check final job status + db.session.expire_all() + final_job = db.session.get(ProcessingJob, job_id) + print(f"Final job status: {final_job.status}") + + # Cleanup + db.session.delete(final_job) + db.session.delete(test_recording) + db.session.commit() + + # Assert only one worker claimed the job + assert len(successful_claims) == 1, f"Expected 1 successful claim, got {len(successful_claims)}" + assert final_job.status == 'processing', f"Expected status 'processing', got {final_job.status}" + + print("\n[PASS] Only one worker successfully claimed the job!") + return True + + +def test_multiple_jobs_fair_distribution(): + """ + Test that multiple jobs are distributed fairly across workers. + """ + print("\n=== Testing Multiple Jobs Distribution ===\n") + + from src.app import app + from src.database import db + from src.models import ProcessingJob, User, Recording + from sqlalchemy import update + + with app.app_context(): + # Use the first existing user for testing + test_user = User.query.first() + if not test_user: + test_user = User( + username='test_distribution_user', + email='test_dist@example.com', + password='not_used' + ) + db.session.add(test_user) + db.session.commit() + + # Create multiple test jobs + num_jobs = 5 + job_ids = [] + recording_ids = [] + + for i in range(num_jobs): + recording = Recording( + user_id=test_user.id, + title=f'Test Distribution Recording {i}', + audio_path=f'/tmp/test_audio_{i}.mp3', + status='QUEUED' + ) + db.session.add(recording) + db.session.commit() + recording_ids.append(recording.id) + + job = ProcessingJob( + recording_id=recording.id, + user_id=test_user.id, + job_type='transcribe', + status='queued' + ) + db.session.add(job) + db.session.commit() + job_ids.append(job.id) + + print(f"Created {num_jobs} test jobs: {job_ids}") + + # Have workers claim jobs + claimed_jobs = [] + + def claim_any_job(worker_id): + with app.app_context(): + # Find a queued job + candidate = ProcessingJob.query.filter( + ProcessingJob.status == 'queued', + ProcessingJob.job_type == 'transcribe' + ).first() + + if not candidate: + return None + + # Atomic claim + result = db.session.execute( + update(ProcessingJob) + .where( + ProcessingJob.id == candidate.id, + ProcessingJob.status == 'queued' + ) + .values(status='processing', started_at=datetime.utcnow()) + ) + + if result.rowcount == 1: + db.session.commit() + return candidate.id + else: + db.session.rollback() + return None + + # Each "worker" claims one job + for i in range(num_jobs + 2): # Extra attempts to ensure no double claims + job_id = claim_any_job(i) + if job_id: + claimed_jobs.append(job_id) + print(f" Worker {i} claimed job {job_id}") + else: + print(f" Worker {i} found no available jobs") + + print(f"\nClaimed jobs: {claimed_jobs}") + print(f"Unique jobs claimed: {len(set(claimed_jobs))}") + + # Verify no duplicates + assert len(claimed_jobs) == len(set(claimed_jobs)), "Duplicate job claims detected!" + assert len(claimed_jobs) == num_jobs, f"Expected {num_jobs} claims, got {len(claimed_jobs)}" + + # Cleanup + for job_id in job_ids: + job = db.session.get(ProcessingJob, job_id) + if job: + db.session.delete(job) + for rec_id in recording_ids: + rec = db.session.get(Recording, rec_id) + if rec: + db.session.delete(rec) + db.session.commit() + + print("\n[PASS] All jobs claimed exactly once!") + return True + + +if __name__ == '__main__': + print("=" * 60) + print("Job Queue Race Condition Tests") + print("=" * 60) + + try: + test_atomic_job_claiming() + test_multiple_jobs_fair_distribution() + + print("\n" + "=" * 60) + print("All tests passed!") + print("=" * 60) + + except AssertionError as e: + print(f"\n[FAIL] Test failed: {e}") + sys.exit(1) + except Exception as e: + print(f"\n[ERROR] Unexpected error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/test_json_fix.py b/tests/test_json_fix.py new file mode 100644 index 0000000..9fe148c --- /dev/null +++ b/tests/test_json_fix.py @@ -0,0 +1,58 @@ +import json +import sys +import os + +# Add the parent directory to the path to import app +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.app import auto_close_json, safe_json_loads + +def run_tests(): + """Runs a series of tests for the JSON fixing functions.""" + + test_cases_auto_close = { + "Unterminated string": ('{"title": "Test", "summary": "This is a test', '{"title": "Test", "summary": "This is a test"}'), + "Missing closing brace": ('{"title": "Test", "summary": "This is a test"}', '{"title": "Test", "summary": "This is a test"}'), + "Missing closing bracket": ('[{"item": 1}, {"item": 2}', '[{"item": 1}, {"item": 2}]'), + "Nested unterminated": ('{"data": {"items": [1, 2', '{"data": {"items": [1, 2]}}'), + "String at the end": ('{"key": "value', '{"key": "value"}'), + "Empty string": ('', ''), + "Already valid": ('{"a": 1}', '{"a": 1}'), + "Complex nested object": ('{"a": {"b": {"c": [1, 2, {"d": "e' , '{"a": {"b": {"c": [1, 2, {"d": "e"}]}}}') + } + + print("--- Testing auto_close_json ---") + for name, (input_str, expected_str) in test_cases_auto_close.items(): + result = auto_close_json(input_str) + print(f"Test: {name}") + print(f" Input: '{input_str}'") + print(f" Output: '{result}'") + print(f" Expected: '{expected_str}'") + assert result == expected_str, f"Failed: {name}" + print(" Result: PASSED\n") + + test_cases_safe_loads = { + "Unterminated string": '{"title": "Test", "summary": "This is a test', + "Markdown with unterminated JSON": '```json\n{"title": "Test", "summary": "This is a test\n```', + "Missing closing brace": '{"title": "Test", "summary": "This is a test"}', + "Valid JSON": '{"title": "Complete", "summary": "This is a complete JSON."}', + "JSON with escaped quotes": '{"title": "Escaped", "summary": "This is a \\"test\\" with quotes."}', + "Invalid JSON": 'this is not json', + } + + print("\n--- Testing safe_json_loads ---") + for name, input_str in test_cases_safe_loads.items(): + result = safe_json_loads(input_str) + print(f"Test: {name}") + print(f" Input: '{input_str}'") + print(f" Output: {result}") + if name == "Invalid JSON": + assert result is None, f"Failed: {name}" + print(" Result: PASSED (Correctly returned None)\n") + else: + assert isinstance(result, dict), f"Failed: {name}" + print(" Result: PASSED\n") + +if __name__ == "__main__": + run_tests() + print("All tests completed successfully!") diff --git a/tests/test_json_preprocessing.py b/tests/test_json_preprocessing.py new file mode 100644 index 0000000..d316143 --- /dev/null +++ b/tests/test_json_preprocessing.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Test suite for JSON preprocessing functionality in Speakr app. +Tests the safe_json_loads function with various malformed JSON scenarios. +""" + +import sys +import os +import json +import unittest +from unittest.mock import Mock + +# Add the app directory to the path so we can import from app.py +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Mock the Flask app and logger for testing +class MockApp: + def __init__(self): + self.logger = Mock() + +# Set up the mock app before importing +app = MockApp() + +# Import the functions we want to test +from src.app import safe_json_loads, preprocess_json_escapes, extract_json_object + +class TestJSONPreprocessing(unittest.TestCase): + """Test cases for JSON preprocessing functionality.""" + + def test_valid_json(self): + """Test that valid JSON is parsed correctly.""" + valid_json = '{"title": "Test Meeting", "summary": "This is a test summary"}' + result = safe_json_loads(valid_json) + expected = {"title": "Test Meeting", "summary": "This is a test summary"} + self.assertEqual(result, expected) + + def test_json_with_markdown_code_blocks(self): + """Test JSON wrapped in markdown code blocks.""" + markdown_json = '''```json +{ + "title": "Meeting Notes", + "summary": "Key points discussed" +} +```''' + result = safe_json_loads(markdown_json) + expected = {"title": "Meeting Notes", "summary": "Key points discussed"} + self.assertEqual(result, expected) + + def test_json_with_unescaped_quotes(self): + """Test JSON with unescaped quotes in string values.""" + malformed_json = '{"title": "John said "Hello world" to everyone", "summary": "Meeting summary"}' + result = safe_json_loads(malformed_json) + expected = {"title": 'John said "Hello world" to everyone', "summary": "Meeting summary"} + self.assertEqual(result, expected) + + def test_json_with_mixed_quotes(self): + """Test JSON with mixed quote scenarios.""" + malformed_json = '{"title": "Alice\'s "big idea" presentation", "summary": "She said "this will change everything""}' + result = safe_json_loads(malformed_json) + self.assertIsInstance(result, dict) + self.assertIn("title", result) + self.assertIn("summary", result) + + def test_json_with_newlines_and_special_chars(self): + """Test JSON with newlines and special characters.""" + malformed_json = '''{"title": "Complex Meeting", "summary": "Discussion about:\n- Point 1\n- Point 2 with "quotes"\n- Point 3"}''' + result = safe_json_loads(malformed_json) + self.assertIsInstance(result, dict) + self.assertIn("title", result) + self.assertIn("summary", result) + + def test_empty_or_invalid_input(self): + """Test handling of empty or invalid input.""" + # Empty string + result = safe_json_loads("", {"default": "value"}) + self.assertEqual(result, {"default": "value"}) + + # None input + result = safe_json_loads(None, {"default": "value"}) + self.assertEqual(result, {"default": "value"}) + + # Non-string input + result = safe_json_loads(123, {"default": "value"}) + self.assertEqual(result, {"default": "value"}) + + def test_completely_malformed_json(self): + """Test completely malformed JSON that can't be fixed.""" + malformed_json = '{"title": "Test", "summary": unclosed string and missing quotes}' + result = safe_json_loads(malformed_json, {"error": "fallback"}) + self.assertEqual(result, {"error": "fallback"}) + + def test_json_with_nested_quotes(self): + """Test JSON with deeply nested quote scenarios.""" + malformed_json = '{"title": "Meeting about "Project Alpha" and "Project Beta"", "summary": "Both projects involve "cutting-edge" technology"}' + result = safe_json_loads(malformed_json) + self.assertIsInstance(result, dict) + # Should have successfully parsed something + self.assertTrue(len(result) > 0) + + def test_json_array_format(self): + """Test JSON array format (for transcription data).""" + json_array = '[{"speaker": "John", "sentence": "Hello everyone"}, {"speaker": "Jane", "sentence": "Good morning"}]' + result = safe_json_loads(json_array) + expected = [{"speaker": "John", "sentence": "Hello everyone"}, {"speaker": "Jane", "sentence": "Good morning"}] + self.assertEqual(result, expected) + + def test_preprocess_json_escapes_function(self): + """Test the preprocess_json_escapes function directly.""" + input_json = '{"title": "John said "Hello" to Mary", "summary": "Simple test"}' + processed = preprocess_json_escapes(input_json) + # Should be valid JSON after preprocessing + result = json.loads(processed) + self.assertIsInstance(result, dict) + self.assertIn("title", result) + self.assertIn("summary", result) + + def test_extract_json_object_function(self): + """Test the extract_json_object function directly.""" + # Test with extra text around JSON object + text_with_json = 'Here is some text {"title": "Test", "summary": "Content"} and more text' + extracted = extract_json_object(text_with_json) + result = json.loads(extracted) + expected = {"title": "Test", "summary": "Content"} + self.assertEqual(result, expected) + + # Test with JSON array + text_with_array = 'Some text [{"item": "one"}, {"item": "two"}] more text' + extracted = extract_json_object(text_with_array) + result = json.loads(extracted) + expected = [{"item": "one"}, {"item": "two"}] + self.assertEqual(result, expected) + + def test_real_world_llm_response_scenarios(self): + """Test real-world scenarios that might come from LLM responses.""" + + # Scenario 1: LLM response with explanation text + llm_response1 = '''Here's the JSON response you requested: + +```json +{ + "title": "Q3 Planning Meeting", + "summary": "We discussed the "new initiative" and John's "breakthrough idea" for next quarter." +} +``` + +This should help with your transcription needs.''' + + result1 = safe_json_loads(llm_response1) + self.assertIsInstance(result1, dict) + self.assertIn("title", result1) + self.assertIn("summary", result1) + + # Scenario 2: LLM response with unescaped quotes and no code blocks + llm_response2 = '{"title": "Team Standup", "summary": "Alice mentioned "the deadline is tight" and Bob said "we need more resources""}' + + result2 = safe_json_loads(llm_response2) + self.assertIsInstance(result2, dict) + self.assertIn("title", result2) + self.assertIn("summary", result2) + + # Scenario 3: LLM response with speaker identification + llm_response3 = '''{"SPEAKER_00": "John Smith", "SPEAKER_01": "Jane "The Expert" Doe", "SPEAKER_02": "Bob"}''' + + result3 = safe_json_loads(llm_response3) + self.assertIsInstance(result3, dict) + self.assertTrue(len(result3) >= 2) # Should have parsed at least some speakers + + def test_fallback_strategies(self): + """Test that different parsing strategies work as fallbacks.""" + + # Test ast.literal_eval fallback for simple cases + simple_dict = "{'title': 'Simple', 'summary': 'Test'}" + result = safe_json_loads(simple_dict) + expected = {"title": "Simple", "summary": "Test"} + self.assertEqual(result, expected) + + # Test regex extraction fallback + messy_response = 'Some text before {"title": "Extracted", "summary": "From regex"} some text after' + result = safe_json_loads(messy_response) + expected = {"title": "Extracted", "summary": "From regex"} + self.assertEqual(result, expected) + + def test_performance_with_large_content(self): + """Test performance with larger JSON content.""" + large_summary = "This is a very long summary. " * 100 # Create a long string + large_json = f'{{"title": "Large Content Test", "summary": "{large_summary}"}}' + + result = safe_json_loads(large_json) + self.assertIsInstance(result, dict) + self.assertIn("title", result) + self.assertIn("summary", result) + self.assertEqual(result["title"], "Large Content Test") + +def run_comprehensive_test(): + """Run a comprehensive test with various malformed JSON examples.""" + print("🧪 Running comprehensive JSON preprocessing tests...\n") + + test_cases = [ + { + "name": "Valid JSON", + "input": '{"title": "Test", "summary": "Valid JSON"}', + "should_succeed": True + }, + { + "name": "Unescaped quotes in title", + "input": '{"title": "Meeting about "Project X"", "summary": "Discussion summary"}', + "should_succeed": True + }, + { + "name": "Multiple unescaped quotes", + "input": '{"title": "John said "Hello" and Mary replied "Hi there"", "summary": "Conversation log"}', + "should_succeed": True + }, + { + "name": "Markdown code block", + "input": '```json\n{"title": "Wrapped", "summary": "In code block"}\n```', + "should_succeed": True + }, + { + "name": "Mixed quotes and apostrophes", + "input": '{"title": "Alice\'s "big idea" presentation", "summary": "She said it\'s "revolutionary""}', + "should_succeed": True + }, + { + "name": "JSON with newlines", + "input": '{"title": "Multi-line", "summary": "Line 1\\nLine 2 with \\"quotes\\"\\nLine 3"}', + "should_succeed": True + }, + { + "name": "Completely malformed", + "input": '{"title": "Test", "summary": this is not valid json at all}', + "should_succeed": False + }, + { + "name": "Empty string", + "input": "", + "should_succeed": False + } + ] + + passed = 0 + failed = 0 + + for i, test_case in enumerate(test_cases, 1): + print(f"Test {i}: {test_case['name']}") + print(f"Input: {test_case['input'][:100]}{'...' if len(test_case['input']) > 100 else ''}") + + try: + result = safe_json_loads(test_case['input'], {"error": "fallback"}) + + if test_case['should_succeed']: + if result != {"error": "fallback"} and isinstance(result, dict): + print("✅ PASSED - Successfully parsed JSON") + passed += 1 + else: + print("❌ FAILED - Expected successful parsing but got fallback") + failed += 1 + else: + if result == {"error": "fallback"}: + print("✅ PASSED - Correctly returned fallback for malformed JSON") + passed += 1 + else: + print("❌ FAILED - Expected fallback but got parsed result") + failed += 1 + + except Exception as e: + print(f"❌ FAILED - Exception occurred: {e}") + failed += 1 + + print("-" * 50) + + print(f"\n📊 Test Results: {passed} passed, {failed} failed") + return failed == 0 + +if __name__ == "__main__": + print("🚀 Starting JSON Preprocessing Tests for Speakr App\n") + + # Run the comprehensive manual test + manual_success = run_comprehensive_test() + + print("\n" + "="*60) + print("🔬 Running Unit Tests") + print("="*60) + + # Run the unit tests + unittest.main(argv=[''], exit=False, verbosity=2) + + if manual_success: + print("\n🎉 All tests completed! JSON preprocessing should handle LLM response issues gracefully.") + else: + print("\n⚠️ Some tests failed. Please review the implementation.") diff --git a/tests/test_json_standalone.py b/tests/test_json_standalone.py new file mode 100644 index 0000000..c08827b --- /dev/null +++ b/tests/test_json_standalone.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +""" +Standalone test for JSON preprocessing functionality. +Tests the safe_json_loads function with various malformed JSON scenarios. +""" + +import json +import re +import ast +from unittest.mock import Mock + +# Mock logger for testing +class MockLogger: + def warning(self, msg): print(f"WARNING: {msg}") + def info(self, msg): print(f"INFO: {msg}") + def debug(self, msg): print(f"DEBUG: {msg}") + def error(self, msg): print(f"ERROR: {msg}") + +# Create mock app with logger +class MockApp: + logger = MockLogger() + +app = MockApp() + +def safe_json_loads(json_string, fallback_value=None): + """ + Safely parse JSON with preprocessing to handle common LLM JSON formatting issues. + + Args: + json_string (str): The JSON string to parse + fallback_value: Value to return if parsing fails (default: None) + + Returns: + Parsed JSON object or fallback_value if parsing fails + """ + if not json_string or not isinstance(json_string, str): + app.logger.warning(f"Invalid JSON input: {type(json_string)} - {json_string}") + return fallback_value + + # Step 1: Clean the input string + cleaned_json = json_string.strip() + + # Step 2: Extract JSON from markdown code blocks if present + json_match = re.search(r'```(?:json)?\s*(.*?)\s*```', cleaned_json, re.DOTALL) + if json_match: + cleaned_json = json_match.group(1).strip() + + # Step 3: Try multiple parsing strategies + parsing_strategies = [ + # Strategy 1: Direct parsing (for well-formed JSON) + lambda x: json.loads(x), + + # Strategy 2: Fix common escape issues + lambda x: json.loads(preprocess_json_escapes(x)), + + # Strategy 3: Use ast.literal_eval as fallback for simple cases + lambda x: ast.literal_eval(x) if x.startswith(('{', '[')) else None, + + # Strategy 4: Extract JSON object/array using regex + lambda x: json.loads(extract_json_object(x)), + ] + + for i, strategy in enumerate(parsing_strategies): + try: + result = strategy(cleaned_json) + if result is not None: + if i > 0: # Log if we had to use a fallback strategy + app.logger.info(f"JSON parsed successfully using strategy {i+1}") + return result + except (json.JSONDecodeError, ValueError, SyntaxError) as e: + if i == 0: # Only log the first failure to avoid spam + app.logger.debug(f"JSON parsing strategy {i+1} failed: {e}") + continue + + # All strategies failed + app.logger.error(f"All JSON parsing strategies failed for: {cleaned_json[:200]}...") + return fallback_value + +def preprocess_json_escapes(json_string): + """ + Preprocess JSON string to fix common escape issues from LLM responses. + Uses a more sophisticated approach to handle nested quotes properly. + """ + if not json_string: + return json_string + + result = [] + i = 0 + in_string = False + escape_next = False + expecting_value = False # Track if we're expecting a value (after :) + + while i < len(json_string): + char = json_string[i] + + if escape_next: + # This character is escaped, add it as-is + result.append(char) + escape_next = False + elif char == '\\': + # This is an escape character + result.append(char) + escape_next = True + elif char == ':' and not in_string: + # We found a colon, next string will be a value + result.append(char) + expecting_value = True + elif char == ',' and not in_string: + # We found a comma, reset expecting_value + result.append(char) + expecting_value = False + elif char == '"': + if not in_string: + # Starting a string + in_string = True + result.append(char) + else: + # We're in a string, check if this quote should be escaped + # Look ahead to see if this is the end of the string value + j = i + 1 + while j < len(json_string) and json_string[j].isspace(): + j += 1 + + # For keys (not expecting_value), only end on colon + # For values (expecting_value), end on comma, closing brace, or closing bracket + if expecting_value: + end_chars = ',}]' + else: + end_chars = ':' + + if j < len(json_string) and json_string[j] in end_chars: + # This is the end of the string + in_string = False + result.append(char) + if not expecting_value: + # We just finished a key, next will be expecting value + expecting_value = True + else: + # This is an inner quote that should be escaped + result.append('\\"') + else: + result.append(char) + + i += 1 + + return ''.join(result) + +def extract_json_object(text): + """ + Extract the first complete JSON object or array from text using regex. + """ + # Look for JSON object + obj_match = re.search(r'\{.*\}', text, re.DOTALL) + if obj_match: + return obj_match.group(0) + + # Look for JSON array + arr_match = re.search(r'\[.*\]', text, re.DOTALL) + if arr_match: + return arr_match.group(0) + + # Return original if no JSON structure found + return text + +def run_comprehensive_test(): + """Run a comprehensive test with various malformed JSON examples.""" + print("🧪 Running comprehensive JSON preprocessing tests...\n") + + test_cases = [ + { + "name": "Valid JSON", + "input": '{"title": "Test", "summary": "Valid JSON"}', + "should_succeed": True + }, + { + "name": "Unescaped quotes in title", + "input": '{"title": "Meeting about "Project X"", "summary": "Discussion summary"}', + "should_succeed": True + }, + { + "name": "Multiple unescaped quotes", + "input": '{"title": "John said "Hello" and Mary replied "Hi there"", "summary": "Conversation log"}', + "should_succeed": True + }, + { + "name": "Markdown code block", + "input": '```json\n{"title": "Wrapped", "summary": "In code block"}\n```', + "should_succeed": True + }, + { + "name": "Mixed quotes and apostrophes", + "input": '{"title": "Alice\'s "big idea" presentation", "summary": "She said it\'s "revolutionary""}', + "should_succeed": True + }, + { + "name": "JSON with newlines", + "input": '{"title": "Multi-line", "summary": "Line 1\\nLine 2 with \\"quotes\\"\\nLine 3"}', + "should_succeed": True + }, + { + "name": "LLM response with explanation", + "input": '''Here's the JSON: +```json +{ + "title": "Q3 Planning", + "summary": "We discussed the "new initiative" for next quarter." +} +``` +Hope this helps!''', + "should_succeed": True + }, + { + "name": "Speaker identification with quotes", + "input": '{"SPEAKER_00": "John Smith", "SPEAKER_01": "Jane "The Expert" Doe", "SPEAKER_02": "Bob"}', + "should_succeed": True + }, + { + "name": "Completely malformed", + "input": '{"title": "Test", "summary": this is not valid json at all}', + "should_succeed": False + }, + { + "name": "Empty string", + "input": "", + "should_succeed": False + } + ] + + passed = 0 + failed = 0 + + for i, test_case in enumerate(test_cases, 1): + print(f"Test {i}: {test_case['name']}") + print(f"Input: {test_case['input'][:100]}{'...' if len(test_case['input']) > 100 else ''}") + + try: + result = safe_json_loads(test_case['input'], {"error": "fallback"}) + + if test_case['should_succeed']: + if result != {"error": "fallback"} and isinstance(result, (dict, list)): + print("✅ PASSED - Successfully parsed JSON") + print(f" Result: {result}") + passed += 1 + else: + print("❌ FAILED - Expected successful parsing but got fallback") + failed += 1 + else: + if result == {"error": "fallback"}: + print("✅ PASSED - Correctly returned fallback for malformed JSON") + passed += 1 + else: + print("❌ FAILED - Expected fallback but got parsed result") + print(f" Unexpected result: {result}") + failed += 1 + + except Exception as e: + print(f"❌ FAILED - Exception occurred: {e}") + failed += 1 + + print("-" * 50) + + print(f"\n📊 Test Results: {passed} passed, {failed} failed") + return failed == 0 + +def test_preprocessing_function(): + """Test the preprocessing function directly.""" + print("\n🔧 Testing preprocessing function directly...\n") + + test_input = '{"title": "Meeting about "Project X"", "summary": "Discussion summary"}' + print(f"Original: {test_input}") + + processed = preprocess_json_escapes(test_input) + print(f"Processed: {processed}") + + try: + result = json.loads(processed) + print(f"✅ Successfully parsed: {result}") + except json.JSONDecodeError as e: + print(f"❌ Still failed: {e}") + +def test_specific_scenarios(): + """Test specific real-world scenarios.""" + print("\n🎯 Testing specific LLM response scenarios...\n") + + # Test case from the original issue + gemini_response = '''{"title": "Meeting about "Project Phoenix" and budget allocation", "summary": "The team discussed John's "breakthrough idea" and Mary said "this will change everything" during the Q3 planning session."}''' + + print("Testing Gemini-style response with unescaped quotes:") + print(f"Input: {gemini_response}") + + # Test preprocessing directly + processed = preprocess_json_escapes(gemini_response) + print(f"Processed: {processed}") + + result = safe_json_loads(gemini_response) + if isinstance(result, dict) and "title" in result and "summary" in result: + print("✅ SUCCESS - Parsed Gemini response correctly!") + print(f"Title: {result['title']}") + print(f"Summary: {result['summary'][:100]}...") + else: + print("❌ FAILED - Could not parse Gemini response") + print(f"Result: {result}") + + print("-" * 50) + + # Test speaker identification scenario + speaker_response = '''{"SPEAKER_00": "John "The Manager" Smith", "SPEAKER_01": "Alice Johnson", "SPEAKER_02": "Bob "Tech Lead" Wilson"}''' + + print("Testing speaker identification with quotes in names:") + print(f"Input: {speaker_response}") + + # Test preprocessing directly + processed = preprocess_json_escapes(speaker_response) + print(f"Processed: {processed}") + + result = safe_json_loads(speaker_response) + if isinstance(result, dict) and len(result) >= 3: + print("✅ SUCCESS - Parsed speaker identification correctly!") + for speaker, name in result.items(): + print(f" {speaker}: {name}") + else: + print("❌ FAILED - Could not parse speaker identification") + print(f"Result: {result}") + +if __name__ == "__main__": + print("🚀 Starting Standalone JSON Preprocessing Tests\n") + + # Test preprocessing function directly + test_preprocessing_function() + + # Run the comprehensive test + success = run_comprehensive_test() + + # Test specific scenarios + test_specific_scenarios() + + if success: + print("\n🎉 All tests completed successfully! JSON preprocessing should handle LLM response issues gracefully.") + else: + print("\n⚠️ Some tests failed. The implementation may need refinement.") diff --git a/tests/test_migration_compatibility.py b/tests/test_migration_compatibility.py new file mode 100644 index 0000000..e8a43b5 --- /dev/null +++ b/tests/test_migration_compatibility.py @@ -0,0 +1,251 @@ +""" +Test suite to ensure database migrations are compatible with both SQLite and PostgreSQL. + +These tests scan the init_db.py file for patterns that would break on PostgreSQL, +such as SQLite-only boolean defaults (0/1 instead of FALSE/TRUE) and unquoted +reserved keywords. + +Run with: python tests/test_migration_compatibility.py +""" + +import re +import unittest +import os + + +class TestMigrationCompatibility(unittest.TestCase): + """Tests to ensure init_db.py uses cross-database compatible SQL.""" + + @classmethod + def setUpClass(cls): + """Load init_db.py content once for all tests.""" + # Find the project root + test_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(test_dir) + init_db_path = os.path.join(project_root, 'src', 'init_db.py') + + with open(init_db_path, 'r') as f: + cls.content = f.read() + + def test_no_raw_boolean_defaults_in_alter_table(self): + """ + Ensure no raw ALTER TABLE statements use SQLite-only boolean defaults. + + The pattern 'BOOLEAN DEFAULT 0' or 'BOOLEAN DEFAULT 1' in raw SQL + will fail on PostgreSQL, which requires 'DEFAULT FALSE' or 'DEFAULT TRUE'. + + Using add_column_if_not_exists() handles this conversion automatically. + """ + # Pattern to find raw SQL with text() that has BOOLEAN DEFAULT 0/1 + # This matches: text('... BOOLEAN DEFAULT 0 ...') or text("...") + pattern = r"conn\.execute\s*\(\s*text\s*\(['\"]([^'\"]*BOOLEAN\s+DEFAULT\s+[01][^'\"]*)['\"]" + + matches = re.findall(pattern, self.content, re.IGNORECASE) + + # Filter out false positives - we're looking for raw ALTER TABLE statements + # not UPDATE statements or other SQL that legitimately uses 0/1 + problematic = [] + for match in matches: + match_upper = match.upper() + # Only flag if it's an ALTER TABLE with BOOLEAN DEFAULT 0/1 + if 'ALTER TABLE' in match_upper and 'BOOLEAN' in match_upper: + if 'DEFAULT 0' in match or 'DEFAULT 1' in match: + problematic.append(match) + + self.assertEqual( + len(problematic), 0, + f"Found SQLite-only boolean defaults in raw ALTER TABLE statements. " + f"Use add_column_if_not_exists() instead:\n" + + "\n".join(f" - {m[:100]}..." if len(m) > 100 else f" - {m}" for m in problematic) + ) + + def test_no_boolean_integer_comparisons_in_raw_sql(self): + """ + Ensure raw SQL doesn't compare boolean columns to integers (0/1). + + PostgreSQL strictly separates boolean and integer types: + - 'column = 1' fails with 'operator does not exist: boolean = integer' + - 'column = TRUE' works on both SQLite (3.23+) and PostgreSQL + + Known boolean columns in migrations: protect_from_deletion, email_verified, + auto_share_on_apply, share_with_group_lead, is_inbox, is_highlighted, + deletion_exempt, is_admin, can_share_publicly. + """ + boolean_columns = [ + 'protect_from_deletion', 'email_verified', 'auto_share_on_apply', + 'share_with_group_lead', 'is_inbox', 'is_highlighted', + 'deletion_exempt', 'is_admin', 'can_share_publicly', + 'auto_speaker_labelling', 'auto_summarization' + ] + + # Find raw SQL in text() calls + sql_pattern = r"text\s*\(\s*['\"\"]\"\"(.*?)['\"\"]\"\"?\s*\)" + # Simpler: find lines with known boolean column = 0 or = 1 + problematic = [] + for col in boolean_columns: + # Match: column = 0 or column = 1 (not = TRUE/FALSE) + pattern = rf"{col}\s*=\s*[01]\b" + matches = re.finditer(pattern, self.content, re.IGNORECASE) + for match in matches: + # Get surrounding context to check if it's in a text() SQL call + start = max(0, match.start() - 200) + context = self.content[start:match.end() + 50] + if 'text(' in context and 'sqlite_master' not in context: + problematic.append(f"{col}: ...{match.group()}...") + + self.assertEqual( + len(problematic), 0, + f"Found boolean columns compared to integers in raw SQL. " + f"Use TRUE/FALSE instead of 1/0 for PostgreSQL compatibility:\n" + + "\n".join(f" - {p}" for p in problematic) + ) + + def test_reserved_keywords_quoted_in_index_creation(self): + """ + Ensure reserved keywords like 'user' are properly quoted in index creation. + + Raw SQL like 'CREATE INDEX ... ON user (column)' will fail on some databases + because 'user' is a reserved keyword. It should be quoted as "user" or use + the create_index_if_not_exists() utility. + """ + reserved_keywords = ['user', 'order', 'group', 'table', 'select', 'index'] + + problematic = [] + + for keyword in reserved_keywords: + # Pattern to find unquoted reserved keyword after ON in index creation + # Matches: CREATE INDEX ... ON user ( but not ON "user" or ON `user` + pattern = rf"CREATE\s+(?:UNIQUE\s+)?INDEX[^;]*\s+ON\s+{keyword}\s*\(" + + matches = re.findall(pattern, self.content, re.IGNORECASE) + + for match in matches: + # Skip if the keyword is already quoted + if f'"{keyword}"' in match.lower() or f'`{keyword}`' in match.lower(): + continue + problematic.append((keyword, match[:80])) + + self.assertEqual( + len(problematic), 0, + f"Found unquoted reserved keywords in index creation. " + f"Use create_index_if_not_exists() or quote the table name:\n" + + "\n".join(f" - '{kw}' in: {sql}..." for kw, sql in problematic) + ) + + def test_add_column_uses_utility(self): + """ + Ensure most ADD COLUMN operations use add_column_if_not_exists(). + + Direct ALTER TABLE ADD COLUMN statements should use the utility function + to ensure cross-database compatibility with boolean defaults and quoting. + """ + # Count direct ALTER TABLE ADD COLUMN in text() calls + direct_pattern = r"conn\.execute\s*\(\s*text\s*\(['\"][^'\"]*ALTER\s+TABLE[^'\"]*ADD\s+COLUMN" + direct_matches = re.findall(direct_pattern, self.content, re.IGNORECASE) + + # Count uses of add_column_if_not_exists + utility_pattern = r"add_column_if_not_exists\s*\(" + utility_matches = re.findall(utility_pattern, self.content) + + # We expect most ADD COLUMN operations to use the utility + # Allow some direct usage for special cases (e.g., table recreation) + # but utility usage should significantly outnumber direct usage + self.assertGreater( + len(utility_matches), len(direct_matches), + f"Found {len(direct_matches)} direct ALTER TABLE ADD COLUMN statements " + f"vs {len(utility_matches)} add_column_if_not_exists() calls. " + f"Consider using the utility function for cross-database compatibility." + ) + + def test_incompatible_types_handled_by_utility(self): + """ + Ensure columns with PostgreSQL-incompatible types (DATETIME, BLOB) are + added through add_column_if_not_exists() which auto-converts them, + and NOT via raw ALTER TABLE statements that would bypass conversion. + + PostgreSQL type differences: + - DATETIME -> TIMESTAMP + - BLOB -> BYTEA + """ + incompatible_types = ['DATETIME', 'BLOB'] + + # Check for raw ALTER TABLE statements using incompatible types + for sql_type in incompatible_types: + pattern = rf"conn\.execute\s*\(\s*text\s*\(['\"][^'\"]*ALTER\s+TABLE[^'\"]*\b{sql_type}\b[^'\"]*['\"]" + matches = re.findall(pattern, self.content, re.IGNORECASE) + + self.assertEqual( + len(matches), 0, + f"Found raw ALTER TABLE statements using '{sql_type}' which is incompatible with PostgreSQL. " + f"Use add_column_if_not_exists() which auto-converts types:\n" + + "\n".join(f" - {m[:100]}..." if len(m) > 100 else f" - {m}" for m in matches) + ) + + # Verify that add_column_if_not_exists calls using these types exist + # (confirming they go through the utility which handles conversion) + for sql_type in incompatible_types: + pattern = rf"add_column_if_not_exists\s*\([^)]*['\"]({sql_type})['\"]" + matches = re.findall(pattern, self.content, re.IGNORECASE) + # Just informational - these are fine because the utility converts them + + def test_no_double_quoted_string_defaults(self): + """ + Ensure no SQL DEFAULT values use double-quoted strings. + + In SQL, double quotes denote identifiers (column/table names), not string + literals. SQLite tolerates this, but PostgreSQL will interpret DEFAULT "en" + as a reference to a column named "en" and fail with 'column "en" does not exist'. + + String defaults must use single quotes: DEFAULT 'en' + """ + # Match DEFAULT followed by a double-quoted string value + pattern = r'DEFAULT\s+"[^"]*"' + + lines = self.content.splitlines() + problematic = [] + for i, line in enumerate(lines, 1): + if re.search(pattern, line, re.IGNORECASE): + problematic.append(f" Line {i}: {line.strip()}") + + self.assertEqual( + len(problematic), 0, + f"Found double-quoted string defaults in init_db.py. " + f"PostgreSQL interprets double quotes as column identifiers, not string literals. " + f"Use single quotes instead (e.g., DEFAULT 'en' not DEFAULT \"en\"):\n" + + "\n".join(problematic) + ) + + def test_create_index_uses_utility_for_user_table(self): + """ + Ensure index creation on 'user' table uses create_index_if_not_exists(). + + The 'user' table name is a reserved keyword that requires special quoting. + Using create_index_if_not_exists() handles this automatically. + """ + # Find all index creation on user table + pattern = r"CREATE\s+(?:UNIQUE\s+)?INDEX[^;]*ON\s+[\"'`]?user[\"'`]?\s*\(" + + # Count raw index creation on user table in text() calls + raw_pattern = r"conn\.execute\s*\(\s*text\s*\(['\"][^'\"]*CREATE\s+(?:UNIQUE\s+)?INDEX[^'\"]*ON\s+[\"'`]?user" + raw_matches = re.findall(raw_pattern, self.content, re.IGNORECASE) + + # Count uses of create_index_if_not_exists for user table + utility_pattern = r"create_index_if_not_exists\s*\([^)]*['\"]user['\"]" + utility_matches = re.findall(utility_pattern, self.content, re.IGNORECASE) + + # All index creation on user table should use the utility + # (excluding table recreation scenarios which have their own quoting) + if len(raw_matches) > 0: + # Check if these are in table recreation blocks (acceptable) + table_recreation_pattern = r"CREATE\s+TABLE\s+user_new" + has_table_recreation = re.search(table_recreation_pattern, self.content, re.IGNORECASE) + + if not has_table_recreation or len(raw_matches) > 1: + self.fail( + f"Found {len(raw_matches)} raw CREATE INDEX statements on 'user' table. " + f"Use create_index_if_not_exists() for proper quoting of reserved keywords." + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_postgres_migrations.py b/tests/test_postgres_migrations.py new file mode 100644 index 0000000..7ea39d2 --- /dev/null +++ b/tests/test_postgres_migrations.py @@ -0,0 +1,165 @@ +""" +Integration test for database migrations against a real database engine. + +Runs initialize_database() and verifies that all tables and critical columns +are created successfully. Works with both SQLite (default, for local runs) +and PostgreSQL (when TEST_DATABASE_URI env var is set). + +IMPORTANT: This test uses TEST_DATABASE_URI (not SQLALCHEMY_DATABASE_URI) to +avoid accidentally connecting to and destroying a real application database. + +Usage: + # Local (SQLite in-memory, safe): + python tests/test_postgres_migrations.py + + # Against PostgreSQL (CI or explicit testing): + TEST_DATABASE_URI=postgresql://user:pass@localhost:5432/testdb \ + python tests/test_postgres_migrations.py +""" + +import os +import sys +import unittest + +# Ensure project root is on the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from flask import Flask +from src.database import db +# Importing models registers them with SQLAlchemy so create_all() builds all tables +import src.models # noqa: F401 +from src.init_db import initialize_database + + +def create_test_app(): + """Create a minimal Flask app for testing database operations. + + Uses TEST_DATABASE_URI env var (NOT SQLALCHEMY_DATABASE_URI) to prevent + accidental connection to production/dev databases. + """ + app = Flask(__name__) + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get( + 'TEST_DATABASE_URI', 'sqlite://' # in-memory SQLite by default + ) + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['TESTING'] = True + db.init_app(app) + return app + + +class TestDatabaseMigrations(unittest.TestCase): + """Test that initialize_database() runs cleanly against the configured DB engine.""" + + @classmethod + def setUpClass(cls): + cls.app = create_test_app() + with cls.app.app_context(): + initialize_database(cls.app) + + @classmethod + def tearDownClass(cls): + with cls.app.app_context(): + # Use raw DROP to avoid circular FK dependency errors in SQLAlchemy's + # drop_all() (user <-> naming_template have mutual foreign keys) + from sqlalchemy import inspect, text + tables = inspect(db.engine).get_table_names() + with db.engine.connect() as conn: + if db.engine.name == 'postgresql': + for table in tables: + conn.execute(text(f'DROP TABLE IF EXISTS "{table}" CASCADE')) + else: + conn.execute(text('PRAGMA foreign_keys = OFF')) + for table in tables: + conn.execute(text(f'DROP TABLE IF EXISTS "{table}"')) + conn.execute(text('PRAGMA foreign_keys = ON')) + conn.commit() + + def _get_table_names(self): + from sqlalchemy import inspect + inspector = inspect(db.engine) + return inspector.get_table_names() + + def _get_column_names(self, table): + from sqlalchemy import inspect + inspector = inspect(db.engine) + return [col['name'] for col in inspector.get_columns(table)] + + def test_core_tables_exist(self): + """Verify that all core tables were created.""" + with self.app.app_context(): + tables = self._get_table_names() + expected_tables = [ + 'user', 'recording', 'transcript_chunk', 'tag', + 'folder', 'share', 'internal_share', 'system_setting', + 'speaker', 'processing_job', 'group', 'group_membership', + ] + for table in expected_tables: + self.assertIn(table, tables, f"Missing table: {table}") + + def test_user_migration_columns(self): + """Verify columns added by migrations exist on the user table.""" + with self.app.app_context(): + columns = self._get_column_names('user') + expected = [ + 'id', 'username', 'email', 'password', + 'transcription_language', 'output_language', 'ui_language', + 'summary_prompt', 'extract_events', 'name', 'job_title', + 'company', 'diarize', 'sso_provider', 'sso_subject', + 'can_share_publicly', 'monthly_token_budget', + 'monthly_transcription_budget', 'email_verified', + 'auto_speaker_labelling', 'auto_summarization', + ] + for col in expected: + self.assertIn(col, columns, f"Missing user column: {col}") + + def test_recording_migration_columns(self): + """Verify columns added by migrations exist on the recording table.""" + with self.app.app_context(): + columns = self._get_column_names('recording') + expected = [ + 'id', 'is_inbox', 'is_highlighted', 'mime_type', + 'completed_at', 'processing_time_seconds', 'error_message', + 'folder_id', 'audio_deleted_at', 'deletion_exempt', + 'speaker_embeddings', + ] + for col in expected: + self.assertIn(col, columns, f"Missing recording column: {col}") + + def test_tag_migration_columns(self): + """Verify columns added by migrations exist on the tag table.""" + with self.app.app_context(): + columns = self._get_column_names('tag') + expected = [ + 'id', 'protect_from_deletion', 'group_id', + 'retention_days', 'auto_share_on_apply', + 'naming_template_id', 'export_template_id', + ] + for col in expected: + self.assertIn(col, columns, f"Missing tag column: {col}") + + def test_system_settings_initialized(self): + """Verify that default system settings were created.""" + with self.app.app_context(): + from src.models import SystemSetting + expected_keys = [ + 'transcript_length_limit', 'max_file_size_mb', + 'asr_timeout_seconds', 'disable_auto_summarization', + 'enable_folders', + ] + for key in expected_keys: + setting = SystemSetting.query.filter_by(key=key).first() + self.assertIsNotNone(setting, f"Missing system setting: {key}") + + def test_engine_type_matches_expectation(self): + """Sanity check: confirm we're testing against the expected engine.""" + with self.app.app_context(): + uri = self.app.config['SQLALCHEMY_DATABASE_URI'] + engine_name = db.engine.name + if uri.startswith('postgresql'): + self.assertEqual(engine_name, 'postgresql') + else: + self.assertEqual(engine_name, 'sqlite') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_video_passthrough.py b/tests/test_video_passthrough.py new file mode 100644 index 0000000..eb2c133 --- /dev/null +++ b/tests/test_video_passthrough.py @@ -0,0 +1,435 @@ +""" +Test suite for the VIDEO_PASSTHROUGH_ASR feature. + +Tests configuration, code path correctness, and interaction with VIDEO_RETENTION +across all entry points (processing pipeline, upload handler, file monitor, incognito). +Uses static analysis — no running server or real video files required. + +Run with: python tests/test_video_passthrough.py +""" + +import os +import re +import sys +import unittest +from pathlib import Path + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(TEST_DIR) +sys.path.insert(0, PROJECT_ROOT) + + +def read_file(rel_path): + with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: + return f.read() + + +# Cache file contents once — they don't change during the run +PROCESSING = read_file('src/tasks/processing.py') +RECORDINGS = read_file('src/api/recordings.py') +FILE_MONITOR = read_file('src/file_monitor.py') +APP_CONFIG = read_file('src/config/app_config.py') +ENV_EXAMPLE = read_file('config/env.transcription.example') + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def get_function_body(source, func_name): + """Extract the body of a top-level function from source code.""" + pattern = rf'^def {func_name}\(' + lines = source.split('\n') + start = None + for i, line in enumerate(lines): + if re.match(pattern, line): + start = i + break + if start is None: + return '' + # Collect until next top-level def or class or EOF + body_lines = [lines[start]] + for line in lines[start + 1:]: + if line and not line[0].isspace() and (line.startswith('def ') or line.startswith('class ')): + break + body_lines.append(line) + return '\n'.join(body_lines) + + +def split_at_incognito(source): + """Split processing.py into main and incognito sections.""" + marker = 'def transcribe_incognito(' + idx = source.find(marker) + if idx == -1: + return source, '' + return source[:idx], source[idx:] + + +PROCESSING_MAIN, PROCESSING_INCOGNITO = split_at_incognito(PROCESSING) + + +# =========================================================================== +# 1. Configuration +# =========================================================================== + +class TestPassthroughConfig(unittest.TestCase): + """VIDEO_PASSTHROUGH_ASR env var is defined and defaults to false.""" + + FILES_THAT_NEED_IT = [ + ('src/config/app_config.py', APP_CONFIG), + ('src/tasks/processing.py', PROCESSING), + ('src/api/recordings.py', RECORDINGS), + ('src/file_monitor.py', FILE_MONITOR), + ] + + def test_defined_in_all_files(self): + for rel_path, content in self.FILES_THAT_NEED_IT: + with self.subTest(file=rel_path): + self.assertIn('VIDEO_PASSTHROUGH_ASR', content, + f"VIDEO_PASSTHROUGH_ASR missing from {rel_path}") + + def test_default_is_false_everywhere(self): + for rel_path, content in self.FILES_THAT_NEED_IT: + match = re.search( + r"VIDEO_PASSTHROUGH_ASR\s*=\s*os\.environ\.get\('VIDEO_PASSTHROUGH_ASR',\s*'(\w+)'\)", + content + ) + if match: + with self.subTest(file=rel_path): + self.assertEqual(match.group(1), 'false', + f"Default should be 'false' in {rel_path}") + + def test_canonical_definition_in_app_config(self): + self.assertIn( + "VIDEO_PASSTHROUGH_ASR = os.environ.get('VIDEO_PASSTHROUGH_ASR', 'false').lower() == 'true'", + APP_CONFIG + ) + + def test_documented_in_env_example(self): + self.assertIn('VIDEO_PASSTHROUGH_ASR', ENV_EXAMPLE) + + def test_processing_imports_from_config(self): + self.assertIn('VIDEO_PASSTHROUGH_ASR', PROCESSING) + # Should import from app_config, not read os.environ directly + self.assertIn('import', PROCESSING) + # Verify it's in an import line from app_config + import_lines = [l for l in PROCESSING.split('\n') + if 'from src.config.app_config import' in l] + found = any('VIDEO_PASSTHROUGH_ASR' in l for l in import_lines) + self.assertTrue(found, "processing.py should import VIDEO_PASSTHROUGH_ASR from app_config") + + +# =========================================================================== +# 2. Processing pipeline — main transcription path +# =========================================================================== + +class TestProcessingMainPath(unittest.TestCase): + """Test transcribe_with_connector() video passthrough code paths.""" + + def test_passthrough_branch_exists_before_retention(self): + """VIDEO_PASSTHROUGH_ASR is checked before VIDEO_RETENTION in the is_video block.""" + # Inside the `if is_video:` block, passthrough should be the first check + video_block_start = PROCESSING_MAIN.find('if is_video:') + self.assertNotEqual(video_block_start, -1) + + after_video = PROCESSING_MAIN[video_block_start:] + passthrough_pos = after_video.find('if VIDEO_PASSTHROUGH_ASR:') + retention_pos = after_video.find('elif VIDEO_RETENTION:') + + self.assertNotEqual(passthrough_pos, -1, "Missing VIDEO_PASSTHROUGH_ASR check in is_video block") + self.assertNotEqual(retention_pos, -1, "Missing elif VIDEO_RETENTION check") + self.assertLess(passthrough_pos, retention_pos, + "VIDEO_PASSTHROUGH_ASR should be checked before VIDEO_RETENTION") + + def test_passthrough_does_not_call_extract_audio(self): + """The passthrough branch must not call extract_audio_from_video.""" + video_block = PROCESSING_MAIN[PROCESSING_MAIN.find('if is_video:'):] + # Find the passthrough branch (from `if VIDEO_PASSTHROUGH_ASR:` to `elif VIDEO_RETENTION:`) + pt_start = video_block.find('if VIDEO_PASSTHROUGH_ASR:') + pt_end = video_block.find('elif VIDEO_RETENTION:') + passthrough_block = video_block[pt_start:pt_end] + self.assertNotIn('extract_audio_from_video', passthrough_block, + "Passthrough branch should NOT extract audio") + + def test_passthrough_keeps_original_filepath(self): + """Passthrough sets actual_filepath = filepath (the original video).""" + video_block = PROCESSING_MAIN[PROCESSING_MAIN.find('if is_video:'):] + pt_start = video_block.find('if VIDEO_PASSTHROUGH_ASR:') + pt_end = video_block.find('elif VIDEO_RETENTION:') + passthrough_block = video_block[pt_start:pt_end] + self.assertIn('actual_filepath = filepath', passthrough_block) + + def test_passthrough_with_retention_sets_recording_path(self): + """When both passthrough and retention are on, recording.audio_path is set.""" + video_block = PROCESSING_MAIN[PROCESSING_MAIN.find('if is_video:'):] + pt_start = video_block.find('if VIDEO_PASSTHROUGH_ASR:') + pt_end = video_block.find('elif VIDEO_RETENTION:') + passthrough_block = video_block[pt_start:pt_end] + self.assertIn('if VIDEO_RETENTION:', passthrough_block, + "Passthrough branch should conditionally handle retention") + self.assertIn('recording.audio_path = filepath', passthrough_block) + self.assertIn("mimetypes.guess_type(filepath)", passthrough_block) + + def test_video_passthrough_active_flag_set(self): + """video_passthrough_active flag is computed from is_video and VIDEO_PASSTHROUGH_ASR.""" + self.assertIn('video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR', + PROCESSING_MAIN) + + def test_conversion_skipped_when_passthrough(self): + """convert_if_needed is inside an else block gated by video_passthrough_active.""" + self.assertIn('if video_passthrough_active:', PROCESSING_MAIN) + # The conversion call should be in the else branch + flag_pos = PROCESSING_MAIN.find('video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR') + after_flag = PROCESSING_MAIN[flag_pos:] + passthrough_if = after_flag.find('if video_passthrough_active:') + else_pos = after_flag.find('else:', passthrough_if) + convert_pos = after_flag.find('convert_if_needed(', else_pos) + self.assertGreater(convert_pos, else_pos, + "convert_if_needed should be in else branch after passthrough check") + + def test_chunking_skipped_when_passthrough(self): + """Chunking evaluates to False when video_passthrough_active.""" + # Find the chunking decision area after the flag + flag_pos = PROCESSING_MAIN.find('video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR') + after_flag = PROCESSING_MAIN[flag_pos:] + self.assertIn('if video_passthrough_active:\n should_chunk = False', after_flag) + + def test_conversion_still_runs_for_non_passthrough(self): + """convert_if_needed still runs when passthrough is off or file is audio.""" + # The else branch of the passthrough check should contain convert_if_needed + self.assertIn('conversion_result = convert_if_needed(', PROCESSING_MAIN) + + def test_chunking_still_evaluated_for_non_passthrough(self): + """Chunking is still evaluated normally when passthrough is not active.""" + self.assertIn('chunking_service.needs_chunking(actual_filepath, False, connector_specs)', + PROCESSING_MAIN) + + +# =========================================================================== +# 3. Processing pipeline — VIDEO_RETENTION paths still intact +# =========================================================================== + +class TestRetentionNotBroken(unittest.TestCase): + """Existing VIDEO_RETENTION behavior must be preserved.""" + + def test_retention_branch_still_extracts_audio(self): + """elif VIDEO_RETENTION branch still calls extract_audio_from_video.""" + video_block = PROCESSING_MAIN[PROCESSING_MAIN.find('if is_video:'):] + ret_start = video_block.find('elif VIDEO_RETENTION:') + # Find next else: at the same indent level + after_ret = video_block[ret_start:] + else_pos = after_ret.find('\n else:') + retention_block = after_ret[:else_pos] if else_pos != -1 else after_ret[:500] + self.assertIn('extract_audio_from_video(filepath, cleanup_original=False)', + retention_block) + + def test_default_branch_still_extracts_and_deletes(self): + """The final else branch extracts audio with default cleanup (deletes video).""" + video_block = PROCESSING_MAIN[PROCESSING_MAIN.find('if is_video:'):] + # The last else in the is_video block + self.assertIn('extract_audio_from_video(filepath)', video_block) + + def test_temp_audio_cleanup_still_present(self): + """Temp audio from retention is still cleaned up after transcription.""" + self.assertIn('is_video and VIDEO_RETENTION and audio_filepath', PROCESSING_MAIN) + self.assertIn('Cleaned up temp audio from video retention', PROCESSING_MAIN) + + +# =========================================================================== +# 4. Incognito path +# =========================================================================== + +class TestIncognitoPassthrough(unittest.TestCase): + """Test passthrough in the incognito transcription path.""" + + def test_passthrough_flag_set_in_incognito(self): + """video_passthrough_active is computed in incognito path.""" + self.assertIn('video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR', + PROCESSING_INCOGNITO) + + def test_passthrough_skips_extraction_in_incognito(self): + """When passthrough is on, incognito skips extract_audio_from_video.""" + # The passthrough branch logs and does NOT extract + self.assertIn('[Incognito] Video passthrough: sending original video to ASR', + PROCESSING_INCOGNITO) + + def test_passthrough_skips_conversion_in_incognito(self): + """When passthrough is on, incognito skips convert_if_needed.""" + self.assertIn('[Incognito] Video passthrough: skipping codec conversion', + PROCESSING_INCOGNITO) + + def test_passthrough_skips_chunking_in_incognito(self): + """When passthrough is on, incognito chunking is False.""" + body = PROCESSING_INCOGNITO + self.assertIn('if video_passthrough_active:\n should_chunk = False', body) + + def test_incognito_does_not_reference_video_retention(self): + """Incognito path should NOT reference VIDEO_RETENTION (no retention in incognito).""" + self.assertNotIn('VIDEO_RETENTION', PROCESSING_INCOGNITO) + + def test_incognito_still_extracts_without_passthrough(self): + """Without passthrough, incognito still extracts audio from video.""" + self.assertIn('extract_audio_from_video(filepath, cleanup_original=False)', + PROCESSING_INCOGNITO) + + def test_incognito_still_converts_without_passthrough(self): + """Without passthrough, incognito still runs convert_if_needed.""" + self.assertIn('convert_if_needed(', PROCESSING_INCOGNITO) + + +# =========================================================================== +# 5. Upload handler (recordings.py) +# =========================================================================== + +class TestUploadHandlerPassthrough(unittest.TestCase): + """Test recordings.py upload handler respects VIDEO_PASSTHROUGH_ASR.""" + + def test_skip_conversion_for_passthrough_video(self): + """Upload handler skips conversion when passthrough or retention + video.""" + self.assertIn('VIDEO_RETENTION or VIDEO_PASSTHROUGH_ASR) and has_video', RECORDINGS) + + def test_extension_fallback_checks_passthrough(self): + """Extension-based video detection also fires for VIDEO_PASSTHROUGH_ASR.""" + self.assertIn('VIDEO_RETENTION or VIDEO_PASSTHROUGH_ASR', RECORDINGS) + + def test_convert_if_needed_still_in_else(self): + """convert_if_needed still runs for audio files or when both flags are off.""" + self.assertIn('convert_if_needed(', RECORDINGS) + + def test_passthrough_log_message(self): + """Upload handler logs which mode caused the skip.""" + self.assertIn("'VIDEO_PASSTHROUGH_ASR'", RECORDINGS) + + +# =========================================================================== +# 6. File monitor +# =========================================================================== + +class TestFileMonitorPassthrough(unittest.TestCase): + """Test file_monitor.py respects VIDEO_PASSTHROUGH_ASR.""" + + def test_passthrough_defined(self): + self.assertIn('VIDEO_PASSTHROUGH_ASR', FILE_MONITOR) + + def test_skip_conversion_for_passthrough_or_retention(self): + """File monitor skips conversion when passthrough or retention + video.""" + self.assertIn('VIDEO_PASSTHROUGH_ASR or VIDEO_RETENTION) and has_video', FILE_MONITOR) + + def test_convert_if_needed_in_else_branch(self): + """convert_if_needed is in the else branch, not inside the skip block.""" + lines = FILE_MONITOR.split('\n') + in_skip_block = False + found_else = False + for i, line in enumerate(lines): + if 'VIDEO_PASSTHROUGH_ASR or VIDEO_RETENTION) and has_video' in line: + in_skip_block = True + elif in_skip_block and line.strip().startswith('else:'): + in_skip_block = False + found_else = True + elif in_skip_block and 'convert_if_needed' in line: + self.fail(f"convert_if_needed inside skip block at line {i + 1}") + self.assertTrue(found_else, "Should have else branch after passthrough/retention skip") + + def test_log_distinguishes_passthrough_from_retention(self): + """Log message indicates whether passthrough or retention caused the skip.""" + self.assertIn("'passthrough'", FILE_MONITOR) + self.assertIn("'retention'", FILE_MONITOR) + + +# =========================================================================== +# 7. Audio files unaffected by passthrough +# =========================================================================== + +class TestAudioUnaffected(unittest.TestCase): + """VIDEO_PASSTHROUGH_ASR must only affect video files, never audio.""" + + def test_passthrough_flag_gated_on_is_video(self): + """video_passthrough_active is always `is_video and VIDEO_PASSTHROUGH_ASR`.""" + # Main path + self.assertIn('video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR', + PROCESSING_MAIN) + # Incognito path + self.assertIn('video_passthrough_active = is_video and VIDEO_PASSTHROUGH_ASR', + PROCESSING_INCOGNITO) + + def test_upload_handler_gated_on_has_video(self): + """Upload handler skip is gated on `has_video`.""" + self.assertIn('and has_video', RECORDINGS) + + def test_file_monitor_gated_on_has_video(self): + """File monitor skip is gated on `has_video`.""" + self.assertIn('and has_video', FILE_MONITOR) + + +# =========================================================================== +# 8. Documentation +# =========================================================================== + +class TestDocumentation(unittest.TestCase): + """VIDEO_PASSTHROUGH_ASR is documented in all relevant places.""" + + DOC_FILES = [ + 'config/env.transcription.example', + 'docs/admin-guide/system-settings.md', + 'docs/features.md', + 'docs/getting-started/installation.md', + ] + + def test_documented_in_all_relevant_files(self): + for rel_path in self.DOC_FILES: + content = read_file(rel_path) + with self.subTest(file=rel_path): + self.assertIn('VIDEO_PASSTHROUGH_ASR', content, + f"VIDEO_PASSTHROUGH_ASR missing from {rel_path}") + + def test_env_example_commented_out_by_default(self): + """The env example has the option commented out (opt-in).""" + self.assertIn('# VIDEO_PASSTHROUGH_ASR=false', ENV_EXAMPLE) + + def test_docs_warn_about_asr_compatibility(self): + """Docs warn that standard APIs will reject video input.""" + system_settings = read_file('docs/admin-guide/system-settings.md') + installation = read_file('docs/getting-started/installation.md') + self.assertIn('reject', system_settings.lower()) + self.assertIn('reject', installation.lower()) + + +# =========================================================================== +# 9. Interaction matrix — structural verification +# =========================================================================== + +class TestInteractionMatrix(unittest.TestCase): + """ + Verify the 3-way branch structure in processing.py: + if VIDEO_PASSTHROUGH_ASR → passthrough + elif VIDEO_RETENTION → retention + else → default extraction + """ + + def test_three_way_branch_in_main_path(self): + """Main path has if/elif/else for passthrough/retention/default.""" + video_block = PROCESSING_MAIN[PROCESSING_MAIN.find('if is_video:'):] + # All three branches present in order + pt_pos = video_block.find('if VIDEO_PASSTHROUGH_ASR:') + ret_pos = video_block.find('elif VIDEO_RETENTION:') + else_pos = video_block.find('\n else:', ret_pos) + self.assertNotEqual(pt_pos, -1) + self.assertNotEqual(ret_pos, -1) + self.assertNotEqual(else_pos, -1) + self.assertLess(pt_pos, ret_pos) + self.assertLess(ret_pos, else_pos) + + def test_incognito_two_way_branch(self): + """Incognito has if/else for passthrough/extract (no retention).""" + video_block = PROCESSING_INCOGNITO[PROCESSING_INCOGNITO.find('if is_video:'):] + pt_pos = video_block.find('if VIDEO_PASSTHROUGH_ASR:') + else_pos = video_block.find('\n else:', pt_pos) + self.assertNotEqual(pt_pos, -1) + self.assertNotEqual(else_pos, -1) + # No VIDEO_RETENTION in incognito + incognito_video_block = video_block[:500] + self.assertNotIn('VIDEO_RETENTION', incognito_video_block) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_video_retention.py b/tests/test_video_retention.py new file mode 100644 index 0000000..5428d57 --- /dev/null +++ b/tests/test_video_retention.py @@ -0,0 +1,370 @@ +""" +Test suite for the VIDEO_RETENTION feature. + +Tests code paths, configuration, and template correctness for video retention. +Does NOT require a running server or real video files - uses static analysis +and mocking where possible. + +Run with: python tests/test_video_retention.py +""" + +import os +import re +import sys +import json +import unittest +from unittest.mock import patch, MagicMock +from pathlib import Path + +# Find project root +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(TEST_DIR) +sys.path.insert(0, PROJECT_ROOT) + + +class TestVideoRetentionConfig(unittest.TestCase): + """Test that VIDEO_RETENTION env var is read correctly everywhere.""" + + ALL_FILES = [ + 'src/app.py', + 'src/tasks/processing.py', + 'src/api/system.py', + 'src/api/recordings.py', + 'src/file_monitor.py', + ] + + def _read_file(self, rel_path): + with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: + return f.read() + + def test_env_var_read_in_all_entry_points(self): + """VIDEO_RETENTION env var is read in all files that need it.""" + for rel_path in self.ALL_FILES: + content = self._read_file(rel_path) + self.assertIn("VIDEO_RETENTION", content, f"VIDEO_RETENTION missing from {rel_path}") + + def test_exposed_in_api_config(self): + """VIDEO_RETENTION is exposed in the /api/config response.""" + content = self._read_file('src/api/system.py') + self.assertIn("'video_retention': VIDEO_RETENTION", content) + + def test_default_is_false(self): + """All VIDEO_RETENTION reads default to 'false'.""" + for rel_path in self.ALL_FILES: + content = self._read_file(rel_path) + match = re.search(r"VIDEO_RETENTION\s*=\s*os\.environ\.get\('VIDEO_RETENTION',\s*'(\w+)'\)", content) + if match: + self.assertEqual(match.group(1), 'false', f"Default should be 'false' in {rel_path}") + + +class TestProcessingPipelineVideoRetention(unittest.TestCase): + """Test processing.py video retention code paths via static analysis.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(PROJECT_ROOT, 'src/tasks/processing.py'), 'r') as f: + cls.content = f.read() + + def test_video_retention_true_keeps_original(self): + """When VIDEO_RETENTION=True, recording.audio_path is set to original filepath.""" + # The VIDEO_RETENTION=True branch should set recording.audio_path = filepath + self.assertIn('recording.audio_path = filepath', self.content) + + def test_video_retention_true_extracts_without_cleanup(self): + """When VIDEO_RETENTION=True, extract_audio_from_video is called with cleanup_original=False.""" + self.assertIn('extract_audio_from_video(filepath, cleanup_original=False)', self.content) + + def test_video_retention_false_extracts_with_cleanup(self): + """When VIDEO_RETENTION=False, extract_audio_from_video is called with default cleanup.""" + self.assertIn('extract_audio_from_video(filepath)', self.content) + + def test_temp_audio_cleanup_after_transcription(self): + """Temp audio from video retention is cleaned up after transcription.""" + self.assertIn('is_video and VIDEO_RETENTION and audio_filepath', self.content) + self.assertIn('Cleaned up temp audio from video retention', self.content) + + def test_audio_filepath_initialized_to_none(self): + """audio_filepath is initialized to None before the is_video check.""" + # Find the initialization line + self.assertIn('audio_filepath = None', self.content) + + def test_video_mime_type_set_for_retention(self): + """When retaining video, mime_type reflects actual video type.""" + self.assertIn("mimetypes.guess_type(filepath)[0] or 'video/mp4'", self.content) + + def test_duration_uses_recording_audio_path(self): + """Duration lookup uses recording.audio_path (always valid), not filepath.""" + self.assertIn('chunking_service.get_audio_duration(recording.audio_path)', self.content) + # Should NOT use bare filepath for duration (pre-existing bug was fixed) + self.assertNotIn('chunking_service.get_audio_duration(filepath)', self.content) + + +class TestUploadHandlerVideoRetention(unittest.TestCase): + """Test recordings.py upload handler video retention code paths.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(PROJECT_ROOT, 'src/api/recordings.py'), 'r') as f: + cls.content = f.read() + + def test_upload_handler_skips_conversion_for_video_retention(self): + """Upload handler skips convert_if_needed for videos when retention is on.""" + self.assertIn('VIDEO_RETENTION and has_video', self.content) + self.assertIn('skipping conversion', self.content) + + def test_upload_handler_has_video_from_codec_info(self): + """Upload handler reads has_video from codec_info probe.""" + self.assertIn("has_video = codec_info.get('has_video', False)", self.content) + + def test_convert_if_needed_still_in_else_branch(self): + """convert_if_needed still runs for non-video files or when retention is off.""" + self.assertIn('convert_if_needed(', self.content) + + def test_processing_pipeline_still_converts_audio(self): + """Processing pipeline runs convert_if_needed on extracted audio (the safety net).""" + proc_content = open(os.path.join(PROJECT_ROOT, 'src/tasks/processing.py')).read() + # After the video extraction block, convert_if_needed runs on actual_filepath + self.assertIn('conversion_result = convert_if_needed(\n' + ' filepath=actual_filepath,', proc_content) + + +class TestFileMonitorVideoRetention(unittest.TestCase): + """Test file_monitor.py video retention code paths.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(PROJECT_ROOT, 'src/file_monitor.py'), 'r') as f: + cls.content = f.read() + + def test_video_retention_skips_conversion(self): + """When VIDEO_RETENTION=True and has_video=True, convert_if_needed is skipped.""" + # Should have the guard: if VIDEO_RETENTION and has_video: ... skip conversion + self.assertIn('VIDEO_RETENTION and has_video', self.content) + self.assertIn('skipping conversion', self.content) + + def test_no_double_extraction(self): + """File monitor does NOT call convert_if_needed for videos when retention is on.""" + # The convert_if_needed call should be in the else branch + lines = self.content.split('\n') + in_retention_skip_block = False + found_convert_in_else = False + + for i, line in enumerate(lines): + if 'VIDEO_RETENTION and has_video' in line and 'if' in line: + in_retention_skip_block = True + elif in_retention_skip_block and 'else:' in line: + in_retention_skip_block = False + found_convert_in_else = True + elif in_retention_skip_block and 'convert_if_needed' in line: + self.fail(f"convert_if_needed called inside VIDEO_RETENTION skip block at line {i+1}") + + self.assertTrue(found_convert_in_else, "Should have else branch after video retention skip") + + def test_no_video_retention_param_in_convert_call(self): + """convert_if_needed should NOT receive a video_retention parameter.""" + # Ensure the old video_retention parameter isn't being passed + self.assertNotIn('video_retention=VIDEO_RETENTION', self.content) + + +class TestAudioConversionNotModified(unittest.TestCase): + """Verify audio_conversion.py was fully reverted (no video_retention parameter).""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(PROJECT_ROOT, 'src/utils/audio_conversion.py'), 'r') as f: + cls.content = f.read() + + def test_no_video_retention_parameter(self): + """convert_if_needed should not have a video_retention parameter.""" + self.assertNotIn('video_retention', self.content) + + def test_no_should_delete_original(self): + """No should_delete_original variable should exist.""" + self.assertNotIn('should_delete_original', self.content) + + +class TestSendFileConditional(unittest.TestCase): + """Test that send_file calls use conditional=True for range request support.""" + + def _read_file(self, rel_path): + with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: + return f.read() + + def test_recordings_streaming_has_conditional(self): + """Streaming send_file in recordings.py has conditional=True.""" + content = self._read_file('src/api/recordings.py') + # Find the non-download send_file call + self.assertIn('send_file(recording.audio_path, conditional=True)', content) + + def test_recordings_download_has_conditional(self): + """Download send_file in recordings.py has conditional=True.""" + content = self._read_file('src/api/recordings.py') + self.assertIn('as_attachment=True, download_name=filename, conditional=True', content) + + def test_shares_has_conditional(self): + """send_file in shares.py has conditional=True.""" + content = self._read_file('src/api/shares.py') + self.assertIn('send_file(recording.audio_path, conditional=True)', content) + + +class TestFrontendTemplates(unittest.TestCase): + """Test that frontend templates correctly switch between video and audio.""" + + TEMPLATE_FILES = [ + 'templates/components/detail/desktop-right-panel.html', + 'templates/components/detail/audio-player.html', + 'templates/modals/speaker-modal.html', + 'templates/share.html', + ] + + def _read_template(self, rel_path): + with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: + return f.read() + + def test_all_templates_use_dynamic_component(self): + """All player templates use for video/audio switching.""" + for tmpl in self.TEMPLATE_FILES: + content = self._read_template(tmpl) + self.assertIn("", content, f"Missing in {tmpl}") + + def test_no_bare_audio_elements_in_main_players(self): + """Main player templates should not have bare