Compare commits
12
Commits
b7c36b8b3b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1877fee258 | ||
|
|
b3fb74fb1b | ||
|
|
f42d124593 | ||
|
|
2b126ee4e6 | ||
|
|
ab14f5e452 | ||
|
|
761f1a1f78 | ||
|
|
5dd0a3aeac | ||
|
|
4898d5cfde | ||
|
|
f65c816464 | ||
|
|
d60851bb53 | ||
|
|
8a66cf6f63 | ||
|
|
5f0f61995c |
+7
-6
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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'));
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('recordings', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('transcription_duration_seconds')
|
||||||
|
->nullable()
|
||||||
|
->after('transcribed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('recordings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('transcription_duration_seconds');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+13
-8
@@ -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
|
||||||
|
|||||||
+17
-2
@@ -36,7 +36,22 @@ if [ ! -f vendor/autoload.php ]; then
|
|||||||
composer install --prefer-dist --no-interaction
|
composer install --prefer-dist --no-interaction
|
||||||
fi
|
fi
|
||||||
|
|
||||||
php artisan migrate --force --no-interaction
|
# Only the web app should migrate/seed. Queue and Reverb share the DB and must
|
||||||
php artisan db:seed --force --no-interaction
|
# not race on sqlite (locks) or re-seed on every restart/deploy.
|
||||||
|
should_bootstrap_db() {
|
||||||
|
case " $* " in
|
||||||
|
*" queue:work "*|*" queue:listen "*|*" reverb:start "*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_bootstrap_db "$@"; then
|
||||||
|
php artisan migrate --force --no-interaction
|
||||||
|
php artisan db:seed --force --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -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,12 +138,14 @@ 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
|
||||||
&& !this.status.has_transcript
|
&& ! this.status.has_transcript
|
||||||
&& this.status.status !== 'failed'
|
&& this.status.status !== 'failed'
|
||||||
&& this.status.status !== 'cancelled') {
|
&& this.status.status !== 'cancelled') {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -158,26 +167,47 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
tickElapsed() {
|
beginHydratePoll() {
|
||||||
if (!this.status.is_active || this.status.elapsed_seconds == null) {
|
if (this.hydrateTimer || ! this.statusUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.status.elapsed_seconds += 1;
|
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
|
||||||
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
|
},
|
||||||
|
|
||||||
|
stopHydratePoll() {
|
||||||
|
if (this.hydrateTimer) {
|
||||||
|
clearInterval(this.hydrateTimer);
|
||||||
|
this.hydrateTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
tickElapsed() {
|
||||||
|
if (! this.status.is_active || this.status.elapsed_seconds == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = this.status.elapsed_seconds + 1;
|
||||||
|
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
elapsed_seconds: next,
|
||||||
|
elapsed_human: formatElapsed(next),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async hydrateOnce() {
|
async hydrateOnce() {
|
||||||
if (!this.statusUrl) {
|
if (! this.statusUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
throw new Error('Status request failed (' + response.status + ')');
|
throw new Error('Status request failed (' + response.status + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -7,7 +13,7 @@
|
|||||||
start();
|
start();
|
||||||
return () => destroy();
|
return () => destroy();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -222,4 +231,5 @@
|
|||||||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||||||
</flux:text>
|
</flux:text>
|
||||||
</flux:card>
|
</flux:card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Executable
+122
@@ -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}"
|
||||||
Executable
+80
@@ -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."
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user