Compare commits

20 Commits
Author SHA1 Message Date
ben 8a66cf6f63 Improve recordings list sorting and live show-page status updates.
Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
2026-08-12 21:27:03 +02:00
ben 5f0f61995c Fix Docker builds when livewire-tmp is root-owned.
Exclude whole storage trees from the build context, create cache dirs before dump-autoload, and drop Buildx-only cache mounts.
2026-08-12 20:47:58 +02:00
ben b7c36b8b3b Compact recordings list rows to title plus one transcript preview line. 2026-08-12 20:36:22 +02:00
ben 6bc5a20606 Speed up Docker builds with a single image and parallel deps.
Build app once for queue/reverb, run Composer and npm in parallel with cache mounts, and skip redundant vite npm ci on restart.
2026-08-12 20:20:06 +02:00
ben c17f8fb506 Improve live recordings UI, upload flow, and default Flux theme.
Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login.
2026-08-12 20:14:35 +02:00
ben 148ba91816 Add delete button with confirm modal on each recordings list row. 2026-08-12 19:23:35 +02:00
ben 34ccf0c32b Move recordings UI to Livewire pages with Flux components.
Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
2026-08-12 19:17:52 +02:00
ben bfcfc12f58 Add Fortify auth, Flux dark mode, demo seed, and Docker hot reload.
Protect recordings per user, seed a demo login on container start, and bind-mount the app with Vite HMR for local Compose development.
2026-08-12 18:38:53 +02:00
ben accb721811 Enable SQLite WAL mode for better concurrent access. 2026-08-12 18:31:37 +02:00
ben 386b15ce94 Clarify Docker install steps in the README.
Make setup a numbered walkthrough covering env, APP_KEY generation, compose up, and verification.
2026-08-12 17:49:55 +02:00
ben e81a27cb8f Raise audio upload limit to 2 GB per file.
Update validation, dropzone checks, Docker PHP ini, and docs so multi-hour recordings can be uploaded.
2026-08-12 17:42:55 +02:00
ben 771ee8db5a 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.
2026-08-12 17:35:48 +02:00
ben 352b564f3a Add DiskSpaceService and DiskSpaceBar component for monitoring disk usage
Implement DiskSpaceService to provide snapshots of disk space usage, including total, free, and used bytes, as well as human-readable formats. Create DiskSpaceBar component to display disk usage information in the UI, including a progress bar that visually represents used space. Update app layout to include the DiskSpaceBar and add tests to ensure functionality of the disk space service and its integration in the layout.
2026-08-12 16:33:57 +02:00
ben 1dcdfc0ed0 Auto-queue transcription on upload and clarify pending status.
Batch uploads were only storing files as pending without dispatching Whisper jobs; queue them immediately, add a bulk pending action, and show human-readable status labels.
2026-08-12 16:26:36 +02:00
ben 3498861184 Recover transcripts falsely marked orphaned mid-run.
Keep finishing Whisper jobs from overwriting a superseded run, while restoring results when orphan detection failed the same run too early.
2026-08-12 15:34:36 +02:00
ben 21b17c7657 Use local faster-whisper only for transcription.
Drop cloud and Ollama engine choices so audio stays on-machine via Docker Whisper, and tighten orphan detection plus UI around a single local flow.
2026-08-12 15:34:16 +02:00
ben bc6cb5efa4 Fix stuck transcriptions and make transcripts stoppable and searchable.
Raise queue retry_after above the job timeout, recover orphaned runs, allow stop/restart with any engine, and keep finished transcripts searchable in the recordings list.
2026-08-12 15:10:33 +02:00
ben eb0f019025 Fix Whisper healthcheck and root redirect test. 2026-08-12 14:42:30 +02:00
ben e0400aadef Add live transcription progress and Docker Whisper.
Surface stage, percent, and elapsed time while jobs run, and ship Compose so local confidential transcription can use faster-whisper on port 8090.
2026-08-12 14:32:56 +02:00
ben 67c1941833 Support common audio formats beyond MP3.
Accept WAV, OGG, FLAC, M4A, and related uploads, and read metadata from non-ID3 tag formats.
2026-08-12 14:32:56 +02:00
116 changed files with 9449 additions and 729 deletions
+34
View File
@@ -0,0 +1,34 @@
.git
.gitattributes
.github
.idea
.vscode
.cursor
.claude
.ai
node_modules
vendor
public/build
public/hot
# Exclude whole trees (not only /**) so Docker never tries to stat root-owned tmp dirs
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
todo.txt
*.md
!README.md
+37 -7
View File
@@ -2,7 +2,7 @@ APP_NAME=AndyTranscribe
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_URL=http://localhost:8080
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
@@ -64,11 +64,41 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# AndyTranscribe / Laravel AI
OPENAI_API_KEY=
OPENAI_URL=https://api.openai.com/v1
LOCAL_WHISPER_URL=http://localhost:8000/v1
# 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
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=localhost
# Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start
VITE_REVERB_PORT="${REVERB_HOST_PORT}"
VITE_REVERB_SCHEME=http
# AndyTranscribe / local faster-whisper
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
LOCAL_WHISPER_API_KEY=not-needed
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660
# Demo user created on every container start (db:seed via entrypoint)
SEED_USER_NAME="Demo User"
SEED_USER_EMAIL=demo@example.com
SEED_USER_PASSWORD=password
# Uncomment for Docker bind mounts + Vite HMR (no rebuild for PHP/Blade/CSS/JS):
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
# VITE_HOST_PORT=5173
+108
View File
@@ -0,0 +1,108 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Composer deps (runs in parallel with npm ci)
# ---------------------------------------------------------------------------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
# ---------------------------------------------------------------------------
# npm ci only (parallel with vendor when BuildKit is available)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS npm
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---------------------------------------------------------------------------
# Vite production build (needs Flux/Livewire + Laravel pagination views)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS assets
WORKDIR /app
COPY --from=npm /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY --from=vendor /app/vendor/livewire ./vendor/livewire
COPY --from=vendor /app/vendor/laravel/framework/src/Illuminate/Pagination \
./vendor/laravel/framework/src/Illuminate/Pagination
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
# ---------------------------------------------------------------------------
# Runtime image
# ---------------------------------------------------------------------------
FROM dunglas/frankenphp:php8.5-bookworm
# Rarely changes — keep early for cache hits
RUN install-php-extensions \
pcntl \
pdo_sqlite \
sqlite3 \
zip \
bcmath \
intl \
opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /app
# Dependency layer (invalidates when lockfiles / vendor change)
COPY --from=vendor /app/vendor ./vendor
COPY composer.json composer.lock ./
# Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
COPY . .
# Built frontend assets
COPY --from=assets /app/public/build ./public/build
# Framework/view cache paths must exist before package:discover runs during dump-autoload
RUN mkdir -p \
storage/app/private \
storage/app/public \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
database \
bootstrap/cache \
&& composer dump-autoload --optimize --no-dev \
&& chown -R www-data:www-data storage bootstrap/cache database
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["frankenphp", "php-server", "--listen", ":80", "--root", "/app/public"]
+207 -56
View File
@@ -1,101 +1,252 @@
# AndyTranscribe
Upload pocket-recorder MP3s, extract ID3 metadata, and transcribe them with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint.
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). 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, Livewire, Flux UI, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai).
## Features
- Upload MP3s (up to 100 MB) and store them on the local disk
- Automatic ID3 metadata extraction (title, artist, album, duration, recorded date)
- User accounts with login and open registration (each user only sees their own recordings)
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB)
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
- Search recordings by title, artist, or transcript
- Queued transcription with three engines:
- **Cloud** — OpenAI Whisper (`whisper-1`)
- **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) (e.g. Docker on this machine)
- **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions`
- Queued local transcription (faster-whisper in Docker)
- 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
- For cloud transcription: an OpenAI API key
- For local transcription: a running faster-whisper-server
- For remote transcription: a host with an OpenAI-compatible transcription API
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- About 2 GB free disk for the Whisper model cache (first run downloads the model)
## Setup
Optional for native PHP development: PHP 8.3+ (8.5 recommended), Composer, Node.js & npm.
## Install
These steps run the full stack with Docker: web app, queue worker, Reverb WebSockets, and Whisper.
### 1. Clone the repository
```bash
composer setup
git clone <your-repo-url> andyTranscibe
cd andyTranscibe
```
That installs PHP and JS dependencies, copies `.env` if needed, generates the app key, runs migrations, and builds frontend assets.
Or step by step:
### 2. Create your environment file
```bash
composer install
cp .env.example .env
```
### 3. Generate an application key
`APP_KEY` is required. Containers refuse to start if it is empty.
**Option A — PHP installed on the host**
```bash
php artisan key:generate
touch database/database.sqlite # if using SQLite
php artisan migrate
npm install
npm run build
```
## Configuration
**Option B — Docker only**
Copy values from `.env.example`. The transcription-related settings are:
Print a key:
| Variable | Purpose |
```bash
docker run --rm php:8.5-cli php -r "echo 'base64:'.base64_encode(random_bytes(32)), PHP_EOL;"
```
Open `.env` and set:
```env
APP_KEY=base64:paste-the-value-here
```
On Linux you can write it in one step:
```bash
KEY=$(docker run --rm php:8.5-cli php -r "echo 'base64:'.base64_encode(random_bytes(32));")
sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
```
### 4. Start the stack
```bash
docker compose up --build -d
```
Compose builds the **app** image once; `queue` and `reverb` reuse `andytranscribe-app:latest` (no triple rebuild). With the [dev overlay](#local-development-hot-reload), skip `--build` for routine PHP/Blade/JS work — the repo is bind-mounted.
On first start the app container will:
- create `database/database.sqlite` if needed
- run migrations
- seed a demo user (see below)
- start FrankenPHP on port **8080**
Whisper may take a minute or two while the model downloads.
### Demo login
Every container start runs `db:seed`, which ensures these users exist:
| Email | Password |
| --- | --- |
| `OPENAI_API_KEY` | Required for cloud Whisper |
| `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) |
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://localhost:8000/v1`) |
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
| `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
| `demo@example.com` | `password` |
| `admin@example.com` | `password` |
Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`).
Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
## Running locally
### 5. Open the app
Start the app, queue worker, and Vite together:
| URL | Purpose |
| --- | --- |
| [http://localhost:8080/recordings](http://localhost:8080/recordings) | App UI |
| [http://localhost:8080/up](http://localhost:8080/up) | Health check |
Live transcription status uses WebSockets on port **8081** (Reverb). Keep that port reachable from your browser.
### 6. Verify services (optional)
```bash
composer run dev
docker compose ps
docker compose logs -f app queue whisper reverb
```
Or separately:
You should see `app`, `queue`, `reverb`, and `whisper` running. Whisper becomes healthy after `/health` succeeds.
### Stop / restart
```bash
php artisan serve
php artisan queue:work
npm run dev
docker compose down
docker compose up -d
```
Open [http://localhost:8000/recordings](http://localhost:8000/recordings).
Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host.
Transcription jobs are queued — keep a queue worker running or jobs will stay pending.
If `docker compose build` fails with `can't stat .../storage/app/private/livewire-tmp`, a container created that directory as root. Fix ownership (or remove it), then rebuild:
```bash
sudo chown -R "$USER:$USER" storage
# or: sudo rm -rf storage/app/private/livewire-tmp
docker compose up --build -d
```
### Live reload while developing
Default Compose uses the built image, so PHP/Blade/CSS/JS changes need a rebuild. For day-to-day work, use the dev overlay (bind-mounts the repo and runs Vite HMR):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
```
Or set once in `.env`:
```env
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
```
Then a normal `docker compose up -d` enables:
- host source mounted at `/app` (PHP, Blade, routes, etc. without rebuild)
- `vite` on port **5173** for CSS/JS hot reload and Blade refresh
- `queue:listen` so worker code picks up changes between jobs
Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`.
## Services and ports
| Service | Host port | Role |
| --- | --- | --- |
| `app` | `8080` | FrankenPHP (Laravel web UI) |
| `reverb` | `8081` | WebSockets for live transcription status |
| `whisper` | `8090` | faster-whisper HTTP API |
| `queue` | — | `php artisan queue:work` for transcription jobs |
| `vite` | `5173` | Vite HMR (dev overlay only) |
### Persistent data
| 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 Docker volume `whisper-huggingface-cache`.
### Useful environment variables
Edit `.env` before `docker compose up` when you need different ports or models:
| Variable | Purpose | Default |
| --- | --- | --- |
| `APP_KEY` | Required Laravel encryption key | — |
| `APP_URL` | Public app URL | `http://localhost:8080` |
| `APP_HOST_PORT` | Host port for the web app | `8080` |
| `REVERB_HOST_PORT` | Host port for WebSockets | `8081` |
| `WHISPER_HOST_PORT` | Host port for Whisper | `8090` |
| `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds) | `600` |
| `DB_QUEUE_RETRY_AFTER` | Must exceed `TRANSCRIPTION_TIMEOUT` | `660` |
Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects to Reverb on `localhost:8081`.
If you change `REVERB_APP_KEY` or browser-facing Reverb host/port settings, rebuild so Vite embeds the new values:
```bash
docker compose up --build -d
```
### GPU Whisper (optional)
```bash
docker compose --profile gpu up -d --build
```
Use the GPU Whisper service instead of the CPU `whisper` service when you have an NVIDIA GPU and the NVIDIA Container Toolkit installed.
## Usage
1. **Upload** an MP3 from Recordings → Upload (optional title override).
2. Open the recording and choose a transcription engine.
3. Wait for the queue job to finish, then refresh to view or copy the transcript.
4. Search the list by title, artist, or transcript text.
1. Open the app and **Log in** with `admin@example.com` / `password` (or `demo@example.com` / `password`), or **Register** a new account.
2. Open **Recordings → Upload** and drop one or many audio files.
3. Transcription starts automatically (the `queue` service must be running).
4. Watch live progress on the list or detail page; stop or restart anytime.
5. Search by title, artist, or transcript text.
6. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
## Transcription engines
The demo user is re-seeded on every container start. Any recordings with no owner are assigned to that demo user. Later registered users only see their own uploads.
| Driver | When to use | Needs |
| --- | --- | --- |
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
| `local` | Confidential; audio stays on this machine | faster-whisper-server at `LOCAL_WHISPER_URL` |
| `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` |
Finished transcripts are stored on each recording and are included in search.
## Native PHP development (optional)
For hacking on the Laravel app outside the FrankenPHP image:
```bash
composer setup
docker compose up -d whisper reverb
```
Then in separate terminals:
```bash
php artisan serve --port=8000
php artisan queue:work
php artisan reverb:start
npm run dev
```
Point `.env` at local services, for example:
```env
APP_URL=http://localhost:8000
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
REVERB_HOST=localhost
REVERB_PORT=8080
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
```
## Tests
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Actions;
use App\Models\Recording;
use App\Models\User;
use App\Services\Mp3MetadataService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class StoreUploadedRecordings
{
public function __construct(private Mp3MetadataService $metadata) {}
/**
* Persist uploaded audio files and queue transcription.
*
* @param list<UploadedFile> $files
* @return array{
* recordings: list<Recording>,
* skipped_duplicates: int,
* message: string,
* }
*/
public function handle(User $user, array $files, ?string $titleOverride = null): array
{
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| $user->recordings()->where('content_hash', $hash)->exists()
|| $user->recordings()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && filled($titleOverride)
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($user, $file, $hash, $title);
}
$message = $this->message(count($recordings), $skippedDuplicates);
return [
'recordings' => $recordings,
'skipped_duplicates' => $skippedDuplicates,
'message' => $message,
];
}
private function storeUploadedRecording(
User $user,
UploadedFile $file,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $this->metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
private function message(int $savedCount, int $skippedDuplicates): string
{
if ($savedCount === 0 && $skippedDuplicates > 0) {
return $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.";
}
if ($savedCount === 0) {
return 'No valid audio files were uploaded.';
}
$message = $savedCount === 1
? 'Recording uploaded — transcription queued.'
: $savedCount.' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
return $message;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Recording;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
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, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('recording.'.$this->recording->id),
new PrivateChannel('user.'.$this->recording->user_id.'.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,
],
);
}
}
@@ -1,95 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreRecordingRequest;
use App\Models\Recording;
use App\Services\Mp3MetadataService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class RecordingController extends Controller
{
/**
* Display a listing of recordings.
*/
public function index(Request $request): View
{
$query = Recording::query()->latest();
if ($search = $request->string('q')->trim()->toString()) {
$query->where(function ($builder) use ($search) {
$builder->where('title', 'like', "%{$search}%")
->orWhere('artist', 'like', "%{$search}%")
->orWhere('album', 'like', "%{$search}%")
->orWhere('original_filename', 'like', "%{$search}%")
->orWhere('transcript', 'like', "%{$search}%");
});
}
$recordings = $query->paginate(20)->withQueryString();
return view('recordings.index', compact('recordings', 'search'));
}
/**
* Show the upload form.
*/
public function create(): View
{
return view('recordings.create');
}
/**
* Store a newly uploaded recording.
*/
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
{
$file = $request->file('audio');
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $metadata->extract($absolutePath);
$title = $request->string('title')->trim()->toString()
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'transcription_status' => 'pending',
]);
return redirect()
->route('recordings.show', $recording)
->with('success', 'Recording uploaded successfully.');
}
/**
* Display the specified recording.
*/
public function show(Recording $recording): View
{
return view('recordings.show', compact('recording'));
}
/**
* Remove the specified recording.
*/
public function destroy(Recording $recording): RedirectResponse
{
$recording->deleteFile();
$recording->delete();
return redirect()
->route('recordings.index')
->with('success', 'Recording deleted.');
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StreamRecordingController extends Controller
{
/**
* Stream the recording audio for in-browser playback.
*/
public function __invoke(Recording $recording): StreamedResponse
{
Gate::authorize('view', $recording);
abort_unless(
$recording->file_path && Storage::disk('local')->exists($recording->file_path),
404,
);
return Storage::disk('local')->response(
$recording->file_path,
$recording->original_filename,
[
'Content-Type' => $recording->audioMimeType(),
'Accept-Ranges' => 'bytes',
],
'inline',
);
}
}
@@ -1,35 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
class TranscribeController extends Controller
{
/**
* Queue transcription for the recording with the chosen engine.
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
if (in_array($recording->transcription_status, ['processing'], true)) {
return back()->with('error', 'Transcription is already in progress.');
}
$driver = $request->validated('driver');
$recording->update([
'transcription_driver' => $driver,
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
'transcription_status' => 'pending',
'transcript' => null,
'transcribed_at' => null,
]);
TranscribeRecording::dispatch($recording->fresh());
return back()->with('success', 'Transcription started. Refresh in a moment to see the result.');
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
class TranscriptionStatusController extends Controller
{
/**
* Live transcription progress for polling.
*/
public function __invoke(Recording $recording): JsonResponse
{
Gate::authorize('view', $recording);
$recording = $recording->fresh();
if ($recording->recoverOrphanedTranscription()) {
$recording->refresh();
}
return response()->json($recording->transcriptionStatusPayload());
}
}
+51 -5
View File
@@ -3,12 +3,51 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rules\File;
class StoreRecordingRequest extends FormRequest
{
/**
* Popular audio extensions Whisper-compatible providers typically accept.
*
* @var list<string>
*/
public const AUDIO_EXTENSIONS = [
'mp3',
'mpeg',
'mpga',
'wav',
'ogg',
'oga',
'flac',
'm4a',
'mp4',
'aac',
'webm',
'wma',
'aiff',
'aif',
];
/**
* Maximum upload size accepted by validation (2 GiB).
*/
public const MAX_AUDIO_KILOBYTES = 2 * 1024 * 1024;
public function authorize(): bool
{
return true;
return $this->user() !== null;
}
/**
* Normalize a single file upload into an array for batch handling.
*/
protected function prepareForValidation(): void
{
if ($this->hasFile('audio') && $this->file('audio') instanceof UploadedFile) {
$this->files->set('audio', [$this->file('audio')]);
}
}
/**
@@ -17,7 +56,11 @@ class StoreRecordingRequest extends FormRequest
public function rules(): array
{
return [
'audio' => ['required', 'file', 'mimes:mp3,mpeg', 'max:102400'],
'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required',
File::types(self::AUDIO_EXTENSIONS)->max('2gb'),
],
'title' => ['nullable', 'string', 'max:255'],
];
}
@@ -28,9 +71,12 @@ class StoreRecordingRequest extends FormRequest
public function messages(): array
{
return [
'audio.required' => 'Please choose an MP3 file to upload.',
'audio.mimes' => 'Only MP3 files are supported.',
'audio.max' => 'The audio file may not be larger than 100 MB.',
'audio.required' => 'Please choose at least one audio file to upload.',
'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'You can upload at most 50 files at once.',
'audio.*.required' => 'Please choose an audio file to upload.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
];
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TranscribeRecordingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'driver' => ['required', Rule::in(['cloud', 'local', 'ollama'])],
'ollama_url' => [
Rule::requiredIf(fn () => $this->input('driver') === 'ollama'),
'nullable',
'url',
'regex:/^https?:\/\//i',
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'driver.required' => 'Choose a transcription engine.',
'ollama_url.required' => 'Enter the URL of the Ollama host (OpenAI-compatible Whisper endpoint).',
'ollama_url.url' => 'Enter a valid URL, e.g. http://192.168.1.50:8000',
'ollama_url.regex' => 'The host URL must start with http:// or https://',
];
}
}
+144 -13
View File
@@ -13,17 +13,28 @@ class TranscribeRecording implements ShouldQueue
{
use Queueable;
/**
* The number of times the job may be attempted.
*/
public int $tries = 1;
/**
* The number of seconds the job can run before timing out.
*/
public int $timeout = 600;
public int $timeout;
/**
* ISO-8601 transcription_started_at this job owns (ignored after cancel/restart).
*/
public ?string $runStartedAt = null;
/**
* Create a new job instance.
*/
public function __construct(public Recording $recording)
{
//
$this->timeout = max(60, (int) config('ai.transcription_timeout', 600));
$this->runStartedAt = $recording->transcription_started_at?->toIso8601String();
}
/**
@@ -31,30 +42,150 @@ class TranscribeRecording implements ShouldQueue
*/
public function handle(TranscriptionService $transcription): void
{
$this->recording->update([
$recording = Recording::query()->find($this->recording->id);
if ($recording === null) {
return;
}
$this->recording = $recording;
if ($this->runStartedAt !== null && ! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
return;
}
if ($this->runStartedAt === null && ! $this->recording->isTranscribing()) {
return;
}
$this->recording->forceFill([
'transcription_status' => 'processing',
]);
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
'transcription_error' => null,
])->save();
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
$this->reportIfOwned('Preparing audio file…', 15);
try {
$text = $transcription->transcribe($this->recording);
$text = $transcription->transcribe(
$this->recording,
function (string $message, int $percent): void {
$this->reportIfOwned($message, $percent);
},
);
$this->recording->update([
'transcript' => $text,
'transcription_status' => 'done',
'transcribed_at' => now(),
]);
if (! $this->claimSuccessfulTranscript($text)) {
return;
}
} catch (Throwable $e) {
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
Log::warning('Transcription exception ignored after run was superseded', [
'recording_id' => $this->recording->id,
'message' => $e->getMessage(),
]);
return;
}
Log::error('Transcription failed', [
'recording_id' => $this->recording->id,
'driver' => $this->recording->transcription_driver,
'message' => $e->getMessage(),
'exception' => $e::class,
]);
$this->recording->update([
'transcription_status' => 'failed',
]);
$this->recording->markTranscriptionFailed($e->getMessage());
throw $e;
}
}
/**
* Handle a job failure (timeouts, worker kill, etc.).
*/
public function failed(?Throwable $e): void
{
$recording = Recording::query()->find($this->recording->id);
if ($recording === null || ! $recording->ownsTranscriptionRun($this->runStartedAt)) {
return;
}
$recording->markTranscriptionFailed(
$e?->getMessage() ?: 'Transcription stopped unexpectedly.',
);
}
/**
* Persist a finished transcript when this job still owns the run.
*
* Also recovers runs that were falsely marked failed by orphan detection
* while Whisper was still working.
*/
private function claimSuccessfulTranscript(string $text): bool
{
$this->recording->refresh();
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
$this->reportIfOwned('Saving transcript…', 90);
$this->recording->refresh();
}
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
$this->recording->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
return true;
}
// Same run was wrongly marked failed as "orphaned" while Whisper was still running.
if (
$this->recording->transcription_status === 'failed'
&& $this->recording->matchesTranscriptionRun($this->runStartedAt)
&& str_contains((string) $this->recording->transcription_error, 'worker stopped')
) {
Log::warning('Recovering transcript after false orphan failure', [
'recording_id' => $this->recording->id,
]);
$this->recording->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
return true;
}
Log::info('Discarding transcript because transcription run was superseded', [
'recording_id' => $this->recording->id,
'status' => $this->recording->transcription_status,
]);
return false;
}
private function reportIfOwned(string $message, int $percent): void
{
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
return;
}
$this->recording->reportProgress($message, $percent);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
class AssignOrphanedRecordings
{
/**
* Assign unowned recordings to the first registered user.
*/
public function handle(Registered $event): void
{
if (User::query()->count() !== 1) {
return;
}
Recording::query()
->whereNull('user_id')
->update(['user_id' => $event->user->id]);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Features\SupportRedirects\Redirector;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): Redirector|RedirectResponse
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Livewire\Recordings;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Upload recording')]
class Create extends Component
{
public function render(): View
{
return view('livewire.recordings.create');
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('Recordings')]
class Index extends Component
{
use WithPagination;
public int $userId;
#[Url(as: 'q', history: true)]
public string $search = '';
#[Url(as: 'sort', history: true)]
public string $sortBy = 'uploaded';
#[Url(as: 'dir', history: true)]
public string $sortDirection = 'desc';
/**
* @var array<string, string>
*/
private const SORTABLE = [
'title' => 'title',
'duration' => 'duration_seconds',
'status' => 'transcription_status',
'uploaded' => 'created_at',
];
public function mount(): void
{
$this->userId = (int) Auth::id();
$this->normalizeSort();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function sort(string $column): void
{
if ($column !== 'words' && ! array_key_exists($column, self::SORTABLE)) {
return;
}
if ($this->sortBy === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDirection = $column === 'uploaded' ? 'desc' : 'asc';
}
$this->resetPage();
}
/**
* Re-render when any of this user's recordings broadcast a status change.
*/
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
public function onTranscriptionUpdated(): void
{
//
}
public function queuePending(): void
{
$queued = 0;
Auth::user()->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->orderBy('id')
->each(function (Recording $recording) use (&$queued): void {
if ($recording->hasActiveTranscriptionJob()) {
return;
}
$recording->queueLocalTranscription();
$queued++;
});
if ($queued === 0) {
Flux::toast(text: 'No recordings need transcription right now.', variant: 'danger');
return;
}
Flux::toast(
text: $queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
variant: 'success',
);
}
public function delete(int $recordingId): void
{
$recording = Auth::user()->recordings()->findOrFail($recordingId);
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
Flux::toast(text: 'Recording deleted.', variant: 'success');
}
public function deleteAll(): void
{
$deleted = 0;
Auth::user()->recordings()
->orderBy('id')
->each(function (Recording $recording) use (&$deleted): void {
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
$deleted++;
});
$this->resetPage();
if ($deleted === 0) {
Flux::toast(text: 'No recordings to delete.', variant: 'danger');
return;
}
Flux::toast(
text: $deleted === 1
? 'Deleted 1 recording.'
: "Deleted {$deleted} recordings.",
variant: 'success',
);
}
public function render(): View
{
$user = Auth::user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$totalCount = $user->recordings()->count();
$this->normalizeSort();
$query = $user->recordings();
$search = trim($this->search);
if ($search !== '') {
$query->search($search);
}
$this->applySort($query);
$recordings = $query->paginate(20);
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
$hasActiveTranscriptions = $user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->exists();
return view('livewire.recordings.index', [
'recordings' => $recordings,
'search' => $search,
'pendingCount' => $pendingCount,
'hasActiveTranscriptions' => $hasActiveTranscriptions,
'totalCount' => $totalCount,
]);
}
private function normalizeSort(): void
{
if ($this->sortBy !== 'words' && ! array_key_exists($this->sortBy, self::SORTABLE)) {
$this->sortBy = 'uploaded';
}
if (! in_array($this->sortDirection, ['asc', 'desc'], true)) {
$this->sortDirection = 'desc';
}
}
/**
* @param Builder<Recording>|HasMany<Recording, User> $query
*/
private function applySort(Builder|HasMany $query): void
{
$direction = $this->sortDirection === 'asc' ? 'asc' : 'desc';
if ($this->sortBy === 'words') {
$query->orderByRaw(
'CASE WHEN transcript IS NULL OR TRIM(transcript) = ? THEN 0 ELSE LENGTH(TRIM(transcript)) - LENGTH(REPLACE(TRIM(transcript), ?, ?)) + 1 END '.$direction,
['', ' ', ''],
);
} else {
$query->orderBy(self::SORTABLE[$this->sortBy], $direction);
}
$query->orderByDesc('id');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Recording $recording;
public int $userId;
public function mount(Recording $recording): void
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
$this->recording = $recording;
$this->userId = (int) $recording->user_id;
}
/**
* Re-render when this recording broadcasts a status change (same channel as the index).
*
* @param array<string, mixed> $event
*/
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
public function onTranscriptionUpdated(array $event = []): void
{
if (isset($event['id']) && (int) $event['id'] !== (int) $this->recording->id) {
return;
}
$this->recording->refresh();
}
public function startTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
$this->recording->queueLocalTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription started. Progress updates below.', variant: 'success');
}
public function cancelTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
if (! $this->recording->isTranscribing()) {
Flux::toast(text: 'No transcription is currently running.', variant: 'danger');
return;
}
$this->recording->cancelTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription stopped.', variant: 'success');
}
public function delete(): mixed
{
Gate::authorize('delete', $this->recording);
$this->recording->deleteFile();
$this->recording->delete();
session()->flash('success', 'Recording deleted.');
return $this->redirect(route('recordings.index'), navigate: true);
}
public function render(): View
{
if ($this->recording->isTranscribing()) {
$this->recording->refresh();
}
return view('livewire.recordings.show')
->title($this->recording->title);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Actions\StoreUploadedRecordings;
use App\Http\Requests\StoreRecordingRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\File;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class UploadRecordings extends Component
{
use WithFileUploads;
/**
* @var list<TemporaryUploadedFile>
*/
public array $audio = [];
public bool $saving = false;
public bool $showCancel = true;
public function updatedAudio(): void
{
if ($this->saving || $this->audio === []) {
return;
}
$this->save();
}
public function save(?StoreUploadedRecordings $store = null): mixed
{
if ($this->saving) {
return null;
}
$this->saving = true;
try {
$store ??= app(StoreUploadedRecordings::class);
$this->validate([
'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required',
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
],
], [
'audio.required' => 'Please choose at least one audio file to upload.',
'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'You can upload at most 50 files at once.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
]);
$result = $store->handle(Auth::user(), $this->audio);
$this->audio = [];
if ($result['recordings'] === []) {
session()->flash('error', $result['message']);
return $this->redirect(route('recordings.create'), navigate: true);
}
session()->flash('success', $result['message']);
if (count($result['recordings']) === 1) {
return $this->redirect(
route('recordings.show', $result['recordings'][0]),
navigate: true,
);
}
return $this->redirect(route('recordings.index'), navigate: true);
} finally {
$this->saving = false;
}
}
public function render()
{
return view('livewire.upload-recordings');
}
}
+463
View File
@@ -2,9 +2,19 @@
namespace App\Models;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class Recording extends Model
{
@@ -12,6 +22,7 @@ class Recording extends Model
* @var list<string>
*/
protected $fillable = [
'user_id',
'title',
'original_filename',
'file_path',
@@ -20,8 +31,13 @@ class Recording extends Model
'artist',
'album',
'file_size_bytes',
'content_hash',
'transcript',
'transcription_status',
'transcription_progress',
'transcription_percent',
'transcription_started_at',
'transcription_error',
'transcription_driver',
'ollama_url',
'transcribed_at',
@@ -35,11 +51,22 @@ class Recording extends Model
return [
'recorded_at' => 'datetime',
'transcribed_at' => 'datetime',
'transcription_started_at' => 'datetime',
'duration_seconds' => 'integer',
'file_size_bytes' => 'integer',
'transcription_percent' => 'integer',
'user_id' => 'integer',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Human-readable duration (m:ss).
*/
@@ -57,6 +84,380 @@ class Recording extends Model
});
}
/**
* Word count of the stored transcript (0 when empty).
*/
protected function wordCount(): Attribute
{
return Attribute::get(function (): int {
if (! filled($this->transcript)) {
return 0;
}
return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []);
});
}
/**
* Friendly label for the selected transcription engine.
*/
protected function transcriptionDriverLabel(): Attribute
{
return Attribute::get(function (): ?string {
return match ($this->transcription_driver) {
'local' => 'Local (faster-whisper)',
default => $this->transcription_driver ?: 'Local (faster-whisper)',
};
});
}
/**
* Whether transcription is actively running or queued.
*/
public function isTranscribing(): bool
{
return in_array($this->transcription_status, ['pending', 'processing'], true);
}
/**
* Queue a new local faster-whisper transcription run.
*/
public function queueLocalTranscription(): void
{
// Only stop a real in-flight/queued run — bare "pending" uploads have no job yet.
if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) {
$this->cancelTranscription(silent: true);
$this->refresh();
}
$this->update([
'transcription_driver' => 'local',
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_started_at' => now(),
'transcription_error' => null,
// Keep the previous transcript until a new run succeeds.
'transcribed_at' => $this->transcribed_at,
]);
$recording = $this->fresh();
RecordingTranscriptionUpdated::dispatch($recording);
TranscribeRecording::dispatch($recording);
}
/**
* Human-readable transcription status for badges.
*/
public function transcriptionStatusLabel(): string
{
return match ($this->transcription_status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $this->transcription_status,
};
}
/**
* Search title, metadata, and stored transcript text.
*/
#[Scope]
protected function search(Builder $query, string $term): void
{
$like = '%'.$term.'%';
$query->where(function (Builder $builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('artist', 'like', $like)
->orWhere('album', 'like', $like)
->orWhere('original_filename', 'like', $like)
->orWhere('transcript', 'like', $like);
});
}
/**
* First line of the transcript, truncated for compact list rows.
*/
public function transcriptFirstLine(int $limit = 120): ?string
{
if (! filled($this->transcript)) {
return null;
}
$firstLine = Str::of($this->transcript)
->before("\n")
->replaceMatches('/\s+/', ' ')
->trim()
->toString();
if ($firstLine === '') {
return null;
}
return Str::limit($firstLine, $limit);
}
/**
* Short transcript excerpt, optionally centered on a search hit.
*/
public function transcriptSnippet(?string $term = null, int $radius = 80): ?string
{
if (! filled($this->transcript)) {
return null;
}
$transcript = preg_replace('/\s+/', ' ', $this->transcript) ?? $this->transcript;
if ($term === null || $term === '') {
return Str::limit($transcript, $radius * 2);
}
$position = mb_stripos($transcript, $term);
if ($position === false) {
return Str::limit($transcript, $radius * 2);
}
$start = max(0, $position - $radius);
$excerpt = mb_substr($transcript, $start, ($radius * 2) + mb_strlen($term));
return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : '');
}
/**
* Whether a TranscribeRecording job for this recording is still on the queue.
*/
public function hasActiveTranscriptionJob(): bool
{
return DB::table('jobs')
->pluck('payload')
->contains(function (string $payload): bool {
if (! str_contains($payload, TranscribeRecording::class)) {
return false;
}
$data = json_decode($payload, true);
$command = $data['data']['command'] ?? null;
if (! is_string($command)) {
return false;
}
try {
$job = unserialize($command);
} catch (Throwable) {
return $this->payloadMentionsRecording($payload);
}
return $job instanceof TranscribeRecording
&& (int) $job->recording->getKey() === (int) $this->id;
});
}
/**
* Fallback payload match when unserialize is unavailable.
*/
private function payloadMentionsRecording(string $payload): bool
{
return (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"');
}
/**
* Processing/pending with no worker job left (crashed worker, bad retry_after, etc.).
*/
public function isOrphanedTranscription(): bool
{
if (! $this->isTranscribing()) {
return false;
}
if ($this->hasActiveTranscriptionJob()) {
return false;
}
$reference = $this->transcription_started_at ?? $this->updated_at;
if ($reference === null) {
return true;
}
// Only treat as orphaned after the job could not possibly still be running.
// (A short grace caused false failures while Whisper was still working.)
$orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60);
return $reference->lte(now()->subSeconds($orphanAfterSeconds));
}
/**
* Whether a payload/job belongs to this recording's transcription run start time.
*/
public function matchesTranscriptionRun(?string $runStartedAt): bool
{
if ($runStartedAt === null || $this->transcription_started_at === null) {
return false;
}
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
}
/**
* Whether this recording still expects results for the given run.
*/
public function ownsTranscriptionRun(?string $runStartedAt): bool
{
$this->refresh();
if (! $this->isTranscribing()) {
return false;
}
return $this->matchesTranscriptionRun($runStartedAt);
}
/**
* Remove queued TranscribeRecording jobs for this recording.
*/
public function discardQueuedTranscriptionJobs(): int
{
$deleted = 0;
DB::table('jobs')
->orderBy('id')
->get()
->each(function (object $job) use (&$deleted): void {
$payload = (string) $job->payload;
if (! str_contains($payload, TranscribeRecording::class)) {
return;
}
$data = json_decode($payload, true);
$command = $data['data']['command'] ?? null;
if (! is_string($command)) {
return;
}
try {
$queued = unserialize($command);
} catch (Throwable) {
if (! preg_match('/id";i:'.$this->id.';/', $payload)) {
return;
}
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
return;
}
if ($queued instanceof TranscribeRecording && (int) $queued->recording->getKey() === (int) $this->id) {
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
}
});
$this->releaseTranscriptionUniqueLock();
return $deleted;
}
/**
* Stop transcription: drop queued jobs and mark the run cancelled.
*
* @param bool $silent When true, skip status update (used before starting a replacement run).
*/
public function cancelTranscription(bool $silent = false): void
{
$this->discardQueuedTranscriptionJobs();
if ($silent) {
return;
}
$this->forceFill([
'transcription_status' => 'cancelled',
'transcription_progress' => 'Stopped by user',
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => 'Stopped by user',
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Release a leftover ShouldBeUnique lock from earlier job versions.
*/
public function releaseTranscriptionUniqueLock(): void
{
Cache::lock(
'laravel_unique_job:'.TranscribeRecording::class.'transcribe-recording:'.$this->id
)->forceRelease();
}
/**
* Mark transcription as failed and unblock the UI.
*/
public function markTranscriptionFailed(string $message): void
{
if (! $this->isTranscribing()) {
return;
}
$this->forceFill([
'transcription_status' => 'failed',
'transcription_progress' => 'Transcription failed',
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => $message,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Recover a stuck transcription if the queue job is gone.
*/
public function recoverOrphanedTranscription(): bool
{
if (! $this->isOrphanedTranscription()) {
return false;
}
$this->markTranscriptionFailed(
'Transcription worker stopped before finishing. Start transcription again.',
);
return true;
}
/**
* Update the live progress fields shown in the UI.
*/
public function reportProgress(string $message, int $percent, string $status = 'processing'): void
{
$this->forceFill([
'transcription_status' => $status,
'transcription_progress' => $message,
'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);
}
/**
* Absolute filesystem path for the stored audio file.
*/
@@ -65,6 +466,26 @@ class Recording extends Model
return Storage::disk('local')->path($this->file_path);
}
/**
* MIME type for browser audio playback based on the original filename.
*/
public function audioMimeType(): string
{
$extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION));
return match ($extension) {
'mp3', 'mpga', 'mpeg' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg', 'oga' => 'audio/ogg',
'flac' => 'audio/flac',
'm4a', 'mp4', 'aac' => 'audio/mp4',
'webm' => 'audio/webm',
'wma' => 'audio/x-ms-wma',
'aiff', 'aif' => 'audio/aiff',
default => 'application/octet-stream',
};
}
/**
* Delete the audio file from storage.
*/
@@ -74,4 +495,46 @@ class Recording extends Model
Storage::disk('local')->delete($this->file_path);
}
}
/**
* Payload for the live status endpoint / Alpine poller.
*
* @return array<string, mixed>
*/
public function transcriptionStatusPayload(): array
{
$startedAt = $this->transcription_started_at;
$elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
return [
'id' => $this->id,
'status' => $this->transcription_status,
'status_label' => $this->transcriptionStatusLabel(),
'progress' => $this->transcription_progress,
'percent' => $this->transcription_percent,
'driver' => $this->transcription_driver,
'driver_label' => $this->transcription_driver_label,
'error' => $this->transcription_error,
'started_at' => $startedAt?->toIso8601String(),
'elapsed_seconds' => $elapsed,
'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed),
'duration_seconds' => $this->duration_seconds,
'is_active' => $this->isTranscribing(),
'has_transcript' => filled($this->transcript),
'transcript' => $this->transcript,
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
];
}
private function formatElapsed(int $seconds): string
{
$minutes = intdiv($seconds, 60);
$remain = $seconds % 60;
if ($minutes === 0) {
return sprintf('%ds', $remain);
}
return sprintf('%dm %02ds', $minutes, $remain);
}
}
+22 -1
View File
@@ -2,13 +2,14 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
@@ -29,4 +30,24 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
/**
* @return HasMany<Recording, $this>
*/
public function recordings(): HasMany
{
return $this->hasMany(Recording::class);
}
/**
* Initials for Flux avatar components.
*/
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn (string $part) => Str::substr($part, 0, 1))
->implode('');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Policies;
use App\Models\Recording;
use App\Models\User;
class RecordingPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can start or stop transcription.
*/
public function transcribe(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
}
+7 -1
View File
@@ -2,7 +2,11 @@
namespace App\Providers;
use App\Listeners\AssignOrphanedRecordings;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
@@ -19,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Password::defaults(fn () => Password::min(8));
Event::listen(Registered::class, AssignOrphanedRecordings::class);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('pages::auth.login'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class DiskSpaceService
{
/**
* Snapshot of free/used space for the recordings storage volume.
*
* @return 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
*/
public function snapshot(?string $path = null): ?array
{
$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),
now()->addSeconds(30),
fn () => $this->measure($path),
);
}
/**
* @return 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
*/
private function measure(string $path): ?array
{
$total = @disk_total_space($path);
$free = @disk_free_space($path);
if ($total === false || $free === false || $total <= 0) {
return null;
}
$totalBytes = (int) $total;
$freeBytes = (int) max(0, $free);
$usedBytes = (int) max(0, $totalBytes - $freeBytes);
$usedPercent = round(($usedBytes / $totalBytes) * 100, 1);
$freePercent = round(($freeBytes / $totalBytes) * 100, 1);
return [
'total_bytes' => $totalBytes,
'free_bytes' => $freeBytes,
'used_bytes' => $usedBytes,
'used_percent' => $usedPercent,
'free_percent' => $freePercent,
'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];
}
}
+38 -8
View File
@@ -8,7 +8,7 @@ use getID3;
class Mp3MetadataService
{
/**
* Extract ID3 and audio metadata from an MP3 file on disk.
* Extract tags and duration from a common audio file (MP3, WAV, OGG, FLAC, M4A, etc.).
*
* @return array{
* title: ?string,
@@ -23,17 +23,15 @@ class Mp3MetadataService
$analyzer = new getID3;
$info = $analyzer->analyze($absolutePath);
$tags = [];
if (isset($info['tags']['id3v2'])) {
$tags = $info['tags']['id3v2'];
} elseif (isset($info['tags']['id3v1'])) {
$tags = $info['tags']['id3v1'];
}
$tags = $this->preferredTags($info);
$title = $this->firstTag($tags, 'title');
$artist = $this->firstTag($tags, 'artist');
$album = $this->firstTag($tags, 'album');
$year = $this->firstTag($tags, 'year') ?? $this->firstTag($tags, 'recording_time');
$year = $this->firstTag($tags, 'year')
?? $this->firstTag($tags, 'date')
?? $this->firstTag($tags, 'recording_time')
?? $this->firstTag($tags, 'creation_date');
$duration = isset($info['playtime_seconds'])
? (int) round((float) $info['playtime_seconds'])
@@ -57,6 +55,38 @@ class Mp3MetadataService
];
}
/**
* Pick the richest tag set getID3 found for this format.
*
* @param array<string, mixed> $info
* @return array<string, mixed>
*/
private function preferredTags(array $info): array
{
$priority = [
'id3v2',
'id3v1',
'vorbiscomment',
'quicktime',
'riff',
'asf',
'ape',
'matroska',
];
foreach ($priority as $format) {
if (! empty($info['tags'][$format]) && is_array($info['tags'][$format])) {
return $info['tags'][$format];
}
}
if (! empty($info['comments']) && is_array($info['comments'])) {
return $info['comments'];
}
return [];
}
/**
* @param array<string, mixed> $tags
*/
+11 -81
View File
@@ -3,100 +3,30 @@
namespace App\Services;
use App\Models\Recording;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Closure;
use Laravel\Ai\Transcription;
use RuntimeException;
class TranscriptionService
{
/**
* Run transcription for a recording using the selected driver.
* Transcribe a recording with the local faster-whisper server.
*
* @param (Closure(string, int): void)|null $onProgress
*/
public function transcribe(Recording $recording): string
{
return match ($recording->transcription_driver) {
'cloud' => $this->viaCloud($recording),
'local' => $this->viaLocal($recording),
'ollama' => $this->viaRemoteCompatible($recording),
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
};
}
private function viaCloud(Recording $recording): string
{
$transcript = Transcription::fromStorage($recording->file_path)
->timeout((int) config('ai.transcription_timeout', 600))
->generate('openai', 'whisper-1');
return (string) $transcript;
}
private function viaLocal(Recording $recording): string
public function transcribe(Recording $recording, ?Closure $onProgress = null): string
{
$report = $onProgress ?? static fn (string $message, int $percent) => null;
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
$report('Connecting to local faster-whisper server…', 30);
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
$transcript = Transcription::fromStorage($recording->file_path)
->timeout((int) config('ai.transcription_timeout', 600))
->generate('local-whisper', $model);
$report('Received transcript from local Whisper…', 85);
return (string) $transcript;
}
/**
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
*/
private function viaRemoteCompatible(Recording $recording): string
{
if (! filled($recording->ollama_url)) {
throw new RuntimeException('Ollama host URL is required for remote transcription.');
}
$base = $this->normalizeBaseUrl($recording->ollama_url);
$model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base'));
$path = $recording->absolutePath();
if (! is_readable($path)) {
throw new RuntimeException('Recording audio file is not readable.');
}
$response = Http::timeout((int) config('ai.transcription_timeout', 600))
->attach(
'file',
fopen($path, 'r'),
$recording->original_filename ?: basename($path),
)
->post($base.'/audio/transcriptions', [
'model' => $model,
'response_format' => 'json',
]);
if (! $response->successful()) {
throw new RuntimeException(
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
);
}
$text = $response->json('text');
if (! is_string($text) || $text === '') {
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
}
return $text;
}
/**
* Normalize a user URL to an OpenAI-style base ending in /v1.
*/
private function normalizeBaseUrl(string $url): string
{
$url = rtrim(trim($url), '/');
if (Str::endsWith($url, '/v1')) {
return $url;
}
return $url.'/v1';
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\View\Components;
use App\Services\DiskSpaceService;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class DiskSpaceBar extends Component
{
/**
* @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
*/
public readonly ?array $disk;
public function __construct(DiskSpaceService $diskSpace)
{
$this->disk = $diskSpace->snapshot();
}
public function shouldRender(): bool
{
return $this->disk !== null;
}
/**
* Progress fill color based on remaining free space.
*/
public function barColor(): string
{
$freePercent = $this->disk['free_percent'] ?? 100;
if ($freePercent <= 5) {
return 'bg-red-600';
}
if ($freePercent <= 15) {
return 'bg-amber-500';
}
return 'bg-teal-600';
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.disk-space-bar');
}
}
+3 -1
View File
@@ -9,10 +9,12 @@ 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 {
//
$middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo(fn () => route('recordings.index'));
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
+2
View File
@@ -1,7 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
];
+6 -1
View File
@@ -9,8 +9,13 @@
"php": "^8.3",
"james-heinrich/getid3": "^1.9",
"laravel/ai": "^0.10.3",
"laravel/fortify": "^1.38",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0"
"laravel/reverb": "^1.11",
"laravel/tinker": "^3.0",
"livewire/flux": "^2.16",
"livewire/livewire": "^4.4",
"symfony/polyfill-iconv": "^1.37"
},
"require-dev": {
"fakerphp/faker": "^1.23",
Generated
+2515 -1
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -28,7 +28,6 @@ return [
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')),
/*
|--------------------------------------------------------------------------
@@ -149,7 +148,7 @@ return [
'local-whisper' => [
'driver' => 'openai',
'key' => env('LOCAL_WHISPER_API_KEY', 'not-needed'),
'url' => env('LOCAL_WHISPER_URL', 'http://localhost:8000/v1'),
'url' => env('LOCAL_WHISPER_URL', 'http://127.0.0.1:8090/v1'),
'store' => false,
],
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
+3 -3
View File
@@ -38,9 +38,9 @@ return [
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'busy_timeout' => 5000,
'journal_mode' => 'WAL',
'synchronous' => 'NORMAL',
'transaction_mode' => 'DEFERRED',
],
+167
View File
@@ -0,0 +1,167 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/recordings',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Passkeys
|--------------------------------------------------------------------------
|
| These settings configure Fortify's passkey (WebAuthn) support. Passkeys
| allow users to sign in without needing to remember credentials since
| they use public-key cryptography - making them immune to breaches.
|
*/
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'timeout' => 60000,
],
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
],
];
+284
View File
@@ -0,0 +1,284 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts.app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
// Pocket-recorder audio can be large; max is kilobytes (2 GiB).
'rules' => ['required', 'file', 'max:2097152'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif',
],
'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 200, // Maximum components per batch request
],
];
+2 -1
View File
@@ -40,7 +40,8 @@ return [
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
// Must exceed TranscribeRecording::$timeout / TRANSCRIPTION_TIMEOUT (default 600).
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 660),
'after_commit' => false,
],
+102
View File
@@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];
Regular → Executable
View File
View File
View File
View File
View File
View File
@@ -0,0 +1,36 @@
<?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('transcription_progress')->nullable()->after('transcription_status');
$table->unsignedTinyInteger('transcription_percent')->nullable()->after('transcription_progress');
$table->timestamp('transcription_started_at')->nullable()->after('transcription_percent');
$table->text('transcription_error')->nullable()->after('transcription_started_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropColumn([
'transcription_progress',
'transcription_percent',
'transcription_started_at',
'transcription_error',
]);
});
}
};
@@ -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');
});
}
};
@@ -0,0 +1,42 @@
<?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('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};
@@ -0,0 +1,34 @@
<?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->foreignId('user_id')
->nullable()
->after('id')
->constrained()
->nullOnDelete();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};
+25 -5
View File
@@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -15,11 +16,30 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
$email = (string) env('SEED_USER_EMAIL', 'demo@example.com');
$name = (string) env('SEED_USER_NAME', 'Demo User');
$password = (string) env('SEED_USER_PASSWORD', 'password');
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$user = User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
'password' => $password,
'email_verified_at' => now(),
],
);
User::query()->updateOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => 'password',
'email_verified_at' => now(),
],
);
Recording::query()
->whereNull('user_id')
->update(['user_id' => $user->id]);
}
}
+60
View File
@@ -0,0 +1,60 @@
# Local development overlay: bind-mount source + Vite HMR.
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
# Or set in .env:
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
services:
app:
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
depends_on:
vite:
condition: service_started
queue:
# Reload PHP between jobs so code mounts take effect without restarting.
command:
- php
- artisan
- queue:listen
- database
- --sleep=1
- --tries=1
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
reverb:
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
vite:
image: node:22-bookworm
container_name: andytranscribe-vite
working_dir: /app
command: >
sh -c "if [ ! -x node_modules/.bin/vite ]; then npm ci; fi;
npm run dev -- --host 0.0.0.0 --port 5173"
ports:
- "${VITE_HOST_PORT:-5173}:5173"
environment:
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}
VITE_USE_POLLING: "true"
volumes:
- .:/app
- vite-node-modules:/app/node_modules
restart: unless-stopped
volumes:
vite-node-modules:
+168
View File
@@ -0,0 +1,168 @@
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}
SEED_USER_NAME: ${SEED_USER_NAME:-Demo User}
SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com}
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password}
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
# Shares andytranscribe-app:latest — do not declare build: here (avoids rebuilding 3×).
# `docker compose up --build` builds `app` first, then starts these with the tagged image.
queue:
image: andytranscribe-app:latest
pull_policy: never
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:
image: andytranscribe-app:latest
pull_policy: never
container_name: andytranscribe-reverb
command:
- php
- artisan
- reverb:start
- --host=0.0.0.0
- --port=8080
ports:
- "${REVERB_HOST_PORT:-8081}:8080"
environment:
<<: *app-env
# Browser connects to localhost:8081; hostname used in app config for allowed hosts
REVERB_HOST: localhost
volumes: *app-data
healthcheck:
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'reverb:start'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
restart: unless-stopped
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: andytranscribe-whisper
ports:
# Host 8090 avoids clashing with the FrankenPHP app on 8080
- "${WHISPER_HOST_PORT:-8090}:8000"
volumes:
- whisper-huggingface-cache:/root/.cache/huggingface
environment:
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
# GPU variant (NVIDIA). Start with:
# docker compose --profile gpu up -d whisper-gpu
whisper-gpu:
profiles: ["gpu"]
image: fedirz/faster-whisper-server:latest-cuda
container_name: andytranscribe-whisper-gpu
ports:
- "${WHISPER_HOST_PORT:-8090}:8000"
volumes:
- whisper-huggingface-cache:/root/.cache/huggingface
environment:
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
whisper-huggingface-cache:
+42
View File
@@ -0,0 +1,42 @@
#!/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
# Production images should not honor a leftover Vite HMR file from the host.
# Development bind mounts keep public/hot so the Vite container can drive assets.
if [ "${KEEP_VITE_HOT:-false}" != "true" ]; then
rm -f public/hot
fi
# 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
# Bind-mounted trees may lack vendor/ (image files are shadowed by the mount).
if [ ! -f vendor/autoload.php ]; then
composer install --prefer-dist --no-interaction
fi
php artisan migrate --force --no-interaction
php artisan db:seed --force --no-interaction
exec "$@"
+10
View File
@@ -0,0 +1,10 @@
; PHP limits for large pocket-recorder uploads (up to 2 GB per file).
upload_max_filesize = 2G
post_max_size = 10G
memory_limit = 512M
max_execution_time = 3600
max_input_time = 3600
; Pick up bind-mounted PHP changes without restarting FrankenPHP.
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
+69
View File
@@ -6,8 +6,11 @@
"": {
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.16.1",
"concurrently": "^10.0.3",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.1",
"pusher-js": "^8.6.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
@@ -661,6 +664,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -1128,6 +1158,28 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-echo": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
},
"peerDependenciesMeta": {
"pusher-js": {
"optional": true
},
"socket.io-client": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
@@ -1542,6 +1594,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pusher-js": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz",
"integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -1822,6 +1884,13 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
+3
View File
@@ -8,8 +8,11 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.16.1",
"concurrently": "^10.0.3",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.1",
"pusher-js": "^8.6.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
+6 -2
View File
@@ -1,11 +1,15 @@
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../views';
@source '../js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
@custom-variant dark (&:where(.dark, .dark *));
+5 -1
View File
@@ -1 +1,5 @@
//
import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription';
window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex;
+20
View File
@@ -0,0 +1,20 @@
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'],
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
},
},
});
+287
View File
@@ -0,0 +1,287 @@
/**
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_COLORS = {
done: 'teal',
processing: 'amber',
pending: 'zinc',
failed: 'red',
cancelled: 'zinc',
};
export function badgeColorFor(status) {
return BADGE_COLORS[status] || 'zinc';
}
/** @deprecated Use badgeColorFor — kept for any leftover callers */
export function badgeClassFor(status) {
return badgeColorFor(status);
}
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 subscribeToRecording(recordingId, handler) {
if (!window.Echo) {
return () => {};
}
const channelName = 'recording.' + recordingId;
const channel = window.Echo.private(channelName);
channel.listen('.RecordingTranscriptionUpdated', handler);
return () => {
// Prefer stopListening over leave() so a remount does not drop other subscribers.
if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
}
};
}
/**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
* Livewire also listens on the user recordings channel and polls while active.
*/
export function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null,
tickTimer: null,
hydrateTimer: null,
leaveChannel: null,
get badgeColor() {
return badgeColorFor(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 = subscribeToRecording(this.status.id, (event) => {
if (Number(event.id) !== Number(this.status.id)) {
return;
}
this.applyPayload(event);
});
if (this.status.is_active) {
this.beginTick();
this.hydrateOnce();
this.beginHydratePoll();
}
},
destroy() {
this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
}
},
applyPayload(payload) {
const wasActive = this.status.is_active;
this.status = {
...this.status,
...payload,
badge_color: badgeColorFor(payload.status ?? this.status.status),
};
this.pollError = null;
if (this.status.is_active) {
this.beginTick();
this.beginHydratePoll();
} else {
this.stopTick();
this.stopHydratePoll();
}
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;
}
},
beginHydratePoll() {
if (this.hydrateTimer || ! this.statusUrl) {
return;
}
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
},
stopHydratePoll() {
if (this.hydrateTimer) {
clearInterval(this.hydrateTimer);
this.hydrateTimer = null;
}
},
tickElapsed() {
if (! this.status.is_active || this.status.elapsed_seconds == null) {
return;
}
const next = this.status.elapsed_seconds + 1;
this.status = {
...this.status,
elapsed_seconds: next,
elapsed_human: formatElapsed(next),
};
},
async hydrateOnce() {
if (! this.statusUrl) {
return;
}
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
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: inline audio player only.
* Status/progress refresh via Livewire Echo + wire:poll.
*/
export function recordingsIndex() {
return {
playingId: null,
isPlaying: false,
start() {
//
},
destroy() {
const player = this.$refs.player;
if (player) {
player.pause();
player.removeAttribute('src');
player.load();
}
},
syncPlayer() {
const player = this.$refs.player;
this.isPlaying = Boolean(player && !player.paused && !player.ended);
if (player?.ended) {
this.playingId = null;
}
},
isPlayingRow(id) {
return this.playingId === id && this.isPlaying;
},
togglePlay(id, url) {
const player = this.$refs.player;
if (!player) {
return;
}
if (this.playingId === id && this.isPlaying) {
player.pause();
return;
}
if (this.playingId !== id) {
player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
},
};
}
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1,17 @@
@props([
'sidebar' => false,
])
@if($sidebar)
<flux:sidebar.brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:brand>
@endif
@@ -0,0 +1,13 @@
<flux:dropdown x-data align="end">
<flux:button variant="subtle" square class="group" aria-label="{{ __('Preferred color scheme') }}">
<flux:icon.sun x-show="$flux.appearance === 'light'" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.moon x-show="$flux.appearance === 'dark'" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.moon x-show="$flux.appearance === 'system' && $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.sun x-show="$flux.appearance === 'system' && ! $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
</flux:button>
<flux:menu>
<flux:menu.item icon="sun" x-on:click="$flux.appearance = 'light'">{{ __('Light') }}</flux:menu.item>
<flux:menu.item icon="moon" x-on:click="$flux.appearance = 'dark'">{{ __('Dark') }}</flux:menu.item>
<flux:menu.item icon="computer-desktop" x-on:click="$flux.appearance = 'system'">{{ __('System') }}</flux:menu.item>
</flux:menu>
</flux:dropdown>
@@ -0,0 +1,9 @@
@props([
'title',
'description',
])
<div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>
@@ -0,0 +1,9 @@
@props([
'status',
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
@endif
@@ -0,0 +1,31 @@
@auth
<flux:dropdown position="bottom" align="end">
<flux:button variant="ghost" class="max-lg:hidden" data-test="user-menu-button">
{{ auth()->user()->name }}
</flux:button>
<flux:button variant="ghost" class="lg:hidden" icon="user" data-test="user-menu-button-mobile" />
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar :name="auth()->user()->name" :initials="auth()->user()->initials()" />
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
@endauth
@@ -0,0 +1,24 @@
@php
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
@endphp
<div
class="flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $disk['used_percent'] }}% used"
>
<div
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ (int) round($disk['used_percent']) }}"
aria-label="Disk space used"
>
<div
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
></div>
</div>
<span class="hidden text-xs font-medium tabular-nums text-zinc-600 sm:inline dark:text-zinc-300">
{{ $disk['free_human'] }} free
</span>
</div>
@@ -0,0 +1,62 @@
@props([
'status',
'label' => null,
/** @var string|null Alpine expression that returns a row object with badge_color + status_label (+ status for spinner) */
'alpineRow' => null,
])
@php
$label ??= match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$color = match ($status) {
'done' => 'teal',
'processing' => 'amber',
'pending' => 'zinc',
'failed' => 'red',
default => 'zinc',
};
$icon = $status === 'processing' ? 'loading' : null;
@endphp
@if ($alpineRow)
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
@if ($badgeColor === $color)
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@else
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-cloak
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@endif
@endforeach
<flux:icon.loading
variant="micro"
class="size-3 text-amber-600 dark:text-amber-400"
x-show="{{ $alpineRow }}.status === 'processing'"
x-cloak
/>
</span>
@else
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
<flux:badge size="sm" :color="$color" :icon="$icon">
{{ $label }}
</flux:badge>
</span>
@endif
+73 -34
View File
@@ -1,50 +1,89 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Recordings') {{ config('app.name', 'AndyTranscribe') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@include('partials.head', ['title' => $title ?? null])
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
<header class="border-b border-stone-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800">
AndyTranscribe
</a>
<nav class="flex items-center gap-3 text-sm">
<a href="{{ route('recordings.index') }}" class="text-stone-600 hover:text-stone-900">Recordings</a>
<a href="{{ route('recordings.create') }}" class="rounded bg-teal-700 px-3 py-1.5 font-medium text-white hover:bg-teal-800">
Upload
</a>
</nav>
</div>
</header>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate class="max-lg:hidden" />
<flux:navbar class="-mb-px max-lg:hidden">
<flux:navbar.item
:href="route('recordings.index')"
:current="request()->routeIs('recordings.index', 'recordings.show')"
wire:navigate
>
{{ __('Recordings') }}
</flux:navbar.item>
<flux:navbar.item
:href="route('recordings.create')"
:current="request()->routeIs('recordings.create')"
wire:navigate
>
{{ __('Upload') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</flux:header>
<flux:sidebar collapsible="mobile" sticky class="lg:hidden">
<flux:sidebar.header>
<flux:sidebar.brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
<flux:sidebar.collapse />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')" wire:navigate>
{{ __('Recordings') }}
</flux:sidebar.item>
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
{{ __('Upload') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
<flux:main container>
@if (session('success'))
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900">
{{ session('success') }}
</div>
<flux:callout variant="success" icon="check-circle" class="mb-6">
<flux:callout.text>{{ session('success') }}</flux:callout.text>
</flux:callout>
@endif
@if (session('error'))
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
{{ session('error') }}
</div>
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>{{ session('error') }}</flux:callout.text>
</flux:callout>
@endif
@if ($errors->any())
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
<ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>
<ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</flux:callout.text>
</flux:callout>
@endif
@yield('content')
</main>
{{ $slot }}
</flux:main>
<flux:toast />
@fluxScripts
</body>
</html>
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</flux:header>
<flux:main container>
{{ $slot }}
</flux:main>
@fluxScripts
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-zinc-50 antialiased dark:bg-zinc-900">
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6">
<flux:brand
class="justify-center"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ route('home') }}"
wire:navigate
/>
<div class="flex flex-col gap-6">
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-800 shadow-xs dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="absolute end-4 top-4">
<x-appearance-toggle />
</div>
<div class="flex w-full max-w-sm flex-col gap-6">
<flux:brand
class="justify-center"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ url('/') }}"
wire:navigate
/>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
</div>
</div>
@fluxScripts
</body>
</html>
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-zinc-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-zinc-800">
<div class="absolute inset-0 bg-zinc-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
{{ config('app.name', 'AndyTranscribe') }}
</a>
@php
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
@endphp
<div class="relative z-20 mt-auto">
<blockquote class="space-y-2">
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
</blockquote>
</div>
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<flux:brand
class="z-20 justify-center lg:hidden"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ route('home') }}"
wire:navigate
/>
{{ $slot }}
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>
@@ -0,0 +1,7 @@
<div>
<div class="mb-8">
<flux:heading size="xl">Upload recordings</flux:heading>
</div>
<livewire:upload-recordings />
</div>
@@ -0,0 +1,234 @@
<div
@if ($hasActiveTranscriptions)
wire:poll.2s.visible
@endif
x-data="recordingsIndex()"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<flux:heading size="xl">Recordings</flux:heading>
<flux:text class="mt-1">Manage pocket-recorder audio and transcripts.</flux:text>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<div class="flex items-end gap-2">
<flux:input
type="search"
wire:model.live.debounce.400ms="search"
placeholder="Search title, artist, transcript…"
class="min-w-[16rem]"
/>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
@if ($pendingCount > 0)
<flux:button
type="button"
variant="ghost"
size="sm"
wire:click="queuePending"
>
Queue {{ $pendingCount }} pending
{{ $pendingCount === 1 ? 'transcription' : 'transcriptions' }}
</flux:button>
@endif
@if ($totalCount > 0)
<flux:modal.trigger name="delete-all-recordings">
<flux:button type="button" variant="danger" size="sm">
Delete all
</flux:button>
</flux:modal.trigger>
@endif
</div>
</div>
</div>
@if ($totalCount > 0)
<flux:modal name="delete-all-recordings" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">Delete all recordings?</flux:heading>
<flux:text class="mt-2">
This permanently removes
{{ $totalCount === 1 ? 'your 1 recording' : "all {$totalCount} recordings" }}
and their audio files. This cannot be undone.
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button type="button" variant="danger" wire:click="deleteAll">
Delete all
</flux:button>
</div>
</div>
</flux:modal>
@endif
@if ($recordings->isEmpty())
@if (filled($search))
<flux:card class="border-dashed py-16 text-center">
<flux:text>No recordings match {{ $search }}.</flux:text>
<div class="mt-4">
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
</div>
</flux:card>
@else
<livewire:upload-recordings :show-cancel="false" />
@endif
@else
<audio
x-ref="player"
class="hidden"
preload="none"
@play="syncPlayer()"
@pause="syncPlayer()"
@ended="playingId = null; syncPlayer()"
></audio>
<flux:table :paginate="$recordings">
<flux:table.columns>
<flux:table.column
sortable
:sorted="$sortBy === 'title'"
:direction="$sortDirection"
wire:click="sort('title')"
>
Title
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'duration'"
:direction="$sortDirection"
wire:click="sort('duration')"
>
Duration
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'words'"
:direction="$sortDirection"
wire:click="sort('words')"
>
Words
</flux:table.column>
<flux:table.column
class="w-36"
sortable
:sorted="$sortBy === 'status'"
:direction="$sortDirection"
wire:click="sort('status')"
>
Status
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'uploaded'"
:direction="$sortDirection"
wire:click="sort('uploaded')"
>
Uploaded
</flux:table.column>
<flux:table.column class="w-24"></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($recordings as $recording)
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
<flux:table.cell class="max-w-xl">
<div class="flex min-w-0 items-center gap-2">
<flux:link
href="{{ route('recordings.show', $recording) }}"
wire:navigate
class="shrink-0 font-medium"
>
{{ $recording->title }}
</flux:link>
<flux:button
type="button"
variant="ghost"
size="sm"
square
class="shrink-0"
data-audio-url="{{ route('recordings.audio', $recording) }}"
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
>
<flux:icon.play
variant="micro"
x-show="! isPlayingRow({{ $recording->id }})"
/>
<flux:icon.pause
variant="micro"
x-show="isPlayingRow({{ $recording->id }})"
x-cloak
/>
</flux:button>
@if ($preview = $recording->transcriptFirstLine())
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
{{ $preview }}
</span>
@endif
</div>
</flux:table.cell>
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
<flux:table.cell>
<span class="tabular-nums">
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</span>
</flux:table.cell>
<flux:table.cell class="w-36 whitespace-nowrap">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
/>
</flux:table.cell>
<flux:table.cell>
{{ $recording->created_at?->format('Y-m-d H:i') }}
</flux:table.cell>
<flux:table.cell>
<flux:modal.trigger name="delete-recording-{{ $recording->id }}">
<flux:button
type="button"
variant="danger"
size="sm"
square
aria-label="Delete {{ $recording->title }}"
>
<flux:icon.trash variant="micro" />
</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-recording-{{ $recording->id }}" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">Delete recording?</flux:heading>
<flux:text class="mt-2">
This permanently removes {{ $recording->title }} and its audio file.
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button
type="button"
variant="danger"
wire:click="delete({{ $recording->id }})"
>
Delete
</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
@endif
</div>
@@ -0,0 +1,232 @@
<div
@if ($recording->isTranscribing())
wire:poll.2s.visible
@endif
>
<div
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link>
<flux:heading size="xl" class="mt-2">{{ $recording->title }}</flux:heading>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
alpine-row="status"
/>
<flux:text
class="text-xs"
x-show="status.driver_label"
x-cloak
x-text="status.driver_label"
></flux:text>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<flux:button
type="button"
variant="primary"
icon="play"
@click="$refs.player.paused ? $refs.player.play() : $refs.player.pause()"
>
Play
</flux:button>
<flux:modal.trigger name="delete-recording">
<flux:button type="button" variant="danger">Delete</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-recording" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">Delete recording?</flux:heading>
<flux:text class="mt-2">
This permanently removes the recording and its audio file.
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button type="button" variant="danger" wire:click="delete">Delete</flux:button>
</div>
</div>
</flux:modal>
</div>
</div>
<flux:card class="mb-6">
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Audio</flux:heading>
<audio
x-ref="player"
class="mt-4 w-full"
controls
preload="metadata"
src="{{ route('recordings.audio', $recording) }}"
>
Your browser does not support audio playback.
</audio>
</flux:card>
<div class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Metadata</flux:heading>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt><flux:text>Original file</flux:text></dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Duration</flux:text></dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Artist</flux:text></dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Album</flux:text></dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Recorded</flux:text></dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Size</flux:text></dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Uploaded</flux:text></dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</flux:card>
<flux:card>
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcribe</flux:heading>
<div class="mt-4">
<flux:button type="button" variant="primary" wire:click="startTranscription">
<span x-text="startButtonLabel"></span>
</flux:button>
</div>
<div
x-show="status.is_active"
x-cloak
class="mt-3"
>
<flux:button type="button" variant="outline" wire:click="cancelTranscription">
Stop transcription
</flux:button>
</div>
</flux:card>
</div>
<flux:card class="mt-6">
<div class="flex items-center justify-between gap-4">
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcript</flux:heading>
<flux:button
type="button"
variant="ghost"
size="sm"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
>
Copy
</flux:button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4">
<flux:callout variant="warning" icon="arrow-path">
<flux:callout.heading>
<span class="inline-flex items-center gap-2">
<flux:icon.loading
variant="micro"
class="size-4"
x-show="status.status === 'processing'"
x-cloak
/>
<span x-text="status.progress || 'Working…'"></span>
</span>
</flux:callout.heading>
<flux:callout.text>
<span
class="tabular-nums"
x-show="status.status === 'processing' && status.percent != null"
x-cloak
>
<span x-text="status.percent + '%'"></span>
·
</span>
Elapsed <span x-text="status.elapsed_human || '0s'"></span>
</flux:callout.text>
</flux:callout>
<div class="mt-3" x-show="status.status === 'processing'" x-cloak>
<flux:progress color="amber" x-bind:value="status.percent || 0" />
</div>
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-zinc-500">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-600 dark:text-red-400" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4">
<flux:callout icon="stop-circle">
<flux:callout.text>
Transcription stopped. Use the button above to start again.
</flux:callout.text>
</flux:callout>
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4">
<flux:callout variant="danger" icon="exclamation-triangle">
<flux:callout.heading>Transcription failed</flux:callout.heading>
<flux:callout.text>
<span x-text="status.error || 'Check the logs and try again.'"></span>
</flux:callout.text>
</flux:callout>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
<flux:text
class="mt-4 text-xs"
x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></flux:text>
</div>
<flux:text
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</flux:text>
</flux:card>
</div>
</div>
@@ -0,0 +1,96 @@
<div
class="max-w-2xl space-y-6 rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
x-data="{
uploading: false,
progress: 0,
dragging: false,
openPicker() {
this.$refs.fileInput.click();
},
onDrop(event) {
this.dragging = false;
const files = event.dataTransfer?.files;
if (! files?.length) {
return;
}
const transfer = new DataTransfer();
Array.from(files).forEach((file) => transfer.items.add(file));
this.$refs.fileInput.files = transfer.files;
this.$refs.fileInput.dispatchEvent(new Event('change', { bubbles: true }));
},
}"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-cancel="uploading = false"
x-on:livewire-upload-error="uploading = false"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
<div>
<flux:label>Audio files</flux:label>
<input
x-ref="fileInput"
type="file"
class="sr-only"
wire:model="audio"
multiple
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
>
<div
role="button"
tabindex="0"
x-on:click="openPicker()"
x-on:keydown.enter.prevent="openPicker()"
x-on:keydown.space.prevent="openPicker()"
x-on:dragenter.prevent="dragging = true"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="onDrop($event)"
x-bind:class="dragging ? 'border-accent bg-accent/5 dark:border-accent dark:bg-accent/10' : 'border-zinc-300 bg-zinc-50 dark:border-white/20 dark:bg-white/5'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed px-6 py-10 text-center transition-colors"
>
<div class="mb-3 flex size-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-white/10">
<flux:icon.cloud-arrow-up class="size-6 text-zinc-500 dark:text-zinc-400" />
</div>
<flux:heading size="sm" class="text-zinc-800 dark:text-zinc-100">
Drop audio files here or click to browse
</flux:heading>
<flux:text class="mt-1 max-w-sm text-zinc-500 dark:text-zinc-400">
MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files
</flux:text>
<div
x-show="uploading || $wire.saving"
x-cloak
class="mt-5 w-full max-w-sm space-y-2"
>
<div class="flex items-center justify-between gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span x-text="$wire.saving ? 'Saving recordings…' : 'Uploading…'"></span>
<span x-show="uploading" x-text="progress + '%'"></span>
</div>
<flux:progress color="teal" x-bind:value="uploading ? progress : 100" />
</div>
</div>
@error('audio')
<flux:error>{{ $message }}</flux:error>
@enderror
@error('audio.*')
<flux:error>{{ $message }}</flux:error>
@enderror
</div>
@if ($showCancel)
<div class="flex items-center gap-3">
<flux:link href="{{ route('recordings.index') }}" wire:navigate>Cancel</flux:link>
</div>
@endif
</div>
@@ -0,0 +1,38 @@
<x-layouts::auth :title="__('Confirm password')">
<div class="flex flex-col gap-6">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<x-auth-session-status class="text-center" :status="session('status')" />
{{-- @chisel-passkeys --}}
<x-passkey-verify
options-route="passkey.confirm-options"
submit-route="passkey.confirm"
:label="__('Confirm with passkey')"
:loading-label="__('Confirming...')"
:separator="__('Or confirm with password')"
/>
{{-- @end-chisel-passkeys --}}
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
{{ __('Confirm') }}
</flux:button>
</form>
</div>
</x-layouts::auth>
@@ -0,0 +1,31 @@
<x-layouts::auth :title="__('Forgot password')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
@csrf
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
type="email"
required
autofocus
placeholder="email@example.com"
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
{{ __('Email password reset link') }}
</flux:button>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-400">
<span>{{ __('Or, return to') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,53 @@
<x-layouts::auth :title="__('Log in')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
<flux:input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
/>
<div class="relative">
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
@if (Route::has('password.request'))
<flux:link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
{{ __('Forgot your password?') }}
</flux:link>
@endif
</div>
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
{{ __('Log in') }}
</flux:button>
</div>
</form>
<div class="space-x-1 text-center text-sm text-zinc-600 rtl:space-x-reverse dark:text-zinc-400">
<span>{{ __('Don\'t have an account?') }}</span>
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,69 @@
<x-layouts::auth :title="__('Register')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Name -->
<flux:input
name="name"
:label="__('Name')"
:value="old('name')"
type="text"
required
autofocus
autocomplete="name"
:placeholder="__('Full name')"
/>
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
<span>{{ __('Already have an account?') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,54 @@
<x-layouts::auth :title="__('Reset password')">
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
@csrf
<!-- Token -->
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<!-- Email Address -->
<flux:input
name="email"
value="{{ request('email') }}"
:label="__('Email')"
type="email"
required
autocomplete="email"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>
</x-layouts::auth>
@@ -0,0 +1,101 @@
<x-layouts::auth :title="__('Two-factor authentication')">
<div class="flex flex-col gap-6">
<div
class="relative w-full h-auto"
x-cloak
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
code: '',
recovery_code: '',
focusOtp() {
this.$nextTick(() => this.$refs.otp?.querySelector('input')?.focus());
},
init() {
if (! this.showRecoveryInput) {
this.focusOtp();
}
},
toggleInput() {
this.showRecoveryInput = !this.showRecoveryInput;
this.code = '';
this.recovery_code = '';
$nextTick(() => {
this.showRecoveryInput
? this.$refs.recovery_code?.focus()
: this.focusOtp();
});
},
}"
>
<div x-show="!showRecoveryInput">
<x-auth-header
:title="__('Authentication code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<div x-show="showRecoveryInput">
<x-auth-header
:title="__('Recovery code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div class="space-y-5 text-center">
<div x-show="!showRecoveryInput">
<div class="flex items-center justify-center my-5" x-ref="otp">
<flux:otp
x-model="code"
length="6"
name="code"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
</div>
<div x-show="showRecoveryInput">
<div class="my-5">
<flux:input
type="text"
name="recovery_code"
x-ref="recovery_code"
x-bind:required="showRecoveryInput"
autocomplete="one-time-code"
x-model="recovery_code"
/>
</div>
@error('recovery_code')
<flux:text color="red">
{{ $message }}
</flux:text>
@enderror
</div>
<flux:button
variant="primary"
type="submit"
class="w-full"
>
{{ __('Continue') }}
</flux:button>
</div>
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
<span class="opacity-50">{{ __('or you can') }}</span>
<div class="inline font-medium underline cursor-pointer opacity-80">
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
</div>
</div>
</form>
</div>
</div>
</x-layouts::auth>
@@ -0,0 +1,29 @@
<x-layouts::auth :title="__('Email verification')">
<div class="mt-4 flex flex-col gap-6">
<flux:text class="text-center">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</flux:text>
@if (session('status') == 'verification-link-sent')
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</flux:text>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
{{ __('Log out') }}
</flux:button>
</form>
</div>
</div>
</x-layouts::auth>
+14
View File
@@ -0,0 +1,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>
{{ filled($title ?? null) ? $title.' — '.config('app.name', 'AndyTranscribe') : config('app.name', 'AndyTranscribe') }}
</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
@@ -1,50 +0,0 @@
@extends('layouts.app')
@section('title', 'Upload recording')
@section('content')
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
<p class="mt-1 text-sm text-stone-600">Upload an MP3 from your pocket recorder. ID3 metadata is extracted automatically.</p>
</div>
<form
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
class="max-w-xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
>
@csrf
<div>
<label for="audio" class="block text-sm font-medium text-stone-700">MP3 file</label>
<input
id="audio"
type="file"
name="audio"
accept=".mp3,audio/mpeg"
required
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
>
</div>
<div>
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label>
<input
id="title"
type="text"
name="title"
value="{{ old('title') }}"
placeholder="Leave blank to use ID3 title or filename"
class="mt-2 w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
</div>
<div class="flex items-center gap-3">
<button type="submit" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800">
Upload
</button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
</div>
</form>
@endsection
@@ -1,77 +0,0 @@
@extends('layouts.app')
@section('title', 'Recordings')
@section('content')
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder MP3s and transcripts.</p>
</div>
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
<p class="text-stone-600">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-stone-200 text-sm">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Engine</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
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td class="px-4 py-3">
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
</td>
<td class="px-4 py-3 text-stone-600">
@if ($recording->transcription_driver)
<span class="uppercase tracking-wide text-xs">{{ $recording->transcription_driver }}</span>
@else
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
@endsection
@@ -1,11 +0,0 @@
@php
$classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
};
@endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $status }}
</span>
-157
View File
@@ -1,157 +0,0 @@
@extends('layouts.app')
@section('title', $recording->title)
@section('content')
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
@if ($recording->transcription_driver)
<span class="text-xs uppercase tracking-wide text-stone-500">{{ $recording->transcription_driver }}</span>
@endif
</div>
</div>
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@csrf
@method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50">
Delete
</button>
</form>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
@if ($recording->ollama_url)
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Ollama host</dt>
<dd class="max-w-[60%] break-all text-right font-medium">{{ $recording->ollama_url }}</dd>
</div>
@endif
</dl>
</section>
<section
x-data="{ driver: '{{ old('driver', $recording->transcription_driver ?: 'cloud') }}' }"
class="rounded border border-stone-200 bg-white p-6 shadow-sm"
>
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
<p class="mt-2 text-sm text-stone-600">Choose how to convert this MP3 to text.</p>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
@csrf
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
<span>
<span class="block text-sm font-medium">Cloud (OpenAI Whisper)</span>
<span class="block text-xs text-stone-500">Fast; audio is sent to OpenAI.</span>
</span>
</label>
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
<input type="radio" name="driver" value="local" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
<span>
<span class="block text-sm font-medium">Local confidential (faster-whisper)</span>
<span class="block text-xs text-stone-500">Requires faster-whisper-server on this machine (Docker).</span>
</span>
</label>
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
<input type="radio" name="driver" value="ollama" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
<span>
<span class="block text-sm font-medium">Ollama host</span>
<span class="block text-xs text-stone-500">
Remote host URL. Must expose OpenAI-compatible <code class="text-[11px]">/v1/audio/transcriptions</code>
(e.g. Whisper beside Ollama).
</span>
</span>
</label>
<div x-show="driver === 'ollama'" x-cloak class="space-y-2">
<label for="ollama_url" class="block text-sm font-medium text-stone-700">Host URL</label>
<input
id="ollama_url"
type="url"
name="ollama_url"
value="{{ old('ollama_url', $recording->ollama_url) }}"
placeholder="http://192.168.1.50:8000"
class="w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
</div>
<button
type="submit"
@disabled($recording->transcription_status === 'processing')
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ $recording->transcript ? 'Re-transcribe' : 'Start transcription' }}
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
@if ($recording->transcript)
<button
type="button"
onclick="navigator.clipboard.writeText(@js($recording->transcript))"
class="text-sm text-teal-700 hover:underline"
>
Copy
</button>
@endif
</div>
@if ($recording->transcription_status === 'processing')
<p class="mt-4 text-sm text-amber-700">Transcription in progress… Refresh shortly.</p>
@elseif ($recording->transcription_status === 'failed')
<p class="mt-4 text-sm text-red-700">Transcription failed. Check the logs and try again with another engine.</p>
@elseif ($recording->transcript)
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
@if ($recording->transcribed_at)
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
@endif
@else
<p class="mt-4 text-sm text-stone-500">No transcript yet. Choose an engine above to start.</p>
@endif
</section>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
@endsection
+16
View File
@@ -0,0 +1,16 @@
<?php
use App\Models\Recording;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('recording.{recordingId}', function (User $user, int $recordingId): bool {
return Recording::query()
->whereKey($recordingId)
->where('user_id', $user->id)
->exists();
});
Broadcast::channel('user.{userId}.recordings', function (User $user, int $userId): bool {
return (int) $user->id === $userId;
});
+15 -5
View File
@@ -1,10 +1,20 @@
<?php
use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\StreamRecordingController;
use App\Http\Controllers\TranscriptionStatusController;
use App\Livewire\Recordings\Create;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings');
Route::redirect('/', '/recordings')->name('home');
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe');
Route::middleware('auth')->group(function (): void {
Route::get('recordings', Index::class)->name('recordings.index');
Route::get('recordings/create', Create::class)->name('recordings.create');
Route::get('recordings/{recording}', Show::class)->name('recordings.show');
Route::get('recordings/{recording}/audio', StreamRecordingController::class)
->name('recordings.audio');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status');
});
Regular → Executable
View File

Some files were not shown because too many files have changed in this diff Show More