From 771ee8db5a977aa24909d07b350fc301e728fcd2 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 17:35:48 +0200 Subject: [PATCH] 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. --- .dockerignore | 32 + .env.example | 28 +- Dockerfile | 72 ++ README.md | 141 ++- app/Events/RecordingTranscriptionUpdated.php | 50 + app/Http/Controllers/RecordingController.php | 77 +- app/Jobs/TranscribeRecording.php | 4 + app/Models/Recording.php | 20 +- app/Services/DiskSpaceService.php | 30 +- bootstrap/app.php | 1 + composer.json | 1 + composer.lock | 910 +++++++++++++++++- config/broadcasting.php | 82 ++ config/reverb.php | 102 ++ database/.gitignore | 0 database/factories/UserFactory.php | 0 .../0001_01_01_000000_create_users_table.php | 0 .../0001_01_01_000001_create_cache_table.php | 0 .../0001_01_01_000002_create_jobs_table.php | 0 ...6_08_12_114417_create_recordings_table.php | 0 ...scription_progress_to_recordings_table.php | 0 ...9_add_content_hash_to_recordings_table.php | 30 + database/seeders/DatabaseSeeder.php | 0 docker-compose.yml | 140 ++- docker/entrypoint.sh | 33 + package-lock.json | 69 ++ package.json | 3 + resources/js/app.js | 12 +- resources/js/echo.js | 14 + resources/js/transcription.js | 253 +++++ resources/js/upload.js | 159 +++ resources/views/layouts/app.blade.php | 1 + resources/views/recordings/create.blade.php | 119 +-- resources/views/recordings/index.blade.php | 218 +++-- resources/views/recordings/show.blade.php | 121 +-- routes/channels.php | 17 + storage/app/.gitignore | 0 storage/app/private/.gitignore | 0 storage/app/public/.gitignore | 0 storage/logs/.gitignore | 0 tests/Feature/DiskSpaceTest.php | 2 - .../Feature/RecordingDuplicateUploadTest.php | 102 ++ tests/Feature/RecordingUploadTest.php | 16 +- tests/Feature/TranscriptionBroadcastTest.php | 146 +++ 44 files changed, 2581 insertions(+), 424 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/Events/RecordingTranscriptionUpdated.php create mode 100644 config/broadcasting.php create mode 100644 config/reverb.php mode change 100644 => 100755 database/.gitignore mode change 100644 => 100755 database/factories/UserFactory.php mode change 100644 => 100755 database/migrations/0001_01_01_000000_create_users_table.php mode change 100644 => 100755 database/migrations/0001_01_01_000001_create_cache_table.php mode change 100644 => 100755 database/migrations/0001_01_01_000002_create_jobs_table.php mode change 100644 => 100755 database/migrations/2026_08_12_114417_create_recordings_table.php mode change 100644 => 100755 database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php create mode 100644 database/migrations/2026_08_12_153209_add_content_hash_to_recordings_table.php mode change 100644 => 100755 database/seeders/DatabaseSeeder.php create mode 100755 docker/entrypoint.sh create mode 100644 resources/js/echo.js create mode 100644 resources/js/transcription.js create mode 100644 resources/js/upload.js create mode 100644 routes/channels.php mode change 100644 => 100755 storage/app/.gitignore mode change 100644 => 100755 storage/app/private/.gitignore mode change 100644 => 100755 storage/app/public/.gitignore mode change 100644 => 100755 storage/logs/.gitignore create mode 100644 tests/Feature/RecordingDuplicateUploadTest.php create mode 100644 tests/Feature/TranscriptionBroadcastTest.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e374712 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 811a432..5e5cf8d 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ APP_NAME=AndyTranscribe APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost:8000 +APP_URL=http://localhost:8080 APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -33,7 +33,7 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -BROADCAST_CONNECTION=log +BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local QUEUE_CONNECTION=database @@ -64,6 +64,21 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# Laravel Reverb (WebSockets). Browser uses VITE_*; server publish uses REVERB_HOST. +REVERB_APP_ID=andytranscribe +REVERB_APP_KEY=andytranscribe-key +REVERB_APP_SECRET=andytranscribe-secret +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http +REVERB_SERVER_HOST=0.0.0.0 +REVERB_SERVER_PORT=8080 + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" + # AndyTranscribe / local faster-whisper LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1 LOCAL_WHISPER_API_KEY=not-needed @@ -72,5 +87,12 @@ TRANSCRIPTION_TIMEOUT=600 # Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run DB_QUEUE_RETRY_AFTER=660 -# Host port for docker compose whisper service +# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper) +APP_HOST_PORT=8080 +REVERB_HOST_PORT=8081 WHISPER_HOST_PORT=8090 + +# Browser-facing Reverb host/port (used at Vite build time in Docker) +VITE_REVERB_HOST=localhost +VITE_REVERB_SCHEME=http + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67ffd25 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 5f7685a..1bc8f82 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker. Audio never leaves your machine. -Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai). +Built with Laravel 13, Blade, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai). ## Features @@ -10,65 +10,70 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co - Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date) - Search recordings by title, artist, or transcript - Queued local transcription (faster-whisper in Docker) -- Live transcription progress (stage, %, elapsed time) +- Live transcription progress over WebSockets (Reverb) on the list and detail pages - Stop or restart a run anytime - Copy finished transcripts from the recording detail page - - ## Requirements -- PHP 8.3+ (8.5 recommended) -- Composer -- Node.js & npm -- SQLite (default) or another supported database -- [Docker](https://docs.docker.com/get-docker/) for the Whisper container +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose (primary way to run the app) +- For native PHP development: PHP 8.3+ (8.5 recommended), Composer, Node.js & npm, SQLite - - -## Setup +## Quick start (Docker + FrankenPHP) ```bash -composer setup -``` - -That installs PHP and JS dependencies, copies `.env` if needed, generates the app key, runs migrations, and builds frontend assets. - -Or step by step: - -```bash -composer install cp .env.example .env -php artisan key:generate -touch database/database.sqlite # if using SQLite -php artisan migrate -npm install -npm run build +# Set a key (required by the app container): +php artisan key:generate # or: docker run --rm -v "$PWD":/app -w /app composer:2 php artisan key:generate + +docker compose up --build ``` +Open [http://localhost:8080/recordings](http://localhost:8080/recordings). +| Service | Host port | Role | +| --- | --- | --- | +| `app` | `8080` | FrankenPHP (Laravel) | +| `reverb` | `8081` | WebSockets for live status | +| `whisper` | `8090` | faster-whisper API | +| `queue` | — | `queue:work` for transcription jobs | -## Local Whisper (Docker) +### Persistent data mounts -Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that: +These host directories are bind-mounted into `app`, `queue`, and `reverb`: + +| Host path | Container path | Contents | +| --- | --- | --- | +| `./database` | `/app/database` | SQLite database | +| `./storage/app` | `/app/storage/app` | Uploaded audio (`private/recordings`) | +| `./storage/logs` | `/app/storage/logs` | Application logs | + +Whisper model cache uses the named volume `whisper-huggingface-cache`. + +### GPU Whisper (optional) ```bash -# CPU (works everywhere; slower on long files) -docker compose up -d whisper - -# Optional: NVIDIA GPU -docker compose --profile gpu up -d whisper-gpu +docker compose --profile gpu up -d --build ``` -First start downloads the model into a Docker volume (can take a few minutes). +Point `LOCAL_WHISPER_URL` at the GPU service if you run it instead of the CPU `whisper` service. -Check it: +### Useful Compose env -```bash -curl -s http://127.0.0.1:8090/health -``` +Copy values from `.env.example`. Important Docker-oriented variables: -Laravel talks to it at `LOCAL_WHISPER_URL` (default `http://127.0.0.1:8090/v1`). Port **8090** is used so it does not conflict with `php artisan serve` on 8000. +| Variable | Purpose | +| --- | --- | +| `APP_KEY` | Required — containers refuse to start without it | +| `APP_URL` | Default `http://localhost:8080` | +| `APP_HOST_PORT` | Host port for FrankenPHP (default `8080`) | +| `REVERB_HOST_PORT` | Host port for Reverb WebSockets (default `8081`) | +| `WHISPER_HOST_PORT` | Host port for Whisper (default `8090`) | +| `REVERB_APP_*` | Reverb credentials (baked into frontend at image build for `VITE_REVERB_*`) | +| `LOCAL_WHISPER_MODEL` | Model name for local transcription | +| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) | + +Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects to Reverb on `localhost:8081`. Stop: @@ -76,60 +81,34 @@ Stop: docker compose down ``` - - -## Configuration - -Copy values from `.env.example`. The transcription-related settings are: - - -| Variable | Purpose | -| ----------------------- | -------------------------------------------------------------------------------- | -| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) | -| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) | -| `LOCAL_WHISPER_MODEL` | Model name for local transcription | -| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) | -| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) | -| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) | -| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background | - - -Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript). - -Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`). - -## Running locally - -Start Whisper, then the app stack: +## Native PHP development (optional) ```bash -docker compose up -d whisper -composer run dev -``` - -Or separately: - -```bash -docker compose up -d whisper -php artisan serve +composer setup +docker compose up -d whisper reverb +# In separate terminals: +php artisan serve --port=8000 php artisan queue:work +php artisan reverb:start npm run dev ``` -Open [http://localhost:8000/recordings](http://localhost:8000/recordings). +Set `APP_URL=http://localhost:8000`, `LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1`, and Reverb `REVERB_HOST=localhost` / `REVERB_PORT=8080` (match `VITE_REVERB_*`). -Transcription jobs are queued — keep a queue worker running or jobs will stay pending. +## Configuration + +Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript). + +`DB_QUEUE_RETRY_AFTER` must exceed `TRANSCRIPTION_TIMEOUT` so long Whisper jobs are not re-queued mid-run. ## Usage 1. **Upload** audio from Recordings → Upload (single file or batch dropzone). -2. Transcription queues automatically — keep `php artisan queue:work` running. -3. Watch live progress on the recording page (or stop and restart). +2. Transcription queues automatically — the Compose `queue` service (or `php artisan queue:work`) must be running. +3. Watch live progress on the recordings list or detail page (Reverb); stop and restart anytime. 4. Search the list by title, artist, or transcript text. 5. If older uploads still show **Queued** with no progress, use **Queue pending transcriptions** on the recordings list. - - ## Tests ```bash @@ -138,8 +117,6 @@ composer test php artisan test ``` - - ## License -MIT \ No newline at end of file +MIT diff --git a/app/Events/RecordingTranscriptionUpdated.php b/app/Events/RecordingTranscriptionUpdated.php new file mode 100644 index 0000000..ad8206b --- /dev/null +++ b/app/Events/RecordingTranscriptionUpdated.php @@ -0,0 +1,50 @@ + + */ + public function broadcastOn(): array + { + return [ + new Channel('recordings'), + ]; + } + + public function broadcastAs(): string + { + return 'RecordingTranscriptionUpdated'; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return array_merge( + $this->recording->transcriptionStatusPayload(), + [ + 'word_count' => $this->recording->word_count, + ], + ); + } +} diff --git a/app/Http/Controllers/RecordingController.php b/app/Http/Controllers/RecordingController.php index 8f52478..0fcf8e2 100644 --- a/app/Http/Controllers/RecordingController.php +++ b/app/Http/Controllers/RecordingController.php @@ -49,7 +49,19 @@ class RecordingController extends Controller */ public function create(): View { - return view('recordings.create'); + $existingFingerprints = Recording::query() + ->get(['original_filename', 'file_size_bytes']) + ->map(fn (Recording $recording) => $this->uploadFingerprint( + $recording->original_filename, + (int) $recording->file_size_bytes, + )) + ->unique() + ->values() + ->all(); + + return view('recordings.create', [ + 'existingFingerprints' => $existingFingerprints, + ]); } /** @@ -65,24 +77,71 @@ class RecordingController extends Controller $titleOverride = $request->string('title')->trim()->toString(); $recordings = []; + $skippedDuplicates = 0; + $seenHashes = []; foreach ($files as $file) { + $hash = hash_file('sha256', $file->getRealPath()); + + if ($hash === false) { + continue; + } + + if ( + isset($seenHashes[$hash]) + || Recording::query()->where('content_hash', $hash)->exists() + || Recording::query() + ->where('original_filename', $file->getClientOriginalName()) + ->where('file_size_bytes', $file->getSize() ?: 0) + ->exists() + ) { + $skippedDuplicates++; + + continue; + } + + $seenHashes[$hash] = true; + $title = count($files) === 1 && $titleOverride !== '' ? $titleOverride : null; - $recordings[] = $this->storeUploadedRecording($file, $metadata, $title); + $recordings[] = $this->storeUploadedRecording($file, $metadata, $hash, $title); + } + + if ($recordings === [] && $skippedDuplicates > 0) { + return redirect() + ->route('recordings.create') + ->with('error', $skippedDuplicates === 1 + ? 'That file is already uploaded — nothing new was saved.' + : "All {$skippedDuplicates} files were duplicates — nothing new was saved."); + } + + if ($recordings === []) { + return redirect() + ->route('recordings.create') + ->with('error', 'No valid audio files were uploaded.'); + } + + $message = count($recordings) === 1 + ? 'Recording uploaded — transcription queued.' + : count($recordings).' recordings uploaded — transcription queued.'; + + if ($skippedDuplicates > 0) { + $message .= $skippedDuplicates === 1 + ? ' Skipped 1 duplicate.' + : " Skipped {$skippedDuplicates} duplicates."; } if (count($recordings) === 1) { return redirect() ->route('recordings.show', $recordings[0]) - ->with('success', 'Recording uploaded — transcription queued.'); + ->with('success', $message); } return redirect() ->route('recordings.index') - ->with('success', count($recordings).' recordings uploaded — transcription queued.'); + ->with('success', $message); } /** @@ -115,6 +174,7 @@ class RecordingController extends Controller private function storeUploadedRecording( UploadedFile $file, Mp3MetadataService $metadata, + string $contentHash, ?string $titleOverride = null, ): Recording { $path = $file->store('recordings', 'local'); @@ -133,6 +193,7 @@ class RecordingController extends Controller 'artist' => $tags['artist'], 'album' => $tags['album'], 'file_size_bytes' => $file->getSize() ?: 0, + 'content_hash' => $contentHash, 'transcription_status' => 'pending', 'transcription_driver' => 'local', ]); @@ -141,4 +202,12 @@ class RecordingController extends Controller return $recording->fresh(); } + + /** + * Client-side fingerprint for name + size duplicate checks before upload. + */ + private function uploadFingerprint(string $filename, int $sizeBytes): string + { + return strtolower($filename).':'.$sizeBytes; + } } diff --git a/app/Jobs/TranscribeRecording.php b/app/Jobs/TranscribeRecording.php index 65730b5..bbe16c9 100644 --- a/app/Jobs/TranscribeRecording.php +++ b/app/Jobs/TranscribeRecording.php @@ -143,6 +143,8 @@ class TranscribeRecording implements ShouldQueue 'transcribed_at' => now(), ])->save(); + $this->recording->broadcastTranscriptionUpdated(); + return true; } @@ -165,6 +167,8 @@ class TranscribeRecording implements ShouldQueue 'transcribed_at' => now(), ])->save(); + $this->recording->broadcastTranscriptionUpdated(); + return true; } diff --git a/app/Models/Recording.php b/app/Models/Recording.php index e68347d..5cd510b 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Events\RecordingTranscriptionUpdated; use App\Jobs\TranscribeRecording; use Carbon\Carbon; use Illuminate\Database\Eloquent\Attributes\Scope; @@ -28,6 +29,7 @@ class Recording extends Model 'artist', 'album', 'file_size_bytes', + 'content_hash', 'transcript', 'transcription_status', 'transcription_progress', @@ -129,7 +131,9 @@ class Recording extends Model 'transcribed_at' => $this->transcribed_at, ]); - TranscribeRecording::dispatch($this->fresh()); + $recording = $this->fresh(); + RecordingTranscriptionUpdated::dispatch($recording); + TranscribeRecording::dispatch($recording); } /** @@ -349,6 +353,8 @@ class Recording extends Model 'transcription_percent' => $this->transcription_percent ?: 0, 'transcription_error' => 'Stopped by user', ])->save(); + + RecordingTranscriptionUpdated::dispatch($this->fresh()); } /** @@ -376,6 +382,8 @@ class Recording extends Model 'transcription_percent' => $this->transcription_percent ?: 0, 'transcription_error' => $message, ])->save(); + + RecordingTranscriptionUpdated::dispatch($this->fresh()); } /** @@ -405,6 +413,16 @@ class Recording extends Model 'transcription_percent' => max(0, min(100, $percent)), 'transcription_error' => null, ])->save(); + + RecordingTranscriptionUpdated::dispatch($this->fresh()); + } + + /** + * Broadcast the current transcription status to connected browsers. + */ + public function broadcastTranscriptionUpdated(): void + { + RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this); } /** diff --git a/app/Services/DiskSpaceService.php b/app/Services/DiskSpaceService.php index 2d39d2e..421528c 100644 --- a/app/Services/DiskSpaceService.php +++ b/app/Services/DiskSpaceService.php @@ -4,7 +4,6 @@ namespace App\Services; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Number; class DiskSpaceService { @@ -26,6 +25,10 @@ class DiskSpaceService { $path ??= Storage::disk('local')->path(''); + if (! is_dir($path)) { + @mkdir($path, 0755, true); + } + /** @var array{total_bytes: int, free_bytes: int, used_bytes: int, used_percent: float, free_percent: float, total_human: string, free_human: string, used_human: string}|null */ return Cache::remember( 'disk-space:'.md5($path), @@ -67,9 +70,28 @@ class DiskSpaceService 'used_bytes' => $usedBytes, 'used_percent' => $usedPercent, 'free_percent' => $freePercent, - 'total_human' => Number::fileSize($totalBytes, precision: 1), - 'free_human' => Number::fileSize($freeBytes, precision: 1), - 'used_human' => Number::fileSize($usedBytes, precision: 1), + 'total_human' => $this->formatBytes($totalBytes), + 'free_human' => $this->formatBytes($freeBytes), + 'used_human' => $this->formatBytes($usedBytes), ]; } + + /** + * Human-readable byte size without requiring the intl extension. + */ + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $value = (float) max(0, $bytes); + $unit = 0; + + while ($value >= 1024 && $unit < count($units) - 1) { + $value /= 1024; + $unit++; + } + + $precision = $unit === 0 ? 0 : 1; + + return number_format($value, $precision).' '.$units[$unit]; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 1085719..99719a4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', + channels: __DIR__.'/../routes/channels.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { diff --git a/composer.json b/composer.json index 3914848..d5d88b1 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "james-heinrich/getid3": "^1.9", "laravel/ai": "^0.10.3", "laravel/framework": "^13.17", + "laravel/reverb": "^1.11", "laravel/tinker": "^3.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 10a365e..7232b64 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "864e82b4686c8cdd77882c9847d6eaa9", + "content-hash": "98ad77590b15b8b0d90cf1dc008cd739", "packages": [ { "name": "aws/aws-crt-php", @@ -285,6 +285,136 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -658,6 +788,53 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1637,6 +1814,85 @@ }, "time": "2026-08-04T14:50:50+00:00" }, + { + "name": "laravel/reverb", + "version": "v1.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.11.1" + }, + "time": "2026-08-06T14:48:58+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.15", @@ -3468,6 +3724,69 @@ }, "time": "2026-06-29T15:41:09+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.3.0", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "php": "^7.3|^8.0", + "psr/http-client": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.3.0" + }, + "time": "2026-08-07T12:47:11+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -3666,6 +3985,595 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.1", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1" + }, + "time": "2026-06-06T14:34:23+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..ebc3fb9 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,82 @@ + 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', + ], + + ], + +]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..91f3880 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,102 @@ + 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), + ], + ], + ], + + ], + +]; diff --git a/database/.gitignore b/database/.gitignore old mode 100644 new mode 100755 diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php old mode 100644 new mode 100755 diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php old mode 100644 new mode 100755 diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php old mode 100644 new mode 100755 diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php old mode 100644 new mode 100755 diff --git a/database/migrations/2026_08_12_114417_create_recordings_table.php b/database/migrations/2026_08_12_114417_create_recordings_table.php old mode 100644 new mode 100755 diff --git a/database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php b/database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php old mode 100644 new mode 100755 diff --git a/database/migrations/2026_08_12_153209_add_content_hash_to_recordings_table.php b/database/migrations/2026_08_12_153209_add_content_hash_to_recordings_table.php new file mode 100644 index 0000000..67f6e8f --- /dev/null +++ b/database/migrations/2026_08_12_153209_add_content_hash_to_recordings_table.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php old mode 100644 new mode 100755 diff --git a/docker-compose.yml b/docker-compose.yml index c86f38f..ba8363c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,150 @@ +x-data volumes: + app-data: &app-data + - ./database:/app/database + - ./storage/app:/app/storage/app + - ./storage/logs:/app/storage/logs + +x-app-env: &app-env + APP_NAME: AndyTranscribe + APP_ENV: ${APP_ENV:-local} + APP_KEY: ${APP_KEY} + APP_DEBUG: ${APP_DEBUG:-true} + APP_URL: ${APP_URL:-http://localhost:8080} + APP_PORT: ${APP_HOST_PORT:-8080} + LOG_CHANNEL: stderr + DB_CONNECTION: sqlite + DB_DATABASE: /app/database/database.sqlite + SESSION_DRIVER: database + QUEUE_CONNECTION: database + CACHE_STORE: database + BROADCAST_CONNECTION: reverb + FILESYSTEM_DISK: local + REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe} + REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key} + REVERB_APP_SECRET: ${REVERB_APP_SECRET:-andytranscribe-secret} + # Server-side publish target (Docker DNS) + REVERB_HOST: reverb + REVERB_PORT: 8080 + REVERB_SCHEME: http + REVERB_SERVER_HOST: 0.0.0.0 + REVERB_SERVER_PORT: 8080 + LOCAL_WHISPER_URL: http://whisper:8000/v1 + LOCAL_WHISPER_API_KEY: ${LOCAL_WHISPER_API_KEY:-not-needed} + LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base} + TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600} + DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660} + services: + app: + build: + context: . + dockerfile: Dockerfile + args: + VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe} + VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key} + VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost} + VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081} + VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http} + image: andytranscribe-app:latest + container_name: andytranscribe-app + ports: + - "${APP_HOST_PORT:-8080}:80" + environment: + <<: *app-env + volumes: *app-data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1/up"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + depends_on: + whisper: + condition: service_healthy + reverb: + condition: service_started + queue: + condition: service_started + restart: unless-stopped + + queue: + build: + context: . + dockerfile: Dockerfile + args: + VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe} + VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key} + VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost} + VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081} + VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http} + image: andytranscribe-app:latest + container_name: andytranscribe-queue + command: + - php + - artisan + - queue:work + - database + - --sleep=1 + - --tries=1 + - --timeout=${TRANSCRIPTION_TIMEOUT:-600} + environment: + <<: *app-env + volumes: *app-data + healthcheck: + test: ["CMD-SHELL", "tr '\\0' ' ' /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 "$@" diff --git a/package-lock.json b/package-lock.json index 69a4e8d..bbb518c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,8 +6,11 @@ "": { "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.16.1", "concurrently": "^10.0.3", + "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.1", + "pusher-js": "^8.6.0", "tailwindcss": "^4.0.0", "vite": "^8.0.0" }, @@ -661,6 +664,33 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz", + "integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1128,6 +1158,28 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/laravel-echo": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz", + "integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + }, + "peerDependenciesMeta": { + "pusher-js": { + "optional": true + }, + "socket.io-client": { + "optional": true + } + } + }, "node_modules/laravel-vite-plugin": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", @@ -1542,6 +1594,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pusher-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz", + "integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1822,6 +1884,13 @@ "dev": true, "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-fest": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", diff --git a/package.json b/package.json index 13ec885..cdac399 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,11 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.16.1", "concurrently": "^10.0.3", + "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.1", + "pusher-js": "^8.6.0", "tailwindcss": "^4.0.0", "vite": "^8.0.0" }, diff --git a/resources/js/app.js b/resources/js/app.js index 8337712..8a519c4 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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(); diff --git a/resources/js/echo.js b/resources/js/echo.js new file mode 100644 index 0000000..9349afa --- /dev/null +++ b/resources/js/echo.js @@ -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'], +}); diff --git a/resources/js/transcription.js b/resources/js/transcription.js new file mode 100644 index 0000000..fed7bd4 --- /dev/null +++ b/resources/js/transcription.js @@ -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] || {}; + }, + }; +} diff --git a/resources/js/upload.js b/resources/js/upload.js new file mode 100644 index 0000000..2f24471 --- /dev/null +++ b/resources/js/upload.js @@ -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'; + }, + }; +} diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index efa54b0..0958c50 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -5,6 +5,7 @@ @yield('title', 'Recordings') — {{ config('app.name', 'AndyTranscribe') }} @vite(['resources/css/app.css', 'resources/js/app.js']) + diff --git a/resources/views/recordings/create.blade.php b/resources/views/recordings/create.blade.php index b73d164..12084d6 100644 --- a/resources/views/recordings/create.blade.php +++ b/resources/views/recordings/create.blade.php @@ -7,6 +7,7 @@

Upload recordings

Drop one or many pocket-recorder files. Embedded metadata is extracted when available. + Duplicate files (same name and size, or identical content) are skipped.

@@ -14,7 +15,7 @@ method="POST" action="{{ route('recordings.store') }}" enctype="multipart/form-data" - x-data="uploadDropzone()" + x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))" @submit="ensureFilesSelected($event)" class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm" > @@ -61,7 +62,7 @@
    -