## Objective
- Keep building the ISP Management module and SIM features: eSIM provisioning (SM-DP+ / LPA), hotspot vs PPPoE subscriber separations, SIM-wallet payments, MAC/IP binding router actions, and the full ISP upgrade (reports with filters/export, dashboard stats, subscriber router-import + reminder/billing crons, wallet invoice payments).
- Keep the IPTV portal player and the native Android Live TV client in shape: current focus is the TiviMate-style portal player (vertical category rail + one-by-one channel list) and keeping the transcoder honest about idle encoders.
- SIP Manager (standalone module, added 2026-10-04) is built and live-verified; see the Completed entry for the two real bugs found during verification and the migration/grant caveat.
- IPTV licence self-service acceptance (added 2026-10-05) is built, live-verified and cleaned up: a per-licence secret URL lets a subscriber read + accept the agreement, and the streaming credentials are revealed only after accepting.

## Important Details
- **LIVE-DATA ISSUE (2026-10-05): the only IPTV package has ZERO channels assigned.** `iptv_packages` has exactly one row (id=1 `Lite`), and `iptv_channels.package_id` is NULL for all 26 channels, so `get.php` returns a bare `#EXTM3U` with no `#EXTINF`. Real users `hamse`(40) and `amla`(41) are both on package 1, i.e. **both currently receive an empty playlist**. This is channel/package assignment, not a code bug — assign channels to the package (Packages → assign) or create a package with channels. Verified while proving the licence-acceptance streaming link; used a throwaway `ZZ Licence Probe` package with 2 channels so live config was never touched.
- **`IptvPackage::channels()` is a `hasMany`, NOT a `belongsToMany`** — each `iptv_channels` row carries its own `package_id`, so `sync()`/`attach()`/`detach()` do not exist. To move channels: `IptvChannel::whereIn('id', $ids)->update(['package_id' => $pkgId])` and to clear `['package_id' => null]`.
- **`roles` requires `display_name`** (`NOT NULL`, no default) and **`permission_role` has NO `created_at`/`updated_at` columns** — `Role::create(['name'=>..,'description'=>..])` fails with `Field 'display_name' doesn't have a default value`, and inserting timestamps fails with `Unknown column 'created_at' in 'field list'`. Insert `['role_id'=>..,'permission_id'=>..]` only. Roles are keyed by **`name`**, not `slug`.
- **`php -r` cannot take multi-line PHP** — inline `php -r` with newlines/`<?php`-style blocks dies with `syntax error, unexpected token ":"` and every check that depends on it silently fails. For any multi-statement probe during live testing, **write a temp `.php` script** that bootstraps the app (`require vendor/autoload.php` + `bootstrap/app.php` + `Kernel->bootstrap()`) and pass the action as `$argv[1]`. This single harness mistake cost 4 debug cycles and produced 34 phantom failures.
- **PHP precedence trap hit in `IptvLicenseController::publicShow`**: `'logoUrl' => $logo = Setting::get('logo') ? storage_url($logo) : null` parses as `$logo = (cond ? storage_url($logo) : null)`, so the ternary body runs *before* the assignment and throws `Undefined variable $logo` (500 on the public page). Compute the variable on its own statement first.
- **Laravel `accepted` validation needs the `required` message key too**: for `'agree' => 'required|accepted'`, an unticked checkbox sends nothing so **`required` fires first** and the custom `agree.accepted` message is never used — the page showed the useless default *"The agree field is required."*. Supply `['agree.required' => ..., 'agree.accepted' => ...]`.
- The public licence view must **never query the DB** (no `\App\Models\Setting::get()` inline); `publicShow()` passes `company` and `logoUrl` (via `storage_url()`), and `stream` is passed as **`null` until `isAccepted()`**, which is what keeps the credentials hidden.
- **CRITICAL — `php artisan schedule:run` is NOT running on this host, by decision.** There is no Windows Task Scheduler entry for it, so every job in `routes/console.php` has silently never fired: `sms:process-scheduled`, `isp:expire-subscriptions`, `isp:send-reminders`, `iptv:send-reminders`, `isp:billing-cycle`, `iptv:check-streams`, `iptv:transcode-cleanup`, `iptv:stream-session-cleanup`. The user decided on 2026-09-25 to keep it OFF; run jobs by hand instead. Do not register a task without asking again.
- **Timezone split**: app/PHP is `UTC`, MySQL `NOW()` is `+03:00`. Timestamps written by the app (e.g. `stream_sessions.last_active_at`) are UTC, so do NOT compare them against MySQL `NOW()` or the Windows taskbar clock — a row stamped `19:58` can be "now". `dailyAt` cron times are UTC too (00:10 UTC = 03:10 local).
- Environment: PHP `C:\xampp\php\php.exe`; MySQL `C:\xampp\mysql\bin\mysql.exe -u root simcard`; **live admin login is user id=1 `admin@example.com` / `password` (the documented `admin@simcard.com` account no longer exists in the DB)**; vhost `http://simcard.test/...`; workdir `C:\xampp\htdocs\simcard`.
- IPTV portal test users are gone: only `amla` (41) and `hamse` (40) remain, and **`amla`/`stream123456` no longer authenticates** (`Invalid username or password.`). Create a disposable user for portal testing and delete it (plus its `iptv_sessions` / `iptv_user_devices` / `iptv_favorites`) afterwards.
- After Blade edits run `php artisan view:cache`; lint with `php -l`; finalize with: `Start-Process -Wait -NoNewWindow "C:\xampp\mysql\bin\mysqldump.exe" -ArgumentList '-u root --default-character-set=utf8mb4 simcard' -RedirectStandardOutput "C:\xampp\htdocs\simcard\database.sql"`.
- MySQL shell strips backticks when using `-e`; query via tinker scripts (`\App\Models\...`) or plain unquoted identifiers instead.
- Live-test flow: use system `curl.exe` with a cookie jar (`-c jar` then `-b jar -c jar`), regex `name="_token" value="([^"]+)"`, POST then `-L` to surface success/error flashes. PowerShell `Invoke-WebRequest` POSTs to this app hit `MaximumRedirectExceeded` / "Operation is not valid" — avoid. Live-test base URL = `http://simcard.test/simcard/public/...` via `curl.exe --resolve simcard.test:80:127.0.0.1`. `http://simcard.test` alone gives 404; HTTPS gives 000. For server-side HTTP fetches (EPG import), `Http::get()` cannot resolve `simcard.test` — use `http://127.0.0.1/simcard/public/...` which the Apache default vhost serves. CSRF token rotates after login session regeneration — always fetch a fresh token from a post-login page (e.g., `/iptv/health`) rather than the login form's token.
- `SmsService::send()/templateBody()/isEnabled()` are STATIC — container-instance stubs do NOT intercept them; to exercise the real send path you must set `sms_enabled=1` + a reachable `sms_api_url` (local HTTP listener attempt was blocked by antivirus). Sms sends via `Http::get` with 15s timeout; unreachable provider logs `cURL error 28: SSL connection timeout`.
- Test fixtures: SIM 12 (`620505555`, cust 4, wallet 130). DB evolved — current state: router id=6 (`smart`, 192.168.10.1, online, api_protocol api); plan 7 `FTTA` (provider 2, pool 4, download_speed 900000 = entered in Mbps, price 100.00, 30d) is the only plan; pool 4 `3MB` (10.0.0.11-10.0.0.200, no router link in DB but name exists on router); connections 28 `admin` + 31 `default-trial` (hotspot, no router/plan, cust 22/23) and 35 `ppp1` (pppoe, router 6, plan 7, cust 4, active).
- RouterOS 7.21.3 quirk: `/ppp/secret` `remote-address` REJECTS pool names (only literal IPs; trap "invalid value for argument remote-address"), but `/ppp/profile` `remote-address` ACCEPTS pool names. So PPPoE pool assignment is done on the plan profile (profile name = plan name), not the secret. PPPoE clients also need `dns-server` on the profile (was missing → "no internet"; now pushed via `isp_pppoe_dns` default `8.8.8.8,8.8.4.4`).
- MySQL outage incident (2026-08-15): data dir at `C:\xampp\mysql\data` was polluted with 58 mangled files named after mysqld's own log lines (from a bad output redirect, all containing `@002d`), causing `Failed to initialize multi master structures` abort. Fix: deleted those files (legit file kept: `multi-master.info`), restarted `mysqld.exe`. MySQL must be started manually via `Start-Process "C:\xampp\mysql\bin\mysqld.exe"` (it is NOT a Windows service).
- Legacy router API only (`api_protocol='api'`); simcard router id=6 (`smart`, 192.168.10.1:8728, RouterOS 7.21.3, no ICMP but API reachable). Router `/ip/pool`: `LAN-POOL` (192.168.10.10-200), `PPPOE-POOL` (10.0.0.10-200), `3MB` (10.0.0.11-200). PPPoE server exists (`ISP` on `LAN-BRIDGE`, default-profile `default`); NAT masquerade on `ether1` (WAN 10.147.107.148/24).
- VPN/SDWAN note (2026-08-15): user pointed the router at `access-africa.mikrotiksdwan.com` (MikroTik SDWAN proxy → 139.84.234.107) — unreachable from this server (ports 443/8728 time out, cURL 28). Fixed by restoring host `192.168.10.1:8728`, `use_tls=0`, `api_protocol='api'`; check() → identity `smart`, online. Also `requestAny()` now tries legacy API first whenever `api_protocol !== 'rest'` (previously a null protocol forced a slow REST attempt first).
- Service Types table: exactly one real row id=2 `FTTH`.
- Live config: `internet_plans.provision_script` is null (test script cleared).
- **Flash extraction in this layout**: `layouts/admin.blade.php` renders `session('success')` inside `alert alert-success` and `session('error')` inside `alert alert-danger` (**not** `invalid-feedback`), and both use `{{ }}` - so the `<strong>`/`<code>` markup the import messages contain is **escaped and shows literally**. That is pre-existing app-wide behaviour; don't chase it when a flash "looks wrong", and grep for the `alert-danger` class when reading errors back from a page.
- **Multipart POSTs from curl** need `-F "_token=..."` (not `--data-urlencode`) plus `-F "field=@C:\path\file.m3u"`. To prove a *file*-upload code path without an external network, drop a throwaway `.m3u` into `public/` and feed the **URL** importer `http://127.0.0.1/simcard/public/<file>` (the Apache default vhost serves it) - that is how the `parseM3uBody()` signature change was regression-proven.
- **Billing settings footgun (bit all 14 IPTV templates on 2026-10-04)**: `IptvSettingController::update()` rewrites `is_active` for every template whose `ntpl_body_*` is posted, so a *partial* POST blanks the other bodies. A missing field is skipped, but a **checkbox-only PUT** silently writes `is_active=0`. Always post the entire settings form. Recover by replaying the migrations' own `$templates` arrays (both use `updateOrInsert`, so `up()` is idempotent).
- **Billing column asymmetry**: `iptv_payments`/`iptv_sessions`/`iptv_user_devices`/`iptv_favorites` use **`user_id`**, while `iptv_invoices`/`iptv_licenses` use **`iptv_user_id`**. Billing defaults live in **`isp_settings`** with columns `key`/`value` (NOT the `settings` table, and NOT `setting_key`/`setting_value` - a `settings` insert looks written but is invisible to `IspSetting::get()`).
- Billing settings routes are `GET /iptv/notifications` + `PUT /iptv/notifications` (`iptv-settings.index/update`), and that form has **`enctype="multipart/form-data"`** - test it with `_method=PUT` **spoofing on POST**, since a real `-X PUT` multipart is not parsed by PHP (419 + `hasFile=false`). Non-file PUTs also 419 unless the token is fetched from a post-login page.
- **PowerShell harness: a function that returns curl output needs `-join "`n"`**. `$h = & curl.exe ...` assigns a **`string[]`** (one element per output line) when the body is multi-line, and `[regex]::Match($h, ...)` then silently fails to match, so the CSRF token comes back **empty** and every POST returns **419** - which looks like an app bug but is not. Two more traps from the same session: a function whose last statement is an **assignment** (`$tok = [regex]::Match(...)`) emits **nothing** in PowerShell, and `(& curl.exe ... -match 'pat')` passes `-match` to **curl** (not PowerShell), producing `curl: option -match: expected a proper numerical parameter`. Always extract tokens inline, or `(...) -join "`n"`, and read flash/HTML with `(...) -join "`n"` too.
- **A CSRF 419 can mask a 403 permission result**: fetching the token from a page that renders no forms (because the action buttons are correctly hidden from a read-only user) yields an empty token. When probing permissions, take the token from a page the limited user *can* read (e.g. `/dashboard`) and use `GET` for GET-only routes, otherwise you measure the wrong status.
- Test customer phone `0000000000` is the designated safe test number; real SMS is enabled (API `https://in.caansan.com/services/send.php?key=...&number=[number]&message=[text]...`), so live tests set `\App\Models\Setting::where('setting_key','sms_enabled')->update(['value'=>'0'])` and restore to `'1'` afterwards (see static-SMS note above).
- Live config: `internet_plans.provision_script` is null (test script cleared).
- **SIP Manager is a standalone module** (2026-10-04): it manages SIP trunks/extensions/destinations/dialplans/registrations/CDR as its own registry and probes reachability itself. It is **not** a FreeSWITCH/Asterisk control plane and does not integrate with billing, payments, packages or the wallet. SIP extension passwords are stored **plaintext** (they must be provisioned to phones); an empty password box on edit must preserve the stored value. Optional relations are limited to `customers` and `sims`. SIP settings live in **`sip_settings`** (`key`/`value` via `App\Models\SipSetting`), NOT in `settings`/`isp_settings`. Routes `sip.*` + `reports.sip-*` (43 route lines), gated by 5 permissions `view_sip`/`add_sip`/`edit_sip`/`delete_sip`/`execute_sip`.
- **Rolling back the SIP migration silently strips the admin/super-admin SIP grants.** `down()` deletes the 5 permission rows and `permission_role.permission_id` is `ON DELETE CASCADE`, so `up()` restores the permissions but **not** the grants. After any `migrate:rollback` that touches `2026_10_04_000001_create_sip_tables.php`, re-run the grant insert (or `db:seed`) or admins lose access to /sip.
- **Carbon aliasing footgun — the only instance of it in `app/` was `SipExpireExtensions`.** `whereBetween('expires_at', [$now->startOfDay(), $now->addDays(7)->endOfDay()])` makes BOTH bounds the *same* mutable Carbon instance, so the window collapses to `now+7d 23:59:59 .. now+7d 23:59:59` and matches nothing (it would also have stamped `last_reminder_at` 7 days in the future). Always `$now->copy()->...` per operand. **`now()` calls are safe** (each returns a fresh instance) — only a *reused variable* aliases, so grep for `$now->` rather than `now()->`.
- **Live-test harness rules for POSTs on this stack** (each cost a debugging cycle): do **NOT** use `curl -L` with `-X POST` — curl re-POSTs after the redirect and you get a spurious **419**; capture the 302 `Location` from `-D` and issue a separate GET to read the flash/error bag. Forms that use `@method('PUT')`/`@method('DELETE')` must be driven with `-X POST` plus `_method=...` **spoofed in the body** (a real `-X PUT`/`-X DELETE` is fine only if the token came from a post-login page). Never name a helper function `Del`/`Copy`/`Move`/`Get`/`Set` — PowerShell **aliases beat functions**, so `Del "/sip/calls/3"` runs `Remove-Item`. Fetch the CSRF token from the page that *holds the form*, not from the POST target (a POST-only route returns 405 and yields an empty token → 419). `notification_templates.key` is a reserved word, so `mysql -e "WHERE \`key\`..."` fails (PowerShell eats the backticks) — use a PHP/query-builder script or match on `name`/`category` instead.
- **The same "partial POST blanks fields" footgun exists for the SIP notification templates.** `updateTemplates()` writes `is_active = boolean('ntpl_active_<key>')` for every template whose body is posted, so posting bodies without the checkboxes silently deactivates them (proven live: one probe POST set `sip.trunk.down` to `is_active=0`). Always post the whole template form. Recover bodies/flags by replaying the migration's own `$templates` array (`up()` is idempotent).
- `PowerShell`/Temp script files written under `C:\Users\hp\AppData\Local\Temp\opencode\` and cleaned up after use.

## Work State
### Completed
- **Xtream Users manager (view of our own IPTV users) - DONE, live-verified, cleaned up** (2026-10-05): the user picked an **Xtream-native view over our own users** (option A) - not upstream-panel sync, not saved panel credentials, and **no new `xtream_users` table**. New `app/Services/XtreamUserInfo.php` builds the `user_info` payload in exactly the shape Xtream apps expect (`active_cons`, `max_connections`, `used_download`/`max_download`/`allowed_download`, `exp_date`, `allowed_output_formats=['m3u8']`, `status`), and **`XtreamApiController::userInfo()` now delegates to it** so the manager and the live API can never drift. `app/Http/Controllers/IptvXtreamUserController.php` (`index`/`show`/`raw`/`kick`/`kickAll`/`ban`/`unban`) projects `iptv_users` + `iptv_sessions` + `iptv_user_devices` with 5 summary cards and search/status/package/online/offline/at-cap filters; views `iptv/xtream-users/{index,show}.blade.php`; 7 `iptv-xtream-users.*` routes under `can:view_iptv_users`/`can:edit_iptv_users` + `iptv.expire`; nav link in both admin nav locations. No migration and no new permissions - it reuses `view_iptv_users`/`edit_iptv_users`.
  - **Reset password**: the detail page gained a `Reset Xtream Password` accordion that posts to the **existing** `iptv-users.reset-password` action (so it keeps the `ActivityLog` + `iptv.user.password_reset` SMS notify), gated by `@can('edit_iptv_users')`.
  - **A real pre-existing security bug found and fixed**: `is_active=0` (admin "Disable", and the new Ban) **never blocked Xtream access**. `XtreamApiController::authenticate()` only checked username+password, and `streamAccess()`, `relay()` and `transcodeSegment()` each **re-implemented** the credential check inline (only `is_expired` was checked, never `is_active`). So a banned/disabled account kept authenticating and streaming in TiviMate/Smarters/IBO. Fixed with an `! $user->is_active` guard at **all four** chokepoints. Live-proven with `/live` on a real channel: active **302**, banned **401**; `player_api.php` `auth=1` -> `auth=0`; `get.php` **200 -> 401**; wrong password still `auth=0`; unban restores all of it.
  - **Permission enforcement proven** with a throwaway role (only `view_iptv_users`) + user: index/detail **200**, raw JSON **403**, kick/kick-all/ban/unban all **403**, every action button and the raw link hidden, and nothing written (`is_active` unchanged, no new live sessions) - role + user deleted.
  - **Live-verified**: 5 summary cards, nav link, detail page (account/devices/recent sessions/payload), manager badge agreeing with the **real** `player_api.php` `active_cons` after the API itself registered a connection, per-account kick (`N session(s), M device(s) deactivated` + badge -> `0/2`), server-wide kick-all, ban/unban (second ban correctly refuses: "already banned"), all 6 filters + empty-state, raw payload, and the reset form actually changing the password so the **old** password stops authenticating and the **new** one works.
  - **Two count semantics worth knowing**: the live badge counts only `expires_at > now()`, while kick deliberately deactivates **every** `is_active=1` row (stale included) and reports the true row counts - a stale session shows as `Ended` in the table, so the two numbers differ by design. Each `player_api.php` call after a kick creates a fresh device row, because `registerConnection()` collapses same-IP devices only while a session is still active.
  - **Data-loss incident spotted and left untouched for the user**: `activity_logs` 375-388 (18:37-18:39, *not* from my tests) record `iptv.clear-all` (**58 channels removed**), a re-import of **58 channels from `Playlist__5.m3u`** (all group-title **`Afsomalia`**), **9 `iptv_categories` deleted** (channels moved to `Uncategorized`), and **`hamse` + `amla` deleted**. Current live state: 58 channels, **1** category row (`Uncategorized`), **0** users. Only `database.sql` exists and it was dumped at 21:34 - i.e. *after* those events - so **there is no backup of the previous 58 channels/9 categories/users**. I removed only my own artifacts (user 58 + 8 sessions + 3 devices + logs 389-403) and left 375-388 alone; **ask the user whether 375-388 was their own deliberate playlist replacement before touching it.**
  - Test state restored: `iptv_users` 0, `iptv_sessions` 0 (0 orphans), `iptv_user_devices` 0 (0 orphans), `iptv_packages` 1, `iptv_channels` 58, `iptv_categories` 1, `activity_logs` 250 (max id 388), `xtmreadonly` role/user gone, `database.sql` regenerated (706,631 B, zero test rows). `php -l` + `view:cache` clean. **NOTE: `git` is NOT installed on this host**, so there is no diff/status review available.
- **IPTV: Import Channels from M3U file upload - DONE, live-verified, cleaned up** (2026-10-05): new `IptvController::importFromM3uFile()` + route `POST iptv/import-m3u-file` (`iptv.import-m3u-file`, `can:add_iptv`), plus a 4th block "M3U File Upload" in the existing **Import Channels from Source** card on `resources/views/iptv/index.blade.php` (`enctype="multipart/form-data"`, `.m3u/.m3u8/.txt` accept filter). It reads the upload with `file_get_contents($file->getRealPath())` (never `Storage::put`, so **no file is persisted** - there is nothing to orphan or clean up) and hands the text to the **existing shared `parseM3uBody()`**, so file imports get exactly the same `#EXTINF`/tvg-logo/group-title parsing, `cleanImportText()` encoding repair, `detectContentType()`/`detectStreamType()` detection and `stream_url` dedup as the URL/FG importers.
  - `parseM3uBody()` gained an **optional third `$options` param** (`category`, `package_id`) with defaults, so the URL/FG/Xtream callers are unchanged; when both are absent it now writes `package_id => null` explicitly, which equals the column default. **Regression-proven** by serving a throwaway M3U from `public/` and importing it through the real `iptv.import-m3u` URL route over `http://127.0.0.1/simcard/public/...` - a network-free way to exercise the fetch path (see the harness note below).
  - Two optional controls: **Force one category** (overrides every `group-title` in the file, handy when a provider ships one junk category) and **Assign to package** (sets `package_id` on every created channel, so a bulk import lands in a subscription package immediately). `index()` now passes `$packages`.
  - Guards, all live-proven with distinct messages: extension not in `m3u/m3u8/txt` -> "Unsupported file type"; empty file -> "The uploaded playlist file is empty"; no `#EXTM3U`/`#EXTINF`/`#EXTVLCOPT` -> "That file is not an M3U playlist"; missing file and non-existent `m3u_assign_package` are validation errors with custom messages (`m3u_file.required`, `m3u_assign_package.exists`); BOM stripped like the URL path. Max upload 20MB.
  - Live-verified: page renders the card/form/select/multipart, a 3-channel mixed live/movie/series file -> **3 created** (correct `content_type` + `stream_type` + `category`), an immediate re-upload -> **0 created / 3 already present**, a UTF-8-**BOM** + no-`tvg-name` file -> 2 created with the comma-fallback name, forced category + package 1 -> both applied and noted in `notes`, all 4 guards, and the URL importer still working. Test channels (5) + 4 `activity_logs` removed; baseline back to 26 channels / all `package_id` NULL / 1 package; `php -l` + `view:cache` clean; `database.sql` regenerated (698,906 B, no test rows).
- **IPTV licence self-service acceptance - DONE, live-verified 56/56, cleaned up** (2026-10-05): migration `2026_10_05_000001_add_public_acceptance_to_iptv_licenses_table.php` adds `public_token` (unique, nullable, 64 chars), `accepted_at`, `accepted_by_name`, `accepted_by_ip`, `accept_count`, `token_revoked_at` to `iptv_licenses` + an `iptv_license_acceptances` audit table (name/email/phone/IP/user-agent/agreed/timestamps). Model `IptvLicenseAcceptance`; `IptvLicense` gains `issueToken()`/`revokeToken()`/`hasToken()`/`isAccepted()`/`acceptances()`. `IptvLicenseController` adds `publicShow()` + `publicAccept()` (guest, deliberately outside `auth` and outside the `iptv.expire` group) and admin `issueLink()`/`revokeLink()`/`acceptances()`. Routes `iptv-licence.show|accept` (`/iptv/licence/{token}` with a `[A-Za-z0-9]{64}` constraint, accept throttled `20,1`) + `iptv-licenses.issue-link|revoke-link|acceptances` (the first two gated `can:execute_iptv_licenses`). New standalone view `resources/views/iptv/license-agreement.blade.php` (Bootstrap 5 CDN, glass/dark, embeds or links the uploaded agreement, warns when none is attached) + `licenses.blade.php` gains accepted/awaiting badges, an issue/revoke link control with copy, an acceptance-history modal, an `acceptance=` filter and 2 summary cards. **User's explicit decisions**: acceptance is **record-only and never a playback gate**, access is by **secret token URL only** (no portal login, the token *is* the credential), and accepting only records the event + reveals the streaming link - it never activates or applies the licence. Because `iptv_users.password` is bcrypt-hashed, the page cannot show the password; the form instead offers an **optional** streaming password that the token holder may set, and this is deliberately visible in the UI as "Leave blank to keep your current password".
  - **Live-verified end to end** with a disposable `lictest` user + `LIC-20261005-DMV4V` and a throwaway package: guest page 200 with licence/subscriber details and **no credentials**, short + unknown 64-char tokens 404, unticked checkbox rejected with the custom message and **no** row written, real accept stamping `accepted_at`/name/IP/`accept_count` + audit row (email, phone, user-agent) + password change, credentials then revealed, `player_api.php` `auth:1`, `get.php` 200 with 2 `#EXTINF` and a stream URL resolving 302, old password 401 and blank password 401, admin badge/public-URL/modal/summary cards/JSON all correct, both acceptance filters 200, a **second** acceptance producing 2 rows + `count=2` + latest-name-wins while a blank password field leaves the password untouched, revoke nulling the token + stamping `token_revoked_at` while **preserving** history and 404ing the link, and a `cancelled` licence returning **410**.
  - **Permission enforcement proven** with a throwaway `licreadonly` role (only `view_iptv_licenses`) + user: list page 200 but no Issue/Revoke buttons rendered, `POST issue-link` and `POST revoke-link` both **403**, the `acceptances` JSON still readable, and the public token URL still open to a guest - then role + user deleted.
  - **Migration round-trip proven**: `migrate:rollback --step=1` dropped the columns and table, `migrate` recreated them (status `[189] Ran`), so `dropUnique(['public_token'])` is valid.
  - **Three real bugs found and fixed during verification**: (1) `'logoUrl' => $logo = Setting::get('logo') ? storage_url($logo) : null` threw `Undefined variable $logo` and 500'd the public page (PHP evaluates the ternary body before the assignment); (2) the custom `agree.accepted` message never fired because `required` matches first on an absent checkbox - added `agree.required`; (3) the view queried the DB inline and **showed the credentials before acceptance** - both corrected (`publicShow()` now passes `company`/`logoUrl` and `stream => null` until accepted).
  - **Harness lessons** (each cost a cycle, recorded in Important Details): `php -r` cannot take multi-line PHP (34 phantom failures before switching to temp scripts); a failed assertion in the *middle* of a suite cascades because later probes depend on the missing token; and a `cancel-guard` step left the licence `cancelled`, which correctly 410'd every subsequent GET so ~29 later checks failed for one stale-fixture reason - run destructive/terminal-state probes **last**.
  - **Test state restored to baseline**: 26 channels with all `package_id` back to NULL, 1 package, users back to `hamse`/`amla` only (`max(user_id)=41`), 0 licences, 0 acceptance rows, 0 orphans in `iptv_sessions`/`iptv_user_devices`/`iptv_favorites`/`iptv_payments`, the 10 pre-existing `iptv_payments` untouched, 235 `activity_logs` (matches the SIP baseline), `role/user licreadonly` gone, all temp scripts/jars removed, `php -l` + `view:cache` OK, `/iptv/licenses` (all 4 filters) `/dashboard` `/iptv` 200, and `database.sql` regenerated (698,762 B, contains the new table/column and none of the test rows).
- **IPTV billing module - DONE, live-verified** (2026-10-03/04): migration `2026_10_03_000002_create_iptv_invoice_tables.php` adds `iptv_invoices` + `iptv_invoice_items`, adds `iptv_invoice_id`/`reseller_id`/`sim_id`/`refunded_at`/`refunded_amount` to `iptv_payments`, forces `iptv_payments.paid_at` to an immutable `CURRENT_TIMESTAMP` default (so a refund can't shift revenue-by-day retroactively), seeds the 3 `iptv.invoice.*` templates and the 4 billing `isp_settings` defaults. Models `IptvInvoice` (totals/discount/balance/status + `daysUntilDue()`/`daysOverdue()`/`isOverdue()`/`recalculate()`/`nextNumber()`), `IptvInvoiceItem`, `IptvPayment`. `IptvBillingController` = 14 `iptv.billing.*` routes (index/create/store/show/**update**/**cancel**/**destroy**/print/payments/standalone-payment/refunds + 2 CSV exports); wallet + reseller-credit charging goes through the existing `IspWalletService`. **Live-verified**: create (subtotal 25.00 - 10% discount 2.50 = total 22.50, 2 line items, configured due date), `apply_to_user` extending a subscription, partial payments, cumulative partial refunds (`$4` then `$6` on one payment), full-refund timestamp, overpayment and over-refund guards, cash, **SIM-wallet** (`12130 -> 12120 -> 12130` with matching `transactions` rows) and **reseller-credit** (`100 -> 90 -> 100` with matching `iptv_reseller_transactions` rows) round-trips, true **standalone** payment (explicit user+package, no invoice), cancel of an unpaid invoice, delete with cascade and no orphan items, and both guards (delete refused when payments exist / cancel refused when paid). `IptvInvoiceCycle` and `IptvInvoiceReminders` proven by **forcing a real user into the window**: generation → 2nd run `1 already billed` (idempotent) → `iptv_recurring_billing=0` short-circuits → backdated invoice flipped `pending`→`overdue` with `daysOverdue=3` → reminder sent, same-day re-run deduped, and at 8 days overdue a **new** weekly-milestone notice fired (`reminder_count` 1→2) → due-soon notice sent for a 2-day-due invoice → master switch `iptv_auto_notify_invoices=0` short-circuits. **`iptv_invoice_due_days`/`currency` now have real form inputs** and round-trip (saved `14`/`EUR` → the create form rendered `due_date=2026-10-18` + `currency=EUR`). **Bugs fixed**: (1) a 6th undefined-route call `route('iptv.billing')` in `destroy()` caused a **500 after the row was already deleted** (fixed to `iptv.billing.index`); (2) optional invoice fields used `$data['x']` without `??`, and `due_date` was hardcoded to `+7 days`/`USD` instead of the configured values. **Footgun re-confirmed**: `IptvSettingController::update()` rewrites `is_active` for every template whose body is posted, so a *partial* settings POST silently blanks all 14 IPTV template bodies - always post the whole form. Replay-restored them from the migrations' own seed arrays (both use `updateOrInsert`, so `up()` is idempotent). Test state restored to baseline: users back to `amla,hamse` (`hamse.expiry_date` 2026-10-15, both phones NULL), 0 invoices/items, the 10 pre-existing `auto` payments untouched, `sims`#12 wallet 12130.00, reseller#1 credit 0.00, 0 billing activity logs, 14/14 templates active with original bodies; all temp scripts removed; `database.sql` regenerated (856,427 B).
- **TiviMate-style portal player redesign - DONE, geometry-verified across 7 viewports** (2026-09-25): `resources/views/iptv/portal-player.blade.php` is now a 3-pane layout - vertical **category rail** + selected-category **channel pane** + player. The rail carries 5 pinned content tabs (All Channels / Live TV / Movies / Series / Favorites) then all 9 categories, each with a live count; the pane keeps the exact existing `#channelList` / `.ch-item` / `.cat-group` hooks plus new `data-stype` and a current-EPG line per row. Pinned tabs filter to their content type (All 396 / Live **317** - the Live badge was wrongly `0` and is fixed / Movies 79 / Series 0) and show one group per category; selecting a category shows only that category with **no** redundant group header. Search spans the whole library, clears a pinned category, and next/previous now walks only *visible* channels and keeps the rail in sync. Empty groups collapse to `h=0` (no stray headers), favourites tab + count sync, and the rail is D-pad navigable in TV mode. Responsive: sidebar `462px`/rail `162px` at 1600-1920, `392/142` <=1400, `326/120` (counts hidden) <=1200, and at <=900px the rail becomes a **wrapping horizontal strip** above the pane with `--player-sidebar:min(270px,44vw)`. Verified with Puppeteer/Edge: 396 rows/9 groups initially, 26 and 65 rows for two categories, 139 search hits across 5 groups, category-scoped next/prev, favourite add+badge, 59/59 `getElementById` targets present, both inline scripts `node --check` clean, `php -l` clean, `view:cache` OK, portal login + manifest.json 200.
  - **Narrow-viewport overflow fixed** (2 pre-existing bugs the rail work exposed, both `position`-independent): (1) the player's control bar needed ~660px inside a 219px player at 430px viewport, so the page scrolled sideways - `#playerWrap` is now `container-type:inline-size` with `@container (max-width:620px)` hiding `.vol-slider`/`#timeLabel`/`#btnPiP` and `@container (max-width:430px)` hiding `#btnPrev`/`#btnNext`/`#btnInfo`. Keying on the **player's** width (not the viewport) means a phone in landscape fullscreen keeps every control, and browsers without container-query support just show everything as before. (2) the topbar overflowed at <=430px (brand 215px + chips + 3 buttons = 473px) - `.user-chips` got `min-width:0;flex:1 1 auto`, a new `hide-xs` chip class hides the data-usage chip <=560px, and the brand drops its tagline + padding shrinks. Result: **`scrollWidth == innerWidth` at 430/380/768/900/1100/1366/1600/1920** (was 464 at 430/380). Confirmed `contain:layout` is safe here: the two `position:fixed` elements (`.help-overlay`, `.player-ticker`) are DOM siblings of `#playerWrap`, not descendants.
- **Transcoder idle-reaper fix - DONE, live-verified** (2026-09-25): two compounding bugs meant **abandoned ffmpeg encoders were never reaped**. (1) `TranscodeService::cleanup()` computed `$activity = max($viewer, $lastSeg)` - but a live encoder keeps rolling segments forever, so `$activity` was always "now" and `(time()-$activity) > idle_seconds` could **never** be true, no matter how long ago the last viewer was. Fixed to `$activity = $viewer > 0 ? $viewer : $lastSeg` (segment age is only a fallback for a session that has no viewer file yet). (2) Nothing ever *called* `cleanup()`: `php artisan schedule:run` is not driven on this host (no Task Scheduler entry), so `iptv:transcode-cleanup` had never run. New `TranscodeService::reapIdleSessions()` runs opportunistically from `ensureSession()` - throttled to one pass per `iptv_transcode_idle_seconds`, guarded by an `flock` on `.reap.lock` + a `.reap.stamp` mtime, early-returning when there are no session dirs. Safe for live watchers because `touchViewer()` already runs on every segment request (`XtreamApiController:813`, portal play/segment hooks), so an actively-pulled session always has a fresh `lastviewer`. Live proof: 3 encoders had been running since 21:18 with **no viewer since 21:18** (90 min of wasted CPU on a 4-core box); `artisan iptv:transcode-cleanup` reaped the 2 genuinely abandoned ones and **correctly kept** the third (a real Chrome user on `localhost` was pulling `play/148354/hd/seg00298.ts` at that moment, `lastviewer` 5 min old). `php -l` clean. Access log also confirms **real users are successfully playing through the redesigned page** (`play/*.m3u8` 200 incl. 72-byte warm-up playlists, and `relay/*` 200 with 0.9-11.5 MB bodies, Chrome 155 from `localhost/simcard/public/iptv/portal/player`).
- **`iptv:stream-session-cleanup` run by hand - DONE** (2026-09-25): the admin Live Streams page was showing **18 phantom "live viewers"** because the 5-minute reaper had never run. Manual run stopped 17 stale + purged 9 old rows; the 1 remaining `is_active=1` row (user 41, channel 148363, portal) is a **genuinely live viewer** - its `last_active_at` looked 3h stale only because PHP writes UTC while MySQL's clock is `+03:00`.
- **Test state restored to baseline**: disposable portal user `railtest` (id 55) and its 15 `iptv_sessions` / 2 `iptv_user_devices` / 0 favourites deleted; `max(iptv_sessions.id)` back to **97**, `max(iptv_user_devices.id)` back to **93**, users back to `amla,hamse`, 396 active channels, `iptv_epg_cache` 0 rows. All temp scripts/screenshots/cookie jars/`node_modules` removed from `C:\Users\abdir\AppData\Local\Temp\opencode` (only the pre-existing `ff`, `mysql_data_backup`, `mysql_init` dirs left). `database.sql` regenerated (830,106 B).
- **Live state corrected in this session**: `iptv_transcode_variants` is **`'3'`** (not 1 - the user raised it back; 3 ffmpeg each emit low+med+hd from one decode, so cap 3 means 9 output variants), `iptv_transcode_max_processes='3'`, `iptv_transcode_idle_seconds='300'`, `iptv_transcode_enabled='1'`, `iptv_default_quality='auto'`, `iptv_low_internet_mode='1'`, `iptv_show_quality_indicator='1'`, `iptv_stream_session_enabled='1'`. EPG table is **`iptv_epg_cache`** (there is no `iptv_epg`). Also present: `stream_events`, `stream_processes`, `stream_sources`, `stream_viewer_log`.
- **MariaDB recovery (`mysql.db` corrupt) + portal player 512 MB fatal fixed - DONE, verified** (2026-09-25): the whole stack was down after a **machine reboot at 18:39** (MySQL/Apache are NOT Windows services - both must be started manually: `Start-Process "C:\xampp\mysql\bin\mysqld.exe" -ArgumentList '--defaults-file=C:\xampp\mysql\bin\my.ini'` and `Start-Process "C:\xampp\apache\bin\httpd.exe"`). Normal MySQL start then failed with **`[ERROR] Fatal error: Can't open and lock privilege tables`** / `Incorrect file format 'db'`; `CHECK TABLE mysql.db` and `REPAIR TABLE mysql.db` both returned `Corrupt`. Root cause: `mysql/db.MAI` had ballooned to **2,162,688 bytes** (a healthy Aria index is 16-24 KB). Deleting it alone does NOT work (server aborts with `Can't find file: '.\mysql\db.MAI'`). Fix: restored the healthy trio `db.frm` + `db.MAD` + `db.MAI` (24,576 B) from **`C:\xampp\mysql\data_broken_2026-08-30\mysql\`** (the Aug-25/28 pre-break copies, self-consistent; live `db.frm` MD5 differed so the whole set had to come from one source), then started **without** `--skip-grant-tables`. Result: MariaDB 10.4.32 up, `OPTIMIZE TABLE mysql.db` + `CHECK TABLE mysql.db` = **OK**, 2 db grants + 5 `global_priv` rows intact, `root@localhost` empty-password auth works, all DBs present (`appmysite, faul, mysql, phpmyadmin, phpnuxbill, simcard, streamvault, test`), `simcard` intact (396 channels, 2 users, 0 EPG rows). Bad files archived **outside** the datadir at `C:\xampp\mysql\recovery_2026-09-25\` (`db.frm.bak_20260925`, `db.MAD.bak_20260925`, `db.MAI.corrupt_20260925`). NOTE: `C:\xampp\mysql\bin\my.ini` is the authoritative config; `C:\xampp\mysql\data\my.ini` is a 49-byte datadir-only stub and `C:\xampp\mysql\my.ini` does NOT exist - passing a non-existent `--defaults-file` makes mysqld ignore the real config. Also note a healthy MariaDB `db` table will still start even if `REPAIR` is refused, as long as the three files are self-consistent.
  - **Portal player fatal** `resources/views/iptv/portal-player.blade.php:383` `Allowed memory size of 536870912 bytes exhausted`: the view ran **2 unbounded EPG queries** - `IptvEpg::upcoming()->whereIn('channel_id', $epgChannelIds)->get()` loaded the **entire future guide** as full Eloquent models. Reproduced exactly: seeded a 30-day guide (396 channels x 960 slots = **380,160 rows**) and the old code died with the same `536870912` error inside `Illuminate\Database\Connection.php:427`. 7-day guide (88,704 rows) already peaked at **178 MB**. Fix: new `IptvPortalController::buildPortalEpg(array $channelIds)` (bounded to the next **12 h**, hard `limit(5000)`, 3-column projection, first-per-channel) and the DB access was **removed from the Blade view** (views must not query). Measured on 88,704 rows: old 5.47 s / 178 MB -> new 2.12 s / **32 MB**; on 380,160 rows: old FATAL -> new 3.99 s / **32 MB**; identical output (all 396 channels with now/next). Line 383 itself was only a trivial `strtoupper(substr($channel->content_type,0,4))` badge inside the channel loop - the reported line is just where PHP happened to die, and the 22 MB single allocation matched `json_encode()` of the oversized EPG map.
  - **N+1 in the same page**: `->filter(fn ($c) => !IptvCategory::isHidden(...))` called `IptvCategory::hiddenNames()` **once per channel** = **398 queries** per portal load. Now hoisted (`$hiddenCategories` fetched once, `in_array` in the closure) = **2 queries**. Live player page: **4.12 s -> 1.64 s** (empty EPG), and 200/674 KB in 4.3 s even with the 380k-row guide. Verified live end-to-end: portal login 302 -> player 200, EPG JSON present (66 KB), `player_api.php` `auth:1` (`active_cons` 1 / `max_connections` 5). `php -l` clean (controller + blade), `view:cache` OK. Test EPG truncated back to 0 rows, temp scripts/outputs removed, `database.sql` regenerated (828,377 B).

- **Transcode capacity tuning + graceful cap - DONE, live-tested** (2026-09-17): user asked to drop `iptv_transcode_variants` 2→1 (keep `iptv_transcode_max_processes=3`) to favor more concurrent channels at low CPU, and to make the cap graceful (no buffering/failure for existing streams). Changes in `app/Services/TranscodeService.php`: (1) **reliable process cap** — `ensureSession()` now serializes the spawn decision with a `flock` on `storage/app/transcode/.spawn.lock` (re-checks `runningPid`/cap/eviction under the lock, releases before the 45 s wait loop), so simultaneous cold starts can no longer all slip past the cap (previously 6 concurrent cold starts spawned 6 ffmpeg despite cap 3). (2) **fast path for cap-blocked viewers** — `playlist()`/`portalPlaylist()` return `warmupPlaylist()` immediately when the channel has no session and `atCapacity()` is true, without touching running encoders or blocking on the lock. (3) new `atCapacity()` helper. **Live results (variants=1, cap=3, 4-core/8-thread i7-6700)**: N=4 → 3 REAL-HLS + 1 warm-up, FFmpeg=3, CPU max 68%/avg 43%, free RAM min 9317 MB, ffmpeg RAM 391 MB, warm playlist ~0.85 s, segment fetch ~0.38 s; N=5 → 3 REAL + 2 warm-up, CPU max 73%/avg 53%; N=6 → 3 REAL + 3 warm-up, CPU max 100% (cold-start spike)/avg 64%. Steady-state with 3 channels encoding = CPU avg 43%/max 77%, ffmpeg ~390 MB total (each ffmpeg ~59 threads). **Cap behavior verified**: with 3 slots full, a 4th channel gets a valid 200 `application/vnd.apple.mpegurl` warm-up playlist in ~1.2 s (no 302), the running channels keep producing segments untouched (148351 2→4, 148353 4→5 over 8 s), and when a slot frees (killed one encoder) the 4th channel's next poll started real HLS (200, `#EXTINF`) within ~8.8 s. **No 302s at any concurrency.** Note: channel 148352 ("8K beIN SPORTS NEWS") produces ~8 s segments (long source GOP) but stays roughly realtime with no ffmpeg errors. **Recommendation**: do NOT raise `max_processes` to 4-5 yet — 3 encoders already spike to ~100% on cold start and each ffmpeg spawns ~59 threads (oversubscribed on 4c/8t); if 4 is needed, also pin ffmpeg threads (`-threads`) to smooth CPU. Live setting changed: `iptv_transcode_variants` 2→1 (persisted); `max_processes=3`/`idle_seconds=300` unchanged. Test users `xtuser1..6` (49-54) + sessions/devices/stream_sessions/temp scripts cleaned; `php -l` clean; `database.sql` regenerated.

- **Xtream concurrent cold-start fallback fix ("mult users at once on different networks not working") - DONE, live-tested** (2026-09-17): user reported that multiple users watching at once (different networks) fail. **Reproduced**: killing all ffmpeg + clearing `storage/app/transcode`, then cold-starting 3 different channels concurrently via `/live/{user}/{pass}/{ch}.m3u8` → user 1 got 200 HLS (30.6 s), user 2 got **302 redirect to the raw provider `.ts`** after the 45 s warm-up deadline expired (45.9 s), user 3 got 200 HLS (10.9 s). Root cause: `TranscodeService::ensureSession()` hard-blocked up to 45 s waiting for the first segment and returned `false` on timeout; `XtreamApiController::streamAccess()` then fell through to `$isManifest && !preg_match('.m3u8')` → `redirect()->away($url)` (raw provider TS). Xtream apps that don't follow that 302 (or that need HLS) show "not working", and under concurrent load some channels always lost the warm-up race. Fixes in `app/Services/TranscodeService.php`: (1) **fast-start ffmpeg input flags** `-probesize 1000000 -analyzeduration 1000000` cut warm-up (cold start observed 8.4 s / 32.8 s / 13.8 s vs 30.6 / 45.9 / 10.9). (2) **warm-up HLS playlist** — new `warmupPlaylist()` returns a valid empty live media playlist (`#EXTM3U`/`#EXT-X-VERSION:3`/`#EXT-X-TARGETDURATION:3`/`#EXT-X-MEDIA-SEQUENCE:0`); `playlist()`/`portalPlaylist()` now serve it whenever `ensureSession()` didn't produce segments **but the ffmpeg process is still running** (`runningPid()`), instead of returning null → 302. Players poll and get the real playlist on the next reload. Also applied when a requested variant isn't ready yet but the session is running. (3) **idle-session eviction at the process cap** — new `evictIdleSession()` kills the least-recently-viewed session (by `lastviewer` mtime, only if idle ≥ `iptv_transcode_idle_seconds`) to free a slot so the newly-requested channel still starts, instead of failing. Live-verified after fix: 3 cold concurrent channels → all **200 `application/vnd.apple.mpegurl`** with proper adaptive master (no 302); 4 cold concurrent channels (this host is a 4-core/8-thread i7-6700, `iptv_transcode_max_processes=3`, `iptv_transcode_variants=2`) → all 4 HLS + 4 ffmpeg running; bad password still → 401 text/plain; `php -l` clean, `view:cache` OK. Test users `xtuser1..4` (45-48) + sessions/devices/temp files cleaned; no live settings changed (`max_processes=3`, `variants=2`, `idle_seconds=300` unchanged). `database.sql` regenerated.

- **Xtream active_cons inflation fix (real root cause of "only one user watching") - DONE, live-tested** (2026-09-17): user re-reported that multiple users cannot watch simultaneously on Xtream apps (sign-in + playlist OK, playback not). Root cause: `XtreamApiController::registerConnection()` keyed devices strictly on `IptvUserDevice::fingerprint(ip, userAgent)`, but ONE physical device presents MULTIPLE User-Agents to an Xtream server (e.g. `TiviMate/5.1.0 (Android TV)` for `player_api.php`/`get.php` and `ExoPlayerLib/2.19 (TiviMate)` for the media engine). Each UA became a separate `iptv_user_devices` row + active `iptv_sessions` row, so `active_cons` inflated (4+ for a single device) and Xtream apps self-block once `active_cons >= max_connections` → looked like "only a single user can watch at a time". Fix: `registerConnection()` now first reuses the most recent **active session for the same user + same client IP** (collapsing all UA variants of one device into ONE connection), falling back to fingerprint only when no same-IP active session exists. `active_cons`/`max_connections` already count `distinct('device_id')`, so one IP = one connection. Live-verified: fresh xtuser2 hit with `TiviMate` API UA then `ExoPlayerLib` media UA → **1 device row, `active_cons=1`** (previously 2+); two different users streaming different channels concurrently → both 200 `application/vnd.apple.mpegurl`, each `active_cons=1`; provider `tv.business-cloud-8.ru` confirmed to allow concurrent streams (2 different channels fetched in parallel → both 200 `video/mp2t`). Legacy duplicate device row (hamse device 68 = `ExoPlayerLib` UA from same IP as device 67 = `TiviMate` UA) removed; test users `xtuser1`(43)/`xtuser2`(44) + all test sessions/devices/temp files cleaned. `php -l` clean, `view:cache` OK; `database.sql` regenerated (827,711 B). Live users now only `hamse`(40)/`amla`(41), both `max_devices=5`.

- **Xtream multi-viewer fix - DONE, live-tested** (2026-09-16): user reported "Xtream apps: cannot watch all streams, only a single user at a time." Root cause: the device-cap eviction in `XtreamApiController::registerConnection()` (and the equivalent block in `IptvPortalController::login()`) bossily evicted the OLDEST **active** session — even one streaming right now — then admitted the new device. With `max_devices=1` on shared accounts (live user `amla` had 1), every poll from one app (TiviMate polls player_api/get.php frequently) killed the other viewer's session → flip-flop "single user watching only". Verified live: two devices on hamse at cap 1 → `active_cons=2`, `max_connections=1`, both sessions simultaneously `is_active=1`. Fix in both call sites: at the cap, evict ONLY **stale** sessions (expired OR `last_active_at` older than the connection TTL / portal session TTL); if all existing sessions are live watchers, **soft-admit** the new device without kicking anyone (`registerConnection` no longer returns `false` on the cap path; portal login no longer flashes "already in use on another device" — that branch is gone). Also bumped misconfigured low caps: live `amla` and `hamse` `max_devices` 1→5 (were below the 5 that all creation paths now default to). Live-verified end-to-end: 3 distinct device fingerprints (TiviMate / ExoPlayer / IPTV Smarters) on one hamse account → `active_cons` 1→2→3 all `auth:1`, re-polls of A and B after C confirmed nobody evicted; **two different users (hamse + amla) streamed the same channel 148351 simultaneously** → both 200 `application/vnd.apple.mpegurl` (transcode manifest served for both). Restored test state: hamse password hash restored to pre-test original (NOT `stream123456`), `max_devices=5`, `q=low`; amla (admin-created test account, no pre-dump hash available) password = `stream123456`, `max_devices=5`, `q=auto`; all curl-test sessions/devices removed; disposable user `relaytest` deleted. `php -l` clean, `view:cache` OK. No DB schema change (`database.sql` unchanged — not regenerated).

- **FG Code import (Drama Live-style) - DONE, live-tested** (2026-09-16): `IptvController::importFromFgCode()` (route `POST iptv/import-fg`, `iptv.import-fg`, `can:add_iptv`) + new form card on the IPTV index Import Sources section. Resolves a short code (`232425`) or `fgcode.org/<code>` link → `https://fgcode.org/<code>` (fetched with `withoutVerifying()` because fgcode serves a self-signed cert → cURL error 60 otherwise), follows the 301 to `fgcode.store/link/api.php/get?code=<code>`. Handles content shapes: raw M3U body, JSON-wrapped M3U via new `extractM3uFromJson()` (deep search of `data.url` JSON-string / `m3u` / `m3u8` / `playlist` / `url` / `link` / `data` / `content` keys), plus `resolveFgCode()` normalizing short code / `fgcode.org/<code>` / full URL / `?code=` query. Reuses the refactored `parseM3uBody()` shared helper (M3U + FG now share one parser). Clear error diagnostics incl. raw response preview. NOTE: demo code `232425` currently returns a **PHP fatal from fgcode's own server** (empty playlist → `bind_param()` error in FGDbHandler.php:805), surfaced cleanly as a non-M3U error; the empty-playlist case (`#EXTM3U` with no `#EXTINF`) is its own error message. Live-tested: form renders, invalid code → clean error with response preview, route 200, lint clean.

- **Stream Sessions (live viewer tracking) - DONE, live-tested** (2026-09-16): added a `stream_sessions` table + `StreamSessionService` (heartbeat rows per user+channel+source, only refreshed while a user actively requests playlists/segments) with three settings on the IPTV Settings page (`iptv_stream_session_enabled`/`iptv_stream_idle_minutes`=5/`iptv_stream_ttl_minutes`=30; defaults come from `config/iptv.php` + `.env`). **Audit result**: the reported "user A blocking user B" bug had no code cause — per-user device limits (`registerConnection`) and the shared per-channel transcoder are already concurrency-safe (transcode cap falls back to the original stream rather than blocking); this feature adds observability, not locking. Implementation: `StreamSessionService::touch()` upserts on `playStream`/`playSegment` (portal) and `streamAccess`/`transcodeSegment` (Xtream); portal heartbeat (`/iptv/portal/heartbeat`) now sends `channel_id`; `iptv:stream-session-cleanup` (every 5 min) deactivates rows idle > `idle_minutes` + purges > 24h. **Admin Live Streams page** `/iptv/sessions/live-streams` (`IptvSessionController@liveStreams`, `can:view_iptv_users`) — summary cards (Live viewers = distinct users, Active streams = rows, login sessions), per-channel/quality/source filters, table with user/channel/quality/source/started/last-active/status badges + Stop (route `iptv.sessions.stream.stop`, `can:edit_iptv_users`). **Stream Monitor** `/iptv/streams` gained a **Live viewers** summary card + per-channel viewer badge column (via `StreamSessionService::viewersForChannel()`). Live-tested end-to-end: portal login → heartbeat POST 200 (`{"ok":true}`) created portal row; Xtream `player_api` auth + `get.php` playlist; real stream access `/live/streamtest/stream123/148325.m3u8?q=low` (transcode manifest returned, `source=xtream`, `quality=low`); both rows visible on the Live Streams page with user/channel/quality + live badge; Stop deactivated the xtream row while portal stayed active; cleanup command ran ("Stopped 0 stale / purged 0"); Stream Monitor showed Live viewers card=1 + `bi bi-eye` badge 1 on the running session. Test rows cleaned, ffmpeg killed + `storage/app/transcode` emptied, `database.sql` regenerated (now includes `stream_sessions`).

- **Multi-user / multi-device concurrent streaming fix - DONE, live-tested** (2026-09-16): user reported "one user playing blocks all users; a new connection logs the previous user out." Root causes found + fixed: (1) **every user was created with `max_devices=1`** (`IptvUserController::store()` `(int)($request->max_devices ?? 1)`, `IptvPortalController::register()` + `IptvResellerPortalController` hardcoded `'max_devices' => 1`) — a single account playing on several Android players bounded to 1 concurrent device; **default raised to 5** in all three creation paths (admin add form default `value="5"` too) and all live existing users bumped 1→5 (now: amore/ahmed/620505555/620510067/cadar/hamse = 5, streamtest = 3). (2) **`registerConnection()` REFUSED at the cap** (`auth:0 "Max devices reached"` / portal error flash) — now when at the cap it **evicts the oldest active session** (lowest `last_active_at`, sets its session `is_active=false`+`expires_at=now` and deactivates its device) and admits the new one; returns `false` only when nothing is evictable. (3) **Portal `IptvUserDevice::updateOrCreate(['device_fingerprint' => ...])` was NOT scoped by `user_id`** — two users sharing IP+UA (`sha256(ip|ua)` fingerprint) reassigned each other's device row, so a second user's login flipped `device.user_id` and the first user's next `player()` page hit `$device->user_id !== $user->id` → **"Your session was ended by another device login" kick**. Fixed by keying `updateOrCreate` on `['device_fingerprint' => ..., 'user_id' => $user->id]`. Note: `orderByAsc()` does NOT exist in Eloquent — use `orderBy('last_active_at','asc')` (a stray `orderByAsc` caused a 500 on the eviction path, caught + fixed). Verified live via curl+Xtream `player_api.php` with distinct UA fingerprints: `amore` TiviMate + IPTV Smarters on 2 devices simultaneously (`active_cons=2`, `max_connections=5`), `streamtest` ExoPlayer playing alongside (`active_cons=1`), no kick, no false device collision; cap-1 eviction test admitted a 3rd device (IBO) by deactivating the oldest session (TiviMate) instead of refusing; portal login at cap-1 evicted the Smarters session and admitted the curl session (200, no error flash). Test passwords reset, test sessions/devices cleaned, `amore`/`streamtest` state restored (streamtest max_devices=3, quality low, data 0). No schema change; `database.sql` unchanged.
- **Adaptive transcode + monitoring + stream optimization - DONE, live-tested** (2026-09-16): reworked the low-bandwidth transcoder into a **shared per-channel multi-variant HLS encoder** with a server-generated adaptive master. Key pieces: (1) `TranscodeService` now supports `iptv_transcode_variants` 1-3 (default '1' = single low variant, CPU-safe, keeps legacy root layout `index.m3u8`+`seg%05d.ts`; 2-3 = adaptive `low|med|hd` subdirs from one decode + generated `master.m3u8`); quality profiles `iptv_transcode_bitrate[_med|_hd]`/`_height[_med|_hd]`/`_audio[_med|_hd]` (defaults 800/1200/2500k @480/720/1080p, 64/96/128k audio); HLS tunables `iptv_hls_segment_duration` (3s), `iptv_hls_list_size` (6), `iptv_hls_cache_ttl` (3600); robustness `iptv_transcode_idle_seconds` (300) + `_max_processes` (3) + `_reconnect_delay` (10) + `_reconnect_attempts` (5); ffmpeg gets `-reconnect 1/streamed/at_eof + delay_max + rw_timeout/timeout 15s + user_agent` input opts, `libx264 veryfast zerolatency`, `-hls_flags delete_segments+append_list`. (2) **auto/user qualities** (`original|auto|low|med|hd`): `resolveVariant()` maps auto→master (adaptive) else lowest; per-user `stream_quality` select in admin (now original/auto/low/med/hd); `?q=` override honored on Xtream `/live` and portal `/iptv/portal/play/<id>.m3u8`. (3) **Monitor page** `/iptv/streams` (`IptvStreamMonitorController`, nav under IPTV → Transcoder): per-channel cards w/ PID/segments/bytes/last-write/idle + log viewer (`?log=`), per-row Restart/Stop + Clear All; granted SuperAdmin. (4) Session lifecycle hardening: layout-version marker (`layout` file: `variants|segDuration|listSize|cacheTtl`) — config change kills+respaws stale session instead of probing wrong dirs; `hasPlayableSegments()` is now adaptive-aware (probes `low/index.m3u8` when adaptive, root otherwise); `buildArgs()` writes root layout for single-variant (fixes "no such file ...\med\seg00000.ts" and single-variant 302 fallback after variant restore). (5) **Portal player JS**: quality chip (`#btnQuality`+`#qLabel`) cycles auto/low/med/hd + indicator dot (`#qualityDot`, `LEVEL_SWITCHED` → green/yellow/orange + `ADAPT nnnp`), hls.js tunable buffer (8s/50s), `stallRatio()`-gated stall watchdog (`lastFrame` nudge ×2 → `retry('Network slow — retrying…')`), low-internet mode (`quietMode()`, product-select auto-quality, auto-stop when paused 3s+), online/offline handler + `online` reload, `visibilitychange` → reload-if-stalled, config events exposed on `window.IPtvPlayer` for debugging, `video.muted` state preserved across channel switches (no autoplay sound). (6) LOW INTERNET MODE (global switch `iptv_low_internet_mode`) + show-quality chip (default `iptv_show_quality_indicator='0'`) on the IPTV Settings page. Routes: GET/PUT `/iptv/notifications` is the **Settings page** (`iptv-settings.index/update`); `iptv.settings` POST `/iptv/settings` is the old CORS-proxy save — do not confuse. Live-verified end-to-end: adaptive master via `?q=auto` (low+med BANDWIDTH/RESOLUTION variants, no-CDN-master 302 loop), `low/index.m3u8`+`seg*.ts` served via both `/xtream/trans/...` and `/iptv/portal/play/...`, byte-counting (`data_used_mb` 0→2 on beIN SS), data-limit 401 on portal segment while playlist 302→login, monitor Restart/Stop/ClearAll/log 200, JS `node --check` clean, `php -l` clean, `view:cache` ok. Test state: `streamtest`/`stream123` (id 39, pkg 1 = 10MB limit) `stream_quality='low'`, `data_used_mb=0`; live settings restored to defaults (`iptv_transcode_variants=1`, `iptv_default_quality=auto`, indicator off); `database.sql` regenerated (settings rows `iptv_transcode_variants`, `iptv_default_quality`, `iptv_low_internet_mode`, `iptv_show_quality_indicator` persist).
- IP Pools router sync + status toggle: `IspIpPoolController::toggle()` now disables = remove pool from router, enables = re-push. Live-tested on pool id=2 (removed, restored); `database.sql` regenerated.
- Plan→pool conversion fully wired: route `POST isp.plans.convert-pool` (permission `execute_internet_plans`), pool dropdown in plans add/edit forms, per-plan pool badge + convert button. Live-tested on plan 4 → reused existing `2mb` pool (id=2) without duplicates; subscriber inherits pool via `ProvisioningService::createAndProvision()`. `database.sql` regenerated.
- Service Types→Plans conversion: route `POST isp.service-types.convert-plan` (permission `add_internet_plans`); `IspServiceTypeController::convertToPlan()` blocks duplicate plan names per type; plans badges row; modal with provider/pool/speeds/price/cycle/validity. Live-tested with `FTTH`→plan then cleaned; `database.sql` regenerated.
- ISP User Notification templates seeded via migration `2026_08_14_000034_seed_isp_user_notification_templates.php`: `isp.user.expired`, `isp.user.reminder_7`, `isp.user.reminder_3`, `isp.user.reminder_1`, `isp.user.invoice_payment`, `isp.user.balance_payment`, `isp.user.welcome` — exact user-provided texts + placeholder docs in `notification_templates` (category "ISP User Notification").
- ISP Settings → User Notification accordion edits template bodies + active switch; `IspSettingController::update()` writes `ntpl_body_<key with dots→underscores>` + `ntpl_active_<key>`. Live-tested save/toggle; `database.sql` regenerated.
- Auto-notify: `SmsHelper::personalize()` now replaces both `{token}` and `[[token]]`; new `App\Services\UserNotifyService` maps placeholders (`company_name`, `name`, `username`, `package`, `price`, `invoice`, `date`, `type`, `user_name`, `user_password`, `expired_date`, `url`, plus `Username`/`Password` capitalized for Welcome); sends SMS via `SmsService::send()`, logs `ActivityLog` (entity_type `subscriber_connection`). Hooks: `IspPaymentController::store()` → `isp.user.invoice_payment`; `IspInvoiceController::generate()` → `isp.user.invoice_payment`; `IspInvoiceController::markPaid()` → `isp.user.invoice_payment`. New settings toggles `isp_auto_notify_invoice`, `isp_auto_notify_payment` (default `'1'`, booleans saved in `update()`), "Auto-notify customer when invoice generated / payment recorded" switch UI. Live-tested with `sms_enabled=0`: both hooks fired "Auto-notify skipped: SMS sending failed (is SMS enabled in Settings?)", invoice `INV-2026-0002` created, logs verified, test data cleaned, `sms_enabled` restored to `1`, `database.sql` regenerated.
- **ISP Subscribers hotspot/PPPoE split - DONE**: migration `2026_08_14_000035_add_connection_type_to_subscriber_connections_table` adds nullable string `connection_type` (`pppoe`/`hotspot`, indexed) to `subscriber_connections`; `SubscriberConnection` fillable + `ProvisioningService::provision()` persists type when pushing PPPoE/hotspot users; `SubscriberConnectionController` has `connection_type` filter + `pppoe`/`hotspot` summary counts, store/update accept the field; `subscribers.blade.php` gained a type filter dropdown, type column with badges, and PPPoE/Hotspot count badges; create/edit forms have a Connection Type select; show page displays a type badge. Live-tested create `connection_type=hotspot` (filter isolation verified), cleaned test row; `database.sql` regenerated.
- **ISP payments via SIM wallet - DONE**: migration `2026_08_14_000036_add_sim_id_to_isp_payments_table` adds nullable `sim_id` FK (nullOnDelete) to `isp_payments`; `IspPayment` fillable + `sim()` relation; `IspPaymentController::store()` accepts method `wallet`, requires a SIM owned by the subscription's customer, deducts `amount` from `sims.wallet_balance` (+ `refreshTotalBalance`), logs a `Transaction` (`deduct`, desc "ISP subscription payment for <customer> (connection #N)") + `ActivityLog`, auto-sets reference `Wallet <sim_number>`; insufficient-balance and wrong-owner SIM both blocked with error flashes; `destroy()` refunds wallet via `Transaction` `topup` ("Refund of removed ISP wallet payment (connection #N)") when deleting a `wallet` payment. `payments.blade.php`: Wallet method option (form + filter), SIM picker (`#walletSimRow`) shown when method=wallet with JS filtering to the selected subscription's customer, SIM line under method badge in table; `index()` passes `$activeConnections` + `$sims`. Live-tested: create+deduct (150→50), insufficient, wrong-SIM, cash still works, delete→refund (50→150), test rows/transactions cleaned; `database.sql` regenerated.
- **MAC/IP Bindings full row actions - DONE**: `MikrotikService::arpBindings()` now prints ARP with explicit `.proplist` (`.id,address,mac-address,interface,dynamic,complete,disabled,comment`) so the `disabled` flag is returned (default print omits it); `arpAdd()` only re-uses an existing row when it's already static — dynamic entries cannot be `set` and are now shadowed by a new static `add` (was failing before); new `arpSetDisabled()` (`/ip/arp/disable|enable`). Routes `POST isp.bindings.make-static` + `isp.bindings.toggle`; `IspBindingController::makeStatic()`/`toggle()` with validation + `ActivityLog`; `bindings.blade.php` Action column now renders per-row buttons: dynamic → "Make Static" (locks the entry), static → Disable/Enable toggle + Remove, plus a `disabled` badge. Live-tested on router 5 dynamic entry 10.73.78.115: make-static → static badge, disable → disabled badge + enable button, enable, remove → back to dynamic; router restored, `database.sql` regenerated.
- **Full ISP upgrade - DONE**: (1) **Wallet service** `app/Services/IspWalletService.php` (`charge()`/`refund()` with ownership+balance checks, `Transaction` deduct/topup, `ActivityLog`); `IspPaymentController::store()/destroy()` delegate to it. (2) **Invoice wallet payments**: `IspInvoiceController::markPaid()` now `method in:cash,bank,mobile-money,card,wallet` + nullable `sim_id`; wallet charges amount via service, reference `Wallet <sim>`, blocks non-pending; invoice view has canonical method codes + wallet SIM picker (`.invoice-wallet-sim`, JS filters by `data-invoice-customer`). (3) **Invoice helpers**: `IspInvoice::nextNumber()` + `createInvoiceFor()` (period dedup → null) used by `generate()`/`generateAll()` and the billing cron. (4) **Reminder cron** migration `2026_08_14_000037_add_last_reminder_at_to_subscriber_connections_table` + `IspSendReminders` (`isp:send-reminders`, scheduled dailyAt 09:00): tiered reminder_1/3/7 by days-to-expiry (≤1/≤3/else) for active subs expiring ≤7d, per-day dedup via `last_reminder_at`, one-time `isp.user.expired` SMS (marker ActivityLog `Expired SMS%`); gated by `isp_auto_notify_reminders`. (5) **Billing cron** `IspBillingCycle` (`isp:billing-cycle`, dailyAt 00:10): recurring invoice generation (`isp_recurring_billing`), mark overdue (pending + due<today → overdue), auto-void (`isp_auto_void_days`, 0=off). (6) **Router user import**: `MikrotikService::pppoeSecrets()`/`hotspotUsers()` (proplists incl. `disabled`); `SubscriberConnectionController::importFromRouter()` (route `isp.subscribers.import-router`, `can:add_isp_subscribers`): type inference, dedup by router + LOWER(service_username), disabled→suspended, placeholder customers (`IMP-<routerId>-<md5>`), fee 0, +30d; import modal; `index()` passes `$routers`. (7) **Self-install**: `store()` reuses latest request row per phone; index `connection_type`/`status` filters + type badges. (8) **Reports**: `IspReportController::index()` from/to + method filters, payment-method breakdown, connection-type + per-router stats, wallet revenue, `revenue_in_range`/`payment_count`; `export()` → `ReportExportService::download` (CSV/XLSX) honoring filters; route `isp.reports.export`. (9) **Dashboard**: 6 stat cards (Connection Types, Outstanding Invoices), Router Health + Expiring-This-Week tables, Quick Actions (Generate Invoices POST form, Reports link). (10) **Settings** keys `isp_auto_notify_reminders`('1'), `isp_recurring_billing`('1'), `isp_auto_void_days`('0') + UI. (11) **Bug fix** `SubscriberConnection::daysRemaining()` sign bug (was negative for future dates; `today->diffInDays(expires_at, false)`), repairs "X days left" badges. Live-tested: reports export CSV, dashboard/invoices pages, invoice INV-2026-0001 paid via wallet (SIM12 130→110), payment delete→refund (110→130), router import (4 created incl. ppp1/kafaaani/default-trial/admin, 4000@2001 skipped), billing-cycle dedup, reminder skip path; test rows cleaned, `sms_enabled` restored, `database.sql` regenerated.

- **eSIM provisioning - DONE**: migration `2026_08_15_000001_add_esim_provisioning_fields_to_sims_table` adds `esim_smdp_address`, `esim_matching_id`, `esim_confirmation_code`, `esim_profile_status` (not_provisioned/available/provisioned/error, indexed), `esim_profile_id`, `esim_eid`, `esim_downloaded_at`, `esim_provision_error` to `sims`; `Sim` fillable + casts; new `app/Services/EsimService.php`: `activationPayload()` builds GSMA `LPA:1$smdp$matchingId$confirmation` + SVG QR data-URI, `provision()` does remote SM-DP+ (POST JSON to `esim_provision_url` with Bearer `esim_api_key`, parses smdp_address/matching_id/confirmation_code/profile_id; failure → `esim_profile_status=error` + `esim_provision_error`) when `esim_remote_enabled` else local; `qrDataUri()`, `parseLpa()`. `SimController::generateEsimQr()` now delegates to the service and `store()` persists all structured fields on eSIM create. New actions: `provisionEsim` (route `sims.provision-esim`), `downloadLpa` (`sims.download-lpa` → `.lpa` text download), `printQr` (`sims.print-qr` → printable install page), `sendActivationCode` (`sims.send-activation` → SMS the activation code to the assigned customer; guards: not-eSIM, no code, no customer phone, SMS disabled). Show page: eSIM profile status badge, SM-DP+/matching/confirmation rows, Provision/Print/LPA/SMS buttons. Settings: new `eSIM` tab (keys `esim_smdp` default `esim-provider.com`, `esim_remote_enabled`, `esim_provision_url`, `esim_api_key`, `esim_profile_name`). Per-SIM SM-DP+ override: `sims/create.blade.php` + `sims/edit.blade.php` gained an **SM-DP+ Address** field (`esim_smdp_address`); `SimController::store()`/`update()` pass it to `generateEsimQr($smdp)` (edit regenerates QR when the address changes), and `EsimService::provisionLocal()` prefers the SIM's own `esim_smdp_address` over the global setting. Global SM-DP+ change now re-provisions existing eSIMs: `SettingController::update()` detects an `esim_smdp` change and calls `EsimService::reprovisionGlobal($old, $new)` which regenerates activation+QR for eSIMs whose `esim_smdp_address` is null or equals the previous global value (manual overrides kept), flash notes "eSIM SIMs updated to SM-DP+ ...: N re-provisioned.". Live-tested (global → new value → 2 eSIMs updated → restored); global currently `https://esim.somteloss.com/`. Live-tested: eSIM create→QR+activation, local provision, `.lpa` download (`LPA:1$esim-provider.com$...`), print page, send-activation no-customer guard, remote SM-DP+ error path (`profile_status=error`), manual smdp (`smdp.test.com`) create + provision; test SIM + logs cleaned, settings restored, `database.sql` regenerated.

- **Subscriber create now auto-provisions (PPPoE works) - DONE**: `SubscriberConnectionController::store()` auto-generates service username/password (via `ProvisioningService::generateUsername` + `Str::random`) when blank, persists `ip_pool_id` from the plan, and when `connection_type` is set + status active calls `ProvisioningService::provision()` right after create (flash shows "PPPoE user synced on router" or a warning; failure logged). `update()` re-provisions when type/plan/router/username/password/static_ip change on an active connection. Create/edit forms gained an **IP Pool** select (`$pools` passed). PPPoE pool addressing fixed: `ProvisioningService::provision()` passes the plan's pool NAME as the **profile** `remote-address` via `MikrotikService::syncProfiles(..., $pppoeRemoteAddress)` (secrets only get a literal `static_ip`), because RouterOS secrets reject pool names. **PPPoE DNS fix**: clients got no DNS → "no internet"; profiles now also get `dns-server` via `syncProfiles(..., $pppoeDns)` from setting `isp_pppoe_dns` (default `8.8.8.8,8.8.4.4`), added to ISP settings Billing card. **LIVE VERIFIED end-to-end** on router 6: re-provision of conn 35 (`ppp1`/FTTA) → `ok=true`, secret pushed (profile FTTA, enabled), FTTA profile `remote-address=3MB` + `dns-server=8.8.8.8,8.8.4.4`; combined with existing PPPoE server on LAN-BRIDGE + NAT masquerade on ether1, a computer dialing the user now gets a 3MB-pool IP + DNS → internet. NOTE: plan 7 `download_speed=900000` is stored in Mbps (i.e. ~900G — data-entry issue, not code; form expects Mbps).

- **MikroTik Logs viewer - DONE**: `MikrotikService::logs($router, array $topics, int $limit)` reads `/log/print` (`=.proplist=time,topics,message`), filters by topic (comma-separated tokens), slices to `$limit`; `logTopics($rows)` collects distinct topics. `MikrotikRouterController::logs()` (route `isp.routers.logs`, GET `/isp/routers/logs`, `can:view_mikrotik_routers`) selects router/topic/limit and derives the topic dropdown from live logs. View `isp/router-logs.blade.php`: router + topic + entries filter bar, scrollable monospace table (Time/Topics badges/Message), error/warning topic badges, Refresh button. Nav link added under ISP → MikroTik Logs. Live-tested on router 6 (200, topics populated from real logs, topic=error filter selected). No DB changes; `database.sql` unchanged.

- **HSS/HLR Call History - DONE**: migration `2026_08_15_000002_create_call_history_table` adds `call_history` (profile_id/sim_id nullable FKs nullOnDelete, direction indexed, peer_number, is_international indexed, duration_seconds, started_at indexed, notes, created_by); `CallHistory` model (table `call_history`). `HssHlrController::callHistory()` (route `hss-hlr.call-history`, GET `/hss-hlr/call-history`, before the `{profile}` catch-all) supports 4 views via `?view=` (all-incoming / all-outgoing / incoming-international / outgoing-international) with search + from/to filters and summary counts; `storeCall()` (POST `hss-hlr.call-history.store`) records a CDR against a subscriber profile — **international auto-detected** when peer starts with `+` or `00` — + `ActivityLog`; `destroyCall()` (DELETE). View `hss-hlr/call-history.blade.php`: 4 stat-card tabs, Record Call form, filter bar, table (time/subscriber/direction/intl badge/duration hh:mm:ss/notes/delete). Nav link under HSS/HLR → Call History. Live-tested: recorded intl incoming (`+252...`) + local outgoing, all 4 views filter correctly, summary counts right; test rows cleaned; `database.sql` regenerated.

- **GSM Network Integration fixed - DONE**: `NetworkController::test()` rewritten (was unreliable `fsockopen`): `probe()` uses `stream_socket_client` TCP test for sigtran-map/diameter/none (default ports 2905/3868) and a real `Http::timeout(6)` GET probe for `http-api`; success sets `integration_status=connected` (+ `last_checked_at`), failure keeps `pending`. New `checkAll()` (route `networks.check-all`, POST, `can:execute_networks`) re-tests every network and reports reachable/unreachable counts. Migration `2026_08_15_000003_add_last_checked_at_to_networks_table` adds `last_checked_at`; index view gained a Check All button + Last Checked column (colspan 10). Live-tested: reachable (192.168.10.1:8728) → Connected + checked, unreachable (10.255.255.1:2905) → Pending + checked, Check All summary correct; test rows cleaned; `database.sql` regenerated.

- **SIMs: Add eSIM + Generate - DONE**: SIM index gained an **Add eSIM** button (`sims.create?type=esim`); `sims/create.blade.php` preselects eSIM from `?type=` (shows SM-DP+ row via existing JS); eSIM rows without an activation code show a per-row **Generate** button (POST `sims.regenerate-qr`) so the QR + activation code are produced immediately. Live-tested: button present, eSIM preselected, Generate produced `LPA:1$https://esim.somteloss.com/$...` + QR; test SIM cleaned; no DB changes (`database.sql` unchanged).

- **SIM Cards ↔ HSS/HLR cross-access - DONE**: bidirectional links. `sims/show.blade.php` gained an **HSS/HLR** row: shows a "View Profile" button + status badge when `$sim->hssHlrProfile` exists (loaded in `SimController::show()`), else "Not in HSS/HLR" + "Add to HSS/HLR" link (`hss-hlr.add-subscriber?search=sim_number`). HSS/HLR index + show now link the SIM number to `sims.show`. Live-tested (profile sim 12 → View Profile; sim 347 without profile → Add link; index/show SIM links); no DB changes (`database.sql` unchanged).

- **MikroTik Systems page - DONE**: `MikrotikRouterController::systems()` (route `isp.routers.systems`, GET `/isp/routers/systems`, `can:execute_mikrotik_routers`) reads `MikrotikService::resources()` (system/resource: version/board-name/cpu-load/free+total-memory/uptime) for every router and builds a per-router card. View `isp/router-systems.blade.php`: total/online/offline stat cards, per-router cards with RouterOS version, board, uptime, CPU-load progress bar (color-coded), memory free/total bar, subscriber count, links to Live Details + Logs; offline routers show the reason. Nav link under ISP → MikroTik Systems. Live-tested on router 6 (`smart`, version 7.21.3). NOTE: fixed a `No application encryption key` 500 by `php artisan config:clear` (stale cached config). No DB changes (`database.sql` unchanged).

- **MikroTik Users - DONE**: RouterOS `/user` management from the app. `MikrotikService::routerUsers()`/`routerGroups()` (proplists incl. `disabled`; `user` + `user/group` in legacySentenceFor), `addRouterUser()`, `setRouterUserDisabled()` (`/user/disable|enable`), `removeRouterUser()`, `setRouterUserPassword()`. `MikrotikRouterController::users()` (route `isp.routers.users`) + store/toggle/remove/password actions; view `isp/router-users.blade.php`: router selector, Add-user form (username/password/group from live groups/comment), table (username/group/address/comment/enabled+disabled badge), per-row Enable/Disable + Change-password (collapse) + Remove. Nav link under ISP → MikroTik Users. Live-tested on router 6 (`smart`): create `opencode-test` (group read) → verified on router, disable, enable, password change, remove → gone. NOTE: router id changed again to 12 during the session (user re-created it). No DB changes (`database.sql` unchanged).

- **Roles: separate HSS/HLR access - DONE**: The Roles UI (`roles/show.blade.php`) already groups permissions by category with per-group Select-All/Clear; HSS/HLR permissions (`view/add/edit/delete/execute_hss_hlr`, category `HSS/HLR`) now get a distinct **`bi-hdd-network` icon** (plus icons for Networks/ISP/My SMS/Banks/Data Packages/Airtime Vouchers/Communications) so the group is visually separate. Verified role separation end-to-end: a test role granted only the 5 HSS/HLR permissions + a test user → `/hss-hlr` + `/hss-hlr/call-history` = 200, but `/sims` + `/isp` = **403**, nav gated by the same `canAccess`; test user + role cleaned. No DB changes (`database.sql` unchanged).

- **MikroTik Routers: accept all ports - DONE**: router connections now work regardless of the configured port. `MikrotikService::probeEndpoint()` tries the standard MikroTik service endpoints (API 8728 plain, API-SSL 8729 TLS, REST 80, REST 443) and returns the first that answers; `check()` falls back to it when the configured endpoint fails with a transport error and **adopts + saves** the working port/protocol/TLS. Router add form gained a **Service preset** dropdown (REST HTTPS 443 / REST HTTP 80 / Legacy API 8728 / API-SSL 8729 / WinBox 8291) that auto-fills port + HTTPS via JS, plus a hint that the app speaks API/REST and WinBox (8291) is GUI-only. Live-tested: set the router to a bogus port/protocol → `check()` auto-found API 8728 and went online (port/protocol adopted). `database.sql` regenerated.

- **IPTV Management + Player - DONE**: migration `2026_08_15_000004_create_iptv_tables` adds `iptv_channels` (name, category indexed, logo, stream_url, stream_type hls/mp4/other, is_active indexed, sort_order, notes, created_by) + seeds 5 permissions (category `IPTV`: view/add/edit/delete/execute_iptv). `IptvChannel` model; `IptvController`: index (search/category/status filters, add + per-row edit collapse, toggle, delete, player link) + `player()` (active channels grouped by category; selected via `?channel=`). Views `iptv/index.blade.php` + `iptv/player.blade.php` (sidebar channel list + HTML5 player, **hls.js CDN** for `.m3u8`, native video for mp4). Player hardened: hls.js for all non-mp4 streams (incl. `.ts`/other), native fallback for Safari, visible on-screen error/status (`playerStatus`) for network/CORS/media failures, `crossorigin` on the video, autoplay-muted retry, and channels with no category now group under **Uncategorized** (previously they were hidden from the sidebar). **CORS proxy workaround**: `IptvController::saveSettings()` (route `iptv.settings`, `execute_iptv`) saves `Setting iptv_cors_proxy`; `player()` prefixes external stream URLs with it (so CORS-blocked HLS still plays) — Player Settings form on the index page. Live-tested: page 200, video + hls.js + status + crossorigin + Uncategorized group render, proxied `src` (`corsproxy.io/?url=...`). Routes `iptv.*` (GET `/iptv`, GET `/iptv/player`, POST/PUT/DELETE/toggle) gated by the new perms; top-level **IPTV nav link** (view_iptv). **Import sources**: `IptvController::importFromM3u()` (`POST iptv.import-m3u`) fetches an M3U playlist URL and parses `#EXTINF` (name/tvg-logo/group-title) into channels; `importFromXtream()` (`POST iptv.import-xtream`) calls `player_api.php?action=get_live_streams` on a server base + credentials and creates one channel per stream (uses `direct_source` or `live/user/pass/<id>.m3u8`; empty URL skipped); both dedup by stream_url and log ActivityLog; "Import Channels from Source" card (M3U + Xtream forms) on the index page. NOTE: large playlists can exceed 30s — import fetch timeouts raised to 120s (`Http::timeout(120)->connectTimeout(10)`) for both M3U and Xtream. **M3U 404/401 fallback**: when the M3U fetch fails (404/401/invalid content), `importFromM3u()` parses `username`/`password` from the URL and auto-falls back to the Xtream `player_api.php?action=get_live_streams` JSON import on the same base URL (path minus `get.php`); clear error hints ("providers often return 404 for invalid credentials") instead of a bare status. **Clear All** button (`POST iptv.clear-all`, `can:delete_iptv`, confirm dialog) empties the `iptv_channels` table + ActivityLog. Live-tested: page 200, channel create, player (video + hls.js + channel list), toggle → deactivated, M3U import (2 created, dedup on re-run), Xtream import (2 created incl. direct_source + generated .m3u8), 404-M3U → Xtream fallback (2 created + dedup), Clear All (user had 12,329 imported channels → cleared to 0 — that bulk import is a likely cause of earlier Xtream memory/timeout fatals); test channels/files cleaned; `database.sql` regenerated.

- **IPTV Users list shows device MACs - DONE**: `app/Http/Controllers/IptvUserController.php` index now eager-loads `devices` + `allowedMacs`; `resources/views/iptv/users.blade.php` gained a **MAC Address** column between Devices and Last Login — shows registered device MACs (stacked `<code>`, title "Registered device") plus whitelist MACs not already shown via a device (shield-check icon, `text-primary`); `colspan` bumped 9→10 for collapse rows + empty state. Live-verified (test user with device MAC AA:BB:CC:DD:EE:FF + whitelist 11:22:33:44:55:66 rendered; test rows cleaned). No DB changes (`database.sql` unchanged). NOTE: device MACs only exist after portal login with an `X-*-MAC`/`STB-MAC` header; no such devices exist yet in live DB.
- **IPTV device MAC capture on sign-in - DONE, live-tested**: `app/Http/Controllers/IptvPortalController.php` now records a device MAC even for ordinary device/browser logins. Three fixes: (1) `detectMac()` previously read `$request->header('HTTP_X_DEVICE_MAC')` which **never matched** — Laravel's HeaderBag normalizes `_`→`-`+lowercase so the real `X-Device-MAC`/`STB-MAC` sent header always returned null → device MAC was never captured. Added fallback `?: $request->server($header)` (raw `$_SERVER['HTTP_*']`) → header MACs now detected. (2) New `resolveMacFromArp(string $ip, MikrotikService $service)`: when no header is present, look up the client IP in each **online** router's ARP table (`MikrotikService::arpBindings()`, row `address === $ip` → `mac-address`) so a normal device/browser login captures the client's real LAN MAC; only routers with `api_status='online'` are queried so unreachable routers can't hang logins. (3) `IptvUserMac::normalizeMac()` used `chunk_split($clean,2,':')` which **appended a trailing colon** → `AA:BB:CC:DD:EE:FF:` (18 chars) overflowed `iptv_user_devices.mac_address` (varchar(17), SQLSTATE 1406 Data too long); now `substr(strtoupper(chunk_split(...)),0,17)` → canonical 17-char uppercase MAC. `login()` also keeps an already-recorded device MAC on a later headerless login (`$macToStore = $mac ? normalizeMac($mac) : ($existingDevice->mac_address ?? null)`). Live-tested with router offline (ARP path safely returns null → login 302): STB-MAC header login stored device mac `33:44:55:66:77:88`; same-fingerprint headerless relogin → 302 + MAC retained (devices count 1); remaining live DB user `test_signin` removed; test rows cleaned. NOTE: ARP fallback only fires while a router has `api_status='online'` (router 14 `smart` is currently unreachable). No DB changes (`database.sql` unchanged).
- **IPTV Packages Assign Select All/Clear All - DONE**: `resources/views/iptv/packages.blade.php` assign-channels collapse (`#assignPkg{id}`) gained a one-click toolbar — live "X / Y selected" badge (`.assign-count`) + **Select All** (`.assign-sel-all`) + **Clear All** (`.assign-clr-all`) buttons; wiring via `@push('scripts')` (`@once`) that auto-attaches change/click listeners + count to every `form[id^="assignPkg"]` with `input[name="channel_ids[]"]`. No controller/route/DB changes. Live-verified (toolbar + JS + checkboxes render, form ids match). `database.sql` unchanged.

- **Route tooling fix - DONE**: `routes/web.php` registered `remittance/branches` routes (`remittance.branches*`, controller `RemitBranchController`) but **missing the `use` import** → `php artisan route:list`/`route:cache` crashed with `Class "RemitBranchController" does not exist` (bare class-string route) and `/remittance/branches` would 500. Added `use App\Http\Controllers\RemitBranchController;`. `route:list` works again; data-usage routes verified.
- **SystemController Str import fix - DONE**: `app/Http/Controllers/SystemController.php` called `Str::limit()` (line ~311, `checkVersion()` yt-dlp log parser) but never imported `Illuminate\Support\Str`, so at runtime PHP resolved it to the nonexistent `App\Http\Controllers\Str` → `Class "App\Http\Controllers\Str" not found`. Added `use Illuminate\Support\Str;`. Audited all other `Str::` callers in `app/` (controllers/services/models/requests) — all have the import. `/system/version/check` now returns 302 instead of 500. No DB changes.
- **IPTV user Data Usage fixed - DONE, live-tested**: `data_used_mb` was unsettable/untracked (created at 0, no way to edit it, nothing incremented it) so the Data Usage column always showed "0 MB" and the pumped-meter feature was dead. Fixes: (1) `IptvUserController::index()` summary `data_exceeded` was comparing `data_used_mb >= data_used_mb` (column-to-itself, always true) — now real: active users eager-loaded `with('package')` + `filter(fn($u) => $u->package && $u->isDataExceeded())->count()`. (2) `users.blade.php` summary row now has a **Data Exceeded** warning card (was computed but never displayed; 4 cards→5, `col-md-3`→`col`). (3) `update()` accepts `data_used_mb` (nullable|integer|min:0) and the edit collapse gained a **Data used (MB)** field so admins can set usage. (4) New `resetData()` + route `iptv-users.reset-data` (POST, `can:edit_iptv_users`) + per-row **Reset Data** button (`bi-speedometer2`) → `data_used_mb=0` + ActivityLog. Portal block at `IptvPortalController.php:179` (`data_used_mb >= data_limit_mb`) now actually reachable via edit/reset. Live-tested: package 1 (limit 10) → user at 12MB showed Data Exceeded + card=1, edit PUT data_used_mb=15 persisted (status Data Exceeded), reset → 0/Active; test user cleaned. No DB changes (`database.sql` unchanged).
- **IPTV Payments + Expiries Reports - DONE, live-tested**: migration `2026_09_09_000001_create_iptv_payments_table.php` adds `iptv_payments` (user_id/package_id FKs nullOnDelete, amount decimal12,2, method auto/cash/mobile_money/wallet/card/bank/other indexed, unique reference `IPV-ymd-XXXXX`, paid_at, notes, created_by) + new `App\Models\IptvPayment` (relations user/package/creator + `METHODS` map + `nextReference()` + `methodLabel()`); `IptvUser::payments()` hasMany added. **Auto-recorded on create + renew**: `IptvUserController::store()` (amount=package price, method auto, notes "New subscription…") and `renew()` (amount=package price, notes "Renewal…") now write a payment row per transaction. Reports module: `ReportController::iptvPayments()`/`exportIptvPayments()` (filters search ref/user/customer/phone, method, package, from/to; summary Total/Received/Average) + `iptvExpires()`/`exportIptvExpires()` (filters search, package, expiry_status active/expired/soon(≤7d), from/to; summary Total/Expired/Expiring-7d/Data-Exceeded); routes `reports.iptv-payments(-.export)` / `reports.iptv-expires(-.export)` gated `can:view_iptv_users` (view) + `can:execute_reports` (export); views `reports/iptv-payments.blade.php` + `reports/iptv-expires.blade.php` (filter bar + summary cards + paginated table + `_export_buttons` CSV/XLSX). Reports index gained **IPTV Payments** (bi-credit-card) + **IPTV Expiries** (bi-calendar-x) cards. Live-tested: create→payment `IPV-20260909-MLLG5` amount 5.00 auto, renew→2nd payment, both report pages 200 with rows + summaries, CSV export filenames `iptv-payments-report.csv`/`iptv-expiry-report.csv`, index cards render; test user/payments cleaned; `database.sql` regenerated.
- **IPTV self-registration portal (SMS OTP verified) - DONE, live-tested**: users can create their own IPTV account on the public portal, **but must first verify their phone number with a 6-digit SMS OTP**. Routes (`iptv-portal.` group, guest-accessible): `register` (GET form), `register.post` (POST create), `register.otp` (POST `sendRegistrationOtp`, `throttle:5,1`). `IptvPortalController`: `registrationForm()` (redirects to player if already logged in); `sendRegistrationOtp()` (validates phone, blocks phones already on file, generates `random_int(100000,999999)`, stores in SESSION `iptv_registration_otp`/`_phone`/`_expires_at` (TTL 5 min const `OTP_TTL_MINUTES`), sends via `SmsService::send`, error if the SMS can't be sent, ActivityLog `notify`); `register()` now validates `otp required|size:6` and only creates the account when it matches the session code for the same phone AND is not expired (session keys forgotten on success; wrong/expired code → back with error, no account). Registration itself: name/phone required, email optional, package nullable (picks picked or cheapest active package), username unique nullable, password min:6 nullable confirmed, auto username from name slug + suffix, 10-char random password when blank, expiry = now + package `validity_days` (fallback 30), max_devices 1, auto `IptvPayment` method `auto`, ActivityLog, `iptv.user.welcome` SMS via `IptvNotifyService` when `iptv_auto_notify_welcome` + credentials flash once on the login redirect. View `iptv/portal-register.blade.php` (styled like portal login): package radio cards, optional username/password, **OTP box with a `Send Code` button that `formaction`s the whole form to `register.otp`** (only phone validated there), letter-spaced 6-digit OTP input, session success/error flashes; login page has a "Create Account" link. Live-tested: page 200, OTP send 302 + session OTP stored, wrong OTP rejected (no user), correct OTP → user `otptest` + payment `IPV-20260909-VZNDD` + welcome SMS "Customer notified by SMS"; test user/payment/logs + stale OTP session files cleaned. No DB schema changes (`database.sql` unchanged).
- **IPTV Settings: Player Portal cover logo - DONE, live-tested**: `IptvSettingController` page (nav already labels it "IPTV Settings") retitled from "IPTV Notification Settings" → **IPTV Settings** and gained a **Player Portal Appearance** card: Cover Logo file upload (`iptv_portal_logo`, `nullable|image|max:2048`, stored `settings/` public disk, validates + deletes old file on replace) + Remove checkbox (`iptv_portal_logo_remove` deletes the stored file and clears the setting). `edit()` passes `$portalLogo`; controller imports `Setting`, `Storage`. Form `enctype="multipart/form-data"`. Player top bar (`iptv/portal-player.blade.php`) renders the uploaded logo as a `.brand-logo` img (30px, `storage_url($logo)?v=md5` cache-busting) replacing the default TV icon when set (CSS `.topbar .brand .brand-logo`); falls back to `<i class="bi bi-tv"></i> Casri IPTV` otherwise. Live-tested: settings page renders card, upload via POST+`_method=PUT` multipart (REAL `-X PUT` multipart fails — PHP doesn't parse multipart fields/files for PUT → 419 CSRF + `hasFile=false`; always test with method spoofing like a browser) → setting saved + 604-byte PNG public URL 200; player login shows `brand-logo` img with the storage URL; remove → file deleted from disk + setting cleared + No-logo fallback; test user/temp files cleaned. No schema change (`database.sql` unchanged).
- **IPTV Portal Player redesign (2026-09-10) - DONE, live-verified**: full visual overhaul of `iptv/portal-player.blade.php` — **CSS-only redesign** (all element IDs/classes the JS hooks into are untouched). New premium glass/dark streaming aesthetic: ambient radial-gradient glows on the body (indigo/violet/pink), `--grad-accent` (indigo→violet→pink) accent system, frosted `backdrop-filter:blur` topbar with a gradient hairline, floating rounded glass sidebar (inset in `.layout` with gap/padding, 18px radius), search with glowing focus ring, segmented control tabs with gradient `active`, channel rows as rounded cards (34px framed logos, hover `translateX(3px)`, gradient `active` with inset accent bar + soft shadow), gradient count pills, gradient usage fill + health-dot glows, player with 16px radius + hover ring/glow + ambient stage `::before` glow, glass bottom control bar with gradient accent fullscreen button + progress fill w/ glow, gradient spinner/retry, blurred info panel + episodes/help glass cards, `fadeUp` entry animations, refined scrollbars, responsive tweak (sidebar 238px). Brand gained a tagline (`<small>TV · VOD</small>`). Live-verified: player 200 with 235 channel rows, all new CSS classes render, all JS hook IDs intact (`playerWrap`,`channelList`,`searchInput`,`infoPanel`,`episodesBox`,`adOverlay`…), old `--player-*` vars fully removed. Test user/temp files cleaned. No DB changes (`database.sql` unchanged).
- **IPTV portal login/register redesign (2026-09-10) - DONE, live-verified**: `iptv/portal-login.blade.php` + `iptv/portal-register.blade.php` redesigned to match the player's premium glass/dark streaming aesthetic. Shared design tokens (`--grad-accent` indigo→violet→pink, same radial ambient glows on the body), frosted `backdrop-filter:blur(20px)` cards with gradient top hairline + corner glow + `@keyframes fadeUp` entry, gradient cover-logo medallion (`@php $portalLogo = Setting::get('iptv_portal_logo')` — falls back to gradient `bi-tv` icon; uses `storage_url()` + `?v=` cache-bust), brand tagline "Live TV · Movies · Series", glass floating floating inputs with gradient focus rings and accent icons, gradient CTA buttons with glow, gradient Pills, glass alerts, gradient package cards via `:has(input:checked)` (inset accent bar), redesigned OTP box (dashed gradient border, letter-spaced input, gradient Send Code button), generated-credentials hint card. All form field names/IDs/routes preserved (`iptv-portal.login.post`, `iptv-portal.register.post`, `iptv-portal.register.otp` formaction, `otp`/`username`/`password`/`package_id`…). Live-verified: both pages 200, absolute form actions + OTP formaction + cross-links render, new CSS/classes present. No DB changes (`database.sql` unchanged).
- **Combined "Core" nav menu - DONE**: `layouts/admin.blade.php` gained a single **Core** dropdown (`bi-grid`, gated by any of the module perms) that lets you select SIM Cards / Customers / HSS/HLR / Networks / Billing from one menu (each entry links to the module landing page). Existing per-module items kept. Live-verified (admin dashboard shows Core + all 5 entries + working links); no DB changes (`database.sql` unchanged).

- **Nav made horizontal - DONE**: `layouts/admin.blade.php` topbar changed from `navbar-expand-lg` (collapsed to a hamburger below 992px) to `navbar-expand` (always expanded), removed the `navbar-toggler`, and made the `#mainNav` container always visible with `navbar-nav me-auto flex-wrap gap-1` so all menu items (Dashboard/Core/SIMs/Customers/Billing/HSS-HLR/Networks/IPTV/ISP/etc.) show in one horizontal bar at any width, wrapping as needed. Live-verified (17 nav items, no toggler). No DB changes (`database.sql` unchanged).

- **Nav made vertical (sidebar) - DONE**: `layouts/admin.blade.php` topbar converted to a fixed **left vertical sidebar** via CSS overrides: `.navbar.topbar` fixed left (width 250px, `flex-direction: column`, `overflow-y: auto`), `#mainNav`/`.navbar-nav` stacked in a column, `body { padding-left: 250px }` (removed `padding-top: 60px`), and `.topbar .dropdown-menu` made `position: static` (accordion-style submenus that expand in place under each item, dark-friendly styling). Sidebar made **compact** so the full menu shows: smaller nav-link/dropdown-item padding + font (`.85rem`/`.82rem`), `gap: 1px`, `overflow-x: hidden`, tighter brand — keeps all items visible while still `overflow-y: auto` for short viewports. Live-verified (page 200, sidebar CSS + 19 nav items + static dropdowns). No DB changes (`database.sql` unchanged).

- **API Integrations module - DONE**: migration `2026_08_15_000005_create_api_integrations_table` adds `api_integrations` (name, company, service_type mobile_money/bank/call_sip/other, protocol http-api/sip/other, endpoint, api_key, username, password, integration_status, last_checked_at, notes, created_by) + seeds 5 permissions (category `Integrations`: view/add/edit/delete/execute_api_integrations). `ApiIntegration` model; `ApiIntegrationController`: index (search/type/status filters, add + edit collapse, toggle, test, delete) + `test()` (HTTP GET probe for `http-api` treating 1xx-4xx as reachable, TCP `stream_socket_client` probe for sip/other with default ports 5060/80); success sets `connected` + `last_checked_at`, failure keeps `pending`. View `integrations/index.blade.php` (summary cards, Connect Service form, table with type/protocol/endpoint/status/last-checked, per-row Test/Connect/Edit/Delete). Top-level **API Integrations nav link** (view_api_integrations). Live-tested: page 200, Mobile Money (http-api) + SIP integrations created and tested → both Connected + checked; test rows cleaned; `database.sql` regenerated.

- **Company API (Settings) - DONE**: migration `2026_08_15_000006_create_company_api_keys_table` adds `company_api_keys` (name, unique api_key, is_active, last_used_at, created_by) + `CompanyApiKey` model. `CompanyApiController` (`settings/company-api.*`, `can:edit_settings`): generate (bin2hex 32 bytes, key shown once in flash), toggle, destroy; view `settings/company-api.blade.php` (generate form, API usage docs with `X-Api-Key` curl example, issued-keys table with masked key + copy button). `CustomerApiAuth` middleware now ALSO accepts an active CompanyApiKey (updates `last_used_at`), so generated keys authenticate `/api/v1/*`; `CustomerApiController::profile()` returns a company payload when a company key is used, and customer-only endpoints (`sims`/`sim`/`topup`/`transactions`) return 403 via `requireCustomer()` (nullable `customer()` fixed a recursion bug). Nav link under Administration → Company API. Live-tested: generate → key → `GET /api/v1/customer` with `X-Api-Key` → 200 company JSON; `/api/v1/sims` with company key → 403; test key cleaned; `database.sql` regenerated.

- **Site Logo / Favicon / Cover upload fix - DONE**: `SettingController::update()` only saved `favicon` + `cover` — the **`logo` upload was validated but never persisted** (uploading a new Site Logo did nothing). Added the missing `if ($request->hasFile('logo'))` store block (mirrors favicon/cover: `store('settings','public')` + `Setting::set('logo', ...)`). Added **cache-busting** (`?v=<md5 of stored path>`) to the brand logo, favicon (head) and the settings previews so changes show immediately. Live-tested: full-settings HTTP save with a new logo → stored path changed (other settings preserved), then restored the original logo + removed the test file. `database.sql` unchanged.

- **Theme / design upgrade - DONE**: appended a **THEME UPGRADE** CSS block to `layouts/admin.blade.php`: premium dark sidebar gradient (`#1e1b4b→#312e81→#4c1d95`) with hover slide/active gradient pill + `aria-expanded` highlight; soft page background gradient; custom scrollbars (thin, indigo); card hover lift + deeper shadows + subtle radial highlight on gradient stat cards; smoother buttons (translateY hover, primary shadow), zebra + cleaner tables, softer alert/modal/form focus states. Live-verified (dashboard 200, all new CSS applied). No DB changes (`database.sql` unchanged).

- **Nav: divide into 4 buttons + horizontal - DONE**: the vertical sidebar was replaced with a **horizontal top bar** (`body padding-top: 64px`), and all menu items consolidated into **4 top-level dropdown buttons**: **Dashboard** (link), **Core** (SIMs/Customers/HSS-HLR/Networks/Billing), **Modules** (IPTV/API Integrations/ISP Management/My SMS/Reports), **Administration** (Users/Roles/Permissions/Settings/Company API) — each permission-gated with popdown menus (`.topnav4`); the old vertical `#mainNav` is hidden via CSS. **RESTORED**: per user request the layout was reverted to the **compact vertical left sidebar** showing all menu items (SIMs/ISP/HSS-HLR/IPTV/API Integrations/Reports/Admin/etc.), with the 4-button horizontal nav (`topnav4`) hidden and accordion-style static submenus; `body padding-left:250px`. Live-verified (dashboard 200, 25 nav items, all links present). No DB changes (`database.sql` unchanged).

- **VPN System - DONE**: migration `2026_08_28_000001_create_vpn_tables.php` adds `vpn_servers` (name, host, port, protocol wireguard/openvpn/ssh/shadow/other, location, status pending/online/offline, last_checked_at, is_active, notes, created_by), `vpn_plans` (name, price, duration_days, speed, data_limit_gb, is_active, description), `vpn_users` (customer_id/plan_id/server_id, username unique, password, expires_at, status active/suspended/expired, data_used_gb, last_connected_at, notes) + `vpn_devices` (vpn_user_id, device_name, platform, config_text, client_cert, last_connected_at) — all no-FK raw FKs; seeds 5 permissions category `VPN` (view/add/edit/delete/execute_vpn). Models `VpnServer`/`VpnPlan`/`VpnUser` (with `daysRemaining()`/`isExpired()`)/`VpnDevice`; single `VpnController` (`index` dashboard w/ cards + server health + expiring-this-week + recent accounts; `servers`/`plans`/`users` list pages w/ filters + collapse add/edit; `testServer` TCP probe → online/offline + last_checked_at; `renewUser` +plan duration; `toggleUser` suspended↔active; `storeDevice` auto-generates client config via `buildConfig()` keyed to server protocol — WireGuard/OpenVPN/SSH/Shadowsocks template with host:port + auto username/password — devices page has copy-button config viewer). Routes `vpn.*` prefix; sidebar VPN System nav link (`bi-shield-lock`). Admin/SuperAdmin roles granted via seeder parity + live `permission_role` insert. Live-tested: servers/plans/users/devices create (auto password + plan-based expiry), renew, suspend, server test → offline; test rows cleaned, `database.sql` regenerated.
- **Remittance System - DONE, live-tested**: migration `2026_08_28_000002_create_remittance_tables.php` adds `remit_agents` (name, code unique, phone, city, commission_rate %, is_active, notes, created_by), `remit_countries` (name, code ISO2 unique, currency_code, currency_symbol, is_active), `remit_senders`, `remit_beneficiaries` (customer_id/payout country/name/phone/id_number/city), `remit_transactions` (reference unique `TX-ymd-XXXXX`, payout_pin, sender/beneficiary/origin_agent/payout_agent, currency_code, amount, exchange_rate, payout_amount=amount×rate, fee, total_amount, status pending/approved/paid/reversed/cancelled, payout_method, paid_by/at, reversed_by/at) + `remit_ledger` (agent_id, entry_type credit/debit/settlement_credit/settlement_debit, reference, amount, balance_after); seeds 5 permissions category `Remittance` (view/add/edit/delete/execute_remittance) + settings defaults (`remit_base_currency=USD`, `remit_default_rate=1`, `remit_default_fee=2`, `remit_company_name=Casri Remittance`, `remit_pin_length=4`). Models `RemitAgent` (`balance()` = ledger credits−debits), `RemitCountry`, `RemitSender`, `RemitBeneficiary`, `RemitTransaction` (`status_label`/`status_color`/`paidBy`/`reversedBy`), `RemitLedger`. Single `RemitController` (dashboard cards + recent + agent balances + status breakdown; agents/countries/senders/beneficiaries CRUD + toggle; transactions list w/ search/status/payout-method (mobile money/bank/wallet/cash)/agent/date filters; `createTransaction` w/ live payout-preview JS; `storeTransaction` auto-reference + PIN (from `remit_pin_length`), beneficiary quick-create, origin-agent ledger credit on collection; `showTransaction` detail + action buttons; `approve`/`pay`(ledger debit payout + fee credit to payout agent)/`reverse`(debit origin when paid)/`cancel`; ledger w/ per-agent balances + settlement form (`storeSettlement` checks source balance, dual entries) + entry delete; settings + `updateSettings`; helpers `newReference()/newPin()/postLedger()`). Routes `remittance.*` group (prefix `remittance`, `can:view_remittance`, per-action can:add/edit/delete/execute). Sidebar Remittance dropdown `bi-cash-coin` (Dashboard/Transactions/Agents/Countries/Senders/Beneficiaries/Ledger/Settings/New). Admin/SuperAdmin granted via seeder parity + live `permission_role` insert. Live-tested end-to-end: agents/country/sender/beneficiary creates, transfer TX-260828-OHDZU (500 @1.25 fee 2, PIN 3857) → approve → pay(mobile_money) → ledger correct (origin +502, payout −625+2), settlement STL-260828-D29B 100 origin→payout, insufficient-balance guard, reverse paid→reversed, settings save/restore, dashboard + list render; test rows + activity logs cleaned, `database.sql` regenerated (120 tables). NOTE: `balance_after` clamps negatives to 0 while `balance()` is signed (hawala netting); reversing a PAID tx debits origin but does NOT auto-restore the payout agent's entries (manual settlement needed). **Print receipt**: `printTransaction()` renders standalone `remittance/print.blade.php` (company/ref/PIN/sender/beneficiary/amount/rate/payout/method/total + rotated stamp — green `PAID` when paid, `Approved` when approved, none otherwise — plus paid-by/at confirmation footer); Print Receipt button on show page Actions card + printer icon per row in transactions list; route `remittance.transactions.print`. Receipt is a two-part slip on one letter — same sheet renders ORIGINAL + COPY with dashed tear line, PAID stamp (green) on both parts when paid. Dashboard has a Today's Remittances list (time/ref/PIN/sender/beneficiary/amount/status badges + per-row print). Live-tested pending=no stamp / approved=Approved stamp / paid=PAID stamp w/ footer; test rows cleaned, `database.sql` regenerated. Senders/Beneficiaries document upload: migration `2026_08_28_000003_add_document_to_remit_parties_tables.php` adds `document_path`/`document_original_name`/`document_mime` to both tables; files stored `storage/app/public/remit_documents` (public disk, `storage:` link exists), validated `mimes:pdf,jpg,jpeg,png,webp|max:2048`; add/edit forms accept a Document file (`enctype multipart`), table shows a View link, edit form shows existing name + Remove (POST `remittance.senders|beneficiaries.document-remove`); delete removes the stored file too; helper `saveRemitDocument()`. Live-tested upload/view/remove + 200 on public file; test row cleaned, `database.sql` regenerated. **Payout targets**: migration `2026_08_28_000004_add_payout_target_to_remit_transactions_table.php` adds `payout_sim_id` (FK sims) + `payout_bank_id` (FK banks) to `remit_transactions`. When marking PAID the operator picks a method — `mobile_money`/`wallet` shows a searchable SIM picker (SIM/phone/customer filter, JS `o.hidden`) and credits that SIM's wallet (+`Transaction` topup + ActivityLog); `bank` shows a searchable bank picker and credits the bank balance (+`BankTransaction` deposit + ActivityLog); `cash` needs no target; guards reject missing target, and reversal deducts the payout back from the SIM wallet / bank balance. Show page has a Payout Target field; receipts and transactions list show the target under Method. **New-remittance beneficiary quick-create**: form accepts `beneficiary_id_number` + `beneficiary_document` file upload (helper key param); quick-created beneficiary stores id + document. Live-tested: quick beneficiary with ID + PNG upload stored + linked (tx pending), 200 on public file; rows cleaned, `database.sql` regenerated. Live-tested: mobile_money → SIM12 wallet 830→930, bank → bank balance 50, missing-target error, reversal claw-back 930→830; test rows cleaned, SIM12 restored, `database.sql` regenerated. Transactions list shows a paperclip link to the beneficiary's document next to their name (opens stored file). **Receipt logo**: Remittance Settings has a `receipt_logo` upload (PNG/JPG/WebP/SVG, max 2MB, stored `settings/` public) + Remove button; setting `remit_receipt_logo` renders on the print receipt header (both Original + Copy); `updateSettings()` handles upload + `receipt_logo_remove` (deletes old file). Live-tested upload→200 public file→2 imgs on receipt→remove (file deleted, setting NULL); restored + cleaned, `database.sql` regenerated. **Receipt bank block**: when `payout_method=bank` + `payoutBank`, the print receipt shows a "Bank Management — Payout Account" box (Bank / Account Name / Account Number / IBAN / Branch) on BOTH Original and Copy (inside the two-part loop; `printTransaction()` already loads `payoutBank`). Live-tested tx 2 with test bank → box ×2 with values; reverted + cleaned, `database.sql` regenerated (banks: real `Casri` id 15 kept). **Bank Ledger page (Banks module) - DONE**: routes `banks.ledger` + `banks.ledger.export` added BEFORE `/{bank}` in the banks group; `BankController::ledger()`/`exportLedger()` (summary cards: accounts/deposits/withdrawals/total balance; bank/type/search/date filters; no hidden columns) → view `banks/ledger.blade.php`; CSV export via `ReportExportService::download`; sidebar Banks converted to a dropdown (Bank Management + Bank Ledger). Live-tested page 200 + real deposit/withdrawal badges + CSV download; no DB changes. **Bank receipt print - DONE**: route `banks.transactions.print` (GET `/banks/transactions/{bankTransaction}/print`, before `/{bank}`) + `BankController::printTransaction()`; view `banks/print.blade.php` — standalone two-part slip (ORIGINAL blue tag + COPY, dashed tear line) on one letter, rotated stamp colored by type (green DEPOSIT / red WITHDRAWAL / blue TOP UP), header company + logo + bank name/reference/status, grid (Bank/Account/Type/Processed By/Date/Reference), money row (Amount/Balance Before/Balance After/Currency), total line, notes, signatures; per-row printer button added to `banks/show.blade.php` (Print column) and `banks/ledger.blade.php`. Live-tested tx 19/20/21 (deposit×2 + withdrawal) → orig/copy/tearline all 1, show+ledger render 3 print icons; no DB changes. **Remittance Report (Reports module) - DONE**: `ReportController::remittance()`/`exportRemittance()` + routes `reports.remittance` (`can:view_remittance`) / `reports.remittance.export` (`can:execute_remittance`); view `reports/remittance.blade.php` (4 summary cards: Transfers/Total Amount/Payout/Fees, date-preset + status + payout-method + origin-agent + search filters, paginated table w/ status badges + payout targets + paid-by, CSV/XLSX export reusing filters — columns Ref/Date/Status/Sender/Beneficiary/Agents/Currency/Amount/Rate/Payout/Fee/Method/Target/PaidBy/Notes via `payout_target_label`/`status_label` accessors); card on reports index + Reports & Logs sidebar item (dropdown gate also matches `view_remittance`). Live-tested 200, 6 status badges, status=paid filter, CSV (mime/filename/6 lines/columns); no DB changes. **Remittance System Autonotify Templates - DONE**: migration `2026_08_28_000005_seed_remit_user_notification_templates.php` seeds 5 templates (category "Remittance User Notification"): `remit.user.created/approved/paid/reversed/cancelled` with full placeholder docs. New `app/Services/RemitNotifyService.php`: `valuesFor()` (company_name/reference/pin/sender/beneficiary/agents/currency/amount/rate/payout_amount/fee/total_amount/payout_method/status/date/url/footer) + `send(key, tx)` — targets sender+beneficiary phones (dedup), needs active template, sends via `SmsService::send()` (with SMS disabled → "Failed to send ... SMS sending failed" ActivityLog), logs `ActivityLog` (action `notify`, entity `remit_transaction`). `RemitController`: 5 toggles `remit_auto_notify_*` (default '1') + protected `notifyTransaction(key,tx)` guard; hooks fire AFTER the normal ActivityLog in `storeTransaction`/`approveTransaction`/`payTransaction`/`reverseTransaction`/`cancelTransaction`; new `updateNotifySettings()` (route `remittance.settings.notifications`, POST, `can:edit_remittance`) updates boolean toggles + `ntpl_body_/ntpl_active_` per template (mirrors IPTV pattern); `settings()` passes `$notifyToggles` + `$userTemplates`. `remittance/settings.blade.php` gained a **System Autonotify Templates** card (inside the `@can('edit_remittance')` block) — per-event switches + accordion template editor with active toggle + placeholders. Live-tested: save settings (created OFF + paid body changed), create (created SKIPPED), approve+pay(TESTPAID)+reverse (all fired to 620505555 + 628861017), cancel fired, 4 notify logs; cleaned test txs/ledger/logs, paid body + toggles + `sms_enabled` restored, `database.sql` regenerated. **Mark PAID pickers (full-information radio list) - DONE**: `remittance/show.blade.php` Pay form replaced the searchable `<select>` pickers with a scrollable radio list showing ALL SIMs/banks with full info (SIM number + phone + customer + wallet balance; bank + account # + account name + balance) — no select option/search box. Rows visible only for the matching method (mobile_money/wallet → SIM list, bank → Bank list), hidden + deselected on method switch. Live-verified on approved tx: page 200, 462 SIM radios with wallet balances, bank radio, old `payoutSimSelect`/`payoutSimSearch` gone; PAID via `payout_sim_id=12` credited SIM12 (+50, wallet 6630→6680, then restored); test tx/ledger/logs cleaned. **Default Fee as percentage - DONE**: new setting `remit_default_fee_type` (flat/percent, default flat). Remittance Settings Default Fee row became an Amount/% select + number input (form-text explains). `RemitController::storeTransaction()` accepts `fee_type` (in:flat,percent, default = setting) and stores the COMPUTED monetary `fee` (percent → `round(amount*raw/100,2)`, `fee_type` unset from `$data`; existing receipts/reports/ledger keep using monetary fee). `create.blade.php` Fee field now has an Amount/% selector (`#feeType`) + live preview recalcs total (amount+fee) and shows a hint (`5% of amount = 10.00 USD`). Live-tested: settings saved percent/5 → create page percent selected + fee=5; tx 200@5% → fee 10.00/total 210.00; tx 100 flat 5 → fee 5.00/total 105.00; test rows cleaned, fee/type/sms restored, `database.sql` regenerated.
- **Payout OTP before PAID - DONE, live-tested**: migration `2026_08_28_000007_add_otp_to_remit_transactions_table.php` adds `payout_otp_hash`/`payout_otp_sent_at`/`payout_otp_expires_at` to `remit_transactions` + settings `remit_pay_otp` (default '1') + `remit_otp_ttl` (default '5'). `RemitController::sendPayoutOtp()` (route `remittance.transactions.send-otp`, `can:execute_remittance`): approved-only, requires beneficiary phone, `sms_enabled=1`, sends 6-digit OTP via `SmsService::send` ("Your {company} payout OTP is {code}. Valid for N minutes..."), stores `bcrypt` hash + sent/expiry, logs ActivityLog `notify`. `payTransaction()` now validates `payout_otp` (required when `remit_pay_otp=1`) and guards: OTP not requested yet / OTP expired / `Hash::check` mismatch → error; clears the hash on successful pay (one-time use). Show page Pay card gained a **Send OTP to Beneficiary** button (+ sent/expiry status line) and an **OTP from Beneficiary** input (required only when setting on); Remittance Settings gained OTP Require switch + Validity minutes (col-md-3). Live-tested on test tx 29 (beneficiary OTP TEST BEN phone 0000000000): no-OTP pay → "OTP not yet requested", send-otp → OK + ActivityLog, wrong OTP → rejected, correct OTP (123456, set via known hash) → marked PAID cash + `remit.user.paid` SMS to safe number; settings toggle OFF/ON persisted; test data cleaned, `database.sql` regenerated.
- **New Remittance free-text Sender/Beneficiary - DONE, live-tested**: `remittance/create.blade.php` replaced the Sender (`sender_id`) and Beneficiary (`beneficiary_id`) **selects** with **text inputs** — `sender_name` (required) + `sender_phone` (optional) and `beneficiary_name` (required) + `beneficiary_phone`/`beneficiary_id_number`/`beneficiary_document` (optional). `RemitController::storeTransaction()` validation changed to `sender_name` required (+ dropped `sender_id`/`beneficiary_id` requirements): quick-creates the sender and beneficiary from the typed values on submit, **reusing an existing record when name matches** (or name+phone when a phone is given) and logging ActivityLog for new records; an uploaded document replaces an existing beneficiary's doc if present. `createTransaction()` still passes `$senders`/`$beneficiaries` (unused now, harmless). **Sender document now shown + REQUIRED too**: create form has a required `sender_document` file field (mimes pdf/jpg/jpeg/png/webp max 2MB); `storeTransaction()` validates `sender_document` `required`, attaches it to NEW senders on quick-create and to an existing sender's record only when it has no doc yet. **Sender phone + Beneficiary phone made REQUIRED too**: both validations now `required|string|max:30` and the inputs render `required` (placeholders "Sender phone"/"Beneficiary phone"). **Sender ID No + Beneficiary ID No REQUIRED too**: `remittance/create.blade.php` gained a `sender_id_number` field (Sender ID No., `required`) next to Sender phone; `beneficiary_id_number` label/input now `required`. `RemitController::storeTransaction()` validates both `required|string|max:60`; `sender_id_number` is persisted on NEW sender quick-create (and unset from `$data` before RemitTransaction create). Live-tested: fields render required, no-ID submit rejected (no tx), with IDs created (reused sender 2/ben 2 — IDs already stored on records, new records store the typed value); test tx 26 + doc cleaned, `database.sql` regenerated.
- **App Downloads module (public portal + auto QR) - DONE, live-tested**: migration `2026_08_28_000006_create_app_downloads_table.php` adds `app_downloads` (platform android/tv/ios indexed, name, version, package, file_path/file_name/file_size stored installer, external_url, icon override, downloads_count, is_active, sort_order, notes, created_by) + seeds 5 permissions category `App Downloads` (view/add/edit/delete/execute_app_downloads; also added to DatabaseSeeder + granted to super_admin/admin via live `permission_role` sync). Model `AppDownload` (helpers: `platformLabel()`/`platformIcon()`/`platformColor()`, `effectiveUrl()` = download route when file else external_url, `hasFile()`/`fileExists()`, `qrDataUri()` via `SimpleSoftwareIO\QrCode\Facades\QrCode` SVG→data-URI, `creator()`). `AppDownloadController`: admin CRUD (`store`/`update` accept `file` mimes apk,ipa,aab,zip max 500MB stored `app_downloads` public + human file_size, `external_url` url validated, toggle, destroy removes stored file; Activities logged); `index()` w/ search/platform/status filters + summary cards (Android/TV/iOS/total); `download()` serves the file (active-only, aborts 404 if missing, increments `downloads_count` + logs); `portal()` public page (no auth) → all active apps grouped by the 3 platforms. Views `downloads/index.blade.php` (collapse add/edit forms, badges, download-count, per-row edit/toggle/delete, filter bar, public-page button) + `downloads/portal.blade.php` (standalone Bootstrap landing page: brand logo + company from settings, 3 platform cards w/ per-app **auto-generated QR** (encodes the effective download URL) + Download button, empty-platform states). Routes: public `GET /downloads` (downloads.portal) + `GET /downloads/{appDownload}/file` (downloads.download); admin group `downloads/admin` prefix name `downloads.*` inside auth group (index/store/update/toggle/destroy, per-action can: gates). Sidebar App Downloads nav link (`bi-download`). NOTE: admin prefix is `downloads/admin` so it does NOT collide with the public `/downloads` portal (registered earlier in routes/web.php). Live-tested: admin index 200, add android-external-url + tv-file(zip) + ios-external-url (fake `.apk` text file failed `mimes` — used a real ZIP), portal 200 with 3 platform cards + base64 SVG QRs + store links + play/apple URLs, file download 200 (`application/zip`, size matched, `downloads_count` 0→1), toggle hides from portal, update renames/versions, delete removes rows + stored file (files_left=0, rows 0); test rows cleaned, `database.sql` regenerated.
- **Reseller portal renew/pay on expired users - DONE, live-tested**: `IptvResellerPortalController::renewUser()` + route `POST iptv/reseller/users/{user}/renew` (`iptv-reseller-portal.renew-user`, `throttle:30,1`). Guards: reseller session + `is_active`, user must belong to the reseller (`reseller_id` match), package must be active, credit check (insufficient → "Insufficient reseller credit… Please top up"). Renewal: optional validity override (defaults to package `validity_days`), expiry stacks on current expiry when still active, clears `is_expired`+reactivates, allows package switch, deducts credit + records `user_credit` reseller transaction (`RENEW-<userId>`), writes `IptvPayment` (method auto, notes "Reseller renewal for N days via <reseller>"), ActivityLog, `iptv.user.renewed` SMS via `IptvNotifyService` when `iptv_auto_notify_renewed`. Dashboard Recent Customers table gained Actions column — per-row **Renew** button toggles a Bootstrap collapse row with package select + validity days + "Renew & Pay" button; expired rows show an **Expired** badge. Live-tested: expired user (2d ago, is_expired=1) renewed 30d → credit 100→95, expiry 2026-10-10, payment 5.00, tx + ActivityLog, success flash; insufficient-credit guard blocked with no new payment/tx; test rows + logs cleaned, `sms_enabled` restored. No DB schema changes (`database.sql` unchanged).
- **IPTV portal/reseller pages light-white fonts - DONE, live-verified**: `iptv/portal-login.blade.php`, `portal-register.blade.php`, `reseller-login.blade.php`, `reseller-dashboard.blade.php`: `:root` `--text:#f4f6ff; --muted:#c9cef0`; `.footer-text` → `#aeb6e2`; dashboard adds `.form-control::placeholder` `#9aa0c8` + `.text-muted{color:#c9cef0 !important}`. No DB changes (`database.sql` unchanged).
- **Reseller renew confirm-first for active users - DONE, live-verified**: `reseller-dashboard.blade.php` renew forms carry `data-user-active="{{ $user->isExpired() ? '0' : '1' }}"`; submit-handler JS shows a confirm dialog only when renewing an ACTIVE user (expired ones renew silently); added the previously-missing `bootstrap.bundle.min.js` so the Renew collapse rows actually expand. Live-verified flags (expired→0, active→1) + page 200. No DB changes (`database.sql` unchanged).
- **IPTV Packages scalable channel assignment - DONE, live-tested**: `IptvPackageController::assignForm()` (route `GET iptv/packages/{package}/assign` `iptv-packages.assign`, `can:edit_iptv_packages`) with search/category/status filters + paginate(100) + matching count + currently-assigned ids; `assignChannels()` reworked to modes `replace|add|remove|bulk-assign|bulk-remove` (bulk modes recompute matching set server-side from filters → works for ~20,000 channels without loading all into a 20k-checkbox form). New view `iptv/package-assign.blade.php`: summary cards, filter form, paginated checkbox table, Select/Clear Visible, Assign/Remove Selected, Assign/Remove All Matching (confirm), selected-count JS; `packages.blade.php` assign collapse replaced with a link button + old assign collapse/JS removed. Live-tested: page render 200, add/remove/bulk-assign/bulk-remove (cat 750 17ch) all correct; package 1 restored to all 233 channels; test channels 147773-147775 back on pkg1. NOTE: flash shows twice because `layouts/admin` line ~918-923 ALSO renders `session('success')` with `{{ }}` (escaped) before every view's own `{!! !!}` — pre-existing app-wide behavior. No DB changes (`database.sql` unchanged).
- **IPTV Portal Player TV/Leanback mode (Android TV / Google TV) - DONE, live-tested**: `IptvPortalController::manifest()` (route `iptv-portal.manifest`, GET `/iptv/portal/manifest.json`, no auth needed) returns a Web App Manifest — name Casri IPTV, `start_url` `player?tv=1`, `scope` `/iptv/portal`, `display standalone`, `orientation landscape`, colors `#0b0b1a`, icons = uploaded `iptv_portal_logo` (if set) + built-in gradient TV-glyph SVG data URI (always present, purpose any/maskable), `shortcuts` Live TV (`player?tab=live&tv=1`) + Settings. `portal-player.blade.php`: head now carries `<link rel="manifest">`, `theme-color`/`mobile-web-app-capable`/apple meta + icon tags; `$portalLogo` computed at top of `<html>` (was in body) so head can reuse it; new `#btnTvToggle` topbar button (bi-tv) with `.tv-focus-on` active style; TV-mode detected via UA regex (`android tv|googletv|smart tv|tizen|webos|fire tv|aftv|roku|vidaa|leanback|chromecast|bravia|hisense...`) OR `?tv=1` param OR `localStorage iptv_tv_mode`; `tvOn()`/`tvOff()` toggle `.tv-mode` on `<html>` — 10-ft CSS: larger channel rows/buttons/tabs/ep-items, bigger focus targets, `cursor:default`, `.tv-focus` spinning glow ring (`.ch-item.tv-focus` gradient border + shadow + translateX/scale; buttons/tabs get white+purple ring). D-pad spatial nav: `tvFocusables()` (visible-only: `.ch-item`, `.sidebar-tab`, `.season-tab`, `.ep-item`, player buttons `#btnPlay/#btnStop/#btnPrev/#btnNext/#btnMute/#btnInfo/#btnPiP/#btnFs`, `.btn-retry`, `.btn-logout`, `#btnHelpTop/#btnHelpClose`, `#btnTvToggle`, `.fav-btn`), `tvMove(dir)` nearest-center scoring (axis + perpendicular×2.5), `tvActivate()` (ch-item→`selectChannel`, otherwise `.click()`), `tvBack()` priority: close help overlay → hide info panel → hide episodes → exit fullscreen → pause → refocus active channel; remote keys handled via `isActivateKey` (Enter/keyCode13/OK/Select/Accept/Space) + `isBackKey` (Backspace/GoBack/BrowserBack/Escape/8/27/10009) + media keys (MediaPlayPause/TrackNext/TrackPrev/VolumeUp/Down) when `.tv-mode`; TV-mode arrows navigate instead of changing volume (desktop keydown listener early-returns `if(tvMode)`). `selectChannelDirect()` re-focuses the active channel in TV mode. Help overlay gained D-pad/OK/Back/Play-Pause remote hints. Live-tested: manifest.json → 200 `application/manifest+json` with correct start_url/scope/icons/shortcuts, portal login + player page 200 with all TV hooks (manifest link, btnTvToggle, tv-mode CSS, detectTv/tvMove/tvActivate/tvBack/TV_SELECTOR/handleTvKey, remote hint), inline JS `node --check` OK; test user cleaned. No DB schema (`database.sql` unchanged).
- **IPTV Portal Player mobile auto-fullscreen + landscape - DONE, live-tested**: on Android/iOS touch devices (`isTouchMobile()` = touch-capable + mobile UA + not TV mode) the player auto-enters fullscreen + locks to landscape. `fsRequest()` calls `wrap.requestFullscreen` then `screen.orientation.lock('landscape')`; `mobileToggleFs()` toggle; channel-list click on mobile auto-fullscreens right after `selectChannel`; single tap on the player (not on `#adOverlay`/buttons/inputs/progress/title) toggles fullscreen; `fullscreenchange` unlocks orientation on exit (`screen.orientation.unlock`) and syncs `#fsIcon` (bi-fullscreen/bi-fullscreen-exit); `wrap` gains `.on-fs` class in fullscreen. New `#mobileHint` toast (`#playerWrap`, `.rotate-hint` glass pill "Tap to fullscreen — rotate to landscape") shown only when a stream actually played (`mobileReady`, set on `playing`, cleared on stop/ended) on mobile and not fullscreen; hidden via `display:none` in `.tv-mode`. `dblclick` fullscreen disabled on touch (single-tap instead). Live-tested: page 200 with all mobile hooks present (`mobileHint`, `isTouchMobile`, `fsRequest`/`ori.lock('landscape')`, channel-click auto-fs, tap handler, unlock on exit), inline JS `node --check` OK; test user cleaned. No DB schema (`database.sql` unchanged).
- **IPTV Settings: Player Portal Appearance - cover + player logos - DONE, live-tested**: the **Cover Logo** (`iptv_portal_logo`) upload existed; added a **second, separate Player Logo** field (`iptv_player_logo`). `IptvSettingController::update()` now validates + saves both (`plural` uploads stored `settings/` public, delete old on replace, remove checkboxes `iptv_portal_logo_remove`/`iptv_player_logo_remove` delete the stored file + clear the setting); `edit()` passes `$playerLogo`. `iptv/settings.blade.php` Player Portal Appearance card: new subtitle explaining cover (login/register/reseller medallions + app icons) vs player (top-bar brand), second upload row + remove checkbox + preview with fallback hint. `portal-player.blade.php` top bar brand now uses `$playerLogo` (falls back to `$portalLogo`, then default TV icon); favicon/apple-touch/manifest still use `$portalLogo`. Live-tested: settings page renders both fields, player-logo upload → 200 public file + player top bar `brand-logo` shows it, remove checkbox → file deleted + setting cleared + TV-icon fallback; test user cleaned, scroll-text/scroll-enabled/tores restored (a checkbox-only PUT blanks the text fields — real forms post all inputs); `database.sql` regenerated (new `iptv_player_logo` row id=141).
- **IPTV FFmpeg path sanitize - DONE, live-tested** (2026-09-15): user reported the IPTV Settings page showing "Detected binary: not found — transcoding will fall back to the original stream." Root cause: the stored `iptv_ffmpeg_path` setting had been saved as `binary: C:\xampp\htdocs\simcard\storage\ffmpeg\ffmpeg.exe` (the "Detected binary:" hint text was pasted into the field verbatim, corrupting the value). `IptvSettingController::update()` now strips a leading `Detected binary:`/`binary:` prefix from `iptv_ffmpeg_path` before saving. Cleared the corrupt value → auto-detect resolves the bundled `storage/ffmpeg/ffmpeg.exe`; page renders `Detected binary: <green path>`; `available()=true`. Verified live: PUT save with `binary: C:\...` → stored value sanitized to clean path; transcode playlist 200 via `/live/streamtest/stream123/148325.m3u8`; `iptv_transcode_enabled` restored to '1' (a save without the checkbox disables it — expected checkbox behavior). `database.sql` regenerated (650,589 B).
- **IPTV Settings: Player Portal scrolling ticker text - DONE, live-tested**: new settings `iptv_scroll_text` + `iptv_scroll_text_enabled` (both in the `settings` table via `Setting::set`, default ''/'1'). `IptvSettingController::update()` saves both; `edit()` passes `$scrollText`/`$scrollEnabled`. `iptv/settings.blade.php` Player Portal Appearance card gained a Scrolling Text textarea (maxlength 500) + Enable switch + live CSS-marquee preview (`.ticker-preview .preview-bar`, `tkPrev` keyframes, only when non-empty + enabled). `portal-player.blade.php` renders a fixed bottom `.player-ticker` bar (glass, blur, purple gradient hairline, `tickerMove` 30s seamless -50% loop, `pointer-events:none`, hover-pause) with two duplicate `.ticker-item` spans (megaphone icon + text) built by inline JS when enabled + non-empty. Live-tested: admin page 200 with field/toggle/preview, player 200 with `#playerTicker`/`#tickerTrack` + stored Somali message injected, PUT save round-trip persisted (`Tickertest-for-verification`), original message restored; test user cleaned. No DB schema (`database.sql` unchanged).
- **IPTV Settings: Portal login page notice - DONE, live-tested**: new settings `iptv_login_notice` (text, maxlength 1000) + `iptv_login_notice_enabled` (default '0') via `Setting::set`. `IptvSettingController::update()` saves both + `edit()` passes `$loginNotice`/`$loginNoticeEnabled`. `iptv/settings.blade.php` Player Portal Appearance card gained a Login Page Notice textarea + Show-on-login switch. `iptv/portal-login.blade.php` renders a `.login-notice` glass banner (bell icon) at the top of the login card when enabled + non-empty; logic in a `@php` block next to `$portalLogo`. Live-tested: fields render, PUT save round-trip persisted (scroll text preserved via full-form POST), banner renders on login page, toggle-off hides it, cleared value hides it; test text cleared, `database.sql` regenerated.
- **IPTV Redesign: Categories, EPG, VOD/Series, Sessions, Health, Resellers - DONE, live-tested**: six new IPTV features built on the existing `iptv_*` schema, all permission-gated under the existing 5 IPTV perms. (1) **Categories**: migration `2026_09_10_000001_create_iptv_categories_table.php` creates `iptv_categories` (name unique, is_active, sort_order, created_by, timestamps) seeded from distinct `iptv_channels.category` strings + `'Uncategorized'`; `IptvCategoryController` (CRUD + toggle + rename + destroy with channel reassignment to Uncategorized); index has search/status filters + summary cards; nav link under IPTV. (2) **EPG schedule**: `IptvEpg` model + `IptvEpgService` (XMLTV parse, channel-index resolution by id/xtream_id, `importXmltv()` via `Http::get`, `purge()`); `IptvEpgController` index + `import()` + `purge()` + `onAir()` JSON; migration `2026_09_10_000002_create_iptv_epg_table.php`. (3) **VOD / Series**: `IptvVodController` vod list (type/category/status filters + season/episode counts) + `seriesShow()` + `storeEpisode()` + `updateEpisode()` + `deleteEpisode()`; views `iptv/vod.blade.php` + `iptv/series-show.blade.php`. (4) **Sessions & kick**: migration `2026_09_10_000003_add_isp_to_iptv_sessions.php` adds `isp_subscriber_id` nullable FK; `IptvSessionController` index + `endSession()` + `endAllSessions()`; `ExpireIptvUsers` middleware auto-expires + auto-blocks data-exceeded; view `iptv/sessions.blade.php`. (5) **Stream health**: `IptvStreamChecker` service (HTTP GET probe, timeout 8s, `runFor()` with Builder/Collection support); `IptvHealthController` index + `checkAll()` (limit 1-500); auto-disable after 3 consecutive failures; view `iptv/health.blade.php`. (6) **Resellers**: `IptvReseller` + `IptvResellerTransaction` models; `IptvResellerController` admin CRUD + credit/debit + toggle; `IptvResellerPortalController` public login/dashboard/users/create + auto-deduct credit + `iptv_reseller_login_enabled` setting; sidebar IPTV dropdown includes Resellers link. All test rows cleaned. **Health bug fix**: `IptvStreamChecker::runFor()` used `method_exists($channels, 'limit')` which returns `false` on Eloquent Builder (limit is via `__call`), causing `foreach` over unexecuted Builder to yield raw arrays instead of models → TypeError 500 on `check()`. Fixed by detecting `Illuminate\Database\Eloquent\Builder` or `Query\Builder` explicitly and calling `->get()`. **Data corruption incident**: earlier full-test script issued HTTP DELETEs to real categories (758/759/760/761), moving ~190 channels to Uncategorized. Restored by parsing the `database.sql` backup (pre-categories-table, contains original `iptv_channels.category` strings) and reassigning all 190 channels; recreated the 4 deleted `iptv_categories` rows. `database.sql` regenerated.

- **Xtream player app account info / devices / data usage - DONE, live-tested** (2026-09-13): Xtream apps (TiviMate/Smarters/IBO) could log in but showed no Active Devices, no connection limit, and no Data Usage because the reverted `XtreamApiController` (1) never registered Xtream connections so `active_cons` stayed 0 and there was no device-limit enforcement, and (2) `userInfo()` lacked the data-usage fields apps read. Fixes in `app/Http/Controllers/XtreamApiController.php`: (a) `registerConnection($request,$user)` called from `playerApi()`, `getPlaylist()`, and `streamAccess()` — upserts an `IptvUserDevice` by fingerprint (`IptvUserDevice::fingerprint(ip,userAgent)`, same scheme as the portal) and an `IptvSession` with a 60-min `expires_at` TTL (`XTREAM_CONNECTION_TTL_MINUTES`), refreshing `last_active_at`/`expires_at` on reuse; new devices are refused (no session/device created) when distinct active unexpired devices ≥ `max_devices` (clamped to ≥1); `guessDeviceName()` maps UAs → TiviMate/IPTV Smarters/IBO Player/OTT Navigator/Android IPTV. (b) `userInfo()` now emits `active_cons` = count of active sessions where `expires_at > now()`, `max_connections` = `max(1,max_devices)`, and the XUI-style data-usage keys `max_download` (package `data_limit_mb`, 0=unlimited), `allowed_download` ('1' when a limit exists else '0'), `used_download` (`data_used_mb`), `btn_url` 0, `external_addr` (`last_login_ip`), plus raw `data_used_mb`/`data_limit_mb`. `getPlaylist()`/`streamAccess()` now also reject expired accounts with 401 ('Account expired'). Live-tested on test user id=33 (pkg 1 Lite, data_limit_mb 10, data_used_mb 3, max_devices 2): account-info JSON shows `active_cons=1` → 2 after a second app UA, `max_connections=2`, `max_download=10`/`used_download=3`; a third UA is refused (stays 2/2, no new session/device row); playback returns to same count (TTL reuse); bad password → player_api auth:0 (200) + get.php 401; test user/sessions/devices deleted. NOTE: counts expire after `expires_at` (60 min since last Xtream call), not on `is_active`; `activeSessions()` alone without the `expires_at > now()` filter would inflate the count — the expiry filter drops both stale Xtream sessions and older portal sessions, while any currently-valid portal session (portal TTL) still counts as one active device (consistent with the portal's own device limit). No DB schema change (`database.sql` unchanged).

- **Xtream Codes API for IPTV users - DONE, live-tested**: the app now acts as an Xtream server so any `iptv_users` account (username + password, bcrypt verified via `Hash::check`) can be used in Xtream apps (TiviMate / IPTV Smarters / IBO Player). New `App\Http\Controllers\XtreamApiController` (public, no auth middleware): `playerApi()` (`player_api.php` — user_info/server_info + `action=get_live_categories|get_live_streams|get_vod_categories|get_vod_streams|get_series_categories|get_series|get_series_info|get_short_epg|get_simple_data_table`; live = all active channel types, VOD = `stream_type=mp4` no series_id, series = `series_id` set, EPG from `iptv_epg_cache`), `getPlaylist()` (`get.php` — M3U8 M3U with tvg-id/name/logo/group-title, stream URLs `{base}/live|movie|series/{user}/{pass}/{id}.{ext}`, base = `rtrim(url('/'),'/')`), `streamAccess()` (`/live/{username}/{password}/{streamId}.{ext}` + movie/series variants — path-based auth + package guard, relays external streams via `rewriteManifest()`/`proxyBytes()` with byte-counting; YouTube→embed redirect, uploaded file→storage response), `xmltv()` (`xmltv.php` — XMLTV channels + programmes from cache). Routes registered in `routes/web.php` BEFORE the auth group (public): `xtream.api`/`xtream.playlist`/`xtream.xmltv`/`xtream.stream`/`xtream.movie`/`xtream.series`/`xtream.relay`. Bad auth → JSON `auth:0` (200) for player_api, 401 plaintext for get.php/stream/relay, 404 for missing stream, 403 for out-of-package/data-limit. `userInfo()` reports `max_connections` = `max_devices`, `active_cons` = active sessions count. **Xtream Access card** added to `iptv/user-show.blade.php` (Server URL/Username/Password + M3U Playlist + Test API buttons). Live-tested end-to-end with a temp user: auth JSON, live categories/streams, M3U playlist (correct base URL), stream 302 redirect (youtube archive channel), 401 bad pass, 404 out-of-pkg, xmltv XML, all VOD/series/EPG actions return `[]`; test user cleaned; `database.sql` regenerated (no schema change).

- **IPTV channel video upload + YouTube links - DONE, live-tested**: IPTV channels now support local video file uploads and YouTube links, both playing in the portal player. `IptvChannel` gained `isYouTube()`, `isUploadedFile()`, `playableUrl()` (returns `storage_url()` for uploaded files), `youtubeId()` (watch/embed/shorts/youtu.be regex). `IptvController::store()/update()` accept `stream_type` `hls|mp4|other|youtube|file`; `video_file` (mimes mp4,m4v,webm,mov,avi,mkv,3gp,ogv,ogg, max 409600 KB) stored under `iptv_videos/` on the public disk; `resolveSource()` helper picks file upload → youtube URL → stream_url; `update()` keeps the old uploaded path when re-saving a `file` channel without a new upload, and `deleteStoredVideo()` removes the previous file when it is replaced or the channel switches away from upload; `destroy()`/`clearAll()` clean stored files too. Admin index add + edit forms are `enctype="multipart/form-data"` with per-type URL vs Upload rows toggled via JS (`toggleStreamType()` on `#addStreamType`/`.editStreamType`); edit form shows the current filename. Portal player injects `$portalUrls` via `playableUrl()` (absolute storage URLs in request context) and gained an `#ytFrame` iframe + `youtubeId()` JS + `loadCurrent()` branch that embeds `https://www.youtube.com/embed/<id>?autoplay=1` when `stype/youtube`. Admin player (`iptv/player.blade.php`) gained a `#ytPlayerFrame` iframe + `loadStream()` `youtube` branch and plays `file` type like mp4. Health checks short-circuit: `streamHealth()` + `IptvStreamChecker::probe()` report uploaded files ok when the stored file exists and YouTube as healthy (ID validated at save). Live-tested end-to-end: admin upload create (file stored + 200 on `http://simcard.test/simcard/public/storage/iptv_videos/...`), YouTube create/`youtu.be` edit + embed URL, edit-preserves-existing-file, edit-replaces-and-deletes-old-file, invalid youtube rejected with error + value preserved, portal player sidebar + embed/upload URL injection, admin player video src = absolute storage URL + yt iframe, health endpoint youtube healthy; test channels + files + test portal user cleaned; `database.sql` regenerated.

- **Xtream data usage counting + limit enforcement - DONE, live-tested** (2026-09-13): Xtream apps showed 0 Data Usage because `streamAccess()` did `redirect()->away($url)` — media bytes went CDN→player with zero server involvement, so `data_used_mb` never grew and a data-limit package was never enforced. Fixed in `XtreamApiController.php`: (a) `streamAccess()` now proxies streams through the app: for `.m3u8` it fetches the master playlist and `rewriteManifest()` rewrites every non-`#` line (variant/sub-playlist/segment URIs, resolved relative→absolute via `resolveAbsoluteUrl()`) to `/xtream/relay/{user}/{pass}?u=<encoded>` so ALL segments flow through the server; direct files go through `proxyBytes()` (Guzzle `stream` + Range/206 → forwards Content-Range/Accept-Ranges/Content-Length, chunks echoed); uploaded-file channels are served via `response()->file()` with `data_used_mb` bumped by file size only when the request has no Range header. (b) New public route `xtream.relay` (`GET /xtream/relay/{username}/{password}?u=...`, path auth + expired + `isDataExceeded` checks) that fetches/rewrites nested `.m3u8` or proxies bytes. (c) `incrementDataUsage()` grows `data_used_mb` in whole-MB steps as chunks stream. (d) **Enforcement**: `isDataExceeded()` → **plaintext 403 `Data usage limit reached`** in `streamAccess()` and `relay()` before serving; all Xtream error paths now use new `abortPlain($status,$msg)` (aborts with a plain-text response) because `abort(401/403/404)` previously rendered `errors::403.blade.php` which extends `layouts.admin` → `auth()->user()->isSuperAdmin()` on null auth → guaranteed 500 for app clients. Live-tested on Apple bipbop HLS (test user pkg 1 Lite / limit 10MB): master + variant manifests rewritten (segments → relay), 1.5MB `.ts` segment relayed (video/MP2T, `data_used_mb` 2→3), Range request → 206 with exact Content-Range, usage at limit → clean 403 text on relay/live/movie paths, `user_info` `used_download=3`/`max_download=10`/`allowed_download=1`; test user/channel/sessions cleaned; `database.sql` regenerated. NOTE: Youtube channels still redirect to embed (not countable); `abortPlain()` keeps all Xtream error replies as clean text.

- **Android Xtream playback relay - ALL PLAYERS FIXED - DONE, live-verified** (2026-09-14): users reported Android players (TiviMate/Smarters/IBO) couldn't play any stream. Root causes found + fixed in `XtreamApiController.php`: (1) **`rewriteManifest()` only rewrote bare URI lines** — `#EXT-X-MAP:URI`, `#EXT-X-KEY:URI`, `#EXT-X-MEDIA:URI`, `#EXT-X-I-FRAME-STREAM-INF:URI` were passed through unchanged, so fMP4/CMAF master playlists (with init segments via `#EXT-X-MAP` + `BYTERANGE`), AES-128 encrypted streams, and alternate audio/subtitle renditions resolved `main.mp4` relative to the *relay* URL → dropped credentials → 401/422 → playback stalled on every modern stream. New `rewriteTagUris()` rewrites quoted `URI="..."` attributes inside any `#EXT-*:` tag to the relay. (2) **proxyBytes used `Http::withOptions(['stream'=>true])` + `response()->stream()` echo/flush loop which deadlocked under mod_php on Windows** — segment responses (esp. Range/206) never reached the client (headers sent, body stuck). Rewrote `proxyBytes()` to buffer each segment (HLS segments are 1-3MB; guard aborts upstream >300MB via `Content-Length` check, Range requests pass through) and return a standard response forwarding Content-Type/Accept-Ranges + Content-Range + Content-Length on 206, with `data_used_mb` counted by body size and `Cache-Control: public, max-age=3600`. (3) Upstream manifest fetches now use a browser UA (`outboundHeaders()`, override via `RELAY_UPSTREAM_UA` env) + up-to-3 retries (`fetchUpstream()`), and non-manifest upstreams requested as `.m3u8` redirect instead of being forced through the manifest path. Manifest responses get `Cache-Control: no-cache`. Live-verified on Apple bipbop (fMP4, `#EXT-X-MAP` BYTERANGE) test channel 148324 with exact TiviMate UA: master 200 w/ all variant/I-FRAME/MEDIA URIs → relay, sub-playlist 200 w/ rewritten `#EXT-X-MAP`, MAP init Range `bytes=0-718` → 206/719 `video/mp4` (real `ftypmp42` bytes), segment `bytes=719-1508718` → 206/1508000, `get.php`/`player_api` auth + streams OK, `data_used_mb` counting works (limit → 403 clean text), >300MB guard. Catalog note: only test channel 148324 exists — user's real channels must be imported for real-provider playback.

- **Real channels imported + TS-only providers - DONE, live-verified** (2026-09-14): imported the user's real source (`https://x-api.uk/get.php?username=2thS4Mn8YBKSp&password=5ZQuSANfb7a`), 26 live beIN-SPORTS channels (category "AR| BEIN SPORTS ⁸ᴷ & ᴴᴰ ⚽", direct `.ts` URLs from `tv.business-cloud-8.ru`) via the admin `iptv.import-m3u` route; `database.sql` regenerated (648,070 bytes, 27 channels incl. Apple test 148324). This provider is **TS-only**: `player_api.php` reports `allowed_output_formats=["ts"]`, and BOTH `.../{id}.ts` and `.../{id}.m3u8` return raw MPEG-TS `video/mp2t` via a 302 chain (no HLS manifests ever). Implications handled in `XtreamApiController.php`: (a) `streamAccess()` now falls back to `redirect()->away($url)` whenever the upstream is a raw media stream — manifest fetch returning body without `#EXTM3U` but Content-Type `video/*|audio/*|application/octet-stream|mp4|x-mpegurl` → redirect (no 502); non-manifest top-level requests (e.g. `.ts` ext on a TS-only channel) also redirect instead of `proxyBytes` (which would buffer the endless live TS until the 60s timeout). Verified live: `/live/.../148350.m3u8` → 302 to provider `.ts` URL → curl follows → `200 video/mp2t` live bytes (14MB/8s); Apple HLS channel still relays properly side-by-side. (b) `importFromM3u()`/`importXtreamAll()` now sanitize provider text via new `cleanImportText()` (Windows-1256/ISO-8859-1/Windows-1252→UTF-8 iconv on non-UTF-8 names, truncate to 255/100 col limits, logo capped 600) → provider mojibake `beIN SP�s�RTS` becomes clean UTF-8 `beIN SP⚽RTS ᴴᴰ` and no SQLSTATE 1406/1366 row-length/encoding failures. NOTE: TS-only channels intentionally bypass the relay+counting (redirect is the only viable strategy for endless live TS under mod_php); data-usage counting+limits only apply to HLS/relayed streams. Test user `streamtest` (pkg 1, limit 10MB from package — `data_limit_mb` on the user row is NULL, `isDataExceeded()` reads the package) had usage reset to 0 after relay verification.

- **Low-bandwidth live transcoding (FFmpeg HLS) - DONE, live-tested** (2026-09-15): users hit ~14 Mbps TS-only beIN streams on slow connections, so the app now converts live streams to ~800 kbps / 480p HLS per-user. Migration `2026_09_15_000001_add_stream_quality_to_iptv_users_table` adds `stream_quality` varchar(20) DEFAULT 'original' to `iptv_users`; admin users edit collapse has an Original/Low select; new `app/Services/TranscodeService.php` + settings (defaults `iptv_transcode_enabled='0'`, `iptv_ffmpeg_path=''` → auto-detect `storage/ffmpeg/ffmpeg.exe`, `iptv_transcode_bitrate='800'`, `iptv_transcode_height='480'`) in the **IPTV Settings page** (labelled "…card in `resources/views/iptv/settings.blade.php`"). `XtreamApiController::streamAccess()`: when the user is `low` + global switch on + binary exists, the `.m3u8` playlist request is **transcoded locally** — path `/xtream/trans/{username}/{password}/{channelId}/{segment}` (public route before the auth group); original-quality / switch-off / missing binary / spawn failure / no-segments → graceful `redirect()->away()` fallback to the provider's original URL. **Windows spawn (mod_php-safe, no wmic — NOT available on host)**: `TranscodeService` writes `storage/app/transcode/{id}/launch.ps1` (`Start-Process -FilePath '<ffmpeg>' -ArgumentList '<pre-quoted quoted args>' -WindowStyle Hidden -RedirectStandardOutput/-Error -PassThru; if ($p) { $p.Id | Out-File ffmpeg.pid }`) + `launch.cmd` (`powershell -NoProfile -ExecutionPolicy Bypass -File "launch.ps1"`), started via `@pclose(@popen('start "" /b "launch.cmd"', 'r'))`, then polls `ffmpeg.pid` up to 5 s. Transcode args: `-y -i <original> -map 0:v -map 0:a -c:v libx264 -preset veryfast -b:v 800k -vf scale=-2:480 -c:a aac -b:a 64k -ac 2 -f hls -hls_time 4 -hls_list_size 12 -hls_flags delete_segments <outdir>/seg%05d.ts`. First segment takes ~30-60 s on the slow provider → `ensureSession()` deadline 40 s; shorter calls return 202 retry. Segments served from disk (`video/mp2t`), bytes counted via `incrementDataUsage()` → data-limit 403; the transcode session files are cleaned by `iptv:transcode-cleanup` (scheduled `routes/console.php` line 17, everyTenMinutes) which killed idle sessions, removed files + rmdir. Live-verified end-to-end on channel 148325 (Apple bipbop HLS; `streamtest`/`stream123` id 39 pkg 1 limit 10MB): fresh-playslist HTTP 200 `application/vnd.apple.mpegurl` with `#EXT-X-STREAM-INF`/segment URIs rewritten to `/xtream/trans/.../seg%05d.ts`, segment fetch 200 `video/mp2t` 1,394,208 bytes → `data_used_mb` 0→2 (ceil), data-exceeded (15MB>10MB) → **403 text/plain** on playlist AND segment, disabled+original → 302 collapse to provider TS, bad password on transcode segment → 401, traversal → 404, fast path ~2 s with live session. Admin IPTV Settings page confirmed rendering the card (checkbox `iptv_transcode_enabled` checked, `iptv_ffmpeg_path`/`iptv_transcode_bitrate`/`iptv_transcode_height` inputs present; binary `storage/ffmpeg/ffmpeg.exe`). `php -l` clean + `view:cache` OK + `database.sql` regenerated (648,938 bytes). Test state left: `streamtest` user `stream_quality='low'`/`data_used_mb=0`, `iptv_transcode_enabled='1'`, ffmpeg killed + `storage/app/transcode` removed. NOTE: the IPTV **Settings page route is GET `/iptv/notifications`** (`iptv-settings.*`, not `/iptv/settings` which is only the CORS-proxy `saveSettings` POST).

- `app/Services/TranscodeService.php` (new): low-bandwidth per-user transcoding — Windows mod_php spawn (launch.ps1 + launch.cmd via `start "" /b` + ffmpeg.pid poll, no wmic), 40 s warmup deadline, HLS args (hls_time 4, list 12, delete_segments), session check + dir cleanup.
- `app/Http/Controllers/XtreamApiController.php`: `streamAccess()` transcode branch (~line 637) + `transcodeActive()`/`transcodeSegment()`; `routes/web.php` lines 104-114 public xtream routes incl. `/xtream/trans/{username}/{password}/{channelId}/{segment}` (before the auth group).
- `database/migrations/2026_09_15_000001_add_stream_quality_to_iptv_users_table.php`: `iptv_users.stream_quality` (original/low, default original) — applied.
- `app/Http/Controllers/IptvSettingController.php` (transcodeKeys + edit/update) + `resources/views/iptv/settings.blade.php` (Stream Transcoder card, lines 118-135+) + `resources/views/iptv/users.blade.php` (Stream Quality select in user edit collapse).
- `app/Console/Commands/IptvTranscodeCleanup.php` + schedule `routes/console.php` line 17; bundled binaries `storage/ffmpeg/ffmpeg.exe`/`ffprobe.exe`.

- **Portal player raw TS (.ts) playback - DONE, live-verified** (2026-09-15): the web portal player couldn't play the provider's TS-only beIN channels (`stream_type='other'`, raw `http://tv.business-cloud-8.ru/live/.../*.ts` MPEG-TS) because HTML5 `<video>` cannot demux MPEG-TS — native apps (TiviMate/XCIPTV/IBO) decode it natively, but browsers need an MSE-based demuxer. Fixed by adding **mpegts.js** (CDN `https://cdn.jsdelivr.net/npm/mpegts.js@1/dist/mpegts.min.js`, same family as hls.js) to BOTH `resources/views/iptv/portal-player.blade.php` and `resources/views/iptv/player.blade.php` (admin player). Portal: `loadCurrent()` detects TS via `ch.stype === 'other' || 'ts' || /\.ts(\?|#|$)/i.test(url)` and runs `mpegts.createPlayer({type:'mpegts', isLive:true, url}, {enableWorker, liveBufferLatencyChasing, deferLoadAfterSourceOpen, lazyLoadMaxDuration:180})` attached to the same `#video`; new `mse` state var + `destroyMse()`; `destroyHls()` also destroys `mse`; video `error` listener now ignores when `mse` is active (`!hls && !mse`). Admin player: `mpegtsInstance` lifecycle (destroy in `initStream` + `btnStop`, guard the generic `video error` handler), TS branch before the native fallback after HLS. EPG chip (`#nowEpg` now/next) and hls.js m3u8 playback already worked — TS was the missing piece. Live-verified: portal login (streamtest) → player page 200, mpegts CDN + `mpegts.createPlayer` + `isTs` logic present, 26 beIN `OTHER` channel rows render, EPG chip present; CDN reachable (200). JS `node --check` clean (Balanced-paren `@json(...)` strip), `view:cache` OK. No DB changes (`database.sql` unchanged). NOTE: mpegts.js fetches cross-origin via XHR/MSE so the provider must send CORS or the `iptv_cors_proxy` setting must be set (PROXY is applied via `playUrl()` for cross-origin URLs already).

- **Xtream device-limit enforcement fixed - DONE, live-tested** (2026-09-15): `registerConnection()` previously returned `void` and every call site ignored it — so a device over `max_devices` got no session row, but the API still served playlists/streams, and a device whose session expired could steal a slot while other devices were active. Fixed: `registerConnection()` now **returns bool** — a device that already has an active session is always refreshed (allowed), otherwise the distinct active-session device count is checked against `max(1, max_devices)` and new logins are **refused** when at the limit. Login endpoints gate on the result: `playerApi()` → JSON `auth:0`/`status:"Max devices reached"` (`deviceLimitResponse()` helper); `getPlaylist()` → 403 plain `Device limit reached (max N)`. `userInfo()`/`deviceLimitResponse()` count distinct `device_id` (not raw sessions) so `active_cons` matches enforcement. **Playback fix (2026-09-15 same session)**: the limit was initially also gated on stream endpoints, which BROKE all players — Xtream apps use different UAs for login/API (e.g. `TiviMate`) vs the media engine (`ExoPlayerLib`, etc.), so with `max_devices=1` the app logged in (1 device) but the engine's `/live`/`relay`/`transcode` requests were a "new device" → 403 → "channels load but nothing plays". Fixed: `streamAccess()`, `relay()`, `transcodeSegment()` now call `registerConnection()` only as a best-effort session touch (ignore the bool), so playback always serves after auth+package+data checks; the device limit remains enforced ONLY at login/playlist (player_api + get.php). Live-verified with `streamtest` at `max_devices=1`: TiviMate login auth:1 active_cons=1, **ExoPlayerLib UA `/live/...m3u8` → 200** (transcode manifest + `video/mp2t` segment 200), while a second app UA via get.php still 403; test devices/sessions cleared, user restored (max_devices=3, quality low, data_used_mb=0). No DB changes (`database.sql` unchanged).

- **Native Android IPTV client (Java + Media3) - first buildable draft (2026-09-26; superseded by the Android re-skin entries below)**: new self-contained Android project in `C:\Users\abdir\Documents\android` (see the Android re-skin entries below for its current state) (package `com.casri.iptv`, appId `com.casri.iptv`) that talks to the app's own Xtream controller, so it honours account expiry, package/channel permissions, device limits, data-usage caps and the transcoder exactly like TiviMate does. **User's chosen scope**: full buildable project, existing Xtream API, Java, Media3/ExoPlayer, tabs `Home` / `Live TV` / `Sport`, files `ExoPlayerFragment.java`, `TVFragment.java`, `SportFragment.java`, `HomeFragment.java`. 19 Java files + 7 layouts + 12 drawables; **no Retrofit/Glide** — `HttpURLConnection` + `org.json` and a small `ImageLoader` (`LruCache` + view tag) so there is no library to manage. AGP 8.5.2 / Gradle 8.7 / compileSdk+targetSdk 34 / minSdk 21 / Java 17; Media3 1.4.1 (`exoplayer`, `exoplayer-hls`, `ui`, `datasource`). Cleartext HTTP allowed via `res/xml/network_security_config.xml` (panels are plain HTTP). `XtreamApi` (net) does login validation against `action=user_info`, an in-memory catalogue cache shared by TV+Sport+Home (`get_live_categories` then `get_live_streams`, concurrent callers queued behind one request), sport filtering that matches **both** category name and channel name against a keyword list (beIN/ESPN/Sky Sports/NBA/…), and stream-URL building. `SessionStore` keeps server/username/password in SharedPreferences and normalises the base URL (bare host gets `/simcard/public` appended, trailing slashes trimmed). `MainActivity` is the shell: tabs are `add`/`hide` (not `replace`) so scroll position and loaded lists survive switching, the player is `add`+`addToBackStack` on top so **back returns to the exact list** instead of an empty container, the bottom bar hides while a player is on the stack, and the toolbar title tracks the tab (`ChannelListFragment::tabTitle()`). Home shows status badge / expiry / `active_cons` of `max_connections` / channel + sport counts and signs out to `LoginActivity` with `CLEAR_TASK`. `ExoPlayerFragment` builds the URL through the shared `XtreamApi` (never from a Bundle string), has a buffering spinner, tap-to-retry error line with `PlaybackException` code, and a persisted **low bandwidth** switch. **Panel-response quirks found while testing the live API and coded around**: this panel's `get_live_streams` returns **no `container_extension`** (so the client defaults to `.ts` — `/live/{u}/{p}/{id}.ts` and `.m3u8` both return 200) and returns an **empty `category`** string with only `category_id`, so `loadStreams()` builds an id→name map from the already-fetched categories and resolves labels (otherwise all 396 channels would read "Uncategorised"). **Low-quality correctness fix**: the server only transcodes the `.m3u8` route, so `buildStreamUrl()` forces the extension to `m3u8` when `?q=low` is requested — a `?q=low` on a `.ts` URL would have silently served the untouched high-bandwidth source. Live API verified with user `amla`/`stream123456`: `auth:1`, `status=Active`, `active_cons=1`, `max_connections=5`, **9 categories, 396 streams**, and `/live/amla/stream123456/148351.ts` / `.m3u8` / `.m3u8?q=low` all 200 (the last with `application/vnd.apple.mpegurl`). Channel names are healthy UTF-8 (`##### beIN SP⚽RTS ᴴᴰ #####`, JSON `\u26bd` escapes) — earlier `?` in the console was PowerShell output encoding, **not** data corruption. **BLOCKED on verification**: this host has no JDK / Android SDK / Gradle / adb, so the app has never been compiled. Compensating checks all pass: every `R.*`/`@*` resource reference resolves (40 strings, 16 colours, 12 drawables, 7 layouts, 53 ids), a single-pass comment/string-aware scanner reports no brace/paren/bracket imbalance, no bad own-package imports, no package/dir mismatches in any of the 19 files, and all 22 cross-class members the fragments call exist. Bugs found and fixed during that audit: missing `;` after `SPORT_KEYWORDS`, `onChannelSelected` using `replace()` (destroyed the tab), dead `MainActivityBridge`/`allLabel()` abstractions (replaced by `ui/TabNavigator` + `allLabelResId()` so the Sport pill can read "All sport"), `ImageLoader` reading `view.getWidth()` off the main thread, duplicated label TextView in `item_category.xml`, a duplicated stat block in `fragment_home.xml`, and a login prefill that stuffed the example URL into the field. No `gradlew`/wrapper JAR is shipped (binary); `android/README.md` documents opening in Android Studio (which syncs with its own Gradle) or running `gradle wrapper` once. No DB changes (`database.sql` unchanged).

- **IPTV VOD/Series catalogue management - DONE, live-verified** (2026-10-03): the VOD page was read-only for titles (a movie's "Edit" button even linked to the *live* channels page with a name search). `IptvVodController` rewritten (`app/Http/Controllers/IptvVodController.php`): `storeTitle`/`updateTitle`/`toggleTitle`/`deleteTitle` for movies **and** series parents (all the VOD metadata columns already existed on `iptv_channels` - plot/rating/cast/director/genre/duration/container_extension/backdrop_path/youtube_trailer/rating_5based), `bulk()` over the grid, a `categories()` VOD category manager plus `renameCategory`/`mergeCategory`/`destroyCategory`, and `titlesQuery()` shared by the list + the bulk action. New `IptvChannel::episodes()`/`isSeriesParent()`/`episodeCount()` relations. Routes `iptv.vod.titles.*` / `iptv.vod.bulk` / `iptv.vod.categories.*` (add/edit/delete/execute perms). Views: `resources/views/iptv/vod.blade.php` (Add Title collapse, per-card edit modal + delete modal, select-all + bulk toolbar with "selected only"/"all matching filters" scope, package + sort filters, 5 summary cards) and new `resources/views/iptv/vod-categories.blade.php` (per-category totals + rename/merge/move-to-package).
  - **Fixed an N+1**: the old `series_without_episodes` card did `->get()` then a `COUNT` **per series**; now one `whereNotIn('id', <parents with children>)` query.
  - **Bug caught by live testing**: the bulk form's assign-category input was named `category`, which collided with the `category` **filter** field, so it filtered on its own target value and matched 0 titles. Renamed to `target_category`. Worth remembering: one form cannot use the same name for a filter and an action value.
  - Guard: deleting a series that still has episodes is refused unless `confirm_episodes` is ticked; with it, parent + children go together.
  - `updateTitle` deliberately preserves `is_active`/`category`/`package_id` when the field is **absent** from the POST (`array_key_exists` guards) - this avoids the app-wide "partial form blanks fields" footgun.
- **`iptv_categories` drift found and repaired (2026-10-03)**: while adding the VOD work, `iptv_categories` was found holding only **1** row (`Uncategorized`) while `iptv_channels` used **8** distinct live category names - i.e. the live categories had been missing since the **2026-09-11** data-corruption incident (activity_logs 99-108 show them being deleted, and the later "recreated" rows were lost again). Consequences were mild (`IptvCategory::hiddenNames()` powers portal hiding, so an empty table just means nothing is hidden) but the admin Categories page could no longer rename/hide a real category. Repaired by re-creating one row per distinct channel category (**10** rows now: 8 live + `US| MOVIES...` + Uncategorized) via a one-off script, and made it impossible to drift again: new `IptvCategoryController::sync()` (route `iptv.categories.sync`, `can:edit_iptv`, optional `include_vod`) re-creates any missing row from channel data and reports rows **no channel uses** (orphan measurement always spans every content type so a live-only sync never calls the VOD categories unused). "Sync from channels" button added to the Categories page. Verified: sync reports 0 created / 0 orphans, page renders 11 `<tr>` with per-row toggle/rename/delete.
- **Android client - RE-SKINNED, COMPILES (2026-09-26)**: the project lives at `C:\Users\abdir\Documents\android` (NOT `C:\xampp\htdocs\simcard\android` - that path does not exist; do not move it without asking). It is now a full dark portal-style reskin of `resources/views/iptv/portal-player.blade.php` (content rail + category rail + pane header + one-by-one rows + overlay player). **Verified: `:app:assembleDebug` BUILD SUCCESSFUL, `:app:lintDebug` 0 errors / 28 benign warnings** (down from 66; remaining = `NotifyDataSetChanged` x8, `GradleDependency` x7, `Overdraw` x5, `UnusedAttribute` x5, `ObsoleteSdkInt` x2, `InsecureBaseConfiguration` x1). Toolchain is cached locally so builds run **offline**: `JAVA_HOME=C:\Users\abdir\.gradle\jdk17`, `ANDROID_HOME=C:\Users\abdir\AppData\Local\Android\Sdk`, Gradle 8.10.2 at `C:\Users\abdir\.gradle\wrapper\dists\gradle-8.10.2-bin\a04bxjujx95o3nb99gddekhwo\gradle-8.10.2\bin\gradle.bat` (no `gradlew`/wrapper JAR ships - binary), AGP 8.8.2, compileSdk+targetSdk **35** (was 34), minSdk 21, Media3 1.4.1, Java 17. APK: `C:\Users\abdir\Documents\android\app\build\outputs\apk\debug\app-debug.apk` (~10.7 MB). Still to build: Series screens, VOD episode playback, EPG grid, favourites sorting, release signing. Preserved live state: users `hamse`(40)/`amla`(41) unchanged, `iptv_transcode_enabled='1'`, `iptv_transcode_variants=3`, `iptv_transcode_max_processes=3` (do NOT raise before pinning ffmpeg `-threads`).

- **Android re-skin content (2026-09-26)**: `XtreamApi` now has `QUALITY_ORIGINAL|AUTO|LOW|MED|HD` constants, quality-aware `buildStreamUrl()` (non-original forces `.m3u8` because only the HLS route is transcoded), `buildVodStreamUrl()` for the Movies tab, lazy VOD/series loading (`ensureVod`/`ensureSeriesCount`), `ensureEpg()` batching 24 visible channels for the now/next line, and `resetCatalog()` clearing VOD waiters. `ChannelListFragment` is the shared browser (content tabs All/Live/Movies/Series/Favourites with live counts via `ContentTabAdapter`, category rail with counts, search, favourites, pull-to-refresh, empty/error states, `onChannelsVisible` reporting what is on screen); `TVFragment`/`SportFragment` only supply pools + category lists (`SportFragment` hides the content rail). `MainActivity` shows/hides tab fragments (never replace), pushes the player on the back stack, hides the bottom bar while playing, keeps the toolbar title in sync, and walks prev/next over the **visible** list. `ExoPlayerFragment` has prev/next, favourite star, mute, fullscreen, buffering/retry, the portal ticker and a persisted quality chip (`PlayerPrefs` cycles auto - low - original; med/HD are deliberately not offered because the panel only encodes a low variant). `HomeFragment` shows status/expiry/connections/channel+sport counts and a favourites card. `LoginActivity` maps the API's English failures to the localized `error_auth_failed`/`error_bad_server` strings. **Watch out when editing on Windows**: `Get-Content | Set-Content -Encoding UTF8` double-encodes non-ASCII (it silently turned an em dash into `E2 80 94` cp1252 chars and the middle dot into `C2 B7`); always read/write with `[System.IO.File]::ReadAllText/WriteAllText` plus an explicit UTF8 encoding, and note javac fails on a UTF-8 BOM in `.java`.

- **Android catalogue semantics + 4 UI bugs fixed, rebuild green (2026-09-26)**: verified the tab/category model against the server before changing anything, which corrected two wrong assumptions. `XtreamApiController::getLiveStreams()` answers `get_live_streams` with **every** stream the package serves (movies included, `getLiveCategories()` also covers `mp4`), so `XtreamApi.channels` is the whole 396-item library, `vod` is the 79-item mp4 subset, and `liveChannels()` = 396 − 79 = **317** - the All 396 / Live 317 / Movies 79 counts were already right and `TAB_ALL` needed no fix. `getVodCategories()` (controller line 381) numbers movies in their **own** id space from the mp4-only subset, so the two category lists are deliberately **never merged** (ids overlap and would fuse unrelated categories while double-counting shared channels). Fixes: (1) `fragment_exo_player.xml` `playerNext` was a **TextView carrying `android:src`**, so the Next button rendered as an invisible empty view (prev/mute/fullscreen were already ImageViews) - now an ImageView plus the `layout_marginStart="10dp"` the other controls have; (2) `MainActivity::showFavourites()` prepared the list on the TV fragment but returned without `selectTab()`, so the Home favourites card changed a **hidden** tab and the user stayed where they were - it now brings the tab forward (the not-yet-created path already used `favouritesRequested`); (3) `applyFilters()` searched only `tabPool()`, contradicting its own comment - a query now searches `allChannels()` (whole library, matching the portal) while Sport stays scoped via its own `allChannels()`; (4) the category rail showed live categories on the Movies tab, leaving every movie uncategorised - `XtreamApi` now keeps the parsed `get_vod_categories` list (`vodCategories` field, `cachedVodCategories()`, cleared in `resetCatalog()`) and `TVFragment::categoriesFor()` returns it for `TAB_MOVIES` via the new `ChannelListFragment::selectedContentTab()`; `SportFragment` keeps its own override. Verified: `:app:assembleDebug` + `:app:lintDebug` **BUILD SUCCESSFUL in 10s**, **0 errors / 28 warnings** (unchanged set; the only hits in the touched files are the deliberate `NotifyDataSetChanged` EPG refresh and the player's `Overdraw`), APK rebuilt at 10,595,705 bytes. `README.md` updated with the two id spaces, the search scope and the favourites-card behaviour. No Laravel/Blade/DB changes, no `database.sql` dump.

- **IPTV EPG management (admin) - DONE, live-verified** (2026-10-03): the EPG page was import/purge-only (no way to add, edit or delete a programme without an XMLTV source). `IptvEpgController` extended (`app/Http/Controllers/IptvEpgController.php`): `store`/`update`/`destroy` for manual programmes, `guide()` (day grid), `coverage()` (what has/hasn't a guide), `bulk()` (clear-channel / shift-channel / clear-day), `export()` (CSV). New routes `iptv.epg.store` (`can:add_iptv`), `iptv.epg.update` (`can:edit_iptv`), `iptv.epg.destroy` (`can:delete_iptv`), `iptv.epg.guide`/`coverage`/`export`, `iptv.epg.bulk` (`can:execute_iptv`). New views `resources/views/iptv/epg-guide.blade.php` (channels as rows, hours as columns, programme blocks positioned by `left/width %` with overlap **lane stacking** + an on-air gradient marker) and `resources/views/iptv/epg-coverage.blade.php` (3 cards, per-category coverage bars, covered/uncovered filters). `epg.blade.php` gained an Add-programme collapse, a Bulk collapse, a per-row Edit modal + Delete, and Guide/Coverage/Export buttons.
  - **Two bugs found by live testing**: (1) `store()` read `$data['programme_id']` **without** `?? null`, so adding a programme *without* the optional XMLTV id threw a 500 (same undefined-key family as the "partial form" footgun) - fixed. (2) `iptv_epg_cache.channel_id` is **varchar(255)**, not an FK int (it holds the resolved id as text), so `whereIn('id', $coveredIds)` needs `array_map('intval', ...)` or it silently matches nothing - the coverage filters use that.
  - Guide grid is bounded (max 60 channels, 1 day) and clips programmes that cross midnight. Bulk `shift-channel` is the fix for a provider whose XMLTV is in the wrong timezone (moves every programme on that channel by N minutes); `clear-day` deletes anything *overlapping* the date on every channel.
  - Live-verified with a seeded 9-row guide (2 channels x 4 programmes incl. 2 overlaps + a midnight-crosser + an on-air row): list page, guide (blocks + lane 2 + on-air gradient), coverage 2/317, CSV export header + rows, add/update/delete (second delete -> 404), validation (end<start and missing title rejected), all 3 bulk ops incl. the no-channel and zero-minutes guards. Test rows + 10 `activity_logs` purged, `iptv_epg_cache` back to 0 rows, all 3 pages re-checked with empty states (guide renders 0 blocks + "no guide" badges, coverage 0/317).
- **IPTV Licenses module - DONE, migrated + live-verified** (2026-10-03): a full subscriber-licence register, separate from `App\Models\SystemLicense` (which is the **installation/product** licence - do not conflate them). Migration `2026_10_03_000001_create_iptv_license_tables.php` adds `iptv_licenses` (unique `license_number` `LIC-ymd-XXXXX`, `iptv_user_id` FK cascade, nullable `iptv_package_id` FK, `license_type` subscription/trial/reseller/partner/commercial, `status` pending/active/suspended/expired/cancelled, `starts_at`/`expires_at` dates, `max_devices`, `price`, `auto_renew`, `agreement_path`/`_original_name`/`_mime`, `renewal_count`, `last_renewed_at`, `last_reminder_at`, `notes`, `issued_by` FK) + `iptv_license_renewals` (`previous_expires_at`, `new_expires_at`, `days_added`, `amount`, `notes`, `created_by`), seeds 5 permissions in category **IPTV Licenses** (`view_/add_/edit_/delete_/execute_iptv_licenses`) and 4 templates (`iptv.license.issued|renewed|expiring|expired`, category `IPTV User Notification`) via `updateOrInsert`; `down()` removes both tables + the permissions + the templates. Models `IptvLicense` (TYPES/STATUSES maps, `nextNumber()`, `isExpired()`, `daysRemaining()`, `expiringWithin()`, `typeLabel()/statusLabel()/statusColor()/typeColor()`) and `IptvLicenseRenewal`. `IptvLicenseController` (`resources/views/iptv/licenses.blade.php`, routes `iptv-licenses.*` under `['can:view_iptv_licenses','iptv.expire']`): index with 6 summary cards + search (licence no/username/customer/phone/notes) + status/type/package/expiry filters; store/update (agreement upload `mimes:pdf,jpg,jpeg,png,webp|max:2048` to the **public** disk under `iptv_license_agreements/`, `remove_agreement` checkbox deletes the file, replacing deletes the old one, `destroy` deletes it too); `toggle` (active<->suspended); `renew` (stacks on the current expiry when still live, else from today; default days = package `validity_days` or 30; bumps `renewal_count`, clears `last_reminder_at`, reactivates an expired licence, writes a renewal row, logs + notifies); **`applyToUser`** copies package/devices/expiry onto the `iptv_users` row and clears `is_expired` - deliberately a **manual** action so renewing a licence never silently re-dates or re-charges a working subscription; `renewals` JSON endpoint feeding a per-row history modal. `IptvLicenseNotifyService` (`App\Services`) holds the `EVENTS` map, `valuesFor()` and `send()`, gated by the master switch + per-event switch, and `SmsService::templateBody()` (so an inactive template silently skips). `IptvLicenseReminders` (`iptv:license-reminders`, scheduled `dailyAt('09:45')`, run by hand while the scheduler is off): `iptv.license.expiring` for active licences inside the window (default 7 days, `--days`) with same-day dedup on `last_reminder_at`, then a **one-time** `iptv.license.expired` (dedup on an `ActivityLog` description marker) that also flips `status` to `expired`. Settings: `iptv_auto_notify_licenses` master + 4 per-event toggles on `IptvSettingController::$keys/$booleanKeys` (stored in **`isp_settings`**, not `settings`) and the 4 templates in `$userNotificationKeys`; the IPTV Settings page Auto-Notify card gained a licence block (master + issued/renewed/expiring/expired) and the template editor accordion picked the 4 templates up automatically. Nav link under Modules -> IPTV. Live-tested via curl as `admin@example.com`: page 200 (78 KB empty / 122 KB with rows), issue (package auto-inherited from the user, number generated), renew (2026-10-08 -> 2026-11-07) + history JSON, apply (moved user 41 `expiry_date` 2026-10-16 -> 2026-11-07), toggle, all 6 filters, agreement upload (stored + 200 on `/storage/...`) / remove (file deleted, next fetch 404), settings save round-trip (template body + toggles both directions), and `iptv:license-reminders` printing exact skip reasons ("user has no phone number on file", "SMS sending failed (is SMS enabled in Settings?)", "notification disabled for iptv.license.expiring") plus the master switch short-circuit. Test rows/renewals/logs/files deleted, `amla` phone NULL + `expiry_date` restored to 2026-10-16, all 10 IPTV notify toggles back to `'1'`, `sms_enabled` back to `'1'`, `database.sql` regenerated (843,101 B).
  - **Two bugs found while testing**: (1) `IptvLicense::nextNumber()` used the FQN `Illuminate\Support\Str::random()` inside `App\Models` with no leading backslash and no `use` import -> `Class "App\Models\Illuminate\Support\Str" not found`, a 500 on store (the exact failure mode AGENTS keeps warning about; now `use Illuminate\Support\Str;`). (2) A `<div class="modal">` rendered inside the table `<tbody>` - browsers hoist it out of the table, so `btn.closest('tr').querySelector('.modal')` returned null and the history modal would have been dead on arrival; the modals now live in their own loop after the card and the JS resolves them via `document.querySelector(data-bs-target)`.
  - **Pre-existing footgun confirmed while testing (applies to the IPTV settings form)**: `IptvSettingController::update()` writes `is_active = $request->boolean('ntpl_active_<key>')` for every template whose `ntpl_body_<key>` is posted, so a **partial** form POST silently deactivates every template in `$userNotificationKeys` (it bit all 11 `IPTV User Notification` templates during a curl test; restored with `UPDATE notification_templates SET is_active=1 WHERE category='IPTV User Notification'`). Bodies survive a missing field because the loop `continue`s when `ntpl_body_*` is absent - but an **empty** one blanks the body. Always post the whole settings form (all checkboxes + all textareas). Same "checkbox-only PUT blanks fields" family as the IPTV Packages note.
  - **Test-harness notes**: `IspSetting::get()` reads the **`isp_settings`** table, so a settings probe must write there (a `settings` insert looks like it worked but is invisible to the app); and PowerShell eats backticks inside double-quoted strings, which silently breaks SQL like ``SELECT `key` FROM isp_settings`` (the documented mysql `-e` stripping is really a PowerShell escaping issue) - write the SQL to a **single-quoted here-string** file and pipe it in. When driving settings checkboxes with curl, send the input **name** (`iptv_auto_notify_licenses`), not the id (`tnLicMaster`), and extract "currently checked" by name with a regex.
- **SIP Manager - DONE, live-verified end to end** (2026-10-04): standalone module in `app/Http/Controllers/SipController.php` (dashboard + 7 CRUD groups + settings/templates + probes + pruning) + `SipReportController` (CDR/traffic reports + CSV/XLSX exports), 7 tables from `2026_10_04_000001_create_sip_tables.php` (`sip_trunks`/`sip_extensions`/`sip_destinations`/`sip_dialplans`/`sip_devices`/`sip_calls`/`sip_settings`), 5 models, `SipReachabilityService`, `SipNotifyService`, 2 console commands (`sip:check-trunks`, `sip:expire-extensions`), 9 Blade views and 43 `sip.*`/`reports.sip-*` routes. Gated by 5 permissions granted to `super_admin` (id 1) + `admin` (id 2) via seeder parity **and** a live `permission_role` insert - the blocker was that `roles` is keyed by **`name`**, not `slug`.
  - **Live-verified**: login + all 11 pages 200 (`/sip`, `/sip/trunks`, `/sip/extensions`, `/sip/destinations`, `/sip/dialplans`, `/sip/devices`, `/sip/calls`, `/sip/settings`, `/reports/sip-calls`, `/reports/sip-traffic`, `/reports`); 25/25 stage-1 CRUD on a **freshly re-migrated schema** (trunk create/toggle/probe, extension create/renew/suspend/resume/blank-password-edit, destination trunk + non-trunk create, dialplan create/test/toggle/update, device create/revoke); template save + deactivate (body survives an unchecked box); call create/show/update/delete; retention purge; missed-call filters over `no_answer`/`failed`/`cancelled`; validation guards (required fields, duplicate `extension_number`, invalid dialplan regex, RTP range); `sip:check-trunks` reporting both trunks offline with a timeout reason; `sip:expire-extensions` expiring a lapsed extension (idempotent on re-run) and firing the reminder inside the window with same-day dedup; expired-device pruning deactivating the device **and** clearing the extension's `registered_until`.
  - **Permission enforcement proven with a throwaway role** (`sip_readonly` = `view_sip` + `view_reports`, user `sipread@example.test`, both deleted afterwards): read pages 200, add/edit forms and endpoints not rendered, `/sip/settings` 403, and POST create / POST settings update / DELETE call / POST purge / CSV export all 403 with nothing written. Non-SIP `activity_logs` rows preserved (235 remain).
  - **Migration round-trip proven**: `migrate:rollback` dropped all 7 tables + 5 permissions + 4 templates, `migrate` recreated 16 settings + 4 active templates + 5 permissions, and the admin/super-admin grants had to be re-inserted (see the cascade caveat above).
  - **Three real bugs found and fixed during verification**:
    1. **`SipExpireExtensions` reminder window matched nothing, ever** - `$now->addDays($window)` mutated the same Carbon instance used for the first bound, so `whereBetween` collapsed to a zero-width window (and would have stamped `last_reminder_at` 7 days in the future). Fixed with `copy()` per operand; proven by a before/after script (`matches 0` -> `matches 1`) and a live reminder.
    2. **The notification-template form always returned 405** - the view posts `method="POST"` + `@method('PUT')` (same pattern as the sibling settings form) but the route was registered `Route::post('/settings/templates')`, so Laravel's method spoofing rewrote the request to PUT and routing failed. Template bodies had therefore never been editable through the UI. Fixed by registering the route as `Route::put`.
    3. **A `trunk`-type destination could be saved with no trunk** - the comment promised the invariant but the code silently wrote `NULL`; now a `ValidationException`.
    Plus a static sweep of all five validators: the only remaining unguarded optional-key reads (`$data['codecs']`, `$data['max_concurrent_calls']`) are fixed, and every flagged hit is now a `required` key, an LHS write, an `unset()` of a possibly-absent key, or a read of a key assigned two lines earlier.
  - **Footgun re-confirmed live**: a *partial* template POST (bodies without the checkboxes) set `sip.trunk.down` to `is_active=0`, exactly as with the IPTV templates; the migration's idempotent `$templates` array restored it.
  - **Test state restored to baseline**: all 7 SIP tables empty, 0 SIP `activity_logs`, 16 settings at migration defaults (`sip_domain=sip.example.com`, `sip_cdr_retention_days=365`, `sip_extension_reminder_days=7`), 4 templates active with their original bodies, 5/5 SIP grants on both roles, all 21 temp scripts/cookie jars removed.

## Next Move
- **Xtream Users manager (2026-10-05) - done, but one question for the user first**: the module is built, live-verified and cleaned up. Before anything else, ask whether `activity_logs` 375-388 (Clear All of 58 channels, re-import of 58 from `Playlist__5.m3u` under category `Afsomalia`, 9 categories deleted, `hamse` + `amla` deleted at 18:37-18:39) was their own deliberate playlist replacement. There is no pre-incident backup - `database.sql` was dumped at 21:34, after the fact - so if it was *not* them, the previous 58 channels/9 categories/2 users are gone and must be re-imported from the provider. The 2 users were the disposable portal test accounts, so no real subscriber data is affected either way.
- **IPTV licence acceptance (2026-10-05)** - nothing blocking; built, live-verified 56/56 and cleaned up. Two things worth the user's attention before it is used for real subscribers: (a) **the only package has 0 channels assigned**, so any user on it receives an empty playlist - assign channels via Packages -> assign; (b) the **token holder can set/overwrite the subscriber's streaming password**, which is the only way to surface usable credentials given `iptv_users.password` is hashed. If they want that riskier, the alternative is to show the portal link only (no password reset) or gate the password field behind an emailed code.
- **Automatic subscription payments vs invoices (2026-10-04) - NEEDS A DECISION, not a bug fix**: `IptvUserController::store()`/`renew()` write an `IptvPayment` with method `auto`, the package price and **no** `iptv_invoice_id`, so a new/renewed subscription records revenue that no invoice represents, which double-counts once recurring billing is switched on. Options: (a) leave it and have reports exclude `iptv_invoice_id IS NULL` auto rows, (b) have create/renew generate **and** auto-pay an invoice, or (c) drop the auto payment and rely on the invoice. Ask the user which accounting they want before touching it.
- **IPTV VOD/Series + EPG (2026-10-03)** - built and live-verified. Optional follow-ups: seed real VOD/series content (the Android client's Series tab has no data to show yet) and wire an EPG source, since `iptv_epg_cache` is currently 0 rows so every now/next line stays blank.
- **IPTV Licenses (2026-10-03)** - module is built, migrated and live-verified; remaining work is optional: an email channel for the 4 licence notifications (only SMS is wired today), and a **live success path for the SMS notices** (impossible on this host while the SMS gateway is blocked, so `last_reminder_at` stamping + the one-time expired-notice dedup are proven by code path only).
- **Scheduler: user decided on 2026-09-25 to leave `schedule:run` OFF** - do NOT register a Task Scheduler entry without asking again. Run the jobs by hand when needed: `php artisan sms:process-scheduled`, `isp:expire-subscriptions`, `isp:send-reminders`, `iptv:send-reminders`, `iptv:license-reminders`, `isp:billing-cycle`, `iptv:invoice-cycle`, `iptv:invoice-reminders`, `iptv:check-streams`, `iptv:transcode-cleanup`, `iptv:stream-session-cleanup` (use workdir `C:\xampp\htdocs\simcard`). `iptv:transcode-cleanup` + `iptv:stream-session-cleanup` are ALSO compensated in-process (reaper fix below), so the CPU-wasting-encoder problem is already solved without a scheduler. Still unmitigated by hand: `sms:process-scheduled` (queued SMS never drains), `isp:expire-subscriptions`, `isp:send-reminders` (09:00 UTC), `iptv:send-reminders` (09:30 UTC), `isp:billing-cycle` (00:10 UTC - recurring invoices never generated), `iptv:check-streams` (health column never refreshes). Reasons given for leaving it off: `iptv:check-streams` auto-disables a channel after 3 consecutive failed probes and `isp:billing-cycle` starts generating real recurring invoices. If the user changes their mind, the implementation is a Windows Task Scheduler entry running `C:\xampp\php\php.exe C:\xampp\htdocs\simcard\artisan schedule:run` every 1 minute (needs elevation; verify the task actually fires, since a wrong working directory silently breaks every job).
- **SIP Manager (2026-10-04)** - nothing blocking; the module is complete, migrated and verified. Optional if you want it: a real external SIP peer (an actual trunk/registrar) to exercise the **online/up transition** and the TCP/TLS branches of `SipReachabilityService` - an unreachable endpoint can only prove the offline/error path, which is what this host has. Note `sip:check-trunks` is only driven when you run it (the scheduler is off by decision).
- **Android client - install and test the debug APK (2026-09-26)**: the only unverified part of the re-skin. Copy `C:\Users\abdir\Documents\android\app\build\outputs\apk\debug\app-debug.apk` to a device on the same LAN, sign in with a working `iptv_users` row (**note: there are currently 0 IPTV users - create a disposable one first**), and check: the content + category rails and their counts, search, favourites add/remove, EPG now/next (currently 0 EPG rows so it will stay blank), live playback and its LIVE badge, Movies playback (`/movie/...m3u8`), prev/next, the quality chip (auto -> low -> original), mute, fullscreen, back returning to the exact list, and rotation. If an emulator is preferred, none is currently created - `adb`/`emulator` availability is unverified on this host.

## Relevant Files
- `app/Services/XtreamUserInfo.php` - the single source of the Xtream `user_info` payload, used by BOTH the manager and `XtreamApiController::userInfo()`.
- `app/Http/Controllers/IptvController.php` - all 4 channel importers (`importFromM3u` URL + Xtream fallback, `importFromM3uFile` upload, `importFromXtream`, `importFromFgCode`) over the shared `parseM3uBody($body, $notes, $options)`; `cleanImportText()` encoding repair, `detectContentType()`/`detectStreamType()`, `importXtreamAll()`.
- `resources/views/iptv/index.blade.php` - channel list + the "Import Channels from Source" card holding the M3U URL, M3U file upload, Xtream and FG Code blocks (`$packages` fills the upload's package select).
- `app/Http/Controllers/IptvLicenseController.php` - `publicShow()`/`publicAccept()` (the guest secret-URL flow; passes `company`/`logoUrl` from `Setting` and `stream => null` until `isAccepted()`) plus admin `issueLink()`/`revokeLink()`/`acceptances()`.- `app/Models/IptvLicense.php` / `app/Models/IptvLicenseAcceptance.php` - token lifecycle (`issueToken()` resets the latest-acceptance stamp + count but keeps the audit rows; `revokeToken()` nulls the token and stamps `token_revoked_at`) and the per-acceptance audit record.
- `resources/views/iptv/license-agreement.blade.php` - the standalone public page. Credentials are inside the `alreadyAccepted` branch, so nothing leaks before acceptance; **no `Setting::` calls in here**.
- `database/migrations/2026_10_05_000001_add_public_acceptance_to_iptv_licenses_table.php` - `iptv_licenses` token/acceptance columns + `iptv_license_acceptances`; round-trip proven.
- `app/Http/Controllers/SipController.php` - the whole SIP module: `index()` dashboard, 7 CRUD groups, `validateTrunk/Extension/Destination/Dialplan/Call()` (all nullable reads guarded so a partial form can't 500), `probeTrunk()`, `pruneDevices()`, `purgeCalls()` (honours `sip_cdr_retention_days`, ignores any posted `days`), `updateSettings()`, `updateTemplates()`.
- `app/Http/Controllers/SipReportController.php` - CDR + trunk/extension traffic reports and CSV/XLSX exports.
- `app/Services/SipReachabilityService.php` - SIP OPTIONS probe over UDP/TCP/TLS; writes status/last_error/last_checked_at and alerts on transitions.
- `app/Services/SipNotifyService.php` - the 4 `sip.*` SMS notifications via `SmsService::templateBody()`; silently skips an inactive template or a missing destination number.
- `app/Console/Commands/SipExpireExtensions.php` - expiry + reminder window (`$now->copy()->...` per bound, see the Carbon note) + same-day dedup. Run by hand: `php artisan sip:expire-extensions` (also `--dry-run`).
- `app/Console/Commands/SipCheckTrunks.php` - probes every active trunk and prints offline reasons. Run by hand.
- `database/migrations/2026_10_04_000001_create_sip_tables.php` - 7 tables + 5 permissions + 16 settings + 4 templates; both `up()` loops use `updateOrInsert`, so re-running it is the way to restore the seeds. **`down()` also deletes the permissions, which cascades away the role grants.**
- `resources/views/sip/*.blade.php` (9 views) - both settings forms use `POST + @method('PUT')`; the placeholder documentation is escaped `@{{placeholder}}` so Blade does not try to resolve it.
- `app/Services/TranscodeService.php` - shared per-channel multi-variant FFmpeg/HLS encoder: `ensureSession()` (flock-serialised spawn + cap), `playlist()`/`portalPlaylist()`, `warmupPlaylist()`, `atCapacity()`, `evictIdleSession()`, `cleanup()` + `reapIdleSessions()` (idle reaper), `resolveVariant()`, `touchViewer()`.
- `app/Services/StreamSessionService.php` - live-viewer heartbeat rows per user+channel+source; `viewersForChannel()` feeds the Stream Monitor and Live Streams pages.
- `app/Http/Controllers/XtreamApiController.php` - the API the Android client and every Xtream app use: `playerApi()` (`player_api.php`), `getPlaylist()` (`get.php`), `streamAccess()`, `relay()` (manifest rewriting + byte counting + Range/206), `transcodeSegment()`, `userInfo()` (`active_cons`/`max_connections`/data usage), `registerConnection()` (same-IP device collapse + stale-only eviction), `xmltv()`.
- `app/Http/Controllers/IptvPortalController.php` + `resources/views/iptv/portal-player.blade.php` - the web portal player that the Android UI mirrors; also owns `manifest()` (TV mode) and the bounded `buildPortalEpg()`.
- `app/Http/Controllers/IptvSettingController.php` + `resources/views/iptv/settings.blade.php` - every `iptv_*` setting: transcoder, variants, process cap, idle seconds, quality indicator, low-internet mode, logos, ticker, login notice.
- `app/Http/Controllers/IptvStreamMonitorController.php`, `IptvSessionController.php`, `IptvHealthController.php` - Transcoder monitor, live/stale sessions, and channel health checks.
- `app/Console/Commands/IptvTranscodeCleanup.php`, `IptvStreamSessionCleanup.php`, `IptvCheckStreams.php` + `routes/console.php` - the scheduled jobs; **not driven by Task Scheduler on this host**, run them by hand.
- `app/Http/Controllers/IptvBillingController.php` - 14 `iptv.billing.*` actions: invoice CRUD/cancel/print, `postPayment()` (invoice + standalone), partial payments, refunds, wallet/reseller charging, CSV exports. Helpers `walletSims()`/`activeResellers()` feed the selects (views never query).
- `app/Models/IptvInvoice.php` / `IptvInvoiceItem.php` / `IptvPayment.php` - totals, discount, balance, `daysUntilDue()`, `daysOverdue()`, `isOverdue()`, `recalculate()`, `nextNumber()`. **NB `iptv_payments` uses `user_id`/`package_id`, while `iptv_invoices`/`iptv_licenses` use `iptv_user_id`** - they are not symmetrical.
- `app/Console/Commands/IptvInvoiceCycle.php` (`iptv:invoice-cycle --days=`) + `IptvInvoiceReminders.php` (`iptv:invoice-reminders --days=`) - recurring generation + overdue sweep, and due-soon/overdue SMS reminders with same-day + weekly-milestone dedup.
- `resources/views/iptv/billing{,-create,-show,-print,-payments}.blade.php` + `resources/views/iptv/settings.blade.php` - billing UI, and the invoice notification toggles / **Payment terms (days) + Billing currency** inputs.
- `database/migrations/2026_10_03_000002_create_iptv_invoice_tables.php` - billing schema, the 3 `iptv.invoice.*` templates, the `iptv_payments.paid_at` immutable default, and the 4 `isp_settings` defaults.
- `resources/views/iptv/user-show.blade.php` - Xtream Access card (server URL / username / password / M3U) and per-user stream quality.
- `database/migrations/2026_09_15_000001_add_stream_quality_to_iptv_users_table.php` - `iptv_users.stream_quality` (`original|auto|low|med|hd`).
- `database.sql` - full dump; regenerate after any DB-affecting change with `mysqldump -u root --default-character-set=utf8mb4 simcard > database.sql`.
- `C:\Users\abdir\Documents\android\README.md` - Android client overview, offline build command, panel/quality semantics, project layout, known limits.
- `C:\Users\abdir\Documents\android\app\src\main\java\com\casri\iptv\net\XtreamApi.java` - catalogue (`get_live_categories`/`get_live_streams`/`get_vod_streams`/series count), `ensureEpg()`, sport keyword filter, `buildStreamUrl()`/`buildVodStreamUrl()`.
- `C:\Users\abdir\Documents\android\app\src\main\java\com\casri\iptv\ui\` - `MainActivity` (tab shell + playback queue), `ChannelListFragment` (content/category rails, search, favourites, EPG), `tv/TVFragment`, `sport/SportFragment`, `home/HomeFragment`, `player/ExoPlayerFragment`.
- `C:\Users\abdir\Documents\android\app\build\outputs\apk\debug\app-debug.apk` - current installable build (~10.7 MB, unsigned release).

