CLAUDE.md
Rama master · réplica de lectura del repositorio original.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> A more detailed agent brief lives in `AGENTS.md`. Read it for module-by-module conventions, schema fields, and per-class responsibilities. This file covers the essentials.
## Commands
```bash
composer install
php scripts/run-migrations.php # apply migrations/*.sql against DB in config/database.php
php start.php start # the ONLY served entry (HTTP :8096, WS :8097)
# Tests
./vendor/bin/phpunit # all three phpunit.xml suites: Unit + Integration + the serial E2E tail
./vendor/bin/phpunit --testsuite Unit
./vendor/bin/phpunit --testsuite Integration
./vendor/bin/phpunit tests/Unit/Auth/JwtHandlerTest.php --testdox # single file
# --testdox cannot name a skipped test — a skip-name set needs a plain run
./vendor/bin/phpunit --filter testRegisterCreatesUser # single test
# Static analysis / style
./vendor/bin/phpcs --standard=PSR12 src/
./vendor/bin/phpcs --standard=phpcs-tests.xml # separate tests/ gate
./vendor/bin/phpstan analyse # production gate: phpstan.neon paths = src/ + scripts/ (level 9)
./vendor/bin/phpstan analyse -c phpstan-tests.neon # separate tests/ gate (level 4)
find src -name '*.php' -exec php -l {} \;
# Release
./scripts/release.sh patch --dry-run # bump src/Common/Version.php + VERSION + k8s/helm/phlix/Chart.yaml
bash scripts/docker-boot-smoke.sh docker/Dockerfile phlix-server:local # boot gate for a built image
```
Coverage outputs to `coverage.xml` (Clover) and `coverage-report/` (HTML), configured in `phpunit.xml`. Test envs (`DB_*`, `APP_ENV=testing`) are also defined there. Note: `scripts/migrate.php` does **not** exist — use `php scripts/run-migrations.php` or `php bin/phlix migrate`. `start.php` **does** exist and is the entry `systemd/phlix-server.service` and `docker/supervisord.conf` run.
## Architecture
PHP 8.3+ media server on **Workerman 5.x**. PSR-4 autoload: `Phlix\` → `src/`, `Phlix\Tests\` → `tests/`. The **only served entry point is `start.php`** at the repo root (the single-process CGI/FPM bootstrap `public/index.php` was unserved by every deployment artifact and deleted by S171; removal pinned by `tests/Unit/Docker/PublicFrontControllerRemovalGuardTest.php`) — `Worker::runAll()` over an HTTP worker on `:8096` (count 14), a WebSocket worker on `:8097`, plus hub-heartbeat, background-timer and relay-tunnel workers (each count 1) — building its routes through `src/Server/Core/Application.php`.
### Layers
- **`src/Server/Http/`** — `Request::fromGlobals()` → `Router::dispatch()` (supports `{param}` placeholders and `$router->group($prefix, $cb, [$middleware])`). Controllers in `Http/Controllers/` return chained `(new Response())->status(...)->json(...)`.
- **`src/Server/WebSocket/`** — `WebSocketServer` wraps `Workerman\Worker`. `ConnectionPool` is a singleton (`getInstance()`). `MessageHandler->on($event, $cb)` registers handlers; event constants in `WebSocketEvents.php` (`WebSocketEvents::PLAYBACK_*`, `SYNCPLAY_*`, `AUTH_*`). Payload shape is always `['type' => $event, 'data' => $payload, 'timestamp' => time()]`.
- **`src/Server/WebPortal/`** — `WebPortalRouter` serves portal JSON (`/api/v1/libraries`, `/api/v1/media/{id}`, `GET /api/v1/media/facets?libraryId=<id>` → `{genres:string[]}` for the filter UI via `ItemRepository::distinctGenres()`, etc.). Per-user favorites/ratings/like-level/watched (E10) live here in the auth group → `MediaUserDataController`: `POST`/`DELETE /api/v1/media/{id}/favorite`, `PUT`/`DELETE /api/v1/media/{id}/rating` (body `{rating:int 1-10|null}`), `PUT /api/v1/media/{id}/like` (body `{level:int −2..2}` signed thumbs axis), `POST /api/v1/media/{id}/watched`|`/unwatched`, all `{message}`; `GET /api/v1/media/{id}` carries an add-only `user_data:{favorite,rating,like_level}` block. Favorites/ratings/like-level/watched are **per-profile** since migration `100_user_item_data_profile_id.sql` widened the PK to `(user_id, profile_id, item_id)`; persisted via `Phlix\Media\UserItemDataRepository` (table `user_item_data`, migrations 039/044/045/100). Every method takes a trailing `?string $profileId = null` that falls back to the account's active (else first) profile and throws `Phlix\Auth\ProfileNotOwnedException` when the profile is not owned by the user. **UI is the `/app` Vue SPA only** — the legacy Smarty page renderer (`PageRenderer` + `public/templates/**/*.tpl` page templates + page controllers) was removed; legacy page paths now 302-redirect to their `/app` equivalent in `HttpHandler` (the served Workerman path; the deleted pre-S171 `public/index.php` had merely mirrored it). The SPA shell is served by `SharedUiController` (`/app` + `/app/*`) from the Vite-built bundle under `public/assets/app/` (TypeScript source in `web-ui/`), injected via `src/Server/WebPortal/ViteAssets.php`; `WebPortalRouter` now serves JSON only. Smarty (`smarty/smarty`) is retained **only** for the newsletter email (`src/Admin/NewsletterGenerator.php` + `public/templates/emails/newsletter.tpl`).
- **`src/Auth/`** — `JwtHandler` (HS256, `iss=phlix`, 1h access / 7d refresh). `UserRepository` uses `password_hash(..., PASSWORD_ARGON2ID)`. `AuthManager` orchestrates register/login/refresh and calls `AuditLogger`. `UserProfileManager` enforces ≤5 profiles per user, PINs (4 or 6 digits, Argon2ID-hashed), and rating filter (G/PG/PG-13/R/NC-17/X/UNRATED). `WatchHistory` marks complete at 90%. External providers (OIDC/LDAP/GitHub) are registered per worker by `AuthProviderBootstrapper` into `AuthProviderRegistry` behind the `auth.{oidc,ldap,github}.enabled` server settings; their config lives in the `plugin_settings` table (migration `093_plugin_settings.sql`) via `Phlix\Plugins\Repository\PluginSettingsRepository` and the `PluginDbSettings` trait, not `settings.json`.
- **`src/Media/`** — `Library/` (scanner parses `S01E02`, `(2020)`; `FolderWatcher` uses mtime checksum; `ItemRepository` hydrates `metadata_json`; genres are normalized into a `media_item_genres(media_item_id, genre)` join table (migration 051, `genre` COLLATE `utf8mb4_bin` for exact-match filtering — replacing migration 050's MySQL 8 multi-valued functional index after it reproduced real InnoDB purge-thread errors under sustained scan/rescan churn), kept in sync from `create()`/`update()` while `metadata_json.$.genres` remains the canonical source; `distinctGenres(?libraryId)` returns DISTINCT/sorted genre facets by joining `media_item_genres` back to `media_items` (re-asserting `COLLATE utf8mb4_unicode_ci` so the facet list stays case-insensitive independent of the filter columns' now-`_bin` collation)); `ItemRepository::upsertByPath()` is the race-safe find-or-create the scanner uses per file, backed by migration 072's `media_items.path_hash` STORED generated column + `(library_id, path_hash)` unique index (the index is added in-chain by `migrations/096_path_hash_unique_index.sql`; `migrations/cleanup_072.php` is the de-dup tool for the duplicates-present error path, not the index creator); `php bin/phlix media:dedupe-paths [--apply]` merges duplicate-path rows via `PathDeduper` (score-based keeper selection, collision-safe reference repointing)). Strings bound into a media write must already be valid UTF-8 and be truncated **character-wise** (`mb_substr`, not `substr`) — `MediaScanner::streamLanguage()`/`streamTitle()` feed `ItemRepository::addStream()`, which has no `toValidUtf8()` guard, so a byte-wise cut is rejected with MySQL error 1366 and the delete-then-insert stream write leaves the item with a partial `media_streams` set. `Metadata/` providers (`Tmdb`, `Tvdb`, `Fanart`, `LocalNfo`) implement `MetadataProviderInterface`; `MetadataManager` priority is `tmdb→local` for movies, `tvdb→fanart→local` for series, with 24h cache via `metadata_refreshed_at`. `Streaming/` (`HlsStreamer` master/variant `.m3u8` + `.ts`; `QualitySelector` profiles: generic, mobile-low, mobile-high, web, tv-4k, samsung-tizen (S507/AD-8 — a UHD panel with opt-in direct-play gates: H.264 ≤ L5.2, SDR-only, no anamorphic/interlaced, HEVC Main ≤ L120, VP9 range/bitrate gate, 2-ch audio budget, per-tier bitrate ceilings; the gate keys are absent on every other profile so those stay byte-identical); S508/AD-8 adds a caller-driven option arm to `selectQuality()` — `force_transcode` (skip direct-play) and `exclude_hevc` (drop the HEVC aliases from the effective direct-play set), surfaced by `MediaItemController::getPlaybackInfo()` from `?forceTranscode`/`?excludeHevc` as an add-only `playback_constraints` key (omitted entirely when neither is supplied → byte-identical); `Streaming/Dash/` MPEG-DASH `DashStreamer` + `AdaptationSet`/`Representation`/`SegmentTemplate`; `Streaming/Trickplay/` scrubber sprites + Roku `.bif` via `BifWriter`, served by `TrickplayController` — `config/trickplay.php` is deliberately tiny and the serving dir is derived from `config/ffmpeg.php`'s `transcode_dir`). `MediaAsset/` is the file-based chapter-thumbnail/trickplay queue: `MediaAssetJobStore` writes one JSON job per item under `config/media_asset_jobs.php`'s `job_queue_dir` (FIFO by mtime), drained by `MediaAssetWorker` on a `Workerman\Timer`; migration `101_library_scan_jobs_media_assets_type.sql` adds a `media_assets` value to `library_scan_jobs.type` so a library's existing rows can be re-enqueued without a rescan. `Music/` (`MusicLibraryScanner`, `MusicLibraryService`, `MusicScanSkipIndex`) — an album with no artist tag is ingested under a structural placeholder artist flagged by `music_artists.is_placeholder` (migration `102_music_artists_placeholder_flag.sql`, which requires a music scan after deploy). `Transcoding/` (`FfmpegRunner` probe/transcode/HLS-segment, `TranscodeManager` HLS pipeline (`readFailureReason()` scrubs then `mb_substr(-500)`s the ffmpeg log tail so `transcode_jobs.error` is always valid UTF-8), `EncodeSettings` (effective preset/CRF/audio-bitrate behind the `transcoding.*` admin settings, and the encode fingerprint folded into the job key); config in `config/ffmpeg.php` + `config/transcoding.php`; DI wiring in `src/Common/Container/Providers/TranscodeServicesProvider.php`).
- **`src/Admin/Maintenance/`** — one-off admin tasks behind `/api/v1/admin/maintenance/*` (`MaintenanceController`). `MaintenanceTask::CATALOGUE` splits them into `MODE_SYNC` (`reap-scan-jobs`, `reap-transcode-jobs`, `cleanup-orphaned-stats` → `200`) and `MODE_QUEUED` (`storage-snapshot`, `dedupe-paths` → `202` plus a `maintenance_jobs` row to poll). Queued rows are claimed atomically by `MaintenanceQueueWorker` (conditional `UPDATE`, `POLL_SECONDS = 5` `Workerman\Timer`) in the background-timers worker; table from migration `098_maintenance_jobs.sql`.
- **`src/Session/`** — `SessionManager` (device sessions), `PlaybackController` (continue-watching at <95%), `SyncPlay/` with `TimeSync` doing NTP-style weighted-mean offset over `OFFSET_SAMPLE_COUNT=5` samples.
- **`src/LiveTv/`** (`ChannelManager`, `GuideManager`, `Recorder`, `LiveTvManager`) and **`src/Dlna/`** (`ContentDirectory`, `AvTransport`, `DlnaServer`, `DeviceRegistry`, `DlnaDevice`).
- **`src/Common/`** — `Database/ConnectionPool` (static `init()` / `getConnection('mysql')`. `Logger/LoggerFactory` + `LogChannels` (`AUTH`, `HTTP`, `WEBSOCKET`, `MEDIA`, `SESSION`, `STREAMING`); `StructuredLogger` wraps Monolog; `AuditLogger` for security events.
### Database
**Only** `Workerman\MySQL\Connection` — never raw PDO or mysqli. Always parameterized:
```php
$rows = $db->query("SELECT * FROM users WHERE id = ?", [$id]);
$db->query("INSERT INTO users (id, name) VALUES (?, ?)", [$id, $name]);
```
Schema lives in `migrations/001_initial_schema.sql` and `migrations/002_user_profiles_and_parental_controls.sql`. Identity tables (the 001/002 lineage) use `CHAR(36)` UUID primary keys generated by a local `generateUuid()` helper (`sprintf('%04x%04x-...', mt_rand(...), ...)`) duplicated across many classes — reuse the existing pattern rather than introducing a UUID library when adding an identity-style key. Later tables vary: AUTO_INCREMENT `INT`/`BIGINT` PKs from 052 onward, VARCHAR (`plugin_settings`), and composite PKs (`user_item_data`, `collection_items`) — check the migration for the table you touch.
A write's return value is shaped by the first SQL token — `array` for `SELECT`/`SHOW`, `int` rowcount for `UPDATE`/`DELETE`/`REPLACE`, a `lastInsertId()` string or `null` for `INSERT` — and is **never `false`**. Test it with `Phlix\Common\Database\WriteResult::wroteNothing($result)`, never `$result === false` or a bare falsy check (a successful UUID insert returns the falsy string `'0'`). `PhlixMySQLConnection` normalises positional bind keys; `PooledMySQLConnection` is the coroutine-aware pooled front and opens no socket until `query()`.
Tests mock the DB with `$this->createMock(Workerman\MySQL\Connection::class)` and stub `->query(...)->willReturn([['col' => 'val']])`.
### Workerman Session Model
**Critical:** `$_SESSION` is **NOT** request-scoped under Workerman. Unlike php-fpm where each request gets a fresh `$_SESSION`, Workerman runs a single process handling multiple concurrent connections. Using `$_SESSION` for state (like OAuth state tokens) causes **race conditions and state leakage** between requests.
**Rule:** For any cross-request state (OAuth state, CSRF tokens, etc.), use **DB-backed state stores**:
- `DbOidcStateStore` for OIDC OAuth
- `DbOAuth2StateStore` for plain OAuth2 providers (e.g. GitHub)
- `DbTraktOAuthStateStore` for Trakt
- `DbLastfmOAuthStateStore` for Last.fm
These use the `oauth_state_store` table with TTL and atomic consume to prevent race conditions.
### Async Patterns
**Blocking vs Async I/O:** Workerman runs a single-process event loop. Blocking I/O (synchronous cURL, `file_get_contents` for HTTP, blocking DB queries) stalls the entire worker, blocking all concurrent connections. Always prefer non-blocking patterns. `docs/dev/BLOCKING_IO_EXCEPTIONS.md` is the complete, closed register of accepted exceptions — each one named, bounded by a measured timeout, and blast-radius costed; `Phlix\Common\Http\EventLoopTls::requiresBlockingCurl()` gates the one TLS case.
**HTTP Clients — Use `workerman/http-client`:**
```php
use Workerman\Http\Client;
$client = new Client(['timeout' => 10]);
// Non-blocking async request with cooperative wait
$client->request($url, [
'success' => function ($response) use (&$state) {
$state['response'] = $response;
$state['done'] = true;
},
'error' => function ($error) use (&$state) {
$state['error'] = $error;
$state['done'] = true;
},
]);
// Cooperative wait — yields to event loop, allowing other tasks to proceed
while (!$state['done'] && $waited < $maxWait) {
usleep(1000); // 1ms interval
$waited += 0.001;
}
```
Classes using this pattern: `MetadataHttpClient`, `Hub\HttpClient`, `S3Client`.
**Monotonic Time — Use `hrtime(true)`:**
Never use `microtime(true)` or `time()` for measuring elapsed intervals. `hrtime(true)` returns a monotonically increasing nanosecond timestamp that is immune to system clock adjustments (NTP, daylight savings, manual changes).
```php
// CORRECT — monotonic, immune to clock jumps
$start = hrtime(true);
doWork();
$elapsedMs = (hrtime(true) - $start) / 1_000_000.0;
// WRONG — microtime can go backwards if the system clock adjusts
$start = microtime(true);
```
Used in: benchmark scripts, streaming throughput measurement, performance profiling.
**Batch Queries for N+1 Prevention:**
Query in batches rather than looping with individual queries. When fetching related data for multiple items, use `WHERE id IN (...)` with a single query.
```php
// WRONG — N+1 queries (one per item)
foreach ($itemIds as $id) {
$items[] = $db->query("SELECT * FROM media_items WHERE id = ?", [$id]);
}
// CORRECT — single batch query
$placeholders = implode(',', array_fill(0, count($itemIds), '?'));
$sql = "SELECT * FROM media_items WHERE id IN ($placeholders)";
$items = $db->query($sql, $itemIds);
```
`DuplicateFinder` demonstrates batch pagination with `DEFAULT_BATCH_SIZE = 500` to bound memory in long-lived workers.
**Chunked/Streaming for Large Data:**
For large data that exceeds memory or response size limits, use chunked processing:
```php
// Reading large files in chunks (see AudiobookScanner)
$handle = fopen($filePath, 'rb');
while (!feof($handle)) {
$chunk = fread($handle, 8192);
// Process chunk without loading entire file
}
// Chunked HTTP responses for Workerman (see HttpHandler)
// Responses over 2 MB are chunked to avoid buffering
```
`AudiobookScanner` uses chunked reading to parse files without loading them into memory entirely.
### Config
`config/{server,database,logger,ffmpeg,artwork,dlna,imdb,managed_workers,media_asset_jobs,metadata,plugins,process,relay,similarity_jobs,transcoding,trickplay,updates,webhooks}.php` — each `include`d, each returns an array. Logger writes rotating files to `.logs/`.
## Conventions
- PSR-12, `declare(strict_types=1);`, namespaces mirror directories.
- Logging always via `LoggerFactory::get(LogChannels::HTTP)` — do not instantiate `new Logger` directly.
- Smarty is now used **only** for the newsletter email template (`public/templates/emails/newsletter.tpl`); the page-rendering templates were removed. The `/app` Vue SPA (in `web-ui/`) is the web UI.
- **Version is single-sourced from `src/Common/Version.php`** (`Version::STRING`). `./scripts/release.sh [patch|minor|major]` propagates the bump to `VERSION` and `k8s/helm/phlix/Chart.yaml` (`version` + `appVersion`) in one commit — never edit those by hand, and never add a `version` field to `composer.json` (`composer validate --strict` fails when it is present). See `RELEASE_PROCESS.md`.
- The historical per-phase step plans (`docs/archive/plans/phase-N/step-N.M-*.md`) and their branch → commit → PR flow were retired into the external [phlix-docs](https://github.com/detain/phlix-docs) when the in-tree `docs/archive/` directory was removed (commit `77e681fd`); no such plan files remain in this repo — do not follow them as current process.
## Caliber (pre-commit)
A pre-commit hook may sync agent configs via `caliber refresh`. Before committing, check `grep -q "caliber" .git/hooks/pre-commit` — if present, the hook handles it; if not, run `caliber refresh` manually and stage the updated agent files (`CLAUDE.md`, `AGENTS.md`, `.claude/`, `.cursor/`, `.cursorrules`, `.github/copilot-instructions.md`, `.opencode/`). Valid `caliber refresh` flags are **only** `--quiet` and `--dry-run`. See `AGENTS.md` "Before Committing" section for full procedure; session-specific lessons in `CALIBER_LEARNINGS.md` if present.
## Further reading
`AGENTS.md` (the more detailed agent brief) · `README.md` · `RELEASE_PROCESS.md` · `docker/README.md` · `docs/dev/BLOCKING_IO_EXCEPTIONS.md` · `docs/dev/settings-restart-gap.md` · `docs/dev/SYNCPLAY_WRITE_THROUGH_BRIDGE.md` · `docs/dev/SYNCPLAY_MEMBER_SYNC_NUDGE.md` · `docs/dev/WEBSOCKET_AUTH_CARRIERS.md`. The narrative docs (`DEVELOPER.md`, the technical spec, the platform guides) are not in this tree — see `AGENTS.md` "Reference docs" for the authoritative list; do not link them as in-tree.
## Before Committing
**IMPORTANT:** Before every git commit, you MUST ensure Caliber syncs agent configs with the latest code changes.
First, check if the pre-commit hook is already installed:
```bash
grep -q "caliber" .git/hooks/pre-commit 2>/dev/null && echo "hook-active" || echo "no-hook"
```
- If **hook-active**: the hook handles sync automatically — just commit normally. Tell the user: "Caliber will sync your agent configs automatically via the pre-commit hook."
- If **no-hook**: run Caliber manually before committing:
1. Tell the user: "Caliber: Syncing agent configs with your latest changes..."
2. Run: `caliber refresh && git add CALIBER_LEARNINGS.md CLAUDE.md .claude/ AGENTS.md .agents/ 2>/dev/null`
3. After it completes, briefly tell the user what Caliber updated. Then proceed with the commit.
**Valid `caliber refresh` options:** `--quiet` (suppress output) and `--dry-run` (preview without writing). Do not pass any other flags — options like `--auto-approve`, `--debug`, or `--force` do not exist and will cause errors.
**`caliber config`** takes no flags — it runs an interactive provider setup. Do not pass `--provider`, `--api-key`, or `--endpoint`.
If `caliber` is not found, tell the user: "This project uses Caliber for agent config sync. Run /setup-caliber to get set up."
## Session Learnings
Read `CALIBER_LEARNINGS.md` for patterns and anti-patterns learned from previous sessions.
These are auto-extracted from real tool usage — treat them as project-specific rules.
## Model Configuration
Recommended default: `claude-sonnet-4-6` with high effort (stronger reasoning; higher cost and latency than smaller models).
Smaller/faster models trade quality for speed and cost — pick what fits the task.
Pin your choice (`/model` in Claude Code, or `CALIBER_MODEL` when using Caliber with an API provider) so upstream default changes do not silently change behavior.
## Context Sync
This project uses [Caliber](https://github.com/caliber-ai-org/ai-setup) to keep AI agent configs in sync across Claude Code, Cursor, Copilot, and Codex.
Configs update automatically before each commit via `caliber refresh`.
If the pre-commit hook is not set up, run `/setup-caliber` to configure everything automatically.