mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
8a5b29ea4dd286f8dddb8734a2fb2eb02d028fa5
243
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8a5b29ea4d | feat(downloads): surface missing files for recovery | ||
|
|
596f5a8d4d | feat(downloads): separate missing files from ready media | ||
|
|
2899091b74 | feat(downloads): refresh missing local files | ||
|
|
26b20658e3 | fix(downloads): keep offline Xtream playback available | ||
|
|
5511d66651 | fix(xtream): ignore stale VOD positions | ||
|
|
1d996b85f0 | fix(xtream): isolate reused detail identities | ||
|
|
6500807701 | docs(downloads): clarify Stalker navigation fallback | ||
|
|
3eda25ac3c | fix(downloads): open Stalker items in provider details | ||
|
|
89e708c591 | fix(downloads): prefer local playback in Xtream details | ||
|
|
34fa69e6f5 | fix(downloads): honor the global cover size | ||
|
|
727ea58bd7 | test(downloads): cover pending series navigation | ||
|
|
4f3567e1d0 | fix(downloads): open completed movies in details | ||
|
|
bcc2e36a05 | feat(downloads): finish manager MVP | ||
|
|
eb55c17a05 | feat(downloads): add active download queue | ||
|
|
8e1af010a9 | feat(downloads): add ready-to-watch library | ||
|
|
28770aad09 | fix(downloads): report library navigation failures | ||
|
|
ce251e5f9b | refactor(downloads): isolate library navigation | ||
|
|
bff9ca1491 | fix(downloads): stabilize malformed view model data | ||
|
|
aae5450f4c | test(downloads): close view model coverage gaps | ||
|
|
d6e1ee5d0c | feat(downloads): derive queue and library view model | ||
|
|
117bd75f01 | fix(downloads): keep renderer download state global | ||
|
|
46c7713841 |
fix(ui): preserve EPG in narrow channel rows (#1312)
Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views. |
||
|
|
2ac0de752f |
fix(skills): align repository guidance with implementation (#1315)
* docs(skills): design implementation synchronization * docs(skills): plan implementation synchronization * fix(release): filter internal notes from public body * docs(release): synchronize release workflow guidance * fix(stalker): normalize catalog series flags * fix(stalker): preserve progress with scoped episode IDs * fix(playback): expose strict position persistence * docs(stalker): record series position compatibility * test(skills): validate repository skill contracts * fix(database): keep SQL trace values private * docs(skills): refresh Nx and SQLite ownership * docs(skills): align provider and UI guidance * docs(skills): tighten validated guidance * docs(release): require exact release pushes * style(electron): remove trailing blank line * fix(ci): classify repository skills coverage |
||
|
|
32ba209b63 |
fix(portals): restore fresh-import pins atomically (#1311)
* fix(portals): restore fresh-import pins atomically * fix(portals): preserve Xtream restore retry state * fix(portals): serialize Xtream restore revisions |
||
|
|
063662028a |
feat(portals): find the same movie in your other playlists (#1286)
* feat(portals): find the same movie in your other playlists A movie that exists in several imported Xtream playlists now shows a "Sources N" chip on its detail page and in the player. Switching playlist mid-film keeps the timecode, a preferred source can be pinned per movie, and a failed stream offers the alternatives instead of a dead end. The governing rule is that a guess is never presented as a fact. Every metadata value carries where it came from — `api` (the provider said so), `parsed` (inferred from the title) or `probe` (we contacted the stream). Facts render as plain tags, guesses are prefixed `~` in a warning colour, and an unknown value renders no tag at all plus a "check" affordance. Ranking and failover read through `factualOnly()`, so a filename claiming 4K is structurally unable to outrank a source that was actually reached. A probe that could not complete reports "unknown", never "unavailable". Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces live content; both are additive later, since the candidate type already carries all three portal kinds. In the PWA every entry point is gated off and the chip renders nothing. Auto-failover is opt-in and off by default. Each source is tried at most once per session, so it terminates structurally, and the switch is never silent — the toast names the new playlist, offers an undo, and warns that the dub may differ only when both sides state an audio track as fact. Notable details: - Playlist names are routinely the pasted URL, credentials included. They are never rendered raw; a short host-only label is derived instead. - Quality is derived from pixel width, not height: a 2.39:1 1080p master is 1920x800, and bucketing that by height would publish "720p" as a fact. - Switching is a single `inlinePlayback.set()` so the player and engine survive and re-seek; the carried position is read before the 15s persistence throttle so it does not rewind. - Sources from one playlist collapse into a group, since the same film often appears there several times under different stream ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portals): stop stale source resolutions from committing Addresses three defects Greptile found in the multi-source review. **Concurrent switches committed out of order.** Selecting a second source before the first resolution returned let the slower request overwrite the newer selection and repoint Undo at itself. `switchTo` now takes a sequence number and drops its result if a newer switch already committed. **Stale switches crossed movie sessions.** Navigating to another film while a resolution was in flight let the continuation activate the old film's source inside the new controller — and restart it from that session's zero resume position. The controller is now snapshotted per operation and the movie session is revalidated after every await. `check()` had the same hazard across its two awaits and is guarded the same way. **Short titles skipped discovery entirely.** The trigram tokenizer cannot index tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH expression and the query was discarded before SQLite was consulted — the chip could never appear for those films. Discovery now falls back to a bounded scan when FTS structurally cannot serve the title; the existing two-tier normalized confirmation still rejects loose hits like "Upgrade". Each fix carries a regression test; all three were mutation-checked by removing the guard and confirming exactly those tests fail. The previous test asserting that short titles return nothing encoded the bug and has been replaced. The host spec passed 400 lines, so its fixtures moved to a shared module and the race suite into its own file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portals): make the pin decide playback and keep failover going Second round of Greptile review findings. **A pin had no behavioural effect.** Loading a stored pin only decorated the row: Play still started the route's playlist and failover ranking ignored `isPinned`, so "make this the main source" survived a restart as an icon and nothing else. The primary action now starts from the pinned source when one is set, and the pin outranks everything else in failover ranking. **Failover stopped at the first unresolvable candidate.** An expired account or a failing `get_vod_info` on the top-ranked source ended the attempt, and since production calls `failover()` only once — on the original playback failure — a healthy lower-ranked source was never reached. It now continues through untried candidates. `switchTo` reports why it stopped so the loop can tell "could not resolve, try the next one" from "something newer owns the screen"; without that distinction a superseded switch would have spun forever, because only the former marks the candidate tried. **Identity ignored enrichment.** The key was `playlistId:contentId:title`, so when `get_vod_info` added a TMDB id and release year to an unchanged title the host saw no change, never reloaded, and kept yearless discovery and title-only pin keys — a `tmdb:`-keyed pin could never be found. The key now covers every field that affects matching. **A server refusing HEAD read as unavailable.** Some stream hosts answer 405 or 501 to HEAD yet serve the media over GET. The probe now retries once with the ranged GET the main process already supported, instead of caching a working source as failed and penalising it during failover. Greptile also flagged a missing token check after the resolve await in `switchTo`; that guard landed in |
||
|
|
deae0a2a4d |
fix(xtream): keep sparse VOD details playable (#1303)
* fix(xtream): keep sparse VOD details playable * fix(xtream): scope VOD fallback to active playlist * fix(xtream): render sparse VOD before recovery * fix(xtream): recover Similar VOD provider categories |
||
|
|
80af9257a0 |
refactor(portals): share external-button and position-writer logic (#1298)
The Xtream and Stalker VOD detail views each carried a private copy of two behaviours: deriving the Play/Stop button state from the active external (MPV/VLC) session, and throttled persistence of the inline player position. A Play button or a resume point that behaves differently per portal is the kind of divergence users notice, so both now read from one implementation. Extracts `createExternalPlaybackButtonState` and `createInlinePlaybackPositionWriter` into portal/shared/util, and lifts the Stalker VOD download errand into its own helper. Behaviour is unchanged; the shared helpers are deliberately identical to the copies they replace. This also brings both hosts back under the 400-line ESLint limit, neither of which was baselined: vod-details.component.ts 389 -> 333 stalker-catalog-detail.component 394 -> 325 vod-details-playback.service.ts 345 -> 275 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a2fafcfc08 |
test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark * feat(xtream-mock-server): add deterministic 100k fixture * style(xtream-mock-server): apply repository formatting * fix(xtream-mock-server): harden performance fixture data * feat(xtream-mock-server): add performance control plane * docs(performance): correct Xtream capture plan * fix(xtream-mock-server): harden performance controls * fix(xtream-mock-server): harden control lifecycle * feat(performance): add Xtream preload markers * feat(performance): trace Xtream main phases * feat(performance): mark Xtream store publications * feat(performance): trace Xtream database phases * feat(performance): trace Xtream delete cancellation * feat(performance): capture Xtream phase attribution * feat(performance): mark Sources Xtream refresh * test(performance): define Xtream benchmark evidence contracts * test(performance): add Xtream benchmark runner * test(performance): surface failure evidence writes * test(performance): align database read clock * test(performance): preserve capture failure contracts |
||
|
|
6bdd6fd8a8 |
fix(playlists): serialize per-playlist collection writes to prevent lost updates (#1255)
* fix(playlists): serialize per-playlist collection writes to prevent lost updates All per-playlist mutations (portal favorites, recently viewed, playlist meta/favorites updates) used an uncoordinated read -> patch -> replace-whole-row pattern, so two overlapping mutations on the same playlist were last-write-wins and silently dropped each other's changes (flagged by Greptile on PR #1253). Chain every read-modify-write through a per-playlist promise queue (Map<playlistId, Promise>) inside defer(), covering both the SQLite upsert and IndexedDB update paths while keeping the Observable-based public API, laziness, and emitted values unchanged. A failed mutation does not wedge the queue, and different playlists are not serialized against each other. Regression coverage: overlapping favorite+recently-viewed adds, two rapid favorite adds, IndexedDB-path overlap (all three fail on the old code), plus queue-continues-after-error and cross-playlist independence guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): close serialization gaps flagged by review bots Greptile P1 (writers bypassing the queue): route updateManyPlaylists and updatePlaylistPositions through the same per-playlist write queue. Auto-refresh batch writes now re-read the stored row inside the queue and preserve user-owned fields (favorites, recently viewed, position) instead of writing a pre-refresh snapshot over them; position updates re-read and patch inside the queue on both storage paths. Codex P1 (callers precompute stale snapshots): add an atomic PlaylistsService.transformPlaylistFavorites(playlistId, transform) that applies the favorites transform to the freshly-read row inside the queue, and convert every read-then-set call site to it: UnifiedFavoritesDataService M3U add/remove/clear/reorder and Stalker reorder/clear, GlobalFavoritesService M3U removal, DashboardDataService M3U removal. Reorders now keep concurrently added favorites (appended after the dragged order) instead of dropping them. New coverage: overlapping favorites transforms, auto-refresh batch write vs queued favorite add, position update vs queued favorite add, and a public addFavorite race through UnifiedFavoritesDataService; existing specs updated to the transform-based contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): share the canonical refresh merge with the auto-refresh batch The batch path previously spread the stale refresh snapshot over the freshly read row and pinned only favorites/recently-viewed/position, so a queued metadata mutation (hiddenGroupTitles, curated EPG sources) finishing before the refresh write could be reverted. Extract updatePlaylist's merge into mergeRefreshedPlaylist() and use it for both the single-playlist update flow and updateManyPlaylists: refresh-owned data (parsed content, count, EPG detection) comes from the payload, user-owned state comes from the current row, and manual/disabled EPG configuration is resolved through resolvePlaylistEpgSourceState instead of being overwritten. Regression test: queued hiddenGroupTitles meta update overlapping an auto-refresh batch write keeps both the metadata change and the refreshed content, including preserved manualEpgUrls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): let the current row decide autoRefresh during batch refresh The batch path force-set autoRefresh: true after the merge, so disabling auto-refresh while a refresh was in flight was reverted by the completing write. Drop the override — mergeRefreshedPlaylist already prefers the current row's autoRefresh over the snapshot — and add a count fallback to the snapshot value for rows without a stored copy. Regression test: disabling auto-refresh concurrently with the batch write keeps autoRefresh false while still applying the refreshed content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9885178f32 |
fix(stalker): refresh stale embedded-series snapshots from favorites and dashboard (#1253)
Favorites and recently-viewed rows store Stalker items as full JSON snapshots, so a vclub-style embedded series[] episode list froze at the moment the row was written: a series favorited when only episode 1 was out kept showing one episode forever when opened from favorites, recents, Continue Watching, or any dashboard rail. New withStalkerSnapshotRefresh() store feature renders the stored snapshot immediately and re-fetches the item from the portal in the background via a title search (get_ordered_list&type=vod&search=..., matched by id, paginated up to 5 pages, wildcard-category retry), patching fresh episodes and cmd into the active selection. The patch is guarded on both the item id and the active playlist id, since Stalker ids are only unique per portal. Only the in-memory selection is patched — the stored snapshot row is deliberately left alone, because every entry path into the detail view runs this refresh and writing it back would add an uncontrolled background writer to the whole-playlist read-modify-write that every favorite/recent mutation performs. Also fixes the stalker-mock-server embedded-series scenario, which generated series[] as objects the app's vclub adapters filter out instead of the episode-number arrays real portals send. Regular type=series and Ministra is_series items are unaffected; Xtream is unaffected (get_series_info is never cached). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d63f76407 |
perf(stalker): skip wasted series-seasons request for non-series items (#1241)
`setSelectedItem` mirrored every selection's id into `selectedSerialId`, and `serialSeasonsResource` fires a `get_ordered_list&type=series&movie_id=<id>` portal request on every change of that id. Opening any Stalker detail page — plain VOD, vclub items with embedded `series[]` (whose result `mapRegularSeriesSeasons` discards), Ministra `is_series` items, and ITV channel clicks — therefore issued a pointless request, on every entry path (browse, favorites, recent, dashboard, search). Set `selectedSerialId` only when `selectedContentType === 'series'`, clearing it otherwise. The gate is deliberately on content type alone, not item shape: under the `series` content type `serialSeasonsResource` is the only episode source (the detail templates render `<app-stalker-series-view />` with no `vodWithSeries` input, and `isVodSeries()` requires content type `vod`), so gating on `is_series`/`series[]` would leave a series-section item carrying either field with a silently empty episode list. Adds selection-state and request-level regression coverage, and corrects a stale invariant in the Stalker architecture docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8e1320cb34 |
feat(tmdb): series production-status chip and person death dates (#1240)
Two fields TMDB already sends us and the merge threw away — no new API calls, no cache-key bump, they light up on existing cached payloads. Series detail views (Xtream and Stalker) gain a production-status chip: "Ended" tells you a show is finished before you commit to it, "Returning" that it is not. TMDB returns `status` as an ENGLISH string even under language=ru-RU, so it is normalized to a stable token (normalizeSeriesStatus) and rendered through translated labels (seriesStatusLabelKey). Unknown values are dropped rather than shown, so a status TMDB adds later can never leak raw English into 19 locales. Person pages render `deathday`, which mapPersonProfile has always parsed into ActorProfile and no template ever read. i18n: 7 keys across all 19 locales via the tools/i18n workflow. Tests: status normalization (token mapping, case-insensitivity, the British "cancelled" spelling, unknown/missing dropped). Docs: tmdb-metadata-enrichment.md, CLAUDE.md. Refs docs/architecture/tmdb-roadmap.md C1 and the zero-extra-call tier. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4ca2b6852e |
feat(playback): Up Next episode rail for the inline series player (#1231)
* feat(playback): Up Next episode rail for the inline series player On wide windows the inline series player now docks left and fills the leftover stage column with a Netflix-style "Up Next" rail: the rest of the current season plus next-season spillover, the playing episode highlighted, and watch-progress bars from playback positions. Clicking an episode plays it inline through the host's existing episode flow (Xtream serial-details and Stalker series view). - New app-up-next-rail component + buildUpNextRailItems() util in ui/playback; entries carry the host's raw episode object so selection needs no id lookup. - PortalInlinePlayerComponent measures the theater stage with a ResizeObserver and docks the rail only when the leftover beside the 16:9 player is >= 320px; narrower stages keep the centered theater/ambient behavior from #1223. Movies and live never show the rail. - New playerUpNextRail setting (Settings > Playback, default on, built-in web players only), mirroring playerAmbientMode; enforced at runtime for non-web engines. - i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in all 18 locales. - The rail renders as an opaque panel on top of the stage, so the ambient fill stays behind it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): address Greptile review on the Up Next rail - Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo has no global reset), so the docked-rail modifier's 12px padding widened the stage past its container and the right edge was clipped. - Width gate: compute the width the rail actually receives (stage minus the docked layout's padding, the height-driven 16:9 player, and the flex gap) instead of raw stage slack, and observe the stage's border box so the modifier's own padding cannot feed back into the measurement. - Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until opened, so the rail's next-season spillover stopped at the current season. Prefetch the following season while an episode plays inline. Adds regression coverage for the gate boundary, gate stability across the padding toggle, and the lazy-season prefetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): stop the rail spillover prefetch from retrying forever A failed or genuinely empty Ministra season resets isLoading while leaving episodes empty, so the prefetch effect re-requested the same season on every emission for as long as inline playback continued. Remember which seasons this view already requested and ask at most once each. Regression test asserts the empty-response case fetches exactly once and does not retrigger on further playback in the same season. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): let a failed spillover prefetch recover on the next episode The previous guard was permanent, so a transient network or authorization failure disabled the rail's next-season prefetch for the component's lifetime. Distinguish the two outcomes instead: - Answered (even with zero episodes) — a real answer, never asked again. - Failed — the claim is released, but pinned to the episode that triggered it, so the retry waits for the next playback change. Retrying immediately would loop, since the failure itself flips isLoading and re-runs the effect. The claim is taken synchronously; awaiting first let the isLoading flip re-run the effect and fire a duplicate request before the answer arrived. `loadEpisodesForSeason` now reports whether the portal answered; existing callers ignore the result and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6dc8a10d52 |
feat(playback): show media title overlay in fullscreen shared controls (#1228)
* feat(playback): show media title overlay in fullscreen shared controls In fullscreen with the shared player-controls layer, a pointer-transparent overlay at the top of the player now names the content while controls are revealed: one line for movies and live channels, two lines for series (series name + S01E03 label). The overlay follows the bar's auto-hide transition and stays hidden outside fullscreen, where page chrome already names the content. Data flows top-down: the Xtream/Stalker series detail views pass the series name via a new PortalInlinePlayerComponent.seriesTitle input (Xtream episode playback titles carry the episode name, not the series), the inline player builds the two-line form from the episode metadata label, and WebPlayerViewComponent falls back to playback.title for movies/channels while skipping raw stream-URL fallbacks. All four shared-controls hosts (HTML5, Video.js, ArtPlayer, Embedded MPV frame-copy) forward the value. To stay under the max-lines limit, scrub/timeline state is extracted into ControlsTimeline and the playback-diagnostics display helpers into web-player-view-diagnostics.utils.ts, with behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: mirror fullscreen media-title contract into AGENTS.md Addresses Codex review: the Shared Player Controls section in AGENTS.md must stay in sync with the CLAUDE.md description of the new mediaTitle input, fullscreen overlay behavior, and series-title wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
188f5c4b56 |
feat(downloads): pause and resume support for the download manager (#1147)
Adds a paused state to the Electron download manager with a full partial-file lifecycle: - Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable. - Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed. - Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update. - Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids. - Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only. - UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales. - Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly. Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
0273ded8e2 |
fix(tmdb): resolve season number from title markers for per-season series slices (#1229)
* fix(tmdb): resolve season number from title markers for per-season series slices
Providers often slice a show into per-season catalog items ("The
Mandalorian (2 season)", "Пацаны 2 сезон", "The Boys S05") and renumber
the single contained season to 1, so season enrichment fetched the wrong
TMDB season (season 1 metadata for a season 2 item).
- new season-marker.util.ts in shared/interfaces: extractSeasonFromTitle
(word-first, number-first and S-form markers, bracketed or trailing)
and resolveEnrichmentSeasonNumber (title marker wins only for
single-season items whose provider number disagrees)
- wired into Xtream enrichSerialSeasonWithTmdb and the Stalker
series-view season service (cache/overlay still keyed by provider
season key)
- SEASON_SUFFIX_PATTERN now also strips number-first season suffixes
("2 season", "2 сезон", "2-й сезон" incl. NFD-decomposed ordinals) so
such titles match the show at all
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): wait for the season map before TMDB season fetch
The TMDB match can arrive before the async season resource; fetching
then passed seasonCount 0, suppressed the title-marker override and
cached the wrong season forever (fetchSeason is idempotent). The effect
now reads the season map tracked and skips while it is empty —
overlay-driven re-runs are safe because fetchSeason early-returns per
(tmdbId, seasonKey).
Addresses Greptile review on #1229.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): reset season selection on detail-to-detail navigation
The router reuses the series view for detail-to-detail navigation, and a
retained season selection let the NEW item's tmdb_id pair with the
PREVIOUS series' season context in the TMDB fetch effect, poisoning its
idempotent per-season cache before the new season resource loaded. The
selection is now a linkedSignal keyed on the displayed item's identity —
compared inside the computation, since displayItem produces a fresh
object on every recomputation.
Addresses Greptile review round 2 on #1229.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): include the resolved season in the TMDB season cache identity
Per-season slices of one show share (tmdbId, provider season key "1")
but resolve to different TMDB seasons — plain key idempotency served the
first slice's episodes to every later slice. Each cache entry now
records the resolved season it was fetched for: a call resolving a
different season refetches and overwrites (also self-healing a fetch
made with stale navigation context), an in-flight marker dedups
concurrent runs, and a superseded fetch may not store its result.
Failed fetches stay uncached so later triggers retry.
Addresses Codex review (P1) and Greptile review round 3 on #1229.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): drop a mismatched season cache entry before its replacement fetch
If a replacement fetch (same key, different resolved season) failed, the
previous slice's entry stayed visible indefinitely through overlay() and
descriptions(). The mismatched entry is now removed up front, so a
failed replacement falls back to provider data until a retry succeeds.
Addresses Codex review (P2) on #1229.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): title-based season reset identity and o_name marker support
- The season-selection reset identity now combines provider id and title:
distinct items can share or lack provider ids, and an id-only identity
retained the previous item's selection across such navigation
- The season marker is read from whichever title field carries it via
pickSeasonMarkedTitle: providers put the descriptive title in o_name
while name stays generic, and the show-level match already used o_name
Addresses Greptile review round 4 (P1) and Codex review (P2) on #1229.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): gate TMDB season fetch on resource coherence, not selection resets
Resetting the parent season selection on item identity change (previous
round) silently disabled enrichment after detail-to-detail navigation
between items sharing one season-key set: the season container keeps its
own selection and deduplicates seasonSelected emissions, so the parent
key stayed null forever. The reset is gone; instead the fetch effect
gates on coherence — it waits while the season resource reloads (the
window in which a reused component pairs the new item's tmdb_id with the
previous item's map) and requires the selected key to exist in the map
with episodes. Stale-snapshot fetches remain self-healing through the
resolution-aware cache.
Addresses Codex review (P2) and Greptile review round 5 on #1229.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5aa44d19d4 |
feat(tmdb): clickable director/creator chips and directing credits on person pages (#1227)
* feat(tmdb): clickable director/creator chips and directing credits on person pages Directors were plain merged text — no photos, no navigation — while the data was already sitting in the cached TMDB payloads (credits.crew and created_by both carry id + profile_path; they just were not typed or parsed). - tmdb-merge: enrichedDirectors (crew, job === 'Director', deduped by person id) and enrichedCreators (created_by) produce the same chip shape as the cast (TmdbEnrichedCastMember) into a new tmdb_directors field on all three merges (Xtream VOD, Xtream series, Stalker); types widened (crew id/profile_path, created_by id/profile_path). - Detail views (shared VodDetailsComponent, Xtream vod/serial routes, Stalker series view) render the Director row as clickable avatar chips when tmdb_directors is present — same markup and openActor handler as the cast strip — falling back to the plain text otherwise. Stalker re-normalization allowlist preserves the new field. - Person pages: mapPersonFilmography now merges combined_credits.crew (jobs Director/Creator) into the filmography — acting wins the per-title dedup, directing-only titles show the job in the character slot. Everything else (library matching, All-portals scope, filters, search fallback, back button) works unchanged because the person page is role-agnostic. Existing caches work as-is: crew/created_by were always part of the stored payloads. Tests: merge spec (director/creator chips + crew-row dedup ×3 merges), person spec (crew credits, Producer excluded, acting-wins dedup), stalker-vod.utils passthrough. Docs updated (CLAUDE.md + architecture). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): address director-pages review — split oversized spec, stable track keys, translated crew roles - tmdb-merge.spec.ts grew past the 400-line lint ceiling — the Stalker merge suite moved to tmdb-merge-stalker.spec.ts (fixes the CI Lint job). - All cast/director chip loops now track by TMDB person id with an index fallback ('p<id>' / 'i<index>') instead of member.name — distinct people can share a name and creator payloads carry no dedup (greptile). - Directing-only filmography credits carry the role in a new crewJob field ('Director' | 'Creator') instead of stuffing TMDB's raw English job into character; ActorViewComponent renders it through translated labels (XTREAM.CREW_JOB_DIRECTOR/CREATOR, added to all 18 locales via the i18n patch workflow, matching each locale's existing glossary — pt "Diretor", de "Regisseur") (Codex). Tests: person spec asserts character/crewJob separation; merge suites green after the split (15 + stalker file). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b3e130aa65 |
feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209)
Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs. |
||
|
|
d308749e2c | fix(stalker): preserve is_series episode metadata (#1218) | ||
|
|
48ff4b9cc5 |
fix(portal): remove redundant catalog type badges (#1217)
Remove redundant LIVE, VOD, and SERIES badges from homogeneous Xtream and Stalker catalog grids while preserving mixed-content badges and type-driven grid behavior. |
||
|
|
bc6e7018e0 |
fix(epg): harden three latent edges from the #1165 manual-mapping review (#1214)
* fix(epg): harden three latent edges from the #1165 manual-mapping review Follow-up to #1165 (manual EPG-to-channel mapping). Three minor but real issues flagged by the bot reviews on #1173, all in already-merged #1165 code rather than the Stalker delta: 1. EPG program dedup ignored source_url. The unique index and upsert key (channel_id, start, title) collapsed programmes imported from different XMLTV sources that shared those columns, and the upsert reassigned source_url to the last importer — so source-scoped queries could miss a programme and source-scoped deletes could drop another source's row. The key and index now include source_url (migrated via a _v2 index that drops the old source-blind one); the upsert no longer overwrites source_url. 2. Xtream getMapping fallback capped candidate streams at an unordered first five, so a mapping saved under a later stream sharing the provider epg_channel_id was silently ignored. Replaced the two-step fetch-then-lookup with a single content⋈categories⋈mappings join that finds a mapping under any matching stream, with no arbitrary cap. 3. The Xtream mapping dialog did not refresh after closing, unlike the Stalker path, so a remapped visible/selected channel kept its stale preview until the 5-minute TTL or a rescroll. Added EpgQueueService.invalidate(streamId) and a before/after mapping compare in the channel-list dialog flow that invalidates the cache and refetches the current viewport when the mapping actually changed. Tests: source-aware dedup index/upsert assertions; join-based getMapping resolution regardless of stream position; EpgQueueService.invalidate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(epg): address review feedback on the #1165 follow-up - portal-channels-list: forward the already-validated playlistId into the mapping dialog instead of re-reading currentPlaylist() after the async getEpgMapping roundtrip (which could return undefined on navigation) - EpgQueueService.invalidate(): bump a per-stream invalidation epoch and clear inFlight so a request already running when the mapping changes has its (pre-change) result discarded via an epoch check in fetchEpg, and the immediate re-enqueue can schedule a fresh mapping-aware fetch - getMapping Xtream fallback: order the join deterministically before limit(1) so the resolved mapping is stable; documented that this backend layer has no caller playlist context and is a best-effort net behind the renderer's playlist-scoped resolution Tests: in-flight staleness discard for invalidate(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(epg): split EpgQueueService invalidation specs under the max-lines limit The added invalidate() tests pushed epg-queue.service.spec.ts to 415 lines, over the 400-line ESLint cap (and the baseline must not grow). Moved them to a focused epg-queue-invalidation.spec.ts; both files are now under the limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(epg): resolve second-order review findings on the mapping follow-up Two P2 issues Codex raised on the previous fixes: - Unscoped program lookups could return the same programme twice now that the dedup index preserves per-source rows: the M3U timeline calls getChannelPrograms without sourceUrls, so two sources sharing channel/start/title both surfaced. Added toEpgProgams(), which collapses duplicate channel|start|title slots after mapping/validation, applied at every getChannelPrograms return. - EpgQueueService.fetchEpg unconditionally cleared the in-flight marker in its finally, which could drop a marker a re-enqueued request took over after invalidate(). It now only releases the marker when the completing request still owns it (epoch unchanged), preserving per-stream dedup and concurrency accounting. Tests: unscoped duplicate-slot collapse; stale request preserving a fresh in-flight marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(epg): deduplicate program rows in SQL before applying row limits Codex follow-up: the JS-level slot dedup ran after the SQL LIMIT, so duplicate cross-source rows consumed the cap and truncated real data. Moved the dedup into SQL with GROUP BY, applied before the limits: - selectChannelPrograms / selectLegacyChannelPrograms: GROUP BY (channel_id, start, title) before ORDER BY start LIMIT 500, so the timeline cap counts distinct programmes rather than duplicate rows - selectCurrentProgramsForChannelIds: GROUP BY channel_id before LIMIT channelIds.length, so duplicate cross-source current slots can't starve other channels of their current-programme preview The JS toEpgPrograms() dedup stays as a safety net (e.g. legacy NULL-source rows the unique index treats as distinct). Test query-chain mocks updated for the new groupBy link. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c707af1334 |
feat(epg): manual EPG mapping for Stalker portals (#1173)
* feat(epg): manual EPG mapping for Stalker portals Extends the manual EPG-to-channel mapping (PR #1165) to Stalker: - shared key helper buildStalkerEpgMappingKey — playlist-scoped stalker:{playlistId}:{channelId} keys, mirroring the Xtream scheme - ITV sidebar: right-click context menu with "Map EPG channel"; after the dialog closes with a change, the bulk EPG cache is rebuilt so the panel and row previews reflect the new mapping immediately - withStalkerEpg().applyMappedItvEpg(): batch-resolves mappings for rendered channels (one getEpgMappingsBatch IPC per new id set) and overlays uploaded-XMLTV programs onto bulkItvEpgByChannel; overrides survive ensureBulkItvEpg reloads - stream-resolver: mapping check in loadStalkerEpgItems (detail) and batched prefetch in loadStalkerEpgBatch (previews); mappingCandidateKeys/prefetchEpgMappings generalized beyond Xtream - global favorites: stalker branch for the Map-EPG menu entry (item id extracted from the stalker::{playlistId}::{id} uid) - docs: manual-mapping section in docs/architecture/stalker-epg.md and a CLAUDE.md EPG bullet covering the whole mapping feature - tests: applyMappedItvEpg suite, stalker preview mapping in the stream-resolver spec, key-builder specs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(epg): harden Stalker mapping edges found in adversarial review - stream-resolver: stalker items resolve only the playlist-scoped mapping key — bare tvgId/name candidates (tvgId mirrors the raw provider id) could only produce false matches against unrelated M3U mappings, and previews would disagree with the detail path - applyMappedItvEpg: staleness guard after every await so an in-flight call cannot write portal A's mapped EPG into portal B's state after a playlist switch (the store is a root singleton) - applyMappedItvEpg: ids are marked checked only after a successful lookup — a transient IPC failure no longer suppresses the mapping for the rest of the session - live layout: post-dialog refresh re-applies overrides for the unfiltered channel list, so an active search cannot drop the playing channel's mapping - global favorites: new epgMappingChanged output emitted when the dialog actually changed a mapping; unified live tab reloads its EPG previews in response Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec6fc403a3 |
feat: manual EPG-to-channel mapping with mapping fallback (#1165)
* feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths
* fix: epg mapping in live tv list
* fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys
Follow-up fixes on top of the manual EPG-to-channel mapping feature:
- epg-database: dedupe existing epg_programs rows before creating the
unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX
crashed the EPG worker on upgrade when historical duplicates exist;
replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the
epg_programs_fts delete trigger is not bypassed (REPLACE skips delete
triggers unless recursive_triggers is on), with a plain-INSERT
fallback when the index cannot be created
- db: add idx_content_epg_channel — the mapping fallback scanned the
whole content table on every single-channel EPG lookup
- keys: scope Xtream mapping keys per playlist via shared
buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids
collide across portals; the backend fallback now joins categories to
resolve the playlist id
- pwa: hide "Map EPG channel" entries behind the supportsEpgMapping
capability — the menu item was a dead end in the PWA
- parser: parse the XMLTV offset sign from the string — Math.sign(0)
dropped the minutes of ±00:xx offsets
- cleanup: typed window.electron access instead of ad-hoc casts, drop
unused resolveChannelId and dialog data field, shared
EpgMappingDialogComponent.open() for all seven call sites
- dialog UX: minimum-characters search hint, save/remove snackbars,
current mapping shows the EPG channel display name,
takeUntilDestroyed on the search stream
- i18n: fill the new keys in all 17 locales
- tests: cover mapping CRUD/search escaping, the dedup-index guard,
offset parsing and playlist-scoped keys; update stale stream-resolver
specs for the new 50-item limit and 10s timeout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): escape backslashes in EPG channel search LIKE pattern
CodeQL js/incomplete-sanitization: a lone trailing backslash in the
search term paired with the closing wildcard under the ESCAPE clause
and corrupted the pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(epg): batch mapping lookups in the viewport preview queue
Expose the existing getEpgMappingsBatch operation over a new
EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings —
the per-entry lookup issued one IPC round-trip per visible channel on
every scroll event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(epg): batch mapping prefetch in the collection preview loader
Resolve all candidate mapping keys for an Xtream preview batch with a
single getEpgMappingsBatch IPC call instead of per-channel lookups.
Also fix a worker early-exit: a channel without tvgId/name returned out
of the shared-iterator loop and silently killed one of the three
concurrent preview workers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5cae310430 |
fix(logging): redact sensitive portal and Electron diagnostics (#1182)
* fix: redact sensitive log data * fix(ci): keep logging preload self-contained * fix(logging): preserve shared diagnostics * fix(logging): close trace redaction gaps * fix(logging): redact Xtream path credentials * fix(logging): close credential redaction gaps * fix(logging): suppress external player arguments * fix(logging): harden URL and date redaction * fix(logging): redact map keys and URL fragments * fix(logging): redact sensitive map values * fix(logging): redact credentials in diagnostic text * fix(logging): close remaining credential leaks |
||
|
|
2f8aee72df |
feat(xtream): add catch-up playback to favorites and recent tabs (#1166)
Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays. Closes #1138. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
643dee1be3 |
feat(xtream): resume the latest series episode (#1187)
Dashboard Continue Watching now carries the exact saved season/episode into Xtream series details and starts it at the persisted offset. Successful external MPV/VLC launches persist the launched episode and retarget the series CTA to "Play episode N". Recent-history rows keyed by an episode id resolve their parent series before navigation. Includes maintainer follow-ups: no zero-offset resume on failed position loads, seriesXtreamId-gated resume targets for legacy rows, and patch coverage raised from 76.7% to 93.9%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d61fd5db19 |
feat: add strip country prefix setting (#1162)
* feat: add strip country prefix setting
* feat: scope country-prefix stripping to live content and cover missing surfaces
- narrow the heuristic: pipes always strip, dash/colon separators only
when the prefix is a short uppercase tag ("UK - BBC One" strips,
"Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact)
- fall back to the original name when stripping would leave nothing
- scope stripping to live content only: grid type (live/itv/radio),
playback isLive, external sessions without contentInfo, dashboard
cards with contentType 'live'
- cover previously missed surfaces: M3U player EPG timeline header,
M3U inline player title, radio player, dashboard live rails
- replace hardcoded settings strings with translate keys and add
SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales
- add unit specs for the utility plus regression specs for
channel-list-item and external-playback-dock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: cover strip-country-prefix call sites for codecov
- dashboard-rail: new spec for cardTitle live/movie/series scoping
- grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback
- portal-inline-player: live strip vs VOD passthrough
- unified-live-tab: timeline channel name strip + M3U name precedence
- video-player: timeline/radio/inline titles with the setting on and off
- settings-store: default false + persisted true round-trip
- settings-form.utils: new spec for form default and ?? false fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
a51c537bb2 |
fix(theme): make scrollbars follow the app theme instead of the OS color scheme (#1179)
On Windows with a light OS theme, scrollbars rendered light even when the app was switched to dark. Two combined causes: - The page never declared `color-scheme`, so Chromium colored native scrollbars from the OS preference. Declare `color-scheme: light` on html and flip it to `dark` via `html:has(> body.dark-theme)` plus the `.dark-theme` block itself. - Scrollbar styling referenced `--mat-sys-*` tokens, which are never emitted by the current Material theme setup (mat.define-theme + all-component-themes does not produce system tokens). Those `scrollbar-color` declarations computed to `auto`, falling back to the native (light) scrollbar. Switch scrollbar styling to the `--app-muted-color` design token (defined for both themes), replace hardcoded white `rgba(255,255,255,.08)` thumbs, and add an explicit `scrollbar-color` where only `scrollbar-width: thin` was set. Verified live in Electron via CDP in both themes: scrollbar-color resolves and scrollbars render dark in dark theme regardless of the OS setting. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
54265755ee |
fix(favorites): persist custom drag-and-drop order for Xtream favorites (#1143)
* fix(favorites): persist custom drag-and-drop order for Xtream favorites
Prepared-statement writes dispatched via drizzle's `.execute()` on the
better-sqlite3 driver return a promise and defer the write to a microtask.
Inside a synchronous `db.transaction(() => ...)` callback (which cannot
await), the transaction commits before that promise settles, so the write
is a silent no-op — no error, no rows changed.
This bit `reorderGlobalFavorites`: the custom favorites order never
persisted for the per-playlist ("This playlist") Xtream scope, which relies
solely on the `favorites.position` column. The global ("All playlists")
scope masked the bug because it also persists an order to the `appState`
`global-favorites-channel-order-v1` key and re-applies it on read.
`removeRecentItemsBatch` had the same latent bug — batch "clear recent
items" silently did nothing.
Switch both writers to synchronous `.run()`. Add regression coverage that
asserts `.run()` (not `.execute()`) is used and would fail on the old
behavior, and document the gotcha in the DB worker architecture doc.
Verified over CDP against a live Electron instance: reorder writes
positions 0..N, and the order survives navigation and a full reload.
Fixes #1137
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): scope reorder position writes by playlist
The global favorites reorder wrote the new position filtering only by
content_id, so two Xtream playlists holding a favorite with the same
content_id would clobber each other's persisted order (greptile P1).
Thread playlist_id through the whole reorder path — the renderer builder
(UnifiedCollectionItem already carries playlistId), the IPC contract
(ElectronBridgeFavoriteReorderUpdate + inline payload types), the worker
op — and scope the prepared UPDATE by (contentId, playlistId), matching
the favorites composite unique index.
Tests: favorites.operations.spec asserts the playlistId placeholder and
per-row playlistId payload; preload contract fixture updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): include playlist_id in workspace global favorites reorder payload
The workspace global-favorites reorder path still sent updates with only
content_id and position. Since the backend UPDATE is now scoped by
(contentId, playlistId), that payload binds an undefined playlist id and
matches no rows — the DB write silently no-ops (flagged by Greptile P1).
Also scope the prepared-statement example in the sqlite-db-worker gotcha
doc by (contentId, playlistId) so it no longer documents the
cross-playlist rewrite this PR fixes (flagged by Codex P3).
Regression spec asserts the reorder payload carries playlist_id per item
(fails on the old payload shape) and that the appState uid order is
still persisted for non-Xtream items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
09764e24d4 |
feat(portals): back button on the in-portal search page (#1142)
* feat(portals): back button on the in-portal search page Opening a "grey" (unmatched) film from an actor page navigates to the portal search prefilled with ?q=, but the search page had no way back — users had to re-navigate through the sidebar to reach the actor's filmography again. SearchLayoutComponent gains a showBackButton input + backClick output; both the Xtream in-portal search (SearchResultsComponent) and the Stalker search wire it to Location.back(). Shown only on the nested in-portal search — the top-level global search (sidebar destination) and the dialog mode (own close button) are excluded. location.back() unwinds the full history, so search → actor → movie detail → … all chain correctly. Tests: search-layout spec covers the conditional button + backClick. Docs: CLAUDE.md actor-pages note updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): address search back-button review (dialog guard, RTL-safe spacing) - showBackButton now also checks !dialogRef, so a hypothetical dialog opened with isGlobalSearch:false from a workspace route can't show a back arrow that navigates the page behind the dialog (greptile). - Replaced the fragile negative margin with a header-title-group flex wrapper (gap: 4px) that keeps the arrow and title together regardless of the outer gap or Material padding, and is RTL-safe (greptile). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |