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:
@@ -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
@@ -2,7 +2,7 @@ APP_NAME=AndyTranscribe
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
|
|||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=reverb
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
@@ -64,6 +64,21 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
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
|
# AndyTranscribe / local faster-whisper
|
||||||
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
||||||
LOCAL_WHISPER_API_KEY=not-needed
|
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
|
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
|
||||||
DB_QUEUE_RETRY_AFTER=660
|
DB_QUEUE_RETRY_AFTER=660
|
||||||
|
|
||||||
# Host 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
|
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
@@ -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"]
|
||||||
@@ -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.
|
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
|
## 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)
|
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
|
||||||
- Search recordings by title, artist, or transcript
|
- Search recordings by title, artist, or transcript
|
||||||
- Queued local transcription (faster-whisper in Docker)
|
- 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
|
- Stop or restart a run anytime
|
||||||
- Copy finished transcripts from the recording detail page
|
- Copy finished transcripts from the recording detail page
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- PHP 8.3+ (8.5 recommended)
|
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose (primary way to run the app)
|
||||||
- Composer
|
- For native PHP development: PHP 8.3+ (8.5 recommended), Composer, Node.js & npm, SQLite
|
||||||
- Node.js & npm
|
|
||||||
- SQLite (default) or another supported database
|
|
||||||
- [Docker](https://docs.docker.com/get-docker/) for the Whisper container
|
|
||||||
|
|
||||||
|
## Quick start (Docker + FrankenPHP)
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
```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
|
cp .env.example .env
|
||||||
php artisan key:generate
|
# Set a key (required by the app container):
|
||||||
touch database/database.sqlite # if using SQLite
|
php artisan key:generate # or: docker run --rm -v "$PWD":/app -w /app composer:2 php artisan key:generate
|
||||||
php artisan migrate
|
|
||||||
npm install
|
docker compose up --build
|
||||||
npm run 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
|
```bash
|
||||||
# CPU (works everywhere; slower on long files)
|
docker compose --profile gpu up -d --build
|
||||||
docker compose up -d whisper
|
|
||||||
|
|
||||||
# Optional: NVIDIA GPU
|
|
||||||
docker compose --profile gpu up -d whisper-gpu
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
Copy values from `.env.example`. Important Docker-oriented variables:
|
||||||
curl -s http://127.0.0.1:8090/health
|
|
||||||
```
|
|
||||||
|
|
||||||
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:
|
Stop:
|
||||||
|
|
||||||
@@ -76,60 +81,34 @@ Stop:
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Native PHP development (optional)
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d whisper
|
composer setup
|
||||||
composer run dev
|
docker compose up -d whisper reverb
|
||||||
```
|
# In separate terminals:
|
||||||
|
php artisan serve --port=8000
|
||||||
Or separately:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d whisper
|
|
||||||
php artisan serve
|
|
||||||
php artisan queue:work
|
php artisan queue:work
|
||||||
|
php artisan reverb:start
|
||||||
npm run dev
|
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
|
## Usage
|
||||||
|
|
||||||
1. **Upload** audio from Recordings → Upload (single file or batch dropzone).
|
1. **Upload** audio from Recordings → Upload (single file or batch dropzone).
|
||||||
2. Transcription queues automatically — keep `php artisan queue:work` running.
|
2. Transcription queues automatically — the Compose `queue` service (or `php artisan queue:work`) must be running.
|
||||||
3. Watch live progress on the recording page (or stop and restart).
|
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.
|
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.
|
5. If older uploads still show **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -138,8 +117,6 @@ composer test
|
|||||||
php artisan test
|
php artisan test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## License
|
## 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,19 @@ class RecordingController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function create(): View
|
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();
|
$titleOverride = $request->string('title')->trim()->toString();
|
||||||
$recordings = [];
|
$recordings = [];
|
||||||
|
$skippedDuplicates = 0;
|
||||||
|
$seenHashes = [];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
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 !== ''
|
$title = count($files) === 1 && $titleOverride !== ''
|
||||||
? $titleOverride
|
? $titleOverride
|
||||||
: null;
|
: 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) {
|
if (count($recordings) === 1) {
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('recordings.show', $recordings[0])
|
->route('recordings.show', $recordings[0])
|
||||||
->with('success', 'Recording uploaded — transcription queued.');
|
->with('success', $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('recordings.index')
|
->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(
|
private function storeUploadedRecording(
|
||||||
UploadedFile $file,
|
UploadedFile $file,
|
||||||
Mp3MetadataService $metadata,
|
Mp3MetadataService $metadata,
|
||||||
|
string $contentHash,
|
||||||
?string $titleOverride = null,
|
?string $titleOverride = null,
|
||||||
): Recording {
|
): Recording {
|
||||||
$path = $file->store('recordings', 'local');
|
$path = $file->store('recordings', 'local');
|
||||||
@@ -133,6 +193,7 @@ class RecordingController extends Controller
|
|||||||
'artist' => $tags['artist'],
|
'artist' => $tags['artist'],
|
||||||
'album' => $tags['album'],
|
'album' => $tags['album'],
|
||||||
'file_size_bytes' => $file->getSize() ?: 0,
|
'file_size_bytes' => $file->getSize() ?: 0,
|
||||||
|
'content_hash' => $contentHash,
|
||||||
'transcription_status' => 'pending',
|
'transcription_status' => 'pending',
|
||||||
'transcription_driver' => 'local',
|
'transcription_driver' => 'local',
|
||||||
]);
|
]);
|
||||||
@@ -141,4 +202,12 @@ class RecordingController extends Controller
|
|||||||
|
|
||||||
return $recording->fresh();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
'transcribed_at' => now(),
|
'transcribed_at' => now(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$this->recording->broadcastTranscriptionUpdated();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +167,8 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
'transcribed_at' => now(),
|
'transcribed_at' => now(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$this->recording->broadcastTranscriptionUpdated();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Events\RecordingTranscriptionUpdated;
|
||||||
use App\Jobs\TranscribeRecording;
|
use App\Jobs\TranscribeRecording;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||||
@@ -28,6 +29,7 @@ class Recording extends Model
|
|||||||
'artist',
|
'artist',
|
||||||
'album',
|
'album',
|
||||||
'file_size_bytes',
|
'file_size_bytes',
|
||||||
|
'content_hash',
|
||||||
'transcript',
|
'transcript',
|
||||||
'transcription_status',
|
'transcription_status',
|
||||||
'transcription_progress',
|
'transcription_progress',
|
||||||
@@ -129,7 +131,9 @@ class Recording extends Model
|
|||||||
'transcribed_at' => $this->transcribed_at,
|
'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_percent' => $this->transcription_percent ?: 0,
|
||||||
'transcription_error' => 'Stopped by user',
|
'transcription_error' => 'Stopped by user',
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,6 +382,8 @@ class Recording extends Model
|
|||||||
'transcription_percent' => $this->transcription_percent ?: 0,
|
'transcription_percent' => $this->transcription_percent ?: 0,
|
||||||
'transcription_error' => $message,
|
'transcription_error' => $message,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -405,6 +413,16 @@ class Recording extends Model
|
|||||||
'transcription_percent' => max(0, min(100, $percent)),
|
'transcription_percent' => max(0, min(100, $percent)),
|
||||||
'transcription_error' => null,
|
'transcription_error' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast the current transcription status to connected browsers.
|
||||||
|
*/
|
||||||
|
public function broadcastTranscriptionUpdated(): void
|
||||||
|
{
|
||||||
|
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Services;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Number;
|
|
||||||
|
|
||||||
class DiskSpaceService
|
class DiskSpaceService
|
||||||
{
|
{
|
||||||
@@ -26,6 +25,10 @@ class DiskSpaceService
|
|||||||
{
|
{
|
||||||
$path ??= Storage::disk('local')->path('');
|
$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 */
|
/** @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(
|
return Cache::remember(
|
||||||
'disk-space:'.md5($path),
|
'disk-space:'.md5($path),
|
||||||
@@ -67,9 +70,28 @@ class DiskSpaceService
|
|||||||
'used_bytes' => $usedBytes,
|
'used_bytes' => $usedBytes,
|
||||||
'used_percent' => $usedPercent,
|
'used_percent' => $usedPercent,
|
||||||
'free_percent' => $freePercent,
|
'free_percent' => $freePercent,
|
||||||
'total_human' => Number::fileSize($totalBytes, precision: 1),
|
'total_human' => $this->formatBytes($totalBytes),
|
||||||
'free_human' => Number::fileSize($freeBytes, precision: 1),
|
'free_human' => $this->formatBytes($freeBytes),
|
||||||
'used_human' => Number::fileSize($usedBytes, precision: 1),
|
'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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"james-heinrich/getid3": "^1.9",
|
"james-heinrich/getid3": "^1.9",
|
||||||
"laravel/ai": "^0.10.3",
|
"laravel/ai": "^0.10.3",
|
||||||
"laravel/framework": "^13.17",
|
"laravel/framework": "^13.17",
|
||||||
|
"laravel/reverb": "^1.11",
|
||||||
"laravel/tinker": "^3.0"
|
"laravel/tinker": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+909
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "864e82b4686c8cdd77882c9847d6eaa9",
|
"content-hash": "98ad77590b15b8b0d90cf1dc008cd739",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
@@ -285,6 +285,136 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "dflydev/dot-access-data",
|
||||||
"version": "v3.0.3",
|
"version": "v3.0.3",
|
||||||
@@ -658,6 +788,53 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"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",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -1637,6 +1814,85 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-08-04T14:50:50+00:00"
|
"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",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.15",
|
"version": "v2.0.15",
|
||||||
@@ -3468,6 +3724,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-29T15:41:09+00:00"
|
"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",
|
"name": "ralouphie/getallheaders",
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
@@ -3666,6 +3985,595 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-18T03:57:49+00:00"
|
"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",
|
"name": "symfony/clock",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.0",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Reverb Server
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default server used by Reverb to handle
|
||||||
|
| incoming messages as well as broadcasting message to all your
|
||||||
|
| connected clients. At this time only "reverb" is supported.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('REVERB_SERVER', 'reverb'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Servers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define details for each of the supported Reverb servers.
|
||||||
|
| Each server has its own configuration options that are defined in
|
||||||
|
| the array below. You should ensure all the options are present.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'servers' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||||
|
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||||
|
'path' => env('REVERB_SERVER_PATH', ''),
|
||||||
|
'hostname' => env('REVERB_HOST'),
|
||||||
|
'options' => [
|
||||||
|
'tls' => [],
|
||||||
|
],
|
||||||
|
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||||
|
'scaling' => [
|
||||||
|
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||||
|
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||||
|
'server' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||||
|
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Applications
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define how Reverb applications are managed. If you choose
|
||||||
|
| to use the "config" provider, you may define an array of apps which
|
||||||
|
| your server will support, including their connection credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
|
||||||
|
'provider' => 'config',
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
[
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'allowed_origins' => ['*'],
|
||||||
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
|
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||||
|
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||||
|
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||||
|
'rate_limiting' => [
|
||||||
|
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||||
|
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||||
|
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||||
|
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Regular → Executable
+138
-2
@@ -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:
|
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:
|
whisper:
|
||||||
image: fedirz/faster-whisper-server:latest-cpu
|
image: fedirz/faster-whisper-server:latest-cpu
|
||||||
container_name: andytranscribe-whisper
|
container_name: andytranscribe-whisper
|
||||||
ports:
|
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"
|
- "${WHISPER_HOST_PORT:-8090}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- whisper-huggingface-cache:/root/.cache/huggingface
|
- whisper-huggingface-cache:/root/.cache/huggingface
|
||||||
environment:
|
environment:
|
||||||
# OpenAI-compatible API; model is also passed per request from Laravel
|
|
||||||
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
|
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
Executable
+33
@@ -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 "$@"
|
||||||
Generated
+69
@@ -6,8 +6,11 @@
|
|||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"alpinejs": "^3.16.1",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
|
"laravel-echo": "^2.4.0",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
|
"pusher-js": "^8.6.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -661,6 +664,33 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"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": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||||
@@ -1128,6 +1158,28 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
|
||||||
@@ -1542,6 +1594,16 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.8",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
@@ -1822,6 +1884,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-fest": {
|
||||||
"version": "5.8.0",
|
"version": "5.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
||||||
|
|||||||
@@ -8,8 +8,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"alpinejs": "^3.16.1",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
|
"laravel-echo": "^2.4.0",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
|
"pusher-js": "^8.6.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-1
@@ -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();
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
});
|
||||||
@@ -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] || {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>@yield('title', 'Recordings') — {{ config('app.name', 'AndyTranscribe') }}</title>
|
<title>@yield('title', 'Recordings') — {{ config('app.name', 'AndyTranscribe') }}</title>
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
|
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
|
||||||
<x-disk-space-bar />
|
<x-disk-space-bar />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
|
||||||
<p class="mt-1 text-sm text-stone-600">
|
<p class="mt-1 text-sm text-stone-600">
|
||||||
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="{{ route('recordings.store') }}"
|
action="{{ route('recordings.store') }}"
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
x-data="uploadDropzone()"
|
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
|
||||||
@submit="ensureFilesSelected($event)"
|
@submit="ensureFilesSelected($event)"
|
||||||
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
|
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
|
<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">
|
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
|
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</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>
|
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<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>
|
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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
|
@endsection
|
||||||
|
|||||||
@@ -3,94 +3,144 @@
|
|||||||
@section('title', 'Recordings')
|
@section('title', 'Recordings')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
@php
|
||||||
<div>
|
$indexRows = $recordings->map(function ($recording) {
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
|
return [
|
||||||
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
|
'id' => $recording->id,
|
||||||
</div>
|
'status' => $recording->transcription_status,
|
||||||
<div class="flex flex-col gap-2 sm:items-end">
|
'status_label' => $recording->transcriptionStatusLabel(),
|
||||||
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
'progress' => $recording->transcription_progress,
|
||||||
<input
|
'percent' => $recording->transcription_percent,
|
||||||
type="search"
|
'is_active' => $recording->isTranscribing(),
|
||||||
name="q"
|
'word_count' => $recording->word_count,
|
||||||
value="{{ $search ?? '' }}"
|
'word_count_display' => $recording->word_count > 0
|
||||||
placeholder="Search title, artist, transcript…"
|
? number_format($recording->word_count)
|
||||||
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"
|
: '—',
|
||||||
>
|
'badge_class' => match ($recording->transcription_status) {
|
||||||
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
|
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
||||||
Search
|
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||||
</button>
|
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||||
</form>
|
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
|
||||||
@if (($pendingCount ?? 0) > 0)
|
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||||
<form method="POST" action="{{ route('recordings.transcribe-pending') }}">
|
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||||
@csrf
|
},
|
||||||
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
|
];
|
||||||
Queue {{ $pendingCount }} pending {{ \Illuminate\Support\Str::plural('transcription', $pendingCount) }}
|
})->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>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($recordings->isEmpty())
|
@if ($recordings->isEmpty())
|
||||||
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
|
<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>
|
<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">
|
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
|
||||||
Upload your first MP3
|
Upload your first MP3
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
|
<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">
|
<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">
|
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3">Title</th>
|
<th class="px-4 py-3">Title</th>
|
||||||
<th class="px-4 py-3">Duration</th>
|
<th class="px-4 py-3">Duration</th>
|
||||||
<th class="px-4 py-3">Words</th>
|
<th class="px-4 py-3">Words</th>
|
||||||
<th class="px-4 py-3">Status</th>
|
<th class="px-4 py-3">Status</th>
|
||||||
<th class="px-4 py-3">Uploaded</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
</thead>
|
||||||
</tbody>
|
<tbody class="divide-y divide-stone-100">
|
||||||
</table>
|
@foreach ($recordings as $recording)
|
||||||
</div>
|
<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">
|
<div class="mt-6">
|
||||||
{{ $recordings->links() }}
|
{{ $recordings->links() }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||||
'initial' => $recording->transcriptionStatusPayload(),
|
'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 class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -173,120 +176,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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
|
@endsection
|
||||||
|
|||||||
@@ -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
Regular → Executable
Regular → Executable
Regular → Executable
@@ -5,7 +5,6 @@ namespace Tests\Feature;
|
|||||||
use App\Services\DiskSpaceService;
|
use App\Services\DiskSpaceService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class DiskSpaceTest extends TestCase
|
class DiskSpaceTest extends TestCase
|
||||||
@@ -39,7 +38,6 @@ class DiskSpaceTest extends TestCase
|
|||||||
public function test_layout_shows_disk_space_bar(): void
|
public function test_layout_shows_disk_space_bar(): void
|
||||||
{
|
{
|
||||||
Cache::flush();
|
Cache::flush();
|
||||||
Storage::fake('local');
|
|
||||||
|
|
||||||
$this->get(route('recordings.index'))
|
$this->get(route('recordings.index'))
|
||||||
->assertOk()
|
->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,9 +65,9 @@ class RecordingUploadTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->post(route('recordings.store'), [
|
$response = $this->post(route('recordings.store'), [
|
||||||
'audio' => [
|
'audio' => [
|
||||||
UploadedFile::fake()->create('one.mp3', 400, 'audio/mpeg'),
|
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
|
||||||
UploadedFile::fake()->create('two.wav', 400, 'audio/wav'),
|
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
|
||||||
UploadedFile::fake()->create('three.ogg', 400, 'audio/ogg'),
|
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -96,12 +96,12 @@ class RecordingUploadTest extends TestCase
|
|||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
['memo.wav', 'audio/wav'],
|
['memo.wav', 'audio/wav', 'wav-bytes'],
|
||||||
['clip.ogg', 'audio/ogg'],
|
['clip.ogg', 'audio/ogg', 'ogg-bytes'],
|
||||||
['talk.m4a', 'audio/mp4'],
|
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
|
||||||
] as [$name, $mime]) {
|
] as [$name, $mime, $contents]) {
|
||||||
$response = $this->post(route('recordings.store'), [
|
$response = $this->post(route('recordings.store'), [
|
||||||
'audio' => [UploadedFile::fake()->create($name, 400, $mime)],
|
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)],
|
||||||
'title' => $name,
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user