Compare commits

17 Commits
Author SHA1 Message Date
ben 1877fee258 Fix flaky transcription duration assertion in CI.
CI / test (push) Successful in 20s
CI / build-and-push (push) Successful in 38s
CI / deploy (push) Successful in 9s
Use integer timestamps for duration and pin the test clock to a whole second so SQLite round-trips stay stable.
2026-08-13 00:49:53 +02:00
ben b3fb74fb1b Make the disk space meter visible and show used percent.
CI / test (push) Failing after 31s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
Use an inline-colored full-width progress bar so the fill renders without relying on purged Tailwind classes.
2026-08-13 00:37:20 +02:00
ben f42d124593 Harden stuck Whisper recovery and record transcription duration.
Fail jobs on timeout, treat stale reserved queue rows as orphans, skip migrate/seed on queue/reverb boot, and store how long each successful run took.
2026-08-13 00:12:06 +02:00
ben 2b126ee4e6 Fix deploy asset check to use forwarded HTTPS headers.
CI / test (push) Successful in 24s
CI / build-and-push (push) Successful in 18s
CI / deploy (push) Successful in 13s
Curling localhost without X-Forwarded-Proto always yields http:// URLs even when trustProxies is correct behind Caddy.
2026-08-12 23:32:14 +02:00
ben ab14f5e452 Disable Vite in tests so CI can run without a frontend build.
CI / test (push) Successful in 27s
CI / build-and-push (push) Successful in 1m34s
CI / deploy (push) Failing after 14s
Feature tests were failing on the host runner with ViteManifestNotFoundException after the flaky Node build step was removed.
2026-08-12 23:25:57 +02:00
ben 761f1a1f78 Fix Gitea CI deploy path and ban production hot-patches.
CI / test (push) Failing after 43s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
Drop the flaky host Node docker step (image build already runs Vite), hard-reset the deploy checkout to the CI SHA, and fail deploy if assets still emit http://.
2026-08-12 23:16:38 +02:00
ben 5dd0a3aeac Add Gitea Actions CI/CD to build, push, and deploy on main.
CI / test (push) Failing after 12m47s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
Mirrors the airports runner flow: test, publish to the Gitea registry, then pull APP_IMAGE into the z00 compose stack.
2026-08-12 22:56:46 +02:00
ben 4898d5cfde Use file session/cache to avoid SQLite lock failures under queue load.
Database sessions and cache competed with the queue worker on one SQLite file, causing "database is locked" when claiming jobs.
2026-08-12 22:32:56 +02:00
ben f65c816464 Default the UI appearance to dark mode for new visitors.
Keep the Flux light/dark/system toggle; persist system explicitly so the app default can stay dark.
2026-08-12 22:12:52 +02:00
ben d60851bb53 Trust reverse proxies so HTTPS asset URLs work behind CPM.
Without TrustProxies, Laravel generated http:// links after TLS termination and browsers blocked CSS/JS as mixed content.
2026-08-12 22:06:22 +02:00
ben 8a66cf6f63 Improve recordings list sorting and live show-page status updates.
Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
2026-08-12 21:27:03 +02:00
ben 5f0f61995c Fix Docker builds when livewire-tmp is root-owned.
Exclude whole storage trees from the build context, create cache dirs before dump-autoload, and drop Buildx-only cache mounts.
2026-08-12 20:47:58 +02:00
ben b7c36b8b3b Compact recordings list rows to title plus one transcript preview line. 2026-08-12 20:36:22 +02:00
ben 6bc5a20606 Speed up Docker builds with a single image and parallel deps.
Build app once for queue/reverb, run Composer and npm in parallel with cache mounts, and skip redundant vite npm ci on restart.
2026-08-12 20:20:06 +02:00
ben c17f8fb506 Improve live recordings UI, upload flow, and default Flux theme.
Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login.
2026-08-12 20:14:35 +02:00
ben 148ba91816 Add delete button with confirm modal on each recordings list row. 2026-08-12 19:23:35 +02:00
ben 34ccf0c32b Move recordings UI to Livewire pages with Flux components.
Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
2026-08-12 19:17:52 +02:00
64 changed files with 3030 additions and 1419 deletions
+8 -6
View File
@@ -10,12 +10,13 @@ node_modules
vendor vendor
public/build public/build
public/hot public/hot
storage/app/private/** # Exclude whole trees (not only /**) so Docker never tries to stat root-owned tmp dirs
storage/app/public/** storage/app/private
storage/logs/** storage/app/public
storage/framework/cache/** storage/logs
storage/framework/sessions/** storage/framework/cache
storage/framework/views/** storage/framework/sessions
storage/framework/views
database/*.sqlite* database/*.sqlite*
.env .env
.env.* .env.*
@@ -28,5 +29,6 @@ npm-debug.log
yarn-error.log yarn-error.log
tests tests
docs docs
todo.txt
*.md *.md
!README.md !README.md
+13 -14
View File
@@ -3,6 +3,8 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost:8080 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_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -27,7 +29,7 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root # DB_USERNAME=root
# DB_PASSWORD= # DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=file
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@@ -37,7 +39,7 @@ BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=file
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
@@ -74,10 +76,16 @@ REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0 REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080 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_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_HOST=localhost
VITE_REVERB_PORT="${REVERB_PORT}" # Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start
VITE_REVERB_SCHEME="${REVERB_SCHEME}" VITE_REVERB_PORT="${REVERB_HOST_PORT}"
VITE_REVERB_SCHEME=http
# AndyTranscribe / local faster-whisper # AndyTranscribe / local faster-whisper
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1 LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
@@ -87,15 +95,6 @@ TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run # Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660 DB_QUEUE_RETRY_AFTER=660
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
# Browser-facing Reverb host/port (used at Vite build time in Docker)
VITE_REVERB_HOST=localhost
VITE_REVERB_SCHEME=http
# Demo user created on every container start (db:seed via entrypoint) # Demo user created on every container start (db:seed via entrypoint)
SEED_USER_NAME="Demo User" SEED_USER_NAME="Demo User"
SEED_USER_EMAIL=demo@example.com SEED_USER_EMAIL=demo@example.com
+117
View File
@@ -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
+35 -10
View File
@@ -1,10 +1,14 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Composer deps (runs in parallel with npm ci)
# ---------------------------------------------------------------------------
FROM composer:2 AS vendor FROM composer:2 AS vendor
WORKDIR /app WORKDIR /app
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
RUN composer install \ RUN composer install \
--no-dev \ --no-dev \
--no-scripts \ --no-scripts \
@@ -12,15 +16,29 @@ RUN composer install \
--prefer-dist \ --prefer-dist \
--no-interaction --no-interaction
FROM node:22-bookworm AS assets # ---------------------------------------------------------------------------
# npm ci only (parallel with vendor when BuildKit is available)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS npm
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY --from=vendor /app/vendor ./vendor # ---------------------------------------------------------------------------
COPY composer.json composer.lock ./ # 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 vite.config.js ./
COPY resources ./resources COPY resources ./resources
COPY public ./public COPY public ./public
@@ -39,8 +57,12 @@ ENV VITE_APP_NAME=$VITE_APP_NAME \
RUN npm run build RUN npm run build
# ---------------------------------------------------------------------------
# Runtime image
# ---------------------------------------------------------------------------
FROM dunglas/frankenphp:php8.5-bookworm FROM dunglas/frankenphp:php8.5-bookworm
# Rarely changes — keep early for cache hits
RUN install-php-extensions \ RUN install-php-extensions \
pcntl \ pcntl \
pdo_sqlite \ pdo_sqlite \
@@ -50,20 +72,25 @@ RUN install-php-extensions \
intl \ intl \
opcache opcache
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer 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 WORKDIR /app
# Dependency layer (invalidates when lockfiles / vendor change)
COPY --from=vendor /app/vendor ./vendor COPY --from=vendor /app/vendor ./vendor
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
# Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
COPY . . COPY . .
# Built frontend assets
COPY --from=assets /app/public/build ./public/build COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \ # Framework/view cache paths must exist before package:discover runs during dump-autoload
&& mkdir -p \ RUN mkdir -p \
storage/app/private \ storage/app/private \
storage/app/public \ storage/app/public \
storage/framework/cache \ storage/framework/cache \
@@ -72,11 +99,9 @@ RUN composer dump-autoload --optimize --no-dev \
storage/logs \ storage/logs \
database \ database \
bootstrap/cache \ bootstrap/cache \
&& composer dump-autoload --optimize --no-dev \
&& chown -R www-data:www-data storage bootstrap/cache database && chown -R www-data:www-data storage bootstrap/cache database
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+41 -7
View File
@@ -76,6 +76,8 @@ sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
docker compose up --build -d 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: On first start the app container will:
- create `database/database.sqlite` if needed - create `database/database.sqlite` if needed
@@ -87,14 +89,14 @@ Whisper may take a minute or two while the model downloads.
### Demo login ### Demo login
Every container start runs `db:seed`, which ensures this user exists: Every container start runs `db:seed`, which ensures these users exist:
| Field | Default | | Email | Password |
| --- | --- | | --- | --- |
| Email | `demo@example.com` | | `demo@example.com` | `password` |
| Password | `password` | | `admin@example.com` | `password` |
Override with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`. Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
### 5. Open the app ### 5. Open the app
@@ -123,6 +125,14 @@ docker compose up -d
Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host. Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host.
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 ### 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): 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):
@@ -144,6 +154,29 @@ Then a normal `docker compose up -d` enables:
- `queue:listen` so worker code picks up changes between jobs - `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`. 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 ## Services and ports
| Service | Host port | Role | | Service | Host port | Role |
@@ -171,12 +204,13 @@ Edit `.env` before `docker compose up` when you need different ports or models:
| Variable | Purpose | Default | | Variable | Purpose | Default |
| --- | --- | --- | | --- | --- | --- |
| `APP_KEY` | Required Laravel encryption key | — | | `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_URL` | Public app URL | `http://localhost:8080` |
| `APP_HOST_PORT` | Host port for the web app | `8080` | | `APP_HOST_PORT` | Host port for the web app | `8080` |
| `REVERB_HOST_PORT` | Host port for WebSockets | `8081` | | `REVERB_HOST_PORT` | Host port for WebSockets | `8081` |
| `WHISPER_HOST_PORT` | Host port for Whisper | `8090` | | `WHISPER_HOST_PORT` | Host port for Whisper | `8090` |
| `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` | | `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds) | `600` | | `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` | | `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`. 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`.
@@ -197,7 +231,7 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a
## Usage ## Usage
1. Open the app and **Log in** with the demo user (`demo@example.com` / `password`), or **Register** a new account. 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. 2. Open **Recordings → Upload** and drop one or many audio files.
3. Transcription starts automatically (the `queue` service must be running). 3. Transcription starts automatically (the `queue` service must be running).
4. Watch live progress on the list or detail page; stop or restart anytime. 4. Watch live progress on the list or detail page; stop or restart anytime.
+130
View File
@@ -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;
}
}
@@ -27,6 +27,7 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
{ {
return [ return [
new PrivateChannel('recording.'.$this->recording->id), new PrivateChannel('recording.'.$this->recording->id),
new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
]; ];
} }
@@ -1,26 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CancelTranscriptionController extends Controller
{
/**
* Stop an in-progress or queued transcription.
*/
public function __invoke(Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
if (! $recording->isTranscribing()) {
return back()->with('error', 'No transcription is currently running.');
}
$recording->cancelTranscription();
return back()->with('success', 'Transcription stopped.');
}
}
@@ -1,223 +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\Http\UploadedFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class RecordingController extends Controller
{
/**
* Display a listing of recordings.
*/
public function index(Request $request): View
{
$user = $request->user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$query = $user->recordings()->latest();
if ($search = $request->string('q')->trim()->toString()) {
$query->search($search);
}
$recordings = $query->paginate(20)->withQueryString();
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
return view('recordings.index', [
'recordings' => $recordings,
'search' => $search ?? '',
'pendingCount' => $pendingCount,
]);
}
/**
* Show the upload form.
*/
public function create(Request $request): View
{
$existingFingerprints = $request->user()->recordings()
->get(['original_filename', 'file_size_bytes'])
->map(fn (Recording $recording) => $this->uploadFingerprint(
$recording->original_filename,
(int) $recording->file_size_bytes,
))
->unique()
->values()
->all();
return view('recordings.create', [
'existingFingerprints' => $existingFingerprints,
]);
}
/**
* Store one or more uploaded recordings.
*/
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
{
/** @var list<UploadedFile> $files */
$files = array_values(array_filter(
$request->file('audio', []),
fn ($file) => $file instanceof UploadedFile,
));
$user = $request->user();
$titleOverride = $request->string('title')->trim()->toString();
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
$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 && $titleOverride !== ''
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title);
}
if ($recordings === [] && $skippedDuplicates > 0) {
return redirect()
->route('recordings.create')
->with('error', $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.");
}
if ($recordings === []) {
return redirect()
->route('recordings.create')
->with('error', 'No valid audio files were uploaded.');
}
$message = count($recordings) === 1
? 'Recording uploaded — transcription queued.'
: count($recordings).' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
if (count($recordings) === 1) {
return redirect()
->route('recordings.show', $recordings[0])
->with('success', $message);
}
return redirect()
->route('recordings.index')
->with('success', $message);
}
/**
* Display the specified recording.
*/
public function show(Recording $recording): View
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
return view('recordings.show', compact('recording'));
}
/**
* Remove the specified recording.
*/
public function destroy(Recording $recording): RedirectResponse
{
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
return redirect()
->route('recordings.index')
->with('success', 'Recording deleted.');
}
/**
* Persist a single uploaded audio file as a recording.
*/
private function storeUploadedRecording(
Request $request,
UploadedFile $file,
Mp3MetadataService $metadata,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([
'user_id' => $request->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();
}
/**
* Client-side fingerprint for name + size duplicate checks before upload.
*/
private function uploadFingerprint(string $filename, int $sizeBytes): string
{
return strtolower($filename).':'.$sizeBytes;
}
}
@@ -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,25 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class TranscribeController extends Controller
{
/**
* Queue local faster-whisper transcription for the recording.
*
* Always allowed: stops any current run first, then starts a new one.
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
$recording->queueLocalTranscription();
return back()->with('success', 'Transcription started. Progress updates below.');
}
}
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TranscribePendingController extends Controller
{
/**
* Queue local transcription for recordings that still need a transcript.
*/
public function __invoke(Request $request): RedirectResponse
{
$queued = 0;
$request->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) {
return back()->with('error', 'No recordings need transcription right now.');
}
return back()->with(
'success',
$queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
);
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TranscribeRecordingRequest extends FormRequest
{
public function authorize(): bool
{
$recording = $this->route('recording');
return $recording !== null && $this->user()?->can('transcribe', $recording) === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}
+11 -21
View File
@@ -6,9 +6,11 @@ use App\Models\Recording;
use App\Services\TranscriptionService; use App\Services\TranscriptionService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
#[FailOnTimeout]
class TranscribeRecording implements ShouldQueue class TranscribeRecording implements ShouldQueue
{ {
use Queueable; use Queueable;
@@ -20,6 +22,9 @@ class TranscribeRecording implements ShouldQueue
/** /**
* The number of seconds the job can run before timing out. * 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; public int $timeout;
@@ -134,16 +139,7 @@ class TranscribeRecording implements ShouldQueue
} }
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) { if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
$this->recording->forceFill([ $this->recording->markTranscriptionComplete($text);
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
return true; return true;
} }
@@ -152,22 +148,16 @@ class TranscribeRecording implements ShouldQueue
if ( if (
$this->recording->transcription_status === 'failed' $this->recording->transcription_status === 'failed'
&& $this->recording->matchesTranscriptionRun($this->runStartedAt) && $this->recording->matchesTranscriptionRun($this->runStartedAt)
&& str_contains((string) $this->recording->transcription_error, 'worker stopped') && (
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', [ Log::warning('Recovering transcript after false orphan failure', [
'recording_id' => $this->recording->id, 'recording_id' => $this->recording->id,
]); ]);
$this->recording->forceFill([ $this->recording->markTranscriptionComplete($text);
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
return true; return true;
} }
+18
View File
@@ -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');
}
}
+227
View File
@@ -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');
}
}
+93
View File
@@ -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);
}
}
+89
View File
@@ -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');
}
}
+138 -40
View File
@@ -41,6 +41,7 @@ class Recording extends Model
'transcription_driver', 'transcription_driver',
'ollama_url', 'ollama_url',
'transcribed_at', 'transcribed_at',
'transcription_duration_seconds',
]; ];
/** /**
@@ -53,6 +54,7 @@ class Recording extends Model
'transcribed_at' => 'datetime', 'transcribed_at' => 'datetime',
'transcription_started_at' => 'datetime', 'transcription_started_at' => 'datetime',
'duration_seconds' => 'integer', 'duration_seconds' => 'integer',
'transcription_duration_seconds' => 'integer',
'file_size_bytes' => 'integer', 'file_size_bytes' => 'integer',
'transcription_percent' => 'integer', 'transcription_percent' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
@@ -135,9 +137,10 @@ class Recording extends Model
'ollama_url' => null, 'ollama_url' => null,
'transcription_status' => 'pending', 'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…', 'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5, 'transcription_percent' => null,
'transcription_started_at' => now(), 'transcription_started_at' => now(),
'transcription_error' => null, 'transcription_error' => null,
'transcription_duration_seconds' => null,
// Keep the previous transcript until a new run succeeds. // Keep the previous transcript until a new run succeeds.
'transcribed_at' => $this->transcribed_at, 'transcribed_at' => $this->transcribed_at,
]); ]);
@@ -179,6 +182,28 @@ class Recording extends Model
}); });
} }
/**
* 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. * Short transcript excerpt, optionally centered on a search hit.
*/ */
@@ -206,33 +231,40 @@ class Recording extends Model
return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : ''); 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. * 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 public function hasActiveTranscriptionJob(): bool
{ {
$staleBefore = now()->timestamp - $this->transcriptionJobStaleAfterSeconds();
return DB::table('jobs') return DB::table('jobs')
->pluck('payload') ->orderBy('id')
->contains(function (string $payload): bool { ->get(['id', 'payload', 'reserved_at'])
if (! str_contains($payload, TranscribeRecording::class)) { ->contains(function (object $job) use ($staleBefore): bool {
$payload = (string) $job->payload;
if (! str_contains($payload, 'TranscribeRecording')) {
return false; return false;
} }
$data = json_decode($payload, true); if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
$command = $data['data']['command'] ?? null;
if (! is_string($command)) {
return false; return false;
} }
try { return $this->jobPayloadBelongsToRecording($payload);
$job = unserialize($command);
} catch (Throwable) {
return $this->payloadMentionsRecording($payload);
}
return $job instanceof TranscribeRecording
&& (int) $job->recording->getKey() === (int) $this->id;
}); });
} }
@@ -241,8 +273,11 @@ class Recording extends Model
*/ */
private function payloadMentionsRecording(string $payload): bool private function payloadMentionsRecording(string $payload): bool
{ {
return (bool) preg_match('/id";i:'.$this->id.';/', $payload) // Jobs table stores JSON; the serialized command inside escapes quotes as \".
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"'); 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.'\\"');
} }
/** /**
@@ -310,34 +345,16 @@ class Recording extends Model
->each(function (object $job) use (&$deleted): void { ->each(function (object $job) use (&$deleted): void {
$payload = (string) $job->payload; $payload = (string) $job->payload;
if (! str_contains($payload, TranscribeRecording::class)) { if (! str_contains($payload, 'TranscribeRecording')) {
return; return;
} }
$data = json_decode($payload, true); if (! $this->jobPayloadBelongsToRecording($payload)) {
$command = $data['data']['command'] ?? null;
if (! is_string($command)) {
return;
}
try {
$queued = unserialize($command);
} catch (Throwable) {
if (! preg_match('/id";i:'.$this->id.';/', $payload)) {
return; return;
} }
DB::table('jobs')->where('id', $job->id)->delete(); DB::table('jobs')->where('id', $job->id)->delete();
$deleted++; $deleted++;
return;
}
if ($queued instanceof TranscribeRecording && (int) $queued->recording->getKey() === (int) $this->id) {
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
}
}); });
$this->releaseTranscriptionUniqueLock(); $this->releaseTranscriptionUniqueLock();
@@ -345,6 +362,36 @@ class Recording extends Model
return $deleted; 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. * Stop transcription: drop queued jobs and mark the run cancelled.
* *
@@ -378,6 +425,30 @@ class Recording extends Model
)->forceRelease(); )->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. * Mark transcription as failed and unblock the UI.
*/ */
@@ -398,7 +469,7 @@ class Recording extends Model
} }
/** /**
* Recover a stuck transcription if the queue job is gone. * Recover a stuck transcription if the queue job is gone or a reservation is stale.
*/ */
public function recoverOrphanedTranscription(): bool public function recoverOrphanedTranscription(): bool
{ {
@@ -406,8 +477,11 @@ class Recording extends Model
return false; return false;
} }
// Drop abandoned reserved rows so a restart can enqueue cleanly.
$this->discardQueuedTranscriptionJobs();
$this->markTranscriptionFailed( $this->markTranscriptionFailed(
'Transcription worker stopped before finishing. Start transcription again.', 'Transcription timed out or the worker stopped before finishing. Start transcription again.',
); );
return true; return true;
@@ -444,6 +518,26 @@ class Recording extends Model
return Storage::disk('local')->path($this->file_path); 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. * Delete the audio file from storage.
*/ */
@@ -477,6 +571,10 @@ class Recording extends Model
'elapsed_seconds' => $elapsed, 'elapsed_seconds' => $elapsed,
'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed), 'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed),
'duration_seconds' => $this->duration_seconds, '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(), 'is_active' => $this->isTranscribing(),
'has_transcript' => filled($this->transcript), 'has_transcript' => filled($this->transcript),
'transcript' => $this->transcript, 'transcript' => $this->transcript,
-18
View File
@@ -33,24 +33,6 @@ class DiskSpaceBar extends Component
return $this->disk !== null; return $this->disk !== null;
} }
/**
* Progress fill color based on remaining free space.
*/
public function barColor(): string
{
$freePercent = $this->disk['free_percent'] ?? 100;
if ($freePercent <= 5) {
return 'bg-red-600';
}
if ($freePercent <= 15) {
return 'bg-amber-500';
}
return 'bg-teal-600';
}
/** /**
* Get the view / contents that represent the component. * Get the view / contents that represent the component.
*/ */
+10
View File
@@ -13,6 +13,16 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->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->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo(fn () => route('recordings.index')); $middleware->redirectUsersTo(fn () => route('recordings.index'));
}) })
+41
View File
@@ -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
+2 -1
View File
@@ -38,7 +38,8 @@ return [
'database' => env('DB_DATABASE', database_path('database.sqlite')), 'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => 5000, // Wait longer under concurrent writers (queue + web on one SQLite file).
'busy_timeout' => (int) env('DB_BUSY_TIMEOUT', 30000),
'journal_mode' => 'WAL', 'journal_mode' => 'WAL',
'synchronous' => 'NORMAL', 'synchronous' => 'NORMAL',
'transaction_mode' => 'DEFERRED', 'transaction_mode' => 'DEFERRED',
+5 -3
View File
@@ -44,7 +44,7 @@ return [
| |
*/ */
'component_layout' => 'layouts::app.header', 'component_layout' => 'layouts.app',
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
@@ -130,15 +130,17 @@ return [
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) // Pocket-recorder audio can be large; max is kilobytes (2 GiB).
'rules' => ['required', 'file', 'max:2097152'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a', 'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif',
], ],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... 'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
], ],
@@ -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');
});
}
};
+9
View File
@@ -29,6 +29,15 @@ class DatabaseSeeder extends Seeder
], ],
); );
User::query()->updateOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => 'password',
'email_verified_at' => now(),
],
);
Recording::query() Recording::query()
->whereNull('user_id') ->whereNull('user_id')
->update(['user_id' => $user->id]); ->update(['user_id' => $user->id]);
+3 -1
View File
@@ -39,7 +39,9 @@ services:
image: node:22-bookworm image: node:22-bookworm
container_name: andytranscribe-vite container_name: andytranscribe-vite
working_dir: /app working_dir: /app
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173" command: >
sh -c "if [ ! -x node_modules/.bin/vite ]; then npm ci; fi;
npm run dev -- --host 0.0.0.0 --port 5173"
ports: ports:
- "${VITE_HOST_PORT:-5173}:5173" - "${VITE_HOST_PORT:-5173}:5173"
environment: environment:
+14 -23
View File
@@ -14,9 +14,11 @@ x-app-env: &app-env
LOG_CHANNEL: stderr LOG_CHANNEL: stderr
DB_CONNECTION: sqlite DB_CONNECTION: sqlite
DB_DATABASE: /app/database/database.sqlite DB_DATABASE: /app/database/database.sqlite
SESSION_DRIVER: database # 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 QUEUE_CONNECTION: database
CACHE_STORE: database CACHE_STORE: file
BROADCAST_CONNECTION: reverb BROADCAST_CONNECTION: reverb
FILESYSTEM_DISK: local FILESYSTEM_DISK: local
REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe} REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe}
@@ -37,8 +39,14 @@ x-app-env: &app-env
SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com} SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com}
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password} 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: services:
app: app:
<<: *app-image
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -48,7 +56,6 @@ services:
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost} VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081} VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http} VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest
container_name: andytranscribe-app container_name: andytranscribe-app
ports: ports:
- "${APP_HOST_PORT:-8080}:80" - "${APP_HOST_PORT:-8080}:80"
@@ -70,17 +77,10 @@ services:
condition: service_started condition: service_started
restart: unless-stopped 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: queue:
build: <<: *app-image
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}
image: andytranscribe-app:latest
container_name: andytranscribe-queue container_name: andytranscribe-queue
command: command:
- php - php
@@ -107,16 +107,7 @@ services:
restart: unless-stopped restart: unless-stopped
reverb: reverb:
build: <<: *app-image
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}
image: andytranscribe-app:latest
container_name: andytranscribe-reverb container_name: andytranscribe-reverb
command: command:
- php - php
+15
View File
@@ -36,7 +36,22 @@ if [ ! -f vendor/autoload.php ]; then
composer install --prefer-dist --no-interaction composer install --prefer-dist --no-interaction
fi 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 migrate --force --no-interaction
php artisan db:seed --force --no-interaction php artisan db:seed --force --no-interaction
fi
exec "$@" exec "$@"
+2 -5
View File
@@ -8,11 +8,8 @@
@source '../js'; @source '../js';
@theme { @theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', --font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Segoe UI Symbol', 'Noto Color Emoji'; 'Noto Color Emoji';
--color-accent: var(--color-teal-700);
--color-accent-content: var(--color-teal-700);
--color-accent-foreground: var(--color-white);
} }
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
-2
View File
@@ -1,7 +1,5 @@
import './echo'; import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription'; import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload';
window.transcriptionMonitor = transcriptionMonitor; window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex; window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone;
+96 -71
View File
@@ -2,16 +2,21 @@
* Shared helpers and Alpine components for live transcription updates via Reverb. * Shared helpers and Alpine components for live transcription updates via Reverb.
*/ */
const BADGE_CLASSES = { const BADGE_COLORS = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30', done: 'teal',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', processing: 'amber',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', pending: 'zinc',
failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30', failed: 'red',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', cancelled: 'zinc',
}; };
export function badgeColorFor(status) {
return BADGE_COLORS[status] || 'zinc';
}
/** @deprecated Use badgeColorFor — kept for any leftover callers */
export function badgeClassFor(status) { export function badgeClassFor(status) {
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30'; return badgeColorFor(status);
} }
export function formatElapsed(seconds) { export function formatElapsed(seconds) {
@@ -50,12 +55,6 @@ export function formatTimestamp(value) {
+ ':' + pad(date.getMinutes()); + ':' + pad(date.getMinutes());
} }
function formatWordCount(count) {
const n = Number(count) || 0;
return n > 0 ? n.toLocaleString() : '—';
}
function subscribeToRecording(recordingId, handler) { function subscribeToRecording(recordingId, handler) {
if (!window.Echo) { if (!window.Echo) {
return () => {}; return () => {};
@@ -67,31 +66,31 @@ function subscribeToRecording(recordingId, handler) {
channel.listen('.RecordingTranscriptionUpdated', handler); channel.listen('.RecordingTranscriptionUpdated', handler);
return () => { return () => {
window.Echo.leave(channelName); // Prefer stopListening over leave() so a remount does not drop other subscribers.
}; if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
} }
function subscribeToRecordings(recordingIds, handler) {
const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler));
return () => {
leaveFns.forEach((leave) => leave());
}; };
} }
/** /**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate. * 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 }) { export function transcriptionMonitor({ statusUrl, initial }) {
return { return {
statusUrl, statusUrl,
status: initial, status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null, pollError: null,
tickTimer: null, tickTimer: null,
hydrateTimer: null,
leaveChannel: null, leaveChannel: null,
get badgeClass() { get badgeColor() {
return badgeClassFor(this.status.status); return badgeColorFor(this.status.status);
}, },
get startButtonLabel() { get startButtonLabel() {
@@ -114,11 +113,13 @@ export function transcriptionMonitor({ statusUrl, initial }) {
if (this.status.is_active) { if (this.status.is_active) {
this.beginTick(); this.beginTick();
this.hydrateOnce(); this.hydrateOnce();
this.beginHydratePoll();
} }
}, },
destroy() { destroy() {
this.stopTick(); this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) { if (this.leaveChannel) {
this.leaveChannel(); this.leaveChannel();
@@ -128,13 +129,19 @@ export function transcriptionMonitor({ statusUrl, initial }) {
applyPayload(payload) { applyPayload(payload) {
const wasActive = this.status.is_active; const wasActive = this.status.is_active;
this.status = { ...this.status, ...payload }; this.status = {
...this.status,
...payload,
badge_color: badgeColorFor(payload.status ?? this.status.status),
};
this.pollError = null; this.pollError = null;
if (this.status.is_active) { if (this.status.is_active) {
this.beginTick(); this.beginTick();
this.beginHydratePoll();
} else { } else {
this.stopTick(); this.stopTick();
this.stopHydratePoll();
} }
if (wasActive && ! this.status.is_active if (wasActive && ! this.status.is_active
@@ -160,13 +167,33 @@ export function transcriptionMonitor({ statusUrl, initial }) {
} }
}, },
beginHydratePoll() {
if (this.hydrateTimer || ! this.statusUrl) {
return;
}
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
},
stopHydratePoll() {
if (this.hydrateTimer) {
clearInterval(this.hydrateTimer);
this.hydrateTimer = null;
}
},
tickElapsed() { tickElapsed() {
if (! this.status.is_active || this.status.elapsed_seconds == null) { if (! this.status.is_active || this.status.elapsed_seconds == null) {
return; return;
} }
this.status.elapsed_seconds += 1; const next = this.status.elapsed_seconds + 1;
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
this.status = {
...this.status,
elapsed_seconds: next,
elapsed_human: formatElapsed(next),
};
}, },
async hydrateOnce() { async hydrateOnce() {
@@ -177,6 +204,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
try { try {
const response = await fetch(this.statusUrl, { const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'same-origin',
}); });
if (! response.ok) { if (! response.ok) {
@@ -196,67 +224,64 @@ export function transcriptionMonitor({ statusUrl, initial }) {
} }
/** /**
* Index-page Alpine component: patch row status from Reverb events. * Index-page Alpine component: inline audio player only.
* Status/progress refresh via Livewire Echo + wire:poll.
*/ */
export function recordingsIndex({ recordings, pendingCount }) { export function recordingsIndex() {
const byId = {};
for (const row of recordings) {
byId[row.id] = row;
}
return { return {
rows: byId, playingId: null,
pendingCount: Number(pendingCount) || 0, isPlaying: false,
leaveChannel: null,
start() { start() {
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => { //
this.applyPayload(event);
});
}, },
destroy() { destroy() {
if (this.leaveChannel) { const player = this.$refs.player;
this.leaveChannel();
this.leaveChannel = null; if (player) {
player.pause();
player.removeAttribute('src');
player.load();
} }
}, },
applyPayload(payload) { syncPlayer() {
const id = payload.id; const player = this.$refs.player;
if (!this.rows[id]) { 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; return;
} }
const previousStatus = this.rows[id].status; if (this.playingId === id && this.isPlaying) {
const next = { player.pause();
...this.rows[id],
status: payload.status,
status_label: payload.status_label,
progress: payload.progress,
percent: payload.percent,
is_active: payload.is_active,
word_count: payload.word_count ?? this.rows[id].word_count,
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
badge_class: badgeClassFor(payload.status),
};
this.rows[id] = next; return;
const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus);
const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status);
if (wasQueueable && !isQueueable) {
this.pendingCount = Math.max(0, this.pendingCount - 1);
} else if (!wasQueueable && isQueueable) {
this.pendingCount += 1;
} }
},
row(id) { if (this.playingId !== id) {
return this.rows[id] || {}; player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
}, },
}; };
} }
-165
View File
@@ -1,165 +0,0 @@
/**
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
* before submitting the form.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
export function uploadDropzone({ existingFingerprints = [] } = {}) {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
const known = new Set(existingFingerprints);
return {
files: [],
dragging: false,
uploading: false,
error: null,
notice: null,
get uploadLabel() {
if (this.uploading) {
return 'Uploading…';
}
if (this.files.length <= 1) {
return 'Upload';
}
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
this.notice = null;
const accepted = [];
let skippedUnsupported = 0;
let skippedTooLarge = 0;
let skippedDuplicates = 0;
for (const file of incoming) {
if (! this.isAccepted(file)) {
skippedUnsupported++;
continue;
}
if (file.size > MAX_FILE_BYTES) {
skippedTooLarge++;
continue;
}
const fingerprint = this.fingerprint(file);
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
accepted.push(file);
}
this.files = [...this.files, ...accepted];
if (this.files.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = this.files.slice(0, 50);
}
if (skippedUnsupported > 0) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
} else if (skippedTooLarge > 0) {
this.error = 'Skipped a file larger than 2 GB.';
}
if (skippedDuplicates > 0) {
this.notice = skippedDuplicates === 1
? 'Skipped 1 duplicate file.'
: `Skipped ${skippedDuplicates} duplicate files.`;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) {
return true;
}
return (file.type || '').startsWith('audio/');
},
fingerprint(file) {
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
},
fileListKey(file, index = 0) {
return `${this.fingerprint(file)}:${index}`;
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.notice = null;
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) {
return;
}
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) {
return bytes + ' B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
},
};
}
@@ -1,29 +1,33 @@
@php @php
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */ /** @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 @endphp
<div class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto max-w-5xl px-4 py-2 sm:px-6">
<div class="flex items-center justify-between gap-3 text-xs text-stone-600 dark:text-zinc-400">
<span class="font-medium text-stone-700 dark:text-zinc-200">Disk space</span>
<span class="tabular-nums">
{{ $disk['free_human'] }} free
<span class="text-stone-400 dark:text-zinc-500">·</span>
{{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
</span>
</div>
<div <div
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700" 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" role="progressbar"
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
aria-valuenow="{{ (int) round($disk['used_percent']) }}" aria-valuenow="{{ (int) round($usedPercent) }}"
aria-label="Disk space used" aria-label="Disk space {{ $usedPercentLabel }}% used"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }}"
> >
<div <div
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}" class="h-full rounded-full transition-[width] duration-300"
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%" style="width: {{ $usedPercent }}%; background-color: {{ $fillColor }};"
></div> ></div>
</div> </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> </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
+28 -28
View File
@@ -1,30 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
@include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null]) @include('partials.head', ['title' => $title ?? null])
<style>[x-cloak]{display:none!important}</style> <style>[x-cloak]{display:none!important}</style>
</head> </head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100"> <body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar /> <flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" /> <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300"> <flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate class="max-lg:hidden" />
AndyTranscribe
</a>
<flux:navbar class="-mb-px max-lg:hidden"> <flux:navbar class="-mb-px max-lg:hidden">
<flux:navbar.item <flux:navbar.item
:href="route('recordings.index')" :href="route('recordings.index')"
:current="request()->routeIs('recordings.index', 'recordings.show')" :current="request()->routeIs('recordings.index', 'recordings.show')"
wire:navigate
> >
{{ __('Recordings') }} {{ __('Recordings') }}
</flux:navbar.item> </flux:navbar.item>
<flux:navbar.item <flux:navbar.item
:href="route('recordings.create')" :href="route('recordings.create')"
:current="request()->routeIs('recordings.create')" :current="request()->routeIs('recordings.create')"
wire:navigate
> >
{{ __('Upload') }} {{ __('Upload') }}
</flux:navbar.item> </flux:navbar.item>
@@ -32,57 +29,60 @@
<flux:spacer /> <flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle /> <x-appearance-toggle />
@auth @auth
<x-desktop-user-menu /> <x-desktop-user-menu />
@endauth @endauth
</div>
</flux:header> </flux:header>
<flux:sidebar collapsible="mobile" sticky class="border-e border-stone-200 bg-white lg:hidden dark:border-zinc-700 dark:bg-zinc-800"> <flux:sidebar collapsible="mobile" sticky class="lg:hidden">
<flux:sidebar.header> <flux:sidebar.header>
<a href="{{ route('recordings.index') }}" class="text-base font-semibold text-teal-800 dark:text-teal-300"> <flux:sidebar.brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
AndyTranscribe
</a>
<flux:sidebar.collapse /> <flux:sidebar.collapse />
</flux:sidebar.header> </flux:sidebar.header>
<flux:sidebar.nav> <flux:sidebar.nav>
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')"> <flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')" wire:navigate>
{{ __('Recordings') }} {{ __('Recordings') }}
</flux:sidebar.item> </flux:sidebar.item>
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')"> <flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
{{ __('Upload') }} {{ __('Upload') }}
</flux:sidebar.item> </flux:sidebar.item>
</flux:sidebar.nav> </flux:sidebar.nav>
</flux:sidebar> </flux:sidebar>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6"> <flux:main container>
@if (session('success')) @if (session('success'))
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900 dark:border-teal-800 dark:bg-teal-950 dark:text-teal-100"> <flux:callout variant="success" icon="check-circle" class="mb-6">
{{ session('success') }} <flux:callout.text>{{ session('success') }}</flux:callout.text>
</div> </flux:callout>
@endif @endif
@if (session('error')) @if (session('error'))
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100"> <flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
{{ session('error') }} <flux:callout.text>{{ session('error') }}</flux:callout.text>
</div> </flux:callout>
@endif @endif
@if ($errors->any()) @if ($errors->any())
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100"> <flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>
<ul class="list-disc space-y-1 pl-5"> <ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error) @foreach ($errors->all() as $error)
<li>{{ $error }}</li> <li>{{ $error }}</li>
@endforeach @endforeach
</ul> </ul>
</div> </flux:callout.text>
</flux:callout>
@endif @endif
@yield('content') {{ $slot }}
</main> </flux:main>
<flux:toast />
@fluxScripts @fluxScripts
</body> </body>
+7 -12
View File
@@ -1,28 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
@include('partials.head') @include('partials.head')
<style>[x-cloak]{display:none!important}</style> <style>[x-cloak]{display:none!important}</style>
</head> </head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100"> <body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar /> <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:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<flux:spacer /> <flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle /> <x-appearance-toggle />
@auth @auth
<x-desktop-user-menu /> <x-desktop-user-menu />
@endauth @endauth
</div>
</flux:header> </flux:header>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6"> <flux:main container>
{{ $slot }} {{ $slot }}
</main> </flux:main>
@fluxScripts @fluxScripts
</body> </body>
+10 -11
View File
@@ -1,21 +1,20 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
@include('partials.head') @include('partials.head')
</head> </head>
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-zinc-50 antialiased dark:bg-zinc-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <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"> <div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate> <flux:brand
<span class="flex h-9 w-9 items-center justify-center rounded-md"> class="justify-center"
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> name="{{ config('app.name', 'AndyTranscribe') }}"
</span> href="{{ route('home') }}"
wire:navigate
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span> />
</a>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> <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 class="px-10 py-8">{{ $slot }}</div>
</div> </div>
</div> </div>
+10 -10
View File
@@ -1,21 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
@include('partials.head') @include('partials.head')
<style>[x-cloak]{display:none!important}</style> <style>[x-cloak]{display:none!important}</style>
</head> </head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100"> <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 bg-stone-100 p-6 md:p-10 dark:bg-zinc-900"> <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"> <div class="absolute end-4 top-4">
<x-appearance-toggle /> <x-appearance-toggle />
</div> </div>
<div class="flex w-full max-w-sm flex-col gap-2"> <div class="flex w-full max-w-sm flex-col gap-6">
<a href="{{ url('/') }}" class="mb-1 flex flex-col items-center gap-2 font-medium"> <flux:brand
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-teal-700 text-sm font-semibold text-white dark:bg-teal-600"> class="justify-center"
AT name="{{ config('app.name', 'AndyTranscribe') }}"
</span> href="{{ url('/') }}"
<span class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">{{ config('app.name', 'AndyTranscribe') }}</span> wire:navigate
</a> />
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
{{ $slot }} {{ $slot }}
</div> </div>
+11 -15
View File
@@ -1,17 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
@include('partials.head') @include('partials.head')
</head> </head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <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 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="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800"> <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-neutral-900"></div> <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> <a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md"> {{ config('app.name', 'AndyTranscribe') }}
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
</span>
{{ config('app.name', 'Laravel') }}
</a> </a>
@php @php
@@ -27,13 +24,12 @@
</div> </div>
<div class="w-full lg:p-8"> <div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> <div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate> <flux:brand
<span class="flex h-9 w-9 items-center justify-center rounded-md"> class="z-20 justify-center lg:hidden"
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> name="{{ config('app.name', 'AndyTranscribe') }}"
</span> href="{{ route('home') }}"
wire:navigate
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span> />
</a>
{{ $slot }} {{ $slot }}
</div> </div>
</div> </div>
@@ -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>
+35 -1
View File
@@ -7,6 +7,40 @@
</title> </title>
<link rel="icon" href="/favicon.ico" sizes="any"> <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']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance {{-- 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>
-109
View File
@@ -1,109 +0,0 @@
@extends('layouts.app')
@section('title', 'Upload recording')
@section('content')
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</p>
</div>
<form
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
>
@csrf
<div>
<label class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Audio files</label>
<div
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop($event)"
@click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50 dark:bg-teal-950/40' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40 dark:border-zinc-600 dark:bg-zinc-900/50 dark:hover:border-teal-500 dark:hover:bg-teal-950/30'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
>
<p class="text-sm font-medium text-stone-800 dark:text-zinc-100">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">or click to browse</p>
<p class="mt-3 text-xs text-stone-500 dark:text-zinc-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p>
</div>
<input
x-ref="fileInput"
id="audio"
type="file"
name="audio[]"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
multiple
class="sr-only"
@change="onBrowse($event)"
>
</div>
<div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700 dark:text-zinc-200">
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">
Clear all
</button>
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200 dark:divide-zinc-700 dark:border-zinc-700">
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800 dark:text-zinc-100" x-text="file.name"></p>
<p class="text-xs text-stone-500 dark:text-zinc-400" x-text="formatSize(file.size)"></p>
</div>
<button
type="button"
@click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700 dark:text-zinc-400 dark:hover:text-red-400"
>
Remove
</button>
</li>
</template>
</ul>
</div>
<div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Title (optional)</label>
<input
id="title"
type="text"
name="title"
value="{{ old('title') }}"
placeholder="Leave blank to use embedded title or filename"
class="mt-2 w-full 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 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
>
</div>
<p x-show="notice" x-cloak class="text-sm text-amber-800 dark:text-amber-300" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<div class="flex items-center gap-3">
<button
type="submit"
:disabled="files.length === 0 || uploading"
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"
>
<span x-text="uploadLabel"></span>
</button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">Cancel</a>
</div>
</form>
@endsection
-146
View File
@@ -1,146 +0,0 @@
@extends('layouts.app')
@section('title', 'Recordings')
@section('content')
@php
$indexRows = $recordings->map(function ($recording) {
return [
'id' => $recording->id,
'status' => $recording->transcription_status,
'status_label' => $recording->transcriptionStatusLabel(),
'progress' => $recording->transcription_progress,
'percent' => $recording->transcription_percent,
'is_active' => $recording->isTranscribing(),
'word_count' => $recording->word_count,
'word_count_display' => $recording->word_count > 0
? number_format($recording->word_count)
: '—',
'badge_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
},
];
})->values();
@endphp
<div
x-data="recordingsIndex(@js([
'recordings' => $indexRows,
'pendingCount' => $pendingCount ?? 0,
]))"
x-init="
start();
return () => destroy();
"
>
<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 dark:text-zinc-400">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<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 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-800 dark:hover:bg-zinc-700">
Search
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe-pending') }}"
x-show="pendingCount > 0"
x-cloak
>
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button>
</form>
</div>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center dark:border-zinc-600 dark:bg-zinc-800">
<p class="text-stone-600 dark:text-zinc-400">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<table class="min-w-full divide-y divide-stone-200 text-sm dark:divide-zinc-700">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500 dark:bg-zinc-900/50 dark:text-zinc-400">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100 dark:divide-zinc-700">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline dark:text-teal-300">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500 dark:text-zinc-400">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500 dark:text-zinc-400">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->duration_formatted }}</td>
<td
class="px-4 py-3 tabular-nums text-stone-600 dark:text-zinc-400"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="row({{ $recording->id }}).badge_class"
x-text="row({{ $recording->id }}).status_label"
>
{{ $recording->transcriptionStatusLabel() }}
</span>
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak
:title="row({{ $recording->id }}).progress"
>
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
<span x-text="row({{ $recording->id }}).progress"></span>
</div>
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
</div>
@endsection
@@ -1,21 +0,0 @@
@php
$label = $label ?? match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
};
@endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $label }}
</span>
-179
View File
@@ -1,179 +0,0 @@
@extends('layouts.app')
@section('title', $recording->title)
@section('content')
<div
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>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800 dark:text-zinc-400 dark:hover:text-zinc-200"> 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 dark:text-zinc-400">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass"
x-text="status.status_label || status.status"
></span>
<template x-if="status.driver_label">
<span class="text-xs text-stone-500 dark:text-zinc-400" x-text="status.driver_label"></span>
</template>
</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 dark:border-red-900 dark:bg-zinc-800 dark:text-red-300 dark:hover:bg-red-950">
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 dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">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 dark:text-zinc-400">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">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 dark:text-zinc-400">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 dark:text-zinc-400">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcribe</h2>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf
<button
type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"
>
<span x-text="startButtonLabel"></span>
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe.cancel', $recording) }}"
x-show="status.is_active"
x-cloak
class="mt-3"
>
@csrf
<button
type="submit"
class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Stop transcription
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcript</h2>
<button
type="button"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
class="text-sm text-teal-700 hover:underline dark:text-teal-300"
>
Copy
</button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100" x-text="status.progress || 'Working…'"></p>
<p class="tabular-nums text-amber-800 dark:text-amber-200">
<span x-text="(status.percent ?? 0) + '%'"></span>
<span class="mx-1 text-amber-600 dark:text-amber-400">·</span>
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
</p>
</div>
<div class="h-2 overflow-hidden rounded-full bg-amber-100 dark:bg-amber-900/50">
<div
class="h-full rounded-full bg-amber-500 transition-all duration-500"
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
></div>
</div>
<ul class="space-y-1 text-xs text-amber-900/80 dark:text-amber-200/80">
<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-amber-700 dark:text-amber-300">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-700 dark:text-red-300" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-300">
Transcription stopped. Choose an engine above to start again.
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
<p class="font-medium">Transcription failed</p>
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-zinc-100" x-text="status.transcript"></p>
<p
class="mt-4 text-xs text-stone-500 dark:text-zinc-400"
x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></p>
</div>
<p
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4 text-sm text-stone-500 dark:text-zinc-400"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</p>
</section>
</div>
@endsection
+4
View File
@@ -10,3 +10,7 @@ Broadcast::channel('recording.{recordingId}', function (User $user, int $recordi
->where('user_id', $user->id) ->where('user_id', $user->id)
->exists(); ->exists();
}); });
Broadcast::channel('user.{userId}.recordings', function (User $user, int $userId): bool {
return (int) $user->id === $userId;
});
+9 -11
View File
@@ -1,22 +1,20 @@
<?php <?php
use App\Http\Controllers\CancelTranscriptionController; use App\Http\Controllers\StreamRecordingController;
use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\TranscribePendingController;
use App\Http\Controllers\TranscriptionStatusController; use App\Http\Controllers\TranscriptionStatusController;
use App\Livewire\Recordings\Create;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings')->name('home'); Route::redirect('/', '/recordings')->name('home');
Route::middleware('auth')->group(function (): void { Route::middleware('auth')->group(function (): void {
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); Route::get('recordings', Index::class)->name('recordings.index');
Route::post('recordings/transcribe-pending', TranscribePendingController::class) Route::get('recordings/create', Create::class)->name('recordings.create');
->name('recordings.transcribe-pending'); Route::get('recordings/{recording}', Show::class)->name('recordings.show');
Route::post('recordings/{recording}/transcribe', TranscribeController::class) Route::get('recordings/{recording}/audio', StreamRecordingController::class)
->name('recordings.transcribe'); ->name('recordings.audio');
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
->name('recordings.transcribe.cancel');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status'); ->name('recordings.transcription-status');
}); });
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Deploy a pre-built APP_IMAGE to one or more AndyTranscribe directories.
Environment:
APP_IMAGE Required. e.g. gitea.z00.nu/ben/andytranscribe:abc1234
DEPLOY_PATHS Comma-separated instance directories (default: current directory)
DEPLOY_SHA Optional git SHA to hard-reset each directory to (CI sets this)
GIT_PULL When 1 (default) and DEPLOY_SHA is empty, run git pull --ff-only
Usage:
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:tag DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
EOF
}
IMAGE="${APP_IMAGE:-${1:-}}"
if [[ -z "${IMAGE}" ]]; then
usage >&2
exit 1
fi
PATHS_CSV="${DEPLOY_PATHS:-${2:-.}}"
GIT_PULL="${GIT_PULL:-1}"
DEPLOY_SHA="${DEPLOY_SHA:-}"
IFS=',' read -r -a DIRS <<< "${PATHS_CSV}"
for dir in "${DIRS[@]}"; do
dir="${dir#"${dir%%[![:space:]]*}"}"
dir="${dir%"${dir##*[![:space:]]}"}"
if [[ ! -d "${dir}" ]]; then
echo "Missing instance directory: ${dir}" >&2
exit 1
fi
name="$(basename "${dir}")"
echo "==> Deploying ${IMAGE} in ${dir}"
(
cd "${dir}"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if [[ -n "${DEPLOY_SHA}" ]]; then
git fetch --force origin "${DEPLOY_SHA}"
git checkout --force --detach "${DEPLOY_SHA}"
git reset --hard "${DEPLOY_SHA}"
# Drop local edits/hot-patches; keep runtime data and secrets.
git clean -fd \
--exclude=database/ \
--exclude=storage/ \
--exclude=.env \
--exclude=.env.*
elif [[ "${GIT_PULL}" == "1" ]]; then
git pull --ff-only
fi
fi
export APP_IMAGE="${IMAGE}"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-${name,,}}"
compose_files=(-f docker-compose.yml)
if [[ -f compose.z00.yaml ]]; then
compose_files+=(-f compose.z00.yaml)
fi
if grep -q '^APP_IMAGE=' .env 2>/dev/null; then
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${IMAGE}|" .env
else
printf '\nAPP_IMAGE=%s\n' "${IMAGE}" >> .env
fi
docker compose "${compose_files[@]}" pull app queue reverb
docker compose "${compose_files[@]}" up -d --remove-orphans
docker compose "${compose_files[@]}" ps
app_port="$(awk -F= '/^APP_HOST_PORT=/ {print $2; exit}' .env 2>/dev/null || true)"
app_port="${app_port:-18080}"
health_url="http://127.0.0.1:${app_port}/up"
echo "waiting for ${health_url}"
ok=0
for _ in $(seq 1 45); do
if curl -fsS "${health_url}" >/dev/null 2>&1; then
echo "healthy"
ok=1
break
fi
sleep 2
done
if [[ "${ok}" -ne 1 ]]; then
echo "ERROR: health check failed for ${name}" >&2
exit 1
fi
# Hit /login as the public HTTPS edge would (trustProxies needs forwarded proto).
app_url="$(awk -F= '/^APP_URL=/ {print $2; exit}' .env 2>/dev/null || true)"
app_url="${app_url:-https://transcribe.z00.nu}"
public_host="$(python3 - <<PY
from urllib.parse import urlparse
print(urlparse("${app_url}").hostname or "transcribe.z00.nu")
PY
)"
asset_scheme="$(
curl -fsS \
-H "X-Forwarded-Proto: https" \
-H "X-Forwarded-Host: ${public_host}" \
-H "X-Forwarded-Port: 443" \
"http://127.0.0.1:${app_port}/login" \
| grep -oE 'https?://[^"'\'' ]+\.css' \
| head -1 || true
)"
if [[ "${asset_scheme}" == http://* ]]; then
echo "ERROR: login page still emits http:// asset URLs (${asset_scheme})" >&2
exit 1
fi
echo "asset check ok: ${asset_scheme:-no absolute css url (relative/ok)}"
)
done
echo "Deploy complete: ${IMAGE}"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Idempotent bootstrap for AndyTranscribe Gitea Actions secrets on z00.
# Requires an already-running Gitea + act_runner (see airports setup).
set -euo pipefail
GITEA_DIR="${GITEA_DIR:-${HOME}/gitea}"
REPO_OWNER="${REPO_OWNER:-ben}"
REPO_NAME="${REPO_NAME:-AndyTranscribe}"
REGISTRY_HOST="${REGISTRY_HOST:-gitea.z00.nu}"
DEPLOY_PATH="${DEPLOY_PATH:-${HOME}/andyTranscibe}"
CREDENTIALS_FILE="${GITEA_DIR}/.credentials"
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
echo "Missing ${CREDENTIALS_FILE}" >&2
exit 1
fi
# shellcheck disable=SC1090
source "${CREDENTIALS_FILE}"
API="https://${REGISTRY_HOST}/api/v1"
AUTH=(-u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}")
if ! curl -fsS "${AUTH[@]}" "${API}/repos/${REPO_OWNER}/${REPO_NAME}" >/dev/null 2>&1; then
echo "Repository ${REPO_OWNER}/${REPO_NAME} not found on ${REGISTRY_HOST}" >&2
exit 1
fi
CI_TOKEN="$(
docker exec -u git gitea gitea admin user generate-access-token \
-u "${ADMIN_USERNAME}" \
-t "ci-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \
--scopes "write:package,read:package,write:repository,read:repository" \
--raw
)"
PULL_TOKEN="$(
docker exec -u git gitea gitea admin user generate-access-token \
-u "${ADMIN_USERNAME}" \
-t "pull-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \
--scopes "read:package" \
--raw
)"
set_secret() {
local name="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
python3 -c 'import json,sys; json.dump({"data": sys.argv[1]}, open(sys.argv[2], "w"))' "${value}" "${tmp}"
curl -fsS "${AUTH[@]}" -X PUT \
"${API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/secrets/${name}" \
-H "Content-Type: application/json" \
--data-binary @"${tmp}" >/dev/null
rm -f "${tmp}"
}
set_secret "REGISTRY_TOKEN" "${CI_TOKEN}"
set_secret "DEPLOY_PATHS" "${DEPLOY_PATH}"
printf '%s' "${PULL_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${ADMIN_USERNAME}" --password-stdin
REPO_LC="$(echo "${REPO_OWNER}/${REPO_NAME}" | tr '[:upper:]' '[:lower:]')"
if [[ -f "${DEPLOY_PATH}/.env" ]]; then
if grep -q '^APP_IMAGE=' "${DEPLOY_PATH}/.env"; then
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${REGISTRY_HOST}/${REPO_LC}:latest|" "${DEPLOY_PATH}/.env"
else
printf '\nAPP_IMAGE=%s/%s:latest\n' "${REGISTRY_HOST}" "${REPO_LC}" >> "${DEPLOY_PATH}/.env"
fi
fi
# Ensure the existing host runner is up (shared with airports).
if systemctl --user is-enabled gitea-act-runner.service >/dev/null 2>&1; then
systemctl --user restart gitea-act-runner.service || true
systemctl --user --no-pager --lines=5 status gitea-act-runner.service || true
fi
echo "Gitea CI secrets configured for ${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}"
echo "Deploy path: ${DEPLOY_PATH}"
echo "Push to main to build, push the image, and deploy."
+12
View File
@@ -24,12 +24,24 @@ class DemoUserSeederTest extends TestCase
$this->assertTrue(Hash::check('password', $user->password)); $this->assertTrue(Hash::check('password', $user->password));
} }
public function test_seeder_creates_admin_user(): void
{
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'admin@example.com')->first();
$this->assertNotNull($user);
$this->assertSame('Admin', $user->name);
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_is_idempotent(): void public function test_seeder_is_idempotent(): void
{ {
$this->seed(DatabaseSeeder::class); $this->seed(DatabaseSeeder::class);
$this->seed(DatabaseSeeder::class); $this->seed(DatabaseSeeder::class);
$this->assertSame(1, User::query()->where('email', 'demo@example.com')->count()); $this->assertSame(1, User::query()->where('email', 'demo@example.com')->count());
$this->assertSame(1, User::query()->where('email', 'admin@example.com')->count());
} }
public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void
+10 -3
View File
@@ -50,10 +50,17 @@ class DiskSpaceTest extends TestCase
{ {
Cache::flush(); Cache::flush();
$this->get(route('recordings.index')) $response = $this->get(route('recordings.index'))
->assertOk() ->assertOk()
->assertSee('Disk space') ->assertSee('Disk space', false)
->assertSee('% used', false)
->assertSee('free') ->assertSee('free')
->assertSee('role="progressbar"', false); ->assertSee('role="progressbar"', false)
->assertSee('background-color:', false);
$this->assertMatchesRegularExpression(
'/width:\s*[\d.]+%;\s*background-color:\s*#(0d9488|f59e0b|dc2626)/',
$response->getContent(),
);
} }
} }
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class RecordingAudioStreamTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/note.mp3', 'fake-audio-bytes');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertOk()
->assertHeader('content-type', 'audio/mpeg')
->assertHeader('content-disposition', 'inline; filename=note.mp3');
}
public function test_show_page_includes_audio_player(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Playable',
'original_filename' => 'playable.mp3',
'file_path' => 'recordings/playable.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSee('Play', false)
->assertSee(route('recordings.audio', $recording), false)
->assertSee('<audio', false);
}
public function test_index_page_links_to_recording_show(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'List playable',
'original_filename' => 'list.mp3',
'file_path' => 'recordings/list.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSee('List playable')
->assertSee(route('recordings.show', $recording), false);
}
public function test_other_user_cannot_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/secret.mp3', 'fake-audio-bytes');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Secret',
'original_filename' => 'secret.mp3',
'file_path' => 'recordings/secret.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($intruder)
->get(route('recordings.audio', $recording))
->assertForbidden();
}
public function test_missing_audio_file_returns_not_found(): void
{
Storage::fake('local');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Gone',
'original_filename' => 'gone.mp3',
'file_path' => 'recordings/gone.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertNotFound();
}
}
+25 -34
View File
@@ -3,12 +3,14 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class RecordingDuplicateUploadTest extends TestCase class RecordingDuplicateUploadTest extends TestCase
@@ -33,19 +35,18 @@ class RecordingDuplicateUploadTest extends TestCase
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes'); $first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes'); $duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
$this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$first], ->set('audio', [$first])
])->assertRedirect(); ->assertRedirect();
$this->assertSame(1, Recording::query()->count()); $this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1); Bus::assertDispatched(TranscribeRecording::class, 1);
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$duplicate], ->set('audio', [$duplicate])
]); ->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count()); $this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1); Bus::assertDispatched(TranscribeRecording::class, 1);
} }
@@ -59,33 +60,24 @@ class RecordingDuplicateUploadTest extends TestCase
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes'); $two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes'); $three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$one, $two, $three], ->set('audio', [$one, $two, $three])
]); ->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertStringContainsString('Skipped 1 duplicate', session('success')); $this->assertStringContainsString('Skipped 1 duplicate', session('success'));
$this->assertSame(2, Recording::query()->count()); $this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2); Bus::assertDispatched(TranscribeRecording::class, 2);
} }
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void public function test_upload_page_shows_dropzone(): void
{ {
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Existing',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 2048,
'content_hash' => str_repeat('a', 64),
'transcription_status' => 'done',
]);
$this->get(route('recordings.create')) $this->get(route('recordings.create'))
->assertOk() ->assertOk()
->assertSee('note.mp3:2048', false) ->assertSeeLivewire('upload-recordings')
->assertSee('Duplicate files', false); ->assertSee('Drop audio files here or click to browse')
->assertDontSee('Title (optional)')
->assertDontSee('Uploads start as soon as you drop');
} }
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
@@ -103,12 +95,11 @@ class RecordingDuplicateUploadTest extends TestCase
'transcription_status' => 'done', 'transcription_status' => 'done',
]); ]);
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')], ->set('audio', [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')])
]); ->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count()); $this->assertSame(1, Recording::query()->count());
Bus::assertNothingDispatched(); Bus::assertNothingDispatched();
} }
@@ -120,9 +111,9 @@ class RecordingDuplicateUploadTest extends TestCase
$file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash'); $file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash');
$this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$file], ->set('audio', [$file])
])->assertRedirect(); ->assertRedirect();
$recording = Recording::query()->first(); $recording = Recording::query()->first();
$this->assertNotNull($recording); $this->assertNotNull($recording);
+5 -2
View File
@@ -2,9 +2,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Livewire\Recordings\Show;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class RecordingOwnershipTest extends TestCase class RecordingOwnershipTest extends TestCase
@@ -74,8 +76,9 @@ class RecordingOwnershipTest extends TestCase
'transcription_status' => 'done', 'transcription_status' => 'done',
]); ]);
$this->actingAs($intruder) $this->actingAs($intruder);
->delete(route('recordings.destroy', $recording))
Livewire::test(Show::class, ['recording' => $recording])
->assertForbidden(); ->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]); $this->assertDatabaseHas('recordings', ['id' => $recording->id]);
+112 -48
View File
@@ -4,14 +4,19 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest; use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use App\Livewire\UploadRecordings;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User; use App\Models\User;
use App\Services\TranscriptionService; use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription; use Laravel\Ai\Transcription;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class RecordingUploadTest extends TestCase class RecordingUploadTest extends TestCase
@@ -54,18 +59,17 @@ class RecordingUploadTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg'); $file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$file], ->set('audio', [$file])
'title' => 'Team meeting', ->assertRedirect(route('recordings.show', Recording::query()->first()));
]);
$recording = Recording::query()->first(); $recording = Recording::query()->first();
$this->assertNotNull($recording); $this->assertNotNull($recording);
$response->assertRedirect(route('recordings.show', $recording)); $this->assertSame('meeting', $recording->title);
$this->assertSame('Team meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status); $this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
$this->assertNull($recording->transcription_percent);
$this->assertNotNull($recording->transcription_started_at); $this->assertNotNull($recording->transcription_started_at);
Storage::disk('local')->assertExists($recording->file_path); Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class); Bus::assertDispatched(TranscribeRecording::class);
@@ -76,16 +80,13 @@ class RecordingUploadTest extends TestCase
Storage::fake('local'); Storage::fake('local');
Bus::fake(); Bus::fake();
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [ ->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)), UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)), UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)), UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
], ])
]); ->assertRedirect(route('recordings.index'));
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertSame(3, Recording::query()->count()); $this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3); Bus::assertDispatched(TranscribeRecording::class, 3);
@@ -99,9 +100,9 @@ class RecordingUploadTest extends TestCase
{ {
$this->get(route('recordings.create')) $this->get(route('recordings.create'))
->assertOk() ->assertOk()
->assertSee('Drop audio files here') ->assertSee('Drop audio files here or click to browse')
->assertSee('max 2 GB each', false) ->assertSee('max 2 GB each', false)
->assertSee('name="audio[]"', false); ->assertSeeLivewire('upload-recordings');
} }
public function test_files_larger_than_two_gigabytes_are_rejected(): void public function test_files_larger_than_two_gigabytes_are_rejected(): void
@@ -113,11 +114,9 @@ class RecordingUploadTest extends TestCase
->create('huge.mp3', 10, 'audio/mpeg') ->create('huge.mp3', 10, 'audio/mpeg')
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1); ->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
$this->from(route('recordings.create')) Livewire::test(UploadRecordings::class)
->post(route('recordings.store'), [ ->set('audio', [$file])
'audio' => [$file], ->assertHasErrors(['audio.0']);
])
->assertSessionHasErrors(['audio.0']);
$this->assertSame(0, Recording::query()->count()); $this->assertSame(0, Recording::query()->count());
Bus::assertNothingDispatched(); Bus::assertNothingDispatched();
@@ -133,15 +132,14 @@ class RecordingUploadTest extends TestCase
['clip.ogg', 'audio/ogg', 'ogg-bytes'], ['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'], ['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) { ] as [$name, $mime, $contents]) {
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)], ->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
'title' => $name, ->assertRedirect();
]);
$recording = Recording::query()->where('title', $name)->first(); $expectedTitle = pathinfo($name, PATHINFO_FILENAME);
$recording = Recording::query()->where('title', $expectedTitle)->first();
$this->assertNotNull($recording, "Failed uploading {$name}"); $this->assertNotNull($recording, "Failed uploading {$name}");
$response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path); Storage::disk('local')->assertExists($recording->file_path);
} }
@@ -152,11 +150,9 @@ class RecordingUploadTest extends TestCase
{ {
Storage::fake('local'); Storage::fake('local');
$this->from(route('recordings.create')) Livewire::test(UploadRecordings::class)
->post(route('recordings.store'), [ ->set('audio', [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')])
'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')], ->assertHasErrors(['audio.0']);
])
->assertSessionHasErrors(['audio.0']);
} }
public function test_user_can_queue_local_transcription(): void public function test_user_can_queue_local_transcription(): void
@@ -173,8 +169,8 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider. // Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']); Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect(); ->call('startTranscription');
$recording->refresh(); $recording->refresh();
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -214,6 +210,8 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Hello from the recorder.']); Transcription::fake(['Hello from the recorder.']);
$this->travelTo(now()->startOfSecond());
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'user_id' => $this->user->id, 'user_id' => $this->user->id,
'title' => 'Sample', 'title' => 'Sample',
@@ -222,6 +220,7 @@ class RecordingUploadTest extends TestCase
'file_size_bytes' => 12, 'file_size_bytes' => 12,
'transcription_status' => 'pending', 'transcription_status' => 'pending',
'transcription_driver' => 'local', 'transcription_driver' => 'local',
'transcription_started_at' => now()->subSeconds(42),
]); ]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class)); (new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
@@ -232,6 +231,12 @@ class RecordingUploadTest extends TestCase
$this->assertSame(100, $recording->transcription_percent); $this->assertSame(100, $recording->transcription_percent);
$this->assertSame('Transcription complete', $recording->transcription_progress); $this->assertSame('Transcription complete', $recording->transcription_progress);
$this->assertNotNull($recording->transcribed_at); $this->assertNotNull($recording->transcribed_at);
$this->assertSame(
$recording->transcribed_at->getTimestamp() - $recording->transcription_started_at->getTimestamp(),
$recording->transcription_duration_seconds,
);
$this->assertSame(42, $recording->transcription_duration_seconds);
$this->assertSame('42s', $recording->transcriptionStatusPayload()['transcription_duration_human']);
} }
public function test_transcription_job_stores_error_on_failure(): void public function test_transcription_job_stores_error_on_failure(): void
@@ -288,7 +293,70 @@ class RecordingUploadTest extends TestCase
$recording->refresh(); $recording->refresh();
$this->assertSame('failed', $recording->transcription_status); $this->assertSame('failed', $recording->transcription_status);
$this->assertStringContainsString('worker stopped', $recording->transcription_error); $this->assertStringContainsString('timed out', $recording->transcription_error);
}
public function test_stale_reserved_job_is_treated_as_orphaned(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Wedged whisper',
'original_filename' => 'wedged.mp3',
'file_path' => 'recordings/wedged.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$job = new TranscribeRecording($recording);
$payload = json_encode([
'displayName' => TranscribeRecording::class,
'data' => [
'command' => serialize($job),
],
], JSON_THROW_ON_ERROR);
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => $payload,
'attempts' => 1,
'reserved_at' => now()->subMinutes(15)->timestamp,
'available_at' => now()->subMinutes(20)->timestamp,
'created_at' => now()->subMinutes(20)->timestamp,
]);
$this->assertFalse($recording->hasActiveTranscriptionJob());
$this->assertTrue($recording->isOrphanedTranscription());
$deleted = $recording->discardQueuedTranscriptionJobs();
$this->assertSame(1, $deleted, 'stale reserved job should be discarded');
$this->assertDatabaseCount('jobs', 0);
// Put the stale job back to exercise status-poll recovery.
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => $payload,
'attempts' => 1,
'reserved_at' => now()->subMinutes(15)->timestamp,
'available_at' => now()->subMinutes(20)->timestamp,
'created_at' => now()->subMinutes(20)->timestamp,
]);
$recording->forceFill([
'transcription_status' => 'processing',
'transcription_error' => null,
])->save();
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'failed');
$this->assertDatabaseCount('jobs', 0);
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
} }
public function test_recent_processing_is_not_marked_orphaned(): void public function test_recent_processing_is_not_marked_orphaned(): void
@@ -332,8 +400,8 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10), 'updated_at' => now()->subMinutes(10),
]); ]);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect(); ->call('startTranscription');
$recording->refresh(); $recording->refresh();
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -355,9 +423,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now(), 'transcription_started_at' => now(),
]); ]);
$this->post(route('recordings.transcribe.cancel', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect() ->call('cancelTranscription');
->assertSessionHas('success');
$recording->refresh(); $recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status); $this->assertSame('cancelled', $recording->transcription_status);
@@ -380,9 +447,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(), 'transcription_started_at' => now()->subMinute(),
]); ]);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect() ->call('startTranscription');
->assertSessionHas('success');
$recording->refresh(); $recording->refresh();
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -464,8 +530,8 @@ class RecordingUploadTest extends TestCase
'transcribed_at' => now()->subHour(), 'transcribed_at' => now()->subHour(),
]); ]);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect(); ->call('startTranscription');
$recording->refresh(); $recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript); $this->assertSame('Old transcript text.', $recording->transcript);
@@ -497,10 +563,8 @@ class RecordingUploadTest extends TestCase
'transcript' => 'Finished text', 'transcript' => 'Finished text',
]); ]);
$this->from(route('recordings.index')) Livewire::test(Index::class)
->post(route('recordings.transcribe-pending')) ->call('queuePending');
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
Bus::assertDispatched(TranscribeRecording::class, 1); Bus::assertDispatched(TranscribeRecording::class, 1);
} }
+340
View File
@@ -0,0 +1,340 @@
<?php
namespace Tests\Feature\Recordings;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class IndexTest extends TestCase
{
use RefreshDatabase;
public function test_index_page_renders_as_livewire(): void
{
$user = User::factory()->create();
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024,
'transcription_status' => 'done',
'transcript' => 'hello world',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire(Index::class)
->assertSee('Pocket note');
}
public function test_empty_index_shows_upload_dropzone(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire('upload-recordings')
->assertSee('Drop audio files here or click to browse')
->assertDontSee('No recordings yet.')
->assertDontSee('Upload your first MP3');
}
public function test_search_filters_recordings(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => "talking about the pocket recorder today\nsecond line stays hidden",
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Unrelated',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'nothing useful',
]);
Livewire::test(Index::class)
->set('search', 'pocket recorder')
->assertSee('Office chat')
->assertSee('talking about the pocket recorder today')
->assertDontSee('second line stays hidden')
->assertDontSee('Unrelated');
}
public function test_queue_pending_dispatches_jobs(): void
{
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
public function test_index_polls_while_transcriptions_are_active(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'In progress',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('wire:poll', false)
->assertSee('Transcribing')
->assertDontSee('Transcribing locally…')
->assertSeeHtml('bg-amber-400');
}
public function test_queued_recordings_do_not_show_fake_percent(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Waiting in line',
'original_filename' => 'queued.mp3',
'file_path' => 'recordings/queued.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('Waiting in line')
->assertSee('Queued')
->assertDontSee('Queued — waiting to start…')
->assertDontSee('5%')
->assertSeeHtml('bg-zinc-400/15')
->assertDontSeeHtml('bg-amber-400');
}
public function test_index_does_not_poll_when_all_transcriptions_are_idle(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Finished',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'all done',
]);
Livewire::test(Index::class)
->assertDontSee('wire:poll', false);
}
public function test_user_can_delete_recording_from_index(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete from list',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->assertSee('Delete from list')
->call('delete', $recording->id)
->assertDontSee('Delete from list');
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
Storage::disk('local')->assertMissing('recordings/delete-me.mp3');
}
public function test_user_can_delete_all_recordings_from_index(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/one.mp3', 'one');
Storage::disk('local')->put('recordings/two.mp3', 'two');
Storage::disk('local')->put('recordings/other.mp3', 'other');
$user = User::factory()->create();
$other = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Mine one',
'original_filename' => 'one.mp3',
'file_path' => 'recordings/one.mp3',
'file_size_bytes' => 3,
'transcription_status' => 'done',
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Mine two',
'original_filename' => 'two.mp3',
'file_path' => 'recordings/two.mp3',
'file_size_bytes' => 3,
'transcription_status' => 'pending',
]);
Recording::query()->create([
'user_id' => $other->id,
'title' => 'Someone else',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->assertSee('Delete all')
->call('deleteAll')
->assertDontSee('Mine one')
->assertDontSee('Mine two');
$this->assertDatabaseMissing('recordings', ['user_id' => $user->id]);
$this->assertDatabaseHas('recordings', ['user_id' => $other->id, 'title' => 'Someone else']);
Storage::disk('local')->assertMissing('recordings/one.mp3');
Storage::disk('local')->assertMissing('recordings/two.mp3');
Storage::disk('local')->assertExists('recordings/other.mp3');
}
public function test_user_cannot_delete_another_users_recording_from_index(): void
{
Storage::fake('local');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder);
try {
Livewire::test(Index::class)
->call('delete', $recording->id);
$this->fail('Expected deleting another user\'s recording to fail.');
} catch (ModelNotFoundException) {
// Owned-query findOrFail hides other users' recordings.
}
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
}
public function test_recordings_can_be_sorted_by_title(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Zebra',
'original_filename' => 'z.mp3',
'file_path' => 'recordings/z.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'created_at' => now()->subDay(),
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Alpha',
'original_filename' => 'a.mp3',
'file_path' => 'recordings/a.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'created_at' => now(),
]);
Livewire::test(Index::class)
->call('sort', 'title')
->assertSet('sortBy', 'title')
->assertSet('sortDirection', 'asc')
->assertSeeInOrder(['Alpha', 'Zebra'])
->call('sort', 'title')
->assertSet('sortDirection', 'desc')
->assertSeeInOrder(['Zebra', 'Alpha']);
}
public function test_invalid_sort_column_is_ignored(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Only one',
'original_filename' => 'one.mp3',
'file_path' => 'recordings/one.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->call('sort', 'not_a_column')
->assertSet('sortBy', 'uploaded')
->assertSet('sortDirection', 'desc');
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature\Recordings;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class ShowTest extends TestCase
{
use RefreshDatabase;
public function test_show_page_renders_as_livewire(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Show me',
'original_filename' => 'show.mp3',
'file_path' => 'recordings/show.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSeeLivewire(Show::class)
->assertSee('Show me')
->assertSee('Play', false);
}
public function test_user_can_delete_own_recording(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete me',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('delete')
->assertRedirect(route('recordings.index'));
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
}
public function test_show_polls_while_transcription_is_active(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'In progress',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertSee('wire:poll', false)
->assertSee('Queued — waiting to start…');
}
public function test_show_does_not_poll_when_transcription_is_idle(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Finished',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'all done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertDontSee('wire:poll', false);
}
public function test_show_refreshes_recording_on_transcription_broadcast(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Live update',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$component = Livewire::test(Show::class, ['recording' => $recording]);
$recording->update([
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
]);
$component
->call('onTranscriptionUpdated', [
'id' => $recording->id,
'status' => 'processing',
])
->assertSet('recording.transcription_status', 'processing')
->assertSet('recording.transcription_percent', 40)
->assertSee('Transcribing locally…');
}
}
@@ -158,5 +158,6 @@ class TranscriptionBroadcastTest extends TestCase
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs()); $this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name); $this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
} }
} }
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class UploadRecordingsLivewireTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_dropping_files_saves_and_queues_transcription(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_batch_upload_redirects_to_index(): void
{
Storage::fake('local');
Bus::fake();
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
])
->assertRedirect(route('recordings.index'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
}
+7 -1
View File
@@ -6,5 +6,11 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
// protected function setUp(): void
{
parent::setUp();
// Feature tests render Blade without a Vite build in CI.
$this->withoutVite();
}
} }