Add Docker stack, Reverb live progress, and duplicate upload detection.

Ship FrankenPHP Compose services with Reverb WebSockets for real-time transcription status, skip re-uploading identical audio via content hash, and format disk usage without requiring intl.
This commit is contained in:
ben
2026-08-12 17:35:48 +02:00
parent 352b564f3a
commit 771ee8db5a
44 changed files with 2581 additions and 424 deletions
+32
View File
@@ -0,0 +1,32 @@
.git
.gitattributes
.github
.idea
.vscode
.cursor
.claude
.ai
node_modules
vendor
public/build
public/hot
storage/app/private/**
storage/app/public/**
storage/logs/**
storage/framework/cache/**
storage/framework/sessions/**
storage/framework/views/**
database/*.sqlite*
.env
.env.*
!.env.example
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
tests
docs
*.md
!README.md
+25 -3
View File
@@ -2,7 +2,7 @@ APP_NAME=AndyTranscribe
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_URL=http://localhost:8080
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
@@ -64,6 +64,21 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Laravel Reverb (WebSockets). Browser uses VITE_*; server publish uses REVERB_HOST.
REVERB_APP_ID=andytranscribe
REVERB_APP_KEY=andytranscribe-key
REVERB_APP_SECRET=andytranscribe-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# AndyTranscribe / local faster-whisper
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
LOCAL_WHISPER_API_KEY=not-needed
@@ -72,5 +87,12 @@ TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660
# Host port for docker compose whisper service
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
# Browser-facing Reverb host/port (used at Vite build time in Docker)
VITE_REVERB_HOST=localhost
VITE_REVERB_SCHEME=http
+72
View File
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY vite.config.js ./
COPY resources ./resources
COPY public ./public
ARG VITE_APP_NAME=AndyTranscribe
ARG VITE_REVERB_APP_KEY=andytranscribe-key
ARG VITE_REVERB_HOST=localhost
ARG VITE_REVERB_PORT=8081
ARG VITE_REVERB_SCHEME=http
ENV VITE_APP_NAME=$VITE_APP_NAME \
VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY \
VITE_REVERB_HOST=$VITE_REVERB_HOST \
VITE_REVERB_PORT=$VITE_REVERB_PORT \
VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
RUN npm run build
FROM dunglas/frankenphp:php8.5-bookworm
RUN install-php-extensions \
pcntl \
pdo_sqlite \
sqlite3 \
zip \
bcmath \
intl \
opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \
&& mkdir -p \
storage/app/private \
storage/app/public \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
database \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache database
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["frankenphp", "php-server", "--listen", ":80", "--root", "/app/public"]
+59 -82
View File
@@ -2,7 +2,7 @@
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker. Audio never leaves your machine.
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
Built with Laravel 13, Blade, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai).
## Features
@@ -10,65 +10,70 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
- Search recordings by title, artist, or transcript
- Queued local transcription (faster-whisper in Docker)
- Live transcription progress (stage, %, elapsed time)
- Live transcription progress over WebSockets (Reverb) on the list and detail pages
- Stop or restart a run anytime
- Copy finished transcripts from the recording detail page
## Requirements
- PHP 8.3+ (8.5 recommended)
- Composer
- Node.js & npm
- SQLite (default) or another supported database
- [Docker](https://docs.docker.com/get-docker/) for the Whisper container
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose (primary way to run the app)
- For native PHP development: PHP 8.3+ (8.5 recommended), Composer, Node.js & npm, SQLite
## Setup
## Quick start (Docker + FrankenPHP)
```bash
composer setup
```
That installs PHP and JS dependencies, copies `.env` if needed, generates the app key, runs migrations, and builds frontend assets.
Or step by step:
```bash
composer install
cp .env.example .env
php artisan key:generate
touch database/database.sqlite # if using SQLite
php artisan migrate
npm install
npm run build
# Set a key (required by the app container):
php artisan key:generate # or: docker run --rm -v "$PWD":/app -w /app composer:2 php artisan key:generate
docker compose up --build
```
Open [http://localhost:8080/recordings](http://localhost:8080/recordings).
| Service | Host port | Role |
| --- | --- | --- |
| `app` | `8080` | FrankenPHP (Laravel) |
| `reverb` | `8081` | WebSockets for live status |
| `whisper` | `8090` | faster-whisper API |
| `queue` | — | `queue:work` for transcription jobs |
## Local Whisper (Docker)
### Persistent data mounts
Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that:
These host directories are bind-mounted into `app`, `queue`, and `reverb`:
| Host path | Container path | Contents |
| --- | --- | --- |
| `./database` | `/app/database` | SQLite database |
| `./storage/app` | `/app/storage/app` | Uploaded audio (`private/recordings`) |
| `./storage/logs` | `/app/storage/logs` | Application logs |
Whisper model cache uses the named volume `whisper-huggingface-cache`.
### GPU Whisper (optional)
```bash
# CPU (works everywhere; slower on long files)
docker compose up -d whisper
# Optional: NVIDIA GPU
docker compose --profile gpu up -d whisper-gpu
docker compose --profile gpu up -d --build
```
First start downloads the model into a Docker volume (can take a few minutes).
Point `LOCAL_WHISPER_URL` at the GPU service if you run it instead of the CPU `whisper` service.
Check it:
### Useful Compose env
```bash
curl -s http://127.0.0.1:8090/health
```
Copy values from `.env.example`. Important Docker-oriented variables:
Laravel talks to it at `LOCAL_WHISPER_URL` (default `http://127.0.0.1:8090/v1`). Port **8090** is used so it does not conflict with `php artisan serve` on 8000.
| Variable | Purpose |
| --- | --- |
| `APP_KEY` | Required — containers refuse to start without it |
| `APP_URL` | Default `http://localhost:8080` |
| `APP_HOST_PORT` | Host port for FrankenPHP (default `8080`) |
| `REVERB_HOST_PORT` | Host port for Reverb WebSockets (default `8081`) |
| `WHISPER_HOST_PORT` | Host port for Whisper (default `8090`) |
| `REVERB_APP_*` | Reverb credentials (baked into frontend at image build for `VITE_REVERB_*`) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
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`.
Stop:
@@ -76,60 +81,34 @@ Stop:
docker compose down
```
## Configuration
Copy values from `.env.example`. The transcription-related settings are:
| Variable | Purpose |
| ----------------------- | -------------------------------------------------------------------------------- |
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) |
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) |
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript).
Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`).
## Running locally
Start Whisper, then the app stack:
## Native PHP development (optional)
```bash
docker compose up -d whisper
composer run dev
```
Or separately:
```bash
docker compose up -d whisper
php artisan serve
composer setup
docker compose up -d whisper reverb
# In separate terminals:
php artisan serve --port=8000
php artisan queue:work
php artisan reverb:start
npm run dev
```
Open [http://localhost:8000/recordings](http://localhost:8000/recordings).
Set `APP_URL=http://localhost:8000`, `LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1`, and Reverb `REVERB_HOST=localhost` / `REVERB_PORT=8080` (match `VITE_REVERB_*`).
Transcription jobs are queued — keep a queue worker running or jobs will stay pending.
## Configuration
Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript).
`DB_QUEUE_RETRY_AFTER` must exceed `TRANSCRIPTION_TIMEOUT` so long Whisper jobs are not re-queued mid-run.
## Usage
1. **Upload** audio from Recordings → Upload (single file or batch dropzone).
2. Transcription queues automatically — keep `php artisan queue:work` running.
3. Watch live progress on the recording page (or stop and restart).
2. Transcription queues automatically — the Compose `queue` service (or `php artisan queue:work`) must be running.
3. Watch live progress on the recordings list or detail page (Reverb); stop and restart anytime.
4. Search the list by title, artist, or transcript text.
5. If older uploads still show **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
## Tests
```bash
@@ -138,8 +117,6 @@ composer test
php artisan test
```
## License
MIT
MIT
@@ -0,0 +1,50 @@
<?php
namespace App\Events;
use App\Models\Recording;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RecordingTranscriptionUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Recording $recording) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('recordings'),
];
}
public function broadcastAs(): string
{
return 'RecordingTranscriptionUpdated';
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return array_merge(
$this->recording->transcriptionStatusPayload(),
[
'word_count' => $this->recording->word_count,
],
);
}
}
+73 -4
View File
@@ -49,7 +49,19 @@ class RecordingController extends Controller
*/
public function create(): View
{
return view('recordings.create');
$existingFingerprints = Recording::query()
->get(['original_filename', 'file_size_bytes'])
->map(fn (Recording $recording) => $this->uploadFingerprint(
$recording->original_filename,
(int) $recording->file_size_bytes,
))
->unique()
->values()
->all();
return view('recordings.create', [
'existingFingerprints' => $existingFingerprints,
]);
}
/**
@@ -65,24 +77,71 @@ class RecordingController extends Controller
$titleOverride = $request->string('title')->trim()->toString();
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| Recording::query()->where('content_hash', $hash)->exists()
|| Recording::query()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && $titleOverride !== ''
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($file, $metadata, $title);
$recordings[] = $this->storeUploadedRecording($file, $metadata, $hash, $title);
}
if ($recordings === [] && $skippedDuplicates > 0) {
return redirect()
->route('recordings.create')
->with('error', $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.");
}
if ($recordings === []) {
return redirect()
->route('recordings.create')
->with('error', 'No valid audio files were uploaded.');
}
$message = count($recordings) === 1
? 'Recording uploaded — transcription queued.'
: count($recordings).' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
if (count($recordings) === 1) {
return redirect()
->route('recordings.show', $recordings[0])
->with('success', 'Recording uploaded — transcription queued.');
->with('success', $message);
}
return redirect()
->route('recordings.index')
->with('success', count($recordings).' recordings uploaded — transcription queued.');
->with('success', $message);
}
/**
@@ -115,6 +174,7 @@ class RecordingController extends Controller
private function storeUploadedRecording(
UploadedFile $file,
Mp3MetadataService $metadata,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
@@ -133,6 +193,7 @@ class RecordingController extends Controller
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
@@ -141,4 +202,12 @@ class RecordingController extends Controller
return $recording->fresh();
}
/**
* Client-side fingerprint for name + size duplicate checks before upload.
*/
private function uploadFingerprint(string $filename, int $sizeBytes): string
{
return strtolower($filename).':'.$sizeBytes;
}
}
+4
View File
@@ -143,6 +143,8 @@ class TranscribeRecording implements ShouldQueue
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
return true;
}
@@ -165,6 +167,8 @@ class TranscribeRecording implements ShouldQueue
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
return true;
}
+19 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Scope;
@@ -28,6 +29,7 @@ class Recording extends Model
'artist',
'album',
'file_size_bytes',
'content_hash',
'transcript',
'transcription_status',
'transcription_progress',
@@ -129,7 +131,9 @@ class Recording extends Model
'transcribed_at' => $this->transcribed_at,
]);
TranscribeRecording::dispatch($this->fresh());
$recording = $this->fresh();
RecordingTranscriptionUpdated::dispatch($recording);
TranscribeRecording::dispatch($recording);
}
/**
@@ -349,6 +353,8 @@ class Recording extends Model
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => 'Stopped by user',
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
@@ -376,6 +382,8 @@ class Recording extends Model
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => $message,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
@@ -405,6 +413,16 @@ class Recording extends Model
'transcription_percent' => max(0, min(100, $percent)),
'transcription_error' => null,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Broadcast the current transcription status to connected browsers.
*/
public function broadcastTranscriptionUpdated(): void
{
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
}
/**
+26 -4
View File
@@ -4,7 +4,6 @@ namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Number;
class DiskSpaceService
{
@@ -26,6 +25,10 @@ class DiskSpaceService
{
$path ??= Storage::disk('local')->path('');
if (! is_dir($path)) {
@mkdir($path, 0755, true);
}
/** @var array{total_bytes: int, free_bytes: int, used_bytes: int, used_percent: float, free_percent: float, total_human: string, free_human: string, used_human: string}|null */
return Cache::remember(
'disk-space:'.md5($path),
@@ -67,9 +70,28 @@ class DiskSpaceService
'used_bytes' => $usedBytes,
'used_percent' => $usedPercent,
'free_percent' => $freePercent,
'total_human' => Number::fileSize($totalBytes, precision: 1),
'free_human' => Number::fileSize($freeBytes, precision: 1),
'used_human' => Number::fileSize($usedBytes, precision: 1),
'total_human' => $this->formatBytes($totalBytes),
'free_human' => $this->formatBytes($freeBytes),
'used_human' => $this->formatBytes($usedBytes),
];
}
/**
* Human-readable byte size without requiring the intl extension.
*/
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$value = (float) max(0, $bytes);
$unit = 0;
while ($value >= 1024 && $unit < count($units) - 1) {
$value /= 1024;
$unit++;
}
$precision = $unit === 0 ? 0 : 1;
return number_format($value, $precision).' '.$units[$unit];
}
}
+1
View File
@@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
+1
View File
@@ -10,6 +10,7 @@
"james-heinrich/getid3": "^1.9",
"laravel/ai": "^0.10.3",
"laravel/framework": "^13.17",
"laravel/reverb": "^1.11",
"laravel/tinker": "^3.0"
},
"require-dev": {
Generated
+909 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "864e82b4686c8cdd77882c9847d6eaa9",
"content-hash": "98ad77590b15b8b0d90cf1dc008cd739",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -285,6 +285,136 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "clue/redis-protocol",
"version": "v0.3.2",
"source": {
"type": "git",
"url": "https://github.com/clue/redis-protocol.git",
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c",
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"Clue\\Redis\\Protocol\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@lueck.tv"
}
],
"description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.",
"homepage": "https://github.com/clue/redis-protocol",
"keywords": [
"parser",
"protocol",
"redis",
"resp",
"serializer",
"streaming"
],
"support": {
"issues": "https://github.com/clue/redis-protocol/issues",
"source": "https://github.com/clue/redis-protocol/tree/v0.3.2"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2024-08-07T11:06:28+00:00"
},
{
"name": "clue/redis-react",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/clue/reactphp-redis.git",
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca",
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca",
"shasum": ""
},
"require": {
"clue/redis-protocol": "^0.3.2",
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.0 || ^1.1",
"react/promise-timer": "^1.11",
"react/socket": "^1.16"
},
"require-dev": {
"clue/block-react": "^1.5",
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"Clue\\React\\Redis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
}
],
"description": "Async Redis client implementation, built on top of ReactPHP.",
"homepage": "https://github.com/clue/reactphp-redis",
"keywords": [
"async",
"client",
"database",
"reactphp",
"redis"
],
"support": {
"issues": "https://github.com/clue/reactphp-redis/issues",
"source": "https://github.com/clue/reactphp-redis/tree/v2.8.0"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2025-01-03T16:18:33+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -658,6 +788,53 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "evenement/evenement",
"version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/igorw/evenement.git",
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
"shasum": ""
},
"require": {
"php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "^9 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"Evenement\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Igor Wiedler",
"email": "igor@wiedler.ch"
}
],
"description": "Événement is a very simple event dispatching library for PHP",
"keywords": [
"event-dispatcher",
"event-emitter"
],
"support": {
"issues": "https://github.com/igorw/evenement/issues",
"source": "https://github.com/igorw/evenement/tree/v3.0.2"
},
"time": "2023-08-08T05:53:35+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -1637,6 +1814,85 @@
},
"time": "2026-08-04T14:50:50+00:00"
},
{
"name": "laravel/reverb",
"version": "v1.11.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/reverb.git",
"reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27",
"reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27",
"shasum": ""
},
"require": {
"clue/redis-react": "^2.6",
"guzzlehttp/psr7": "^2.6",
"illuminate/console": "^10.47|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.47|^11.0|^12.0|^13.0",
"illuminate/http": "^10.47|^11.0|^12.0|^13.0",
"illuminate/support": "^10.47|^11.0|^12.0|^13.0",
"laravel/prompts": "^0.1.15|^0.2.0|^0.3.0",
"php": "^8.2",
"pusher/pusher-php-server": "^7.2",
"ratchet/rfc6455": "^0.4",
"react/promise-timer": "^1.10",
"react/socket": "^1.14",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-foundation": "^6.3|^7.0|^8.0"
},
"require-dev": {
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"phpstan/phpstan": "^1.10",
"ratchet/pawl": "^0.4.1",
"react/async": "^4.2",
"react/http": "^1.9"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Reverb\\ApplicationManagerServiceProvider",
"Laravel\\Reverb\\ReverbServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Reverb\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Joe Dixon",
"email": "joe@laravel.com"
}
],
"description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.",
"keywords": [
"WebSockets",
"laravel",
"real-time",
"websocket"
],
"support": {
"issues": "https://github.com/laravel/reverb/issues",
"source": "https://github.com/laravel/reverb/tree/v1.11.1"
},
"time": "2026-08-06T14:48:58+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.15",
@@ -3468,6 +3724,69 @@
},
"time": "2026-06-29T15:41:09+00:00"
},
{
"name": "pusher/pusher-php-server",
"version": "7.3.0",
"source": {
"type": "git",
"url": "https://github.com/pusher/pusher-http-php.git",
"reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c",
"reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"php": "^7.3|^8.0",
"psr/http-client": "^1.0",
"psr/log": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"overtrue/phplint": "^2.3",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.0-dev"
}
},
"autoload": {
"psr-4": {
"Pusher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Library for interacting with the Pusher REST API",
"keywords": [
"events",
"messaging",
"php-pusher-server",
"publish",
"push",
"pusher",
"real time",
"real-time",
"realtime",
"rest",
"trigger"
],
"support": {
"issues": "https://github.com/pusher/pusher-http-php/issues",
"source": "https://github.com/pusher/pusher-http-php/tree/7.3.0"
},
"time": "2026-08-07T12:47:11+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
@@ -3666,6 +3985,595 @@
},
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "ratchet/rfc6455",
"version": "v0.4.1",
"source": {
"type": "git",
"url": "https://github.com/ratchetphp/RFC6455.git",
"reference": "9b05f371219cbaf9748b505f139617dd0715592b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b",
"reference": "9b05f371219cbaf9748b505f139617dd0715592b",
"shasum": ""
},
"require": {
"php": ">=7.4",
"psr/http-factory-implementation": "^1.0",
"symfony/polyfill-php80": "^1.15"
},
"require-dev": {
"guzzlehttp/psr7": "^2.7",
"phpunit/phpunit": "^9.5",
"react/socket": "^1.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Ratchet\\RFC6455\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"role": "Developer"
},
{
"name": "Matt Bonneau",
"role": "Developer"
}
],
"description": "RFC6455 WebSocket protocol handler",
"homepage": "http://socketo.me",
"keywords": [
"WebSockets",
"rfc6455",
"websocket"
],
"support": {
"chat": "https://gitter.im/reactphp/reactphp",
"issues": "https://github.com/ratchetphp/RFC6455/issues",
"source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1"
},
"time": "2026-06-06T14:34:23+00:00"
},
{
"name": "react/cache",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/cache.git",
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
"shasum": ""
},
"require": {
"php": ">=5.3.0",
"react/promise": "^3.0 || ^2.0 || ^1.1"
},
"require-dev": {
"phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async, Promise-based cache interface for ReactPHP",
"keywords": [
"cache",
"caching",
"promise",
"reactphp"
],
"support": {
"issues": "https://github.com/reactphp/cache/issues",
"source": "https://github.com/reactphp/cache/tree/v1.2.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2022-11-30T15:59:55+00:00"
},
{
"name": "react/dns",
"version": "v1.14.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/dns.git",
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
"shasum": ""
},
"require": {
"php": ">=5.3.0",
"react/cache": "^1.0 || ^0.6 || ^0.5",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.7 || ^1.2.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
"react/async": "^4.3 || ^3 || ^2",
"react/promise-timer": "^1.11"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Dns\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async DNS resolver for ReactPHP",
"keywords": [
"async",
"dns",
"dns-resolver",
"reactphp"
],
"support": {
"issues": "https://github.com/reactphp/dns/issues",
"source": "https://github.com/reactphp/dns/tree/v1.14.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-18T19:34:28+00:00"
},
{
"name": "react/event-loop",
"version": "v1.6.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/event-loop.git",
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"suggest": {
"ext-pcntl": "For signal handling support when using the StreamSelectLoop"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\EventLoop\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
"keywords": [
"asynchronous",
"event-loop"
],
"support": {
"issues": "https://github.com/reactphp/event-loop/issues",
"source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-17T20:46:25+00:00"
},
{
"name": "react/promise",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/promise.git",
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
"shasum": ""
},
"require": {
"php": ">=7.1.0"
},
"require-dev": {
"phpstan/phpstan": "1.12.28 || 1.4.10",
"phpunit/phpunit": "^9.6 || ^7.5"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"React\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "A lightweight implementation of CommonJS Promises/A for PHP",
"keywords": [
"promise",
"promises"
],
"support": {
"issues": "https://github.com/reactphp/promise/issues",
"source": "https://github.com/reactphp/promise/tree/v3.3.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-08-19T18:57:03+00:00"
},
{
"name": "react/promise-timer",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/promise-timer.git",
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1",
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1",
"shasum": ""
},
"require": {
"php": ">=5.3",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.7.0 || ^1.2.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"React\\Promise\\Timer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.",
"homepage": "https://github.com/reactphp/promise-timer",
"keywords": [
"async",
"event-loop",
"promise",
"reactphp",
"timeout",
"timer"
],
"support": {
"issues": "https://github.com/reactphp/promise-timer/issues",
"source": "https://github.com/reactphp/promise-timer/tree/v1.11.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2024-06-04T14:27:45+00:00"
},
{
"name": "react/socket",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/socket.git",
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.0",
"react/dns": "^1.13",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.6 || ^1.2.1",
"react/stream": "^1.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
"react/async": "^4.3 || ^3.3 || ^2",
"react/promise-stream": "^1.4",
"react/promise-timer": "^1.11"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Socket\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
"keywords": [
"Connection",
"Socket",
"async",
"reactphp",
"stream"
],
"support": {
"issues": "https://github.com/reactphp/socket/issues",
"source": "https://github.com/reactphp/socket/tree/v1.17.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-19T20:47:34+00:00"
},
{
"name": "react/stream",
"version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/stream.git",
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.8",
"react/event-loop": "^1.2"
},
"require-dev": {
"clue/stream-filter": "~1.2",
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Stream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
"keywords": [
"event-driven",
"io",
"non-blocking",
"pipe",
"reactphp",
"readable",
"stream",
"writable"
],
"support": {
"issues": "https://github.com/reactphp/stream/issues",
"source": "https://github.com/reactphp/stream/tree/v1.4.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "symfony/clock",
"version": "v8.1.0",
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
+102
View File
@@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];
Regular → Executable
View File
View File
View File
View File
View File
View File
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->string('content_hash', 64)->nullable()->after('file_size_bytes');
$table->unique('content_hash');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropUnique(['content_hash']);
$table->dropColumn('content_hash');
});
}
};
View File
+138 -2
View File
@@ -1,14 +1,150 @@
x-data volumes:
app-data: &app-data
- ./database:/app/database
- ./storage/app:/app/storage/app
- ./storage/logs:/app/storage/logs
x-app-env: &app-env
APP_NAME: AndyTranscribe
APP_ENV: ${APP_ENV:-local}
APP_KEY: ${APP_KEY}
APP_DEBUG: ${APP_DEBUG:-true}
APP_URL: ${APP_URL:-http://localhost:8080}
APP_PORT: ${APP_HOST_PORT:-8080}
LOG_CHANNEL: stderr
DB_CONNECTION: sqlite
DB_DATABASE: /app/database/database.sqlite
SESSION_DRIVER: database
QUEUE_CONNECTION: database
CACHE_STORE: database
BROADCAST_CONNECTION: reverb
FILESYSTEM_DISK: local
REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe}
REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
REVERB_APP_SECRET: ${REVERB_APP_SECRET:-andytranscribe-secret}
# Server-side publish target (Docker DNS)
REVERB_HOST: reverb
REVERB_PORT: 8080
REVERB_SCHEME: http
REVERB_SERVER_HOST: 0.0.0.0
REVERB_SERVER_PORT: 8080
LOCAL_WHISPER_URL: http://whisper:8000/v1
LOCAL_WHISPER_API_KEY: ${LOCAL_WHISPER_API_KEY:-not-needed}
LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600}
DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660}
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest
container_name: andytranscribe-app
ports:
- "${APP_HOST_PORT:-8080}:80"
environment:
<<: *app-env
volumes: *app-data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1/up"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
depends_on:
whisper:
condition: service_healthy
reverb:
condition: service_started
queue:
condition: service_started
restart: unless-stopped
queue:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest
container_name: andytranscribe-queue
command:
- php
- artisan
- queue:work
- database
- --sleep=1
- --tries=1
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
environment:
<<: *app-env
volumes: *app-data
healthcheck:
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'queue:work'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
whisper:
condition: service_healthy
reverb:
condition: service_started
restart: unless-stopped
reverb:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest
container_name: andytranscribe-reverb
command:
- php
- artisan
- reverb:start
- --host=0.0.0.0
- --port=8080
ports:
- "${REVERB_HOST_PORT:-8081}:8080"
environment:
<<: *app-env
# Browser connects to localhost:8081; hostname used in app config for allowed hosts
REVERB_HOST: localhost
volumes: *app-data
healthcheck:
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'reverb:start'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
restart: unless-stopped
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: andytranscribe-whisper
ports:
# Host 8090 avoids clashing with `php artisan serve` on 8000
# Host 8090 avoids clashing with the FrankenPHP app on 8080
- "${WHISPER_HOST_PORT:-8090}:8000"
volumes:
- whisper-huggingface-cache:/root/.cache/huggingface
environment:
# OpenAI-compatible API; model is also passed per request from Laravel
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
restart: unless-stopped
healthcheck:
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
cd /app
mkdir -p \
database \
storage/app/private/recordings \
storage/app/public \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
if [ ! -f database/database.sqlite ]; then
touch database/database.sqlite
fi
# Never use a host Vite HMR file inside the container image.
rm -f public/hot
# Bind mounts may arrive as root-owned; keep the app and host tooling writable.
chmod -R a+rwX database storage bootstrap/cache 2>/dev/null || true
if [ -z "${APP_KEY:-}" ]; then
echo "APP_KEY is not set. Generate one with: php artisan key:generate --show" >&2
exit 1
fi
php artisan migrate --force --no-interaction
exec "$@"
+69
View File
@@ -6,8 +6,11 @@
"": {
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.16.1",
"concurrently": "^10.0.3",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.1",
"pusher-js": "^8.6.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
@@ -661,6 +664,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -1128,6 +1158,28 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-echo": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
},
"peerDependenciesMeta": {
"pusher-js": {
"optional": true
},
"socket.io-client": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
@@ -1542,6 +1594,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pusher-js": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz",
"integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -1822,6 +1884,13 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
+3
View File
@@ -8,8 +8,11 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.16.1",
"concurrently": "^10.0.3",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.1",
"pusher-js": "^8.6.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
+11 -1
View File
@@ -1 +1,11 @@
//
import Alpine from 'alpinejs';
import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload';
window.Alpine = Alpine;
window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone;
Alpine.start();
+14
View File
@@ -0,0 +1,14 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
+253
View File
@@ -0,0 +1,253 @@
/**
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_CLASSES = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20',
failed: 'bg-red-50 text-red-800 ring-red-600/20',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20',
};
export function badgeClassFor(status) {
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20';
}
export function formatElapsed(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
if (minutes === 0) {
return remain + 's';
}
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
}
export function formatDuration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
return minutes + ':' + String(remain).padStart(2, '0');
}
export function formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (n) => String(n).padStart(2, '0');
return date.getFullYear()
+ '-' + pad(date.getMonth() + 1)
+ '-' + pad(date.getDate())
+ ' ' + pad(date.getHours())
+ ':' + pad(date.getMinutes());
}
function formatWordCount(count) {
const n = Number(count) || 0;
return n > 0 ? n.toLocaleString() : '—';
}
function subscribeToRecordings(handler) {
if (!window.Echo) {
return () => {};
}
const channel = window.Echo.channel('recordings');
channel.listen('.RecordingTranscriptionUpdated', handler);
return () => {
window.Echo.leave('recordings');
};
}
/**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
*/
export function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: initial,
pollError: null,
tickTimer: null,
leaveChannel: null,
get badgeClass() {
return badgeClassFor(this.status.status);
},
get startButtonLabel() {
if (this.status.is_active) {
return 'Restart transcription';
}
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
},
start() {
this.leaveChannel = subscribeToRecordings((event) => {
if (Number(event.id) !== Number(this.status.id)) {
return;
}
this.applyPayload(event);
});
if (this.status.is_active) {
this.beginTick();
this.hydrateOnce();
}
},
destroy() {
this.stopTick();
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
}
},
applyPayload(payload) {
const wasActive = this.status.is_active;
this.status = { ...this.status, ...payload };
this.pollError = null;
if (this.status.is_active) {
this.beginTick();
} else {
this.stopTick();
}
if (wasActive && !this.status.is_active
&& !this.status.has_transcript
&& this.status.status !== 'failed'
&& this.status.status !== 'cancelled') {
window.location.reload();
}
},
beginTick() {
if (this.tickTimer) {
return;
}
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
},
stopTick() {
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
},
tickElapsed() {
if (!this.status.is_active || this.status.elapsed_seconds == null) {
return;
}
this.status.elapsed_seconds += 1;
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
},
async hydrateOnce() {
if (!this.statusUrl) {
return;
}
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Status request failed (' + response.status + ')');
}
this.applyPayload(await response.json());
} catch (error) {
this.pollError = error.message || 'Could not refresh progress.';
}
},
formatElapsed,
formatDuration,
formatTimestamp,
};
}
/**
* Index-page Alpine component: patch row status from Reverb events.
*/
export function recordingsIndex({ recordings, pendingCount }) {
const byId = {};
for (const row of recordings) {
byId[row.id] = row;
}
return {
rows: byId,
pendingCount: Number(pendingCount) || 0,
leaveChannel: null,
start() {
this.leaveChannel = subscribeToRecordings((event) => {
this.applyPayload(event);
});
},
destroy() {
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
}
},
applyPayload(payload) {
const id = payload.id;
if (!this.rows[id]) {
return;
}
const previousStatus = this.rows[id].status;
const next = {
...this.rows[id],
status: payload.status,
status_label: payload.status_label,
progress: payload.progress,
percent: payload.percent,
is_active: payload.is_active,
word_count: payload.word_count ?? this.rows[id].word_count,
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
badge_class: badgeClassFor(payload.status),
};
this.rows[id] = next;
const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus);
const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status);
if (wasQueueable && !isQueueable) {
this.pendingCount = Math.max(0, this.pendingCount - 1);
} else if (!wasQueueable && isQueueable) {
this.pendingCount += 1;
}
},
row(id) {
return this.rows[id] || {};
},
};
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
* before submitting the form.
*/
export function uploadDropzone({ existingFingerprints = [] } = {}) {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
const known = new Set(existingFingerprints);
return {
files: [],
dragging: false,
uploading: false,
error: null,
notice: null,
get uploadLabel() {
if (this.uploading) {
return 'Uploading…';
}
if (this.files.length <= 1) {
return 'Upload';
}
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
this.notice = null;
const accepted = [];
let skippedUnsupported = 0;
let skippedTooLarge = 0;
let skippedDuplicates = 0;
for (const file of incoming) {
if (! this.isAccepted(file)) {
skippedUnsupported++;
continue;
}
if (file.size > 100 * 1024 * 1024) {
skippedTooLarge++;
continue;
}
const fingerprint = this.fingerprint(file);
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
accepted.push(file);
}
this.files = [...this.files, ...accepted];
if (this.files.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = this.files.slice(0, 50);
}
if (skippedUnsupported > 0) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
} else if (skippedTooLarge > 0) {
this.error = 'Skipped a file larger than 100 MB.';
}
if (skippedDuplicates > 0) {
this.notice = skippedDuplicates === 1
? 'Skipped 1 duplicate file.'
: `Skipped ${skippedDuplicates} duplicate files.`;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) {
return true;
}
return (file.type || '').startsWith('audio/');
},
fingerprint(file) {
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
},
fileListKey(file, index = 0) {
return `${this.fingerprint(file)}:${index}`;
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.notice = null;
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) {
return;
}
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) {
return bytes + ' B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
}
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
};
}
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Recordings') {{ config('app.name', 'AndyTranscribe') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
<x-disk-space-bar />
+4 -115
View File
@@ -7,6 +7,7 @@
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</p>
</div>
@@ -14,7 +15,7 @@
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
x-data="uploadDropzone()"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
>
@@ -61,7 +62,7 @@
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
<template x-for="(file, index) in files" :key="fileKey(file, index)">
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
@@ -91,6 +92,7 @@
>
</div>
<p x-show="notice" x-cloak class="text-sm text-amber-800" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p>
<div class="flex items-center gap-3">
@@ -104,117 +106,4 @@
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
</div>
</form>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
<script>
function uploadDropzone() {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
return {
files: [],
dragging: false,
uploading: false,
error: null,
get uploadLabel() {
if (this.uploading) return 'Uploading…';
if (this.files.length <= 1) return 'Upload';
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
const accepted = [];
for (const file of incoming) {
if (! this.isAccepted(file)) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
continue;
}
if (file.size > 100 * 1024 * 1024) {
this.error = 'Skipped a file larger than 100 MB.';
continue;
}
accepted.push(file);
}
const merged = [...this.files, ...accepted];
const unique = [];
const seen = new Set();
for (const file of merged) {
const key = this.fileKey(file);
if (seen.has(key)) continue;
seen.add(key);
unique.push(file);
}
if (unique.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = unique.slice(0, 50);
} else {
this.files = unique;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) return true;
return (file.type || '').startsWith('audio/');
},
fileKey(file, index = 0) {
return [file.name, file.size, file.lastModified, index].join(':');
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) return;
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
};
}
</script>
@endsection
+134 -84
View File
@@ -3,94 +3,144 @@
@section('title', 'Recordings')
@section('content')
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
@if (($pendingCount ?? 0) > 0)
<form method="POST" action="{{ route('recordings.transcribe-pending') }}">
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
Queue {{ $pendingCount }} pending {{ \Illuminate\Support\Str::plural('transcription', $pendingCount) }}
@php
$indexRows = $recordings->map(function ($recording) {
return [
'id' => $recording->id,
'status' => $recording->transcription_status,
'status_label' => $recording->transcriptionStatusLabel(),
'progress' => $recording->transcription_progress,
'percent' => $recording->transcription_percent,
'is_active' => $recording->isTranscribing(),
'word_count' => $recording->word_count,
'word_count_display' => $recording->word_count > 0
? number_format($recording->word_count)
: '—',
'badge_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
},
];
})->values();
@endphp
<div
x-data="recordingsIndex(@js([
'recordings' => $indexRows,
'pendingCount' => $pendingCount ?? 0,
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
@endif
<form
method="POST"
action="{{ route('recordings.transcribe-pending') }}"
x-show="pendingCount > 0"
x-cloak
>
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button>
</form>
</div>
</div>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
<p class="text-stone-600">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-stone-200 text-sm">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td class="px-4 py-3 tabular-nums text-stone-600">
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
@include('recordings.partials.status-badge', [
'status' => $recording->transcription_status,
'label' => $recording->transcriptionStatusLabel(),
])
@if ($recording->isTranscribing() && $recording->transcription_progress)
<div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}">
{{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }}
</div>
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
<p class="text-stone-600">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-stone-200 text-sm">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
@endforeach
</tbody>
</table>
</div>
</thead>
<tbody class="divide-y divide-stone-100">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td
class="px-4 py-3 tabular-nums text-stone-600"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="row({{ $recording->id }}).badge_class"
x-text="row({{ $recording->id }}).status_label"
>
{{ $recording->transcriptionStatusLabel() }}
</span>
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak
:title="row({{ $recording->id }}).progress"
>
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
<span x-text="row({{ $recording->id }}).progress"></span>
</div>
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
</div>
@endsection
+4 -117
View File
@@ -8,7 +8,10 @@
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="start()"
x-init="
start();
return () => destroy();
"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
@@ -173,120 +176,4 @@
</p>
</section>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
<script>
function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: initial,
pollError: null,
timer: null,
tickTimer: null,
get badgeClass() {
const map = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20',
failed: 'bg-red-50 text-red-800 ring-red-600/20',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20',
};
return map[this.status.status] || 'bg-stone-100 text-stone-700 ring-stone-500/20';
},
get startButtonLabel() {
if (this.status.is_active) {
return 'Restart transcription';
}
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
},
start() {
if (this.status.is_active) {
this.beginPolling();
}
},
beginPolling() {
this.stopPolling();
this.poll();
this.timer = setInterval(() => this.poll(), 1500);
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
},
stopPolling() {
if (this.timer) clearInterval(this.timer);
if (this.tickTimer) clearInterval(this.tickTimer);
this.timer = null;
this.tickTimer = null;
},
tickElapsed() {
if (!this.status.is_active || this.status.elapsed_seconds == null) return;
this.status.elapsed_seconds += 1;
this.status.elapsed_human = this.formatElapsed(this.status.elapsed_seconds);
},
async poll() {
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Status request failed (' + response.status + ')');
}
const wasActive = this.status.is_active;
this.status = await response.json();
this.pollError = null;
if (this.status.is_active && !this.timer) {
this.beginPolling();
}
if (wasActive && !this.status.is_active) {
this.stopPolling();
if (this.status.has_transcript || this.status.status === 'failed' || this.status.status === 'cancelled') {
return;
}
window.location.reload();
}
} catch (error) {
this.pollError = error.message || 'Could not refresh progress.';
}
},
formatElapsed(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
if (minutes === 0) return remain + 's';
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
},
formatDuration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
return minutes + ':' + String(remain).padStart(2, '0');
},
formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const pad = (n) => String(n).padStart(2, '0');
return date.getFullYear()
+ '-' + pad(date.getMonth() + 1)
+ '-' + pad(date.getDate())
+ ' ' + pad(date.getHours())
+ ':' + pad(date.getMinutes());
},
};
}
</script>
@endsection
+17
View File
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Broadcast;
/*
|--------------------------------------------------------------------------
| Broadcast Channels
|--------------------------------------------------------------------------
|
| Transcription updates use the public "recordings" channel (no auth).
| Private channel stubs can be added here when the app gains users.
|
*/
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
-2
View File
@@ -5,7 +5,6 @@ namespace Tests\Feature;
use App\Services\DiskSpaceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DiskSpaceTest extends TestCase
@@ -39,7 +38,6 @@ class DiskSpaceTest extends TestCase
public function test_layout_shows_disk_space_bar(): void
{
Cache::flush();
Storage::fake('local');
$this->get(route('recordings.index'))
->assertOk()
@@ -0,0 +1,102 @@
<?php
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class RecordingDuplicateUploadTest extends TestCase
{
use RefreshDatabase;
public function test_duplicate_content_hash_is_skipped_on_upload(): void
{
Storage::fake('local');
Bus::fake();
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
$this->post(route('recordings.store'), [
'audio' => [$first],
])->assertRedirect();
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
$response = $this->post(route('recordings.store'), [
'audio' => [$duplicate],
]);
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
}
public function test_batch_upload_skips_duplicate_content_within_request(): void
{
Storage::fake('local');
Bus::fake();
$one = UploadedFile::fake()->createWithContent('one.mp3', 'same-bytes');
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
$response = $this->post(route('recordings.store'), [
'audio' => [$one, $two, $three],
]);
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertStringContainsString('Skipped 1 duplicate', session('success'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
{
Recording::query()->create([
'title' => 'Existing',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 2048,
'content_hash' => str_repeat('a', 64),
'transcription_status' => 'done',
]);
$this->get(route('recordings.create'))
->assertOk()
->assertSee('note.mp3:2048', false)
->assertSee('Duplicate files', false);
}
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
{
Storage::fake('local');
Bus::fake();
Recording::query()->create([
'title' => 'Legacy',
'original_filename' => 'legacy.mp3',
'file_path' => 'recordings/legacy.mp3',
'file_size_bytes' => strlen('legacy-audio'),
'content_hash' => null,
'transcription_status' => 'done',
]);
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')],
]);
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertNothingDispatched();
}
}
+8 -8
View File
@@ -65,9 +65,9 @@ class RecordingUploadTest extends TestCase
$response = $this->post(route('recordings.store'), [
'audio' => [
UploadedFile::fake()->create('one.mp3', 400, 'audio/mpeg'),
UploadedFile::fake()->create('two.wav', 400, 'audio/wav'),
UploadedFile::fake()->create('three.ogg', 400, 'audio/ogg'),
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
],
]);
@@ -96,12 +96,12 @@ class RecordingUploadTest extends TestCase
Bus::fake();
foreach ([
['memo.wav', 'audio/wav'],
['clip.ogg', 'audio/ogg'],
['talk.m4a', 'audio/mp4'],
] as [$name, $mime]) {
['memo.wav', 'audio/wav', 'wav-bytes'],
['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) {
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->create($name, 400, $mime)],
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)],
'title' => $name,
]);
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription;
use Tests\TestCase;
class TranscriptionBroadcastTest extends TestCase
{
use RefreshDatabase;
public function test_report_progress_broadcasts_transcription_updated(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'title' => 'Broadcast progress',
'original_filename' => 'progress.mp3',
'file_path' => 'recordings/progress.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 50);
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
return $event->recording->is($recording)
&& $event->broadcastWith()['percent'] === 50
&& $event->broadcastWith()['progress'] === 'Transcribing locally…';
});
}
public function test_successful_transcription_broadcasts_done_status(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
Storage::fake('local');
Storage::disk('local')->put('recordings/sample.mp3', 'fake-audio-bytes');
Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([
'title' => 'Sample',
'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
$payload = $event->broadcastWith();
return $event->recording->is($recording)
&& $payload['status'] === 'done'
&& $payload['percent'] === 100
&& $payload['has_transcript'] === true
&& ($payload['word_count'] ?? 0) > 0;
});
}
public function test_failed_transcription_broadcasts_failure(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
Storage::fake('local');
Storage::disk('local')->put('recordings/bad.mp3', 'fake-audio-bytes');
Transcription::fake(function () {
throw new \RuntimeException('Provider unavailable');
});
$recording = Recording::query()->create([
'title' => 'Bad',
'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
try {
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
$this->fail('Expected transcription to throw');
} catch (\RuntimeException $e) {
$this->assertSame('Provider unavailable', $e->getMessage());
}
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
$payload = $event->broadcastWith();
return $event->recording->is($recording)
&& $payload['status'] === 'failed'
&& $payload['error'] === 'Provider unavailable';
});
}
public function test_cancel_transcription_broadcasts_cancelled_status(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'title' => 'Cancel me',
'original_filename' => 'cancel.mp3',
'file_path' => 'recordings/cancel.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->cancelTranscription();
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
return $event->recording->is($recording)
&& $event->broadcastWith()['status'] === 'cancelled';
});
}
public function test_broadcast_event_uses_public_recordings_channel(): void
{
$recording = Recording::query()->create([
'title' => 'Channel',
'original_filename' => 'channel.mp3',
'file_path' => 'recordings/channel.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
$event = new RecordingTranscriptionUpdated($recording);
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('recordings', $event->broadcastOn()[0]->name);
}
}