Compare commits
42
Commits
771658040b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77a18c49f | ||
|
|
1898be4def | ||
|
|
3601ce8b6f | ||
|
|
5e76bfdec1 | ||
|
|
c6856c04d5 | ||
|
|
7b9bf3ac7f | ||
|
|
500a88a8c1 | ||
|
|
3a9cf50529 | ||
|
|
187b6b5d12 | ||
|
|
4c73620458 | ||
|
|
5cea5192c2 | ||
|
|
a1bddca2dd | ||
|
|
1877fee258 | ||
|
|
b3fb74fb1b | ||
|
|
f42d124593 | ||
|
|
2b126ee4e6 | ||
|
|
ab14f5e452 | ||
|
|
761f1a1f78 | ||
|
|
5dd0a3aeac | ||
|
|
4898d5cfde | ||
|
|
f65c816464 | ||
|
|
d60851bb53 | ||
|
|
8a66cf6f63 | ||
|
|
5f0f61995c | ||
|
|
b7c36b8b3b | ||
|
|
6bc5a20606 | ||
|
|
c17f8fb506 | ||
|
|
148ba91816 | ||
|
|
34ccf0c32b | ||
|
|
bfcfc12f58 | ||
|
|
accb721811 | ||
|
|
386b15ce94 | ||
|
|
e81a27cb8f | ||
|
|
771ee8db5a | ||
|
|
352b564f3a | ||
|
|
1dcdfc0ed0 | ||
|
|
3498861184 | ||
|
|
21b17c7657 | ||
|
|
bc6cb5efa4 | ||
|
|
eb0f019025 | ||
|
|
e0400aadef | ||
|
|
67c1941833 |
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: echo-development
|
||||
description: "Develops real-time broadcasting with Laravel Echo. Activates when setting up broadcasting (Reverb, Pusher, Ably); creating ShouldBroadcast events; defining broadcast channels (public, private, presence, encrypted); authorizing channels; configuring Echo; listening for events; implementing client events (whisper); setting up model broadcasting; broadcasting notifications; or when the user mentions broadcasting, Echo, WebSockets, real-time events, Reverb, or presence channels."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Broadcasting & Echo
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Installing or configuring Laravel broadcasting (Reverb, Pusher, Ably)
|
||||
- Creating events that implement `ShouldBroadcast`
|
||||
- Defining broadcast channels and authorization
|
||||
- Setting up Laravel Echo on the client side
|
||||
- Listening for broadcast events, notifications, or model events
|
||||
- Implementing client-to-client events (whisper)
|
||||
- Working with presence channels for user awareness
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed broadcasting patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Installing Broadcasting
|
||||
|
||||
```bash
|
||||
php artisan install:broadcasting
|
||||
```
|
||||
|
||||
Use flags for specific drivers: `--reverb`, `--pusher`, `--ably`. This creates `config/broadcasting.php` and `routes/channels.php`.
|
||||
|
||||
### Creating a Broadcast Event
|
||||
|
||||
```bash
|
||||
php artisan make:event OrderShipped
|
||||
```
|
||||
|
||||
<!-- Broadcast Event -->
|
||||
```php
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderShipped implements ShouldBroadcast
|
||||
{
|
||||
use InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public Order $order) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('orders.'.$this->order->id)];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dispatch the event:
|
||||
|
||||
<!-- Dispatch Event -->
|
||||
```php
|
||||
use App\Events\OrderShipped;
|
||||
|
||||
OrderShipped::dispatch($order);
|
||||
```
|
||||
|
||||
### Authorizing Channels
|
||||
|
||||
Define authorization in `routes/channels.php`:
|
||||
|
||||
<!-- Channel Authorization -->
|
||||
```php
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
|
||||
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
|
||||
return $user->id === Order::findOrNew($orderId)->user_id;
|
||||
});
|
||||
```
|
||||
|
||||
Create a channel class for complex authorization:
|
||||
|
||||
```bash
|
||||
php artisan make:channel OrderChannel
|
||||
```
|
||||
|
||||
List all registered channels:
|
||||
|
||||
```bash
|
||||
php artisan channel:list
|
||||
```
|
||||
|
||||
### Client-Side Setup
|
||||
|
||||
Install Echo and Pusher JS:
|
||||
|
||||
```bash
|
||||
npm install --save-dev laravel-echo pusher-js
|
||||
```
|
||||
|
||||
<!-- Echo Client Configuration -->
|
||||
```javascript
|
||||
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'],
|
||||
});
|
||||
```
|
||||
|
||||
### Listening for Events
|
||||
|
||||
<!-- Listen on Private Channel -->
|
||||
```javascript
|
||||
Echo.private(`orders.${orderId}`)
|
||||
.listen('OrderShipmentStatusUpdated', (e) => {
|
||||
console.log(e.order);
|
||||
});
|
||||
```
|
||||
|
||||
### Running Required Processes
|
||||
|
||||
```bash
|
||||
php artisan queue:work # Required for ShouldBroadcast events
|
||||
|
||||
php artisan reverb:start # Required for Reverb driver
|
||||
|
||||
```
|
||||
|
||||
## What's Possible
|
||||
|
||||
Use `search-docs` to find detailed code examples and configuration for each of these:
|
||||
|
||||
### Channel Types
|
||||
|
||||
- Public (`new Channel`) — no auth, anyone can subscribe. Use for app-wide announcements, public feeds, or status pages.
|
||||
- Private (`new PrivateChannel`) — requires authorization. Use for user-specific data like orders, messages, or account updates.
|
||||
- Presence (`new PresenceChannel`) — authorized + tracks who's online. Use for chat rooms, collaborative editing, "who's viewing this" features, or typing indicators.
|
||||
- EncryptedPrivate — end-to-end encryption, Pusher/Reverb only. Use when payload must be hidden from the broadcast server (e.g., sensitive financial data or private messages).
|
||||
- Drivers: `reverb` (self-hosted WebSocket server), `pusher` (managed service), `ably` (managed service), `log` (writes to Laravel log, use for debugging), `null` (no-op, use for testing)
|
||||
|
||||
### Event Customization
|
||||
|
||||
- `broadcastAs()` — custom event name (client must use dot prefix: `.listen('.custom.name')`). Use when you want stable API names decoupled from PHP class names, or shorter event names for the frontend.
|
||||
- `broadcastWith()` — control exact payload. Use to avoid leaking sensitive model attributes, slim down large payloads, or add computed data not on the model.
|
||||
- `broadcastWhen()` — conditional broadcasting. Use to skip broadcasting when changes are trivial (e.g., only broadcast order updates above a threshold, or skip unchanged fields).
|
||||
- `broadcastQueue()` / `$queue` — route to specific queue. Use to isolate real-time broadcasts from slow background jobs so they're processed faster.
|
||||
- `$connection` — set queue connection per event. Use when broadcasts should go through a faster queue backend like Redis while other jobs use the database driver.
|
||||
|
||||
### Broadcasting Interfaces
|
||||
|
||||
- `ShouldBroadcast` — queue the broadcast (default). Use for most events to avoid blocking the HTTP response.
|
||||
- `ShouldBroadcastNow` — broadcast synchronously, skip queue. Use during development or for time-critical events where queue latency is unacceptable.
|
||||
- `ShouldDispatchAfterCommit` — wait for DB transaction commit. Use when the event references newly created records that listeners need to query (prevents race conditions).
|
||||
- `ShouldRescue` — auto-catch broadcast exceptions. Use to prevent broadcast failures (e.g., WebSocket server down) from disrupting the user's HTTP request.
|
||||
- `InteractsWithSockets` — required for `toOthers()`. Use on any event where you want to exclude the sender (optimistic UI updates).
|
||||
- `InteractsWithBroadcasting` — override driver per event via `broadcastVia()`. Use in multi-driver setups (e.g., some events via Reverb, others via Pusher).
|
||||
|
||||
### Broadcasting Helpers
|
||||
|
||||
- `broadcast(new Event)->toOthers()` — exclude current user's socket. Use when the client already updates optimistically from the API response to avoid duplicate updates.
|
||||
- `broadcast(new Event)->via('pusher')` — override connection. Use to route specific events through a different broadcast driver than the default.
|
||||
- `Broadcast::on()`, `Broadcast::private()`, `Broadcast::presence()` — anonymous broadcasting without event classes. Chain `.as('name')->with($data)->send()` or `.sendNow()`. Use for simple one-off broadcasts where creating a full event class is overkill (e.g., quick status updates, simple notifications).
|
||||
|
||||
### Channel Authorization
|
||||
|
||||
- Closure-based in `routes/channels.php` — use for simple authorization logic (e.g., checking ownership).
|
||||
- Model binding: `Broadcast::channel('orders.{order}', fn (User $user, Order $order) => ...)` — use when authorization depends on the model instance (auto-resolves from route parameter).
|
||||
- Channel classes via `php artisan make:channel` — use for complex authorization logic that benefits from dependency injection or reusable logic across channels.
|
||||
- Multiple guards: `['guards' => ['web', 'admin']]` — use when the channel should be accessible by users authenticated via different guards (e.g., both regular users and admins).
|
||||
|
||||
### Model Broadcasting
|
||||
|
||||
- `BroadcastsEvents` trait auto-broadcasts created/updated/deleted/trashed/restored. Use to automatically keep clients in sync with Eloquent model changes without writing individual events.
|
||||
- Channel convention: `App.Models.Post.{id}` — clients subscribe to model-specific channels.
|
||||
- `broadcastAs($event)` and `broadcastWith($event)` for per-action customization. Use to send different payloads for create vs update, or suppress certain event types.
|
||||
- `newBroadcastableEvent($event)` for event instance customization (e.g., `->dontBroadcastToCurrentUser()`). Use when you need to modify the underlying event object before it's dispatched.
|
||||
|
||||
### Client-Side Features
|
||||
|
||||
- Client events: `whisper()` / `listenForWhisper()` — peer-to-peer without server roundtrip (private/presence channels only). Use for typing indicators, cursor positions, or any ephemeral state that doesn't need server persistence.
|
||||
- Presence channels: `Echo.join()` with `here()`, `joining()`, `leaving()`, `error()` callbacks. Use for showing online users, "X is viewing this document" features, or live participant counts.
|
||||
- Notification broadcasting: `.notification()` on user's private channel. Use to show real-time notifications (toast, badge counts) pushed from Laravel's notification system.
|
||||
- Connection management: `Echo.connectionStatus()`, `Echo.leaveAllChannels()`, `Echo.disconnect()`. Use to show connection indicators, clean up on logout, or handle offline/reconnect scenarios.
|
||||
- Custom namespace: `new Echo({ namespace: 'App.Other.Namespace' })`. Use when your events live outside the default `App\Events` namespace.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Queue worker must be running for `ShouldBroadcast` events. Use `ShouldBroadcastNow` during development.
|
||||
- `BROADCAST_CONNECTION` not `BROADCAST_DRIVER`: Laravel 11+ renamed this env key.
|
||||
- `toOthers()` requires `InteractsWithSockets` trait AND `X-Socket-ID` header. Echo auto-adds this to global Axios. For `fetch`, manually send `Echo.socketId()`.
|
||||
- CORS: When frontend/backend are on different origins, add `broadcasting/auth` to `config/cors.php` paths and set `supports_credentials` to `true`.
|
||||
- Missing `VITE_` prefix: Client-side env vars must start with `VITE_`.
|
||||
- `channels.php` not loaded: Verify it's included in `withRouting()` in `bootstrap/app.php`.
|
||||
- Reverb is long-running: Code changes require `php artisan reverb:restart`.
|
||||
- Presence channel auth must return an array of user data (`['id' => $user->id, 'name' => $user->name]`), not `true`. Returning `true` silently fails.
|
||||
- Dot prefix rule: When using `broadcastAs()`, client must prefix with `.` (e.g., `.listen('.custom.name')`). Without the dot, Echo looks for `App\Events\custom.name` which silently fails.
|
||||
- Reverb host separation: `REVERB_SERVER_HOST`/`REVERB_SERVER_PORT` (internal bind) vs `REVERB_HOST`/`REVERB_PORT` (public address) vs `VITE_REVERB_HOST`/`VITE_REVERB_PORT` (client JS).
|
||||
- Sanctum SPA auth: Ensure `/broadcasting/auth` uses `auth:sanctum` middleware and CSRF tokens are sent with `withCredentials: true`.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: fluxui-development
|
||||
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Flux UI Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Flux UI patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
|
||||
|
||||
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
|
||||
|
||||
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
|
||||
|
||||
<!-- Basic Button -->
|
||||
```blade
|
||||
<flux:button variant="primary">Click me</flux:button>
|
||||
```
|
||||
|
||||
## Available Components (Free Edition)
|
||||
|
||||
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
|
||||
|
||||
## Icons
|
||||
|
||||
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
|
||||
|
||||
<!-- Icon Button -->
|
||||
```blade
|
||||
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||
```
|
||||
|
||||
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
|
||||
|
||||
```bash
|
||||
php artisan flux:icon crown grip-vertical github
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Fields
|
||||
|
||||
<!-- Form Field -->
|
||||
```blade
|
||||
<flux:field>
|
||||
<flux:label>Email</flux:label>
|
||||
<flux:input type="email" wire:model="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
<!-- Modal -->
|
||||
```blade
|
||||
<flux:modal wire:model="showModal">
|
||||
<flux:heading>Title</flux:heading>
|
||||
<p>Content</p>
|
||||
</flux:modal>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check component renders correctly
|
||||
2. Test interactive states
|
||||
3. Verify mobile responsiveness
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Trying to use Pro-only components in the free edition
|
||||
- Not checking if a Flux component exists before creating custom implementations
|
||||
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||
- Not following existing project patterns for Flux usage
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: fortify-development
|
||||
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Fortify Development
|
||||
|
||||
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
|
||||
|
||||
## Usage
|
||||
|
||||
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
|
||||
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
|
||||
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
|
||||
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
|
||||
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||
|
||||
## Available Features
|
||||
|
||||
Enable in `config/fortify.php` features array:
|
||||
|
||||
- `Features::registration()` - User registration
|
||||
- `Features::resetPasswords()` - Password reset via email
|
||||
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
|
||||
- `Features::updateProfileInformation()` - Profile updates
|
||||
- `Features::updatePasswords()` - Password changes
|
||||
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
|
||||
|
||||
> Use `search-docs` for feature configuration options and customization patterns.
|
||||
|
||||
## Setup Workflows
|
||||
|
||||
### Two-Factor Authentication Setup
|
||||
|
||||
```
|
||||
- [ ] Add TwoFactorAuthenticatable trait to User model
|
||||
- [ ] Enable feature in config/fortify.php
|
||||
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||
- [ ] Set up view callbacks in FortifyServiceProvider
|
||||
- [ ] Create 2FA management UI
|
||||
- [ ] Test QR code and recovery codes
|
||||
```
|
||||
|
||||
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||
|
||||
### Passkeys Setup
|
||||
|
||||
```
|
||||
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
|
||||
- [ ] Enable passkeys feature in config/fortify.php
|
||||
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
|
||||
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
|
||||
```
|
||||
|
||||
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
|
||||
|
||||
### Email Verification Setup
|
||||
|
||||
```
|
||||
- [ ] Enable emailVerification feature in config
|
||||
- [ ] Implement MustVerifyEmail interface on User model
|
||||
- [ ] Set up verifyEmailView callback
|
||||
- [ ] Add verified middleware to protected routes
|
||||
- [ ] Test verification email flow
|
||||
```
|
||||
|
||||
> Use `search-docs` for MustVerifyEmail implementation patterns.
|
||||
|
||||
### Password Reset Setup
|
||||
|
||||
```
|
||||
- [ ] Enable resetPasswords feature in config
|
||||
- [ ] Set up requestPasswordResetLinkView callback
|
||||
- [ ] Set up resetPasswordView callback
|
||||
- [ ] Define password.reset named route (if views disabled)
|
||||
- [ ] Test reset email and link flow
|
||||
```
|
||||
|
||||
> Use `search-docs` for custom password reset flow patterns.
|
||||
|
||||
### SPA Authentication Setup
|
||||
|
||||
```
|
||||
- [ ] Set 'views' => false in config/fortify.php
|
||||
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
|
||||
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
|
||||
- [ ] Set up CSRF token handling
|
||||
- [ ] Test XHR authentication flows
|
||||
```
|
||||
|
||||
> Use `search-docs` for integration and SPA authentication patterns.
|
||||
|
||||
#### Two-Factor Authentication in SPA Mode
|
||||
|
||||
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
|
||||
|
||||
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
|
||||
|
||||
```json
|
||||
{
|
||||
"two_factor": true
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Custom Authentication Logic
|
||||
|
||||
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
|
||||
|
||||
### Registration Customization
|
||||
|
||||
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
| Feature | Method | Endpoint |
|
||||
|------------------------|----------|---------------------------------------------|
|
||||
| Login | POST | `/login` |
|
||||
| Logout | POST | `/logout` |
|
||||
| Register | POST | `/register` |
|
||||
| Password Reset Request | POST | `/forgot-password` |
|
||||
| Password Reset | POST | `/reset-password` |
|
||||
| Email Verify Notice | GET | `/email/verify` |
|
||||
| Resend Verification | POST | `/email/verification-notification` |
|
||||
| Password Confirm | POST | `/user/confirm-password` |
|
||||
| Enable 2FA | POST | `/user/two-factor-authentication` |
|
||||
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
|
||||
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||
| Passkey Login Options | GET | `/passkeys/login/options` |
|
||||
| Passkey Login | POST | `/passkeys/login` |
|
||||
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
|
||||
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||
| Passkey Options | GET | `/user/passkeys/options` |
|
||||
| Register Passkey | POST | `/user/passkeys` |
|
||||
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||
@@ -30,7 +30,7 @@ Fan out when you can. The sweep is embarrassingly parallel. If your environment
|
||||
|
||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||
|
||||
This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions.
|
||||
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||
|
||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||
|
||||
|
||||
@@ -77,12 +77,16 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
|
||||
|
||||
## F. Frontend & views
|
||||
|
||||
No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable.
|
||||
This app ships a frontend stack, so the items below apply.
|
||||
|
||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
|
||||
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
|
||||
31. UI kit (Flux): Flux components vs custom components vs another library.
|
||||
- Hint: grep `<flux:` in `resources/views`.
|
||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: livewire-development
|
||||
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Livewire Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Livewire 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Components
|
||||
|
||||
```bash
|
||||
|
||||
# Single-file component (SFC - default in v4)
|
||||
|
||||
# Creates: resources/views/components/⚡create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post
|
||||
|
||||
# Page component (SFC - Full Page in v4)
|
||||
|
||||
# Creates: resources/views/pages/⚡create-post.blade.php
|
||||
|
||||
php artisan make:livewire pages::create-post
|
||||
|
||||
# Multi-file component (MFC)
|
||||
|
||||
# Creates: resources/views/components/⚡create-post/create-post.php
|
||||
|
||||
# resources/views/components/⚡create-post/create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post --mfc
|
||||
|
||||
# Class-based component (v3 style)
|
||||
|
||||
# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
|
||||
|
||||
php artisan make:livewire create-post --class
|
||||
|
||||
# With namespace
|
||||
|
||||
php artisan make:livewire Posts/CreatePost
|
||||
```
|
||||
|
||||
### Converting Between Formats
|
||||
|
||||
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
|
||||
|
||||
### Choosing a Component Format
|
||||
|
||||
> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
|
||||
|
||||
Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
|
||||
|
||||
### Component Format Reference
|
||||
|
||||
| Format | Flag | Class Path | View Path |
|
||||
|--------|------|------------|-----------|
|
||||
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
|
||||
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
|
||||
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
|
||||
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
||||
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
|
||||
|
||||
> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
|
||||
|
||||
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
|
||||
|
||||
### Single-File Component Example
|
||||
|
||||
<!-- Single-File Component Example -->
|
||||
```php
|
||||
<?php
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component {
|
||||
public int $count = 0;
|
||||
|
||||
public function increment(): void
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div>
|
||||
<button wire:click="increment">Count: @{{ $count }}</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Livewire 4 Specifics
|
||||
|
||||
### Key Changes From Livewire 3
|
||||
|
||||
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
|
||||
|
||||
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
|
||||
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
|
||||
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
|
||||
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
|
||||
|
||||
### New Features
|
||||
|
||||
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
|
||||
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
|
||||
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
|
||||
|
||||
| Feature | Usage | Purpose |
|
||||
|---------|-------|---------|
|
||||
| Islands | `@island(name: 'stats')` | Isolated update regions |
|
||||
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
|
||||
| Deferred | `defer` attribute | Load after page render |
|
||||
| Bundled | `lazy.bundle` | Load multiple together |
|
||||
|
||||
### New Directives
|
||||
|
||||
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
|
||||
- `data-loading` attribute automatically added to elements triggering network requests.
|
||||
|
||||
| Directive | Purpose |
|
||||
|-----------|---------|
|
||||
| `wire:sort` | Drag-and-drop sorting |
|
||||
| `wire:intersect` | Viewport intersection detection |
|
||||
| `wire:ref` | Element references for JS |
|
||||
| `.renderless` | Component without rendering |
|
||||
| `.preserve-scroll` | Preserve scroll position |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use `wire:key` in loops
|
||||
- Use `wire:loading` for loading states
|
||||
- Use `wire:model.live` for instant updates (default is debounced)
|
||||
- Validate and authorize in actions (treat like HTTP requests)
|
||||
|
||||
## Configuration
|
||||
|
||||
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
|
||||
|
||||
## Alpine & JavaScript
|
||||
|
||||
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
|
||||
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
|
||||
|
||||
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Testing Example -->
|
||||
```php
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1);
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Browser console: Check for JS errors
|
||||
2. Network tab: Verify Livewire requests return 200
|
||||
3. Ensure `wire:key` on all `@foreach` loops
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Missing `wire:key` in loops → unexpected re-rendering
|
||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||
- Unclosed component tags → syntax errors in v4
|
||||
- Using deprecated config keys or JS hooks
|
||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Livewire 4 JavaScript Integration
|
||||
|
||||
## Interceptor System (v4)
|
||||
|
||||
### Intercept Messages
|
||||
|
||||
```js
|
||||
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
|
||||
onFinish(() => { /* After response, before processing */ });
|
||||
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
|
||||
onError(() => { /* Server errors */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Intercept Requests
|
||||
|
||||
```js
|
||||
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
|
||||
onResponse(({ response }) => { /* When received */ });
|
||||
onSuccess(({ response, responseJson }) => { /* Success */ });
|
||||
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
|
||||
onFailure(({ error }) => { /* Network failures */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Component-Scoped Interceptors
|
||||
|
||||
```blade
|
||||
<script>
|
||||
this.$intercept('save', ({ component, onSuccess }) => {
|
||||
onSuccess(() => console.log('Saved!'));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Magic Properties
|
||||
|
||||
- `$errors` - Access validation errors from JavaScript
|
||||
- `$intercept` - Component-scoped interceptors
|
||||
@@ -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
|
||||
+51
-9
@@ -2,7 +2,9 @@ APP_NAME=AndyTranscribe
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
APP_URL=http://localhost:8080
|
||||
# Production/CI: set to the Gitea registry image (local Compose builds andytranscribe-app:latest).
|
||||
# APP_IMAGE=gitea.z00.nu/ben/andytranscribe:latest
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -27,17 +29,17 @@ DB_CONNECTION=sqlite
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
@@ -64,11 +66,51 @@ 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 Blade meta (reverb.client) with VITE_* fallback.
|
||||
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
|
||||
# Public Echo target (z00 stage/prod set these in the instance .env).
|
||||
# REVERB_PUBLIC_HOST=reverb.transcribe.z00.nu
|
||||
# REVERB_PUBLIC_PORT=443
|
||||
# REVERB_PUBLIC_SCHEME=https
|
||||
|
||||
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
|
||||
APP_HOST_PORT=8080
|
||||
REVERB_HOST_PORT=8081
|
||||
WHISPER_HOST_PORT=8090
|
||||
# Unique per Compose instance on a shared Docker host (prod default keeps existing names).
|
||||
# CONTAINER_PREFIX=andytranscribe
|
||||
# CADDY_NETWORK=caddy-proxy-manager-test_caddy-test-network
|
||||
# PUBLIC_APP_URL=https://transcribe.z00.nu
|
||||
|
||||
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
|
||||
# curl streams large uploads off disk; http is for tests (Http::fake)
|
||||
LOCAL_WHISPER_TRANSPORT=curl
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- stage
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.z00.nu
|
||||
# Fallback baked into the Vite bundle; public pages prefer Blade meta tags.
|
||||
VITE_REVERB_HOST: reverb.transcribe.z00.nu
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: https
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force --no-interaction
|
||||
php -d memory_limit=512M artisan test --compact
|
||||
|
||||
- name: Validate compose
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
|
||||
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
|
||||
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
|
||||
|
||||
- name: Validate stage compose
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
|
||||
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
|
||||
PUBLIC_APP_URL="https://stage.transcribe.z00.nu" \
|
||||
APP_URL="https://stage.transcribe.z00.nu" \
|
||||
REVERB_PUBLIC_HOST="reverb.stage.transcribe.z00.nu" \
|
||||
CONTAINER_PREFIX="andytranscribe-stage" \
|
||||
CADDY_NETWORK="andytranscribe-stage-caddy" \
|
||||
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
|
||||
|
||||
build-and-push:
|
||||
if: gitea.event_name != 'pull_request'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
TAG_SHA="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
TAG_LATEST="${REGISTRY}/${REPO_LC}:latest"
|
||||
TAG_STAGE="${REGISTRY}/${REPO_LC}:stage"
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY}" -u "${{ gitea.actor }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VITE_APP_NAME=AndyTranscribe \
|
||||
--build-arg VITE_REVERB_APP_KEY=andytranscribe-key \
|
||||
--build-arg "VITE_REVERB_HOST=${VITE_REVERB_HOST}" \
|
||||
--build-arg "VITE_REVERB_PORT=${VITE_REVERB_PORT}" \
|
||||
--build-arg "VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}" \
|
||||
-t "${TAG_SHA}" \
|
||||
.
|
||||
docker push "${TAG_SHA}"
|
||||
if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
|
||||
docker tag "${TAG_SHA}" "${TAG_LATEST}"
|
||||
docker push "${TAG_LATEST}"
|
||||
elif [ "${{ gitea.ref }}" = "refs/heads/stage" ]; then
|
||||
docker tag "${TAG_SHA}" "${TAG_STAGE}"
|
||||
docker push "${TAG_STAGE}"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy production
|
||||
env:
|
||||
DEPLOY_PATHS: ${{ secrets.DEPLOY_PATHS }}
|
||||
DEPLOY_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
chmod +x scripts/deploy-production.sh
|
||||
if [ -z "${DEPLOY_PATHS:-}" ]; then
|
||||
echo "DEPLOY_PATHS secret is not set; skipping deploy."
|
||||
echo "Built image: ${APP_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
./scripts/deploy-production.sh
|
||||
|
||||
deploy-stage:
|
||||
if: gitea.ref == 'refs/heads/stage' && gitea.event_name != 'pull_request'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ gitea.server_url }}"
|
||||
HOST="${HOST#https://}"
|
||||
HOST="${HOST#http://}"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
|
||||
.
|
||||
git fetch --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout --force "${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy stage
|
||||
env:
|
||||
DEPLOY_PATHS: ${{ secrets.STAGE_DEPLOY_PATHS }}
|
||||
DEPLOY_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
|
||||
chmod +x scripts/deploy-production.sh
|
||||
if [ -z "${DEPLOY_PATHS:-}" ]; then
|
||||
echo "STAGE_DEPLOY_PATHS secret is not set; skipping deploy."
|
||||
echo "Built image: ${APP_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
./scripts/deploy-production.sh
|
||||
@@ -21,6 +21,7 @@
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/storage/app/private/truss
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"truss": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"mcp:start",
|
||||
"truss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Tools
|
||||
|
||||
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||
|
||||
## Searching Documentation (IMPORTANT)
|
||||
|
||||
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Search Syntax
|
||||
|
||||
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -104,6 +104,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
@@ -134,6 +141,14 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
+108
@@ -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"]
|
||||
@@ -1,101 +1,299 @@
|
||||
# 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`.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
Gitea Actions (host runner on z00) on push:
|
||||
|
||||
| Branch | Image tags | Deploy target |
|
||||
| --- | --- | --- |
|
||||
| `main` | `<sha>` and `:latest` | `~/andyTranscibe` → https://transcribe.z00.nu |
|
||||
| `stage` | `<sha>` and `:stage` | `~/andyTranscibe-stage` → https://stage.transcribe.z00.nu |
|
||||
|
||||
Both runs:
|
||||
|
||||
1. PHPUnit (+ compose config check for prod and stage env)
|
||||
2. Build and push `gitea.z00.nu/ben/andytranscribe:<sha>` — Vite assets are baked in; browser Reverb host/key come from Blade meta at runtime
|
||||
3. Hard-reset the matching checkout and pull the image (`docker-compose.yml` + `compose.z00.yaml`)
|
||||
|
||||
Stage is a separate Compose project: own SQLite, uploads, `APP_KEY`, Reverb credentials, container names (`andytranscribe-stage-*`), host ports, and Caddy Docker network (`andytranscribe-stage-caddy`). Do not copy prod `.env` or data into the stage directory.
|
||||
|
||||
Do not hot-patch production or staging containers or their deploy checkouts. Fix in git; push `stage` to preview, then `main` to ship.
|
||||
|
||||
One-time server bootstrap (secrets + registry login):
|
||||
|
||||
```bash
|
||||
./scripts/setup-gitea-ci.sh
|
||||
./scripts/setup-stage.sh
|
||||
```
|
||||
|
||||
`setup-stage.sh` clones `$HOME/andyTranscibe-stage` from Gitea (not the prod checkout), writes a unique `.env`, creates `andytranscribe-stage-caddy`, and attaches Caddy Proxy Manager to that network. You still need DNS for `stage.transcribe.z00.nu` and `reverb.stage.transcribe.z00.nu`, plus Proxy Manager hosts:
|
||||
|
||||
- `stage.transcribe.z00.nu` → `andytranscribe-stage-app:80`
|
||||
- `reverb.stage.transcribe.z00.nu` → `andytranscribe-stage-reverb:8080`
|
||||
|
||||
Create and push the branch after the workflow file is on the default branch:
|
||||
|
||||
```bash
|
||||
git checkout -b stage
|
||||
git push -u origin stage
|
||||
```
|
||||
|
||||
Manual deploy of an already-built tag:
|
||||
|
||||
```bash
|
||||
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
|
||||
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe-stage ./scripts/deploy-production.sh
|
||||
```
|
||||
|
||||
## 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_IMAGE` | Pre-built image for CI/prod deploys (omit locally) | `andytranscribe-app:latest` |
|
||||
| `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` |
|
||||
| `REVERB_PUBLIC_HOST` | Browser WebSocket host (Blade meta) | `REVERB_HOST` |
|
||||
| `CONTAINER_PREFIX` | Docker `container_name` prefix | `andytranscribe` |
|
||||
| `CADDY_NETWORK` | External Caddy network (z00 overlay) | `caddy-proxy-manager-test_caddy-test-network` |
|
||||
| `PUBLIC_APP_URL` | Public URL injected by the z00 overlay (unset = prod) | `https://transcribe.z00.nu` |
|
||||
| `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds). Hung Whisper calls fail the job; UI can restart. | `600` |
|
||||
| `DB_QUEUE_RETRY_AFTER` | Must exceed `TRANSCRIPTION_TIMEOUT` | `660` |
|
||||
|
||||
Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects using Reverb settings from the HTML meta tags (with Vite `VITE_REVERB_*` as a local fallback).
|
||||
|
||||
If you change `REVERB_APP_KEY` or browser-facing Reverb host/port settings, restart the app container so Blade picks up the new values. An image rebuild is only needed when Vite-baked fallbacks must change.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -105,6 +303,20 @@ composer test
|
||||
php artisan test
|
||||
```
|
||||
|
||||
## Schema viewer (Laravel Truss)
|
||||
|
||||
[Laravel Truss](https://github.com/albertoarena/laravel-truss) is installed as a **dev** dependency. In `local`, open:
|
||||
|
||||
```
|
||||
/truss
|
||||
```
|
||||
|
||||
It shows a live ER diagram of your schema (structure only, never row data). Optional agent MCP:
|
||||
|
||||
```bash
|
||||
php artisan mcp:start truss
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Reverb's default max payload is 10KB. Keep streamed text well under that.
|
||||
*/
|
||||
public const MAX_DELTA_BYTES = 8_000;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param array<string, mixed>|null $whisperDelta
|
||||
*/
|
||||
public function __construct(
|
||||
public Recording $recording,
|
||||
public ?string $transcriptDelta = null,
|
||||
public bool $transcriptReplace = false,
|
||||
public ?array $whisperDelta = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$payload = array_merge(
|
||||
$this->recording->transcriptionStatusPayload(),
|
||||
[
|
||||
'word_count' => $this->recording->word_count,
|
||||
],
|
||||
);
|
||||
|
||||
unset($payload['transcript'], $payload['whisper']);
|
||||
|
||||
$delta = $this->transcriptDelta;
|
||||
|
||||
if ($delta !== null && $delta !== '' && strlen($delta) <= self::MAX_DELTA_BYTES) {
|
||||
$payload['transcript_delta'] = $delta;
|
||||
$payload['transcript_replace'] = $this->transcriptReplace;
|
||||
}
|
||||
|
||||
$whisper = $this->compactWhisperDelta($this->whisperDelta);
|
||||
|
||||
if ($whisper !== null) {
|
||||
$payload['whisper_delta'] = $whisper;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $whisper
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function compactWhisperDelta(?array $whisper): ?array
|
||||
{
|
||||
if ($whisper === null || $whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accumulated segment lists belong in HTTP hydrate, not on Reverb.
|
||||
unset($whisper['segments']);
|
||||
|
||||
if ($whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['tokens', 'logprobs', 'words'] as $drop) {
|
||||
$encoded = json_encode($whisper);
|
||||
|
||||
if (! is_string($encoded) || strlen($encoded) <= self::MAX_DELTA_BYTES) {
|
||||
return $whisper;
|
||||
}
|
||||
|
||||
$whisper = $this->dropWhisperField($whisper, $drop);
|
||||
}
|
||||
|
||||
$encoded = json_encode($whisper);
|
||||
|
||||
if (! is_string($encoded) || strlen($encoded) > self::MAX_DELTA_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $whisper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $whisper
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function dropWhisperField(array $whisper, string $field): array
|
||||
{
|
||||
unset($whisper[$field]);
|
||||
|
||||
if (isset($whisper['segment']) && is_array($whisper['segment'])) {
|
||||
unset($whisper['segment'][$field]);
|
||||
}
|
||||
|
||||
if (isset($whisper['segments']) && is_array($whisper['segments'])) {
|
||||
$whisper['segments'] = array_map(function (mixed $segment) use ($field): mixed {
|
||||
if (is_array($segment)) {
|
||||
unset($segment[$field]);
|
||||
}
|
||||
|
||||
return $segment;
|
||||
}, $whisper['segments']);
|
||||
}
|
||||
|
||||
return $whisper;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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://',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,40 @@ use App\Models\Recording;
|
||||
use App\Services\TranscriptionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\Attributes\FailOnTimeout;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
#[FailOnTimeout]
|
||||
class TranscribeRecording implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public int $timeout = 600;
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*
|
||||
* Covers a hung Whisper HTTP call: the worker is killed, failed() runs,
|
||||
* and the recording is marked failed so the UI can restart.
|
||||
*/
|
||||
public int $timeout;
|
||||
|
||||
/**
|
||||
* 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 +47,131 @@ 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,
|
||||
'transcription_verbose' => null,
|
||||
])->save();
|
||||
|
||||
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
|
||||
|
||||
try {
|
||||
$text = $transcription->transcribe($this->recording);
|
||||
$text = $transcription->transcribe(
|
||||
$this->recording,
|
||||
function (string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void {
|
||||
$this->reportIfOwned($message, $percent, $partialTranscript, $whisper);
|
||||
},
|
||||
fn (): bool => Recording::query()->find($this->recording->id)
|
||||
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
|
||||
);
|
||||
|
||||
$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->recording->markTranscriptionComplete($text);
|
||||
|
||||
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')
|
||||
|| str_contains((string) $this->recording->transcription_error, 'timed out')
|
||||
)
|
||||
) {
|
||||
Log::warning('Recovering transcript after false orphan failure', [
|
||||
'recording_id' => $this->recording->id,
|
||||
]);
|
||||
|
||||
$this->recording->markTranscriptionComplete($text);
|
||||
|
||||
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, ?string $partialTranscript = null, ?array $whisper = null): void
|
||||
{
|
||||
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript, whisperDelta: $whisper);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?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\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
#[Title('Recordings')]
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[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->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();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Show extends Component
|
||||
{
|
||||
public Recording $recording;
|
||||
|
||||
public function mount(Recording $recording): void
|
||||
{
|
||||
Gate::authorize('view', $recording);
|
||||
|
||||
$recording->recoverOrphanedTranscription();
|
||||
$recording->refresh();
|
||||
|
||||
$this->recording = $recording;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return view('livewire.recordings.show')
|
||||
->title($this->recording->title);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
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\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class Recording extends Model
|
||||
{
|
||||
@@ -12,6 +23,7 @@ class Recording extends Model
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'original_filename',
|
||||
'file_path',
|
||||
@@ -20,11 +32,18 @@ class Recording extends Model
|
||||
'artist',
|
||||
'album',
|
||||
'file_size_bytes',
|
||||
'content_hash',
|
||||
'transcript',
|
||||
'transcription_verbose',
|
||||
'transcription_status',
|
||||
'transcription_progress',
|
||||
'transcription_percent',
|
||||
'transcription_started_at',
|
||||
'transcription_error',
|
||||
'transcription_driver',
|
||||
'ollama_url',
|
||||
'transcribed_at',
|
||||
'transcription_duration_seconds',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -35,11 +54,24 @@ class Recording extends Model
|
||||
return [
|
||||
'recorded_at' => 'datetime',
|
||||
'transcribed_at' => 'datetime',
|
||||
'transcription_started_at' => 'datetime',
|
||||
'duration_seconds' => 'integer',
|
||||
'transcription_duration_seconds' => 'integer',
|
||||
'file_size_bytes' => 'integer',
|
||||
'transcription_percent' => 'integer',
|
||||
'transcription_verbose' => 'array',
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable duration (m:ss).
|
||||
*/
|
||||
@@ -57,6 +89,601 @@ 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,
|
||||
'transcription_duration_seconds' => null,
|
||||
// Keep the previous transcript until a new run succeeds.
|
||||
'transcribed_at' => $this->transcribed_at,
|
||||
]);
|
||||
|
||||
$recording = $this->fresh();
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds since the current transcription run started.
|
||||
*/
|
||||
public function transcriptionElapsedSeconds(): ?int
|
||||
{
|
||||
if ($this->transcription_started_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, now()->getTimestamp() - $this->transcription_started_at->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) ? '…' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds after which a reserved queue row is considered abandoned
|
||||
* (worker died mid-Whisper without releasing the job).
|
||||
*/
|
||||
public function transcriptionJobStaleAfterSeconds(): int
|
||||
{
|
||||
return max(120, (int) config('ai.transcription_timeout', 600) + 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a TranscribeRecording job for this recording is still on the queue.
|
||||
*
|
||||
* Reserved jobs older than the transcription timeout (+ grace) are ignored so
|
||||
* orphan recovery can unblock the UI when Whisper/the worker is wedged.
|
||||
*/
|
||||
public function hasActiveTranscriptionJob(): bool
|
||||
{
|
||||
$staleBefore = now()->timestamp - $this->transcriptionJobStaleAfterSeconds();
|
||||
|
||||
return DB::table('jobs')
|
||||
->orderBy('id')
|
||||
->get(['id', 'payload', 'reserved_at'])
|
||||
->contains(function (object $job) use ($staleBefore): bool {
|
||||
$payload = (string) $job->payload;
|
||||
|
||||
if (! str_contains($payload, 'TranscribeRecording')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->jobPayloadBelongsToRecording($payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback payload match when unserialize is unavailable.
|
||||
*/
|
||||
private function payloadMentionsRecording(string $payload): bool
|
||||
{
|
||||
// Jobs table stores JSON; the serialized command inside escapes quotes as \".
|
||||
return (bool) preg_match('/id\\\\";i:'.$this->id.';/', $payload)
|
||||
|| (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|
||||
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|
||||
|| str_contains($payload, 'id\\\\";s:'.strlen((string) $this->id).':\\"'.$this->id.'\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->jobPayloadBelongsToRecording($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('jobs')->where('id', $job->id)->delete();
|
||||
$deleted++;
|
||||
});
|
||||
|
||||
$this->releaseTranscriptionUniqueLock();
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a jobs.payload row targets this recording.
|
||||
*/
|
||||
private function jobPayloadBelongsToRecording(string $payload): bool
|
||||
{
|
||||
$data = json_decode($payload, true);
|
||||
$command = $data['data']['command'] ?? null;
|
||||
|
||||
if (is_string($command)) {
|
||||
if (
|
||||
preg_match('/id";i:'.$this->id.';/', $command)
|
||||
|| str_contains($command, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$queued = unserialize($command);
|
||||
|
||||
if ($queued instanceof TranscribeRecording) {
|
||||
return (int) $queued->recording->getKey() === (int) $this->id;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Fall through to escaped JSON heuristics.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payloadMentionsRecording($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transcription: drop queued jobs and mark the run cancelled.
|
||||
*
|
||||
* @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();
|
||||
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a successful transcript and how long the run took.
|
||||
*/
|
||||
public function markTranscriptionComplete(string $text): void
|
||||
{
|
||||
$finishedAt = now();
|
||||
$startedAt = $this->transcription_started_at;
|
||||
$durationSeconds = $startedAt === null
|
||||
? null
|
||||
: max(0, $finishedAt->getTimestamp() - $startedAt->getTimestamp());
|
||||
|
||||
$this->forceFill([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcription_progress' => 'Transcription complete',
|
||||
'transcription_percent' => 100,
|
||||
'transcription_error' => null,
|
||||
'transcribed_at' => $finishedAt,
|
||||
'transcription_duration_seconds' => $durationSeconds,
|
||||
])->save();
|
||||
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
$this->broadcastTranscriptionUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a stuck transcription if the queue job is gone or a reservation is stale.
|
||||
*/
|
||||
public function recoverOrphanedTranscription(): bool
|
||||
{
|
||||
if (! $this->isOrphanedTranscription()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop abandoned reserved rows so a restart can enqueue cleanly.
|
||||
$this->discardQueuedTranscriptionJobs();
|
||||
|
||||
$this->markTranscriptionFailed(
|
||||
'Transcription timed out or the worker stopped before finishing. Start transcription again.',
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the live progress fields shown in the UI.
|
||||
*
|
||||
* @param array<string, mixed>|null $whisperDelta
|
||||
*/
|
||||
public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null, ?array $whisperDelta = null): void
|
||||
{
|
||||
$diff = $this->transcriptBroadcastDiff($partialTranscript);
|
||||
|
||||
$attributes = [
|
||||
'transcription_status' => $status,
|
||||
'transcription_progress' => $message,
|
||||
'transcription_percent' => max(0, min(100, $percent)),
|
||||
'transcription_error' => null,
|
||||
];
|
||||
|
||||
if ($partialTranscript !== null) {
|
||||
$attributes['transcript'] = $partialTranscript;
|
||||
}
|
||||
|
||||
if ($whisperDelta !== null) {
|
||||
$attributes['transcription_verbose'] = $this->mergeWhisperVerbose($whisperDelta);
|
||||
}
|
||||
|
||||
$this->forceFill($attributes)->save();
|
||||
|
||||
$this->broadcastTranscriptionUpdated($diff['delta'], $diff['replace'], $whisperDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the current transcription status to connected browsers.
|
||||
*
|
||||
* @param array<string, mixed>|null $whisperDelta
|
||||
*/
|
||||
public function broadcastTranscriptionUpdated(?string $transcriptDelta = null, bool $transcriptReplace = false, ?array $whisperDelta = null): void
|
||||
{
|
||||
try {
|
||||
RecordingTranscriptionUpdated::dispatch(
|
||||
$this->fresh() ?? $this,
|
||||
$transcriptDelta,
|
||||
$transcriptReplace,
|
||||
$whisperDelta,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to broadcast transcription status', [
|
||||
'recording_id' => $this->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental text to push over Reverb instead of the full transcript.
|
||||
*
|
||||
* @return array{delta: ?string, replace: bool}
|
||||
*/
|
||||
public function transcriptBroadcastDiff(?string $next): array
|
||||
{
|
||||
if ($next === null) {
|
||||
return ['delta' => null, 'replace' => false];
|
||||
}
|
||||
|
||||
$previous = (string) $this->transcript;
|
||||
|
||||
if ($previous === '' || ! str_starts_with($next, $previous)) {
|
||||
return ['delta' => $next, 'replace' => true];
|
||||
}
|
||||
|
||||
$delta = substr($next, strlen($previous));
|
||||
|
||||
return ['delta' => $delta === '' ? null : $delta, 'replace' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a streamed Whisper chunk into the stored verbose_json snapshot.
|
||||
*
|
||||
* @param array<string, mixed> $delta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function mergeWhisperVerbose(array $delta): array
|
||||
{
|
||||
$verbose = is_array($this->transcription_verbose) ? $this->transcription_verbose : [];
|
||||
|
||||
if (isset($delta['language']) && is_string($delta['language'])) {
|
||||
$verbose['language'] = $delta['language'];
|
||||
}
|
||||
|
||||
if (isset($delta['duration']) && is_numeric($delta['duration'])) {
|
||||
$verbose['duration'] = (float) $delta['duration'];
|
||||
}
|
||||
|
||||
$segments = is_array($verbose['segments'] ?? null) ? $verbose['segments'] : [];
|
||||
|
||||
if (isset($delta['segment']) && is_array($delta['segment'])) {
|
||||
$segments = $this->appendVerboseSegment($segments, $delta['segment']);
|
||||
}
|
||||
|
||||
if (isset($delta['segments']) && is_array($delta['segments'])) {
|
||||
$incoming = [];
|
||||
|
||||
foreach ($delta['segments'] as $segment) {
|
||||
if (is_array($segment)) {
|
||||
$incoming[] = $segment;
|
||||
}
|
||||
}
|
||||
|
||||
if ($incoming !== []) {
|
||||
if (isset($delta['segment'])) {
|
||||
foreach ($incoming as $segment) {
|
||||
$segments = $this->appendVerboseSegment($segments, $segment);
|
||||
}
|
||||
} else {
|
||||
$segments = $incoming;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($delta['words']) && is_array($delta['words']) && $delta['words'] !== []) {
|
||||
$last = $segments === [] ? null : count($segments) - 1;
|
||||
|
||||
if ($last === null) {
|
||||
$segments[] = ['words' => $delta['words']];
|
||||
} else {
|
||||
$existing = is_array($segments[$last]['words'] ?? null) ? $segments[$last]['words'] : [];
|
||||
$segments[$last]['words'] = array_merge($existing, $delta['words']);
|
||||
}
|
||||
}
|
||||
|
||||
$logprobs = is_array($verbose['logprobs'] ?? null) ? $verbose['logprobs'] : [];
|
||||
|
||||
if (isset($delta['logprobs']) && is_array($delta['logprobs'])) {
|
||||
foreach ($delta['logprobs'] as $row) {
|
||||
if (is_array($row)) {
|
||||
$logprobs[] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($segments !== []) {
|
||||
$verbose['segments'] = $segments;
|
||||
}
|
||||
|
||||
if ($logprobs !== []) {
|
||||
$verbose['logprobs'] = $logprobs;
|
||||
}
|
||||
|
||||
return $verbose;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $segments
|
||||
* @param array<string, mixed> $segment
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function appendVerboseSegment(array $segments, array $segment): array
|
||||
{
|
||||
$key = $this->verboseSegmentKey($segment);
|
||||
|
||||
foreach ($segments as $existing) {
|
||||
if ($this->verboseSegmentKey($existing) === $key) {
|
||||
return $segments;
|
||||
}
|
||||
}
|
||||
|
||||
$segments[] = $segment;
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $segment
|
||||
*/
|
||||
private function verboseSegmentKey(array $segment): string
|
||||
{
|
||||
return implode('|', [
|
||||
$segment['id'] ?? '',
|
||||
$segment['start'] ?? '',
|
||||
$segment['end'] ?? '',
|
||||
$segment['text'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute filesystem path for the stored audio file.
|
||||
*/
|
||||
@@ -65,6 +692,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 +721,51 @@ 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 = $this->transcriptionElapsedSeconds();
|
||||
|
||||
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,
|
||||
'transcription_duration_seconds' => $this->transcription_duration_seconds,
|
||||
'transcription_duration_human' => $this->transcription_duration_seconds === null
|
||||
? null
|
||||
: $this->formatElapsed($this->transcription_duration_seconds),
|
||||
'is_active' => $this->isTranscribing(),
|
||||
'has_transcript' => filled($this->transcript),
|
||||
'transcript' => $this->transcript,
|
||||
'whisper' => $this->transcription_verbose,
|
||||
'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
@@ -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('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Closure;
|
||||
use CURLFile;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Streams large audio uploads to Whisper with curl (disk → network),
|
||||
* and delivers response body chunks without buffering the request in PHP.
|
||||
*
|
||||
* Laravel Http's stream => true uses Guzzle's StreamHandler, which casts the
|
||||
* multipart body to a string and OOMs on big recordings.
|
||||
*/
|
||||
class LocalWhisperCurlClient
|
||||
{
|
||||
/**
|
||||
* POST /audio/transcriptions and stream the response body.
|
||||
*
|
||||
* @param Closure(string): bool $onBodyChunk Return false to abort the transfer.
|
||||
* @param Closure(string): void|null $onContentType Invoked when the response Content-Type header arrives.
|
||||
* @return array{status: int, content_type: string}
|
||||
*/
|
||||
public function streamTranscription(
|
||||
string $url,
|
||||
string $apiKey,
|
||||
string $path,
|
||||
string $filename,
|
||||
string $mimeType,
|
||||
string $model,
|
||||
int $timeout,
|
||||
Closure $onBodyChunk,
|
||||
?Closure $onContentType = null,
|
||||
): array {
|
||||
if (! function_exists('curl_init')) {
|
||||
throw new RuntimeException('The curl PHP extension is required for local Whisper transcription.');
|
||||
}
|
||||
|
||||
$contentType = '';
|
||||
$abort = false;
|
||||
|
||||
$handle = curl_init($url);
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to initialize curl for local Whisper.');
|
||||
}
|
||||
|
||||
try {
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer '.$apiKey,
|
||||
'Accept: application/json, text/event-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => [
|
||||
'file' => new CURLFile($path, $mimeType, $filename),
|
||||
'model' => $model,
|
||||
'response_format' => 'verbose_json',
|
||||
'stream' => 'true',
|
||||
'without_timestamps' => 'false',
|
||||
'timestamp_granularities[]' => 'word',
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_HEADER => false,
|
||||
CURLOPT_TIMEOUT => max(1, $timeout),
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HEADERFUNCTION => static function ($ch, string $header) use (&$contentType, $onContentType): int {
|
||||
if (stripos($header, 'Content-Type:') === 0) {
|
||||
$contentType = trim(substr($header, strlen('Content-Type:')));
|
||||
|
||||
if ($onContentType !== null) {
|
||||
$onContentType($contentType);
|
||||
}
|
||||
}
|
||||
|
||||
return strlen($header);
|
||||
},
|
||||
CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use ($onBodyChunk, &$abort): int {
|
||||
if ($abort) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($onBodyChunk($chunk) === false) {
|
||||
$abort = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strlen($chunk);
|
||||
},
|
||||
]);
|
||||
|
||||
$ok = curl_exec($handle);
|
||||
$errno = curl_errno($handle);
|
||||
$error = curl_error($handle);
|
||||
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
|
||||
if ($abort) {
|
||||
return [
|
||||
'status' => $status > 0 ? $status : 499,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
if ($ok === false && $errno !== 0) {
|
||||
throw new RuntimeException(
|
||||
'Local transcription request failed: '.$error.' (curl '.$errno.')'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
} finally {
|
||||
curl_close($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -3,100 +3,418 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Closure;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Transcription;
|
||||
use RuntimeException;
|
||||
|
||||
class TranscriptionService
|
||||
{
|
||||
public function __construct(
|
||||
private WhisperTranscriptionStream $stream,
|
||||
private LocalWhisperCurlClient $curl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Run transcription for a recording using the selected driver.
|
||||
* Transcribe a recording with the local faster-whisper server.
|
||||
*
|
||||
* @param (Closure(string, int, ?string, ?array): void)|null $onProgress
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
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, ?Closure $shouldContinue = null): string
|
||||
{
|
||||
$report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = null, ?array $whisper = null) => null;
|
||||
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||
|
||||
$report("Transcribing locally with {$model} (audio stays on this machine)…", 0);
|
||||
|
||||
if (Transcription::isFaked()) {
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('local-whisper', $model);
|
||||
} else {
|
||||
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
|
||||
}
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
|
||||
* Call local Whisper, streaming SSE when the server supports it.
|
||||
*
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
private function viaRemoteCompatible(Recording $recording): string
|
||||
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): 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', [
|
||||
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
|
||||
$filename = $recording->original_filename ?: basename($path);
|
||||
|
||||
if ($this->usesHttpTransport()) {
|
||||
return $this->transcribeViaHttp($recording, $report, $message, $model, $filename, $path, $shouldContinue);
|
||||
}
|
||||
|
||||
return $this->transcribeViaCurl($recording, $report, $message, $model, $filename, $path, $shouldContinue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel Http client path (used in tests via Http::fake).
|
||||
*
|
||||
* Does not set Guzzle stream => true, which would string-cast the multipart upload.
|
||||
*
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
private function transcribeViaHttp(
|
||||
Recording $recording,
|
||||
Closure $report,
|
||||
string $message,
|
||||
string $model,
|
||||
string $filename,
|
||||
string $path,
|
||||
?Closure $shouldContinue,
|
||||
): string {
|
||||
$response = $this->localWhisperRequest($filename, $path)
|
||||
->post('audio/transcriptions', [
|
||||
'model' => $model,
|
||||
'response_format' => 'json',
|
||||
'response_format' => 'verbose_json',
|
||||
'stream' => 'true',
|
||||
'without_timestamps' => 'false',
|
||||
'timestamp_granularities[]' => 'word',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(
|
||||
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
|
||||
'Local transcription failed (HTTP '.$response->status().'): '.$this->truncateBody($response->body())
|
||||
);
|
||||
}
|
||||
|
||||
$text = $response->json('text');
|
||||
$contentType = strtolower((string) $response->header('Content-Type'));
|
||||
|
||||
if (! str_contains($contentType, 'text/event-stream')) {
|
||||
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
||||
'recording_id' => $recording->id,
|
||||
'content_type' => $contentType,
|
||||
'transport' => 'http',
|
||||
]);
|
||||
|
||||
return $this->extractTranscriptText($response->json(), $report, $message);
|
||||
}
|
||||
|
||||
return $this->consumeSseBody($response->body(), $report, $message, $recording, $shouldContinue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Curl path for production: streams the file upload and SSE response chunks.
|
||||
*
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
private function transcribeViaCurl(
|
||||
Recording $recording,
|
||||
Closure $report,
|
||||
string $message,
|
||||
string $model,
|
||||
string $filename,
|
||||
string $path,
|
||||
?Closure $shouldContinue,
|
||||
): string {
|
||||
$config = config('ai.providers.local-whisper');
|
||||
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
|
||||
$timeout = (int) config('ai.transcription_timeout', 600);
|
||||
$apiKey = (string) ($config['key'] ?? 'not-needed');
|
||||
|
||||
$buffer = '';
|
||||
$rawBody = '';
|
||||
$accumulated = '';
|
||||
$lastPercent = 0;
|
||||
$isSse = null;
|
||||
|
||||
$result = $this->curl->streamTranscription(
|
||||
$baseUrl.'/audio/transcriptions',
|
||||
$apiKey,
|
||||
$path,
|
||||
$filename,
|
||||
$recording->audioMimeType(),
|
||||
$model,
|
||||
$timeout,
|
||||
function (string $chunk) use (
|
||||
&$buffer,
|
||||
&$rawBody,
|
||||
&$accumulated,
|
||||
&$lastPercent,
|
||||
&$isSse,
|
||||
$report,
|
||||
$message,
|
||||
$recording,
|
||||
$shouldContinue,
|
||||
): bool {
|
||||
if ($shouldContinue !== null && ! $shouldContinue()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isSse === null) {
|
||||
$rawBody .= $chunk;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($isSse === false) {
|
||||
$rawBody .= $chunk;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($rawBody !== '') {
|
||||
$buffer .= $rawBody;
|
||||
$rawBody = '';
|
||||
|
||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$buffer .= $chunk;
|
||||
|
||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
function (string $contentType) use (&$isSse): void {
|
||||
$isSse = str_contains(strtolower($contentType), 'text/event-stream');
|
||||
},
|
||||
);
|
||||
|
||||
if ($result['status'] >= 400 || $result['status'] === 0) {
|
||||
throw new RuntimeException(
|
||||
'Local transcription failed (HTTP '.$result['status'].'): '.$this->truncateBody($rawBody)
|
||||
);
|
||||
}
|
||||
|
||||
$contentType = strtolower($result['content_type']);
|
||||
$isSse ??= str_contains($contentType, 'text/event-stream');
|
||||
|
||||
if (! $isSse) {
|
||||
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
||||
'recording_id' => $recording->id,
|
||||
'content_type' => $contentType,
|
||||
'transport' => 'curl',
|
||||
]);
|
||||
|
||||
$json = json_decode($rawBody, true);
|
||||
|
||||
return $this->extractTranscriptText(is_array($json) ? $json : null, $report, $message);
|
||||
}
|
||||
|
||||
if ($rawBody !== '') {
|
||||
$buffer .= $rawBody;
|
||||
$rawBody = '';
|
||||
}
|
||||
|
||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
|
||||
if ($accumulated === '') {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
return $accumulated;
|
||||
}
|
||||
|
||||
private function usesHttpTransport(): bool
|
||||
{
|
||||
return config('ai.local_whisper_transport', 'curl') === 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @param (Closure(): bool)|null $shouldContinue
|
||||
*/
|
||||
private function consumeSseBody(
|
||||
string $body,
|
||||
Closure $report,
|
||||
string $message,
|
||||
Recording $recording,
|
||||
?Closure $shouldContinue,
|
||||
): string {
|
||||
if ($shouldContinue !== null && ! $shouldContinue()) {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
$buffer = $body;
|
||||
$accumulated = '';
|
||||
$lastPercent = 0;
|
||||
|
||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||
$payload,
|
||||
$accumulated,
|
||||
$lastPercent,
|
||||
$recording,
|
||||
$report,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
|
||||
if ($accumulated === '') {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
return $accumulated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
* @return array{0: string, 1: int}
|
||||
*/
|
||||
private function ingestEvent(
|
||||
string $payload,
|
||||
string $accumulated,
|
||||
int $lastPercent,
|
||||
Recording $recording,
|
||||
Closure $report,
|
||||
string $message,
|
||||
): array {
|
||||
$event = $this->stream->parseEvent($payload);
|
||||
|
||||
if ($event === null) {
|
||||
return [$accumulated, $lastPercent];
|
||||
}
|
||||
|
||||
$accumulated = $this->stream->applyEvent($accumulated, $event);
|
||||
$whisper = $event['whisper'] ?? [];
|
||||
|
||||
if ($accumulated === '' && $whisper === []) {
|
||||
return [$accumulated, $lastPercent];
|
||||
}
|
||||
|
||||
$lastPercent = $this->percentForEvent($event, $recording, $lastPercent);
|
||||
|
||||
$report(
|
||||
$message,
|
||||
$lastPercent,
|
||||
$accumulated === '' ? null : $accumulated,
|
||||
$whisper === [] ? null : $whisper,
|
||||
);
|
||||
|
||||
return [$accumulated, $lastPercent];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
||||
*/
|
||||
private function percentForEvent(array $event, Recording $recording, int $lastPercent): int
|
||||
{
|
||||
$duration = $recording->duration_seconds;
|
||||
$end = $event['end'] ?? null;
|
||||
|
||||
if ($end === null || $duration === null || $duration <= 0) {
|
||||
return $lastPercent;
|
||||
}
|
||||
|
||||
return (int) min(99, max($lastPercent, round(100 * $end / $duration)));
|
||||
}
|
||||
|
||||
private function localWhisperRequest(string $filename, string $path): PendingRequest
|
||||
{
|
||||
$config = config('ai.providers.local-whisper');
|
||||
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
|
||||
$timeout = (int) config('ai.transcription_timeout', 600);
|
||||
|
||||
return Http::baseUrl($baseUrl)
|
||||
->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')])
|
||||
->timeout($timeout)
|
||||
->connectTimeout(10)
|
||||
->attach('file', fopen($path, 'r'), $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $json
|
||||
* @param Closure(string, int, ?string, ?array): void $report
|
||||
*/
|
||||
private function extractTranscriptText(?array $json, ?Closure $report = null, string $message = ''): string
|
||||
{
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
$text = $json['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
|
||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||
}
|
||||
|
||||
$whisper = $this->stream->extractWhisperMeta($json);
|
||||
|
||||
if ($report !== null && $whisper !== []) {
|
||||
$report($message, 99, $text, $whisper);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user URL to an OpenAI-style base ending in /v1.
|
||||
*/
|
||||
private function normalizeBaseUrl(string $url): string
|
||||
private function truncateBody(string $body): string
|
||||
{
|
||||
$url = rtrim(trim($url), '/');
|
||||
$body = trim($body);
|
||||
|
||||
if (Str::endsWith($url, '/v1')) {
|
||||
return $url;
|
||||
if (strlen($body) <= 2000) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return $url.'/v1';
|
||||
return substr($body, 0, 2000).'…';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class WhisperTranscriptionStream
|
||||
{
|
||||
/**
|
||||
* Pull complete SSE `data:` payloads out of a rolling buffer.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function extractPayloads(string &$buffer): array
|
||||
{
|
||||
$frames = explode("\n\n", str_replace("\r\n", "\n", $buffer));
|
||||
$buffer = array_pop($frames) ?? '';
|
||||
$payloads = [];
|
||||
|
||||
foreach ($frames as $frame) {
|
||||
foreach ($this->dataLines($frame) as $payload) {
|
||||
if ($payload === '[DONE]' || $payload === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payloads[] = $payload;
|
||||
}
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a trailing incomplete frame at end-of-stream.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function flushBuffer(string &$buffer): array
|
||||
{
|
||||
$trimmed = trim($buffer);
|
||||
|
||||
if ($trimmed === '') {
|
||||
$buffer = '';
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$buffer .= "\n\n";
|
||||
|
||||
return $this->extractPayloads($buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one SSE JSON payload into append/replace/end/done/whisper fields.
|
||||
*
|
||||
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper: array<string, mixed>}|null
|
||||
*/
|
||||
public function parseEvent(string $json): ?array
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $data['type'] ?? null;
|
||||
$whisper = $this->extractWhisperMeta($data);
|
||||
$end = $this->latestAudioEnd($data, $whisper);
|
||||
|
||||
if ($type === 'transcript.text.delta') {
|
||||
$delta = $data['delta'] ?? '';
|
||||
|
||||
if ((! is_string($delta) || $delta === '') && $whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'append' => is_string($delta) && $delta !== '' ? $delta : null,
|
||||
'replace' => null,
|
||||
'end' => $end,
|
||||
'done' => false,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'transcript.text.done') {
|
||||
$text = $data['text'] ?? '';
|
||||
|
||||
return [
|
||||
'append' => null,
|
||||
'replace' => is_string($text) ? $text : '',
|
||||
'end' => $end,
|
||||
'done' => true,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->isLiveSegmentEvent($type, $data)) {
|
||||
return [
|
||||
'append' => $data['text'],
|
||||
'replace' => null,
|
||||
'end' => $end,
|
||||
'done' => false,
|
||||
'legacy' => true,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($data['segments']) && is_array($data['segments'])) {
|
||||
$text = $data['text'] ?? '';
|
||||
|
||||
if ((! is_string($text) || $text === '') && $whisper === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'append' => null,
|
||||
'replace' => is_string($text) && $text !== '' ? $text : null,
|
||||
'end' => $end,
|
||||
'done' => true,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
if ($whisper !== []) {
|
||||
return [
|
||||
'append' => null,
|
||||
'replace' => null,
|
||||
'end' => $end,
|
||||
'done' => false,
|
||||
'legacy' => false,
|
||||
'whisper' => $whisper,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a parsed event to the accumulated transcript.
|
||||
*
|
||||
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
||||
*/
|
||||
public function applyEvent(string $accumulated, array $event): string
|
||||
{
|
||||
if (is_string($event['replace'])) {
|
||||
return $event['replace'];
|
||||
}
|
||||
|
||||
$chunk = $event['append'] ?? '';
|
||||
|
||||
if ($chunk === '') {
|
||||
return $accumulated;
|
||||
}
|
||||
|
||||
if ($event['legacy']) {
|
||||
$chunk = trim($chunk);
|
||||
|
||||
if ($accumulated === '') {
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
return rtrim($accumulated).' '.$chunk;
|
||||
}
|
||||
|
||||
return $accumulated.$chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function extractWhisperMeta(array $data): array
|
||||
{
|
||||
$meta = [];
|
||||
|
||||
if (isset($data['language']) && is_string($data['language']) && $data['language'] !== '') {
|
||||
$meta['language'] = $data['language'];
|
||||
}
|
||||
|
||||
if (is_numeric($data['duration'] ?? null)) {
|
||||
$meta['duration'] = (float) $data['duration'];
|
||||
}
|
||||
|
||||
if (isset($data['logprobs']) && is_array($data['logprobs'])) {
|
||||
$logprobs = $this->normalizeLogprobs($data['logprobs']);
|
||||
|
||||
if ($logprobs !== []) {
|
||||
$meta['logprobs'] = $logprobs;
|
||||
}
|
||||
}
|
||||
|
||||
$segment = $this->normalizeSegment($data);
|
||||
|
||||
if ($segment !== null && ($data['type'] ?? null) !== 'transcript.text.delta') {
|
||||
$meta['segment'] = $segment;
|
||||
}
|
||||
|
||||
if (isset($data['segments']) && is_array($data['segments'])) {
|
||||
$segments = [];
|
||||
|
||||
foreach ($data['segments'] as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeSegment($row);
|
||||
|
||||
if ($normalized !== null) {
|
||||
$segments[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ($segments !== [] && ! isset($meta['segment'])) {
|
||||
$meta['segments'] = $segments;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['words']) && is_array($data['words']) && ! isset($meta['segment']) && ! isset($meta['segments'])) {
|
||||
$words = $this->normalizeWords($data['words']);
|
||||
|
||||
if ($words !== []) {
|
||||
$meta['words'] = $words;
|
||||
}
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function normalizeSegment(array $data): ?array
|
||||
{
|
||||
$hasDetail = isset($data['start'])
|
||||
|| isset($data['end'])
|
||||
|| isset($data['words'])
|
||||
|| isset($data['avg_logprob'])
|
||||
|| isset($data['tokens'])
|
||||
|| isset($data['no_speech_prob'])
|
||||
|| array_key_exists('id', $data);
|
||||
|
||||
if (! $hasDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $data['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$segment = [
|
||||
'id' => is_numeric($data['id'] ?? null) ? (int) $data['id'] : null,
|
||||
'seek' => is_numeric($data['seek'] ?? null) ? (int) $data['seek'] : null,
|
||||
'start' => $this->nullableFloat($data['start'] ?? null),
|
||||
'end' => $this->nullableFloat($data['end'] ?? null),
|
||||
'text' => $text,
|
||||
'tokens' => $this->normalizeTokens($data['tokens'] ?? null),
|
||||
'temperature' => $this->nullableFloat($data['temperature'] ?? null),
|
||||
'avg_logprob' => $this->nullableFloat($data['avg_logprob'] ?? null),
|
||||
'compression_ratio' => $this->nullableFloat($data['compression_ratio'] ?? null),
|
||||
'no_speech_prob' => $this->nullableFloat($data['no_speech_prob'] ?? null),
|
||||
'words' => $this->normalizeWords($data['words'] ?? null),
|
||||
];
|
||||
|
||||
return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* One streamed Whisper chunk (not a finished verbose_json document).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function isLiveSegmentEvent(mixed $type, array $data): bool
|
||||
{
|
||||
$text = $data['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($type === 'segment') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($type !== null && $type !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($data['start']) || isset($data['end'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! (isset($data['segments']) && is_array($data['segments']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function dataLines(string $frame): array
|
||||
{
|
||||
$payloads = [];
|
||||
|
||||
foreach (explode("\n", $frame) as $line) {
|
||||
if (str_starts_with($line, 'data:')) {
|
||||
$payloads[] = ltrim(substr($line, 5));
|
||||
}
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest media timestamp in this event (segment/word `end`). Never file `duration`.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $whisper
|
||||
*/
|
||||
public function latestAudioEnd(array $data, array $whisper): ?float
|
||||
{
|
||||
$ends = [];
|
||||
|
||||
$direct = $this->nullableFloat($data['end'] ?? null);
|
||||
|
||||
if ($direct !== null) {
|
||||
$ends[] = $direct;
|
||||
}
|
||||
|
||||
$this->collectAudioEnds($ends, $whisper);
|
||||
|
||||
if (isset($data['words']) && is_array($data['words'])) {
|
||||
$this->collectAudioEnds($ends, ['words' => $data['words']]);
|
||||
}
|
||||
|
||||
return $ends === [] ? null : max($ends);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $ends
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function collectAudioEnds(array &$ends, array $node): void
|
||||
{
|
||||
$end = $this->nullableFloat($node['end'] ?? null);
|
||||
|
||||
if ($end !== null) {
|
||||
$ends[] = $end;
|
||||
}
|
||||
|
||||
foreach (['words', 'segments'] as $key) {
|
||||
if (! isset($node[$key]) || ! is_array($node[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($node[$key] as $child) {
|
||||
if (is_array($child)) {
|
||||
$this->collectAudioEnds($ends, $child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($node['segment']) && is_array($node['segment'])) {
|
||||
$this->collectAudioEnds($ends, $node['segment']);
|
||||
}
|
||||
}
|
||||
|
||||
private function nullableFloat(mixed $value): ?float
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null
|
||||
*/
|
||||
private function normalizeTokens(mixed $tokens): ?array
|
||||
{
|
||||
if (! is_array($tokens) || $tokens === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (is_numeric($token)) {
|
||||
$normalized[] = (int) $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized === [] ? null : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{word: string, start: ?float, end: ?float, probability: ?float}>|null
|
||||
*/
|
||||
private function normalizeWords(mixed $words): ?array
|
||||
{
|
||||
if (! is_array($words) || $words === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (! is_array($word)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $word['word'] ?? $word['text'] ?? null;
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = array_filter([
|
||||
'word' => $text,
|
||||
'start' => $this->nullableFloat($word['start'] ?? null),
|
||||
'end' => $this->nullableFloat($word['end'] ?? null),
|
||||
'probability' => $this->nullableFloat($word['probability'] ?? $word['prob'] ?? null),
|
||||
], fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
return $normalized === [] ? null : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{token: ?string, logprob: ?float}>
|
||||
*/
|
||||
private function normalizeLogprobs(array $logprobs): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($logprobs as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token = $row['token'] ?? $row['bytes'] ?? null;
|
||||
$token = is_string($token) ? $token : null;
|
||||
$logprob = $this->nullableFloat($row['logprob'] ?? $row['avg_logprob'] ?? null);
|
||||
|
||||
if ($token === null && $logprob === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = array_filter([
|
||||
'token' => $token,
|
||||
'logprob' => $logprob,
|
||||
], fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.disk-space-bar');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"cursor"
|
||||
],
|
||||
"cloud": false,
|
||||
"guidelines": true,
|
||||
"mcp": true,
|
||||
@@ -7,7 +11,11 @@
|
||||
"skills": [
|
||||
"infer-conventions",
|
||||
"ai-sdk-development",
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"fluxui-development",
|
||||
"livewire-development",
|
||||
"echo-development",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
|
||||
+13
-1
@@ -9,10 +9,22 @@ 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 {
|
||||
//
|
||||
// CPM/Caddy terminates TLS; trust forwarded proto/host so asset() URLs stay https.
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
headers: Request::HEADER_X_FORWARDED_FOR
|
||||
| Request::HEADER_X_FORWARDED_HOST
|
||||
| Request::HEADER_X_FORWARDED_PORT
|
||||
| Request::HEADER_X_FORWARDED_PROTO
|
||||
| Request::HEADER_X_FORWARDED_PREFIX,
|
||||
);
|
||||
|
||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||
$middleware->redirectUsersTo(fn () => route('recordings.index'));
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# z00 overlay for AndyTranscribe (behind Caddy Proxy Manager).
|
||||
# Instance .env supplies PUBLIC_APP_URL, REVERB_PUBLIC_HOST, CADDY_NETWORK, CONTAINER_PREFIX.
|
||||
services:
|
||||
app:
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
|
||||
TRUSTED_PROXIES: "*"
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: "8080"
|
||||
REVERB_SCHEME: http
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PUBLIC_PORT: "443"
|
||||
REVERB_PUBLIC_SCHEME: https
|
||||
reverb:
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
environment:
|
||||
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
|
||||
REVERB_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PORT: "443"
|
||||
REVERB_SCHEME: https
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PUBLIC_PORT: "443"
|
||||
REVERB_PUBLIC_SCHEME: https
|
||||
queue:
|
||||
networks:
|
||||
- default
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
|
||||
REVERB_HOST: reverb
|
||||
REVERB_PORT: "8080"
|
||||
REVERB_SCHEME: http
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
|
||||
REVERB_PUBLIC_PORT: "443"
|
||||
REVERB_PUBLIC_SCHEME: https
|
||||
whisper:
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
name: ${CADDY_NETWORK:-caddy-proxy-manager-test_caddy-test-network}
|
||||
+7
-1
@@ -9,10 +9,16 @@
|
||||
"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": {
|
||||
"albertoarena/laravel-truss": "^1.8",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^2.5",
|
||||
"laravel/pail": "^1.2.5",
|
||||
|
||||
Generated
+2657
-1
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -28,7 +28,12 @@ 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')),
|
||||
|
||||
/*
|
||||
| "curl" streams large uploads off disk (default). "http" uses Laravel's
|
||||
| HTTP client and is intended for Http::fake() in tests.
|
||||
*/
|
||||
'local_whisper_transport' => env('LOCAL_WHISPER_TRANSPORT', 'curl'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -149,7 +154,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,
|
||||
],
|
||||
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
+4
-3
@@ -38,9 +38,10 @@ 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,
|
||||
// Wait longer under concurrent writers (queue + web on one SQLite file).
|
||||
'busy_timeout' => (int) env('DB_BUSY_TIMEOUT', 30000),
|
||||
'journal_mode' => 'WAL',
|
||||
'synchronous' => 'NORMAL',
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?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'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Browser (Echo) connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Public WebSocket host/key rendered into HTML so one image can serve
|
||||
| production and staging. Server-side publish still uses REVERB_HOST
|
||||
| (Docker DNS "reverb" behind the z00 overlay).
|
||||
|
|
||||
*/
|
||||
|
||||
'client' => [
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'host' => env('REVERB_PUBLIC_HOST', env('REVERB_HOST')),
|
||||
'port' => env('REVERB_PUBLIC_PORT', env('REVERB_PORT', 443)),
|
||||
'scheme' => env('REVERB_PUBLIC_SCHEME', env('REVERB_SCHEME', 'https')),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Configuration for albertoarena/laravel-truss.
|
||||
// This is the single source of truth for Truss's behaviour. Authorization is a
|
||||
// fixed `viewTruss` gate the host app defines — the ability name is not set here.
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Route prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| URL prefix under which the index page and the JSON schema endpoint are
|
||||
| registered, e.g. "truss" → GET /truss and GET /truss/api/schema.
|
||||
|
|
||||
*/
|
||||
|
||||
'route_prefix' => env('TRUSS_ROUTE_PREFIX', 'truss'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enabled
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Global on/off switch. Defaults to enabled only in the local environment.
|
||||
| Authorization is enforced separately by the fixed `viewTruss` gate.
|
||||
|
|
||||
*/
|
||||
|
||||
'enabled' => env('TRUSS_ENABLED', env('APP_ENV', 'production') === 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The middleware stack applied to both Truss routes. Its job is to establish
|
||||
| the auth context (session, cookies, the authenticated user) so the
|
||||
| `viewTruss` gate can identify who is viewing — without it, the gate sees no
|
||||
| user and denies everyone in non-local environments. The default `web` group
|
||||
| covers session-based auth; swap it for a custom guard/Sanctum stack if your
|
||||
| app authenticates differently.
|
||||
|
|
||||
| The fixed `viewTruss` authorization check is always appended after this and
|
||||
| cannot be configured away — this list controls the auth *context*, not
|
||||
| whether authorization runs.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authorization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Truss is gated by the fixed `viewTruss` gate (the ability name is not
|
||||
| configurable). In non-local environments the shipped default gate admits
|
||||
| only the emails listed here — the zero-code path for "let these admins in".
|
||||
| Set them via TRUSS_ALLOWED_EMAILS as a comma-separated list, e.g.
|
||||
| TRUSS_ALLOWED_EMAILS="ada@example.com,grace@example.com".
|
||||
|
|
||||
| The list is ignored in local (the gate is not consulted there) and ignored
|
||||
| entirely if the host app defines its own `viewTruss` gate (e.g. a role
|
||||
| check). An empty list fails closed: no one may view in non-local until you
|
||||
| either add emails here or override the gate.
|
||||
|
|
||||
*/
|
||||
|
||||
'authorization' => [
|
||||
'allowed_emails' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('TRUSS_ALLOWED_EMAILS', '')),
|
||||
))),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The schema snapshot is derived, disposable data cached via Laravel's Cache
|
||||
| facade, keyed per connection. `ttl` is in seconds.
|
||||
|
|
||||
*/
|
||||
|
||||
'cache' => [
|
||||
'ttl' => (int) env('TRUSS_CACHE_TTL', 3600),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Which database connections are visualizable, and any per-connection
|
||||
| overrides. When left empty, Truss uses the application's default
|
||||
| connection (config('database.default')).
|
||||
|
|
||||
| Example:
|
||||
| 'mysql' => ['excluded_tables' => ['legacy_import']],
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
//
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Excluded tables
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Tables hidden from the diagram by default (framework/infrastructure noise).
|
||||
| Applied server-side: excluded tables never appear in the API response.
|
||||
|
|
||||
*/
|
||||
|
||||
'excluded_tables' => [
|
||||
'migrations',
|
||||
'password_reset_tokens',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Diagram
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Styling options passed through to the Mermaid theme, plus the default
|
||||
| column-type label mode:
|
||||
| 'native' → the full DB type (varchar(255), bigint unsigned) [default]
|
||||
| 'laravel' → a best-effort Laravel-style short label (string, integer)
|
||||
| The mode is user-toggleable in the UI; this is only the default.
|
||||
|
|
||||
*/
|
||||
|
||||
'diagram' => [
|
||||
'type_labels' => env('TRUSS_TYPE_LABELS', 'native'),
|
||||
|
||||
// Where the browser loads Mermaid from. Null (the default) self-hosts it
|
||||
// from the package's own asset route — no CDN, so a strict CSP needs only
|
||||
// `script-src 'self'`. Set a URL (e.g. a CDN or your own copy) to opt out
|
||||
// of self-hosting: TRUSS_MERMAID_URL=https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js
|
||||
'mermaid_url' => env('TRUSS_MERMAID_URL'),
|
||||
|
||||
// Lower bound for the automatic fit-to-screen: a large schema is never
|
||||
// auto-zoomed below this (it stays legible and you pan). The "Fit" button
|
||||
// ignores this and frames the whole diagram. 1.0 = 100%.
|
||||
'min_zoom' => (float) env('TRUSS_MIN_ZOOM', 0.7),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Theme
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Truss ships a light and dark "blueprint" theme. To match the app Truss is
|
||||
| embedded in, redefine its colours and fonts here. Everything is optional:
|
||||
| only the knobs you set are overridden, the rest stay on the default, so a
|
||||
| handful of values re-skins the whole dashboard (chrome and diagram) in both
|
||||
| light and dark. Config driven, no build step.
|
||||
|
|
||||
| It is delivered as a same-origin stylesheet, so a strict CSP still needs
|
||||
| only style-src 'self' (no inline styles). Each value is validated before it
|
||||
| is emitted; an invalid value is ignored and falls back to the default.
|
||||
|
|
||||
| Colours accept hex, rgb()/rgba()/hsl()/hsla(), or a CSS colour keyword.
|
||||
| Fonts are family names only: name a font your app already loads or a system
|
||||
| font (Truss serves no font files here). Set a knob under both 'light' and
|
||||
| 'dark' to theme both modes; omit 'dark' to theme light only.
|
||||
|
|
||||
| Colour knobs and what each paints:
|
||||
| accent primary accent: headings, PK badges, entity borders, focus ring
|
||||
| accent-secondary secondary accent
|
||||
| background the canvas / page background (and relationship-label backdrop)
|
||||
| surface panels, table bodies, rows, and inputs
|
||||
| surface-alt row striping
|
||||
| text body and diagram text
|
||||
| muted secondary text and the relationship lines / labels
|
||||
| border table, panel, and field lines
|
||||
|
|
||||
*/
|
||||
|
||||
'theme' => [
|
||||
'fonts' => [
|
||||
'mono' => env('TRUSS_THEME_FONT_MONO'),
|
||||
'sans' => env('TRUSS_THEME_FONT_SANS'),
|
||||
],
|
||||
|
||||
'colors' => [
|
||||
'light' => [
|
||||
// 'accent' => '#3730a3',
|
||||
// 'background' => '#ffffff',
|
||||
],
|
||||
'dark' => [
|
||||
// 'accent' => '#a5b4fc',
|
||||
// 'background' => '#0b1020',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Focus
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Focus mode reduces the diagram to a table and its foreign-key neighbours.
|
||||
| `default_depth` is how many hops of neighbours are shown by default.
|
||||
|
|
||||
*/
|
||||
|
||||
'focus' => [
|
||||
'default_depth' => (int) env('TRUSS_FOCUS_DEPTH', 1),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Large schema
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Table count above which the UI shows a "large schema — use focus/filter"
|
||||
| warning before rendering everything at once.
|
||||
|
|
||||
*/
|
||||
|
||||
'large_schema' => [
|
||||
'warn_above' => (int) env('TRUSS_LARGE_SCHEMA_WARN_ABOVE', 60),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Schema diff
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| "What changed since the last migration". After each migration Truss keeps
|
||||
| the previous schema snapshot as a baseline and compares it against the
|
||||
| current one, surfacing added, removed, and changed tables, columns, indexes,
|
||||
| and foreign keys in the dashboard "Changes" panel and via `truss:diff`.
|
||||
|
|
||||
| This is the only feature that writes to the filesystem: the baseline is a
|
||||
| structure-only JSON file (never row data), stored on disk rather than in the
|
||||
| cache because it cannot be rebuilt from the live database once a migration
|
||||
| has run.
|
||||
|
|
||||
| `enabled`: master switch. When false, no baseline is captured, nothing is
|
||||
| written to disk, the "Changes" toggle is hidden, and `truss:diff` reports the
|
||||
| feature is off. Set it false if you do not want Truss touching your disk.
|
||||
|
|
||||
| `disk`: the filesystem disk the baseline is written to, `local` by default.
|
||||
| The path is always `truss/baselines/{connection}`. This deliberately does not
|
||||
| follow the application's default disk: the baseline is derived tooling state,
|
||||
| not application data, so it should not land in a production bucket, cost
|
||||
| money, or have several instances racing on one object. A failure to read or
|
||||
| write it is never fatal; the diff is simply unavailable until it recovers.
|
||||
|
|
||||
*/
|
||||
|
||||
'diff' => [
|
||||
'enabled' => (bool) env('TRUSS_DIFF_ENABLED', true),
|
||||
'disk' => env('TRUSS_DIFF_DISK', 'local'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Doctor
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `truss:doctor` reviews the schema for problems visible from structure
|
||||
| alone (a table with no primary key, an unindexed foreign key, and so on)
|
||||
| and can fail CI. Structure only: it never reads row data and makes no
|
||||
| network call.
|
||||
|
|
||||
| preset: recommended (high-confidence rules), strict (every rule), none.
|
||||
| rules: per-rule overrides keyed by code: false disables, true enables
|
||||
| (even a heuristic one), ['severity' => 'error'] changes severity.
|
||||
| ignore: per-rule fnmatch patterns (table or table.column) to silence.
|
||||
| fail_on: the severity at or above which the command exits non-zero.
|
||||
| exclude: extra tables to skip, on top of truss.excluded_tables.
|
||||
| dashboard: show the findings in the dashboard "Health" panel. When false,
|
||||
| the schema endpoint sends no doctor payload and the panel and
|
||||
| node badges never appear, leaving the CLI/CI doctor untouched.
|
||||
| flag_tables: always mark tables that have findings on the diagram with a
|
||||
| small severity count, even when the Health panel is closed. Set
|
||||
| false to keep the diagram clean and surface findings only when
|
||||
| the panel is open.
|
||||
|
|
||||
*/
|
||||
|
||||
'doctor' => [
|
||||
'preset' => env('TRUSS_DOCTOR_PRESET', 'recommended'),
|
||||
|
||||
'rules' => [
|
||||
// 'TRUSS-INT-002' => true,
|
||||
// 'TRUSS-IDX-001' => ['severity' => 'error'],
|
||||
],
|
||||
|
||||
'ignore' => [
|
||||
// 'TRUSS-IDX-001' => ['audit_log.actor_id'],
|
||||
],
|
||||
|
||||
'fail_on' => env('TRUSS_DOCTOR_FAIL_ON', 'error'),
|
||||
|
||||
'exclude' => [],
|
||||
|
||||
'dashboard' => (bool) env('TRUSS_DOCTOR_DASHBOARD', true),
|
||||
|
||||
'flag_tables' => (bool) env('TRUSS_DOCTOR_FLAG_TABLES', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Annotations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Business meaning that cannot be introspected: that status = 1 means paid,
|
||||
| that total_amount is integer cents, that legacy_orders is deprecated. It is
|
||||
| declared here (and/or read from native schema comments) and rendered into
|
||||
| the exports so a coding agent has grounding a type alone cannot give.
|
||||
|
|
||||
| This is still structure only: native comments are part of the CREATE TABLE
|
||||
| definition, not row content, the same boundary as column defaults.
|
||||
|
|
||||
| source: ordered precedence for resolving an annotation; first match
|
||||
| wins. 'config' reads the maps below; 'database' reads native
|
||||
| table/column comments (MySQL and Postgres; SQLite and SQL Server
|
||||
| have none and are skipped). Drop 'database' to ignore DB comments.
|
||||
| notes: global notes rendered in a header block where the format has one.
|
||||
| tables: per-table annotations, keyed by table name.
|
||||
| columns: per-column annotations, keyed by "table.column".
|
||||
|
|
||||
| --no-annotations (and the facade withoutAnnotations()) strip them all.
|
||||
|
|
||||
*/
|
||||
|
||||
'annotations' => [
|
||||
'source' => ['config', 'database'],
|
||||
|
||||
'notes' => [
|
||||
// 'All timestamps are UTC.',
|
||||
// 'Monetary columns are integer cents unless stated.',
|
||||
],
|
||||
|
||||
'tables' => [
|
||||
// 'orders' => 'One row per order, not per line item.',
|
||||
],
|
||||
|
||||
'columns' => [
|
||||
// 'orders.status' => '0 draft, 1 paid, 2 refunded',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Export
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `default_format`: the format truss:export and the facade use when none is
|
||||
| given. One of dbml, json, csv, markdown, mermaid, or llm.
|
||||
|
|
||||
*/
|
||||
|
||||
'export' => [
|
||||
'default_format' => env('TRUSS_EXPORT_FORMAT', 'dbml'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MCP server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The optional read-only, structure-only MCP server exposes the live schema
|
||||
| to a coding agent (structure only, never row data). It requires the
|
||||
| optional first-party `laravel/mcp` package: install it with
|
||||
| `composer require laravel/mcp`. When that package is absent this switch has
|
||||
| no effect (the server is only registered when the package is present), so a
|
||||
| host that does not opt in is unaffected.
|
||||
|
|
||||
| `enabled`: master switch for registering the server once laravel/mcp is
|
||||
| installed. Defaults on, so installing the package is the only opt-in step.
|
||||
|
|
||||
*/
|
||||
|
||||
'mcp' => [
|
||||
'enabled' => (bool) env('TRUSS_MCP_ENABLED', true),
|
||||
],
|
||||
|
||||
];
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+36
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->unsignedInteger('transcription_duration_seconds')
|
||||
->nullable()
|
||||
->after('transcribed_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropColumn('transcription_duration_seconds');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->json('transcription_verbose')
|
||||
->nullable()
|
||||
->after('transcript');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('recordings', function (Blueprint $table) {
|
||||
$table->dropColumn('transcription_verbose');
|
||||
});
|
||||
}
|
||||
};
|
||||
Regular → Executable
+25
-5
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -0,0 +1,177 @@
|
||||
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
|
||||
# File drivers avoid SQLite lock storms: Livewire polls + database queue +
|
||||
# session/cache all writing the same sqlite file caused "database is locked".
|
||||
SESSION_DRIVER: file
|
||||
QUEUE_CONNECTION: database
|
||||
CACHE_STORE: file
|
||||
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
|
||||
# Browser-facing Reverb (Blade meta). Overlay sets public host for z00.
|
||||
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-localhost}
|
||||
REVERB_PUBLIC_PORT: ${REVERB_PUBLIC_PORT:-8081}
|
||||
REVERB_PUBLIC_SCHEME: ${REVERB_PUBLIC_SCHEME:-http}
|
||||
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}
|
||||
|
||||
# Local: omit APP_IMAGE (builds andytranscribe-app:latest).
|
||||
# CI/prod: set APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> and pull.
|
||||
x-app-image: &app-image
|
||||
image: ${APP_IMAGE:-andytranscribe-app:latest}
|
||||
|
||||
services:
|
||||
app:
|
||||
<<: *app-image
|
||||
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}
|
||||
container_name: ${CONTAINER_PREFIX:-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 the app image — do not declare build: here (avoids rebuilding 3×).
|
||||
# `docker compose up --build` builds `app` first, then starts these with the tagged image.
|
||||
queue:
|
||||
<<: *app-image
|
||||
container_name: ${CONTAINER_PREFIX:-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:
|
||||
<<: *app-image
|
||||
container_name: ${CONTAINER_PREFIX:-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: ${CONTAINER_PREFIX:-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: ${CONTAINER_PREFIX:-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:
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/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
|
||||
|
||||
# Only the web app should migrate/seed. Queue and Reverb share the DB and must
|
||||
# not race on sqlite (locks) or re-seed on every restart/deploy.
|
||||
should_bootstrap_db() {
|
||||
case " $* " in
|
||||
*" queue:work "*|*" queue:listen "*|*" reverb:start "*)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if should_bootstrap_db "$@"; then
|
||||
php artisan migrate --force --no-interaction
|
||||
php artisan db:seed --force --no-interaction
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -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
|
||||
Generated
+69
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -32,5 +32,10 @@
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="REVERB_APP_KEY" value="testing-reverb-key" force="true"/>
|
||||
<env name="REVERB_PUBLIC_HOST" value="reverb.testing.example" force="true"/>
|
||||
<env name="REVERB_PUBLIC_PORT" value="443" force="true"/>
|
||||
<env name="REVERB_PUBLIC_SCHEME" value="https" force="true"/>
|
||||
<env name="LOCAL_WHISPER_TRANSPORT" value="http"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -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
@@ -1 +1,5 @@
|
||||
//
|
||||
import './echo';
|
||||
import { recordingsIndex, transcriptionMonitor } from './transcription';
|
||||
|
||||
window.transcriptionMonitor = transcriptionMonitor;
|
||||
window.recordingsIndex = recordingsIndex;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Echo from 'laravel-echo';
|
||||
|
||||
import Pusher from 'pusher-js';
|
||||
window.Pusher = Pusher;
|
||||
|
||||
function metaContent(name) {
|
||||
return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content')?.trim() || '';
|
||||
}
|
||||
|
||||
const isLocalHost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||||
const internalHosts = new Set(['', 'reverb', 'localhost', '127.0.0.1']);
|
||||
|
||||
const key = metaContent('reverb-key') || import.meta.env.VITE_REVERB_APP_KEY;
|
||||
|
||||
let host = metaContent('reverb-host') || import.meta.env.VITE_REVERB_HOST || '';
|
||||
let port = metaContent('reverb-port') || import.meta.env.VITE_REVERB_PORT || '';
|
||||
let scheme = metaContent('reverb-scheme') || import.meta.env.VITE_REVERB_SCHEME || 'https';
|
||||
|
||||
if (!isLocalHost && internalHosts.has(host)) {
|
||||
host = `reverb.${window.location.hostname}`;
|
||||
port = '443';
|
||||
scheme = 'https';
|
||||
} else if (isLocalHost && host === 'reverb') {
|
||||
host = import.meta.env.VITE_REVERB_HOST || 'localhost';
|
||||
port = import.meta.env.VITE_REVERB_PORT || '8081';
|
||||
scheme = import.meta.env.VITE_REVERB_SCHEME || 'http';
|
||||
}
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key,
|
||||
wsHost: host,
|
||||
wsPort: port || 80,
|
||||
wssPort: port || 443,
|
||||
forceTLS: scheme === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: '/broadcasting/auth',
|
||||
auth: {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* 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 unwrapBroadcast(event) {
|
||||
if (! event || typeof event !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (
|
||||
event.transcript_delta === undefined
|
||||
&& event.transcriptDelta === undefined
|
||||
&& event.whisper_delta === undefined
|
||||
&& event.whisperDelta === undefined
|
||||
&& event.data
|
||||
&& typeof event.data === 'object'
|
||||
) {
|
||||
return event.data;
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function subscribeToRecording({ recordingId, userId }, handler) {
|
||||
if (! window.Echo) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const channels = [window.Echo.private('recording.' + recordingId)];
|
||||
|
||||
if (userId) {
|
||||
channels.push(window.Echo.private('user.' + userId + '.recordings'));
|
||||
}
|
||||
|
||||
channels.forEach((channel) => {
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
});
|
||||
|
||||
return () => {
|
||||
channels.forEach((channel) => {
|
||||
if (typeof channel.stopListening === 'function') {
|
||||
channel.stopListening('.RecordingTranscriptionUpdated');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function whisperSnapshot(snapshot) {
|
||||
return {
|
||||
language: snapshot?.language ?? null,
|
||||
duration: snapshot?.duration ?? null,
|
||||
segments: Array.isArray(snapshot?.segments) ? snapshot.segments.slice() : [],
|
||||
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs.slice() : [],
|
||||
};
|
||||
}
|
||||
|
||||
function segmentKey(segment) {
|
||||
return [segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|');
|
||||
}
|
||||
|
||||
export function formatClock(seconds) {
|
||||
if (seconds == null || Number.isNaN(Number(seconds))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = Math.max(0, Number(seconds));
|
||||
const minutes = Math.floor(value / 60);
|
||||
const rest = value - minutes * 60;
|
||||
|
||||
return minutes + ':' + rest.toFixed(1).padStart(4, '0');
|
||||
}
|
||||
|
||||
export function wordConfidenceClass(probability) {
|
||||
if (probability == null) {
|
||||
return 'text-zinc-700 dark:text-zinc-200';
|
||||
}
|
||||
|
||||
if (probability >= 0.85) {
|
||||
return 'text-teal-700 dark:text-teal-300';
|
||||
}
|
||||
|
||||
if (probability >= 0.6) {
|
||||
return 'text-amber-700 dark:text-amber-300';
|
||||
}
|
||||
|
||||
return 'text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
/**
|
||||
* Live updates come from Echo. While a run is active we also hydrate from HTTP
|
||||
* so segment cards keep appearing even if Livewire remorphs or an Echo frame is missed.
|
||||
*/
|
||||
export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
return {
|
||||
statusUrl,
|
||||
userId,
|
||||
status: {
|
||||
...initial,
|
||||
badge_color: badgeColorFor(initial.status),
|
||||
},
|
||||
pollError: null,
|
||||
tickTimer: null,
|
||||
hydrateTimer: null,
|
||||
leaveChannel: null,
|
||||
liveFromEcho: false,
|
||||
lastDeltaStamp: null,
|
||||
whisper: whisperSnapshot(initial?.whisper),
|
||||
|
||||
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';
|
||||
},
|
||||
|
||||
get whisperWords() {
|
||||
return this.whisper.segments.flatMap((segment, segmentIndex) => {
|
||||
if (Array.isArray(segment.words) && segment.words.length) {
|
||||
return segment.words.map((word, wordIndex) => ({
|
||||
word: word.word,
|
||||
start: word.start,
|
||||
probability: word.probability,
|
||||
key: [segmentIndex, wordIndex, word.start ?? '', word.word ?? ''].join(':'),
|
||||
}));
|
||||
}
|
||||
|
||||
const text = typeof segment.text === 'string' ? segment.text.trim() : '';
|
||||
|
||||
if (text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
word: text,
|
||||
start: segment.start,
|
||||
probability: null,
|
||||
key: segmentIndex + ':text',
|
||||
}];
|
||||
});
|
||||
},
|
||||
|
||||
start() {
|
||||
this.leaveChannel = subscribeToRecording({
|
||||
recordingId: this.status.id,
|
||||
userId: this.userId,
|
||||
}, (event) => {
|
||||
const payload = unwrapBroadcast(event);
|
||||
|
||||
if (Number(payload.id) !== Number(this.status.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyPayload(payload, { fromEcho: true });
|
||||
});
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.beginHydratePoll();
|
||||
this.hydrateOnce();
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (this.leaveChannel) {
|
||||
this.leaveChannel();
|
||||
this.leaveChannel = null;
|
||||
}
|
||||
},
|
||||
|
||||
applyPayload(payload, { fromEcho = false } = {}) {
|
||||
const wasActive = this.status.is_active;
|
||||
const transcript = this.mergeTranscript(payload, fromEcho);
|
||||
const nextStatus = payload.status ?? this.status.status;
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
...payload,
|
||||
transcript,
|
||||
has_transcript: Boolean(transcript),
|
||||
percent: this.mergePercent(payload, nextStatus),
|
||||
badge_color: badgeColorFor(nextStatus),
|
||||
};
|
||||
|
||||
if (this.shouldResetWhisper(payload, fromEcho)) {
|
||||
this.whisper = whisperSnapshot(null);
|
||||
}
|
||||
|
||||
this.whisper = this.mergeWhisper(payload, fromEcho);
|
||||
this.pollError = null;
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.beginHydratePoll();
|
||||
} else {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (wasActive) {
|
||||
this.hydrateOnce();
|
||||
this.refreshLivewire();
|
||||
}
|
||||
}
|
||||
|
||||
if (wasActive && ! this.status.is_active
|
||||
&& ! this.status.has_transcript
|
||||
&& this.status.status !== 'failed'
|
||||
&& this.status.status !== 'cancelled') {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
refreshLivewire() {
|
||||
if (typeof this.$wire?.$refresh === 'function') {
|
||||
this.$wire.$refresh();
|
||||
}
|
||||
},
|
||||
|
||||
mergeTranscript(payload, fromEcho = false) {
|
||||
const delta = payload.transcript_delta || payload.transcriptDelta;
|
||||
const replace = payload.transcript_replace ?? payload.transcriptReplace ?? false;
|
||||
|
||||
if (fromEcho && delta) {
|
||||
const stamp = String(payload.percent ?? '') + ':' + delta;
|
||||
|
||||
if (this.lastDeltaStamp === stamp) {
|
||||
return this.status.transcript;
|
||||
}
|
||||
|
||||
this.lastDeltaStamp = stamp;
|
||||
this.liveFromEcho = true;
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
return delta || '';
|
||||
}
|
||||
|
||||
if (delta) {
|
||||
this.liveFromEcho = this.liveFromEcho || fromEcho;
|
||||
|
||||
return (this.status.transcript || '') + delta;
|
||||
}
|
||||
|
||||
if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') {
|
||||
const incoming = typeof payload.transcript === 'string' ? payload.transcript : '';
|
||||
const current = this.status.transcript || '';
|
||||
|
||||
if (incoming.length > current.length) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
if (typeof payload.transcript === 'string') {
|
||||
const current = this.status.transcript || '';
|
||||
|
||||
if (payload.transcript.length < current.length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return payload.transcript;
|
||||
}
|
||||
|
||||
return this.status.transcript;
|
||||
},
|
||||
|
||||
shouldResetWhisper(payload, fromEcho = false) {
|
||||
const delta = payload.whisper_delta || payload.whisperDelta;
|
||||
const segment = delta?.segment;
|
||||
|
||||
if (
|
||||
fromEcho
|
||||
&& payload.status === 'processing'
|
||||
&& payload.percent === 0
|
||||
&& ! delta
|
||||
&& ! payload.transcript_delta
|
||||
&& ! payload.transcriptDelta
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segment && this.whisper.segments.length) {
|
||||
const last = this.whisper.segments[this.whisper.segments.length - 1];
|
||||
|
||||
if (segment.start != null && last.start != null && Number(segment.start) + 0.05 < Number(last.start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
mergeWhisper(payload, fromEcho = false) {
|
||||
const snapshot = payload.whisper;
|
||||
const delta = payload.whisper_delta || payload.whisperDelta;
|
||||
|
||||
if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
|
||||
if (fromEcho) {
|
||||
return this.whisper;
|
||||
}
|
||||
|
||||
// HTTP hydrate is source of truth once it is ahead of (or equal to) local Echo state.
|
||||
if (snapshot.segments.length >= this.whisper.segments.length) {
|
||||
return whisperSnapshot(snapshot);
|
||||
}
|
||||
|
||||
const next = whisperSnapshot(this.whisper);
|
||||
|
||||
if (! next.language && snapshot.language) {
|
||||
next.language = snapshot.language;
|
||||
}
|
||||
|
||||
if (next.duration == null && snapshot.duration != null) {
|
||||
next.duration = snapshot.duration;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
if (! delta) {
|
||||
if (snapshot) {
|
||||
const next = whisperSnapshot(this.whisper);
|
||||
|
||||
if (snapshot.language) {
|
||||
next.language = snapshot.language;
|
||||
}
|
||||
|
||||
if (snapshot.duration != null) {
|
||||
next.duration = snapshot.duration;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
return this.whisper;
|
||||
}
|
||||
|
||||
if (fromEcho) {
|
||||
this.liveFromEcho = true;
|
||||
}
|
||||
|
||||
const next = whisperSnapshot(this.whisper);
|
||||
|
||||
if (delta.language) {
|
||||
next.language = delta.language;
|
||||
}
|
||||
|
||||
if (delta.duration != null) {
|
||||
next.duration = delta.duration;
|
||||
}
|
||||
|
||||
if (delta.segment) {
|
||||
next.segments = this.appendSegment(next.segments, delta.segment);
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.segments) && delta.segments.length) {
|
||||
if (! delta.segment) {
|
||||
next.segments = delta.segments.slice();
|
||||
} else {
|
||||
delta.segments.forEach((row) => {
|
||||
next.segments = this.appendSegment(next.segments, row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.logprobs) && delta.logprobs.length) {
|
||||
next.logprobs = next.logprobs.concat(delta.logprobs);
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.words) && delta.words.length && next.segments.length) {
|
||||
const last = { ...next.segments[next.segments.length - 1] };
|
||||
last.words = (last.words || []).concat(delta.words);
|
||||
next.segments = next.segments.slice(0, -1).concat([last]);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
|
||||
appendSegment(segments, segment) {
|
||||
const key = segmentKey(segment);
|
||||
|
||||
if (segments.some((row) => segmentKey(row) === key)) {
|
||||
return segments;
|
||||
}
|
||||
|
||||
return segments.concat([segment]);
|
||||
},
|
||||
|
||||
seekTo(seconds) {
|
||||
const player = this.$refs.player;
|
||||
|
||||
if (! player || seconds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.currentTime = Number(seconds);
|
||||
player.play().catch(() => {});
|
||||
},
|
||||
|
||||
mergePercent(payload, status) {
|
||||
if (status !== 'processing') {
|
||||
return payload.percent !== undefined ? payload.percent : this.status.percent;
|
||||
}
|
||||
|
||||
const incoming = payload.percent;
|
||||
const current = Number(this.status.percent) || 0;
|
||||
|
||||
if (incoming == null) {
|
||||
return this.status.percent;
|
||||
}
|
||||
|
||||
return Math.max(current, Number(incoming) || 0);
|
||||
},
|
||||
|
||||
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(() => {
|
||||
if (! this.status.is_active) {
|
||||
this.stopHydratePoll();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.hydrateOnce();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
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;
|
||||
const duration = Number(this.status.duration_seconds) || 0;
|
||||
let percent = this.status.percent;
|
||||
|
||||
if (this.status.status === 'processing' && duration > 0) {
|
||||
const elapsedPercent = Math.min(99, Math.round((100 * next) / duration));
|
||||
|
||||
percent = Math.max(Number(percent) || 0, elapsedPercent);
|
||||
}
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
elapsed_seconds: next,
|
||||
elapsed_human: formatElapsed(next),
|
||||
percent,
|
||||
};
|
||||
},
|
||||
|
||||
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,
|
||||
formatClock,
|
||||
wordConfidenceClass,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Index-page Alpine component: inline audio player only.
|
||||
* Status/progress refresh via wire:poll. Live transcript on the show page uses Echo.
|
||||
*/
|
||||
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,33 @@
|
||||
@php
|
||||
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
|
||||
$usedPercent = min(100, max(0, (float) $disk['used_percent']));
|
||||
$usedPercentLabel = rtrim(rtrim(number_format($usedPercent, 1, '.', ''), '0'), '.') ?: '0';
|
||||
$freePercent = (float) ($disk['free_percent'] ?? 100);
|
||||
// Inline colors so the fill is visible even before / without a Tailwind rebuild.
|
||||
$fillColor = match (true) {
|
||||
$freePercent <= 5 => '#dc2626',
|
||||
$freePercent <= 15 => '#f59e0b',
|
||||
default => '#0d9488',
|
||||
};
|
||||
@endphp
|
||||
<div
|
||||
class="flex min-w-36 flex-col gap-1 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
|
||||
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $usedPercentLabel }}% used"
|
||||
>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="{{ (int) round($usedPercent) }}"
|
||||
aria-label="Disk space {{ $usedPercentLabel }}% used"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-300"
|
||||
style="width: {{ $usedPercent }}%; background-color: {{ $fillColor }};"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium tabular-nums text-zinc-600 dark:text-zinc-300">
|
||||
{{ $usedPercentLabel }}% used · {{ $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
|
||||
@@ -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">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user