Compare commits

12 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
28 changed files with 1081 additions and 191 deletions
+7 -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.*
+4 -2
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
+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
+7 -8
View File
@@ -9,8 +9,7 @@ WORKDIR /app
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \ RUN composer install \
composer install \
--no-dev \ --no-dev \
--no-scripts \ --no-scripts \
--no-autoloader \ --no-autoloader \
@@ -18,7 +17,7 @@ RUN --mount=type=cache,target=/tmp/cache \
--no-interaction --no-interaction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# npm ci only (parallel with vendor — does not wait on Composer) # npm ci only (parallel with vendor when BuildKit is available)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM node:22-bookworm AS npm FROM node:22-bookworm AS npm
@@ -26,8 +25,7 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \ RUN npm ci
npm ci
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vite production build (needs Flux/Livewire + Laravel pagination views) # Vite production build (needs Flux/Livewire + Laravel pagination views)
@@ -85,14 +83,14 @@ WORKDIR /app
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) # Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
COPY . . COPY . .
# Built frontend assets # 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 \
@@ -101,6 +99,7 @@ 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
EXPOSE 80 EXPOSE 80
+33 -1
View File
@@ -125,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):
@@ -146,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 |
@@ -173,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`.
+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;
} }
+70 -1
View File
@@ -5,6 +5,8 @@ namespace App\Livewire\Recordings;
use App\Models\Recording; use App\Models\Recording;
use Flux\Flux; use Flux\Flux;
use Illuminate\Contracts\View\View; 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\Auth;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
@@ -25,9 +27,26 @@ class Index extends Component
#[Url(as: 'q', history: true)] #[Url(as: 'q', history: true)]
public string $search = ''; 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 public function mount(): void
{ {
$this->userId = (int) Auth::id(); $this->userId = (int) Auth::id();
$this->normalizeSort();
} }
public function updatedSearch(): void public function updatedSearch(): void
@@ -35,6 +54,22 @@ class Index extends Component
$this->resetPage(); $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. * Re-render when any of this user's recordings broadcast a status change.
*/ */
@@ -127,7 +162,9 @@ class Index extends Component
$totalCount = $user->recordings()->count(); $totalCount = $user->recordings()->count();
$query = $user->recordings()->latest(); $this->normalizeSort();
$query = $user->recordings();
$search = trim($this->search); $search = trim($this->search);
@@ -135,6 +172,8 @@ class Index extends Component
$query->search($search); $query->search($search);
} }
$this->applySort($query);
$recordings = $query->paginate(20); $recordings = $query->paginate(20);
$pendingCount = $user->recordings() $pendingCount = $user->recordings()
@@ -155,4 +194,34 @@ class Index extends Component
'totalCount' => $totalCount, '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');
}
} }
+23
View File
@@ -7,6 +7,7 @@ use Flux\Flux;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
@@ -14,6 +15,8 @@ class Show extends Component
{ {
public Recording $recording; public Recording $recording;
public int $userId;
public function mount(Recording $recording): void public function mount(Recording $recording): void
{ {
Gate::authorize('view', $recording); Gate::authorize('view', $recording);
@@ -22,6 +25,22 @@ class Show extends Component
$recording->refresh(); $recording->refresh();
$this->recording = $recording; $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 public function startTranscription(): void
@@ -64,6 +83,10 @@ class Show extends Component
public function render(): View public function render(): View
{ {
if ($this->recording->isTranscribing()) {
$this->recording->refresh();
}
return view('livewire.recordings.show') return view('livewire.recordings.show')
->title($this->recording->title); ->title($this->recording->title);
} }
+95 -39
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',
@@ -138,6 +140,7 @@ class Recording extends Model
'transcription_percent' => null, '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,
]); ]);
@@ -228,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;
}); });
} }
@@ -263,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.'\\"');
} }
/** /**
@@ -332,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();
@@ -367,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.
* *
@@ -400,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.
*/ */
@@ -420,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
{ {
@@ -428,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;
@@ -519,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',
@@ -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');
});
}
};
+13 -8
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,11 +77,10 @@ services:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
# Shares andytranscribe-app:latest — do not declare build: here (avoids rebuilding 3×). # 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. # `docker compose up --build` builds `app` first, then starts these with the tagged image.
queue: queue:
image: andytranscribe-app:latest <<: *app-image
pull_policy: never
container_name: andytranscribe-queue container_name: andytranscribe-queue
command: command:
- php - php
@@ -101,8 +107,7 @@ services:
restart: unless-stopped restart: unless-stopped
reverb: reverb:
image: andytranscribe-app:latest <<: *app-image
pull_policy: never
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 "$@"
+33 -3
View File
@@ -66,12 +66,16 @@ 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');
}
}; };
} }
/** /**
* 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 {
@@ -82,6 +86,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}, },
pollError: null, pollError: null,
tickTimer: null, tickTimer: null,
hydrateTimer: null,
leaveChannel: null, leaveChannel: null,
get badgeColor() { get badgeColor() {
@@ -108,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();
@@ -131,8 +138,10 @@ export function transcriptionMonitor({ statusUrl, initial }) {
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
@@ -158,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() {
@@ -175,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) {
@@ -1,24 +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 <div
class="flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50" 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'] }} · {{ $disk['used_percent'] }}% used" title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $usedPercentLabel }}% used"
> >
<div <div
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700" 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"
> >
<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>
<span class="hidden text-xs font-medium tabular-nums text-zinc-600 sm:inline dark:text-zinc-300"> <span class="text-xs font-medium tabular-nums text-zinc-600 dark:text-zinc-300">
{{ $disk['free_human'] }} free {{ $usedPercentLabel }}% used · {{ $disk['free_human'] }} free
</span> </span>
</div> </div>
@@ -92,24 +92,68 @@
<flux:table :paginate="$recordings"> <flux:table :paginate="$recordings">
<flux:table.columns> <flux:table.columns>
<flux:table.column class="w-12"></flux:table.column> <flux:table.column
<flux:table.column>Title</flux:table.column> sortable
<flux:table.column>Duration</flux:table.column> :sorted="$sortBy === 'title'"
<flux:table.column>Words</flux:table.column> :direction="$sortDirection"
<flux:table.column>Status</flux:table.column> wire:click="sort('title')"
<flux:table.column>Uploaded</flux:table.column> >
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.column class="w-24"></flux:table.column>
</flux:table.columns> </flux:table.columns>
<flux:table.rows> <flux:table.rows>
@foreach ($recordings as $recording) @foreach ($recordings as $recording)
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}"> <flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
<flux:table.cell> <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 <flux:button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
square square
class="shrink-0"
data-audio-url="{{ route('recordings.audio', $recording) }}" data-audio-url="{{ route('recordings.audio', $recording) }}"
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'" x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)" x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
@@ -124,16 +168,6 @@
x-cloak x-cloak
/> />
</flux:button> </flux:button>
</flux:table.cell>
<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>
@if ($preview = $recording->transcriptFirstLine()) @if ($preview = $recording->transcriptFirstLine())
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}"> <span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
{{ $preview }} {{ $preview }}
@@ -147,29 +181,11 @@
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }} {{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</span> </span>
</flux:table.cell> </flux:table.cell>
<flux:table.cell class="whitespace-normal py-2"> <flux:table.cell class="w-36 whitespace-nowrap">
<x-transcription-status-badge <x-transcription-status-badge
:status="$recording->transcription_status" :status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()" :label="$recording->transcriptionStatusLabel()"
/> />
@if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress))
<div
class="mt-1 max-w-[14rem] truncate text-xs text-zinc-500 dark:text-zinc-400"
title="{{ $recording->transcription_progress }}"
>
{{ $recording->transcription_progress }}
</div>
@elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress))
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
title="{{ $recording->transcription_progress }}"
>
@if ($recording->transcription_percent)
{{ $recording->transcription_percent }}% ·
@endif
{{ $recording->transcription_progress }}
</div>
@endif
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
{{ $recording->created_at?->format('Y-m-d H:i') }} {{ $recording->created_at?->format('Y-m-d H:i') }}
@@ -1,4 +1,10 @@
<div <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([ x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording), 'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(), 'initial' => $recording->transcriptionStatusPayload(),
@@ -210,7 +216,10 @@
<flux:text <flux:text
class="mt-4 text-xs" class="mt-4 text-xs"
x-show="status.transcribed_at" x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''" x-text="status.transcribed_at
? ('Transcribed ' + formatTimestamp(status.transcribed_at)
+ (status.transcription_duration_human ? (' · took ' + status.transcription_duration_human) : ''))
: ''"
></flux:text> ></flux:text>
</div> </div>
@@ -223,3 +232,4 @@
</flux:text> </flux:text>
</flux:card> </flux:card>
</div> </div>
</div>
+33 -1
View File
@@ -11,4 +11,36 @@
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" /> <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>
+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."
+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 used', false) ->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(),
);
} }
} }
+74 -1
View File
@@ -13,6 +13,7 @@ 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 Livewire\Livewire;
@@ -209,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',
@@ -217,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));
@@ -227,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
@@ -283,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
+58 -3
View File
@@ -125,8 +125,8 @@ class IndexTest extends TestCase
Livewire::test(Index::class) Livewire::test(Index::class)
->assertSee('wire:poll', false) ->assertSee('wire:poll', false)
->assertSee('Transcribing locally…') ->assertSee('Transcribing')
->assertSee('40%') ->assertDontSee('Transcribing locally…')
->assertSeeHtml('bg-amber-400'); ->assertSeeHtml('bg-amber-400');
} }
@@ -151,7 +151,7 @@ class IndexTest extends TestCase
Livewire::test(Index::class) Livewire::test(Index::class)
->assertSee('Waiting in line') ->assertSee('Waiting in line')
->assertSee('Queued') ->assertSee('Queued')
->assertSee('Queued — waiting to start…') ->assertDontSee('Queued — waiting to start…')
->assertDontSee('5%') ->assertDontSee('5%')
->assertSeeHtml('bg-zinc-400/15') ->assertSeeHtml('bg-zinc-400/15')
->assertDontSeeHtml('bg-amber-400'); ->assertDontSeeHtml('bg-amber-400');
@@ -282,4 +282,59 @@ class IndexTest extends TestCase
$this->assertDatabaseHas('recordings', ['id' => $recording->id]); $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');
}
} }
+77
View File
@@ -59,4 +59,81 @@ class ShowTest extends TestCase
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]); $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…');
}
} }
+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();
}
} }