Compare commits
30
Commits
771658040b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1877fee258 | ||
|
|
b3fb74fb1b | ||
|
|
f42d124593 | ||
|
|
2b126ee4e6 | ||
|
|
ab14f5e452 | ||
|
|
761f1a1f78 | ||
|
|
5dd0a3aeac | ||
|
|
4898d5cfde | ||
|
|
f65c816464 | ||
|
|
d60851bb53 | ||
|
|
8a66cf6f63 | ||
|
|
5f0f61995c | ||
|
|
b7c36b8b3b | ||
|
|
6bc5a20606 | ||
|
|
c17f8fb506 | ||
|
|
148ba91816 | ||
|
|
34ccf0c32b | ||
|
|
bfcfc12f58 | ||
|
|
accb721811 | ||
|
|
386b15ce94 | ||
|
|
e81a27cb8f | ||
|
|
771ee8db5a | ||
|
|
352b564f3a | ||
|
|
1dcdfc0ed0 | ||
|
|
3498861184 | ||
|
|
21b17c7657 | ||
|
|
bc6cb5efa4 | ||
|
|
eb0f019025 | ||
|
|
e0400aadef | ||
|
|
67c1941833 |
@@ -0,0 +1,34 @@
|
||||
.git
|
||||
.gitattributes
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
.ai
|
||||
node_modules
|
||||
vendor
|
||||
public/build
|
||||
public/hot
|
||||
# Exclude whole trees (not only /**) so Docker never tries to stat root-owned tmp dirs
|
||||
storage/app/private
|
||||
storage/app/public
|
||||
storage/logs
|
||||
storage/framework/cache
|
||||
storage/framework/sessions
|
||||
storage/framework/views
|
||||
database/*.sqlite*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
auth.json
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
tests
|
||||
docs
|
||||
todo.txt
|
||||
*.md
|
||||
!README.md
|
||||
+41
-9
@@ -2,7 +2,9 @@ APP_NAME=AndyTranscribe
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
APP_URL=http://localhost:8080
|
||||
# Production/CI: set to the Gitea registry image (local Compose builds andytranscribe-app:latest).
|
||||
# APP_IMAGE=gitea.z00.nu/ben/andytranscribe:latest
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -27,17 +29,17 @@ DB_CONNECTION=sqlite
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
@@ -64,11 +66,41 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# AndyTranscribe / Laravel AI
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_URL=https://api.openai.com/v1
|
||||
LOCAL_WHISPER_URL=http://localhost:8000/v1
|
||||
# Laravel Reverb (WebSockets). Browser uses VITE_*; server publish uses REVERB_HOST.
|
||||
REVERB_APP_ID=andytranscribe
|
||||
REVERB_APP_KEY=andytranscribe-key
|
||||
REVERB_APP_SECRET=andytranscribe-secret
|
||||
REVERB_HOST=localhost
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
REVERB_SERVER_HOST=0.0.0.0
|
||||
REVERB_SERVER_PORT=8080
|
||||
|
||||
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
|
||||
APP_HOST_PORT=8080
|
||||
REVERB_HOST_PORT=8081
|
||||
WHISPER_HOST_PORT=8090
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST=localhost
|
||||
# Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start
|
||||
VITE_REVERB_PORT="${REVERB_HOST_PORT}"
|
||||
VITE_REVERB_SCHEME=http
|
||||
|
||||
# AndyTranscribe / local faster-whisper
|
||||
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
||||
LOCAL_WHISPER_API_KEY=not-needed
|
||||
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
||||
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
|
||||
TRANSCRIPTION_TIMEOUT=600
|
||||
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
|
||||
DB_QUEUE_RETRY_AFTER=660
|
||||
|
||||
# Demo user created on every container start (db:seed via entrypoint)
|
||||
SEED_USER_NAME="Demo User"
|
||||
SEED_USER_EMAIL=demo@example.com
|
||||
SEED_USER_PASSWORD=password
|
||||
|
||||
# Uncomment for Docker bind mounts + Vite HMR (no rebuild for PHP/Blade/CSS/JS):
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
||||
# VITE_HOST_PORT=5173
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.z00.nu
|
||||
# Baked into the Vite client bundle for production WebSockets.
|
||||
VITE_REVERB_HOST: reverb.transcribe.z00.nu
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: https
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force --no-interaction
|
||||
php -d memory_limit=512M artisan test --compact
|
||||
|
||||
- name: Validate compose
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
|
||||
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
|
||||
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
|
||||
|
||||
build-and-push:
|
||||
if: gitea.event_name != 'pull_request'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
TAG_SHA="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
TAG_LATEST="${REGISTRY}/${REPO_LC}:latest"
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY}" -u "${{ gitea.actor }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VITE_APP_NAME=AndyTranscribe \
|
||||
--build-arg VITE_REVERB_APP_KEY=andytranscribe-key \
|
||||
--build-arg "VITE_REVERB_HOST=${VITE_REVERB_HOST}" \
|
||||
--build-arg "VITE_REVERB_PORT=${VITE_REVERB_PORT}" \
|
||||
--build-arg "VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}" \
|
||||
-t "${TAG_SHA}" \
|
||||
-t "${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${TAG_SHA}"
|
||||
docker push "${TAG_LATEST}"
|
||||
|
||||
deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy production
|
||||
env:
|
||||
DEPLOY_PATHS: ${{ secrets.DEPLOY_PATHS }}
|
||||
DEPLOY_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
chmod +x scripts/deploy-production.sh
|
||||
if [ -z "${DEPLOY_PATHS:-}" ]; then
|
||||
echo "DEPLOY_PATHS secret is not set; skipping deploy."
|
||||
echo "Built image: ${APP_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
./scripts/deploy-production.sh
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composer deps (runs in parallel with npm ci)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM composer:2 AS vendor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
RUN composer install \
|
||||
--no-dev \
|
||||
--no-scripts \
|
||||
--no-autoloader \
|
||||
--prefer-dist \
|
||||
--no-interaction
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# npm ci only (parallel with vendor when BuildKit is available)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-bookworm AS npm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vite production build (needs Flux/Livewire + Laravel pagination views)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-bookworm AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=npm /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
COPY --from=vendor /app/vendor/livewire ./vendor/livewire
|
||||
COPY --from=vendor /app/vendor/laravel/framework/src/Illuminate/Pagination \
|
||||
./vendor/laravel/framework/src/Illuminate/Pagination
|
||||
COPY vite.config.js ./
|
||||
COPY resources ./resources
|
||||
COPY public ./public
|
||||
|
||||
ARG VITE_APP_NAME=AndyTranscribe
|
||||
ARG VITE_REVERB_APP_KEY=andytranscribe-key
|
||||
ARG VITE_REVERB_HOST=localhost
|
||||
ARG VITE_REVERB_PORT=8081
|
||||
ARG VITE_REVERB_SCHEME=http
|
||||
|
||||
ENV VITE_APP_NAME=$VITE_APP_NAME \
|
||||
VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY \
|
||||
VITE_REVERB_HOST=$VITE_REVERB_HOST \
|
||||
VITE_REVERB_PORT=$VITE_REVERB_PORT \
|
||||
VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime image
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM dunglas/frankenphp:php8.5-bookworm
|
||||
|
||||
# Rarely changes — keep early for cache hits
|
||||
RUN install-php-extensions \
|
||||
pcntl \
|
||||
pdo_sqlite \
|
||||
sqlite3 \
|
||||
zip \
|
||||
bcmath \
|
||||
intl \
|
||||
opcache
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependency layer (invalidates when lockfiles / vendor change)
|
||||
COPY --from=vendor /app/vendor ./vendor
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
# Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
|
||||
COPY . .
|
||||
|
||||
# Built frontend assets
|
||||
COPY --from=assets /app/public/build ./public/build
|
||||
|
||||
# Framework/view cache paths must exist before package:discover runs during dump-autoload
|
||||
RUN mkdir -p \
|
||||
storage/app/private \
|
||||
storage/app/public \
|
||||
storage/framework/cache \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
database \
|
||||
bootstrap/cache \
|
||||
&& composer dump-autoload --optimize --no-dev \
|
||||
&& chown -R www-data:www-data storage bootstrap/cache database
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["frankenphp", "php-server", "--listen", ":80", "--root", "/app/public"]
|
||||
@@ -1,101 +1,276 @@
|
||||
# AndyTranscribe
|
||||
|
||||
Upload pocket-recorder MP3s, extract ID3 metadata, and transcribe them with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint.
|
||||
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server). Audio never leaves your machine.
|
||||
|
||||
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
|
||||
Built with Laravel 13, Blade, Livewire, Flux UI, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai).
|
||||
|
||||
## Features
|
||||
|
||||
- Upload MP3s (up to 100 MB) and store them on the local disk
|
||||
- Automatic ID3 metadata extraction (title, artist, album, duration, recorded date)
|
||||
- User accounts with login and open registration (each user only sees their own recordings)
|
||||
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB)
|
||||
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
|
||||
- Search recordings by title, artist, or transcript
|
||||
- Queued transcription with three engines:
|
||||
- **Cloud** — OpenAI Whisper (`whisper-1`)
|
||||
- **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) (e.g. Docker on this machine)
|
||||
- **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions`
|
||||
- Queued local transcription (faster-whisper in Docker)
|
||||
- Live transcription progress over WebSockets (Reverb) on the list and detail pages
|
||||
- Stop or restart a run anytime
|
||||
- Copy finished transcripts from the recording detail page
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.3+ (8.5 recommended)
|
||||
- Composer
|
||||
- Node.js & npm
|
||||
- SQLite (default) or another supported database
|
||||
- For cloud transcription: an OpenAI API key
|
||||
- For local transcription: a running faster-whisper-server
|
||||
- For remote transcription: a host with an OpenAI-compatible transcription API
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
- About 2 GB free disk for the Whisper model cache (first run downloads the model)
|
||||
|
||||
## Setup
|
||||
Optional for native PHP development: PHP 8.3+ (8.5 recommended), Composer, Node.js & npm.
|
||||
|
||||
## Install
|
||||
|
||||
These steps run the full stack with Docker: web app, queue worker, Reverb WebSockets, and Whisper.
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
composer setup
|
||||
git clone <your-repo-url> andyTranscibe
|
||||
cd andyTranscibe
|
||||
```
|
||||
|
||||
That installs PHP and JS dependencies, copies `.env` if needed, generates the app key, runs migrations, and builds frontend assets.
|
||||
|
||||
Or step by step:
|
||||
### 2. Create your environment file
|
||||
|
||||
```bash
|
||||
composer install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 3. Generate an application key
|
||||
|
||||
`APP_KEY` is required. Containers refuse to start if it is empty.
|
||||
|
||||
**Option A — PHP installed on the host**
|
||||
|
||||
```bash
|
||||
php artisan key:generate
|
||||
touch database/database.sqlite # if using SQLite
|
||||
php artisan migrate
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
**Option B — Docker only**
|
||||
|
||||
Copy values from `.env.example`. The transcription-related settings are:
|
||||
Print a key:
|
||||
|
||||
| Variable | Purpose |
|
||||
```bash
|
||||
docker run --rm php:8.5-cli php -r "echo 'base64:'.base64_encode(random_bytes(32)), PHP_EOL;"
|
||||
```
|
||||
|
||||
Open `.env` and set:
|
||||
|
||||
```env
|
||||
APP_KEY=base64:paste-the-value-here
|
||||
```
|
||||
|
||||
On Linux you can write it in one step:
|
||||
|
||||
```bash
|
||||
KEY=$(docker run --rm php:8.5-cli php -r "echo 'base64:'.base64_encode(random_bytes(32));")
|
||||
sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
|
||||
```
|
||||
|
||||
### 4. Start the stack
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Compose builds the **app** image once; `queue` and `reverb` reuse `andytranscribe-app:latest` (no triple rebuild). With the [dev overlay](#local-development-hot-reload), skip `--build` for routine PHP/Blade/JS work — the repo is bind-mounted.
|
||||
|
||||
On first start the app container will:
|
||||
|
||||
- create `database/database.sqlite` if needed
|
||||
- run migrations
|
||||
- seed a demo user (see below)
|
||||
- start FrankenPHP on port **8080**
|
||||
|
||||
Whisper may take a minute or two while the model downloads.
|
||||
|
||||
### Demo login
|
||||
|
||||
Every container start runs `db:seed`, which ensures these users exist:
|
||||
|
||||
| Email | Password |
|
||||
| --- | --- |
|
||||
| `OPENAI_API_KEY` | Required for cloud Whisper |
|
||||
| `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) |
|
||||
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://localhost:8000/v1`) |
|
||||
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
|
||||
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
|
||||
| `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
|
||||
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
|
||||
| `demo@example.com` | `password` |
|
||||
| `admin@example.com` | `password` |
|
||||
|
||||
Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`).
|
||||
Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
|
||||
|
||||
## Running locally
|
||||
### 5. Open the app
|
||||
|
||||
Start the app, queue worker, and Vite together:
|
||||
| URL | Purpose |
|
||||
| --- | --- |
|
||||
| [http://localhost:8080/recordings](http://localhost:8080/recordings) | App UI |
|
||||
| [http://localhost:8080/up](http://localhost:8080/up) | Health check |
|
||||
|
||||
Live transcription status uses WebSockets on port **8081** (Reverb). Keep that port reachable from your browser.
|
||||
|
||||
### 6. Verify services (optional)
|
||||
|
||||
```bash
|
||||
composer run dev
|
||||
docker compose ps
|
||||
docker compose logs -f app queue whisper reverb
|
||||
```
|
||||
|
||||
Or separately:
|
||||
You should see `app`, `queue`, `reverb`, and `whisper` running. Whisper becomes healthy after `/health` succeeds.
|
||||
|
||||
### Stop / restart
|
||||
|
||||
```bash
|
||||
php artisan serve
|
||||
php artisan queue:work
|
||||
npm run dev
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open [http://localhost:8000/recordings](http://localhost:8000/recordings).
|
||||
Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host.
|
||||
|
||||
Transcription jobs are queued — keep a queue worker running or jobs will stay pending.
|
||||
If `docker compose build` fails with `can't stat .../storage/app/private/livewire-tmp`, a container created that directory as root. Fix ownership (or remove it), then rebuild:
|
||||
|
||||
```bash
|
||||
sudo chown -R "$USER:$USER" storage
|
||||
# or: sudo rm -rf storage/app/private/livewire-tmp
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### Live reload while developing
|
||||
|
||||
Default Compose uses the built image, so PHP/Blade/CSS/JS changes need a rebuild. For day-to-day work, use the dev overlay (bind-mounts the repo and runs Vite HMR):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
|
||||
```
|
||||
|
||||
Or set once in `.env`:
|
||||
|
||||
```env
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
||||
```
|
||||
|
||||
Then a normal `docker compose up -d` enables:
|
||||
|
||||
- host source mounted at `/app` (PHP, Blade, routes, etc. without rebuild)
|
||||
- `vite` on port **5173** for CSS/JS hot reload and Blade refresh
|
||||
- `queue:listen` so worker code picks up changes between jobs
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
On push to `main`, Gitea Actions (host runner on z00):
|
||||
|
||||
1. Runs PHPUnit (+ compose config check)
|
||||
2. Builds and pushes `gitea.z00.nu/ben/andytranscribe:<sha>` (+ `:latest`) — Vite assets are baked in the image build
|
||||
3. Deploys by hard-resetting `~/andyTranscibe` to that SHA and pulling the image (`docker-compose.yml` + `compose.z00.yaml`)
|
||||
|
||||
Do not hot-patch production containers or the deploy checkout. Fix in git and push to `main` so CI deploys.
|
||||
|
||||
One-time server bootstrap (secrets + registry login):
|
||||
|
||||
```bash
|
||||
./scripts/setup-gitea-ci.sh
|
||||
```
|
||||
|
||||
Manual deploy of an already-built tag:
|
||||
|
||||
```bash
|
||||
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
|
||||
```
|
||||
|
||||
## Services and ports
|
||||
|
||||
| Service | Host port | Role |
|
||||
| --- | --- | --- |
|
||||
| `app` | `8080` | FrankenPHP (Laravel web UI) |
|
||||
| `reverb` | `8081` | WebSockets for live transcription status |
|
||||
| `whisper` | `8090` | faster-whisper HTTP API |
|
||||
| `queue` | — | `php artisan queue:work` for transcription jobs |
|
||||
| `vite` | `5173` | Vite HMR (dev overlay only) |
|
||||
|
||||
### Persistent data
|
||||
|
||||
| Host path | Container path | Contents |
|
||||
| --- | --- | --- |
|
||||
| `./database` | `/app/database` | SQLite database |
|
||||
| `./storage/app` | `/app/storage/app` | Uploaded audio (`private/recordings`) |
|
||||
| `./storage/logs` | `/app/storage/logs` | Application logs |
|
||||
|
||||
Whisper model cache uses the Docker volume `whisper-huggingface-cache`.
|
||||
|
||||
### Useful environment variables
|
||||
|
||||
Edit `.env` before `docker compose up` when you need different ports or models:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `APP_KEY` | Required Laravel encryption key | — |
|
||||
| `APP_IMAGE` | Pre-built image for CI/prod deploys (omit locally) | `andytranscribe-app:latest` |
|
||||
| `APP_URL` | Public app URL | `http://localhost:8080` |
|
||||
| `APP_HOST_PORT` | Host port for the web app | `8080` |
|
||||
| `REVERB_HOST_PORT` | Host port for WebSockets | `8081` |
|
||||
| `WHISPER_HOST_PORT` | Host port for Whisper | `8090` |
|
||||
| `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds). Hung Whisper calls fail the job; UI can restart. | `600` |
|
||||
| `DB_QUEUE_RETRY_AFTER` | Must exceed `TRANSCRIPTION_TIMEOUT` | `660` |
|
||||
|
||||
Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects to Reverb on `localhost:8081`.
|
||||
|
||||
If you change `REVERB_APP_KEY` or browser-facing Reverb host/port settings, rebuild so Vite embeds the new values:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### GPU Whisper (optional)
|
||||
|
||||
```bash
|
||||
docker compose --profile gpu up -d --build
|
||||
```
|
||||
|
||||
Use the GPU Whisper service instead of the CPU `whisper` service when you have an NVIDIA GPU and the NVIDIA Container Toolkit installed.
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Upload** an MP3 from Recordings → Upload (optional title override).
|
||||
2. Open the recording and choose a transcription engine.
|
||||
3. Wait for the queue job to finish, then refresh to view or copy the transcript.
|
||||
4. Search the list by title, artist, or transcript text.
|
||||
1. Open the app and **Log in** with `admin@example.com` / `password` (or `demo@example.com` / `password`), or **Register** a new account.
|
||||
2. Open **Recordings → Upload** and drop one or many audio files.
|
||||
3. Transcription starts automatically (the `queue` service must be running).
|
||||
4. Watch live progress on the list or detail page; stop or restart anytime.
|
||||
5. Search by title, artist, or transcript text.
|
||||
6. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
|
||||
|
||||
## Transcription engines
|
||||
The demo user is re-seeded on every container start. Any recordings with no owner are assigned to that demo user. Later registered users only see their own uploads.
|
||||
|
||||
| Driver | When to use | Needs |
|
||||
| --- | --- | --- |
|
||||
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
|
||||
| `local` | Confidential; audio stays on this machine | faster-whisper-server at `LOCAL_WHISPER_URL` |
|
||||
| `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` |
|
||||
Finished transcripts are stored on each recording and are included in search.
|
||||
|
||||
## Native PHP development (optional)
|
||||
|
||||
For hacking on the Laravel app outside the FrankenPHP image:
|
||||
|
||||
```bash
|
||||
composer setup
|
||||
docker compose up -d whisper reverb
|
||||
```
|
||||
|
||||
Then in separate terminals:
|
||||
|
||||
```bash
|
||||
php artisan serve --port=8000
|
||||
php artisan queue:work
|
||||
php artisan reverb:start
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Point `.env` at local services, for example:
|
||||
|
||||
```env
|
||||
APP_URL=http://localhost:8000
|
||||
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
||||
REVERB_HOST=localhost
|
||||
REVERB_PORT=8080
|
||||
VITE_REVERB_HOST=localhost
|
||||
VITE_REVERB_PORT=8080
|
||||
VITE_REVERB_SCHEME=http
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Recording;
|
||||
use App\Models\User;
|
||||
use App\Services\Mp3MetadataService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class StoreUploadedRecordings
|
||||
{
|
||||
public function __construct(private Mp3MetadataService $metadata) {}
|
||||
|
||||
/**
|
||||
* Persist uploaded audio files and queue transcription.
|
||||
*
|
||||
* @param list<UploadedFile> $files
|
||||
* @return array{
|
||||
* recordings: list<Recording>,
|
||||
* skipped_duplicates: int,
|
||||
* message: string,
|
||||
* }
|
||||
*/
|
||||
public function handle(User $user, array $files, ?string $titleOverride = null): array
|
||||
{
|
||||
$recordings = [];
|
||||
$skippedDuplicates = 0;
|
||||
$seenHashes = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof UploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = hash_file('sha256', $file->getRealPath());
|
||||
|
||||
if ($hash === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isset($seenHashes[$hash])
|
||||
|| $user->recordings()->where('content_hash', $hash)->exists()
|
||||
|| $user->recordings()
|
||||
->where('original_filename', $file->getClientOriginalName())
|
||||
->where('file_size_bytes', $file->getSize() ?: 0)
|
||||
->exists()
|
||||
) {
|
||||
$skippedDuplicates++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenHashes[$hash] = true;
|
||||
|
||||
$title = count($files) === 1 && filled($titleOverride)
|
||||
? $titleOverride
|
||||
: null;
|
||||
|
||||
$recordings[] = $this->storeUploadedRecording($user, $file, $hash, $title);
|
||||
}
|
||||
|
||||
$message = $this->message(count($recordings), $skippedDuplicates);
|
||||
|
||||
return [
|
||||
'recordings' => $recordings,
|
||||
'skipped_duplicates' => $skippedDuplicates,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
private function storeUploadedRecording(
|
||||
User $user,
|
||||
UploadedFile $file,
|
||||
string $contentHash,
|
||||
?string $titleOverride = null,
|
||||
): Recording {
|
||||
$path = $file->store('recordings', 'local');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
$tags = $this->metadata->extract($absolutePath);
|
||||
|
||||
$title = $titleOverride
|
||||
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => $title,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'duration_seconds' => $tags['duration_seconds'],
|
||||
'recorded_at' => $tags['recorded_at'],
|
||||
'artist' => $tags['artist'],
|
||||
'album' => $tags['album'],
|
||||
'file_size_bytes' => $file->getSize() ?: 0,
|
||||
'content_hash' => $contentHash,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$recording->queueLocalTranscription();
|
||||
|
||||
return $recording->fresh();
|
||||
}
|
||||
|
||||
private function message(int $savedCount, int $skippedDuplicates): string
|
||||
{
|
||||
if ($savedCount === 0 && $skippedDuplicates > 0) {
|
||||
return $skippedDuplicates === 1
|
||||
? 'That file is already uploaded — nothing new was saved.'
|
||||
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.";
|
||||
}
|
||||
|
||||
if ($savedCount === 0) {
|
||||
return 'No valid audio files were uploaded.';
|
||||
}
|
||||
|
||||
$message = $savedCount === 1
|
||||
? 'Recording uploaded — transcription queued.'
|
||||
: $savedCount.' recordings uploaded — transcription queued.';
|
||||
|
||||
if ($skippedDuplicates > 0) {
|
||||
$message .= $skippedDuplicates === 1
|
||||
? ' Skipped 1 duplicate.'
|
||||
: " Skipped {$skippedDuplicates} duplicates.";
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate the current password.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function currentPasswordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'current_password'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ProfileValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate user profiles.
|
||||
*
|
||||
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
|
||||
*/
|
||||
protected function profileRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user names.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function nameRules(): array
|
||||
{
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user emails.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function emailRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
$userId === null
|
||||
? Rule::unique(User::class)
|
||||
: Rule::unique(User::class)->ignore($userId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecordingTranscriptionUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Recording $recording) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, PrivateChannel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('recording.'.$this->recording->id),
|
||||
new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'RecordingTranscriptionUpdated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return array_merge(
|
||||
$this->recording->transcriptionStatusPayload(),
|
||||
[
|
||||
'word_count' => $this->recording->word_count,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreRecordingRequest;
|
||||
use App\Models\Recording;
|
||||
use App\Services\Mp3MetadataService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RecordingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of recordings.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Recording::query()->latest();
|
||||
|
||||
if ($search = $request->string('q')->trim()->toString()) {
|
||||
$query->where(function ($builder) use ($search) {
|
||||
$builder->where('title', 'like', "%{$search}%")
|
||||
->orWhere('artist', 'like', "%{$search}%")
|
||||
->orWhere('album', 'like', "%{$search}%")
|
||||
->orWhere('original_filename', 'like', "%{$search}%")
|
||||
->orWhere('transcript', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$recordings = $query->paginate(20)->withQueryString();
|
||||
|
||||
return view('recordings.index', compact('recordings', 'search'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the upload form.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('recordings.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly uploaded recording.
|
||||
*/
|
||||
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
|
||||
{
|
||||
$file = $request->file('audio');
|
||||
$path = $file->store('recordings', 'local');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
$tags = $metadata->extract($absolutePath);
|
||||
|
||||
$title = $request->string('title')->trim()->toString()
|
||||
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
|
||||
|
||||
$recording = Recording::create([
|
||||
'title' => $title,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'duration_seconds' => $tags['duration_seconds'],
|
||||
'recorded_at' => $tags['recorded_at'],
|
||||
'artist' => $tags['artist'],
|
||||
'album' => $tags['album'],
|
||||
'file_size_bytes' => $file->getSize() ?: 0,
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('recordings.show', $recording)
|
||||
->with('success', 'Recording uploaded successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified recording.
|
||||
*/
|
||||
public function show(Recording $recording): View
|
||||
{
|
||||
return view('recordings.show', compact('recording'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified recording.
|
||||
*/
|
||||
public function destroy(Recording $recording): RedirectResponse
|
||||
{
|
||||
$recording->deleteFile();
|
||||
$recording->delete();
|
||||
|
||||
return redirect()
|
||||
->route('recordings.index')
|
||||
->with('success', 'Recording deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class StreamRecordingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Stream the recording audio for in-browser playback.
|
||||
*/
|
||||
public function __invoke(Recording $recording): StreamedResponse
|
||||
{
|
||||
Gate::authorize('view', $recording);
|
||||
|
||||
abort_unless(
|
||||
$recording->file_path && Storage::disk('local')->exists($recording->file_path),
|
||||
404,
|
||||
);
|
||||
|
||||
return Storage::disk('local')->response(
|
||||
$recording->file_path,
|
||||
$recording->original_filename,
|
||||
[
|
||||
'Content-Type' => $recording->audioMimeType(),
|
||||
'Accept-Ranges' => 'bytes',
|
||||
],
|
||||
'inline',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\TranscribeRecordingRequest;
|
||||
use App\Jobs\TranscribeRecording;
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class TranscribeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Queue transcription for the recording with the chosen engine.
|
||||
*/
|
||||
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
|
||||
{
|
||||
if (in_array($recording->transcription_status, ['processing'], true)) {
|
||||
return back()->with('error', 'Transcription is already in progress.');
|
||||
}
|
||||
|
||||
$driver = $request->validated('driver');
|
||||
|
||||
$recording->update([
|
||||
'transcription_driver' => $driver,
|
||||
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
|
||||
'transcription_status' => 'pending',
|
||||
'transcript' => null,
|
||||
'transcribed_at' => null,
|
||||
]);
|
||||
|
||||
TranscribeRecording::dispatch($recording->fresh());
|
||||
|
||||
return back()->with('success', 'Transcription started. Refresh in a moment to see the result.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class TranscriptionStatusController extends Controller
|
||||
{
|
||||
/**
|
||||
* Live transcription progress for polling.
|
||||
*/
|
||||
public function __invoke(Recording $recording): JsonResponse
|
||||
{
|
||||
Gate::authorize('view', $recording);
|
||||
|
||||
$recording = $recording->fresh();
|
||||
|
||||
if ($recording->recoverOrphanedTranscription()) {
|
||||
$recording->refresh();
|
||||
}
|
||||
|
||||
return response()->json($recording->transcriptionStatusPayload());
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,51 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\Rules\File;
|
||||
|
||||
class StoreRecordingRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Popular audio extensions Whisper-compatible providers typically accept.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const AUDIO_EXTENSIONS = [
|
||||
'mp3',
|
||||
'mpeg',
|
||||
'mpga',
|
||||
'wav',
|
||||
'ogg',
|
||||
'oga',
|
||||
'flac',
|
||||
'm4a',
|
||||
'mp4',
|
||||
'aac',
|
||||
'webm',
|
||||
'wma',
|
||||
'aiff',
|
||||
'aif',
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum upload size accepted by validation (2 GiB).
|
||||
*/
|
||||
public const MAX_AUDIO_KILOBYTES = 2 * 1024 * 1024;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single file upload into an array for batch handling.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->hasFile('audio') && $this->file('audio') instanceof UploadedFile) {
|
||||
$this->files->set('audio', [$this->file('audio')]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,7 +56,11 @@ class StoreRecordingRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'audio' => ['required', 'file', 'mimes:mp3,mpeg', 'max:102400'],
|
||||
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'audio.*' => [
|
||||
'required',
|
||||
File::types(self::AUDIO_EXTENSIONS)->max('2gb'),
|
||||
],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
@@ -28,9 +71,12 @@ class StoreRecordingRequest extends FormRequest
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'audio.required' => 'Please choose an MP3 file to upload.',
|
||||
'audio.mimes' => 'Only MP3 files are supported.',
|
||||
'audio.max' => 'The audio file may not be larger than 100 MB.',
|
||||
'audio.required' => 'Please choose at least one audio file to upload.',
|
||||
'audio.min' => 'Please choose at least one audio file to upload.',
|
||||
'audio.max' => 'You can upload at most 50 files at once.',
|
||||
'audio.*.required' => 'Please choose an audio file to upload.',
|
||||
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
||||
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TranscribeRecordingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'driver' => ['required', Rule::in(['cloud', 'local', 'ollama'])],
|
||||
'ollama_url' => [
|
||||
Rule::requiredIf(fn () => $this->input('driver') === 'ollama'),
|
||||
'nullable',
|
||||
'url',
|
||||
'regex:/^https?:\/\//i',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'driver.required' => 'Choose a transcription engine.',
|
||||
'ollama_url.required' => 'Enter the URL of the Ollama host (OpenAI-compatible Whisper endpoint).',
|
||||
'ollama_url.url' => 'Enter a valid URL, e.g. http://192.168.1.50:8000',
|
||||
'ollama_url.regex' => 'The host URL must start with http:// or https://',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,40 @@ use App\Models\Recording;
|
||||
use App\Services\TranscriptionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\Attributes\FailOnTimeout;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
#[FailOnTimeout]
|
||||
class TranscribeRecording implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public int $timeout = 600;
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*
|
||||
* Covers a hung Whisper HTTP call: the worker is killed, failed() runs,
|
||||
* and the recording is marked failed so the UI can restart.
|
||||
*/
|
||||
public int $timeout;
|
||||
|
||||
/**
|
||||
* ISO-8601 transcription_started_at this job owns (ignored after cancel/restart).
|
||||
*/
|
||||
public ?string $runStartedAt = null;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(public Recording $recording)
|
||||
{
|
||||
//
|
||||
$this->timeout = max(60, (int) config('ai.transcription_timeout', 600));
|
||||
$this->runStartedAt = $recording->transcription_started_at?->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,30 +47,135 @@ class TranscribeRecording implements ShouldQueue
|
||||
*/
|
||||
public function handle(TranscriptionService $transcription): void
|
||||
{
|
||||
$this->recording->update([
|
||||
$recording = Recording::query()->find($this->recording->id);
|
||||
|
||||
if ($recording === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording = $recording;
|
||||
|
||||
if ($this->runStartedAt !== null && ! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->runStartedAt === null && ! $this->recording->isTranscribing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->forceFill([
|
||||
'transcription_status' => 'processing',
|
||||
]);
|
||||
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
|
||||
'transcription_error' => null,
|
||||
])->save();
|
||||
|
||||
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
|
||||
|
||||
$this->reportIfOwned('Preparing audio file…', 15);
|
||||
|
||||
try {
|
||||
$text = $transcription->transcribe($this->recording);
|
||||
$text = $transcription->transcribe(
|
||||
$this->recording,
|
||||
function (string $message, int $percent): void {
|
||||
$this->reportIfOwned($message, $percent);
|
||||
},
|
||||
);
|
||||
|
||||
$this->recording->update([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcribed_at' => now(),
|
||||
]);
|
||||
if (! $this->claimSuccessfulTranscript($text)) {
|
||||
return;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
Log::warning('Transcription exception ignored after run was superseded', [
|
||||
'recording_id' => $this->recording->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::error('Transcription failed', [
|
||||
'recording_id' => $this->recording->id,
|
||||
'driver' => $this->recording->transcription_driver,
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => $e::class,
|
||||
]);
|
||||
|
||||
$this->recording->update([
|
||||
'transcription_status' => 'failed',
|
||||
]);
|
||||
$this->recording->markTranscriptionFailed($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure (timeouts, worker kill, etc.).
|
||||
*/
|
||||
public function failed(?Throwable $e): void
|
||||
{
|
||||
$recording = Recording::query()->find($this->recording->id);
|
||||
|
||||
if ($recording === null || ! $recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recording->markTranscriptionFailed(
|
||||
$e?->getMessage() ?: 'Transcription stopped unexpectedly.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a finished transcript when this job still owns the run.
|
||||
*
|
||||
* Also recovers runs that were falsely marked failed by orphan detection
|
||||
* while Whisper was still working.
|
||||
*/
|
||||
private function claimSuccessfulTranscript(string $text): bool
|
||||
{
|
||||
$this->recording->refresh();
|
||||
|
||||
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
$this->reportIfOwned('Saving transcript…', 90);
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
$this->recording->markTranscriptionComplete($text);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same run was wrongly marked failed as "orphaned" while Whisper was still running.
|
||||
if (
|
||||
$this->recording->transcription_status === 'failed'
|
||||
&& $this->recording->matchesTranscriptionRun($this->runStartedAt)
|
||||
&& (
|
||||
str_contains((string) $this->recording->transcription_error, 'worker stopped')
|
||||
|| str_contains((string) $this->recording->transcription_error, 'timed out')
|
||||
)
|
||||
) {
|
||||
Log::warning('Recovering transcript after false orphan failure', [
|
||||
'recording_id' => $this->recording->id,
|
||||
]);
|
||||
|
||||
$this->recording->markTranscriptionComplete($text);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::info('Discarding transcript because transcription run was superseded', [
|
||||
'recording_id' => $this->recording->id,
|
||||
'status' => $this->recording->transcription_status,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function reportIfOwned(string $message, int $percent): void
|
||||
{
|
||||
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->reportProgress($message, $percent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Recording;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
|
||||
class AssignOrphanedRecordings
|
||||
{
|
||||
/**
|
||||
* Assign unowned recordings to the first registered user.
|
||||
*/
|
||||
public function handle(Registered $event): void
|
||||
{
|
||||
if (User::query()->count() !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
Recording::query()
|
||||
->whereNull('user_id')
|
||||
->update(['user_id' => $event->user->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Actions;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Features\SupportRedirects\Redirector;
|
||||
|
||||
class Logout
|
||||
{
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke(): Redirector|RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
Session::invalidate();
|
||||
Session::regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Recordings;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
#[Title('Upload recording')]
|
||||
class Create extends Component
|
||||
{
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.recordings.create');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Recordings;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
#[Title('Recordings')]
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public int $userId;
|
||||
|
||||
#[Url(as: 'q', history: true)]
|
||||
public string $search = '';
|
||||
|
||||
#[Url(as: 'sort', history: true)]
|
||||
public string $sortBy = 'uploaded';
|
||||
|
||||
#[Url(as: 'dir', history: true)]
|
||||
public string $sortDirection = 'desc';
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const SORTABLE = [
|
||||
'title' => 'title',
|
||||
'duration' => 'duration_seconds',
|
||||
'status' => 'transcription_status',
|
||||
'uploaded' => 'created_at',
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->userId = (int) Auth::id();
|
||||
$this->normalizeSort();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function sort(string $column): void
|
||||
{
|
||||
if ($column !== 'words' && ! array_key_exists($column, self::SORTABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sortBy === $column) {
|
||||
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
$this->sortBy = $column;
|
||||
$this->sortDirection = $column === 'uploaded' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render when any of this user's recordings broadcast a status change.
|
||||
*/
|
||||
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
||||
public function onTranscriptionUpdated(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function queuePending(): void
|
||||
{
|
||||
$queued = 0;
|
||||
|
||||
Auth::user()->recordings()
|
||||
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
|
||||
->orderBy('id')
|
||||
->each(function (Recording $recording) use (&$queued): void {
|
||||
if ($recording->hasActiveTranscriptionJob()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recording->queueLocalTranscription();
|
||||
$queued++;
|
||||
});
|
||||
|
||||
if ($queued === 0) {
|
||||
Flux::toast(text: 'No recordings need transcription right now.', variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Flux::toast(
|
||||
text: $queued === 1
|
||||
? 'Queued 1 recording for transcription.'
|
||||
: "Queued {$queued} recordings for transcription.",
|
||||
variant: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
public function delete(int $recordingId): void
|
||||
{
|
||||
$recording = Auth::user()->recordings()->findOrFail($recordingId);
|
||||
|
||||
Gate::authorize('delete', $recording);
|
||||
|
||||
$recording->deleteFile();
|
||||
$recording->delete();
|
||||
|
||||
Flux::toast(text: 'Recording deleted.', variant: 'success');
|
||||
}
|
||||
|
||||
public function deleteAll(): void
|
||||
{
|
||||
$deleted = 0;
|
||||
|
||||
Auth::user()->recordings()
|
||||
->orderBy('id')
|
||||
->each(function (Recording $recording) use (&$deleted): void {
|
||||
Gate::authorize('delete', $recording);
|
||||
|
||||
$recording->deleteFile();
|
||||
$recording->delete();
|
||||
$deleted++;
|
||||
});
|
||||
|
||||
$this->resetPage();
|
||||
|
||||
if ($deleted === 0) {
|
||||
Flux::toast(text: 'No recordings to delete.', variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Flux::toast(
|
||||
text: $deleted === 1
|
||||
? 'Deleted 1 recording.'
|
||||
: "Deleted {$deleted} recordings.",
|
||||
variant: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$user->recordings()
|
||||
->whereIn('transcription_status', ['pending', 'processing'])
|
||||
->orderBy('id')
|
||||
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
|
||||
|
||||
$totalCount = $user->recordings()->count();
|
||||
|
||||
$this->normalizeSort();
|
||||
|
||||
$query = $user->recordings();
|
||||
|
||||
$search = trim($this->search);
|
||||
|
||||
if ($search !== '') {
|
||||
$query->search($search);
|
||||
}
|
||||
|
||||
$this->applySort($query);
|
||||
|
||||
$recordings = $query->paginate(20);
|
||||
|
||||
$pendingCount = $user->recordings()
|
||||
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
|
||||
->get()
|
||||
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
|
||||
->count();
|
||||
|
||||
$hasActiveTranscriptions = $user->recordings()
|
||||
->whereIn('transcription_status', ['pending', 'processing'])
|
||||
->exists();
|
||||
|
||||
return view('livewire.recordings.index', [
|
||||
'recordings' => $recordings,
|
||||
'search' => $search,
|
||||
'pendingCount' => $pendingCount,
|
||||
'hasActiveTranscriptions' => $hasActiveTranscriptions,
|
||||
'totalCount' => $totalCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeSort(): void
|
||||
{
|
||||
if ($this->sortBy !== 'words' && ! array_key_exists($this->sortBy, self::SORTABLE)) {
|
||||
$this->sortBy = 'uploaded';
|
||||
}
|
||||
|
||||
if (! in_array($this->sortDirection, ['asc', 'desc'], true)) {
|
||||
$this->sortDirection = 'desc';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Recording>|HasMany<Recording, User> $query
|
||||
*/
|
||||
private function applySort(Builder|HasMany $query): void
|
||||
{
|
||||
$direction = $this->sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if ($this->sortBy === 'words') {
|
||||
$query->orderByRaw(
|
||||
'CASE WHEN transcript IS NULL OR TRIM(transcript) = ? THEN 0 ELSE LENGTH(TRIM(transcript)) - LENGTH(REPLACE(TRIM(transcript), ?, ?)) + 1 END '.$direction,
|
||||
['', ' ', ''],
|
||||
);
|
||||
} else {
|
||||
$query->orderBy(self::SORTABLE[$this->sortBy], $direction);
|
||||
}
|
||||
|
||||
$query->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Recordings;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Show extends Component
|
||||
{
|
||||
public Recording $recording;
|
||||
|
||||
public int $userId;
|
||||
|
||||
public function mount(Recording $recording): void
|
||||
{
|
||||
Gate::authorize('view', $recording);
|
||||
|
||||
$recording->recoverOrphanedTranscription();
|
||||
$recording->refresh();
|
||||
|
||||
$this->recording = $recording;
|
||||
$this->userId = (int) $recording->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render when this recording broadcasts a status change (same channel as the index).
|
||||
*
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
||||
public function onTranscriptionUpdated(array $event = []): void
|
||||
{
|
||||
if (isset($event['id']) && (int) $event['id'] !== (int) $this->recording->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
public function startTranscription(): void
|
||||
{
|
||||
Gate::authorize('transcribe', $this->recording);
|
||||
|
||||
$this->recording->queueLocalTranscription();
|
||||
$this->recording->refresh();
|
||||
|
||||
Flux::toast(text: 'Transcription started. Progress updates below.', variant: 'success');
|
||||
}
|
||||
|
||||
public function cancelTranscription(): void
|
||||
{
|
||||
Gate::authorize('transcribe', $this->recording);
|
||||
|
||||
if (! $this->recording->isTranscribing()) {
|
||||
Flux::toast(text: 'No transcription is currently running.', variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->cancelTranscription();
|
||||
$this->recording->refresh();
|
||||
|
||||
Flux::toast(text: 'Transcription stopped.', variant: 'success');
|
||||
}
|
||||
|
||||
public function delete(): mixed
|
||||
{
|
||||
Gate::authorize('delete', $this->recording);
|
||||
|
||||
$this->recording->deleteFile();
|
||||
$this->recording->delete();
|
||||
|
||||
session()->flash('success', 'Recording deleted.');
|
||||
|
||||
return $this->redirect(route('recordings.index'), navigate: true);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
if ($this->recording->isTranscribing()) {
|
||||
$this->recording->refresh();
|
||||
}
|
||||
|
||||
return view('livewire.recordings.show')
|
||||
->title($this->recording->title);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\StoreUploadedRecordings;
|
||||
use App\Http\Requests\StoreRecordingRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\File;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class UploadRecordings extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/**
|
||||
* @var list<TemporaryUploadedFile>
|
||||
*/
|
||||
public array $audio = [];
|
||||
|
||||
public bool $saving = false;
|
||||
|
||||
public bool $showCancel = true;
|
||||
|
||||
public function updatedAudio(): void
|
||||
{
|
||||
if ($this->saving || $this->audio === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function save(?StoreUploadedRecordings $store = null): mixed
|
||||
{
|
||||
if ($this->saving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->saving = true;
|
||||
|
||||
try {
|
||||
$store ??= app(StoreUploadedRecordings::class);
|
||||
|
||||
$this->validate([
|
||||
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'audio.*' => [
|
||||
'required',
|
||||
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
|
||||
],
|
||||
], [
|
||||
'audio.required' => 'Please choose at least one audio file to upload.',
|
||||
'audio.min' => 'Please choose at least one audio file to upload.',
|
||||
'audio.max' => 'You can upload at most 50 files at once.',
|
||||
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
||||
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
|
||||
]);
|
||||
|
||||
$result = $store->handle(Auth::user(), $this->audio);
|
||||
|
||||
$this->audio = [];
|
||||
|
||||
if ($result['recordings'] === []) {
|
||||
session()->flash('error', $result['message']);
|
||||
|
||||
return $this->redirect(route('recordings.create'), navigate: true);
|
||||
}
|
||||
|
||||
session()->flash('success', $result['message']);
|
||||
|
||||
if (count($result['recordings']) === 1) {
|
||||
return $this->redirect(
|
||||
route('recordings.show', $result['recordings'][0]),
|
||||
navigate: true,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->redirect(route('recordings.index'), navigate: true);
|
||||
} finally {
|
||||
$this->saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.upload-recordings');
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\RecordingTranscriptionUpdated;
|
||||
use App\Jobs\TranscribeRecording;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class Recording extends Model
|
||||
{
|
||||
@@ -12,6 +22,7 @@ class Recording extends Model
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'original_filename',
|
||||
'file_path',
|
||||
@@ -20,11 +31,17 @@ class Recording extends Model
|
||||
'artist',
|
||||
'album',
|
||||
'file_size_bytes',
|
||||
'content_hash',
|
||||
'transcript',
|
||||
'transcription_status',
|
||||
'transcription_progress',
|
||||
'transcription_percent',
|
||||
'transcription_started_at',
|
||||
'transcription_error',
|
||||
'transcription_driver',
|
||||
'ollama_url',
|
||||
'transcribed_at',
|
||||
'transcription_duration_seconds',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -35,11 +52,23 @@ class Recording extends Model
|
||||
return [
|
||||
'recorded_at' => 'datetime',
|
||||
'transcribed_at' => 'datetime',
|
||||
'transcription_started_at' => 'datetime',
|
||||
'duration_seconds' => 'integer',
|
||||
'transcription_duration_seconds' => 'integer',
|
||||
'file_size_bytes' => 'integer',
|
||||
'transcription_percent' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable duration (m:ss).
|
||||
*/
|
||||
@@ -57,6 +86,430 @@ class Recording extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Word count of the stored transcript (0 when empty).
|
||||
*/
|
||||
protected function wordCount(): Attribute
|
||||
{
|
||||
return Attribute::get(function (): int {
|
||||
if (! filled($this->transcript)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly label for the selected transcription engine.
|
||||
*/
|
||||
protected function transcriptionDriverLabel(): Attribute
|
||||
{
|
||||
return Attribute::get(function (): ?string {
|
||||
return match ($this->transcription_driver) {
|
||||
'local' => 'Local (faster-whisper)',
|
||||
default => $this->transcription_driver ?: 'Local (faster-whisper)',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether transcription is actively running or queued.
|
||||
*/
|
||||
public function isTranscribing(): bool
|
||||
{
|
||||
return in_array($this->transcription_status, ['pending', 'processing'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a new local faster-whisper transcription run.
|
||||
*/
|
||||
public function queueLocalTranscription(): void
|
||||
{
|
||||
// Only stop a real in-flight/queued run — bare "pending" uploads have no job yet.
|
||||
if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) {
|
||||
$this->cancelTranscription(silent: true);
|
||||
$this->refresh();
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'transcription_driver' => 'local',
|
||||
'ollama_url' => null,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_progress' => 'Queued — waiting to start…',
|
||||
'transcription_percent' => null,
|
||||
'transcription_started_at' => now(),
|
||||
'transcription_error' => null,
|
||||
'transcription_duration_seconds' => null,
|
||||
// Keep the previous transcript until a new run succeeds.
|
||||
'transcribed_at' => $this->transcribed_at,
|
||||
]);
|
||||
|
||||
$recording = $this->fresh();
|
||||
RecordingTranscriptionUpdated::dispatch($recording);
|
||||
TranscribeRecording::dispatch($recording);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable transcription status for badges.
|
||||
*/
|
||||
public function transcriptionStatusLabel(): string
|
||||
{
|
||||
return match ($this->transcription_status) {
|
||||
'pending' => 'Queued',
|
||||
'processing' => 'Transcribing',
|
||||
'done' => 'Done',
|
||||
'failed' => 'Failed',
|
||||
'cancelled' => 'Cancelled',
|
||||
default => (string) $this->transcription_status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search title, metadata, and stored transcript text.
|
||||
*/
|
||||
#[Scope]
|
||||
protected function search(Builder $query, string $term): void
|
||||
{
|
||||
$like = '%'.$term.'%';
|
||||
|
||||
$query->where(function (Builder $builder) use ($like): void {
|
||||
$builder->where('title', 'like', $like)
|
||||
->orWhere('artist', 'like', $like)
|
||||
->orWhere('album', 'like', $like)
|
||||
->orWhere('original_filename', 'like', $like)
|
||||
->orWhere('transcript', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* First line of the transcript, truncated for compact list rows.
|
||||
*/
|
||||
public function transcriptFirstLine(int $limit = 120): ?string
|
||||
{
|
||||
if (! filled($this->transcript)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstLine = Str::of($this->transcript)
|
||||
->before("\n")
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->trim()
|
||||
->toString();
|
||||
|
||||
if ($firstLine === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::limit($firstLine, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short transcript excerpt, optionally centered on a search hit.
|
||||
*/
|
||||
public function transcriptSnippet(?string $term = null, int $radius = 80): ?string
|
||||
{
|
||||
if (! filled($this->transcript)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$transcript = preg_replace('/\s+/', ' ', $this->transcript) ?? $this->transcript;
|
||||
|
||||
if ($term === null || $term === '') {
|
||||
return Str::limit($transcript, $radius * 2);
|
||||
}
|
||||
|
||||
$position = mb_stripos($transcript, $term);
|
||||
|
||||
if ($position === false) {
|
||||
return Str::limit($transcript, $radius * 2);
|
||||
}
|
||||
|
||||
$start = max(0, $position - $radius);
|
||||
$excerpt = mb_substr($transcript, $start, ($radius * 2) + mb_strlen($term));
|
||||
|
||||
return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds after which a reserved queue row is considered abandoned
|
||||
* (worker died mid-Whisper without releasing the job).
|
||||
*/
|
||||
public function transcriptionJobStaleAfterSeconds(): int
|
||||
{
|
||||
return max(120, (int) config('ai.transcription_timeout', 600) + 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a TranscribeRecording job for this recording is still on the queue.
|
||||
*
|
||||
* Reserved jobs older than the transcription timeout (+ grace) are ignored so
|
||||
* orphan recovery can unblock the UI when Whisper/the worker is wedged.
|
||||
*/
|
||||
public function hasActiveTranscriptionJob(): bool
|
||||
{
|
||||
$staleBefore = now()->timestamp - $this->transcriptionJobStaleAfterSeconds();
|
||||
|
||||
return DB::table('jobs')
|
||||
->orderBy('id')
|
||||
->get(['id', 'payload', 'reserved_at'])
|
||||
->contains(function (object $job) use ($staleBefore): bool {
|
||||
$payload = (string) $job->payload;
|
||||
|
||||
if (! str_contains($payload, 'TranscribeRecording')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->jobPayloadBelongsToRecording($payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback payload match when unserialize is unavailable.
|
||||
*/
|
||||
private function payloadMentionsRecording(string $payload): bool
|
||||
{
|
||||
// Jobs table stores JSON; the serialized command inside escapes quotes as \".
|
||||
return (bool) preg_match('/id\\\\";i:'.$this->id.';/', $payload)
|
||||
|| (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|
||||
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|
||||
|| str_contains($payload, 'id\\\\";s:'.strlen((string) $this->id).':\\"'.$this->id.'\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing/pending with no worker job left (crashed worker, bad retry_after, etc.).
|
||||
*/
|
||||
public function isOrphanedTranscription(): bool
|
||||
{
|
||||
if (! $this->isTranscribing()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasActiveTranscriptionJob()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reference = $this->transcription_started_at ?? $this->updated_at;
|
||||
|
||||
if ($reference === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only treat as orphaned after the job could not possibly still be running.
|
||||
// (A short grace caused false failures while Whisper was still working.)
|
||||
$orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60);
|
||||
|
||||
return $reference->lte(now()->subSeconds($orphanAfterSeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a payload/job belongs to this recording's transcription run start time.
|
||||
*/
|
||||
public function matchesTranscriptionRun(?string $runStartedAt): bool
|
||||
{
|
||||
if ($runStartedAt === null || $this->transcription_started_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this recording still expects results for the given run.
|
||||
*/
|
||||
public function ownsTranscriptionRun(?string $runStartedAt): bool
|
||||
{
|
||||
$this->refresh();
|
||||
|
||||
if (! $this->isTranscribing()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->matchesTranscriptionRun($runStartedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove queued TranscribeRecording jobs for this recording.
|
||||
*/
|
||||
public function discardQueuedTranscriptionJobs(): int
|
||||
{
|
||||
$deleted = 0;
|
||||
|
||||
DB::table('jobs')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (object $job) use (&$deleted): void {
|
||||
$payload = (string) $job->payload;
|
||||
|
||||
if (! str_contains($payload, 'TranscribeRecording')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->jobPayloadBelongsToRecording($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('jobs')->where('id', $job->id)->delete();
|
||||
$deleted++;
|
||||
});
|
||||
|
||||
$this->releaseTranscriptionUniqueLock();
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a jobs.payload row targets this recording.
|
||||
*/
|
||||
private function jobPayloadBelongsToRecording(string $payload): bool
|
||||
{
|
||||
$data = json_decode($payload, true);
|
||||
$command = $data['data']['command'] ?? null;
|
||||
|
||||
if (is_string($command)) {
|
||||
if (
|
||||
preg_match('/id";i:'.$this->id.';/', $command)
|
||||
|| str_contains($command, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$queued = unserialize($command);
|
||||
|
||||
if ($queued instanceof TranscribeRecording) {
|
||||
return (int) $queued->recording->getKey() === (int) $this->id;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Fall through to escaped JSON heuristics.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payloadMentionsRecording($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transcription: drop queued jobs and mark the run cancelled.
|
||||
*
|
||||
* @param bool $silent When true, skip status update (used before starting a replacement run).
|
||||
*/
|
||||
public function cancelTranscription(bool $silent = false): void
|
||||
{
|
||||
$this->discardQueuedTranscriptionJobs();
|
||||
|
||||
if ($silent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'transcription_status' => 'cancelled',
|
||||
'transcription_progress' => 'Stopped by user',
|
||||
'transcription_percent' => $this->transcription_percent ?: 0,
|
||||
'transcription_error' => 'Stopped by user',
|
||||
])->save();
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a leftover ShouldBeUnique lock from earlier job versions.
|
||||
*/
|
||||
public function releaseTranscriptionUniqueLock(): void
|
||||
{
|
||||
Cache::lock(
|
||||
'laravel_unique_job:'.TranscribeRecording::class.'transcribe-recording:'.$this->id
|
||||
)->forceRelease();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a successful transcript and how long the run took.
|
||||
*/
|
||||
public function markTranscriptionComplete(string $text): void
|
||||
{
|
||||
$finishedAt = now();
|
||||
$startedAt = $this->transcription_started_at;
|
||||
$durationSeconds = $startedAt === null
|
||||
? null
|
||||
: max(0, $finishedAt->getTimestamp() - $startedAt->getTimestamp());
|
||||
|
||||
$this->forceFill([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcription_progress' => 'Transcription complete',
|
||||
'transcription_percent' => 100,
|
||||
'transcription_error' => null,
|
||||
'transcribed_at' => $finishedAt,
|
||||
'transcription_duration_seconds' => $durationSeconds,
|
||||
])->save();
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transcription as failed and unblock the UI.
|
||||
*/
|
||||
public function markTranscriptionFailed(string $message): void
|
||||
{
|
||||
if (! $this->isTranscribing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'transcription_status' => 'failed',
|
||||
'transcription_progress' => 'Transcription failed',
|
||||
'transcription_percent' => $this->transcription_percent ?: 0,
|
||||
'transcription_error' => $message,
|
||||
])->save();
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a stuck transcription if the queue job is gone or a reservation is stale.
|
||||
*/
|
||||
public function recoverOrphanedTranscription(): bool
|
||||
{
|
||||
if (! $this->isOrphanedTranscription()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop abandoned reserved rows so a restart can enqueue cleanly.
|
||||
$this->discardQueuedTranscriptionJobs();
|
||||
|
||||
$this->markTranscriptionFailed(
|
||||
'Transcription timed out or the worker stopped before finishing. Start transcription again.',
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the live progress fields shown in the UI.
|
||||
*/
|
||||
public function reportProgress(string $message, int $percent, string $status = 'processing'): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'transcription_status' => $status,
|
||||
'transcription_progress' => $message,
|
||||
'transcription_percent' => max(0, min(100, $percent)),
|
||||
'transcription_error' => null,
|
||||
])->save();
|
||||
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the current transcription status to connected browsers.
|
||||
*/
|
||||
public function broadcastTranscriptionUpdated(): void
|
||||
{
|
||||
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute filesystem path for the stored audio file.
|
||||
*/
|
||||
@@ -65,6 +518,26 @@ class Recording extends Model
|
||||
return Storage::disk('local')->path($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME type for browser audio playback based on the original filename.
|
||||
*/
|
||||
public function audioMimeType(): string
|
||||
{
|
||||
$extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION));
|
||||
|
||||
return match ($extension) {
|
||||
'mp3', 'mpga', 'mpeg' => 'audio/mpeg',
|
||||
'wav' => 'audio/wav',
|
||||
'ogg', 'oga' => 'audio/ogg',
|
||||
'flac' => 'audio/flac',
|
||||
'm4a', 'mp4', 'aac' => 'audio/mp4',
|
||||
'webm' => 'audio/webm',
|
||||
'wma' => 'audio/x-ms-wma',
|
||||
'aiff', 'aif' => 'audio/aiff',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the audio file from storage.
|
||||
*/
|
||||
@@ -74,4 +547,50 @@ class Recording extends Model
|
||||
Storage::disk('local')->delete($this->file_path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the live status endpoint / Alpine poller.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function transcriptionStatusPayload(): array
|
||||
{
|
||||
$startedAt = $this->transcription_started_at;
|
||||
$elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'status' => $this->transcription_status,
|
||||
'status_label' => $this->transcriptionStatusLabel(),
|
||||
'progress' => $this->transcription_progress,
|
||||
'percent' => $this->transcription_percent,
|
||||
'driver' => $this->transcription_driver,
|
||||
'driver_label' => $this->transcription_driver_label,
|
||||
'error' => $this->transcription_error,
|
||||
'started_at' => $startedAt?->toIso8601String(),
|
||||
'elapsed_seconds' => $elapsed,
|
||||
'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed),
|
||||
'duration_seconds' => $this->duration_seconds,
|
||||
'transcription_duration_seconds' => $this->transcription_duration_seconds,
|
||||
'transcription_duration_human' => $this->transcription_duration_seconds === null
|
||||
? null
|
||||
: $this->formatElapsed($this->transcription_duration_seconds),
|
||||
'is_active' => $this->isTranscribing(),
|
||||
'has_transcript' => filled($this->transcript),
|
||||
'transcript' => $this->transcript,
|
||||
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatElapsed(int $seconds): string
|
||||
{
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$remain = $seconds % 60;
|
||||
|
||||
if ($minutes === 0) {
|
||||
return sprintf('%ds', $remain);
|
||||
}
|
||||
|
||||
return sprintf('%dm %02ds', $minutes, $remain);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -2,13 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
@@ -29,4 +30,24 @@ class User extends Authenticatable
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Recording, $this>
|
||||
*/
|
||||
public function recordings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Recording::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initials for Flux avatar components.
|
||||
*/
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)
|
||||
->explode(' ')
|
||||
->take(2)
|
||||
->map(fn (string $part) => Str::substr($part, 0, 1))
|
||||
->implode('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Recording;
|
||||
use App\Models\User;
|
||||
|
||||
class RecordingPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Recording $recording): bool
|
||||
{
|
||||
return $recording->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Recording $recording): bool
|
||||
{
|
||||
return $recording->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Recording $recording): bool
|
||||
{
|
||||
return $recording->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can start or stop transcription.
|
||||
*/
|
||||
public function transcribe(User $user, Recording $recording): bool
|
||||
{
|
||||
return $recording->user_id === $user->id;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\AssignOrphanedRecordings;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -19,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Password::defaults(fn () => Password::min(8));
|
||||
|
||||
Event::listen(Registered::class, AssignOrphanedRecordings::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureActions();
|
||||
$this->configureViews();
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify actions.
|
||||
*/
|
||||
private function configureActions(): void
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify views.
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn () => view('pages::auth.login'));
|
||||
Fortify::registerView(fn () => view('pages::auth.register'));
|
||||
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DiskSpaceService
|
||||
{
|
||||
/**
|
||||
* Snapshot of free/used space for the recordings storage volume.
|
||||
*
|
||||
* @return array{
|
||||
* total_bytes: int,
|
||||
* free_bytes: int,
|
||||
* used_bytes: int,
|
||||
* used_percent: float,
|
||||
* free_percent: float,
|
||||
* total_human: string,
|
||||
* free_human: string,
|
||||
* used_human: string
|
||||
* }|null
|
||||
*/
|
||||
public function snapshot(?string $path = null): ?array
|
||||
{
|
||||
$path ??= Storage::disk('local')->path('');
|
||||
|
||||
if (! is_dir($path)) {
|
||||
@mkdir($path, 0755, true);
|
||||
}
|
||||
|
||||
/** @var array{total_bytes: int, free_bytes: int, used_bytes: int, used_percent: float, free_percent: float, total_human: string, free_human: string, used_human: string}|null */
|
||||
return Cache::remember(
|
||||
'disk-space:'.md5($path),
|
||||
now()->addSeconds(30),
|
||||
fn () => $this->measure($path),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total_bytes: int,
|
||||
* free_bytes: int,
|
||||
* used_bytes: int,
|
||||
* used_percent: float,
|
||||
* free_percent: float,
|
||||
* total_human: string,
|
||||
* free_human: string,
|
||||
* used_human: string
|
||||
* }|null
|
||||
*/
|
||||
private function measure(string $path): ?array
|
||||
{
|
||||
$total = @disk_total_space($path);
|
||||
$free = @disk_free_space($path);
|
||||
|
||||
if ($total === false || $free === false || $total <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totalBytes = (int) $total;
|
||||
$freeBytes = (int) max(0, $free);
|
||||
$usedBytes = (int) max(0, $totalBytes - $freeBytes);
|
||||
$usedPercent = round(($usedBytes / $totalBytes) * 100, 1);
|
||||
$freePercent = round(($freeBytes / $totalBytes) * 100, 1);
|
||||
|
||||
return [
|
||||
'total_bytes' => $totalBytes,
|
||||
'free_bytes' => $freeBytes,
|
||||
'used_bytes' => $usedBytes,
|
||||
'used_percent' => $usedPercent,
|
||||
'free_percent' => $freePercent,
|
||||
'total_human' => $this->formatBytes($totalBytes),
|
||||
'free_human' => $this->formatBytes($freeBytes),
|
||||
'used_human' => $this->formatBytes($usedBytes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable byte size without requiring the intl extension.
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
$value = (float) max(0, $bytes);
|
||||
$unit = 0;
|
||||
|
||||
while ($value >= 1024 && $unit < count($units) - 1) {
|
||||
$value /= 1024;
|
||||
$unit++;
|
||||
}
|
||||
|
||||
$precision = $unit === 0 ? 0 : 1;
|
||||
|
||||
return number_format($value, $precision).' '.$units[$unit];
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use getID3;
|
||||
class Mp3MetadataService
|
||||
{
|
||||
/**
|
||||
* Extract ID3 and audio metadata from an MP3 file on disk.
|
||||
* Extract tags and duration from a common audio file (MP3, WAV, OGG, FLAC, M4A, etc.).
|
||||
*
|
||||
* @return array{
|
||||
* title: ?string,
|
||||
@@ -23,17 +23,15 @@ class Mp3MetadataService
|
||||
$analyzer = new getID3;
|
||||
$info = $analyzer->analyze($absolutePath);
|
||||
|
||||
$tags = [];
|
||||
if (isset($info['tags']['id3v2'])) {
|
||||
$tags = $info['tags']['id3v2'];
|
||||
} elseif (isset($info['tags']['id3v1'])) {
|
||||
$tags = $info['tags']['id3v1'];
|
||||
}
|
||||
$tags = $this->preferredTags($info);
|
||||
|
||||
$title = $this->firstTag($tags, 'title');
|
||||
$artist = $this->firstTag($tags, 'artist');
|
||||
$album = $this->firstTag($tags, 'album');
|
||||
$year = $this->firstTag($tags, 'year') ?? $this->firstTag($tags, 'recording_time');
|
||||
$year = $this->firstTag($tags, 'year')
|
||||
?? $this->firstTag($tags, 'date')
|
||||
?? $this->firstTag($tags, 'recording_time')
|
||||
?? $this->firstTag($tags, 'creation_date');
|
||||
|
||||
$duration = isset($info['playtime_seconds'])
|
||||
? (int) round((float) $info['playtime_seconds'])
|
||||
@@ -57,6 +55,38 @@ class Mp3MetadataService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the richest tag set getID3 found for this format.
|
||||
*
|
||||
* @param array<string, mixed> $info
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function preferredTags(array $info): array
|
||||
{
|
||||
$priority = [
|
||||
'id3v2',
|
||||
'id3v1',
|
||||
'vorbiscomment',
|
||||
'quicktime',
|
||||
'riff',
|
||||
'asf',
|
||||
'ape',
|
||||
'matroska',
|
||||
];
|
||||
|
||||
foreach ($priority as $format) {
|
||||
if (! empty($info['tags'][$format]) && is_array($info['tags'][$format])) {
|
||||
return $info['tags'][$format];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($info['comments']) && is_array($info['comments'])) {
|
||||
return $info['comments'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $tags
|
||||
*/
|
||||
|
||||
@@ -3,100 +3,30 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Closure;
|
||||
use Laravel\Ai\Transcription;
|
||||
use RuntimeException;
|
||||
|
||||
class TranscriptionService
|
||||
{
|
||||
/**
|
||||
* Run transcription for a recording using the selected driver.
|
||||
* Transcribe a recording with the local faster-whisper server.
|
||||
*
|
||||
* @param (Closure(string, int): void)|null $onProgress
|
||||
*/
|
||||
public function transcribe(Recording $recording): string
|
||||
{
|
||||
return match ($recording->transcription_driver) {
|
||||
'cloud' => $this->viaCloud($recording),
|
||||
'local' => $this->viaLocal($recording),
|
||||
'ollama' => $this->viaRemoteCompatible($recording),
|
||||
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
|
||||
};
|
||||
}
|
||||
|
||||
private function viaCloud(Recording $recording): string
|
||||
{
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('openai', 'whisper-1');
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
private function viaLocal(Recording $recording): string
|
||||
public function transcribe(Recording $recording, ?Closure $onProgress = null): string
|
||||
{
|
||||
$report = $onProgress ?? static fn (string $message, int $percent) => null;
|
||||
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||
|
||||
$report('Connecting to local faster-whisper server…', 30);
|
||||
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
|
||||
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('local-whisper', $model);
|
||||
|
||||
$report('Received transcript from local Whisper…', 85);
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
|
||||
*/
|
||||
private function viaRemoteCompatible(Recording $recording): string
|
||||
{
|
||||
if (! filled($recording->ollama_url)) {
|
||||
throw new RuntimeException('Ollama host URL is required for remote transcription.');
|
||||
}
|
||||
|
||||
$base = $this->normalizeBaseUrl($recording->ollama_url);
|
||||
$model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base'));
|
||||
$path = $recording->absolutePath();
|
||||
|
||||
if (! is_readable($path)) {
|
||||
throw new RuntimeException('Recording audio file is not readable.');
|
||||
}
|
||||
|
||||
$response = Http::timeout((int) config('ai.transcription_timeout', 600))
|
||||
->attach(
|
||||
'file',
|
||||
fopen($path, 'r'),
|
||||
$recording->original_filename ?: basename($path),
|
||||
)
|
||||
->post($base.'/audio/transcriptions', [
|
||||
'model' => $model,
|
||||
'response_format' => 'json',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(
|
||||
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
|
||||
);
|
||||
}
|
||||
|
||||
$text = $response->json('text');
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user URL to an OpenAI-style base ending in /v1.
|
||||
*/
|
||||
private function normalizeBaseUrl(string $url): string
|
||||
{
|
||||
$url = rtrim(trim($url), '/');
|
||||
|
||||
if (Str::endsWith($url, '/v1')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.'/v1';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use App\Services\DiskSpaceService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class DiskSpaceBar extends Component
|
||||
{
|
||||
/**
|
||||
* @var array{
|
||||
* total_bytes: int,
|
||||
* free_bytes: int,
|
||||
* used_bytes: int,
|
||||
* used_percent: float,
|
||||
* free_percent: float,
|
||||
* total_human: string,
|
||||
* free_human: string,
|
||||
* used_human: string
|
||||
* }|null
|
||||
*/
|
||||
public readonly ?array $disk;
|
||||
|
||||
public function __construct(DiskSpaceService $diskSpace)
|
||||
{
|
||||
$this->disk = $diskSpace->snapshot();
|
||||
}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
return $this->disk !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.disk-space-bar');
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -9,10 +9,22 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
// CPM/Caddy terminates TLS; trust forwarded proto/host so asset() URLs stay https.
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
headers: Request::HEADER_X_FORWARDED_FOR
|
||||
| Request::HEADER_X_FORWARDED_HOST
|
||||
| Request::HEADER_X_FORWARDED_PORT
|
||||
| Request::HEADER_X_FORWARDED_PROTO
|
||||
| Request::HEADER_X_FORWARDED_PREFIX,
|
||||
);
|
||||
|
||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||
$middleware->redirectUsersTo(fn () => route('recordings.index'));
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# z00 production overlay for AndyTranscribe (behind Caddy Proxy Manager)
|
||||
services:
|
||||
app:
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: https://transcribe.z00.nu
|
||||
TRUSTED_PROXIES: "*"
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: "8080"
|
||||
REVERB_SCHEME: http
|
||||
reverb:
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
environment:
|
||||
APP_URL: https://transcribe.z00.nu
|
||||
REVERB_HOST: reverb.transcribe.z00.nu
|
||||
REVERB_PORT: "443"
|
||||
REVERB_SCHEME: https
|
||||
queue:
|
||||
networks:
|
||||
- default
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: https://transcribe.z00.nu
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: "8080"
|
||||
REVERB_SCHEME: http
|
||||
whisper:
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
name: caddy-proxy-manager-test_caddy-test-network
|
||||
+6
-1
@@ -9,8 +9,13 @@
|
||||
"php": "^8.3",
|
||||
"james-heinrich/getid3": "^1.9",
|
||||
"laravel/ai": "^0.10.3",
|
||||
"laravel/fortify": "^1.38",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/reverb": "^1.11",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/flux": "^2.16",
|
||||
"livewire/livewire": "^4.4",
|
||||
"symfony/polyfill-iconv": "^1.37"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
Generated
+2515
-1
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -28,7 +28,6 @@ return [
|
||||
|
||||
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
|
||||
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
|
||||
'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -149,7 +148,7 @@ return [
|
||||
'local-whisper' => [
|
||||
'driver' => 'openai',
|
||||
'key' => env('LOCAL_WHISPER_API_KEY', 'not-needed'),
|
||||
'url' => env('LOCAL_WHISPER_URL', 'http://localhost:8000/v1'),
|
||||
'url' => env('LOCAL_WHISPER_URL', 'http://127.0.0.1:8090/v1'),
|
||||
'store' => false,
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
+4
-3
@@ -38,9 +38,10 @@ return [
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
// Wait longer under concurrent writers (queue + web on one SQLite file).
|
||||
'busy_timeout' => (int) env('DB_BUSY_TIMEOUT', 30000),
|
||||
'journal_mode' => 'WAL',
|
||||
'synchronous' => 'NORMAL',
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/recordings',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passkeys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These settings configure Fortify's passkey (WebAuthn) support. Passkeys
|
||||
| allow users to sign in without needing to remember credentials since
|
||||
| they use public-key cryptography - making them immune to breaches.
|
||||
|
|
||||
*/
|
||||
|
||||
'passkeys' => [
|
||||
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||
'allowed_origins' => [config('app.url')],
|
||||
'timeout' => 60000,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Component Locations
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets the root directories that'll be used to resolve view-based
|
||||
| components like single and multi-file components. The make command will
|
||||
| use the first directory in this array to add new component files to.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_locations' => [
|
||||
resource_path('views/components'),
|
||||
resource_path('views/livewire'),
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Component Namespaces
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets default namespaces that will be used to resolve view-based
|
||||
| components like single-file and multi-file components. These folders'll
|
||||
| also be referenced when creating new components via the make command.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_namespaces' => [
|
||||
'layouts' => resource_path('views/layouts'),
|
||||
'pages' => resource_path('views/pages'),
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Page Layout
|
||||
|---------------------------------------------------------------------------
|
||||
| The view that will be used as the layout when rendering a single component as
|
||||
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
|
||||
| In this case, the content of pages::create-post will render into $slot.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_layout' => 'layouts.app',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Lazy Loading Placeholder
|
||||
|---------------------------------------------------------------------------
|
||||
| Livewire allows you to lazy load components that would otherwise slow down
|
||||
| the initial page load. Every component can have a custom placeholder or
|
||||
| you can define the default placeholder view for all components below.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_placeholder' => null, // Example: 'placeholders::skeleton'
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Make Command
|
||||
|---------------------------------------------------------------------------
|
||||
| This value determines the default configuration for the artisan make command
|
||||
| You can configure the component type (sfc, mfc, class) and whether to use
|
||||
| the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names.
|
||||
|
|
||||
*/
|
||||
|
||||
'make_command' => [
|
||||
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
|
||||
'emoji' => true, // Options: true, false
|
||||
'with' => [
|
||||
'js' => false,
|
||||
'css' => false,
|
||||
'test' => false,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Class Namespace
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets the root class namespace for Livewire component classes in
|
||||
| your application. This value will change where component auto-discovery
|
||||
| finds components. It's also referenced by the file creation commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'class_namespace' => 'App\\Livewire',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Class Path
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value is used to specify the path where Livewire component class files
|
||||
| are created when running creation commands like `artisan make:livewire`.
|
||||
| This path is customizable to match your projects directory structure.
|
||||
|
|
||||
*/
|
||||
|
||||
'class_path' => app_path('Livewire'),
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| View Path
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value is used to specify where Livewire component Blade templates are
|
||||
| stored when running file creation commands like `artisan make:livewire`.
|
||||
| It is also used if you choose to omit a component's render() method.
|
||||
|
|
||||
*/
|
||||
|
||||
'view_path' => resource_path('views/livewire'),
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Temporary File Uploads
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire handles file uploads by storing uploads in a temporary directory
|
||||
| before the file is stored permanently. All file uploads are directed to
|
||||
| a global endpoint for temporary storage. You may configure this below:
|
||||
|
|
||||
*/
|
||||
|
||||
'temporary_file_upload' => [
|
||||
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
|
||||
// Pocket-recorder audio can be large; max is kilobytes (2 GiB).
|
||||
'rules' => ['required', 'file', 'max:2097152'],
|
||||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
|
||||
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||
'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif',
|
||||
],
|
||||
'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
|
||||
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Render On Redirect
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines if Livewire will run a component's `render()` method
|
||||
| after a redirect has been triggered using something like `redirect(...)`
|
||||
| Setting this to true will render the view once more before redirecting
|
||||
|
|
||||
*/
|
||||
|
||||
'render_on_redirect' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Eloquent Model Binding
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Previous versions of Livewire supported binding directly to eloquent model
|
||||
| properties using wire:model by default. However, this behavior has been
|
||||
| deemed too "magical" and has therefore been put under a feature flag.
|
||||
|
|
||||
*/
|
||||
|
||||
'legacy_model_binding' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Auto-inject Frontend Assets
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Livewire automatically injects its JavaScript and CSS into the
|
||||
| <head> and <body> of pages containing Livewire components. By disabling
|
||||
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_assets' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Navigate (SPA mode)
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| By adding `wire:navigate` to links in your Livewire application, Livewire
|
||||
| will prevent the default link handling and instead request those pages
|
||||
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
||||
|
|
||||
*/
|
||||
|
||||
'navigate' => [
|
||||
'show_progress_bar' => true,
|
||||
'progress_bar_color' => '#2299dd',
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| HTML Morph Markers
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
|
||||
| after each update. To make this process more reliable, Livewire injects
|
||||
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_morph_markers' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Smart Wire Keys
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire uses loops and keys used within loops to generate smart keys that
|
||||
| are applied to nested components that don't have them. This makes using
|
||||
| nested components more reliable by ensuring that they all have keys.
|
||||
|
|
||||
*/
|
||||
|
||||
'smart_wire_keys' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Pagination Theme
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| When enabling Livewire's pagination feature by using the `WithPagination`
|
||||
| trait, Livewire will use Tailwind templates to render pagination views
|
||||
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
||||
|
|
||||
*/
|
||||
|
||||
'pagination_theme' => 'tailwind',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Release Token
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This token is stored client-side and sent along with each request to check
|
||||
| a users session to see if a new release has invalidated it. If there is
|
||||
| a mismatch it will throw an error and prompt for a browser refresh.
|
||||
|
|
||||
*/
|
||||
|
||||
'release_token' => 'a',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| CSP Safe
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This config is used to determine if Livewire will use the CSP-safe version
|
||||
| of Alpine in its bundle. This is useful for applications that are using
|
||||
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
||||
|
|
||||
*/
|
||||
|
||||
'csp_safe' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Payload Guards
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| These settings protect against malicious or oversized payloads that could
|
||||
| cause denial of service. The default values should feel reasonable for
|
||||
| most web applications. Each can be set to null to disable the limit.
|
||||
|
|
||||
*/
|
||||
|
||||
'payload' => [
|
||||
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
|
||||
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
|
||||
'max_calls' => 50, // Maximum method calls per request
|
||||
'max_components' => 200, // Maximum components per batch request
|
||||
],
|
||||
];
|
||||
+2
-1
@@ -40,7 +40,8 @@ return [
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
// Must exceed TranscribeRecording::$timeout / TRANSCRIPTION_TIMEOUT (default 600).
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 660),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_HOST'),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->string('transcription_progress')->nullable()->after('transcription_status');
|
||||
$table->unsignedTinyInteger('transcription_percent')->nullable()->after('transcription_progress');
|
||||
$table->timestamp('transcription_started_at')->nullable()->after('transcription_percent');
|
||||
$table->text('transcription_error')->nullable()->after('transcription_started_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'transcription_progress',
|
||||
'transcription_percent',
|
||||
'transcription_started_at',
|
||||
'transcription_error',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->string('content_hash', 64)->nullable()->after('file_size_bytes');
|
||||
$table->unique('content_hash');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropUnique(['content_hash']);
|
||||
$table->dropColumn('content_hash');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->text('two_factor_secret')
|
||||
->after('password')
|
||||
->nullable();
|
||||
|
||||
$table->text('two_factor_recovery_codes')
|
||||
->after('two_factor_secret')
|
||||
->nullable();
|
||||
|
||||
$table->timestamp('two_factor_confirmed_at')
|
||||
->after('two_factor_recovery_codes')
|
||||
->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_confirmed_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->after('id')
|
||||
->constrained()
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->unsignedInteger('transcription_duration_seconds')
|
||||
->nullable()
|
||||
->after('transcribed_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropColumn('transcription_duration_seconds');
|
||||
});
|
||||
}
|
||||
};
|
||||
Regular → Executable
+25
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Recording;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -15,11 +16,30 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
$email = (string) env('SEED_USER_EMAIL', 'demo@example.com');
|
||||
$name = (string) env('SEED_USER_NAME', 'Demo User');
|
||||
$password = (string) env('SEED_USER_PASSWORD', 'password');
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => $name,
|
||||
'password' => $password,
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
User::query()->updateOrCreate(
|
||||
['email' => 'admin@example.com'],
|
||||
[
|
||||
'name' => 'Admin',
|
||||
'password' => 'password',
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
Recording::query()
|
||||
->whereNull('user_id')
|
||||
->update(['user_id' => $user->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Local development overlay: bind-mount source + Vite HMR.
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
|
||||
# Or set in .env:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
||||
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
KEEP_VITE_HOT: "true"
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
vite:
|
||||
condition: service_started
|
||||
|
||||
queue:
|
||||
# Reload PHP between jobs so code mounts take effect without restarting.
|
||||
command:
|
||||
- php
|
||||
- artisan
|
||||
- queue:listen
|
||||
- database
|
||||
- --sleep=1
|
||||
- --tries=1
|
||||
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
|
||||
environment:
|
||||
KEEP_VITE_HOT: "true"
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
reverb:
|
||||
environment:
|
||||
KEEP_VITE_HOT: "true"
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
vite:
|
||||
image: node:22-bookworm
|
||||
container_name: andytranscribe-vite
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "if [ ! -x node_modules/.bin/vite ]; then npm ci; fi;
|
||||
npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
ports:
|
||||
- "${VITE_HOST_PORT:-5173}:5173"
|
||||
environment:
|
||||
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
|
||||
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
|
||||
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
|
||||
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
|
||||
VITE_USE_POLLING: "true"
|
||||
volumes:
|
||||
- .:/app
|
||||
- vite-node-modules:/app/node_modules
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
vite-node-modules:
|
||||
@@ -0,0 +1,173 @@
|
||||
x-data volumes:
|
||||
app-data: &app-data
|
||||
- ./database:/app/database
|
||||
- ./storage/app:/app/storage/app
|
||||
- ./storage/logs:/app/storage/logs
|
||||
|
||||
x-app-env: &app-env
|
||||
APP_NAME: AndyTranscribe
|
||||
APP_ENV: ${APP_ENV:-local}
|
||||
APP_KEY: ${APP_KEY}
|
||||
APP_DEBUG: ${APP_DEBUG:-true}
|
||||
APP_URL: ${APP_URL:-http://localhost:8080}
|
||||
APP_PORT: ${APP_HOST_PORT:-8080}
|
||||
LOG_CHANNEL: stderr
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: /app/database/database.sqlite
|
||||
# File drivers avoid SQLite lock storms: Livewire polls + database queue +
|
||||
# session/cache all writing the same sqlite file caused "database is locked".
|
||||
SESSION_DRIVER: file
|
||||
QUEUE_CONNECTION: database
|
||||
CACHE_STORE: file
|
||||
BROADCAST_CONNECTION: reverb
|
||||
FILESYSTEM_DISK: local
|
||||
REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe}
|
||||
REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
|
||||
REVERB_APP_SECRET: ${REVERB_APP_SECRET:-andytranscribe-secret}
|
||||
# Server-side publish target (Docker DNS)
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: 8080
|
||||
REVERB_SCHEME: http
|
||||
REVERB_SERVER_HOST: 0.0.0.0
|
||||
REVERB_SERVER_PORT: 8080
|
||||
LOCAL_WHISPER_URL: http://whisper:8000/v1
|
||||
LOCAL_WHISPER_API_KEY: ${LOCAL_WHISPER_API_KEY:-not-needed}
|
||||
LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
|
||||
TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600}
|
||||
DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660}
|
||||
SEED_USER_NAME: ${SEED_USER_NAME:-Demo User}
|
||||
SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com}
|
||||
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password}
|
||||
|
||||
# Local: omit APP_IMAGE (builds andytranscribe-app:latest).
|
||||
# CI/prod: set APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> and pull.
|
||||
x-app-image: &app-image
|
||||
image: ${APP_IMAGE:-andytranscribe-app:latest}
|
||||
|
||||
services:
|
||||
app:
|
||||
<<: *app-image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
|
||||
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
|
||||
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
|
||||
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
|
||||
container_name: andytranscribe-app
|
||||
ports:
|
||||
- "${APP_HOST_PORT:-8080}:80"
|
||||
environment:
|
||||
<<: *app-env
|
||||
volumes: *app-data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1/up"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
whisper:
|
||||
condition: service_healthy
|
||||
reverb:
|
||||
condition: service_started
|
||||
queue:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
# Shares the app image — do not declare build: here (avoids rebuilding 3×).
|
||||
# `docker compose up --build` builds `app` first, then starts these with the tagged image.
|
||||
queue:
|
||||
<<: *app-image
|
||||
container_name: andytranscribe-queue
|
||||
command:
|
||||
- php
|
||||
- artisan
|
||||
- queue:work
|
||||
- database
|
||||
- --sleep=1
|
||||
- --tries=1
|
||||
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
|
||||
environment:
|
||||
<<: *app-env
|
||||
volumes: *app-data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'queue:work'"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
whisper:
|
||||
condition: service_healthy
|
||||
reverb:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
reverb:
|
||||
<<: *app-image
|
||||
container_name: andytranscribe-reverb
|
||||
command:
|
||||
- php
|
||||
- artisan
|
||||
- reverb:start
|
||||
- --host=0.0.0.0
|
||||
- --port=8080
|
||||
ports:
|
||||
- "${REVERB_HOST_PORT:-8081}:8080"
|
||||
environment:
|
||||
<<: *app-env
|
||||
# Browser connects to localhost:8081; hostname used in app config for allowed hosts
|
||||
REVERB_HOST: localhost
|
||||
volumes: *app-data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'reverb:start'"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
whisper:
|
||||
image: fedirz/faster-whisper-server:latest-cpu
|
||||
container_name: andytranscribe-whisper
|
||||
ports:
|
||||
# Host 8090 avoids clashing with the FrankenPHP app on 8080
|
||||
- "${WHISPER_HOST_PORT:-8090}:8000"
|
||||
volumes:
|
||||
- whisper-huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# GPU variant (NVIDIA). Start with:
|
||||
# docker compose --profile gpu up -d whisper-gpu
|
||||
whisper-gpu:
|
||||
profiles: ["gpu"]
|
||||
image: fedirz/faster-whisper-server:latest-cuda
|
||||
container_name: andytranscribe-whisper-gpu
|
||||
ports:
|
||||
- "${WHISPER_HOST_PORT:-8090}:8000"
|
||||
volumes:
|
||||
- whisper-huggingface-cache:/root/.cache/huggingface
|
||||
environment:
|
||||
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
whisper-huggingface-cache:
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd /app
|
||||
|
||||
mkdir -p \
|
||||
database \
|
||||
storage/app/private/recordings \
|
||||
storage/app/public \
|
||||
storage/framework/cache \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
bootstrap/cache
|
||||
|
||||
if [ ! -f database/database.sqlite ]; then
|
||||
touch database/database.sqlite
|
||||
fi
|
||||
|
||||
# Production images should not honor a leftover Vite HMR file from the host.
|
||||
# Development bind mounts keep public/hot so the Vite container can drive assets.
|
||||
if [ "${KEEP_VITE_HOT:-false}" != "true" ]; then
|
||||
rm -f public/hot
|
||||
fi
|
||||
|
||||
# Bind mounts may arrive as root-owned; keep the app and host tooling writable.
|
||||
chmod -R a+rwX database storage bootstrap/cache 2>/dev/null || true
|
||||
|
||||
if [ -z "${APP_KEY:-}" ]; then
|
||||
echo "APP_KEY is not set. Generate one with: php artisan key:generate --show" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bind-mounted trees may lack vendor/ (image files are shadowed by the mount).
|
||||
if [ ! -f vendor/autoload.php ]; then
|
||||
composer install --prefer-dist --no-interaction
|
||||
fi
|
||||
|
||||
# Only the web app should migrate/seed. Queue and Reverb share the DB and must
|
||||
# not race on sqlite (locks) or re-seed on every restart/deploy.
|
||||
should_bootstrap_db() {
|
||||
case " $* " in
|
||||
*" queue:work "*|*" queue:listen "*|*" reverb:start "*)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if should_bootstrap_db "$@"; then
|
||||
php artisan migrate --force --no-interaction
|
||||
php artisan db:seed --force --no-interaction
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,10 @@
|
||||
; PHP limits for large pocket-recorder uploads (up to 2 GB per file).
|
||||
upload_max_filesize = 2G
|
||||
post_max_size = 10G
|
||||
memory_limit = 512M
|
||||
max_execution_time = 3600
|
||||
max_input_time = 3600
|
||||
|
||||
; Pick up bind-mounted PHP changes without restarting FrankenPHP.
|
||||
opcache.validate_timestamps = 1
|
||||
opcache.revalidate_freq = 0
|
||||
Generated
+69
@@ -6,8 +6,11 @@
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"alpinejs": "^3.16.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"pusher-js": "^8.6.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
@@ -661,6 +664,33 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.16.1",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
|
||||
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
@@ -1128,6 +1158,28 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-echo": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
|
||||
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pusher-js": "*",
|
||||
"socket.io-client": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pusher-js": {
|
||||
"optional": true
|
||||
},
|
||||
"socket.io-client": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
|
||||
@@ -1542,6 +1594,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz",
|
||||
"integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -1822,6 +1884,13 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"alpinejs": "^3.16.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"pusher-js": "^8.6.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/livewire/flux/dist/flux.css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../views';
|
||||
@source '../js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
+5
-1
@@ -1 +1,5 @@
|
||||
//
|
||||
import './echo';
|
||||
import { recordingsIndex, transcriptionMonitor } from './transcription';
|
||||
|
||||
window.transcriptionMonitor = transcriptionMonitor;
|
||||
window.recordingsIndex = recordingsIndex;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Echo from 'laravel-echo';
|
||||
|
||||
import Pusher from 'pusher-js';
|
||||
window.Pusher = Pusher;
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: '/broadcasting/auth',
|
||||
auth: {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Shared helpers and Alpine components for live transcription updates via Reverb.
|
||||
*/
|
||||
|
||||
const BADGE_COLORS = {
|
||||
done: 'teal',
|
||||
processing: 'amber',
|
||||
pending: 'zinc',
|
||||
failed: 'red',
|
||||
cancelled: 'zinc',
|
||||
};
|
||||
|
||||
export function badgeColorFor(status) {
|
||||
return BADGE_COLORS[status] || 'zinc';
|
||||
}
|
||||
|
||||
/** @deprecated Use badgeColorFor — kept for any leftover callers */
|
||||
export function badgeClassFor(status) {
|
||||
return badgeColorFor(status);
|
||||
}
|
||||
|
||||
export function formatElapsed(seconds) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const remain = total % 60;
|
||||
|
||||
if (minutes === 0) {
|
||||
return remain + 's';
|
||||
}
|
||||
|
||||
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const remain = total % 60;
|
||||
|
||||
return minutes + ':' + String(remain).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function formatTimestamp(value) {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
|
||||
return date.getFullYear()
|
||||
+ '-' + pad(date.getMonth() + 1)
|
||||
+ '-' + pad(date.getDate())
|
||||
+ ' ' + pad(date.getHours())
|
||||
+ ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
function subscribeToRecording(recordingId, handler) {
|
||||
if (!window.Echo) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const channelName = 'recording.' + recordingId;
|
||||
const channel = window.Echo.private(channelName);
|
||||
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
|
||||
return () => {
|
||||
// Prefer stopListening over leave() so a remount does not drop other subscribers.
|
||||
if (typeof channel.stopListening === 'function') {
|
||||
channel.stopListening('.RecordingTranscriptionUpdated');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
|
||||
* Livewire also listens on the user recordings channel and polls while active.
|
||||
*/
|
||||
export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
return {
|
||||
statusUrl,
|
||||
status: {
|
||||
...initial,
|
||||
badge_color: badgeColorFor(initial.status),
|
||||
},
|
||||
pollError: null,
|
||||
tickTimer: null,
|
||||
hydrateTimer: null,
|
||||
leaveChannel: null,
|
||||
|
||||
get badgeColor() {
|
||||
return badgeColorFor(this.status.status);
|
||||
},
|
||||
|
||||
get startButtonLabel() {
|
||||
if (this.status.is_active) {
|
||||
return 'Restart transcription';
|
||||
}
|
||||
|
||||
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
|
||||
},
|
||||
|
||||
start() {
|
||||
this.leaveChannel = subscribeToRecording(this.status.id, (event) => {
|
||||
if (Number(event.id) !== Number(this.status.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyPayload(event);
|
||||
});
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.hydrateOnce();
|
||||
this.beginHydratePoll();
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (this.leaveChannel) {
|
||||
this.leaveChannel();
|
||||
this.leaveChannel = null;
|
||||
}
|
||||
},
|
||||
|
||||
applyPayload(payload) {
|
||||
const wasActive = this.status.is_active;
|
||||
this.status = {
|
||||
...this.status,
|
||||
...payload,
|
||||
badge_color: badgeColorFor(payload.status ?? this.status.status),
|
||||
};
|
||||
this.pollError = null;
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.beginHydratePoll();
|
||||
} else {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
}
|
||||
|
||||
if (wasActive && ! this.status.is_active
|
||||
&& ! this.status.has_transcript
|
||||
&& this.status.status !== 'failed'
|
||||
&& this.status.status !== 'cancelled') {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
beginTick() {
|
||||
if (this.tickTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
|
||||
},
|
||||
|
||||
stopTick() {
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
beginHydratePoll() {
|
||||
if (this.hydrateTimer || ! this.statusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
|
||||
},
|
||||
|
||||
stopHydratePoll() {
|
||||
if (this.hydrateTimer) {
|
||||
clearInterval(this.hydrateTimer);
|
||||
this.hydrateTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
tickElapsed() {
|
||||
if (! this.status.is_active || this.status.elapsed_seconds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = this.status.elapsed_seconds + 1;
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
elapsed_seconds: next,
|
||||
elapsed_human: formatElapsed(next),
|
||||
};
|
||||
},
|
||||
|
||||
async hydrateOnce() {
|
||||
if (! this.statusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.statusUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (! response.ok) {
|
||||
throw new Error('Status request failed (' + response.status + ')');
|
||||
}
|
||||
|
||||
this.applyPayload(await response.json());
|
||||
} catch (error) {
|
||||
this.pollError = error.message || 'Could not refresh progress.';
|
||||
}
|
||||
},
|
||||
|
||||
formatElapsed,
|
||||
formatDuration,
|
||||
formatTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Index-page Alpine component: inline audio player only.
|
||||
* Status/progress refresh via Livewire Echo + wire:poll.
|
||||
*/
|
||||
export function recordingsIndex() {
|
||||
return {
|
||||
playingId: null,
|
||||
isPlaying: false,
|
||||
|
||||
start() {
|
||||
//
|
||||
},
|
||||
|
||||
destroy() {
|
||||
const player = this.$refs.player;
|
||||
|
||||
if (player) {
|
||||
player.pause();
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
}
|
||||
},
|
||||
|
||||
syncPlayer() {
|
||||
const player = this.$refs.player;
|
||||
|
||||
this.isPlaying = Boolean(player && !player.paused && !player.ended);
|
||||
|
||||
if (player?.ended) {
|
||||
this.playingId = null;
|
||||
}
|
||||
},
|
||||
|
||||
isPlayingRow(id) {
|
||||
return this.playingId === id && this.isPlaying;
|
||||
},
|
||||
|
||||
togglePlay(id, url) {
|
||||
const player = this.$refs.player;
|
||||
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playingId === id && this.isPlaying) {
|
||||
player.pause();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playingId !== id) {
|
||||
player.src = url;
|
||||
this.playingId = id;
|
||||
}
|
||||
|
||||
player.play().catch(() => {
|
||||
this.playingId = null;
|
||||
this.isPlaying = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 714 B |
@@ -0,0 +1,17 @@
|
||||
@props([
|
||||
'sidebar' => false,
|
||||
])
|
||||
|
||||
@if($sidebar)
|
||||
<flux:sidebar.brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
|
||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||
</x-slot>
|
||||
</flux:sidebar.brand>
|
||||
@else
|
||||
<flux:brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
|
||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||
</x-slot>
|
||||
</flux:brand>
|
||||
@endif
|
||||
@@ -0,0 +1,13 @@
|
||||
<flux:dropdown x-data align="end">
|
||||
<flux:button variant="subtle" square class="group" aria-label="{{ __('Preferred color scheme') }}">
|
||||
<flux:icon.sun x-show="$flux.appearance === 'light'" variant="mini" class="text-zinc-500 dark:text-white" />
|
||||
<flux:icon.moon x-show="$flux.appearance === 'dark'" variant="mini" class="text-zinc-500 dark:text-white" />
|
||||
<flux:icon.moon x-show="$flux.appearance === 'system' && $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
|
||||
<flux:icon.sun x-show="$flux.appearance === 'system' && ! $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
|
||||
</flux:button>
|
||||
<flux:menu>
|
||||
<flux:menu.item icon="sun" x-on:click="$flux.appearance = 'light'">{{ __('Light') }}</flux:menu.item>
|
||||
<flux:menu.item icon="moon" x-on:click="$flux.appearance = 'dark'">{{ __('Dark') }}</flux:menu.item>
|
||||
<flux:menu.item icon="computer-desktop" x-on:click="$flux.appearance = 'system'">{{ __('System') }}</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@@ -0,0 +1,9 @@
|
||||
@props([
|
||||
'title',
|
||||
'description',
|
||||
])
|
||||
|
||||
<div class="flex w-full flex-col text-center">
|
||||
<flux:heading size="xl">{{ $title }}</flux:heading>
|
||||
<flux:subheading>{{ $description }}</flux:subheading>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
@props([
|
||||
'status',
|
||||
])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,31 @@
|
||||
@auth
|
||||
<flux:dropdown position="bottom" align="end">
|
||||
<flux:button variant="ghost" class="max-lg:hidden" data-test="user-menu-button">
|
||||
{{ auth()->user()->name }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" class="lg:hidden" icon="user" data-test="user-menu-button-mobile" />
|
||||
|
||||
<flux:menu>
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<flux:avatar :name="auth()->user()->name" :initials="auth()->user()->initials()" />
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
|
||||
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
<flux:menu.separator />
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item
|
||||
as="button"
|
||||
type="submit"
|
||||
icon="arrow-right-start-on-rectangle"
|
||||
class="w-full cursor-pointer"
|
||||
data-test="logout-button"
|
||||
>
|
||||
{{ __('Log out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@endauth
|
||||
@@ -0,0 +1,33 @@
|
||||
@php
|
||||
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
||||
$usedPercent = min(100, max(0, (float) $disk['used_percent']));
|
||||
$usedPercentLabel = rtrim(rtrim(number_format($usedPercent, 1, '.', ''), '0'), '.') ?: '0';
|
||||
$freePercent = (float) ($disk['free_percent'] ?? 100);
|
||||
// Inline colors so the fill is visible even before / without a Tailwind rebuild.
|
||||
$fillColor = match (true) {
|
||||
$freePercent <= 5 => '#dc2626',
|
||||
$freePercent <= 15 => '#f59e0b',
|
||||
default => '#0d9488',
|
||||
};
|
||||
@endphp
|
||||
<div
|
||||
class="flex min-w-36 flex-col gap-1 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
|
||||
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $usedPercentLabel }}% used"
|
||||
>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ (int) round($usedPercent) }}"
|
||||
aria-label="Disk space {{ $usedPercentLabel }}% used"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-300"
|
||||
style="width: {{ $usedPercent }}%; background-color: {{ $fillColor }};"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium tabular-nums text-zinc-600 dark:text-zinc-300">
|
||||
{{ $usedPercentLabel }}% used · {{ $disk['free_human'] }} free
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
@props([
|
||||
'status',
|
||||
'label' => null,
|
||||
/** @var string|null Alpine expression that returns a row object with badge_color + status_label (+ status for spinner) */
|
||||
'alpineRow' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$label ??= match ($status) {
|
||||
'pending' => 'Queued',
|
||||
'processing' => 'Transcribing',
|
||||
'done' => 'Done',
|
||||
'failed' => 'Failed',
|
||||
'cancelled' => 'Cancelled',
|
||||
default => (string) $status,
|
||||
};
|
||||
|
||||
$color = match ($status) {
|
||||
'done' => 'teal',
|
||||
'processing' => 'amber',
|
||||
'pending' => 'zinc',
|
||||
'failed' => 'red',
|
||||
default => 'zinc',
|
||||
};
|
||||
|
||||
$icon = $status === 'processing' ? 'loading' : null;
|
||||
@endphp
|
||||
|
||||
@if ($alpineRow)
|
||||
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
|
||||
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
|
||||
@if ($badgeColor === $color)
|
||||
<flux:badge
|
||||
size="sm"
|
||||
:color="$badgeColor"
|
||||
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
|
||||
x-text="{{ $alpineRow }}.status_label"
|
||||
>{{ $label }}</flux:badge>
|
||||
@else
|
||||
<flux:badge
|
||||
size="sm"
|
||||
:color="$badgeColor"
|
||||
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
|
||||
x-cloak
|
||||
x-text="{{ $alpineRow }}.status_label"
|
||||
>{{ $label }}</flux:badge>
|
||||
@endif
|
||||
@endforeach
|
||||
<flux:icon.loading
|
||||
variant="micro"
|
||||
class="size-3 text-amber-600 dark:text-amber-400"
|
||||
x-show="{{ $alpineRow }}.status === 'processing'"
|
||||
x-cloak
|
||||
/>
|
||||
</span>
|
||||
@else
|
||||
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
|
||||
<flux:badge size="sm" :color="$color" :icon="$icon">
|
||||
{{ $label }}
|
||||
</flux:badge>
|
||||
</span>
|
||||
@endif
|
||||
@@ -1,50 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>@yield('title', 'Recordings') — {{ config('app.name', 'AndyTranscribe') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@include('partials.head', ['title' => $title ?? null])
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
|
||||
<header class="border-b border-stone-200 bg-white">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800">
|
||||
AndyTranscribe
|
||||
</a>
|
||||
<nav class="flex items-center gap-3 text-sm">
|
||||
<a href="{{ route('recordings.index') }}" class="text-stone-600 hover:text-stone-900">Recordings</a>
|
||||
<a href="{{ route('recordings.create') }}" class="rounded bg-teal-700 px-3 py-1.5 font-medium text-white hover:bg-teal-800">
|
||||
Upload
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
||||
|
||||
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
||||
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate class="max-lg:hidden" />
|
||||
|
||||
<flux:navbar class="-mb-px max-lg:hidden">
|
||||
<flux:navbar.item
|
||||
:href="route('recordings.index')"
|
||||
:current="request()->routeIs('recordings.index', 'recordings.show')"
|
||||
wire:navigate
|
||||
>
|
||||
{{ __('Recordings') }}
|
||||
</flux:navbar.item>
|
||||
<flux:navbar.item
|
||||
:href="route('recordings.create')"
|
||||
:current="request()->routeIs('recordings.create')"
|
||||
wire:navigate
|
||||
>
|
||||
{{ __('Upload') }}
|
||||
</flux:navbar.item>
|
||||
</flux:navbar>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<x-disk-space-bar />
|
||||
|
||||
<x-appearance-toggle />
|
||||
|
||||
@auth
|
||||
<x-desktop-user-menu />
|
||||
@endauth
|
||||
</flux:header>
|
||||
|
||||
<flux:sidebar collapsible="mobile" sticky class="lg:hidden">
|
||||
<flux:sidebar.header>
|
||||
<flux:sidebar.brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
|
||||
<flux:sidebar.collapse />
|
||||
</flux:sidebar.header>
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')" wire:navigate>
|
||||
{{ __('Recordings') }}
|
||||
</flux:sidebar.item>
|
||||
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
|
||||
{{ __('Upload') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.nav>
|
||||
</flux:sidebar>
|
||||
|
||||
<flux:main container>
|
||||
@if (session('success'))
|
||||
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
<flux:callout variant="success" icon="check-circle" class="mb-6">
|
||||
<flux:callout.text>{{ session('success') }}</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
|
||||
<flux:callout.text>{{ session('error') }}</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
||||
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
|
||||
<flux:callout.text>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
|
||||
<flux:toast />
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
|
||||
<flux:spacer />
|
||||
<x-disk-space-bar />
|
||||
<x-appearance-toggle />
|
||||
@auth
|
||||
<x-desktop-user-menu />
|
||||
@endauth
|
||||
</flux:header>
|
||||
|
||||
<flux:main container>
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-zinc-50 antialiased dark:bg-zinc-900">
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-md flex-col gap-6">
|
||||
<flux:brand
|
||||
class="justify-center"
|
||||
name="{{ config('app.name', 'AndyTranscribe') }}"
|
||||
href="{{ route('home') }}"
|
||||
wire:navigate
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-800 shadow-xs dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">
|
||||
<div class="px-10 py-8">{{ $slot }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@persist('toast')
|
||||
<flux:toast.group>
|
||||
<flux:toast />
|
||||
</flux:toast.group>
|
||||
@endpersist
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="absolute end-4 top-4">
|
||||
<x-appearance-toggle />
|
||||
</div>
|
||||
<div class="flex w-full max-w-sm flex-col gap-6">
|
||||
<flux:brand
|
||||
class="justify-center"
|
||||
name="{{ config('app.name', 'AndyTranscribe') }}"
|
||||
href="{{ url('/') }}"
|
||||
wire:navigate
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white antialiased dark:bg-zinc-900">
|
||||
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div class="relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-zinc-800">
|
||||
<div class="absolute inset-0 bg-zinc-900"></div>
|
||||
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
|
||||
{{ config('app.name', 'AndyTranscribe') }}
|
||||
</a>
|
||||
|
||||
@php
|
||||
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
|
||||
@endphp
|
||||
|
||||
<div class="relative z-20 mt-auto">
|
||||
<blockquote class="space-y-2">
|
||||
<flux:heading size="lg">“{{ trim($message) }}”</flux:heading>
|
||||
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:p-8">
|
||||
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<flux:brand
|
||||
class="z-20 justify-center lg:hidden"
|
||||
name="{{ config('app.name', 'AndyTranscribe') }}"
|
||||
href="{{ route('home') }}"
|
||||
wire:navigate
|
||||
/>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@persist('toast')
|
||||
<flux:toast.group>
|
||||
<flux:toast />
|
||||
</flux:toast.group>
|
||||
@endpersist
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<flux:heading size="xl">Upload recordings</flux:heading>
|
||||
</div>
|
||||
|
||||
<livewire:upload-recordings />
|
||||
</div>
|
||||
@@ -0,0 +1,234 @@
|
||||
<div
|
||||
@if ($hasActiveTranscriptions)
|
||||
wire:poll.2s.visible
|
||||
@endif
|
||||
x-data="recordingsIndex()"
|
||||
x-init="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">Recordings</flux:heading>
|
||||
<flux:text class="mt-1">Manage pocket-recorder audio and transcripts.</flux:text>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 sm:items-end">
|
||||
<div class="flex items-end gap-2">
|
||||
<flux:input
|
||||
type="search"
|
||||
wire:model.live.debounce.400ms="search"
|
||||
placeholder="Search title, artist, transcript…"
|
||||
class="min-w-[16rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
@if ($pendingCount > 0)
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
wire:click="queuePending"
|
||||
>
|
||||
Queue {{ $pendingCount }} pending
|
||||
{{ $pendingCount === 1 ? 'transcription' : 'transcriptions' }}
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if ($totalCount > 0)
|
||||
<flux:modal.trigger name="delete-all-recordings">
|
||||
<flux:button type="button" variant="danger" size="sm">
|
||||
Delete all
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($totalCount > 0)
|
||||
<flux:modal name="delete-all-recordings" class="max-w-md">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Delete all recordings?</flux:heading>
|
||||
<flux:text class="mt-2">
|
||||
This permanently removes
|
||||
{{ $totalCount === 1 ? 'your 1 recording' : "all {$totalCount} recordings" }}
|
||||
and their audio files. This cannot be undone.
|
||||
</flux:text>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="ghost">Cancel</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button type="button" variant="danger" wire:click="deleteAll">
|
||||
Delete all
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
|
||||
@if ($recordings->isEmpty())
|
||||
@if (filled($search))
|
||||
<flux:card class="border-dashed py-16 text-center">
|
||||
<flux:text>No recordings match “{{ $search }}”.</flux:text>
|
||||
<div class="mt-4">
|
||||
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<livewire:upload-recordings :show-cancel="false" />
|
||||
@endif
|
||||
@else
|
||||
<audio
|
||||
x-ref="player"
|
||||
class="hidden"
|
||||
preload="none"
|
||||
@play="syncPlayer()"
|
||||
@pause="syncPlayer()"
|
||||
@ended="playingId = null; syncPlayer()"
|
||||
></audio>
|
||||
|
||||
<flux:table :paginate="$recordings">
|
||||
<flux:table.columns>
|
||||
<flux:table.column
|
||||
sortable
|
||||
:sorted="$sortBy === 'title'"
|
||||
:direction="$sortDirection"
|
||||
wire:click="sort('title')"
|
||||
>
|
||||
Title
|
||||
</flux:table.column>
|
||||
<flux:table.column
|
||||
sortable
|
||||
:sorted="$sortBy === 'duration'"
|
||||
:direction="$sortDirection"
|
||||
wire:click="sort('duration')"
|
||||
>
|
||||
Duration
|
||||
</flux:table.column>
|
||||
<flux:table.column
|
||||
sortable
|
||||
:sorted="$sortBy === 'words'"
|
||||
:direction="$sortDirection"
|
||||
wire:click="sort('words')"
|
||||
>
|
||||
Words
|
||||
</flux:table.column>
|
||||
<flux:table.column
|
||||
class="w-36"
|
||||
sortable
|
||||
:sorted="$sortBy === 'status'"
|
||||
:direction="$sortDirection"
|
||||
wire:click="sort('status')"
|
||||
>
|
||||
Status
|
||||
</flux:table.column>
|
||||
<flux:table.column
|
||||
sortable
|
||||
:sorted="$sortBy === 'uploaded'"
|
||||
:direction="$sortDirection"
|
||||
wire:click="sort('uploaded')"
|
||||
>
|
||||
Uploaded
|
||||
</flux:table.column>
|
||||
<flux:table.column class="w-24"></flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($recordings as $recording)
|
||||
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
|
||||
<flux:table.cell class="max-w-xl">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<flux:link
|
||||
href="{{ route('recordings.show', $recording) }}"
|
||||
wire:navigate
|
||||
class="shrink-0 font-medium"
|
||||
>
|
||||
{{ $recording->title }}
|
||||
</flux:link>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
square
|
||||
class="shrink-0"
|
||||
data-audio-url="{{ route('recordings.audio', $recording) }}"
|
||||
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
|
||||
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
|
||||
>
|
||||
<flux:icon.play
|
||||
variant="micro"
|
||||
x-show="! isPlayingRow({{ $recording->id }})"
|
||||
/>
|
||||
<flux:icon.pause
|
||||
variant="micro"
|
||||
x-show="isPlayingRow({{ $recording->id }})"
|
||||
x-cloak
|
||||
/>
|
||||
</flux:button>
|
||||
@if ($preview = $recording->transcriptFirstLine())
|
||||
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
|
||||
{{ $preview }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="tabular-nums">
|
||||
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="w-36 whitespace-nowrap">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
/>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:modal.trigger name="delete-recording-{{ $recording->id }}">
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
square
|
||||
aria-label="Delete {{ $recording->title }}"
|
||||
>
|
||||
<flux:icon.trash variant="micro" />
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:modal name="delete-recording-{{ $recording->id }}" class="max-w-md">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Delete recording?</flux:heading>
|
||||
<flux:text class="mt-2">
|
||||
This permanently removes “{{ $recording->title }}” and its audio file.
|
||||
</flux:text>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="ghost">Cancel</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="danger"
|
||||
wire:click="delete({{ $recording->id }})"
|
||||
>
|
||||
Delete
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,235 @@
|
||||
<div
|
||||
@if ($recording->isTranscribing())
|
||||
wire:poll.2s.visible
|
||||
@endif
|
||||
>
|
||||
<div
|
||||
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
|
||||
x-data="transcriptionMonitor(@js([
|
||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||
'initial' => $recording->transcriptionStatusPayload(),
|
||||
]))"
|
||||
x-init="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||||
<flux:heading size="xl" class="mt-2">{{ $recording->title }}</flux:heading>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<x-transcription-status-badge
|
||||
:status="$recording->transcription_status"
|
||||
:label="$recording->transcriptionStatusLabel()"
|
||||
alpine-row="status"
|
||||
/>
|
||||
<flux:text
|
||||
class="text-xs"
|
||||
x-show="status.driver_label"
|
||||
x-cloak
|
||||
x-text="status.driver_label"
|
||||
></flux:text>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="play"
|
||||
@click="$refs.player.paused ? $refs.player.play() : $refs.player.pause()"
|
||||
>
|
||||
Play
|
||||
</flux:button>
|
||||
|
||||
<flux:modal.trigger name="delete-recording">
|
||||
<flux:button type="button" variant="danger">Delete</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:modal name="delete-recording" class="max-w-md">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Delete recording?</flux:heading>
|
||||
<flux:text class="mt-2">
|
||||
This permanently removes the recording and its audio file.
|
||||
</flux:text>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="ghost">Cancel</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button type="button" variant="danger" wire:click="delete">Delete</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Audio</flux:heading>
|
||||
<audio
|
||||
x-ref="player"
|
||||
class="mt-4 w-full"
|
||||
controls
|
||||
preload="metadata"
|
||||
src="{{ route('recordings.audio', $recording) }}"
|
||||
>
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Metadata</flux:heading>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Original file</flux:text></dt>
|
||||
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Duration</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Artist</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Album</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Recorded</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Size</flux:text></dt>
|
||||
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt><flux:text>Uploaded</flux:text></dt>
|
||||
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcribe</flux:heading>
|
||||
<div class="mt-4">
|
||||
<flux:button type="button" variant="primary" wire:click="startTranscription">
|
||||
<span x-text="startButtonLabel"></span>
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="status.is_active"
|
||||
x-cloak
|
||||
class="mt-3"
|
||||
>
|
||||
<flux:button type="button" variant="outline" wire:click="cancelTranscription">
|
||||
Stop transcription
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:card class="mt-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcript</flux:heading>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
x-show="!status.is_active && status.has_transcript"
|
||||
x-cloak
|
||||
@click="navigator.clipboard.writeText(status.transcript || '')"
|
||||
>
|
||||
Copy
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div x-show="status.is_active" x-cloak class="mt-4">
|
||||
<flux:callout variant="warning" icon="arrow-path">
|
||||
<flux:callout.heading>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<flux:icon.loading
|
||||
variant="micro"
|
||||
class="size-4"
|
||||
x-show="status.status === 'processing'"
|
||||
x-cloak
|
||||
/>
|
||||
<span x-text="status.progress || 'Working…'"></span>
|
||||
</span>
|
||||
</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
<span
|
||||
class="tabular-nums"
|
||||
x-show="status.status === 'processing' && status.percent != null"
|
||||
x-cloak
|
||||
>
|
||||
<span x-text="status.percent + '%'"></span>
|
||||
·
|
||||
</span>
|
||||
Elapsed <span x-text="status.elapsed_human || '0s'"></span>
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
|
||||
<div class="mt-3" x-show="status.status === 'processing'" x-cloak>
|
||||
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
||||
</div>
|
||||
|
||||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<li>
|
||||
Engine:
|
||||
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
||||
</li>
|
||||
<template x-if="status.duration_seconds">
|
||||
<li>
|
||||
Audio length:
|
||||
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
|
||||
<span class="text-zinc-500">(longer files take longer)</span>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="pollError" class="text-red-600 dark:text-red-400" x-text="pollError"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4">
|
||||
<flux:callout icon="stop-circle">
|
||||
<flux:callout.text>
|
||||
Transcription stopped. Use the button above to start again.
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4">
|
||||
<flux:callout variant="danger" icon="exclamation-triangle">
|
||||
<flux:callout.heading>Transcription failed</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
<span x-text="status.error || 'Check the logs and try again.'"></span>
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
|
||||
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
|
||||
<flux:text
|
||||
class="mt-4 text-xs"
|
||||
x-show="status.transcribed_at"
|
||||
x-text="status.transcribed_at
|
||||
? ('Transcribed ' + formatTimestamp(status.transcribed_at)
|
||||
+ (status.transcription_duration_human ? (' · took ' + status.transcription_duration_human) : ''))
|
||||
: ''"
|
||||
></flux:text>
|
||||
</div>
|
||||
|
||||
<flux:text
|
||||
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
||||
x-cloak
|
||||
class="mt-4"
|
||||
>
|
||||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<div
|
||||
class="max-w-2xl space-y-6 rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
|
||||
x-data="{
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
dragging: false,
|
||||
openPicker() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
onDrop(event) {
|
||||
this.dragging = false;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
if (! files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transfer = new DataTransfer();
|
||||
|
||||
Array.from(files).forEach((file) => transfer.items.add(file));
|
||||
|
||||
this.$refs.fileInput.files = transfer.files;
|
||||
this.$refs.fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
}"
|
||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
||||
x-on:livewire-upload-finish="uploading = false; progress = 100"
|
||||
x-on:livewire-upload-cancel="uploading = false"
|
||||
x-on:livewire-upload-error="uploading = false"
|
||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||
>
|
||||
<div>
|
||||
<flux:label>Audio files</flux:label>
|
||||
|
||||
<input
|
||||
x-ref="fileInput"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
wire:model="audio"
|
||||
multiple
|
||||
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
|
||||
>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
x-on:click="openPicker()"
|
||||
x-on:keydown.enter.prevent="openPicker()"
|
||||
x-on:keydown.space.prevent="openPicker()"
|
||||
x-on:dragenter.prevent="dragging = true"
|
||||
x-on:dragover.prevent="dragging = true"
|
||||
x-on:dragleave.prevent="dragging = false"
|
||||
x-on:drop.prevent="onDrop($event)"
|
||||
x-bind:class="dragging ? 'border-accent bg-accent/5 dark:border-accent dark:bg-accent/10' : 'border-zinc-300 bg-zinc-50 dark:border-white/20 dark:bg-white/5'"
|
||||
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed px-6 py-10 text-center transition-colors"
|
||||
>
|
||||
<div class="mb-3 flex size-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-white/10">
|
||||
<flux:icon.cloud-arrow-up class="size-6 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<flux:heading size="sm" class="text-zinc-800 dark:text-zinc-100">
|
||||
Drop audio files here or click to browse
|
||||
</flux:heading>
|
||||
|
||||
<flux:text class="mt-1 max-w-sm text-zinc-500 dark:text-zinc-400">
|
||||
MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files
|
||||
</flux:text>
|
||||
|
||||
<div
|
||||
x-show="uploading || $wire.saving"
|
||||
x-cloak
|
||||
class="mt-5 w-full max-w-sm space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<span x-text="$wire.saving ? 'Saving recordings…' : 'Uploading…'"></span>
|
||||
<span x-show="uploading" x-text="progress + '%'"></span>
|
||||
</div>
|
||||
<flux:progress color="teal" x-bind:value="uploading ? progress : 100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@error('audio')
|
||||
<flux:error>{{ $message }}</flux:error>
|
||||
@enderror
|
||||
@error('audio.*')
|
||||
<flux:error>{{ $message }}</flux:error>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@if ($showCancel)
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate>Cancel</flux:link>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<x-layouts::auth :title="__('Confirm password')">
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header
|
||||
:title="__('Confirm password')"
|
||||
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||
/>
|
||||
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
{{-- @chisel-passkeys --}}
|
||||
<x-passkey-verify
|
||||
options-route="passkey.confirm-options"
|
||||
submit-route="passkey.confirm"
|
||||
:label="__('Confirm with passkey')"
|
||||
:loading-label="__('Confirming...')"
|
||||
:separator="__('Or confirm with password')"
|
||||
/>
|
||||
{{-- @end-chisel-passkeys --}}
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
|
||||
<flux:input
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
|
||||
{{ __('Confirm') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-layouts::auth :title="__('Forgot password')">
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
|
||||
{{ __('Email password reset link') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-400">
|
||||
<span>{{ __('Or, return to') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,53 @@
|
||||
<x-layouts::auth :title="__('Log in')">
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
||||
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
|
||||
<flux:input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<flux:input
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
/>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<flux:link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
|
||||
{{ __('Forgot your password?') }}
|
||||
</flux:link>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
|
||||
{{ __('Log in') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 text-center text-sm text-zinc-600 rtl:space-x-reverse dark:text-zinc-400">
|
||||
<span>{{ __('Don\'t have an account?') }}</span>
|
||||
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,69 @@
|
||||
<x-layouts::auth :title="__('Register')">
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<!-- Name -->
|
||||
<flux:input
|
||||
name="name"
|
||||
:label="__('Name')"
|
||||
:value="old('name')"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
:placeholder="__('Full name')"
|
||||
/>
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<flux:input
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Password')"
|
||||
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<flux:input
|
||||
name="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Confirm password')"
|
||||
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
|
||||
{{ __('Create account') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-layouts::auth :title="__('Reset password')">
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<!-- Token -->
|
||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
name="email"
|
||||
value="{{ request('email') }}"
|
||||
:label="__('Email')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<flux:input
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Password')"
|
||||
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<flux:input
|
||||
name="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Confirm password')"
|
||||
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
|
||||
{{ __('Reset password') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,101 @@
|
||||
<x-layouts::auth :title="__('Two-factor authentication')">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
class="relative w-full h-auto"
|
||||
x-cloak
|
||||
x-data="{
|
||||
showRecoveryInput: @js($errors->has('recovery_code')),
|
||||
code: '',
|
||||
recovery_code: '',
|
||||
focusOtp() {
|
||||
this.$nextTick(() => this.$refs.otp?.querySelector('input')?.focus());
|
||||
},
|
||||
init() {
|
||||
if (! this.showRecoveryInput) {
|
||||
this.focusOtp();
|
||||
}
|
||||
},
|
||||
toggleInput() {
|
||||
this.showRecoveryInput = !this.showRecoveryInput;
|
||||
|
||||
this.code = '';
|
||||
this.recovery_code = '';
|
||||
|
||||
$nextTick(() => {
|
||||
this.showRecoveryInput
|
||||
? this.$refs.recovery_code?.focus()
|
||||
: this.focusOtp();
|
||||
});
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div x-show="!showRecoveryInput">
|
||||
<x-auth-header
|
||||
:title="__('Authentication code')"
|
||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryInput">
|
||||
<x-auth-header
|
||||
:title="__('Recovery code')"
|
||||
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('two-factor.login.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="space-y-5 text-center">
|
||||
<div x-show="!showRecoveryInput">
|
||||
<div class="flex items-center justify-center my-5" x-ref="otp">
|
||||
<flux:otp
|
||||
x-model="code"
|
||||
length="6"
|
||||
name="code"
|
||||
label="OTP Code"
|
||||
label:sr-only
|
||||
class="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryInput">
|
||||
<div class="my-5">
|
||||
<flux:input
|
||||
type="text"
|
||||
name="recovery_code"
|
||||
x-ref="recovery_code"
|
||||
x-bind:required="showRecoveryInput"
|
||||
autocomplete="one-time-code"
|
||||
x-model="recovery_code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@error('recovery_code')
|
||||
<flux:text color="red">
|
||||
{{ $message }}
|
||||
</flux:text>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<flux:button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
class="w-full"
|
||||
>
|
||||
{{ __('Continue') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
|
||||
<span class="opacity-50">{{ __('or you can') }}</span>
|
||||
<div class="inline font-medium underline cursor-pointer opacity-80">
|
||||
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
|
||||
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,29 @@
|
||||
<x-layouts::auth :title="__('Email verification')">
|
||||
<div class="mt-4 flex flex-col gap-6">
|
||||
<flux:text class="text-center">
|
||||
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
|
||||
</flux:text>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col items-center justify-between space-y-3">
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="primary" class="w-full">
|
||||
{{ __('Resend verification email') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
|
||||
{{ __('Log out') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -0,0 +1,46 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>
|
||||
{{ filled($title ?? null) ? $title.' — '.config('app.name', 'AndyTranscribe') : config('app.name', 'AndyTranscribe') }}
|
||||
</title>
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
{{-- Flux appearance with dark as the default (Flux ships with "system"). --}}
|
||||
<style>
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.Flux = {
|
||||
applyAppearance (appearance) {
|
||||
let applyDark = () => document.documentElement.classList.add('dark')
|
||||
let applyLight = () => document.documentElement.classList.remove('dark')
|
||||
|
||||
if (appearance === 'system') {
|
||||
let media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
// Persist "system" explicitly so a missing key can mean "use app default (dark)".
|
||||
window.localStorage.setItem('flux.appearance', 'system')
|
||||
|
||||
media.matches ? applyDark() : applyLight()
|
||||
} else if (appearance === 'dark') {
|
||||
window.localStorage.setItem('flux.appearance', 'dark')
|
||||
|
||||
applyDark()
|
||||
} else if (appearance === 'light') {
|
||||
window.localStorage.setItem('flux.appearance', 'light')
|
||||
|
||||
applyLight()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.Flux.applyAppearance(window.localStorage.getItem('flux.appearance') || 'dark')
|
||||
</script>
|
||||
@@ -1,50 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Upload recording')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
|
||||
<p class="mt-1 text-sm text-stone-600">Upload an MP3 from your pocket recorder. ID3 metadata is extracted automatically.</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('recordings.store') }}"
|
||||
enctype="multipart/form-data"
|
||||
class="max-w-xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="audio" class="block text-sm font-medium text-stone-700">MP3 file</label>
|
||||
<input
|
||||
id="audio"
|
||||
type="file"
|
||||
name="audio"
|
||||
accept=".mp3,audio/mpeg"
|
||||
required
|
||||
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value="{{ old('title') }}"
|
||||
placeholder="Leave blank to use ID3 title or filename"
|
||||
class="mt-2 w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800">
|
||||
Upload
|
||||
</button>
|
||||
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -1,77 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Recordings')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
|
||||
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder MP3s and transcripts.</p>
|
||||
</div>
|
||||
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ $search ?? '' }}"
|
||||
placeholder="Search title, artist, transcript…"
|
||||
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||
>
|
||||
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if ($recordings->isEmpty())
|
||||
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
|
||||
<p class="text-stone-600">No recordings yet.</p>
|
||||
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
|
||||
Upload your first MP3
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-stone-200 text-sm">
|
||||
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Title</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Engine</th>
|
||||
<th class="px-4 py-3">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-100">
|
||||
@foreach ($recordings as $recording)
|
||||
<tr class="hover:bg-stone-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
|
||||
{{ $recording->title }}
|
||||
</a>
|
||||
@if ($recording->artist)
|
||||
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">
|
||||
@if ($recording->transcription_driver)
|
||||
<span class="uppercase tracking-wide text-xs">{{ $recording->transcription_driver }}</span>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $recordings->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -1,11 +0,0 @@
|
||||
@php
|
||||
$classes = match ($status) {
|
||||
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
||||
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
|
||||
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||
};
|
||||
@endphp
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
|
||||
{{ $status }}
|
||||
</span>
|
||||
@@ -1,157 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $recording->title)
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800">← Recordings</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
|
||||
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||
@if ($recording->transcription_driver)
|
||||
<span class="text-xs uppercase tracking-wide text-stone-500">{{ $recording->transcription_driver }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Original file</dt>
|
||||
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Duration</dt>
|
||||
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Artist</dt>
|
||||
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Album</dt>
|
||||
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Recorded</dt>
|
||||
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Size</dt>
|
||||
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Uploaded</dt>
|
||||
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@if ($recording->ollama_url)
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Ollama host</dt>
|
||||
<dd class="max-w-[60%] break-all text-right font-medium">{{ $recording->ollama_url }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
x-data="{ driver: '{{ old('driver', $recording->transcription_driver ?: 'cloud') }}' }"
|
||||
class="rounded border border-stone-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
|
||||
<p class="mt-2 text-sm text-stone-600">Choose how to convert this MP3 to text.</p>
|
||||
|
||||
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Cloud (OpenAI Whisper)</span>
|
||||
<span class="block text-xs text-stone-500">Fast; audio is sent to OpenAI.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="local" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Local — confidential (faster-whisper)</span>
|
||||
<span class="block text-xs text-stone-500">Requires faster-whisper-server on this machine (Docker).</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="ollama" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Ollama host</span>
|
||||
<span class="block text-xs text-stone-500">
|
||||
Remote host URL. Must expose OpenAI-compatible <code class="text-[11px]">/v1/audio/transcriptions</code>
|
||||
(e.g. Whisper beside Ollama).
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div x-show="driver === 'ollama'" x-cloak class="space-y-2">
|
||||
<label for="ollama_url" class="block text-sm font-medium text-stone-700">Host URL</label>
|
||||
<input
|
||||
id="ollama_url"
|
||||
type="url"
|
||||
name="ollama_url"
|
||||
value="{{ old('ollama_url', $recording->ollama_url) }}"
|
||||
placeholder="http://192.168.1.50:8000"
|
||||
class="w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@disabled($recording->transcription_status === 'processing')
|
||||
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{{ $recording->transcript ? 'Re-transcribe' : 'Start transcription' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
|
||||
@if ($recording->transcript)
|
||||
<button
|
||||
type="button"
|
||||
onclick="navigator.clipboard.writeText(@js($recording->transcript))"
|
||||
class="text-sm text-teal-700 hover:underline"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($recording->transcription_status === 'processing')
|
||||
<p class="mt-4 text-sm text-amber-700">Transcription in progress… Refresh shortly.</p>
|
||||
@elseif ($recording->transcription_status === 'failed')
|
||||
<p class="mt-4 text-sm text-red-700">Transcription failed. Check the logs and try again with another engine.</p>
|
||||
@elseif ($recording->transcript)
|
||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
|
||||
@if ($recording->transcribed_at)
|
||||
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
|
||||
@endif
|
||||
@else
|
||||
<p class="mt-4 text-sm text-stone-500">No transcript yet. Choose an engine above to start.</p>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
@endsection
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user