From 1471e7af367449820260137e7d0626f1ec55b764 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:28:30 +0200 Subject: [PATCH 1/8] ci(release): sweep PR draft releases the close event never reaches (#1641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `pull_request: closed` cleanup is the fast path, not a guarantee: GitHub does not run that workflow when the head ref is already gone at event time, which is what Dependabot does when it supersedes one of its own PRs. 15 `test-pr-` drafts had been orphaned that way, 13 of them Dependabot's. Add a daily scheduled (and manually dispatchable) sweep to the same workflow. It lists every draft tagged `^test-pr-[0-9]+$`, asks GitHub for that PR's live state, and deletes only when the PR reports closed. It fails closed: a PR lookup error leaves the draft untouched, a failed release listing fails the job rather than sweeping a short list, and only a confirmed HTTP 404 excuses a failed delete — `gh api` exits 1 for every failure alike, so the re-check reads the response status instead of the exit code. Workflow permissions drop to `contents: read`; the event job keeps `actions: write` + `contents: write`, the sweep takes only `contents: write`. No new actions. Docs: new "Rolling test drafts" section in docs/architecture/release-pipeline.md. Co-Authored-By: Claude Opus 5 --- .github/workflows/cleanup-pr-draft.yml | 112 +++++++++++++++++++++++-- docs/architecture/release-pipeline.md | 18 ++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cleanup-pr-draft.yml b/.github/workflows/cleanup-pr-draft.yml index 695685255..4b442cfe3 100644 --- a/.github/workflows/cleanup-pr-draft.yml +++ b/.github/workflows/cleanup-pr-draft.yml @@ -3,12 +3,20 @@ name: Cleanup PR Draft Release on: pull_request: types: [closed] + # The event above is the fast path, not a guarantee: GitHub does not run a + # `pull_request: closed` workflow when the head ref is already gone at + # event time, which is exactly what Dependabot does when it supersedes one + # of its own PRs (closes it and deletes the branch in a single operation). + # Those drafts — and any the event path missed for other reasons — are + # collected by the scheduled sweep below. + schedule: + - cron: '17 4 * * *' + workflow_dispatch: -# contents: write — delete the draft release; actions: write — cancel the -# closed PR's still-running build workflow before deleting. +# contents: write — delete the draft release. The `actions: write` needed to +# cancel a closed PR's still-running build is scoped to the event job. permissions: - actions: write - contents: write + contents: read jobs: delete-draft: @@ -16,8 +24,11 @@ jobs: # Fork PRs never get a draft (the release job skips them) and their # GITHUB_TOKEN is read-only regardless of the permissions block, so # there is nothing to cancel or delete. - if: github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + permissions: + actions: write + contents: write steps: # A closed PR can be reopened while this job is still queued or # waiting; a reopened PR's fresh build must not be cancelled and @@ -90,3 +101,94 @@ jobs: gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \ --jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" | xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}" + + sweep-drafts: + name: Sweep orphaned PR draft releases + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + # Two sweeps must not race each other into a double delete; a manual + # dispatch during the nightly cron would otherwise produce a spurious + # failure on an already-deleted draft. + concurrency: + group: cleanup-pr-draft-sweep + cancel-in-progress: false + steps: + # Unlike the event job this one does not cancel in-progress builds: + # the build's draft steps are themselves gated on the live PR state + # being `open` ("Check PR is still open" in build-and-make.yaml), so + # a run that outlives the close cannot recreate what was swept. A + # build that passed that check just before the PR closed is caught + # by the next sweep. + # + # Draft releases have no real git tag, so they are invisible to + # `gh release view ` and to the tags API. Enumerate releases + # and match on the stored tag_name, exactly as the event job does. + # The `test-` drafts and every other release are left + # alone by the ^test-pr-$ shape. + - name: Delete drafts whose PR is closed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # Assigned rather than piped: a failed or partially paginated + # listing must fail the job instead of silently sweeping a + # short list. + drafts="$( + gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \ + --jq '.[] | select(.draft and (.tag_name | test("^test-pr-[0-9]+$"))) | "\(.id) \(.tag_name)"' + )" + + if [ -z "${drafts}" ]; then + echo "No test-pr- drafts found." + exit 0 + fi + + failed=0 + + while IFS=' ' read -r release_id tag; do + pr_number="${tag#test-pr-}" + + # Fail closed: a lookup error (404, rate limit, outage) + # leaves `state` empty and the draft untouched. Only a + # PR GitHub currently reports as closed loses its draft, + # so a reopened PR and an open PR mid-build keep theirs. + # gh's own stderr is left visible on purpose — a draft + # kept as `unknown` should say why in the job log. + state="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.state' || true)" + if [ "${state}" != "closed" ]; then + echo "Keeping ${tag}: PR #${pr_number} is ${state:-unknown}." + continue + fi + + if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}" >/dev/null; then + echo "Deleted ${tag} (release ${release_id}) for closed PR #${pr_number}." + continue + fi + + # The delete failed. Only a release GitHub confirms is + # gone (404) excuses that — the event job racing us to + # the same draft. `gh api` exits 1 for every failure + # alike, so a rate limit or outage hitting both calls + # would otherwise read as "already deleted" and leave a + # green sweep behind an undeleted draft. Read the status + # line instead: `-i` prints it even on an error status, + # and a request that never got a response leaves it + # empty, which is not 404 and so stays a failure. + recheck_status="$( + gh api -i "repos/${GITHUB_REPOSITORY}/releases/${release_id}" 2>/dev/null | + sed -n '1s#^HTTP/[0-9.]* \([0-9]\{3\}\).*#\1#p' || true + )" + + if [ "${recheck_status}" = "404" ]; then + echo "Draft ${tag} (release ${release_id}) was already gone." + else + echo "::error::Failed to delete draft ${tag} (release ${release_id}); re-check returned ${recheck_status:-no HTTP status}." + failed=1 + fi + done <<< "${drafts}" + + exit "${failed}" diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 89140fe05..58354bc01 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -276,6 +276,24 @@ References: [AppImage desktop keys](https://docs.appimage.org/reference/desktop- [AppManager desktop parser](https://github.com/kem-a/AppManager/blob/v3.8.0/src/core/desktop_entry.vala), [AppManager updater](https://github.com/kem-a/AppManager/blob/v3.8.0/src/core/updater.vala). +## Rolling test drafts + +Every non-fork PR build publishes its artifacts to a rolling **draft** release +tagged `test-pr-`; a non-PR, non-tag build (a dispatch on a branch) uses +`test-`, the shape master pushes used before the nightly channel took +over. The tag is stable per PR, so the draft is updated in place and a PR has +at most one. + +`cleanup-pr-draft.yml` deletes a PR's draft when the PR closes. That event is +the fast path, not a guarantee: GitHub does not run a `pull_request: closed` +workflow when the head ref is already gone at event time, which is what +Dependabot does when it supersedes one of its own PRs — 15 drafts were +orphaned that way before this was noticed. A daily scheduled sweep in the same +workflow (also runnable with `gh workflow run cleanup-pr-draft.yml`) therefore +lists every `test-pr-` draft, asks GitHub for that PR's live state, and +deletes the draft only when the PR is closed; anything else — an open PR, a +lookup failure, a `test-` draft — is left alone. + ## Nightly channel Every push to `master` of `4gray/iptvnator` is also a nightly. The same From 975b1f7f745b148f10bc02915b201658746efb0c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:51:34 +0200 Subject: [PATCH 2/8] refactor(collections): split the unified collection page and live tab (#1642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(collections): split the unified collection page and live tab Both files sat far above the workspace's 400-line hard maximum and were only green because `tools/eslint/max-lines-baseline.mjs` exempted them. No behavior change: every template binding, public signal and handler the components exposed still resolves the same way, and the two baseline entries are gone. `unified-collection-page.component.ts` (966 → 398 lines) keeps the view surface and delegates: - `unified-collection-data.service.ts` — component-provided; owns the rows, the favorite uid set, the loading/reload indicators and every mutation, plus `loadedRequest` (the PR #1636 invariant, now read through one `mutationRequest` computed instead of two ad-hoc fallbacks) - `unified-collection-load.ts` — the reload key and the load effect - `unified-collection-scope.ts` — the This-playlist/All-playlists toggle - `unified-collection-content-type.ts` — the Live/Movies/Series tab - `unified-collection-history.ts` — the `window.history.state` view entry - `unified-collection-detail-state.ts` / `-detail-navigation.ts` — the inline detail and where an item this route cannot render goes instead - `unified-collection-clear-action.ts`, `-labels.ts`, `-favorites-sort.ts` `unified-live-tab.component.ts` (1051 → 426 lines) continues the pattern its siblings already established: - `unified-live-selection.ts` (+ `-selection-generation.ts`) — activating a row while the mounted player stays alive - `unified-live-selection-view.ts` — what the selection implies about the source and the player surface - `unified-live-epg-view.ts`, `unified-live-epg-map.ts` — the EPG panel and the rail's now-playing map - `unified-live-catchup.ts` — the timeshift override - `unified-live-recording-metadata.ts`, `unified-live-channel-rows.ts` Co-Authored-By: Claude Opus 5 * refactor(collections): move the collection data service into data-access Codex review on PR #1642: `UnifiedCollectionDataService` loads and persists favorites/recent rows, which CLAUDE.md places in `@iptvnator/portal/shared/data-access`, not in the `type:ui` project that renders them — and the dialog-scoped `SourceCleanupService` already sets that precedent for a component-provided stateful collection service. It was also the only non-root `@Injectable()` in `portal-shared-ui`. `collection-reload-indicator.ts` moves with it: `type:data-access` may not depend on `type:ui`, and the indicator is the service's own loading state machine rather than a view. Both are exported from the data-access collection barrel, so the boundary is now lint-enforced instead of conventional. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- docs/architecture/iptvnator-ui-guidelines.md | 5 +- docs/architecture/m3u-playlist-module.md | 8 +- .../collection-reload-indicator.spec.ts | 0 .../collection-reload-indicator.ts | 0 .../data-access/src/lib/collection/index.ts | 2 + .../unified-collection-data.service.ts | 204 ++++ .../unified-collection-clear-action.ts | 144 +++ .../unified-collection-content-type.ts | 96 ++ .../unified-collection-detail-navigation.ts | 88 ++ .../unified-collection-detail-state.ts | 166 ++++ .../unified-collection-favorites-sort.ts | 66 ++ .../unified-collection-history.ts | 141 +++ .../unified-collection-labels.ts | 64 ++ .../unified-collection-load.ts | 78 ++ .../unified-collection-page.component.spec.ts | 2 +- .../unified-collection-page.component.ts | 896 +++-------------- .../unified-collection-scope.ts | 78 ++ .../unified-live-catchup.ts | 183 ++++ .../unified-live-channel-rows.ts | 84 ++ .../unified-live-epg-map.ts | 63 ++ .../unified-live-epg-view.ts | 177 ++++ .../unified-live-recording-metadata.ts | 132 +++ .../unified-live-selection-generation.ts | 20 + .../unified-live-selection-view.ts | 184 ++++ .../unified-live-selection.ts | 273 +++++ .../unified-live-tab.component.ts | 937 +++--------------- tools/eslint/max-lines-baseline.mjs | 2 - 27 files changed, 2552 insertions(+), 1541 deletions(-) rename libs/portal/shared/{ui/src/lib/components/unified-collection => data-access/src/lib/collection}/collection-reload-indicator.spec.ts (100%) rename libs/portal/shared/{ui/src/lib/components/unified-collection => data-access/src/lib/collection}/collection-reload-indicator.ts (100%) create mode 100644 libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9bc74167f..9ed67dbe5 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -564,14 +564,15 @@ shows nothing. `aria-busy` is set immediately, since it does not paint. A superseded reload keeps the earliest deadline and never clears the indicator; only the latest request's completion does. Reference implementation: `createCollectionReloadIndicator` in -`libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts`. +`libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts`. Controls that triggered the reload stay enabled and reflect the requested value at once (`scope.set()` runs synchronously before the load starts). Because the rows on screen then belong to the PREVIOUS request, actions on them (Clear, drag reorder) must bind to the request that loaded those rows, never to the toggle's current value — "This playlist" applied to still-mounted global rows would delete other playlists' favorites or write foreign URLs -into this playlist (`loadedRequest` on the collection page). +into this playlist (`loadedRequest` on `UnifiedCollectionDataService`, which +the collection page reads through its own `mutationRequest`). ## Navigation Lists diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 3432628fe..ca85817f1 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1227,9 +1227,11 @@ class EpgService { - The unified favorites/recent live tab (`libs/portal/shared/ui/.../unified-collection/unified-live-tab.component.ts`) hosts the same timeline but does not use the NgRx playlist state; it keeps - its own `activeTimeshift` signal, resolves the replay URL with - `resolveM3uCatchupUrl`, and swaps the inline player's playback target (or - hands the URL to the configured external player). Selecting another channel, + its own `activeTimeshift` signal and hands it to + `createUnifiedLiveCatchup` (`unified-live-catchup.ts`), which resolves the + replay URL with `resolveM3uCatchupUrl` and swaps the inline player's + playback target (or hands the URL to the configured external player). + Selecting another channel, closing the player, or "Return to live" clears the override. - Catch-up activation is never silent: if the replay URL cannot be resolved for a programme the user clicked, both hosts surface a diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts b/libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.spec.ts similarity index 100% rename from libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts rename to libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.spec.ts diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts b/libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts similarity index 100% rename from libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts rename to libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts diff --git a/libs/portal/shared/data-access/src/lib/collection/index.ts b/libs/portal/shared/data-access/src/lib/collection/index.ts index 7ae7c22f2..5c1d9fde2 100644 --- a/libs/portal/shared/data-access/src/lib/collection/index.ts +++ b/libs/portal/shared/data-access/src/lib/collection/index.ts @@ -1,3 +1,5 @@ +export * from './collection-reload-indicator'; +export * from './unified-collection-data.service'; export * from './unified-favorites-data.service'; export * from './unified-recent-data.service'; export * from './stream-resolver.service'; diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts new file mode 100644 index 000000000..1b03ae617 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts @@ -0,0 +1,204 @@ +import { DestroyRef, inject, Injectable, Signal, signal } from '@angular/core'; +import { + CollectionContentType, + CollectionScope, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { createCollectionReloadIndicator } from './collection-reload-indicator'; +import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; +import { UnifiedRecentDataService } from './unified-recent-data.service'; + +export type CollectionMode = 'favorites' | 'recent'; + +export interface CollectionLoadRequest { + scope: CollectionScope; + playlistId?: string; + portalType?: string; +} + +/** + * Reads and writes the rows of one unified collection (a favorites or a + * recently-viewed list) and owns their loading state. Not `providedIn: + * 'root'`: `UnifiedCollectionPageComponent` provides it, so every mounted + * page has its own; the page itself keeps only the view concerns on top of + * this. + */ +@Injectable() +export class UnifiedCollectionDataService { + private readonly favoritesData = inject(UnifiedFavoritesDataService); + private readonly recentData = inject(UnifiedRecentDataService); + private readonly reloadIndicator = createCollectionReloadIndicator( + inject(DestroyRef) + ); + + /** First load with nothing on screen: the skeleton replaces the content. */ + readonly isLoading = signal(true); + /** + * A reload (scope switch, favorites reload) is in flight while the + * previous items stay mounted. Never swaps to the skeleton, so a playing + * channel and the focused toggle survive. + */ + readonly isReloading = this.reloadIndicator.active; + /** `isReloading` past its grace period: progress bar + dimming render. */ + readonly showReloadIndicator = this.reloadIndicator.visible; + readonly allItems = signal([]); + readonly favoriteUidSet = signal>(new Set()); + + private readonly request = signal(null); + /** + * The request that produced `allItems`. Actions on displayed rows (Clear, + * drag reorder) must use it, not the scope toggle's current value: during + * a reload the toggle already names the requested scope while the + * previous rows are still on screen, and "This playlist" against global + * rows would delete other playlists' favorites or write foreign URLs into + * this playlist. + */ + readonly loadedRequest: Signal = + this.request.asReadonly(); + + private requestId = 0; + + /** + * Replace the collection with the rows the request resolves to. Returns + * the loaded rows, or `null` when the load was superseded by a newer one + * or failed — in both cases the caller must not post-process it. + */ + async load( + params: CollectionLoadRequest & { mode: CollectionMode } + ): Promise { + const requestId = ++this.requestId; + if (this.allItems().length === 0) { + this.isLoading.set(true); + } else { + this.reloadIndicator.begin(); + } + + try { + const items = + params.mode === 'favorites' + ? await this.favoritesData.getFavorites( + params.scope, + params.playlistId, + params.portalType + ) + : await this.recentData.getRecentItems( + params.scope, + params.playlistId, + params.portalType + ); + const favoriteUids = + params.mode === 'favorites' + ? new Set(items.map((item) => item.uid)) + : await this.loadFavoriteUidSet(params); + if (requestId !== this.requestId) { + return null; + } + this.allItems.set(items); + this.request.set({ + scope: params.scope, + playlistId: params.playlistId, + portalType: params.portalType, + }); + this.favoriteUidSet.set(favoriteUids); + return items; + } catch { + if (requestId !== this.requestId) { + return null; + } + this.allItems.set([]); + return null; + } finally { + if (requestId === this.requestId) { + this.isLoading.set(false); + this.reloadIndicator.settle(); + } + } + } + + async removeItem( + mode: CollectionMode, + item: UnifiedCollectionItem + ): Promise { + if (mode === 'favorites') { + await this.favoritesData.removeFavorite(item); + this.favoriteUidSet.update((favoriteUids) => { + const nextFavoriteUids = new Set(favoriteUids); + nextFavoriteUids.delete(item.uid); + return nextFavoriteUids; + }); + } else { + await this.recentData.removeRecentItem(item); + } + this.allItems.update((items) => + items.filter((i) => i.uid !== item.uid) + ); + } + + async toggleFavorite(item: UnifiedCollectionItem): Promise { + const nextFavoriteUids = new Set(this.favoriteUidSet()); + + if (nextFavoriteUids.has(item.uid)) { + await this.favoritesData.removeFavorite(item); + nextFavoriteUids.delete(item.uid); + } else { + await this.favoritesData.addFavorite(item); + nextFavoriteUids.add(item.uid); + } + + this.favoriteUidSet.set(nextFavoriteUids); + } + + async reorder( + items: UnifiedCollectionItem[], + request: CollectionLoadRequest + ): Promise { + const nonLive = this.allItems().filter((i) => i.contentType !== 'live'); + this.allItems.set([...items, ...nonLive]); + await this.favoritesData.reorder(items, request); + } + + /** Drop every row of one type from the list, without persisting. */ + dropContentType(contentType: CollectionContentType): void { + this.allItems.update((items) => + items.filter((item) => item.contentType !== contentType) + ); + } + + clearFavorites(items: UnifiedCollectionItem[]): Promise { + return this.favoritesData.clearFavorites(items); + } + + removeRecentItemsBatch(items: UnifiedCollectionItem[]): void { + void this.recentData.removeRecentItemsBatch(items); + } + + /** Move a just-played item back to the head of the recent list. */ + promoteRecentItem(item: UnifiedCollectionItem): void { + this.allItems.update((items) => { + const nextItems = [ + item, + ...items.filter((candidate) => candidate.uid !== item.uid), + ]; + return nextItems.sort( + (a, b) => + new Date(b.viewedAt ?? 0).getTime() - + new Date(a.viewedAt ?? 0).getTime() + ); + }); + } + + private async loadFavoriteUidSet( + params: CollectionLoadRequest + ): Promise> { + try { + const favorites = await this.favoritesData.getFavorites( + params.scope, + params.playlistId, + params.portalType + ); + return new Set(favorites.map((item) => item.uid)); + } catch { + return new Set(); + } + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts new file mode 100644 index 000000000..b86701537 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts @@ -0,0 +1,144 @@ +import { effect, inject, Signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + CollectionContentType, + UnifiedCollectionItem, + WorkspaceViewCommandService, +} from '@iptvnator/portal/shared/util'; +import { + CollectionMode, + UnifiedCollectionDataService, +} from '@iptvnator/portal/shared/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { resolveClearCollectionDialogKeys } from './unified-collection-labels'; + +export interface ClearCollectionAction { + /** Ask for confirmation, then clear every row of the tab on screen. */ + run(): void; +} + +/** + * "Clear this tab" for a unified collection. The rows leave the screen as + * soon as the user confirms; a favorites write that then fails puts the + * collection back the way storage has it. Must run in an injection context. + */ +export function createClearCollectionAction(options: { + mode: Signal; + /** Rows of the tab on screen — the ones this clears. */ + items: Signal; + typeLabelKey: Signal; + /** Whether those rows came from a single playlist; names the wording. */ + isPlaylistScope: () => boolean; + data: UnifiedCollectionDataService; + /** Drop the cleared tab and move to one that still has rows. */ + dropCurrentType: () => void; + /** Restore the collection from storage after a failed write. */ + reload: () => Promise; +}): ClearCollectionAction { + const dialogService = inject(DialogService); + const translate = inject(TranslateService); + + const clearFavorites = async ( + itemsToRemove: UnifiedCollectionItem[] + ): Promise => { + options.dropCurrentType(); + + try { + await options.data.clearFavorites(itemsToRemove); + } catch { + await options.reload(); + } + }; + + return { + run(): void { + const itemsToRemove = options.items(); + if (itemsToRemove.length === 0) { + return; + } + + const isFavorites = options.mode() === 'favorites'; + const type = translate.instant(options.typeLabelKey()); + const { titleKey, messageKey } = resolveClearCollectionDialogKeys( + options.mode(), + options.isPlaylistScope() + ); + + dialogService.openConfirmDialog({ + title: translate.instant(titleKey, { type }), + message: translate.instant(messageKey, { type }), + onConfirm: async () => { + if (isFavorites) { + await clearFavorites(itemsToRemove); + return; + } + + options.dropCurrentType(); + options.data.removeRecentItemsBatch(itemsToRemove); + }, + }); + }, + }; +} + +export interface ClearCollectionViewCommandHost { + /** Only the workspace shell renders a command palette to register into. */ + isWorkspaceLayout: boolean; + mode: Signal; + selectedContentType: Signal; + /** Rows the command would clear; with none there is nothing to offer. */ + currentTypeItems: Signal; + labelKey: Signal; + typeLabelKey: Signal; + run: () => void; +} + +/** + * Publish "clear the current tab" to the workspace command palette while the + * tab has rows. Must run in an injection context (the hosting component's + * field initializer or constructor). + */ +export function setupClearCollectionViewCommand( + host: ClearCollectionViewCommandHost +): void { + const workspaceViewCommands = inject(WorkspaceViewCommandService); + const translate = inject(TranslateService); + const typeParams = () => ({ + type: translate.instant(host.typeLabelKey()), + }); + + effect((onCleanup) => { + if (!host.isWorkspaceLayout || host.currentTypeItems().length === 0) { + return; + } + + onCleanup( + workspaceViewCommands.registerCommand({ + id: `unified-collection-clear-current-${host.mode()}`, + group: 'view', + icon: 'delete_sweep', + labelKey: host.labelKey(), + labelParams: typeParams, + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION', + descriptionParams: typeParams, + keywords: () => + host.mode() === 'favorites' + ? [ + 'clear', + 'favorites', + 'remove', + host.selectedContentType(), + ] + : [ + 'clear', + 'recent', + 'history', + host.selectedContentType(), + ], + priority: 10, + run: host.run, + }) + ); + }); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts new file mode 100644 index 000000000..1d08d84c2 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts @@ -0,0 +1,96 @@ +import { computed, Signal, signal, WritableSignal } from '@angular/core'; +import { + CollectionContentType, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; + +/** + * The Live / Movies / Series tab of a unified collection, plus the item + * partition it selects from. A tab exists only while the collection holds + * items of that type, so the state also owns the correction that runs after + * every load and every bulk removal. + */ +export interface CollectionContentTypeState { + readonly selected: WritableSignal; + readonly liveItems: Signal; + readonly movieItems: Signal; + readonly seriesItems: Signal; + readonly hasLive: Signal; + readonly hasMovies: Signal; + readonly hasSeries: Signal; + readonly availableTypes: Signal; + readonly showToggle: Signal; + readonly currentTypeItems: Signal; + readonly currentTypeLabelKey: Signal; + /** Move off a tab the collection no longer has items for. */ + autoSelect(): void; +} + +export function createCollectionContentTypeState( + allItems: Signal, + initialType: CollectionContentType +): CollectionContentTypeState { + const selected = signal(initialType); + const itemsOfType = (contentType: CollectionContentType) => + computed(() => + allItems().filter((item) => item.contentType === contentType) + ); + + const liveItems = itemsOfType('live'); + const movieItems = itemsOfType('movie'); + const seriesItems = itemsOfType('series'); + + const hasLive = computed(() => liveItems().length > 0); + const hasMovies = computed(() => movieItems().length > 0); + const hasSeries = computed(() => seriesItems().length > 0); + + const availableTypes = computed(() => { + const types: CollectionContentType[] = []; + if (hasLive()) types.push('live'); + if (hasMovies()) types.push('movie'); + if (hasSeries()) types.push('series'); + return types; + }); + + const currentTypeItems = computed(() => { + switch (selected()) { + case 'live': + return liveItems(); + case 'movie': + return movieItems(); + case 'series': + return seriesItems(); + } + }); + + const currentTypeLabelKey = computed(() => { + switch (selected()) { + case 'live': + return 'PORTALS.LIVE_TV'; + case 'movie': + return 'PORTALS.MOVIES'; + case 'series': + return 'PORTALS.SERIES'; + } + }); + + return { + selected, + liveItems, + movieItems, + seriesItems, + hasLive, + hasMovies, + hasSeries, + availableTypes, + showToggle: computed(() => availableTypes().length > 1), + currentTypeItems, + currentTypeLabelKey, + autoSelect(): void { + const types = availableTypes(); + if (types.length > 0 && !types.includes(selected())) { + selected.set(types[0]); + } + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts new file mode 100644 index 000000000..83149ebec --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts @@ -0,0 +1,88 @@ +import { + buildGlobalCollectionDetailNavigationTarget, + getUnifiedCollectionNavigation, + SeriesResumeTarget, + STALKER_RETURN_TO_STATE_KEY, + UnifiedCollectionItem, + WorkspaceNavigationTarget, +} from '@iptvnator/portal/shared/util'; + +/** + * Whether the route on screen may host this item's detail inline. A portal's + * own favorites/recent tabs render only their own provider's items, while the + * global collections host both portal providers; anything else falls back to + * the portal type the page was configured with. + */ +export function canRenderCollectionDetailOnRoute( + routerUrl: string, + portalType: string | undefined, + item: UnifiedCollectionItem +): boolean { + if (routerUrl.includes('/workspace/xtreams/')) { + return item.sourceType === 'xtream'; + } + + if (routerUrl.includes('/workspace/stalker/')) { + return item.sourceType === 'stalker'; + } + + if ( + routerUrl.includes('/workspace/global-favorites') || + routerUrl.includes('/workspace/global-recent') + ) { + return item.sourceType === 'xtream' || item.sourceType === 'stalker'; + } + + return !portalType || portalType === item.sourceType; +} + +/** + * Route to the item's detail inside the matching global collection. Live + * channels and M3U items have no such detail, so they keep the ordinary + * in-portal navigation below. + */ +export function buildCollectionDetailNavigation( + mode: 'favorites' | 'recent', + item: UnifiedCollectionItem, + seriesResume?: SeriesResumeTarget | null +): WorkspaceNavigationTarget | null { + if ( + item.contentType === 'live' || + (item.sourceType !== 'xtream' && item.sourceType !== 'stalker') + ) { + return null; + } + + return buildGlobalCollectionDetailNavigationTarget( + mode, + item, + seriesResume + ); +} + +/** + * Ordinary navigation into the item's own portal. A Stalker detail carries + * the current URL so its back affordance returns to this collection instead + * of the portal's catalog root. + */ +export function buildCollectionPortalNavigation( + item: UnifiedCollectionItem, + routerUrl: string +): WorkspaceNavigationTarget | null { + const navigation = getUnifiedCollectionNavigation(item); + if (!navigation) { + return null; + } + + if (item.sourceType !== 'stalker' || item.contentType === 'live') { + return navigation; + } + + return { + ...navigation, + state: { + ...(navigation.state ?? {}), + [STALKER_RETURN_TO_STATE_KEY]: routerUrl, + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts new file mode 100644 index 000000000..51d4659dc --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts @@ -0,0 +1,166 @@ +import { + computed, + effect, + inject, + signal, + Signal, + untracked, + WritableSignal, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + clearNavigationStateKeys, + CollectionContentType, + getOpenCollectionDetailItemState, + OPEN_COLLECTION_DETAIL_STATE_KEY, + SeriesResumeTarget, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { CollectionMode } from '@iptvnator/portal/shared/data-access'; +import { + buildCollectionDetailNavigation, + canRenderCollectionDetailOnRoute, +} from './unified-collection-detail-navigation'; +import { UnifiedCollectionDetailContext } from './unified-collection-detail.directive'; + +export interface CollectionDetailState { + readonly item: WritableSignal; + readonly seriesResume: WritableSignal; + /** Template context for the host's projected detail template. */ + readonly context: Signal; + /** Whether this route and host can render the item's detail in place. */ + canOpen(item: UnifiedCollectionItem): boolean; + open( + item: UnifiedCollectionItem, + seriesResume?: SeriesResumeTarget | null + ): void; + clear(): void; + /** Open, keep, or close the inline detail per the history entry. */ + syncFromHistory(): void; +} + +/** + * The detail a collection renders in place of its list. Only the portal + * providers have one, and only where the host projected a detail template; + * an item belonging to a provider this route cannot render is handed over to + * the matching global collection instead of being dropped. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createCollectionDetailState(options: { + mode: Signal; + portalType: Signal; + /** Absent while the host projects no detail template. */ + detailTemplate: Signal; + /** Show the tab the opened item belongs to. */ + onOpen: (contentType: CollectionContentType) => void; + /** The detail closed: fall back to a tab that still has rows. */ + onClear: () => void; +}): CollectionDetailState { + const router = inject(Router); + const item = signal(null); + const seriesResume = signal(null); + + const canRenderOnCurrentRoute = ( + candidate: UnifiedCollectionItem + ): boolean => + canRenderCollectionDetailOnRoute( + router.url, + options.portalType(), + candidate + ); + + const canOpen = (candidate: UnifiedCollectionItem): boolean => + Boolean(options.detailTemplate()) && + candidate.contentType !== 'live' && + (candidate.sourceType === 'xtream' || + candidate.sourceType === 'stalker') && + canRenderOnCurrentRoute(candidate); + + const open = ( + next: UnifiedCollectionItem, + nextSeriesResume?: SeriesResumeTarget | null + ): void => { + options.onOpen(next.contentType); + item.set(next); + seriesResume.set(nextSeriesResume ?? null); + }; + + const clear = (): void => { + item.set(null); + seriesResume.set(null); + options.onClear(); + clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]); + }; + + const requestClose = (): void => { + if (getOpenCollectionDetailItemState(window.history.state)) { + window.history.back(); + return; + } + + clear(); + }; + + // A detail this route cannot render (a Stalker item inside an Xtream + // portal's own tabs, say) belongs to the matching global collection. + effect(() => { + const current = item(); + if (!current || canRenderOnCurrentRoute(current)) { + return; + } + + const navigation = buildCollectionDetailNavigation( + options.mode(), + current, + seriesResume() + ); + if (!navigation) { + return; + } + + untracked(() => { + item.set(null); + void router.navigate(navigation.link, { + state: navigation.state, + }); + }); + }); + + return { + item, + seriesResume, + context: computed(() => { + const current = item(); + if (!current) { + return null; + } + + return { + $implicit: current, + item: current, + seriesResume: seriesResume(), + close: requestClose, + }; + }), + canOpen, + open, + clear, + syncFromHistory(): void { + const detailState = getOpenCollectionDetailItemState( + window.history.state + ); + const detailItem = detailState?.item; + + if (detailItem && canOpen(detailItem)) { + open(detailItem, detailState.seriesResume); + return; + } + + if (item()) { + clear(); + } + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts new file mode 100644 index 000000000..f425330ef --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts @@ -0,0 +1,66 @@ +import { computed, Signal, signal } from '@angular/core'; +import { + FavoritesChannelSortMode, + getFavoritesChannelSortModeTranslationKey, + persistFavoritesChannelSortMode, + restoreFavoritesChannelSortMode, +} from '@iptvnator/portal/shared/util'; + +export interface FavoritesSortOption { + readonly mode: FavoritesChannelSortMode; + readonly translationKey: string; + readonly icon: string; +} + +export const FAVORITES_SORT_OPTIONS: readonly FavoritesSortOption[] = [ + { + mode: 'custom', + translationKey: 'WORKSPACE.SORT_CUSTOM', + icon: 'drag_indicator', + }, + { + mode: 'name-asc', + translationKey: 'WORKSPACE.SORT_NAME_ASC', + icon: 'sort_by_alpha', + }, + { + mode: 'name-desc', + translationKey: 'WORKSPACE.SORT_NAME_DESC', + icon: 'sort_by_alpha', + }, + { + mode: 'date-desc', + translationKey: 'WORKSPACE.SORT_DATE_DESC', + icon: 'schedule', + }, +]; + +export interface FavoritesSortState { + readonly mode: Signal; + readonly labelKey: Signal; + readonly options: readonly FavoritesSortOption[]; + setMode(mode: FavoritesChannelSortMode): void; +} + +/** + * Sort order of the favorites live rail. The choice is global rather than + * per collection, so it is restored from — and written straight back to — + * the shared persisted value. + */ +export function createFavoritesSortState(): FavoritesSortState { + const mode = signal( + restoreFavoritesChannelSortMode() + ); + + return { + mode: mode.asReadonly(), + labelKey: computed(() => + getFavoritesChannelSortModeTranslationKey(mode()) + ), + options: FAVORITES_SORT_OPTIONS, + setMode(next: FavoritesChannelSortMode): void { + mode.set(next); + persistFavoritesChannelSortMode(next); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts new file mode 100644 index 000000000..1a0474176 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts @@ -0,0 +1,141 @@ +import { + effect, + Signal, + signal, + untracked, + WritableSignal, +} from '@angular/core'; +import { + buildCollectionViewState, + buildOpenCollectionDetailItemState, + COLLECTION_VIEW_STATE_KEY, + CollectionContentType, + CollectionScope, + CollectionViewState, + getCollectionViewState, + OPEN_COLLECTION_DETAIL_STATE_KEY, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; + +/** + * The collection's selected tab and scope live in `window.history.state`, so + * the browser's own Back/Forward restores the view the user left instead of + * the route defaults. This owns that one key of the current entry: reading + * it back after a popstate, and replacing it in place while the user works. + */ +export class CollectionViewStateHistory { + private readonly state = signal( + getCollectionViewState(window.history.state) + ); + + /** View state of the history entry currently on screen. */ + readonly current: Signal = + this.state.asReadonly(); + + /** Re-read the entry — after a popstate, or on first content init. */ + refresh(): CollectionViewState | null { + const collectionViewState = getCollectionViewState( + window.history.state + ); + this.state.set(collectionViewState); + return collectionViewState; + } + + /** + * Replace the current entry's view state. A no-op while nothing changed, + * so a repeated read never spends a `replaceState` on an identical value. + */ + commit(next: CollectionViewState): void { + const nextCollectionViewState = buildCollectionViewState(next); + if (isSameCollectionViewState(this.state(), nextCollectionViewState)) { + return; + } + + const nextState = toHistoryStateRecord(window.history.state); + if (nextCollectionViewState) { + nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState; + } else { + delete nextState[COLLECTION_VIEW_STATE_KEY]; + } + + window.history.replaceState(nextState, document.title); + this.state.set(nextCollectionViewState); + } +} + +export interface CollectionViewStateSync { + /** Apply the entry's view state to the page (popstate, first init). */ + restore(): void; + /** Write the page's current view state before it navigates away. */ + commitCurrent(): void; +} + +/** + * Keep the history entry and the page's own view state in step: the page + * writes every change back as it happens, and reads the entry again after + * the browser moved to another one. Must run in an injection context. + */ +export function setupCollectionViewStateSync(options: { + history: CollectionViewStateHistory; + selectedContentType: WritableSignal; + /** `undefined` wherever the scope toggle is not offered. */ + scope: Signal; +}): CollectionViewStateSync { + const currentViewState = (): CollectionViewState => ({ + selectedContentType: options.selectedContentType(), + scope: options.scope(), + }); + + effect(() => { + const state = currentViewState(); + untracked(() => options.history.commit(state)); + }); + + return { + restore(): void { + const collectionViewState = options.history.refresh(); + if (collectionViewState?.selectedContentType) { + options.selectedContentType.set( + collectionViewState.selectedContentType + ); + } + }, + commitCurrent(): void { + options.history.commit(currentViewState()); + }, + }; +} + +/** + * Push a history entry for an inline detail, so every way back out of it + * (Escape, the shell's Back arrow, the browser's own Back) returns to the + * list rather than leaving the route. + */ +export function pushOpenCollectionDetailState( + item: UnifiedCollectionItem +): void { + window.history.pushState( + { + ...toHistoryStateRecord(window.history.state), + [OPEN_COLLECTION_DETAIL_STATE_KEY]: + buildOpenCollectionDetailItemState(item), + }, + document.title + ); +} + +function isSameCollectionViewState( + left: CollectionViewState | null, + right: CollectionViewState | null +): boolean { + return ( + left?.selectedContentType === right?.selectedContentType && + left?.scope === right?.scope + ); +} + +function toHistoryStateRecord(state: unknown): Record { + return state && typeof state === 'object' + ? { ...(state as Record) } + : {}; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts new file mode 100644 index 000000000..5674fd74c --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts @@ -0,0 +1,64 @@ +import { computed, Signal } from '@angular/core'; +import { CollectionMode } from '@iptvnator/portal/shared/data-access'; + +export interface CollectionModeLabels { + readonly title: Signal; + readonly clearTooltipKey: Signal; + readonly emptyStateIcon: Signal; + readonly emptyStateTitleKey: Signal; + readonly emptyStateBodyKey: Signal; +} + +/** + * The copy a unified collection page shows for its mode. Favorites and + * recently viewed share every control, so only these strings differ. + */ +export function createCollectionModeLabels( + mode: Signal +): CollectionModeLabels { + const byMode = (favorites: T, recent: T): Signal => + computed(() => (mode() === 'favorites' ? favorites : recent)); + + return { + title: byMode('PORTALS.FAVORITES', 'PORTALS.RECENTLY_VIEWED'), + clearTooltipKey: byMode( + 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE', + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE' + ), + emptyStateIcon: byMode('favorite_border', 'history_toggle_off'), + emptyStateTitleKey: byMode( + 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE', + 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE' + ), + emptyStateBodyKey: byMode( + 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY', + 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY' + ), + }; +} + +/** + * Confirmation copy for "clear the current tab". The message names what the + * action reaches: the playlist scope removes this playlist's rows, the + * global one every playlist's. + */ +export function resolveClearCollectionDialogKeys( + mode: CollectionMode, + isPlaylistScope: boolean +): { titleKey: string; messageKey: string } { + if (mode === 'favorites') { + return { + titleKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE', + messageKey: isPlaylistScope + ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST' + : 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL', + }; + } + + return { + titleKey: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE', + messageKey: isPlaylistScope + ? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST' + : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL', + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts new file mode 100644 index 000000000..d889bbc98 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts @@ -0,0 +1,78 @@ +import { + computed, + effect, + EffectRef, + inject, + Signal, + untracked, +} from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; +import { + CollectionLoadRequest, + CollectionMode, +} from '@iptvnator/portal/shared/data-access'; +import { CollectionScope } from '@iptvnator/portal/shared/util'; + +/** + * Re-load the collection whenever what it should contain changes: its mode, + * the playlist or scope it is bound to, and — for favorites — the playlist + * rows themselves, since a heart toggled anywhere else in the app lands + * there rather than in this page's state. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function setupCollectionLoad(options: { + mode: Signal; + portalType: Signal; + playlistId: Signal; + scope: Signal; + load: (request: CollectionLoadRequest & { mode: CollectionMode }) => void; +}): EffectRef { + const store = inject(Store); + const playlists = store.selectSignal(selectAllPlaylistsMeta); + const playlistsLoaded = store.selectSignal(selectPlaylistsLoadingFlag); + + const favoritesReloadKey = computed(() => { + if (options.mode() !== 'favorites') { + return 'recent'; + } + + if (!playlistsLoaded()) { + return null; + } + + return playlists() + .map((playlist) => + [ + playlist._id, + playlist.serverUrl + ? 'xtream' + : playlist.macAddress + ? 'stalker' + : 'm3u', + JSON.stringify(playlist.favorites ?? []), + ].join('::') + ) + .join('|'); + }); + + const loadRequest = computed(() => ({ + mode: options.mode(), + portalType: options.portalType(), + playlistId: options.playlistId(), + scope: options.scope(), + reloadKey: favoritesReloadKey(), + })); + + return effect(() => { + const { mode, portalType, playlistId, scope } = loadRequest(); + untracked(() => { + options.load({ mode, portalType, playlistId, scope }); + }); + }); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index f41b3207d..c868ee6dd 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -35,7 +35,7 @@ import { import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from './collection-reload-indicator'; +import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from '@iptvnator/portal/shared/data-access'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 3900a1b20..2b0dfc2dd 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -6,14 +6,11 @@ import { computed, contentChild, DestroyRef, - effect, ElementRef, HostListener, inject, - linkedSignal, input, signal, - untracked, } from '@angular/core'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconButton } from '@angular/material/button'; @@ -22,59 +19,58 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { ChannelListSkeletonComponent, DialogService } from '@iptvnator/ui/components'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ChannelListSkeletonComponent } from '@iptvnator/ui/components'; import { - buildGlobalCollectionDetailNavigationTarget, - buildCollectionViewState, - buildOpenCollectionDetailItemState, clearNavigationStateKeys, - COLLECTION_VIEW_STATE_KEY, CollectionContentType, CollectionScope, - CollectionViewState, FavoritesChannelSortMode, - getFavoritesChannelSortModeTranslationKey, - getOpenCollectionDetailItemState, - getCollectionViewState, getOpenLiveCollectionItemState, - getUnifiedCollectionNavigation, isWorkspaceLayoutRoute, isTypingInInput, LiveLayoutSidebarStateService, - OPEN_COLLECTION_DETAIL_STATE_KEY, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, - persistFavoritesChannelSortMode, queryParamSignal, - restoreFavoritesChannelSortMode, routeParamSignal, - ScopeToggleService, - STALKER_RETURN_TO_STATE_KEY, - SeriesResumeTarget, UnifiedCollectionItem, - WorkspaceViewCommandService, } from '@iptvnator/portal/shared/util'; import { - UnifiedFavoritesDataService, - UnifiedRecentDataService, + CollectionLoadRequest, + CollectionMode, + UnifiedCollectionDataService, } from '@iptvnator/portal/shared/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; -import { createCollectionReloadIndicator } from './collection-reload-indicator'; import { - UnifiedCollectionDetailContext, - UnifiedCollectionDetailDirective, -} from './unified-collection-detail.directive'; + createClearCollectionAction, + setupClearCollectionViewCommand, +} from './unified-collection-clear-action'; +import { createCollectionContentTypeState } from './unified-collection-content-type'; +import { + buildCollectionDetailNavigation, + buildCollectionPortalNavigation, +} from './unified-collection-detail-navigation'; +import { createCollectionDetailState } from './unified-collection-detail-state'; +import { createFavoritesSortState } from './unified-collection-favorites-sort'; +import { + CollectionViewStateHistory, + pushOpenCollectionDetailState, + setupCollectionViewStateSync, +} from './unified-collection-history'; +import { createCollectionModeLabels } from './unified-collection-labels'; +import { setupCollectionLoad } from './unified-collection-load'; +import { createCollectionScopeState } from './unified-collection-scope'; +import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive'; @Component({ selector: 'app-unified-collection-page', templateUrl: './unified-collection-page.component.html', styleUrl: './unified-collection-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [UnifiedCollectionDataService], imports: [ ChannelListSkeletonComponent, EmptyStateComponent, @@ -91,7 +87,7 @@ import { ], }) export class UnifiedCollectionPageComponent implements AfterContentInit { - readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly mode = input('favorites'); readonly portalType = input(); readonly playlistIdInput = input(undefined, { alias: 'playlistId', @@ -100,32 +96,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly store = inject(Store); private readonly destroyRef = inject(DestroyRef); private readonly hostElement = inject(ElementRef); - private readonly scopeService = inject(ScopeToggleService); - private readonly favoritesData = inject(UnifiedFavoritesDataService); - private readonly recentData = inject(UnifiedRecentDataService); - private readonly dialogService = inject(DialogService); + private readonly data = inject(UnifiedCollectionDataService); private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly translate = inject(TranslateService); - private readonly workspaceViewCommands = inject(WorkspaceViewCommandService); private readonly liveSidebarStateService = inject( LiveLayoutSidebarStateService ); readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective); - private readonly playlists = this.store.selectSignal( - selectAllPlaylistsMeta - ); - private readonly playlistsLoaded = this.store.selectSignal( - selectPlaylistsLoadingFlag - ); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); - private readonly queryScope = queryParamSignal( - this.route, - 'scope', - (value) => (value === 'all' || value === 'playlist' ? value : null) - ); private readonly routeSearchTerm = queryParamSignal( this.route, 'q', @@ -142,165 +121,72 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); - private readonly historyCollectionViewState = - signal( - getCollectionViewState(window.history.state) - ); + private readonly viewStateHistory = new CollectionViewStateHistory(); - /** First load with nothing on screen: the skeleton replaces the content. */ - readonly isLoading = signal(true); - private readonly reloadIndicator = createCollectionReloadIndicator( - this.destroyRef - ); - /** - * A reload (scope switch, favorites reload) is in flight while the - * previous items stay mounted. Never swaps to the skeleton, so a playing - * channel and the focused toggle survive. - */ - readonly isReloading = this.reloadIndicator.active; - /** `isReloading` past its grace period: progress bar + dimming render. */ - readonly showReloadIndicator = this.reloadIndicator.visible; - readonly allItems = signal([]); - /** - * The request that produced `allItems`. Actions on displayed rows (Clear, - * drag reorder) must use it, not `effectiveScope()`: during a reload the - * toggle already names the requested scope while the previous rows are - * still on screen, and "This playlist" against global rows would delete - * other playlists' favorites or write foreign URLs into this playlist. - */ - private readonly loadedRequest = signal<{ - scope: CollectionScope; - playlistId?: string; - portalType?: string; - } | null>(null); - readonly favoriteUidSet = signal>(new Set()); - readonly selectedContentType = signal( - this.historyCollectionViewState()?.selectedContentType ?? 'live' - ); + readonly isLoading = this.data.isLoading; + readonly isReloading = this.data.isReloading; + readonly showReloadIndicator = this.data.showReloadIndicator; + readonly allItems = this.data.allItems; + readonly favoriteUidSet = this.data.favoriteUidSet; readonly supportsEpg = this.runtime.supportsEpg; - readonly selectedDetailItem = signal(null); - readonly selectedDetailSeriesResume = signal( - null - ); + readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); + readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); readonly pendingAutoOpenLiveItem = signal( getOpenLiveCollectionItemState(window.history.state) ); - readonly detailContext = computed( - () => { - const item = this.selectedDetailItem(); - if (!item) { - return null; - } - return { - $implicit: item, - item, - seriesResume: this.selectedDetailSeriesResume(), - close: this.requestCloseDetail, - }; - } + private readonly contentType = createCollectionContentTypeState( + this.allItems, + this.viewStateHistory.current()?.selectedContentType ?? 'live' ); - - readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); - readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); + readonly selectedContentType = this.contentType.selected; + readonly liveItems = this.contentType.liveItems; + readonly movieItems = this.contentType.movieItems; + readonly seriesItems = this.contentType.seriesItems; + readonly hasLive = this.contentType.hasLive; + readonly hasMovies = this.contentType.hasMovies; + readonly hasSeries = this.contentType.hasSeries; + readonly showContentToggle = this.contentType.showToggle; + readonly currentTypeItems = this.contentType.currentTypeItems; + readonly currentTypeLabelKey = this.contentType.currentTypeLabelKey; readonly scopeKey = computed(() => this.mode()); - private readonly persistedScope = computed(() => - this.scopeService.getScope(this.scopeKey())() - ); - readonly scope = linkedSignal(() => { - if (!this.showScopeToggle()) { - return 'all'; - } - - const queryScope = this.queryScope(); - if (queryScope) { - return queryScope; - } - - const historyScope = this.historyCollectionViewState()?.scope; - if (historyScope) { - return historyScope; - } - - const defaultScope = this.defaultScope(); - if (defaultScope) { - return defaultScope; - } - - return this.persistedScope(); + private readonly scopeState = createCollectionScopeState({ + scopeKey: this.scopeKey, + playlistId: this.playlistId, + defaultScope: this.defaultScope, + historyScope: computed(() => this.viewStateHistory.current()?.scope), }); - readonly showScopeToggle = computed(() => Boolean(this.playlistId())); - readonly effectiveScope = computed(() => - this.showScopeToggle() ? this.scope() : 'all' + readonly scope = this.scopeState.scope; + readonly showScopeToggle = this.scopeState.showToggle; + readonly effectiveScope = this.scopeState.effective; + /** + * Scope of the rows on screen. Every action on them (Clear, drag + * reorder) uses this instead of `effectiveScope()`, which already names + * the requested scope while a reload is in flight — "This playlist" + * against global rows would delete other playlists' favorites or write + * foreign URLs into this playlist. + */ + private readonly mutationRequest = computed( + () => + this.data.loadedRequest() ?? { + scope: this.effectiveScope(), + playlistId: this.playlistId(), + portalType: this.portalType(), + } ); - readonly liveItems = computed(() => - this.allItems().filter((i) => i.contentType === 'live') - ); - readonly movieItems = computed(() => - this.allItems().filter((i) => i.contentType === 'movie') - ); - readonly seriesItems = computed(() => - this.allItems().filter((i) => i.contentType === 'series') - ); + private readonly labels = createCollectionModeLabels(this.mode); + readonly title = this.labels.title; + readonly clearButtonTooltipKey = this.labels.clearTooltipKey; + readonly emptyStateIcon = this.labels.emptyStateIcon; + readonly emptyStateTitleKey = this.labels.emptyStateTitleKey; + readonly emptyStateBodyKey = this.labels.emptyStateBodyKey; - readonly hasLive = computed(() => this.liveItems().length > 0); - readonly hasMovies = computed(() => this.movieItems().length > 0); - readonly hasSeries = computed(() => this.seriesItems().length > 0); - - readonly availableTypes = computed(() => { - const types: CollectionContentType[] = []; - if (this.hasLive()) types.push('live'); - if (this.hasMovies()) types.push('movie'); - if (this.hasSeries()) types.push('series'); - return types; - }); - - readonly showContentToggle = computed( - () => this.availableTypes().length > 1 - ); - - readonly currentTypeItems = computed(() => { - switch (this.selectedContentType()) { - case 'live': - return this.liveItems(); - case 'movie': - return this.movieItems(); - case 'series': - return this.seriesItems(); - } - }); - - readonly currentTypeLabelKey = computed(() => { - switch (this.selectedContentType()) { - case 'live': - return 'PORTALS.LIVE_TV'; - case 'movie': - return 'PORTALS.MOVIES'; - case 'series': - return 'PORTALS.SERIES'; - } - }); - - readonly clearButtonTooltipKey = computed(() => - this.mode() === 'favorites' - ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE' - : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE' - ); - - readonly title = computed(() => { - return this.mode() === 'favorites' - ? 'PORTALS.FAVORITES' - : 'PORTALS.RECENTLY_VIEWED'; - }); - - readonly favSortMode = signal( - restoreFavoritesChannelSortMode() - ); - readonly favSortLabelKey = computed(() => - getFavoritesChannelSortModeTranslationKey(this.favSortMode()) - ); + private readonly favoritesSort = createFavoritesSortState(); + readonly favSortMode = this.favoritesSort.mode; + readonly favSortLabelKey = this.favoritesSort.labelKey; + readonly favSortOptions = this.favoritesSort.options; readonly showFavSortButton = computed( () => this.mode() === 'favorites' && @@ -312,148 +198,62 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly showSidebarToggle = computed( () => this.selectedContentType() === 'live' && this.hasLive() ); - readonly favSortOptions: ReadonlyArray<{ - mode: FavoritesChannelSortMode; - translationKey: string; - icon: string; - }> = [ - { - mode: 'custom', - translationKey: 'WORKSPACE.SORT_CUSTOM', - icon: 'drag_indicator', - }, - { - mode: 'name-asc', - translationKey: 'WORKSPACE.SORT_NAME_ASC', - icon: 'sort_by_alpha', - }, - { - mode: 'name-desc', - translationKey: 'WORKSPACE.SORT_NAME_DESC', - icon: 'sort_by_alpha', - }, - { - mode: 'date-desc', - translationKey: 'WORKSPACE.SORT_DATE_DESC', - icon: 'schedule', - }, - ]; - private readonly favoritesReloadKey = computed(() => { - if (this.mode() !== 'favorites') { - return 'recent'; - } - - if (!this.playlistsLoaded()) { - return null; - } - - return this.playlists() - .map((playlist) => - [ - playlist._id, - playlist.serverUrl - ? 'xtream' - : playlist.macAddress - ? 'stalker' - : 'm3u', - JSON.stringify(playlist.favorites ?? []), - ].join('::') - ) - .join('|'); + private readonly loadEffect = setupCollectionLoad({ + mode: this.mode, + portalType: this.portalType, + playlistId: this.playlistId, + scope: this.effectiveScope, + load: (request) => void this.loadData(request), }); - private readonly loadRequest = computed(() => ({ - mode: this.mode(), - portalType: this.portalType(), - playlistId: this.playlistId(), - scope: this.effectiveScope(), - reloadKey: this.favoritesReloadKey(), - })); - - private loadRequestId = 0; - - private readonly loadEffect = effect(() => { - const { mode, portalType, playlistId, scope } = this.loadRequest(); - untracked(() => { - void this.loadData({ - mode, - portalType, - playlistId, - scope, - }); - }); + private readonly viewStateSync = setupCollectionViewStateSync({ + history: this.viewStateHistory, + selectedContentType: this.selectedContentType, + scope: computed(() => + this.showScopeToggle() ? this.scope() : undefined + ), }); - private readonly persistCollectionViewState = effect(() => { - const selectedContentType = this.selectedContentType(); - const scope = this.showScopeToggle() ? this.scope() : undefined; - - untracked(() => { - this.syncCollectionViewStateToHistory({ - selectedContentType, - scope, - }); - }); - }); - private readonly crossProviderDetailRedirect = effect(() => { - const item = this.selectedDetailItem(); - if (!item || this.canRenderInlineDetailOnCurrentRoute(item)) { - return; - } - - const navigation = this.getGlobalCollectionDetailNavigation( - item, - this.selectedDetailSeriesResume() - ); - if (!navigation) { - return; - } - - untracked(() => { - this.selectedDetailItem.set(null); - void this.router.navigate(navigation.link, { - state: navigation.state, - }); - }); - }); - private readonly workspaceCommandEffect = effect((onCleanup) => { - if (!this.isWorkspaceLayout) { - return; - } - - const items = this.currentTypeItems(); - if (items.length === 0) { - return; - } - - const unregister = this.workspaceViewCommands.registerCommand({ - id: `unified-collection-clear-current-${this.mode()}`, - group: 'view', - icon: 'delete_sweep', - labelKey: this.clearButtonTooltipKey(), - labelParams: () => ({ - type: this.translate.instant(this.currentTypeLabelKey()), + private readonly clearAction = createClearCollectionAction({ + mode: this.mode, + items: this.currentTypeItems, + typeLabelKey: this.currentTypeLabelKey, + isPlaylistScope: () => this.mutationRequest().scope === 'playlist', + data: this.data, + dropCurrentType: () => this.dropCurrentContentType(), + reload: () => + this.loadData({ + mode: this.mode(), + portalType: this.portalType(), + playlistId: this.playlistId(), + scope: this.effectiveScope(), }), - descriptionKey: - 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION', - descriptionParams: () => ({ - type: this.translate.instant(this.currentTypeLabelKey()), - }), - keywords: () => - this.mode() === 'favorites' - ? ['clear', 'favorites', 'remove', this.selectedContentType()] - : ['clear', 'recent', 'history', this.selectedContentType()], - priority: 10, + }); + private readonly detailState = createCollectionDetailState({ + mode: this.mode, + portalType: this.portalType, + detailTemplate: this.detailTemplate, + onOpen: (contentType) => this.selectedContentType.set(contentType), + onClear: () => this.autoSelectContentType(), + }); + readonly selectedDetailItem = this.detailState.item; + readonly selectedDetailSeriesResume = this.detailState.seriesResume; + readonly detailContext = this.detailState.context; + + constructor() { + setupClearCollectionViewCommand({ + isWorkspaceLayout: this.isWorkspaceLayout, + mode: this.mode, + selectedContentType: this.selectedContentType, + currentTypeItems: this.currentTypeItems, + labelKey: this.clearButtonTooltipKey, + typeLabelKey: this.currentTypeLabelKey, run: () => this.clearAllCurrent(), }); - onCleanup(unregister); - }); - - constructor() { if (typeof window !== 'undefined') { const onPopState = () => { - this.syncCollectionViewStateFromHistory(); - this.syncDetailFromHistoryState(); + this.viewStateSync.restore(); + this.detailState.syncFromHistory(); }; window.addEventListener('popstate', onPopState); this.destroyRef.onDestroy(() => { @@ -463,39 +263,18 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } ngAfterContentInit(): void { - this.syncCollectionViewStateFromHistory(); - this.syncDetailFromHistoryState(); + this.viewStateSync.restore(); + this.detailState.syncFromHistory(); } onScopeChange(value: CollectionScope): void { - if (!this.showScopeToggle()) { - return; - } - - this.scope.set(value); - this.scopeService.setScope(this.scopeKey(), value); + this.scopeState.select(value); } onContentTypeChange(value: CollectionContentType): void { this.selectedContentType.set(value); } - readonly emptyStateIcon = computed(() => - this.mode() === 'favorites' ? 'favorite_border' : 'history_toggle_off' - ); - - readonly emptyStateTitleKey = computed(() => - this.mode() === 'favorites' - ? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE' - : 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE' - ); - - readonly emptyStateBodyKey = computed(() => - this.mode() === 'favorites' - ? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY' - : 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY' - ); - goToDashboard(): void { void this.router.navigate(['/workspace', 'dashboard']); } @@ -528,58 +307,32 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } setFavSortMode(mode: FavoritesChannelSortMode): void { - this.favSortMode.set(mode); - persistFavoritesChannelSortMode(mode); + this.favoritesSort.setMode(mode); } onGridItemSelected(item: UnifiedCollectionItem): void { - this.syncCurrentCollectionViewState(); + this.viewStateSync.commitCurrent(); - if (this.canOpenInlineDetail(item)) { - this.pushInlineDetailState(item); - this.openInlineDetail(item); + if (this.detailState.canOpen(item)) { + pushOpenCollectionDetailState(item); + this.detailState.open(item); return; } - const globalDetailNavigation = - this.getGlobalCollectionDetailNavigation(item); - if (globalDetailNavigation) { - void this.router.navigate(globalDetailNavigation.link, { - state: globalDetailNavigation.state, - }); - return; - } - - const navigation = getUnifiedCollectionNavigation(item); + const navigation = + buildCollectionDetailNavigation(this.mode(), item) ?? + buildCollectionPortalNavigation(item, this.router.url); if (!navigation) { return; } - const state = - item.sourceType === 'stalker' && item.contentType !== 'live' - ? { - ...(navigation.state ?? {}), - [STALKER_RETURN_TO_STATE_KEY]: this.router.url, - } - : navigation.state; - - void this.router.navigate(navigation.link, { state }); + void this.router.navigate(navigation.link, { + state: navigation.state, + }); } async onRemoveItem(item: UnifiedCollectionItem): Promise { - if (this.mode() === 'favorites') { - await this.favoritesData.removeFavorite(item); - this.favoriteUidSet.update((favoriteUids) => { - const nextFavoriteUids = new Set(favoriteUids); - nextFavoriteUids.delete(item.uid); - return nextFavoriteUids; - }); - } else { - await this.recentData.removeRecentItem(item); - } - this.allItems.update((items) => - items.filter((i) => i.uid !== item.uid) - ); + await this.data.removeItem(this.mode(), item); } async onFavoriteToggled(item: UnifiedCollectionItem): Promise { @@ -587,74 +340,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { return; } - const nextFavoriteUids = new Set(this.favoriteUidSet()); - - if (nextFavoriteUids.has(item.uid)) { - await this.favoritesData.removeFavorite(item); - nextFavoriteUids.delete(item.uid); - } else { - await this.favoritesData.addFavorite(item); - nextFavoriteUids.add(item.uid); - } - - this.favoriteUidSet.set(nextFavoriteUids); + await this.data.toggleFavorite(item); } clearAllCurrent(): void { - const itemsToRemove = this.currentTypeItems(); - if (itemsToRemove.length === 0) { - return; - } - - const isFavorites = this.mode() === 'favorites'; - const type = this.translate.instant(this.currentTypeLabelKey()); - const isPlaylistScope = - (this.loadedRequest()?.scope ?? this.effectiveScope()) === - 'playlist'; - const titleKey = isFavorites - ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE' - : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE'; - const messageKey = isFavorites - ? isPlaylistScope - ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST' - : 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL' - : isPlaylistScope - ? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST' - : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL'; - - this.dialogService.openConfirmDialog({ - title: this.translate.instant(titleKey, { type }), - message: this.translate.instant(messageKey, { type }), - onConfirm: async () => { - if (isFavorites) { - await this.clearCurrentFavorites(itemsToRemove); - return; - } - - const removedType = this.selectedContentType(); - this.allItems.update((items) => - items.filter((item) => item.contentType !== removedType) - ); - const remaining = this.availableTypes(); - if (remaining.length > 0) { - this.selectedContentType.set(remaining[0]); - } - void this.recentData.removeRecentItemsBatch(itemsToRemove); - }, - }); + this.clearAction.run(); } async onReorder(items: UnifiedCollectionItem[]): Promise { - const nonLive = this.allItems().filter((i) => i.contentType !== 'live'); - this.allItems.set([...items, ...nonLive]); - await this.favoritesData.reorder( - items, - this.loadedRequest() ?? { - scope: this.effectiveScope(), - playlistId: this.playlistId(), - portalType: this.portalType(), - } - ); + await this.data.reorder(items, this.mutationRequest()); } onItemPlayed(item: UnifiedCollectionItem): void { @@ -662,133 +356,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { return; } - this.allItems.update((items) => { - const nextItems = [ - item, - ...items.filter((candidate) => candidate.uid !== item.uid), - ]; - return nextItems.sort( - (a, b) => - new Date(b.viewedAt ?? 0).getTime() - - new Date(a.viewedAt ?? 0).getTime() - ); - }); - } - - private async clearCurrentFavorites( - itemsToRemove: UnifiedCollectionItem[] - ): Promise { - const removedType = this.selectedContentType(); - this.allItems.update((items) => - items.filter((item) => item.contentType !== removedType) - ); - const remaining = this.availableTypes(); - if (remaining.length > 0) { - this.selectedContentType.set(remaining[0]); - } - - try { - await this.favoritesData.clearFavorites(itemsToRemove); - } catch { - await this.reloadCurrentCollection(); - } - } - - private async loadData(params: { - mode: 'favorites' | 'recent'; - portalType?: string; - playlistId?: string; - scope: CollectionScope; - }): Promise { - const requestId = ++this.loadRequestId; - if (this.allItems().length === 0) { - this.isLoading.set(true); - } else { - this.reloadIndicator.begin(); - } - - try { - const items = - params.mode === 'favorites' - ? await this.favoritesData.getFavorites( - params.scope, - params.playlistId, - params.portalType - ) - : await this.recentData.getRecentItems( - params.scope, - params.playlistId, - params.portalType - ); - const favoriteUids = - params.mode === 'favorites' - ? new Set(items.map((item) => item.uid)) - : await this.loadFavoriteUidSet(params); - if (requestId !== this.loadRequestId) { - return; - } - this.allItems.set(items); - this.loadedRequest.set({ - scope: params.scope, - playlistId: params.playlistId, - portalType: params.portalType, - }); - this.favoriteUidSet.set(favoriteUids); - this.autoSelectContentType(); - if ( - this.pendingAutoOpenLiveItem() && - items.some((item) => item.contentType === 'live') - ) { - this.selectedContentType.set('live'); - } - } catch { - if (requestId !== this.loadRequestId) { - return; - } - this.allItems.set([]); - } finally { - if (requestId === this.loadRequestId) { - this.isLoading.set(false); - this.reloadIndicator.settle(); - } - } - } - - private async reloadCurrentCollection(): Promise { - await this.loadData({ - mode: this.mode(), - portalType: this.portalType(), - playlistId: this.playlistId(), - scope: this.effectiveScope(), - }); - } - - private async loadFavoriteUidSet(params: { - portalType?: string; - playlistId?: string; - scope: CollectionScope; - }): Promise> { - try { - const favorites = await this.favoritesData.getFavorites( - params.scope, - params.playlistId, - params.portalType - ); - return new Set(favorites.map((item) => item.uid)); - } catch { - return new Set(); - } - } - - private autoSelectContentType(): void { - if (this.selectedDetailItem()) { - return; - } - - const types = this.availableTypes(); - if (types.length > 0 && !types.includes(this.selectedContentType())) { - this.selectedContentType.set(types[0]); - } + this.data.promoteRecentItem(item); } onLiveAutoOpenHandled(): void { @@ -796,171 +364,35 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { clearNavigationStateKeys([OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]); } - private readonly requestCloseDetail = (): void => { - if (getOpenCollectionDetailItemState(window.history.state)) { - window.history.back(); + /** Take the just-cleared tab off screen and move to a remaining one. */ + private dropCurrentContentType(): void { + this.data.dropContentType(this.selectedContentType()); + this.contentType.autoSelect(); + } + + private async loadData( + params: CollectionLoadRequest & { mode: CollectionMode } + ): Promise { + const items = await this.data.load(params); + if (!items) { return; } - this.clearInlineDetail(); - }; - - private canOpenInlineDetail(item: UnifiedCollectionItem): boolean { - return ( - Boolean(this.detailTemplate()) && - item.contentType !== 'live' && - (item.sourceType === 'xtream' || item.sourceType === 'stalker') && - this.canRenderInlineDetailOnCurrentRoute(item) - ); - } - - private canRenderInlineDetailOnCurrentRoute( - item: UnifiedCollectionItem - ): boolean { - const url = this.router.url; - - if (url.includes('/workspace/xtreams/')) { - return item.sourceType === 'xtream'; - } - - if (url.includes('/workspace/stalker/')) { - return item.sourceType === 'stalker'; - } - - if ( - url.includes('/workspace/global-favorites') || - url.includes('/workspace/global-recent') - ) { - return ( - item.sourceType === 'xtream' || item.sourceType === 'stalker' - ); - } - - const portalType = this.portalType(); - return !portalType || portalType === item.sourceType; - } - - private getGlobalCollectionDetailNavigation( - item: UnifiedCollectionItem, - seriesResume?: SeriesResumeTarget | null - ) { - if ( - item.contentType === 'live' || - (item.sourceType !== 'xtream' && item.sourceType !== 'stalker') - ) { - return null; - } - - return buildGlobalCollectionDetailNavigationTarget( - this.mode(), - item, - seriesResume - ); - } - - private openInlineDetail( - item: UnifiedCollectionItem, - seriesResume?: SeriesResumeTarget | null - ): void { - this.selectedContentType.set(item.contentType); - this.selectedDetailItem.set(item); - this.selectedDetailSeriesResume.set(seriesResume ?? null); - } - - private clearInlineDetail(): void { - this.selectedDetailItem.set(null); - this.selectedDetailSeriesResume.set(null); this.autoSelectContentType(); - clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]); - } - - private pushInlineDetailState(item: UnifiedCollectionItem): void { - const currentState = window.history.state ?? {}; - window.history.pushState( - { - ...currentState, - [OPEN_COLLECTION_DETAIL_STATE_KEY]: - buildOpenCollectionDetailItemState(item), - }, - document.title - ); - } - - private syncCollectionViewStateFromHistory(): void { - const collectionViewState = getCollectionViewState( - window.history.state - ); - this.historyCollectionViewState.set(collectionViewState); - - if (collectionViewState?.selectedContentType) { - this.selectedContentType.set( - collectionViewState.selectedContentType - ); - } - } - - private syncCurrentCollectionViewState(): void { - this.syncCollectionViewStateToHistory({ - selectedContentType: this.selectedContentType(), - scope: this.showScopeToggle() ? this.scope() : undefined, - }); - } - - private syncCollectionViewStateToHistory(state: CollectionViewState): void { - const nextCollectionViewState = buildCollectionViewState(state); - const currentCollectionViewState = this.historyCollectionViewState(); - if ( - this.isSameCollectionViewState( - currentCollectionViewState, - nextCollectionViewState - ) + this.pendingAutoOpenLiveItem() && + items.some((item) => item.contentType === 'live') ) { - return; + this.selectedContentType.set('live'); } - - const currentState = this.toHistoryStateRecord(window.history.state); - const nextState = { ...currentState }; - - if (nextCollectionViewState) { - nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState; - } else { - delete nextState[COLLECTION_VIEW_STATE_KEY]; - } - - window.history.replaceState(nextState, document.title); - this.historyCollectionViewState.set(nextCollectionViewState); } - private syncDetailFromHistoryState(): void { - const detailState = getOpenCollectionDetailItemState( - window.history.state - ); - const detailItem = detailState?.item; - - if (detailItem && this.canOpenInlineDetail(detailItem)) { - this.openInlineDetail(detailItem, detailState.seriesResume); - return; - } - + /** Never moves off the tab an open inline detail belongs to. */ + private autoSelectContentType(): void { if (this.selectedDetailItem()) { - this.clearInlineDetail(); + return; } - } - private isSameCollectionViewState( - left: CollectionViewState | null, - right: CollectionViewState | null - ): boolean { - return ( - left?.selectedContentType === right?.selectedContentType && - left?.scope === right?.scope - ); - } - - private toHistoryStateRecord(state: unknown): Record { - return state && typeof state === 'object' - ? { ...(state as Record) } - : {}; + this.contentType.autoSelect(); } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts new file mode 100644 index 000000000..92dd86d41 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts @@ -0,0 +1,78 @@ +import { + computed, + inject, + linkedSignal, + Signal, + WritableSignal, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { + CollectionScope, + queryParamSignal, + ScopeToggleService, +} from '@iptvnator/portal/shared/util'; + +export interface CollectionScopeState { + /** The toggle's value, even where the toggle itself is not offered. */ + readonly scope: WritableSignal; + /** Only a playlist-bound collection can narrow to one playlist. */ + readonly showToggle: Signal; + /** The scope to load with: `all` wherever the toggle is absent. */ + readonly effective: Signal; + /** The user picked a scope; remember it for the next visit. */ + select(value: CollectionScope): void; +} + +/** + * "This playlist" vs "All playlists" for a collection. The value is taken + * from the first source that states one — the `scope` query param, the + * history entry the user came back to, the route's own default — and falls + * back to what this collection was last left on. Must run in an injection + * context (the hosting component's field initializer or constructor). + */ +export function createCollectionScopeState(options: { + /** Keys the persisted scope: favorites and recent are remembered apart. */ + scopeKey: Signal; + playlistId: Signal; + defaultScope: Signal; + historyScope: Signal; +}): CollectionScopeState { + const route = inject(ActivatedRoute); + const scopeService = inject(ScopeToggleService); + const queryScope = queryParamSignal( + route, + 'scope', + (value) => (value === 'all' || value === 'playlist' ? value : null) + ); + const persistedScope = computed(() => + scopeService.getScope(options.scopeKey())() + ); + const showToggle = computed(() => Boolean(options.playlistId())); + + const scope = linkedSignal(() => { + if (!showToggle()) { + return 'all'; + } + + return ( + queryScope() ?? + options.historyScope() ?? + options.defaultScope() ?? + persistedScope() + ); + }); + + return { + scope, + showToggle, + effective: computed(() => (showToggle() ? scope() : 'all')), + select(value: CollectionScope): void { + if (!showToggle()) { + return; + } + + scope.set(value); + scopeService.setScope(options.scopeKey(), value); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts new file mode 100644 index 000000000..b161d877b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts @@ -0,0 +1,183 @@ +import { computed, inject, Signal, WritableSignal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils'; +import { + ResolvedLiveCollectionDetail, + StreamResolverService, +} from '@iptvnator/portal/shared/data-access'; +import { + PORTAL_PLAYER, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { + EpgArchiveCopyService, + EpgProgramActivationEvent, +} from '@iptvnator/ui/epg'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { toEpochSeconds } from './unified-live-epg-summary.util'; +import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; + +/** Catch-up override for the active channel; `null` = live playback. */ +export interface UnifiedLiveTimeshift { + url: string; + program: EpgProgram; +} + +export interface UnifiedLiveCatchup { + readonly activeProgram: Signal; + /** Act on a programme picked in the timeline or the list view. */ + handleProgramActivation(event: EpgProgramActivationEvent): void; + returnToLive(): void; +} + +/** + * Catch-up playback for the live tab: turning a programme from the EPG + * panel into a timeshift URL, and getting back to the live edge. The + * override itself lives on the host, because the selection session has to + * swap it together with the detail it belongs to. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveCatchup(options: { + activeTimeshift: WritableSignal; + activeItem: Signal; + activeDetail: Signal; + isM3uSelection: Signal; + currentM3uChannel: Signal; + shouldUseInlinePlayer: Signal; + /** Drops a portal resolution that landed after a channel switch. */ + generation: UnifiedLiveSelectionGeneration; +}): UnifiedLiveCatchup { + const archiveCopy = inject(EpgArchiveCopyService); + const streamResolver = inject(StreamResolverService); + const portalPlayer = inject(PORTAL_PLAYER); + const snackBar = inject(MatSnackBar); + const translate = inject(TranslateService); + + const reportFailure = (): void => { + snackBar.open( + translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + }; + + /** Hand the resolved archive URL to whichever surface is playing. */ + const playArchive = (url: string, program: EpgProgram): void => { + options.activeTimeshift.set({ url, program }); + + const playback = options.activeDetail()?.playback; + if (!options.shouldUseInlinePlayer() && playback) { + void portalPlayer.openResolvedPlayback({ + ...playback, + streamUrl: url, + isLive: false, + }); + } + }; + + /** M3U catch-up: resolve via the M3U timeshift URL resolver. */ + const activateM3uCatchup = (program: EpgProgram): void => { + const playbackUrl = resolveM3uCatchupUrl( + options.currentM3uChannel(), + program + ); + if (!playbackUrl) { + reportFailure(); + return; + } + + playArchive(playbackUrl, program); + }; + + /** + * Portal (Xtream) catch-up: compute start/stop as epoch seconds and + * resolve via the provider's timeshift endpoint. + */ + const activatePortalCatchup = async ( + program: EpgProgram + ): Promise => { + const generation = options.generation.current(); + const item = options.activeItem(); + const startEpoch = toEpochSeconds( + program.startTimestamp, + program.start + ); + const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop); + if (!item?.xtreamId || startEpoch == null || stopEpoch == null) { + reportFailure(); + return; + } + + const playbackUrl = await streamResolver.resolveXtreamCatchupUrl( + item, + startEpoch, + stopEpoch + ); + if (generation !== options.generation.current()) { + return; // switched channel — discard silently + } + if (!playbackUrl) { + reportFailure(); + return; + } + + playArchive(playbackUrl, program); + }; + + const copyArchiveUrl = (program: EpgProgram): void => { + const channel = options.currentM3uChannel(); + const item = options.activeItem(); + const isM3u = options.isM3uSelection(); + void archiveCopy.copy(() => { + if (isM3u) return resolveM3uCatchupUrl(channel, program); + const start = toEpochSeconds(program.startTimestamp, program.start); + const stop = toEpochSeconds(program.stopTimestamp, program.stop); + return item?.xtreamId && + start != null && + stop != null && + stop > start + ? streamResolver.resolveXtreamCatchupUrl(item, start, stop) + : null; + }); + }; + + const returnToLive = (): void => { + options.activeTimeshift.set(null); + + // Inline player is already (back) on the live stream once the + // timeshift override is cleared. With an external player configured, + // "Watch live" must open it even when no archive was active — e.g. + // openStreamOnDoubleClick shows the guide without launching playback. + const playback = options.activeDetail()?.playback; + if (!options.shouldUseInlinePlayer() && playback) { + void portalPlayer.openResolvedPlayback(playback); + } + }; + + return { + activeProgram: computed( + () => options.activeTimeshift()?.program ?? null + ), + handleProgramActivation(event: EpgProgramActivationEvent): void { + if (event.type === 'copy-catchup-url') { + copyArchiveUrl(event.program); + return; + } + + if (event.type === 'live') { + returnToLive(); + return; + } + + if (options.isM3uSelection()) { + activateM3uCatchup(event.program); + } else { + void activatePortalCatchup(event.program); + } + }, + returnToLive, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts new file mode 100644 index 000000000..0d9745477 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts @@ -0,0 +1,84 @@ +import { computed, Signal } from '@angular/core'; +import { + deriveVisibleFavoriteChannels, + FavoritesChannelSortMode, + UnifiedCollectionItem, + UnifiedFavoriteChannel, +} from '@iptvnator/portal/shared/util'; + +export interface UnifiedLiveChannelRows { + /** Every collection row, as the shared list renders them. */ + readonly all: Signal; + /** + * The channel list in exactly the order the sidebar renders it + * (search-filtered; sorted in favorites mode) — remote-control + * navigation and channel numbers must follow what is on screen. + */ + readonly visible: Signal; + /** + * Rows for the fullscreen channel panel. Radio is withheld: it renders + * through `app-audio-player` instead of `app-web-player-view`, so + * selecting a station destroys the element that owns fullscreen and + * drops the user out of it — the opposite of what the panel exists for. + * The page's own list keeps every row. + */ + readonly fullscreenPanel: Signal; +} + +export function createUnifiedLiveChannelRows(options: { + items: Signal; + searchTerm: Signal; + mode: Signal<'favorites' | 'recent'>; + sortMode: Signal; +}): UnifiedLiveChannelRows { + const all = computed((): UnifiedFavoriteChannel[] => + options.items().map(toUnifiedFavoriteChannel) + ); + + return { + all, + visible: computed(() => + deriveVisibleFavoriteChannels(all(), { + searchTerm: options.searchTerm(), + sortMode: + options.mode() === 'favorites' ? options.sortMode() : null, + getName: (channel) => channel.name, + getAddedAt: (channel) => channel.addedAt, + }) + ), + fullscreenPanel: computed(() => + all().filter((channel) => channel.radio !== 'true') + ), + }; +} + +/** + * Project a collection row onto the channel shape the shared favorites list + * renders. The two differ because a collection row also carries VOD fields + * the live rail has no use for. + */ +export function toUnifiedFavoriteChannel( + item: UnifiedCollectionItem +): UnifiedFavoriteChannel { + return { + uid: item.uid, + name: item.name, + logo: item.logo ?? null, + sourceType: item.sourceType, + playlistId: item.playlistId, + playlistName: item.playlistName, + streamUrl: item.streamUrl, + m3uChannel: item.m3uChannel, + radio: item.radio, + xtreamId: item.xtreamId, + tvArchive: item.tvArchive ?? null, + tvArchiveDuration: item.tvArchiveDuration ?? null, + tvgId: item.tvgId, + stalkerCmd: item.stalkerCmd, + stalkerPortalUrl: item.stalkerPortalUrl, + stalkerMacAddress: item.stalkerMacAddress, + addedAt: item.addedAt ?? new Date(0).toISOString(), + position: item.position ?? 0, + contentId: item.contentId, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts new file mode 100644 index 000000000..06d6d2cbf --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts @@ -0,0 +1,63 @@ +import { effect, inject, Signal, signal, untracked } from '@angular/core'; +import { StreamResolverService } from '@iptvnator/portal/shared/data-access'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; + +export interface UnifiedLiveEpgMap { + /** What is on now, per collection row, for the channel rail. */ + readonly programs: Signal>; + load(items: UnifiedCollectionItem[]): void; + clear(): void; +} + +/** + * The "now playing" programme of every row in the live rail. Must run in an + * injection context (the hosting component's field initializer). + */ +export function createUnifiedLiveEpgMap(options: { + supportsEpg: boolean; + items: Signal; + /** EPG display offset; the map answers "what is on at that clock". */ + offsetMinutes: Signal; +}): UnifiedLiveEpgMap { + const streamResolver = inject(StreamResolverService); + const programs = signal>(new Map()); + let requestId = 0; + + const load = (items: UnifiedCollectionItem[]): void => { + // Only the latest load may install its map: an older one — started + // under a previous display offset or item set — must not overwrite + // the refreshed result when it completes last. + const currentRequestId = ++requestId; + void streamResolver.loadEpgForItems(items).then((epgMap) => { + if (currentRequestId === requestId) { + programs.set(epgMap); + } + }); + }; + + // A changed display offset reloads the map; the request id above drops + // the older load. The first run only records the initial value. + let appliedOffsetMinutes: number | null = null; + effect(() => { + const offsetMinutes = options.offsetMinutes(); + if (appliedOffsetMinutes === offsetMinutes) { + return; + } + const isInitial = appliedOffsetMinutes === null; + appliedOffsetMinutes = offsetMinutes; + if (isInitial || !options.supportsEpg) { + return; + } + load(untracked(() => options.items())); + }); + + return { + programs: programs.asReadonly(), + load, + clear(): void { + requestId += 1; + programs.set(new Map()); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts new file mode 100644 index 000000000..c66880399 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts @@ -0,0 +1,177 @@ +import { computed, inject, Signal, signal } from '@angular/core'; +import { + applyChannelNameStrip, + getM3uArchiveDays, + isM3uCatchupPlaybackSupported, +} from '@iptvnator/shared/m3u-utils'; +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; +import { + LiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { + EpgDateNavigationDirection, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '@iptvnator/ui/epg'; +import { SettingsStore } from '@iptvnator/services'; +import { + Channel, + EpgItem, + EpgProgram, + EpgViewMode, +} from '@iptvnator/shared/interfaces'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { + getLiveEpgPanelSummary, + toEpgProgram, + toLiveEpgPanelSummary, +} from './unified-live-epg-summary.util'; +import { UnifiedLiveTimeshift } from './unified-live-catchup'; + +/** + * The EPG panel under the live player: its collapsed/expanded state, the + * day it is showing, and the programme data the timeline and list views + * render for whichever channel is selected. + */ +export interface UnifiedLiveEpgView { + readonly panelState: Signal; + readonly isPanelCollapsed: Signal; + readonly selectedDate: Signal; + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly viewMode: Signal; + readonly offsetMinutes: Signal; + readonly timelinePrograms: Signal; + readonly timelineChannelName: Signal; + readonly timelineChannelLogo: Signal; + readonly timelineArchiveAvailable: Signal; + readonly timelineArchiveDays: Signal; + readonly panelSummary: Signal; + readonly panelSummaryLabelKey: Signal; + setPanelCollapsed(collapsed: boolean): void; + navigateDate(direction: EpgDateNavigationDirection): void; + selectDate(selectedDate: string): void; +} + +/** + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveEpgView(options: { + activeDetail: Signal; + activeItem: Signal; + activeTimeshift: Signal; + isM3uSelection: Signal; + currentM3uChannel: Signal; + currentM3uPrograms: Signal; + currentPortalEpgItems: Signal; + /** 30 s tick that re-evaluates "what is on now". */ + progressTick: Signal; +}): UnifiedLiveEpgView { + const settingsStore = inject(SettingsStore); + const panelState = signal(restoreLiveEpgPanelState()); + const selectedDate = signal(getTodayEpgDateKey()); + const offsetMinutes = settingsStore.resolvedEpgOffsetMinutes; + + const currentM3uArchivePlaybackAvailable = computed(() => + isM3uCatchupPlaybackSupported(options.currentM3uChannel()) + ); + + const timelinePrograms = computed(() => + options.isM3uSelection() + ? options.currentM3uPrograms() + : // Portal EPG items normalised to the timeline programme shape. + options.currentPortalEpgItems().map((item) => toEpgProgram(item)) + ); + + const timelineArchiveAvailable = computed(() => { + if (options.isM3uSelection()) { + return currentM3uArchivePlaybackAvailable(); + } + + // Portal archive: tvArchive === 1 means the provider has + // timeshift / archive enabled for this channel. + const item = options.activeItem(); + return ( + Number(item?.tvArchive ?? 0) === 1 && + Number(item?.tvArchiveDuration ?? 0) > 0 + ); + }); + + return { + panelState: panelState.asReadonly(), + isPanelCollapsed: computed(() => panelState() === 'collapsed'), + selectedDate: selectedDate.asReadonly(), + viewMode: settingsStore.resolvedEpgViewMode, + offsetMinutes, + timelinePrograms, + timelineChannelName: computed(() => + applyChannelNameStrip( + options.currentM3uChannel()?.name ?? + options.activeDetail()?.playback?.title, + settingsStore.stripCountryPrefix?.() + ) + ), + timelineChannelLogo: computed( + () => + options.currentM3uChannel()?.tvg?.logo ?? + options.activeDetail()?.playback?.thumbnail ?? + '' + ), + timelineArchiveAvailable, + /** + * Catch-up window (days) for the active channel, so the timeline can + * gate "Watch" to programmes inside it. Without this the timeline + * defaults `archiveDays` to 0 (treated as unlimited) and offers + * catch-up on programmes older than the real archive window. + * + * M3U: reads catchup-days / timeshift / tvg-rec from the channel + * attrs. Portal: tvArchiveDuration is already in days — pass it + * through, matching `live-stream-layout.controlledArchiveDays`. + */ + timelineArchiveDays: computed(() => { + if (!timelineArchiveAvailable()) return 0; + + if (options.isM3uSelection()) { + return getM3uArchiveDays(options.currentM3uChannel()); + } + + return Math.max( + 0, + Number(options.activeItem()?.tvArchiveDuration ?? 0) || 0 + ); + }), + panelSummary: computed(() => { + const timeshift = options.activeTimeshift(); + if (timeshift) { + // Archive summary is frozen — don't track the 30s tick. + return toLiveEpgPanelSummary(timeshift.program); + } + options.progressTick(); + return getLiveEpgPanelSummary( + options.activeDetail(), + offsetMinutes() + ); + }), + panelSummaryLabelKey: computed(() => + options.activeTimeshift() + ? 'EPG.ARCHIVE_PLAYBACK' + : 'EPG.CURRENT_PROGRAM' + ), + setPanelCollapsed(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed + ? 'collapsed' + : 'expanded'; + panelState.set(state); + persistLiveEpgPanelState(state); + }, + navigateDate(direction: EpgDateNavigationDirection): void { + selectedDate.set(shiftEpgDateKey(selectedDate(), direction)); + }, + selectDate(nextDate: string): void { + selectedDate.set(nextDate); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts new file mode 100644 index 000000000..0b60f28a2 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts @@ -0,0 +1,132 @@ +import { computed, inject, Signal } from '@angular/core'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { RecordingsService } from '@iptvnator/services'; +import { + buildStalkerEpgMappingKey, + buildXtreamEpgMappingKey, + EpgProgram, + epgProviderClockMs, + filterRecordingProgramsOverlap, + playlistDisplayLabel, + RecordingStartMetadata, + RecordingStoppedEvent, + toRecordingProgramSnapshot, +} from '@iptvnator/shared/interfaces'; + +export interface UnifiedLiveRecording { + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly metadata: Signal; + /** Stop enrichment: programs overlapping the recorded window. */ + handleRecordingStopped(event: RecordingStoppedEvent): void; +} + +/** + * What a live recording started from this tab records about its channel. + * Provider EPG never reaches SQLite, so the schedule can only be captured + * while the stream is on screen. Must run in an injection context. + */ +export function createUnifiedLiveRecording(options: { + activeItem: Signal; + timelinePrograms: Signal; + timelineChannelLogo: Signal; + epgOffsetMinutes: Signal; + /** 30 s tick; see `metadata`. */ + progressTick: Signal; +}): UnifiedLiveRecording { + const recordingsService = inject(RecordingsService); + + const metadata = computed(() => { + const item = options.activeItem(); + if (!item) { + return null; + } + // Track the 30 s tick: without it this computed caches its + // Date.now() verdict, and a recording started after an EPG boundary + // would snapshot the previous show. + options.progressTick(); + // Raw programme times vs. now in the provider's EPG clock, like the + // panel summary — the start snapshot is authoritative for the + // recording's title, so it must name the same programme. + const now = epgProviderClockMs(Date.now(), options.epgOffsetMinutes()); + const program = + options.timelinePrograms().find((candidate) => { + const start = Date.parse(candidate.start); + const stop = Date.parse(candidate.stop); + return ( + Number.isFinite(start) && + Number.isFinite(stop) && + start <= now && + now < stop + ); + }) ?? null; + return { + channelName: item.name?.trim() || 'Live TV', + channelLogoUrl: + options.timelineChannelLogo() || item.logo || undefined, + playlistId: item.playlistId, + playlistName: playlistDisplayLabel(item.playlistName) || undefined, + sourceType: item.sourceType, + epgChannelId: resolveRecordingEpgChannelId(item), + // The EPG key is not unique for M3U items (shared tvgId, or the + // display-name fallback); the uid names the exact selection. + sourceItemKey: item.uid, + currentProgram: program + ? toRecordingProgramSnapshot(program) + : undefined, + }; + }); + + return { + metadata, + handleRecordingStopped(event: RecordingStoppedEvent): void { + // A channel switch auto-stops the recording, and by now this host + // already describes the new channel — enriching then would attach + // the wrong schedule (and could promote an unrelated program to + // the recording's title). + if ( + event.epgChannelId && + event.epgChannelId !== metadata()?.epgChannelId + ) { + return; + } + // The EPG key alone cannot tell two same-keyed M3U items apart — + // the uid must also match the exact recorded selection. + if ( + event.sourceItemKey && + event.sourceItemKey !== metadata()?.sourceItemKey + ) { + return; + } + const programs = filterRecordingProgramsOverlap( + options.timelinePrograms().map(toRecordingProgramSnapshot), + event.startedAt, + event.endedAt, + options.epgOffsetMinutes() + ); + if (programs.length === 0) { + return; + } + void recordingsService.updatePrograms(event.targetPath, programs); + }, + }; +} + +function resolveRecordingEpgChannelId( + item: UnifiedCollectionItem +): string | undefined { + switch (item.sourceType) { + case 'm3u': + return item.tvgId?.trim() || item.name?.trim() || undefined; + case 'xtream': + return item.xtreamId !== undefined + ? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) + : undefined; + case 'stalker': + return item.stalkerId !== undefined + ? buildStalkerEpgMappingKey( + item.playlistId, + String(item.stalkerId) + ) + : undefined; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts new file mode 100644 index 000000000..3c223abbc --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts @@ -0,0 +1,20 @@ +/** + * Monotonic counter shared by everything the live tab resolves for the + * selection on screen — the detail itself, its M3U programmes, a catch-up + * URL. Anything that started under an older generation landed too late and + * must be discarded instead of replacing what the user is now watching. + */ +export interface UnifiedLiveSelectionGeneration { + current(): number; + /** Invalidate everything in flight and take the next generation. */ + next(): number; +} + +export function createUnifiedLiveSelectionGeneration(): UnifiedLiveSelectionGeneration { + let generation = 0; + + return { + current: () => generation, + next: () => ++generation, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts new file mode 100644 index 000000000..e03e9f41e --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts @@ -0,0 +1,184 @@ +import { computed, inject, Signal } from '@angular/core'; +import { isDashChannel, isDashStreamUrl } from '@iptvnator/shared/m3u-utils'; +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; +import { + getLiveCollectionPlaylistNavigation, + PORTAL_PLAYER, + UnifiedCollectionItem, + WorkspaceNavigationTarget, +} from '@iptvnator/portal/shared/util'; +import { SettingsStore } from '@iptvnator/services'; +import { + Channel, + EpgItem, + EpgProgram, + playlistDisplayLabel, + ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; +import { UnifiedLiveTimeshift } from './unified-live-catchup'; + +/** + * Everything the live tab derives from "what is selected right now": the + * source shape behind it, and which player surface it belongs on. + */ +export interface UnifiedLiveSelectionView { + readonly player: Signal; + readonly isEmbeddedPlayer: Signal; + /** Playback target for the inline player, honouring a catch-up override. */ + readonly inlinePlayback: Signal; + readonly currentStreamUrl: Signal; + readonly isM3uSelection: Signal; + readonly currentPortalEpgItems: Signal; + readonly currentM3uPrograms: Signal; + readonly currentM3uChannel: Signal; + readonly activeRadioChannel: Signal; + readonly isRadioSelection: Signal; + readonly isM3uDashSelection: Signal; + /** Player the inline surface uses, after the DASH correction. */ + readonly inlinePlayer: Signal; + readonly shouldUseInlinePlayer: Signal; + /** Identity of the selection an EPG archive action would act on. */ + readonly archiveContextKey: Signal; + /** + * "Open in playlist" for the channel on screen: the EPG panel chip + * renders only while this resolves (Xtream/M3U; Stalker has no + * open-on-arrival contract yet). Same verdict the row menu uses. + */ + readonly openInPlaylistTarget: Signal; + readonly openInPlaylistName: Signal; + readonly playbackSessionKey: Signal; + isRadioDetail( + detail: ResolvedLiveCollectionDetail | null | undefined + ): boolean; + /** Whether activating this detail should launch the external player. */ + shouldOpenExternalPlayback( + detail: ResolvedLiveCollectionDetail, + startPlayback?: boolean + ): boolean; +} + +/** + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveSelectionView(options: { + activeItem: Signal; + activeDetail: Signal; + activeTimeshift: Signal; +}): UnifiedLiveSelectionView { + const settingsStore = inject(SettingsStore); + const portalPlayer = inject(PORTAL_PLAYER); + + const player = settingsStore.player; + const isEmbeddedPlayer = computed(() => portalPlayer.isEmbeddedPlayer()); + + const inlinePlayback = computed(() => { + const playback = options.activeDetail()?.playback ?? null; + const timeshift = options.activeTimeshift(); + if (!playback || !timeshift) { + return playback; + } + + return { + ...playback, + streamUrl: timeshift.url, + isLive: false, + }; + }); + + const isM3uSelection = computed( + () => options.activeDetail()?.epgMode === 'm3u' + ); + + const currentM3uChannel = computed(() => { + const detail = options.activeDetail(); + return detail?.epgMode === 'm3u' ? (detail.channel ?? null) : null; + }); + + const currentStreamUrl = computed(() => inlinePlayback()?.streamUrl ?? ''); + + const activeRadioChannel = computed(() => { + const channel = options.activeDetail()?.channel ?? null; + return channel?.radio === 'true' ? channel : null; + }); + const isRadioSelection = computed(() => activeRadioChannel() !== null); + + // Match the M3U player: DASH needs Shaka even with Video.js or MPV/VLC + // selected. + const isM3uDashSelection = computed( + () => + isM3uSelection() && + (isDashStreamUrl(currentStreamUrl()) || + isDashChannel(currentM3uChannel())) + ); + + const isRadioDetail = ( + detail: ResolvedLiveCollectionDetail | null | undefined + ): boolean => detail?.channel?.radio === 'true'; + + return { + player, + isEmbeddedPlayer, + inlinePlayback, + currentStreamUrl, + isM3uSelection, + currentPortalEpgItems: computed( + () => options.activeDetail()?.epgItems ?? [] + ), + currentM3uPrograms: computed(() => { + const detail = options.activeDetail(); + if (detail?.epgMode !== 'm3u' || detail.channel?.radio === 'true') { + return []; + } + + return detail.epgPrograms ?? []; + }), + currentM3uChannel, + activeRadioChannel, + isRadioSelection, + isM3uDashSelection, + archiveContextKey: computed(() => + JSON.stringify([ + options.activeItem()?.uid, + options.activeItem()?.playlistId, + currentM3uChannel()?.url, + ]) + ), + openInPlaylistTarget: computed(() => { + const item = options.activeItem(); + return item ? getLiveCollectionPlaylistNavigation(item) : null; + }), + openInPlaylistName: computed(() => + playlistDisplayLabel(options.activeItem()?.playlistName) + ), + playbackSessionKey: computed(() => + createUnifiedLivePlaybackSessionKey(options.activeItem()) + ), + inlinePlayer: computed(() => + isM3uDashSelection() && player() !== VideoPlayer.ArtPlayer + ? VideoPlayer.Html5Player + : player() + ), + shouldUseInlinePlayer: computed( + () => + isRadioSelection() || isM3uDashSelection() || isEmbeddedPlayer() + ), + isRadioDetail, + shouldOpenExternalPlayback( + detail: ResolvedLiveCollectionDetail, + startPlayback = false + ): boolean { + if ( + isRadioDetail(detail) || + isM3uDashSelection() || + portalPlayer.isEmbeddedPlayer() + ) { + return false; + } + + return !settingsStore.openStreamOnDoubleClick() || startPlayback; + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts new file mode 100644 index 000000000..aa91b7fe9 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -0,0 +1,273 @@ +import { inject, WritableSignal } from '@angular/core'; +import { + ResolvedLiveCollectionDetail, + StreamResolverService, + UnifiedRecentDataService, +} from '@iptvnator/portal/shared/data-access'; +import { + PORTAL_PLAYER, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; +import { UnifiedLiveTimeshift } from './unified-live-catchup'; +import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; + +export interface UnifiedLiveSelection { + /** + * Activate a collection row. `startPlayback` forces the external player + * in double-click-to-play mode; `isAutoOpen` reports back to the host + * once the row is really on screen. + */ + activate( + item: UnifiedCollectionItem, + isAutoOpen?: boolean, + startPlayback?: boolean + ): Promise; + /** Tear the selection down and release anything scoped to it. */ + close(): void; + /** Release the radio header override; for the host's destroy hook. */ + dispose(): void; +} + +/** + * Resolving the selected live row into something playable, and keeping the + * player that is already mounted alive while the next one resolves. + * + * `activeUid` is the pending selection (row highlight). `activeItem` stays + * paired with `activeDetail`: the previous detail stays mounted while the + * next one resolves, because the player it renders owns DOM fullscreen and a + * selection from the fullscreen channel panel must not unmount that element + * (which would end fullscreen) for the resolution round-trip — and + * everything derived from the item (`playbackSessionKey`, recording/archive + * metadata) must keep describing the stream that player is still showing. + * The catch-up override belongs to that detail too: clearing it early would + * drop the mounted player back to the old channel's live URL for the gap. + * All of it swaps together when the new detail is in; a failed replacement + * keeps a previously mounted video and restores its row. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveSelection(options: { + activeUid: WritableSignal; + activeItem: WritableSignal; + activeDetail: WritableSignal; + activeTimeshift: WritableSignal; + isSelecting: WritableSignal; + generation: UnifiedLiveSelectionGeneration; + supportsEpg: boolean; + isRadioDetail( + detail: ResolvedLiveCollectionDetail | null | undefined + ): boolean; + shouldOpenExternalPlayback( + detail: ResolvedLiveCollectionDetail, + startPlayback: boolean + ): boolean; + /** The row became the latest watched item; the host refreshes its list. */ + onItemPlayed(item: UnifiedCollectionItem): void; + onAutoOpenHandled(): void; +}): UnifiedLiveSelection { + const streamResolver = inject(StreamResolverService); + const recentData = inject(UnifiedRecentDataService); + const streamHeaders = inject(ElectronStreamHeadersService); + const portalPlayer = inject(PORTAL_PLAYER); + + /** Stream URL of the radio playback whose header override this configured. */ + let radioHeaderScopeUrl: string | null = null; + /** + * The selection still resolving. `activeUid` alone cannot tell a pending + * highlight from the stream on screen — `activeItem`/`activeDetail` stay + * paired with the retained player until the replacement is in — so a + * second activation of the same pending row (a double-click's second + * click, an auto-open) folds its intent in here rather than restarting + * the request or, worse, launching the retained stream in its place. + */ + let pendingActivation: { + readonly uid: string; + startPlayback: boolean; + isAutoOpen: boolean; + } | null = null; + + const releaseRadioHeaders = (): void => { + streamHeaders.clear(radioHeaderScopeUrl); + radioHeaderScopeUrl = null; + }; + + const hydrateSelectedM3uPrograms = async ( + item: UnifiedCollectionItem, + detail: ResolvedLiveCollectionDetail, + generation: number + ): Promise => { + if (detail.epgMode !== 'm3u' || detail.channel?.radio === 'true') { + return; + } + + const epgPrograms = await streamResolver.loadM3uProgramsForItem( + item, + detail.channel + ); + if (generation !== options.generation.current()) { + return; + } + + options.activeDetail.update((currentDetail) => { + if (!currentDetail || currentDetail.epgMode !== 'm3u') { + return currentDetail; + } + + return { ...currentDetail, epgPrograms }; + }); + }; + + const close = (): void => { + options.generation.next(); + pendingActivation = null; + options.isSelecting.set(false); + options.activeDetail.set(null); + options.activeUid.set(null); + options.activeItem.set(null); + options.activeTimeshift.set(null); + // Radio credentials must not outlive the closed player; the service + // no-ops when a newer playback already owns the override slot. + releaseRadioHeaders(); + }; + + async function activate( + item: UnifiedCollectionItem, + isAutoOpen = false, + startPlayback = false + ): Promise { + const activeDetail = options.activeDetail(); + // The row is on screen only when it is both the highlight and the + // resolved item: while a replacement resolves, `activeUid` already + // points at it but `activeDetail` still belongs to the retained + // stream, and launching that here would open the wrong channel. + if ( + options.activeUid() === item.uid && + options.activeItem()?.uid === item.uid && + activeDetail + ) { + if ( + startPlayback && + options.shouldOpenExternalPlayback(activeDetail, true) + ) { + void portalPlayer.openResolvedPlayback(activeDetail.playback); + } + if (isAutoOpen) { + options.onAutoOpenHandled(); + } + return; + } + + if (pendingActivation?.uid === item.uid && options.isSelecting()) { + // The same row is still resolving: fold this activation's intent + // into that request so its detail launches (or reports the + // auto-open handled) when it lands, without a second round-trip. + pendingActivation.startPlayback ||= startPlayback; + pendingActivation.isAutoOpen ||= isAutoOpen; + return; + } + + const generation = options.generation.next(); + const activation = { uid: item.uid, startPlayback, isAutoOpen }; + pendingActivation = activation; + options.activeUid.set(item.uid); + options.isSelecting.set(true); + // A previously owned radio override must not survive into a + // selection that never mounts a player surface of its own — external + // video playback and failed resolutions would otherwise keep the old + // radio credentials installed for that origin. + releaseRadioHeaders(); + + try { + const detail = + item.sourceType === 'm3u' + ? await streamResolver.resolveM3uPlaybackDetail(item) + : await streamResolver.resolveLiveDetail(item); + if (generation !== options.generation.current()) { + return; + } + + if (item.radio === 'true') { + // Radio renders the dedicated audio player, never + // WebPlayerViewComponent, so the scoped Electron header + // override (portal cookie/token for auth-gated streams) is + // configured here BEFORE the audio element gets the URL. + // Ownership is claimed synchronously so a close/destroy + // during the pending IPC can still clear the credentials. + const headerSync = streamHeaders.apply(detail.playback); + radioHeaderScopeUrl = detail.playback.streamUrl; + const stillCurrent = headerSync ? await headerSync : true; + if ( + !stillCurrent || + generation !== options.generation.current() + ) { + return; + } + } + + options.activeTimeshift.set(null); + options.activeItem.set(item); + options.activeDetail.set(detail); + + if (options.supportsEpg && detail.epgMode === 'm3u') { + void hydrateSelectedM3uPrograms(item, detail, generation); + } + + if ( + options.shouldOpenExternalPlayback( + detail, + activation.startPlayback + ) + ) { + void portalPlayer.openResolvedPlayback(detail.playback); + } + + try { + const updatedItem = await recentData.recordLivePlayback(item); + if (generation === options.generation.current()) { + options.onItemPlayed(updatedItem); + } + } catch { + // Keep playback/EPG visible even if history persistence fails. + } + + if ( + generation === options.generation.current() && + activation.isAutoOpen + ) { + options.onAutoOpenHandled(); + } + } catch { + if (generation === options.generation.current()) { + // The fullscreen panel only offers video rows. Its previous + // stream is still valid when resolution of a replacement + // fails, so retain the player and its catch-up/session state. + // Radio's scoped headers were released above; that path keeps + // its existing reset behavior instead of reviving that scope. + if (!activeDetail || options.isRadioDetail(activeDetail)) { + options.activeTimeshift.set(null); + options.activeDetail.set(null); + options.activeItem.set(null); + } + options.activeUid.set(options.activeItem()?.uid ?? null); + } + } finally { + if (generation === options.generation.current()) { + pendingActivation = null; + options.isSelecting.set(false); + } + } + } + + return { + activate, + close, + dispose(): void { + // Invalidate a playback continuation still awaiting its header + // IPC and drop any radio credentials owned by this tab. + options.generation.next(); + streamHeaders.clear(radioHeaderScopeUrl); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 3183806a5..ee6eeefe5 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -10,65 +10,37 @@ import { output, signal, TemplateRef, - untracked, viewChild, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { - applyChannelNameStrip, - getM3uArchiveDays, - isDashChannel, - isDashStreamUrl, - isM3uCatchupPlaybackSupported, - resolveM3uCatchupUrl, -} from '@iptvnator/shared/m3u-utils'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, - deriveVisibleFavoriteChannels, FavoritesChannelSortMode, getLiveCollectionPlaylistNavigation, - LiveEpgPanelState, matchesOpenLiveCollectionItem, OpenLiveCollectionItemState, PORTAL_PLAYER, - persistLiveEpgPanelState, - restoreLiveEpgPanelState, UnifiedCollectionItem, UnifiedFavoriteChannel, } from '@iptvnator/portal/shared/util'; import { setupUnifiedLiveTabRemoteControl } from './unified-live-tab-remote-control'; +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; import { - ResolvedLiveCollectionDetail, - StreamResolverService, - UnifiedRecentDataService, -} from '@iptvnator/portal/shared/data-access'; -import { - EpgArchiveCopyService, EpgDateNavigationDirection, EpgListViewComponent, EpgProgramActivationEvent, EpgTimelineComponent, - getTodayEpgDateKey, - shiftEpgDateKey, } from '@iptvnator/ui/epg'; -import { - getLiveEpgPanelSummary, - toEpgProgram, - toEpochSeconds, - toLiveEpgPanelSummary, -} from './unified-live-epg-summary.util'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-playlist-chip.component'; import { ChannelListHiddenStateComponent } from '../channel-list-hidden-state/channel-list-hidden-state.component'; import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component'; import { AudioPlayerComponent, - ElectronStreamHeadersService, FULLSCREEN_CHANNEL_PANEL, type FullscreenChannelPanelContext, type FullscreenChannelPanelHost, @@ -76,24 +48,19 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { ResizableDirective } from '@iptvnator/ui/components'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { RecordingStoppedEvent } from '@iptvnator/shared/interfaces'; import { - RecordingsService, - RuntimeCapabilitiesService, - SettingsStore, -} from '@iptvnator/services'; -import { - buildStalkerEpgMappingKey, - buildXtreamEpgMappingKey, - EpgProgram, - epgProviderClockMs, - filterRecordingProgramsOverlap, - playlistDisplayLabel, - RecordingStartMetadata, - RecordingStoppedEvent, - toRecordingProgramSnapshot, - VideoPlayer, -} from '@iptvnator/shared/interfaces'; -import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; + createUnifiedLiveCatchup, + UnifiedLiveTimeshift, +} from './unified-live-catchup'; +import { createUnifiedLiveChannelRows } from './unified-live-channel-rows'; +import { createUnifiedLiveEpgMap } from './unified-live-epg-map'; +import { createUnifiedLiveEpgView } from './unified-live-epg-view'; +import { createUnifiedLiveRecording } from './unified-live-recording-metadata'; +import { createUnifiedLiveSelection } from './unified-live-selection'; +import { createUnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; +import { createUnifiedLiveSelectionView } from './unified-live-selection-view'; @Component({ selector: 'app-unified-live-tab', @@ -145,32 +112,14 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { /** The rail is owned by the page header toggle; ask it to expand. */ readonly restoreSidebarRequested = output(); - readonly archiveContextKey = computed(() => - JSON.stringify([ - this.activeItem()?.uid, - this.activeItem()?.playlistId, - this.currentM3uChannel()?.url, - ]) - ); - private readonly archiveCopy = inject(EpgArchiveCopyService); - private readonly streamResolver = inject(StreamResolverService); - private readonly recentData = inject(UnifiedRecentDataService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); - private readonly portalPlayer = inject(PORTAL_PLAYER); - private readonly streamHeaders = inject(ElectronStreamHeadersService); private readonly destroyRef = inject(DestroyRef); - private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); private readonly router = inject(Router); - /** Stream URL of the radio playback whose header override this tab configured. */ - private radioHeaderScopeUrl: string | null = null; + private readonly portalPlayer = inject(PORTAL_PLAYER); - readonly player = this.settingsStore.player; readonly supportsEpg = this.runtime.supportsEpg; - readonly isEmbeddedPlayer = computed(() => - this.portalPlayer.isEmbeddedPlayer() - ); private readonly fullscreenChannelPanelTemplate = viewChild< TemplateRef @@ -195,351 +144,109 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { * portal archive fields (tvArchive/tvArchiveDuration) and to * supply credentials for Xtream catch-up URL resolution. */ readonly activeItem = signal(null); - /** - * "Open in playlist" for the channel on screen: the EPG panel chip - * renders only while this resolves (Xtream/M3U; Stalker has no - * open-on-arrival contract yet). Same verdict the row menu uses. - */ - readonly openInPlaylistTarget = computed(() => { - const item = this.activeItem(); - return item ? getLiveCollectionPlaylistNavigation(item) : null; - }); - readonly openInPlaylistName = computed(() => - playlistDisplayLabel(this.activeItem()?.playlistName) - ); - readonly playbackSessionKey = computed(() => - createUnifiedLivePlaybackSessionKey(this.activeItem()) - ); readonly isSelecting = signal(false); - readonly epgMap = signal>(new Map()); + /** Catch-up override for the active channel; null = live playback. */ + readonly activeTimeshift = signal(null); readonly progressTick = signal(0); - readonly liveEpgPanelState = signal( - restoreLiveEpgPanelState() - ); - readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); - /** Catch-up override for the active M3U channel; null = live playback. */ - readonly activeTimeshift = signal<{ - url: string; - program: EpgProgram; - } | null>(null); - readonly activeTimeshiftProgram = computed( - () => this.activeTimeshift()?.program ?? null - ); - /** Playback target for the inline player, honouring a catch-up override. */ - readonly inlinePlayback = computed(() => { - const playback = this.activeDetail()?.playback ?? null; - const timeshift = this.activeTimeshift(); - if (!playback || !timeshift) { - return playback; - } + private readonly generation = createUnifiedLiveSelectionGeneration(); - return { - ...playback, - streamUrl: timeshift.url, - isLive: false, - }; + private readonly view = createUnifiedLiveSelectionView({ + activeItem: this.activeItem, + activeDetail: this.activeDetail, + activeTimeshift: this.activeTimeshift, }); - readonly currentStreamUrl = computed( - () => this.inlinePlayback()?.streamUrl ?? '' - ); - readonly isM3uSelection = computed( - () => this.activeDetail()?.epgMode === 'm3u' - ); - readonly currentPortalEpgItems = computed( - () => this.activeDetail()?.epgItems ?? [] - ); - readonly currentM3uPrograms = computed(() => { - const detail = this.activeDetail(); - if (detail?.epgMode !== 'm3u') { - return []; - } + readonly inlinePlayback = this.view.inlinePlayback; + readonly currentStreamUrl = this.view.currentStreamUrl; + readonly inlinePlayer = this.view.inlinePlayer; + readonly shouldUseInlinePlayer = this.view.shouldUseInlinePlayer; + readonly activeRadioChannel = this.view.activeRadioChannel; + readonly isRadioSelection = this.view.isRadioSelection; + readonly archiveContextKey = this.view.archiveContextKey; + readonly openInPlaylistTarget = this.view.openInPlaylistTarget; + readonly openInPlaylistName = this.view.openInPlaylistName; + readonly playbackSessionKey = this.view.playbackSessionKey; - if (detail.channel?.radio === 'true') { - return []; - } + private readonly catchup = createUnifiedLiveCatchup({ + activeTimeshift: this.activeTimeshift, + activeItem: this.activeItem, + activeDetail: this.activeDetail, + isM3uSelection: this.view.isM3uSelection, + currentM3uChannel: this.view.currentM3uChannel, + shouldUseInlinePlayer: this.view.shouldUseInlinePlayer, + generation: this.generation, + }); + readonly activeTimeshiftProgram = this.catchup.activeProgram; - return detail.epgPrograms ?? []; + private readonly epgView = createUnifiedLiveEpgView({ + activeDetail: this.activeDetail, + activeItem: this.activeItem, + activeTimeshift: this.activeTimeshift, + isM3uSelection: this.view.isM3uSelection, + currentM3uChannel: this.view.currentM3uChannel, + currentM3uPrograms: this.view.currentM3uPrograms, + currentPortalEpgItems: this.view.currentPortalEpgItems, + progressTick: this.progressTick, }); - readonly currentM3uChannel = computed(() => { - const detail = this.activeDetail(); - if (detail?.epgMode !== 'm3u') { - return null; - } + readonly epgViewMode = this.epgView.viewMode; + readonly epgOffsetMinutes = this.epgView.offsetMinutes; + readonly selectedLiveEpgDate = this.epgView.selectedDate; + readonly isLiveEpgPanelCollapsed = this.epgView.isPanelCollapsed; + readonly liveEpgPanelSummary = this.epgView.panelSummary; + readonly liveEpgPanelSummaryLabelKey = this.epgView.panelSummaryLabelKey; + readonly timelinePrograms = this.epgView.timelinePrograms; + readonly timelineChannelName = this.epgView.timelineChannelName; + readonly timelineChannelLogo = this.epgView.timelineChannelLogo; + readonly timelineArchiveAvailable = this.epgView.timelineArchiveAvailable; + readonly timelineArchiveDays = this.epgView.timelineArchiveDays; - return detail.channel ?? null; + private readonly recording = createUnifiedLiveRecording({ + activeItem: this.activeItem, + timelinePrograms: this.epgView.timelinePrograms, + timelineChannelLogo: this.epgView.timelineChannelLogo, + epgOffsetMinutes: this.epgView.offsetMinutes, + progressTick: this.progressTick, }); - readonly currentM3uArchivePlaybackAvailable = computed(() => - isM3uCatchupPlaybackSupported(this.currentM3uChannel()) - ); - /** Portal EPG items normalised to the timeline programme shape. */ - readonly currentPortalEpgPrograms = computed(() => - this.currentPortalEpgItems().map((item) => toEpgProgram(item)) - ); - readonly timelinePrograms = computed(() => - this.isM3uSelection() - ? this.currentM3uPrograms() - : this.currentPortalEpgPrograms() - ); - readonly timelineChannelName = computed(() => - applyChannelNameStrip( - this.currentM3uChannel()?.name ?? - this.activeDetail()?.playback?.title, - this.settingsStore.stripCountryPrefix?.() - ) - ); - readonly timelineChannelLogo = computed( - () => - this.currentM3uChannel()?.tvg?.logo ?? - this.activeDetail()?.playback?.thumbnail ?? - '' - ); - readonly timelineArchiveAvailable = computed(() => { - if (this.isM3uSelection()) { - return this.currentM3uArchivePlaybackAvailable(); - } + readonly recordingMetadata = this.recording.metadata; - // Portal archive: tvArchive === 1 means the provider has - // timeshift / archive enabled for this channel. - const item = this.activeItem(); - return ( - Number(item?.tvArchive ?? 0) === 1 && - Number(item?.tvArchiveDuration ?? 0) > 0 - ); + private readonly epgPrograms = createUnifiedLiveEpgMap({ + supportsEpg: this.supportsEpg, + items: this.items, + offsetMinutes: this.epgView.offsetMinutes, }); - /** - * Catch-up window (days) for the active channel, so the timeline can - * gate "Watch" to programmes inside it. Without this the timeline defaults - * `archiveDays` to 0 (treated as unlimited) and offers catch-up on - * programmes older than the real archive window. - * - * M3U: reads catchup-days / timeshift / tvg-rec from the channel attrs. - * Portal: tvArchiveDuration is already in days — pass it through, - * matching `live-stream-layout.controlledArchiveDays`. - */ - readonly timelineArchiveDays = computed(() => { - if (!this.timelineArchiveAvailable()) return 0; + readonly epgMap = this.epgPrograms.programs; - if (this.isM3uSelection()) { - return getM3uArchiveDays(this.currentM3uChannel()); - } - - return Math.max( - 0, - Number(this.activeItem()?.tvArchiveDuration ?? 0) || 0 - ); - }); - readonly activeRadioChannel = computed(() => { - const channel = this.activeDetail()?.channel ?? null; - return channel?.radio === 'true' ? channel : null; - }); - readonly isRadioSelection = computed( - () => this.activeRadioChannel() !== null - ); - // Match the M3U player: DASH needs Shaka even with Video.js or MPV/VLC selected. - readonly isM3uDashSelection = computed( - () => - this.isM3uSelection() && - (isDashStreamUrl(this.currentStreamUrl()) || - isDashChannel(this.currentM3uChannel())) - ); - readonly inlinePlayer = computed(() => - this.isM3uDashSelection() && this.player() !== VideoPlayer.ArtPlayer - ? VideoPlayer.Html5Player - : this.player() - ); - readonly shouldUseInlinePlayer = computed( - () => - this.isRadioSelection() || - this.isM3uDashSelection() || - this.isEmbeddedPlayer() - ); - readonly isLiveEpgPanelCollapsed = computed( - () => this.liveEpgPanelState() === 'collapsed' - ); - /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; - readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; - readonly liveEpgPanelSummary = computed(() => { - const timeshift = this.activeTimeshift(); - if (timeshift) { - // Archive summary is frozen — don't track the 30s progress tick. - return toLiveEpgPanelSummary(timeshift.program); - } - this.progressTick(); - return getLiveEpgPanelSummary( - this.activeDetail(), - this.epgOffsetMinutes() - ); - }); - readonly liveEpgPanelSummaryLabelKey = computed(() => - this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM' - ); - private readonly recordingsService = inject(RecordingsService); - /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ - readonly recordingMetadata = computed(() => { - const item = this.activeItem(); - if (!item) { - return null; - } - // Track the 30 s tick: without it this computed caches its - // Date.now() verdict, and a recording started after an EPG boundary - // would snapshot the previous show. - this.progressTick(); - // Raw programme times vs. now in the provider's EPG clock, like the - // panel summary — the start snapshot is authoritative for the - // recording's title, so it must name the same programme. - const now = epgProviderClockMs(Date.now(), this.epgOffsetMinutes()); - const program = - this.timelinePrograms().find((candidate) => { - const start = Date.parse(candidate.start); - const stop = Date.parse(candidate.stop); - return ( - Number.isFinite(start) && - Number.isFinite(stop) && - start <= now && - now < stop - ); - }) ?? null; - return { - channelName: item.name?.trim() || 'Live TV', - channelLogoUrl: - this.timelineChannelLogo() || item.logo || undefined, - playlistId: item.playlistId, - playlistName: playlistDisplayLabel(item.playlistName) || undefined, - sourceType: item.sourceType, - epgChannelId: this.recordingEpgChannelId(item), - // The EPG key is not unique for M3U items (shared tvgId, or the - // display-name fallback); the uid names the exact selection. - sourceItemKey: item.uid, - currentProgram: program - ? toRecordingProgramSnapshot(program) - : undefined, - }; + private readonly selection = createUnifiedLiveSelection({ + activeUid: this.activeUid, + activeItem: this.activeItem, + activeDetail: this.activeDetail, + activeTimeshift: this.activeTimeshift, + isSelecting: this.isSelecting, + generation: this.generation, + supportsEpg: this.supportsEpg, + isRadioDetail: (detail) => this.view.isRadioDetail(detail), + shouldOpenExternalPlayback: (detail, startPlayback) => + this.view.shouldOpenExternalPlayback(detail, startPlayback), + onItemPlayed: (item) => this.itemPlayed.emit(item), + onAutoOpenHandled: () => this.autoOpenHandled.emit(), }); - /** Stop enrichment: programs overlapping the recorded window. */ - onRecordingStopped(event: RecordingStoppedEvent): void { - // A channel switch auto-stops the recording, and by now this host - // already describes the new channel — enriching then would attach the - // wrong schedule (and could promote an unrelated program to the - // recording's title). - if ( - event.epgChannelId && - event.epgChannelId !== this.recordingMetadata()?.epgChannelId - ) { - return; - } - // The EPG key alone cannot tell two same-keyed M3U items apart — - // the uid must also match the exact recorded selection. - if ( - event.sourceItemKey && - event.sourceItemKey !== this.recordingMetadata()?.sourceItemKey - ) { - return; - } - const programs = filterRecordingProgramsOverlap( - this.timelinePrograms().map(toRecordingProgramSnapshot), - event.startedAt, - event.endedAt, - this.epgOffsetMinutes() - ); - if (programs.length === 0) { - return; - } - void this.recordingsService.updatePrograms(event.targetPath, programs); - } - - private recordingEpgChannelId( - item: UnifiedCollectionItem - ): string | undefined { - switch (item.sourceType) { - case 'm3u': - return item.tvgId?.trim() || item.name?.trim() || undefined; - case 'xtream': - return item.xtreamId !== undefined - ? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) - : undefined; - case 'stalker': - return item.stalkerId !== undefined - ? buildStalkerEpgMappingKey( - item.playlistId, - String(item.stalkerId) - ) - : undefined; - } - } - - /** - * The channel list in exactly the order the sidebar renders it - * (search-filtered; sorted in favorites mode) — remote-control - * navigation and channel numbers must follow what is on screen. - */ - readonly visibleChannels = computed(() => - deriveVisibleFavoriteChannels(this.channelsForList(), { - searchTerm: this.searchTerm(), - sortMode: this.mode() === 'favorites' ? this.sortMode() : null, - getName: (channel) => channel.name, - getAddedAt: (channel) => channel.addedAt, - }) - ); - - readonly channelsForList = computed((): UnifiedFavoriteChannel[] => - this.items().map((item) => ({ - uid: item.uid, - name: item.name, - logo: item.logo ?? null, - sourceType: item.sourceType, - playlistId: item.playlistId, - playlistName: item.playlistName, - streamUrl: item.streamUrl, - m3uChannel: item.m3uChannel, - radio: item.radio, - xtreamId: item.xtreamId, - tvArchive: item.tvArchive ?? null, - tvArchiveDuration: item.tvArchiveDuration ?? null, - tvgId: item.tvgId, - stalkerCmd: item.stalkerCmd, - stalkerPortalUrl: item.stalkerPortalUrl, - stalkerMacAddress: item.stalkerMacAddress, - addedAt: item.addedAt ?? new Date(0).toISOString(), - position: item.position ?? 0, - contentId: item.contentId, - })) - ); - - /** - * Rows for the fullscreen channel panel. Radio is withheld: it renders - * through `app-audio-player` instead of `app-web-player-view`, so - * selecting a station destroys the element that owns fullscreen and drops - * the user out of it — the opposite of what the panel exists for. The - * page's own list keeps every row. - */ - readonly fullscreenPanelChannels = computed(() => - this.channelsForList().filter((channel) => channel.radio !== 'true') - ); - - private selectionRequestId = 0; - /** - * The selection still resolving. `activeUid` alone cannot tell a pending - * highlight from the stream on screen — `activeItem`/`activeDetail` stay - * paired with the retained player until the replacement is in — so a - * second activation of the same pending row (a double-click's second - * click, an auto-open) folds its intent in here rather than restarting - * the request or, worse, launching the retained stream in its place. - */ - private pendingActivation: { - readonly uid: string; - startPlayback: boolean; - isAutoOpen: boolean; - } | null = null; + private readonly channelRows = createUnifiedLiveChannelRows({ + items: this.items, + searchTerm: this.searchTerm, + mode: this.mode, + sortMode: this.sortMode, + }); + readonly channelsForList = this.channelRows.all; + readonly visibleChannels = this.channelRows.visible; + readonly fullscreenPanelChannels = this.channelRows.fullscreenPanel; constructor() { effect(() => { const items = this.items(); if (this.supportsEpg) { - void this.loadEpgMap(items); + this.epgPrograms.load(items); } else { - this.epgMap.set(new Map()); + this.epgPrograms.clear(); } const activeUid = this.activeUid(); @@ -548,23 +255,6 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } }); - // The map answers "what is on at the provider clock", so a changed - // display offset reloads it; the request id in loadEpgMap drops the - // older load. The first run only records the initial value. - let appliedEpgOffsetMinutes: number | null = null; - effect(() => { - const offsetMinutes = this.epgOffsetMinutes(); - if (appliedEpgOffsetMinutes === offsetMinutes) { - return; - } - const isInitial = appliedEpgOffsetMinutes === null; - appliedEpgOffsetMinutes = offsetMinutes; - if (isInitial || !this.supportsEpg) { - return; - } - void this.loadEpgMap(untracked(() => this.items())); - }); - effect(() => { const target = this.autoOpenItem(); const items = this.items(); @@ -581,8 +271,8 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { // Handled only once the row is on screen: while it is merely the // pending highlight, `activeDetail` still belongs to the retained - // stream, and `activateItem` folds this intent into the request - // in flight instead of restarting it. + // stream, and `activate` folds this intent into the request in + // flight instead of restarting it. if ( this.activeUid() === matchedItem.uid && this.activeItem()?.uid === matchedItem.uid && @@ -592,7 +282,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { return; } - void this.activateItem(matchedItem, true); + void this.selection.activate(matchedItem, true); }); setupUnifiedLiveTabRemoteControl({ @@ -615,43 +305,36 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { ); this.destroyRef.onDestroy(() => { clearInterval(tickInterval); - // Invalidate a playback continuation still awaiting its header - // IPC and drop any radio credentials owned by this tab. - this.selectionRequestId += 1; - this.streamHeaders.clear(this.radioHeaderScopeUrl); + this.selection.dispose(); }); } async onChannelSelected(channel: UnifiedFavoriteChannel): Promise { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { - await this.activateItem(item); + await this.selection.activate(item); } } async onChannelPlaybackRequested( channel: UnifiedFavoriteChannel ): Promise { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { - await this.activateItem(item, false, true); + await this.selection.activate(item, false, true); } } onFavoriteToggled(channel: UnifiedFavoriteChannel): void { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); - if (item) { - if (this.mode() === 'favorites') { - this.removeItem.emit(item); - } else { - this.favoriteToggled.emit(item); - } + const item = this.itemFor(channel); + if (!item) { + return; + } + + if (this.mode() === 'favorites') { + this.removeItem.emit(item); + } else { + this.favoriteToggled.emit(item); } } @@ -663,25 +346,14 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onOpenInPlaylistRequested(channel: UnifiedFavoriteChannel): void { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { this.openInPlaylist(item); } } - private openInPlaylist(item: UnifiedCollectionItem): void { - const target = getLiveCollectionPlaylistNavigation(item); - if (target) { - void this.router.navigate(target.link, { state: target.state }); - } - } - onRemoveRequested(channel: UnifiedFavoriteChannel): void { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { this.removeItem.emit(item); } @@ -697,160 +369,28 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onLiveEpgPanelCollapsedChange(collapsed: boolean): void { - const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; - this.liveEpgPanelState.set(state); - persistLiveEpgPanelState(state); + this.epgView.setPanelCollapsed(collapsed); } onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { - this.selectedLiveEpgDate.set( - shiftEpgDateKey(this.selectedLiveEpgDate(), direction) - ); + this.epgView.navigateDate(direction); } onLiveEpgSelectedDateChange(selectedDate: string): void { - this.selectedLiveEpgDate.set(selectedDate); + this.epgView.selectDate(selectedDate); } onTimelineProgramActivated(event: EpgProgramActivationEvent): void { - if (event.type === 'copy-catchup-url') { - const channel = this.currentM3uChannel(); - const item = this.activeItem(); - const isM3u = this.isM3uSelection(); - void this.archiveCopy.copy(() => { - if (isM3u) return resolveM3uCatchupUrl(channel, event.program); - const start = toEpochSeconds( - event.program.startTimestamp, - event.program.start - ); - const stop = toEpochSeconds( - event.program.stopTimestamp, - event.program.stop - ); - return item?.xtreamId && - start != null && - stop != null && - stop > start - ? this.streamResolver.resolveXtreamCatchupUrl( - item, - start, - stop - ) - : null; - }); - return; - } - if (event.type === 'live') { - this.returnToLivePlayback(); - return; - } - - if (this.isM3uSelection()) { - this.activateM3uCatchup(event.program); - } else { - void this.activatePortalCatchup(event.program); - } - } - - /** - * M3U catch-up: resolve via the M3U timeshift URL resolver. - */ - private activateM3uCatchup(program: EpgProgram): void { - const playbackUrl = resolveM3uCatchupUrl( - this.currentM3uChannel(), - program - ); - if (!playbackUrl) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - this.activeTimeshift.set({ url: playbackUrl, program }); - - const playback = this.activeDetail()?.playback; - if (!this.shouldUseInlinePlayer() && playback) { - void this.portalPlayer.openResolvedPlayback({ - ...playback, - streamUrl: playbackUrl, - isLive: false, - }); - } - } - - /** - * Portal (Xtream) catch-up: compute start/stop as epoch seconds and - * resolve via the provider's timeshift endpoint. - */ - private async activatePortalCatchup(program: EpgProgram): Promise { - const requestId = this.selectionRequestId; - const item = this.activeItem(); - if (!item?.xtreamId) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - const startEpoch = toEpochSeconds( - program.startTimestamp, - program.start - ); - const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop); - if (startEpoch == null || stopEpoch == null) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - const playbackUrl = await this.streamResolver.resolveXtreamCatchupUrl( - item, - startEpoch, - stopEpoch - ); - if (requestId !== this.selectionRequestId) { - return; // switched channel — discard silently - } - if (!playbackUrl) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - this.activeTimeshift.set({ url: playbackUrl, program }); - - const playback = this.activeDetail()?.playback; - if (!this.shouldUseInlinePlayer() && playback) { - void this.portalPlayer.openResolvedPlayback({ - ...playback, - streamUrl: playbackUrl, - isLive: false, - }); - } + this.catchup.handleProgramActivation(event); } returnToLivePlayback(): void { - this.activeTimeshift.set(null); + this.catchup.returnToLive(); + } - // Inline player is already (back) on the live stream once the - // timeshift override is cleared. With an external player configured, - // "Watch live" must open it even when no archive was active — e.g. - // openStreamOnDoubleClick shows the guide without launching playback. - const playback = this.activeDetail()?.playback; - if (!this.shouldUseInlinePlayer() && playback) { - void this.portalPlayer.openResolvedPlayback(playback); - } + /** Stop enrichment: programs overlapping the recorded window. */ + onRecordingStopped(event: RecordingStoppedEvent): void { + this.recording.handleRecordingStopped(event); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { @@ -863,229 +403,24 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onClose(): void { - this.selectionRequestId += 1; - this.pendingActivation = null; - this.isSelecting.set(false); - this.activeDetail.set(null); - this.activeUid.set(null); - this.activeItem.set(null); - this.activeTimeshift.set(null); - // Radio credentials must not outlive the closed player; the service - // no-ops when a newer playback already owns the override slot. - this.streamHeaders.clear(this.radioHeaderScopeUrl); - this.radioHeaderScopeUrl = null; + this.selection.close(); } onEpgMappingChanged(): void { - void this.loadEpgMap(this.items()); + this.epgPrograms.load(this.items()); } - private epgMapRequestId = 0; - private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { - // Only the latest load may install its map: an older one — started - // under a previous display offset or item set — must not overwrite - // the refreshed result when it completes last. - const requestId = ++this.epgMapRequestId; - const epgMap = await this.streamResolver.loadEpgForItems(items); - if (requestId !== this.epgMapRequestId) { - return; - } - this.epgMap.set(epgMap); - } - - private async activateItem( - item: UnifiedCollectionItem, - isAutoOpen = false, - startPlayback = false - ): Promise { - const activeDetail = this.activeDetail(); - // The row is on screen only when it is both the highlight and the - // resolved item: while a replacement resolves, `activeUid` already - // points at it but `activeDetail` still belongs to the retained - // stream, and launching that here would open the wrong channel. - if ( - this.activeUid() === item.uid && - this.activeItem()?.uid === item.uid && - activeDetail - ) { - if ( - startPlayback && - this.shouldOpenExternalPlayback(activeDetail, true) - ) { - void this.portalPlayer.openResolvedPlayback( - activeDetail.playback - ); - } - if (isAutoOpen) { - this.autoOpenHandled.emit(); - } - return; - } - - const pending = this.pendingActivation; - if (pending?.uid === item.uid && this.isSelecting()) { - // The same row is still resolving: fold this activation's intent - // into that request so its detail launches (or reports the - // auto-open handled) when it lands, without a second round-trip. - pending.startPlayback ||= startPlayback; - pending.isAutoOpen ||= isAutoOpen; - return; - } - - const requestId = ++this.selectionRequestId; - const activation = { uid: item.uid, startPlayback, isAutoOpen }; - this.pendingActivation = activation; - // `activeUid` is the pending selection (row highlight). `activeItem` - // stays paired with `activeDetail`: the previous detail stays mounted - // while the next one resolves, because the player it renders owns - // DOM fullscreen and a selection from the fullscreen channel panel - // must not unmount that element (which would end fullscreen) for the - // resolution round-trip — and everything derived from the item - // (`playbackSessionKey`, recording/archive metadata) must keep - // describing the stream that player is still showing. The catch-up - // override belongs to that detail too: clearing it early would drop - // the mounted player back to the old channel's live URL for the - // gap. All of it swaps together when the new detail is in; a failed - // replacement keeps a previously mounted video and restores its row. - this.activeUid.set(item.uid); - this.isSelecting.set(true); - // A previously owned radio override must not survive into a - // selection that never mounts a player surface of its own — external - // video playback and failed resolutions would otherwise keep the old - // radio credentials installed for that origin. - this.streamHeaders.clear(this.radioHeaderScopeUrl); - this.radioHeaderScopeUrl = null; - - try { - const detail = - item.sourceType === 'm3u' - ? await this.streamResolver.resolveM3uPlaybackDetail(item) - : await this.streamResolver.resolveLiveDetail(item); - if (requestId !== this.selectionRequestId) { - return; - } - - if (item.radio === 'true') { - // Radio renders the dedicated audio player, never - // WebPlayerViewComponent, so the scoped Electron header - // override (portal cookie/token for auth-gated streams) is - // configured here BEFORE the audio element gets the URL. - // Ownership is claimed synchronously so a close/destroy - // during the pending IPC can still clear the credentials. - const headerSync = this.streamHeaders.apply(detail.playback); - this.radioHeaderScopeUrl = detail.playback.streamUrl; - const stillCurrent = headerSync ? await headerSync : true; - if (!stillCurrent || requestId !== this.selectionRequestId) { - return; - } - } - - this.activeTimeshift.set(null); - this.activeItem.set(item); - this.activeDetail.set(detail); - - if (this.supportsEpg && detail.epgMode === 'm3u') { - void this.hydrateSelectedM3uPrograms(item, detail, requestId); - } - - if ( - this.shouldOpenExternalPlayback( - detail, - activation.startPlayback - ) - ) { - void this.portalPlayer.openResolvedPlayback(detail.playback); - } - - try { - const updatedItem = - await this.recentData.recordLivePlayback(item); - if (requestId === this.selectionRequestId) { - this.itemPlayed.emit(updatedItem); - } - } catch { - // Keep playback/EPG visible even if history persistence fails. - } - - if ( - requestId === this.selectionRequestId && - activation.isAutoOpen - ) { - this.autoOpenHandled.emit(); - } - } catch { - if (requestId === this.selectionRequestId) { - // The fullscreen panel only offers video rows. Its previous - // stream is still valid when resolution of a replacement - // fails, so retain the player and its catch-up/session state. - // Radio's scoped headers were released above; that path keeps - // its existing reset behavior instead of reviving that scope. - if (!activeDetail || this.isRadioDetail(activeDetail)) { - this.activeTimeshift.set(null); - this.activeDetail.set(null); - this.activeItem.set(null); - } - this.activeUid.set(this.activeItem()?.uid ?? null); - } - } finally { - if (requestId === this.selectionRequestId) { - this.pendingActivation = null; - this.isSelecting.set(false); - } + private openInPlaylist(item: UnifiedCollectionItem): void { + const target = getLiveCollectionPlaylistNavigation(item); + if (target) { + void this.router.navigate(target.link, { state: target.state }); } } - private shouldOpenExternalPlayback( - detail: ResolvedLiveCollectionDetail, - startPlayback = false - ): boolean { - if ( - this.isRadioDetail(detail) || - this.isM3uDashSelection() || - this.portalPlayer.isEmbeddedPlayer() - ) { - return false; - } - - return !this.settingsStore.openStreamOnDoubleClick() || startPlayback; - } - - private isRadioDetail( - detail: ResolvedLiveCollectionDetail | null | undefined - ): boolean { - return detail?.channel?.radio === 'true'; - } - - private async hydrateSelectedM3uPrograms( - item: UnifiedCollectionItem, - detail: ResolvedLiveCollectionDetail, - requestId: number - ): Promise { - if (detail.epgMode !== 'm3u') { - return; - } - - if (detail.channel?.radio === 'true') { - return; - } - - const epgPrograms = await this.streamResolver.loadM3uProgramsForItem( - item, - detail.channel - ); - if (requestId !== this.selectionRequestId) { - return; - } - - this.activeDetail.update((currentDetail) => { - if (!currentDetail || currentDetail.epgMode !== 'm3u') { - return currentDetail; - } - - return { - ...currentDetail, - epgPrograms, - }; - }); + /** The collection row the rendered channel row stands for. */ + private itemFor( + channel: UnifiedFavoriteChannel + ): UnifiedCollectionItem | undefined { + return this.items().find((candidate) => candidate.uid === channel.uid); } } diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 1a44727aa..26f1032a9 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -33,8 +33,6 @@ export const maxLinesBaseline = [ 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts', 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts', 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts', - 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts', - 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts', 'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts', From b30c783e85b992ba58d7887fc510b96e7cee832e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:14:02 +0200 Subject: [PATCH 3/8] fix(search): locale-invariant Turkish case folding in every search path (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turkish upper and lower case queries now return the same results everywhere a title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a combining dot behind, so "İnş" and "inş" reached different search arms and different results. - Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`, which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı". - The Electron content search composes to NFC and drops the leftover combining marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII. - A shared `foldSearchText` covers every in-memory filter: channel lists, the Xtream and Stalker catalogs, category filters, collections, the EPG guide, the command palette, sources, the playlist switcher and the download lists. - Composing before the strip keeps canonically equivalent spellings equal while the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left alone, as the FTS index does not fold it either. Covered by a SQLite-backed spec over the real trigram index plus regression cases in the affected renderer specs. Closes #609. Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com> Co-Authored-By: Claude Opus 5 --- .changes/search-turkish-case-folding.md | 7 + CLAUDE.md | 2 +- .../operations/content-search.util.spec.ts | 84 ++++++- .../content-search.util.sqlite.spec.ts | 221 ++++++++++++++++++ .../operations/content-search.util.ts | 95 +++++--- .../playlist-switcher.component.ts | 8 +- .../recent-playlists.component.ts | 7 +- .../src/lib/download-manager.viewmodel.ts | 5 +- .../src/lib/recording-manager.viewmodel.ts | 5 +- .../unified-grid-tab.component.ts | 5 +- .../util/src/lib/favorites-channel-sort.ts | 6 +- .../src/lib/portal-collection-items.spec.ts | 21 ++ .../util/src/lib/portal-collection-items.ts | 6 +- .../stalker-itv-all-items.component.ts | 9 +- .../stalker-live-stream-layout.component.ts | 13 +- .../data-sources/pwa-xtream-data-source.ts | 5 +- .../features/with-selection.feature.spec.ts | 21 ++ .../stores/features/with-selection.feature.ts | 5 +- .../category-management-dialog.component.ts | 5 +- ...am-live-channel-navigation.service.spec.ts | 23 +- .../xtream-live-channel-navigation.service.ts | 9 +- .../portal-channels-list.component.ts | 9 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/search-text-fold.util.spec.ts | 58 +++++ .../src/lib/search-text-fold.util.ts | 35 +++ .../all-channels-view.component.spec.ts | 24 ++ .../all-channels-view.component.ts | 7 +- .../channel-list-container.component.ts | 5 +- .../favorites-view.component.ts | 9 +- .../group-management-dialog.component.ts | 5 +- .../groups-view/groups-view.component.ts | 13 +- .../recent-view/recent-view.component.ts | 9 +- .../vod-sources/vod-sources-menu.component.ts | 9 +- .../src/lib/epg-guide/epg-guide-view-state.ts | 6 +- ...orkspace-command-palette.component.spec.ts | 72 ++++++ .../workspace-command-palette.component.ts | 9 +- .../workspace-context-panel.component.ts | 9 +- .../workspace-sources.component.ts | 5 +- 38 files changed, 736 insertions(+), 111 deletions(-) create mode 100644 .changes/search-turkish-case-folding.md create mode 100644 apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts create mode 100644 libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/search-text-fold.util.ts diff --git a/.changes/search-turkish-case-folding.md b/.changes/search-turkish-case-folding.md new file mode 100644 index 000000000..05a94724a --- /dev/null +++ b/.changes/search-turkish-case-folding.md @@ -0,0 +1,7 @@ +--- +type: fix +area: search +issues: [609] +--- + +Search now returns the same results whether you type upper or lower case Turkish characters. Titles with the dotted capital I (for example "İnşaat") are found by typing a plain lower case i, so "inş" and "İnş" match the same channels and movies — in global search, in each portal's search, in the command palette, and in the channel, category, favorites and download list filters. diff --git a/CLAUDE.md b/CLAUDE.md index 44b439f06..908f3a906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -394,7 +394,7 @@ This is an Nx monorepo with the following structure: - **portal/downloads/feature** - Download manager UI - **portal/shared/{data-access,ui,util}** - Cross-portal shared code: stateful collection services and VOD multi-source discovery/resolve/ranking live in `data-access`; reusable views live in `ui`; `util` is for pure contracts/helpers - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) + - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`). `foldSearchText` (`search-text-fold.util.ts`) is the one case fold every in-memory search filter uses (channel lists, catalog/category filters, the command palette, sources, downloads): `toLowerCase()`, then NFC, then a combining-mark strip, never `toLocaleLowerCase()`. Composing before the strip is what makes canonically equivalent spellings fold to one string (a decomposed `e`+U+0301 title and a precomposed `é` query, a Greek `Ά` and `ά`); only marks that cannot compose are dropped, the Turkish dotted İ's leftover dot among them, so the fold matches a plain `i` while staying accent-sensitive and locale-independent (issue #609). A filter must call it on BOTH sides, and a list's row filter and its count/queue source must use the same fold or the two disagree. The Electron content search (`content-search.util.ts`) folds the same way and additionally spells explicit `'tr'`-locale İ forms into its LIKE/GLOB variants, because SQLite LIKE folds only ASCII - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Also home to the two Node networking helpers both backends share: the happy-eyeballs attempt-timeout default (`network-family-autoselection.ts`) and the socket-connect observer that feeds the guard's `connected` flag (`socket-connect-observer.ts`). Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438) - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts index c668afcf6..db2e6b8a2 100644 --- a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts @@ -1,10 +1,14 @@ import { + buildContentTitleFtsMatchQuery, buildCompoundFtsMatchQuery, buildCompoundLikePatterns, + buildGlobPrefixPatterns, + buildLikePatterns, buildM3uPayloadCompoundPatterns, getCompoundResidualTokenGroups, - getCompoundSearchWords, getSearchWordPlans, + getSqlSearchTokenGroups, + getCompoundSearchWords, scoreSearchTextMatch, shouldUseContentTitlePrefixIndex, } from './content-search.util'; @@ -122,6 +126,84 @@ describe('content-search.util', () => { }); }); + describe('Turkish case folding (issue #609)', () => { + // Upper and lower case forms of the same query must build the same + // search and match the same titles. The folding is locale-invariant, + // so these hold under a Turkish OS locale too, where toLocaleLowerCase + // would otherwise send "İ" and "I" to different letters. + const pairs: ReadonlyArray = [ + ['İnş', 'inş'], + ['Ünlü', 'ünlü'], + ['Çan', 'çan'], + ['Şan', 'şan'], + ]; + + it.each(pairs)( + 'builds the same search for %s and %s', + (upper, lower) => { + expect(getSqlSearchTokenGroups(upper)).toEqual( + getSqlSearchTokenGroups(lower) + ); + expect(buildContentTitleFtsMatchQuery(upper)).toBe( + buildContentTitleFtsMatchQuery(lower) + ); + expect(shouldUseContentTitlePrefixIndex(upper)).toBe( + shouldUseContentTitlePrefixIndex(lower) + ); + } + ); + + it('keeps the dotted capital I as one word, not "i" + "nş"', () => { + // The combining dot from lower-casing "İ" must not split the word, + // which used to flip it onto the short-token prefix index. + expect(getSqlSearchTokenGroups('İnş')).toEqual([['inş', 'ins']]); + expect(shouldUseContentTitlePrefixIndex('İnş')).toBe(false); + }); + + it('scores the same title for the upper and lower case query', () => { + const upper = scoreSearchTextMatch('İnşaat', 'İnş'); + const lower = scoreSearchTextMatch('İnşaat', 'inş'); + + expect(lower).not.toBeNull(); + expect(lower).toBe(upper); + }); + + it('spells the dotted capital İ forms in LIKE and GLOB patterns', () => { + // SQLite LIKE folds only ASCII and GLOB folds nothing, and the + // locale-invariant toUpperCase() turns "inş" into "INŞ", which + // never matches a stored "İnşaat". The Turkish forms are added + // with an explicit 'tr' locale so they do not depend on the OS. + expect(buildLikePatterns('inş')).toEqual( + expect.arrayContaining(['%İNŞ%', '%İnş%', '%INŞ%', '%inş%']) + ); + expect(buildLikePatterns('İnş', 'prefix')).toEqual( + expect.arrayContaining(['İnş%', 'İNŞ%', 'inş%']) + ); + expect(buildGlobPrefixPatterns('iş')).toEqual( + expect.arrayContaining(['İş*', 'İŞ*', 'iş*', 'IŞ*']) + ); + expect(buildCompoundLikePatterns('iş-tv')).toEqual( + expect.arrayContaining(['%İş-tv%', '%İŞ-TV%']) + ); + }); + + it('adds no Turkish forms to values without an i', () => { + expect(buildLikePatterns('çan')).toEqual([ + '%çan%', + '%ÇAN%', + '%Çan%', + '%can%', + '%CAN%', + '%Can%', + ]); + expect(buildGlobPrefixPatterns('ма')).toEqual([ + 'ма*', + 'МА*', + 'Ма*', + ]); + }); + }); + describe('scoreSearchTextMatch', () => { it('matches compound words anywhere in the title (issue #1161)', () => { expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40); diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts b/apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts new file mode 100644 index 000000000..24115f65c --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts @@ -0,0 +1,221 @@ +/** + * Runs the search helpers' output through a real SQLite instance with the + * same trigram FTS tokenizer the content index uses, so the case-folding + * contract is checked against SQLite's own LIKE/GLOB/MATCH semantics rather + * than against a string assertion (issue #609). Each SQL arm of the global + * and per-playlist search is reproduced from the same pattern builders the + * operations consume; the AND/OR composition mirrors `content.operations.ts`. + */ +import Database from 'better-sqlite3'; +import { + buildCompoundLikePatterns, + buildContentTitleFtsMatchQuery, + buildGlobPrefixPatterns, + buildLikePatterns, + buildM3uPayloadTextFieldPatterns, + getCompoundSearchWords, + getSqlSearchTokenGroups, + isShortSearchTokenGroup, + scoreSearchTextMatch, + shouldUseContentTitleFts, + shouldUseContentTitlePrefixIndex, +} from './content-search.util'; + +const TITLES = [ + 'İnşaat Kanalı', + 'inşaat dünyası', + 'İşte Benim Stilim', + 'Ünlü Şef', + 'Çanakkale', + 'Şan Ve Şeref', + 'Первый канал HD', + 'ПЕРВЫЙ КАНАЛ', + 'Ёлки 1914', + 'Йога для всех', + 'Матч ТВ', + 'Ελλάδα Σήμερα', + 'Amélie', + 'US: iş-tv', +]; + +type Arm = 'fts' | 'glob' | 'like' | 'm3u' | 'compound' | 'score'; + +function likeClauses( + column: string, + groups: string[][], + build: (token: string, mode: 'contains' | 'prefix') => string[] +): { where: string; params: string[] } { + const clauses: string[] = []; + const params: string[] = []; + groups.forEach((tokens, index) => { + const mode = + index === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + const patterns = tokens.flatMap((token) => build(token, mode)); + clauses.push( + `(${patterns.map(() => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})` + ); + params.push(...patterns); + }); + return { where: clauses.join(' AND '), params }; +} + +describe('content-search.util against SQLite', () => { + let db: Database.Database; + + beforeAll(() => { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE c(title TEXT); + CREATE VIRTUAL TABLE f USING fts5( + title, content='c', content_rowid='rowid', + tokenize='trigram remove_diacritics 1' + ); + CREATE TABLE p(title TEXT, payload TEXT); + `); + const insert = db.prepare('INSERT INTO c(title) VALUES (?)'); + const insertPayload = db.prepare( + 'INSERT INTO p(title, payload) VALUES (?, ?)' + ); + for (const title of TITLES) { + insert.run(title); + insertPayload.run( + title, + JSON.stringify({ items: [{ name: title, title }] }) + ); + } + db.exec(`INSERT INTO f(f) VALUES ('rebuild')`); + }); + + afterAll(() => { + db.close(); + }); + + function titles(sql: string, params: string[]): string[] { + return db + .prepare(sql) + .all(...params) + .map((row) => (row as { title: string }).title) + .sort(); + } + + function run(query: string): Partial> { + const out: Partial> = {}; + const groups = getSqlSearchTokenGroups(query); + + if (shouldUseContentTitlePrefixIndex(query)) { + const patterns = groups[0].flatMap((token) => + buildGlobPrefixPatterns(token) + ); + out.glob = titles( + `SELECT title FROM c WHERE ${patterns + .map(() => 'title GLOB ?') + .join(' OR ')}`, + patterns + ); + } else if (shouldUseContentTitleFts(query)) { + out.fts = titles( + `SELECT c.title FROM f INNER JOIN c ON c.rowid = f.rowid WHERE f MATCH ?`, + [buildContentTitleFtsMatchQuery(query)] + ); + } + + const like = likeClauses('title', groups, buildLikePatterns); + out.like = titles( + `SELECT title FROM c WHERE ${like.where}`, + like.params + ); + + const m3u = likeClauses( + 'payload', + groups, + buildM3uPayloadTextFieldPatterns + ); + out.m3u = titles(`SELECT title FROM p WHERE ${m3u.where}`, m3u.params); + + // The compound arm of the prefix path: a punctuation-joined word is + // looked up as an intact substring, because the prefix index only + // sees titles starting with the short first token (issue #1161). + const compoundWords = getCompoundSearchWords(query); + if (compoundWords.length > 0) { + const patterns = compoundWords.flatMap((word) => + buildCompoundLikePatterns(word) + ); + out.compound = titles( + `SELECT title FROM c WHERE ${patterns + .map(() => `title LIKE ? ESCAPE '\\'`) + .join(' OR ')}`, + patterns + ); + } + + out.score = TITLES.filter( + (title) => scoreSearchTextMatch(title, query) !== null + ).sort(); + return out; + } + + it.each([ + ['İnş', 'inş', ['İnşaat Kanalı', 'inşaat dünyası']], + ['İşt', 'işt', ['İşte Benim Stilim']], + ['İş', 'iş', ['İşte Benim Stilim']], + ['Ünl', 'ünl', ['Ünlü Şef']], + ['Çan', 'çan', ['Çanakkale']], + ['Şan', 'şan', ['Şan Ve Şeref']], + ])( + 'finds the same Turkish titles for %s and %s in every SQL arm', + (upper, lower, expected) => { + const upperResult = run(upper); + const lowerResult = run(lower); + + expect(lowerResult).toEqual(upperResult); + for (const arm of Object.keys(lowerResult) as Arm[]) { + expect(lowerResult[arm]).toEqual([...expected].sort()); + } + } + ); + + it.each([ + ['Первый', 'первый', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']], + ['ПЕРВ', 'перв', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']], + ['Ёлки', 'ёлки', ['Ёлки 1914']], + ['Йога', 'йога', ['Йога для всех']], + ['Ма', 'ма', ['Матч ТВ']], + ['ΕΛΛ', 'ελλ', ['Ελλάδα Σήμερα']], + ])( + 'keeps Cyrillic and Greek case pairs %s / %s equal and complete', + (upper, lower, expected) => { + const upperResult = run(upper); + const lowerResult = run(lower); + + expect(lowerResult).toEqual(upperResult); + for (const arm of Object.keys(lowerResult) as Arm[]) { + expect(lowerResult[arm]).toEqual([...expected].sort()); + } + } + ); + + it('reaches a lower-case compound title from the dotted capital İ spelling', () => { + // "İş-TV" lower-cases to "i" + U+0307, which is not how the stored + // "US: iş-tv" is spelled, and its short first token keeps the token + // arm prefix-anchored — so the compound arm has to carry the + // mark-free lower-case form. + expect(run('İş-TV').compound).toEqual(['US: iş-tv']); + expect(run('iş-tv').compound).toEqual(['US: iş-tv']); + expect(run('İş-TV')).toEqual(run('iş-tv')); + }); + + it('matches a decomposed query against the precomposed stored title', () => { + // The query normalizer composes first, so "Ame" + U+0301 + "lie" + // produces the same tokens as the precomposed spelling. + expect(run('Ame\u0301lie').like).toEqual(run('Amélie').like); + expect(run('Ame\u0301lie').score).toEqual(['Amélie']); + }); + + it('keeps the diacritic-stripped fallback for Latin accents', () => { + expect(run('Amélie').fts).toEqual(['Amélie']); + expect(run('amelie').fts).toEqual(['Amélie']); + expect(run('amelie').score).toEqual(['Amélie']); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.ts b/apps/electron-backend/src/app/database/operations/content-search.util.ts index dc0d9488b..ba48c662a 100644 --- a/apps/electron-backend/src/app/database/operations/content-search.util.ts +++ b/apps/electron-backend/src/app/database/operations/content-search.util.ts @@ -16,12 +16,55 @@ export function escapeLikePattern(term: string): string { return term.replace(/[%_\\]/g, '\\$&'); } +/** + * Case spellings a stored title may use for `value`, for LIKE/GLOB patterns: + * SQLite LIKE folds only ASCII and GLOB folds nothing, so every form has to + * be spelled out. Besides lower/upper/title case, a value containing "i" + * also gets the Turkish forms with the dotted capital İ (U+0130) — "inş" + * becomes "İNŞ" / "İnş" — because the locale-invariant `toUpperCase()` + * yields "INŞ", which never matches a title such as "İnşaat" (issue #609), + * and conversely the mark-free lower-case form, which `toLowerCase()` alone + * does not produce for a value already spelled with "İ". + * The explicit `'tr'` locale is the point: it is fixed, not the OS locale. + */ +function getCaseVariants(value: string): string[] { + const lower = value.toLowerCase(); + // Lower-casing "İ" leaves "i" + U+0307, so `lower` is not the spelling a + // stored lower-case title uses; `folded` is. Without it a query typed as + // "İş-TV" carries no pattern that matches a stored "US: iş-tv" — the + // token arm is prefix-anchored on the short first token, and the + // compound arm would only offer the diacritic-stripped "is-tv". + const folded = lower.replace(/[\u0300-\u036f]/g, ''); + const variants = new Set([ + value, + lower, + folded, + value.toUpperCase(), + value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(), + ]); + + if (folded.includes('i')) { + variants.add(folded.toLocaleUpperCase('tr')); + variants.add( + folded.charAt(0).toLocaleUpperCase('tr') + folded.slice(1) + ); + } + + return [...variants]; +} + +// Search case folding is deliberately locale-invariant (toLowerCase, not +// toLocaleLowerCase). Under a Turkish or Azeri locale toLocaleLowerCase maps +// ASCII "I" to the dotless "ı" and folds the dotted "İ" its own way, so the +// same title would be found or missed depending on the user's OS locale, and +// the query would not line up with the SQLite FTS index, which folds +// locale-invariantly. Issue #609: "İnş" and "inş" returned different results. export function normalizeSearchMatchText(value: unknown): string { return typeof value === 'string' ? value .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') - .toLocaleLowerCase() + .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim() .replace(/\s+/g, ' ') @@ -31,7 +74,20 @@ export function normalizeSearchMatchText(value: unknown): string { function normalizeSqlSearchText(value: unknown): string { return typeof value === 'string' ? value - .toLocaleLowerCase() + .toLowerCase() + // Re-compose first so canonically equivalent spellings of the + // query produce one token: a decomposed "e" + U+0301 would + // otherwise lose its accent below and stop matching a stored + // precomposed "é". Same rule as `foldSearchText`. + .normalize('NFC') + // Drop the combining marks case folding leaves behind, before the + // split below can read them as word separators: "İ" (U+0130) + // lower-cases to "i" plus a combining dot above (U+0307), which + // otherwise split "İnş" into "i" and "nş" while "inş" stayed one + // token, routing the two differently (issue #609). Same + // U+0300-U+036F range normalizeSearchMatchText strips; precomposed + // letters (ç, ş, ü, é) are outside it and left untouched. + .replace(/[\u0300-\u036f]/g, '') .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim() .replace(/\s+/g, ' ') @@ -75,16 +131,9 @@ export function buildLikePatterns( continue; } - const titleCase = - trimmedValue.length > 0 - ? trimmedValue.charAt(0).toLocaleUpperCase() + - trimmedValue.slice(1).toLocaleLowerCase() - : trimmedValue; - - variants.add(trimmedValue); - variants.add(trimmedValue.toLocaleLowerCase()); - variants.add(trimmedValue.toLocaleUpperCase()); - variants.add(titleCase); + for (const variant of getCaseVariants(trimmedValue)) { + variants.add(variant); + } } return [...variants].map((value) => { @@ -97,13 +146,9 @@ export function buildGlobPrefixPatterns(token: string): string[] { const variants = new Set(); for (const value of [token, ...getSqlSearchTokenVariants(token)]) { - variants.add(value); - variants.add(value.toLocaleLowerCase()); - variants.add(value.toLocaleUpperCase()); - variants.add( - value.charAt(0).toLocaleUpperCase() + - value.slice(1).toLocaleLowerCase() - ); + for (const variant of getCaseVariants(value)) { + variants.add(variant); + } } return [...variants].map((value) => `${value}*`); @@ -236,13 +281,9 @@ export function buildCompoundLikePatterns(word: string): string[] { const variants = new Set(); for (const value of getCompoundWordVariants(word)) { - variants.add(value); - variants.add(value.toLocaleLowerCase()); - variants.add(value.toLocaleUpperCase()); - variants.add( - value.charAt(0).toLocaleUpperCase() + - value.slice(1).toLocaleLowerCase() - ); + for (const variant of getCaseVariants(value)) { + variants.add(variant); + } } return [...variants].map((value) => `%${escapeLikePattern(value)}%`); @@ -261,7 +302,7 @@ export function buildCompoundFtsMatchQuery(searchTerm: string): string { const quotedVariants = [ ...new Set( getCompoundWordVariants(word).map((variant) => - variant.toLocaleLowerCase() + variant.toLowerCase() ) ), ].map((variant) => `"${variant.replace(/"/g, '""')}"`); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 6563f04a7..d44c5899a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -1,7 +1,7 @@ import { Injector } from '@angular/core'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; -import { sourceHealthType } from '@iptvnator/shared/interfaces'; +import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces'; import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component'; import { DatePipe, DOCUMENT } from '@angular/common'; import { @@ -138,7 +138,7 @@ export class PlaylistSwitcherComponent { readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; readonly activePlaylist = this.playlistContext.activePlaylist; readonly filteredPlaylists = computed(() => { - const query = this.searchQuery().toLowerCase().trim(); + const query = foldSearchText(this.searchQuery()).trim(); const filters = this.playlistTypeFilters(); const allPlaylists = this.playlists(); const filteredByType = allPlaylists.filter((playlist) => { @@ -148,8 +148,8 @@ export class PlaylistSwitcherComponent { const filtered = query ? filteredByType.filter( (playlist) => - playlist.title?.toLowerCase().includes(query) || - playlist.filename?.toLowerCase().includes(query) + foldSearchText(playlist.title ?? '').includes(query) || + foldSearchText(playlist.filename ?? '').includes(query) ) : filteredByType; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 570af73f2..05153931c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -40,6 +40,7 @@ import { SortService, } from '@iptvnator/services'; import { + foldSearchText, PLAYLIST_UPDATE, PlaylistMeta, PlaylistRefreshEvent, @@ -169,9 +170,9 @@ export class RecentPlaylistsComponent { ); }) .filter((item) => - (item.title || '') - .toLowerCase() - .includes(searchQuery.toLowerCase()) + foldSearchText(item.title || '').includes( + foldSearchText(searchQuery) + ) ); // Apply sorting using the SortService diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 5682b30ba..4efc557c1 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -9,6 +9,7 @@ import { type DownloadLibraryEntity, type DownloadLibraryRow, } from './download-library.viewmodel'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export type { DownloadCatchupCardViewModel, @@ -124,7 +125,7 @@ function matchesSearch( row: DownloadListItemViewModel, searchTerm: string ): boolean { - const query = searchTerm.trim().toLowerCase(); + const query = foldSearchText(searchTerm.trim()); return ( query.length === 0 || [ @@ -133,7 +134,7 @@ function matchesSearch( row.sourceName, row.episodeLabel, row.item.errorMessage ?? '', - ].some((value) => value.toLowerCase().includes(query)) + ].some((value) => foldSearchText(value).includes(query)) ); } diff --git a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts index 4304f24d9..d7571ceb7 100644 --- a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts @@ -1,5 +1,6 @@ import type { RecordingItem } from '@iptvnator/services'; import { normalizeDownloadFilter } from './download-manager.viewmodel'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export type RecordingAttentionReason = 'file-missing' | 'failed'; @@ -96,7 +97,7 @@ function toRow(item: RecordingItem): RecordingRowViewModel { } function matchesSearch(row: RecordingRowViewModel, searchTerm: string): boolean { - const query = searchTerm.trim().toLowerCase(); + const query = foldSearchText(searchTerm.trim()); return ( query.length === 0 || [ @@ -104,7 +105,7 @@ function matchesSearch(row: RecordingRowViewModel, searchTerm: string): boolean row.channelName, row.item.playlistName ?? '', row.item.errorMessage ?? '', - ].some((value) => value.toLowerCase().includes(query)) + ].some((value) => foldSearchText(value).includes(query)) ); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts index ff88648f5..075978c42 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -12,6 +12,7 @@ import { UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { ContentCardComponent } from '../content-card/content-card.component'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; @Component({ selector: 'app-unified-grid-tab', @@ -30,7 +31,7 @@ export class UnifiedGridTabComponent { readonly itemSelected = output(); private readonly normalizedSearchTerm = computed(() => - this.searchTerm().trim().toLowerCase() + foldSearchText(this.searchTerm().trim()) ); /** * Search results are identified by the name the user typed, so a @@ -44,7 +45,7 @@ export class UnifiedGridTabComponent { const term = this.normalizedSearchTerm(); const all = this.items(); return term - ? all.filter((i) => i.name.toLowerCase().includes(term)) + ? all.filter((i) => foldSearchText(i.name).includes(term)) : all; }); diff --git a/libs/portal/shared/util/src/lib/favorites-channel-sort.ts b/libs/portal/shared/util/src/lib/favorites-channel-sort.ts index 39978c1da..e4b994c95 100644 --- a/libs/portal/shared/util/src/lib/favorites-channel-sort.ts +++ b/libs/portal/shared/util/src/lib/favorites-channel-sort.ts @@ -1,3 +1,5 @@ +import { foldSearchText } from '@iptvnator/shared/interfaces'; + const FAVORITES_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', @@ -72,10 +74,10 @@ export function deriveVisibleFavoriteChannels( getAddedAt?: (item: T) => string | null | undefined; } ): readonly T[] { - const term = options.searchTerm.trim().toLowerCase(); + const term = foldSearchText(options.searchTerm.trim()); const filtered = term ? channels.filter((channel) => - (options.getName(channel) ?? '').toLowerCase().includes(term) + foldSearchText(options.getName(channel) ?? '').includes(term) ) : channels; diff --git a/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts b/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts index 670f6b2a1..f74dfd236 100644 --- a/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts @@ -135,4 +135,25 @@ describe('portal-collection-items', () => { expect(filtered).toEqual([items[1]]); }); + + it('folds the Turkish dotted capital İ so upper and lower case terms agree (issue #609)', () => { + const items = [ + { name: 'İnşaat Kanalı', o_name: '' }, + { name: 'Matrix', o_name: 'Neo' }, + ]; + const filter = (searchTerm: string) => + filterCollectionBucket({ + selectedCategoryId: 'all', + allItems: items, + buckets: { + movie: items, + }, + searchTerm, + textOf: (item) => `${item.name} ${item.o_name}`, + }); + + expect(filter('inş')).toEqual([items[0]]); + expect(filter('İnş')).toEqual([items[0]]); + expect(filter('İNŞ')).toEqual([items[0]]); + }); }); diff --git a/libs/portal/shared/util/src/lib/portal-collection-items.ts b/libs/portal/shared/util/src/lib/portal-collection-items.ts index 5e4fe7177..4797d1ebd 100644 --- a/libs/portal/shared/util/src/lib/portal-collection-items.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-items.ts @@ -1,4 +1,4 @@ -import { XtreamCategory } from '@iptvnator/shared/interfaces'; +import { foldSearchText, XtreamCategory } from '@iptvnator/shared/interfaces'; type StandardCollectionBucket = 'all' | 'movie' | 'live' | 'series'; @@ -84,13 +84,13 @@ export function filterCollectionBucket( : selectedCategoryId === 'series' ? buckets.series ?? [] : allItems ?? []; - const normalizedTerm = (searchTerm ?? '').trim().toLowerCase(); + const normalizedTerm = foldSearchText((searchTerm ?? '').trim()); if (!normalizedTerm) { return [...baseItems]; } return baseItems.filter((item) => - String(textOf(item)).toLowerCase().includes(normalizedTerm) + foldSearchText(String(textOf(item))).includes(normalizedTerm) ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts index 9e7303ab6..0aadc6156 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts @@ -16,6 +16,7 @@ import { StalkerItvChannel, StalkerItvLoadProgress, } from '@iptvnator/portal/stalker/data-access'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; /** Initial render window and per-`loadMore` growth over the cached list. */ const RENDER_CHUNK = 50; @@ -101,16 +102,16 @@ export class StalkerItvAllItemsComponent { }); readonly filteredChannels = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); const channels = this.channels(); if (!term) { return channels; } return channels.filter((channel) => - `${channel.o_name ?? ''} ${channel.name ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${channel.o_name ?? ''} ${channel.name ?? ''}` + ).includes(term) ); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 12378040b..1ef6827fe 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -42,6 +42,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + foldSearchText, buildStalkerEpgMappingKey, Channel, EpgItem, @@ -132,9 +133,9 @@ function matchesStalkerChannelTerm( item: StalkerItvChannel, term: string ): boolean { - return `${item.o_name ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(term); + return foldSearchText(`${item.o_name ?? ''} ${item.name ?? ''}`).includes( + term + ); } import { StalkerLiveNavigation } from './stalker-live-navigation'; @@ -203,7 +204,7 @@ export class StalkerLiveStreamLayoutComponent this.isRadioMode() ? this.radioChannels() : this.itvChannels() ); readonly searchTerm = computed(() => - this.stalkerStore.searchPhrase().trim().toLowerCase() + foldSearchText(this.stalkerStore.searchPhrase().trim()) ); /** Full-list mode: the complete channel list is cached, so search covers everything. */ readonly isFullListMode = computed( @@ -1099,7 +1100,7 @@ export class StalkerLiveStreamLayoutComponent * instance shows the sidebar's windowed rows. */ channelsForList(panelSearchTerm?: Signal): StalkerItvChannel[] { - const term = panelSearchTerm?.().trim().toLowerCase() ?? ''; + const term = foldSearchText(panelSearchTerm?.().trim() ?? ''); if (!term) { if (!panelSearchTerm) { return this.visibleChannels(); @@ -1776,7 +1777,7 @@ export class StalkerLiveStreamLayoutComponent matchesStalkerChannelTerm(item, this.searchTerm()) ) : this.filteredChannels(); - const query = term().trim().toLowerCase(); + const query = foldSearchText(term().trim()); return query ? this.searchableChannels().filter((item) => matchesStalkerChannelTerm(item, query) diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index d469dd8be..d31de8e6f 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -1,5 +1,6 @@ import { inject, Injectable, Injector } from '@angular/core'; import { + foldSearchText, ContentMetadataPatch, Playlist, PlaybackPositionData, @@ -548,7 +549,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ): Promise { void excludeHidden; const results: XtreamCachedContentItem[] = []; - const searchLower = searchTerm.toLowerCase(); + const searchLower = foldSearchText(searchTerm); for (const type of types) { const cacheKey = `${playlistId}-${type}-content`; @@ -557,7 +558,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const filtered = content.filter((item) => { const title = item.name || item.title || item.stream_display_name || ''; - return title.toLowerCase().includes(searchLower); + return foldSearchText(title).includes(searchLower); }); results.push(...filtered); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts index 16e07a691..c813f680d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts @@ -110,6 +110,12 @@ const TestSelectionStore = signalStore( title: 'Cosmos', added: '6', }, + { + xtream_id: 7, + category_id: '20', + title: 'İnşaat Belgeseli', + added: '7', + }, ...bulkVodStreams, ], serialCategories: [ @@ -191,6 +197,21 @@ describe('withSelection', () => { expect(store.visibleCount()).toBe(coveredCount); }); + it('matches the Turkish dotted capital İ from a lower-case search term (issue #609)', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(20); + + store.setCategorySearchTerm('inş'); + expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ + 'İnşaat Belgeseli', + ]); + + store.setCategorySearchTerm('İNŞ'); + expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ + 'İnşaat Belgeseli', + ]); + }); + it('keeps the render window when the category search term is unchanged', () => { store.setSelectedContentType('vod'); store.setSelectedCategory(90); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 531067e9b..ec60c8da1 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -7,6 +7,7 @@ import { withState, } from '@ngrx/signals'; import { ContentType, XtreamContentLoadState } from '../../xtream-state'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; /** * Module-level collator — allocating Intl.Collator is expensive; @@ -295,14 +296,14 @@ export function withSelection() { items: XtreamSelectionItem[], searchTerm: string ): XtreamSelectionItem[] => { - const normalized = searchTerm.trim().toLocaleLowerCase(); + const normalized = foldSearchText(searchTerm.trim()); if (!normalized) { return items; } return items.filter((item) => { const title = (item.title ?? item.name ?? '').toString(); - return title.toLocaleLowerCase().includes(normalized); + return foldSearchText(title).includes(normalized); }); }; diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index 8c2fd60e4..33b16ee16 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -19,6 +19,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService, XCategoryFromDb } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export interface CategoryManagementDialogData { playlistId: string; @@ -58,10 +59,10 @@ export class CategoryManagementDialogComponent implements OnInit { readonly searchTerm = signal(''); readonly filteredCategories = computed(() => { - const term = this.searchTerm().toLowerCase(); + const term = foldSearchText(this.searchTerm()); if (!term) return this.categories(); return this.categories().filter((c) => - c.name.toLowerCase().includes(term) + foldSearchText(c.name).includes(term) ); }); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts index 88bf0f9e0..6e500c8b4 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts @@ -15,6 +15,7 @@ import { } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { BehaviorSubject, Subject } from 'rxjs'; import { XtreamLiveChannelNavigationService } from './xtream-live-channel-navigation.service'; @@ -22,6 +23,7 @@ import { XtreamLiveChannelNavigationService } from './xtream-live-channel-naviga const first = { xtream_id: 1, category_id: '10', title: 'Zulu' }; const second = { xtream_id: 2, category_id: '10', title: 'Alpha' }; const other = { xtream_id: 3, category_id: '20', title: 'Other' }; +const turkish = { xtream_id: 4, category_id: '10', title: 'İnşaat TV' }; @Component({ template: '', @@ -47,9 +49,11 @@ describe('XtreamLiveChannelNavigationService', () => { (item) => (!selectedCategoryId() || Number(item.category_id) === selectedCategoryId()) && - item.title - .toLowerCase() - .includes(categorySearchTerm().toLowerCase()) + // Mirrors the real store's withSelection filter, which + // folds the term (issue #609). + foldSearchText(item.title).includes( + foldSearchText(categorySearchTerm()) + ) ) ); const store = { @@ -118,6 +122,19 @@ describe('XtreamLiveChannelNavigationService', () => { TestBed.flushEffects(); }); + it('lists the dotted capital İ for a lower-case route query, like the sidebar (issue #609)', () => { + // The rendered sidebar and this playback-order source must agree on + // what the query matches, or capture() records a queue that does not + // contain the row the user just clicked. + liveStreams.set([first, second, other, turkish]); + + query.next(convertToParamMap({ q: 'inş' })); + expect(service.displayedChannels()).toEqual([turkish]); + + query.next(convertToParamMap({ q: 'İNŞ' })); + expect(service.displayedChannels()).toEqual([turkish]); + }); + it('captures the displayed sort and keeps it after category, search, and sort changes', () => { service.sortMode.set('name-asc'); service.capture(first); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts index 2519914a6..bbbb843e7 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts @@ -17,6 +17,7 @@ import { sortPortalChannelItems, } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export interface XtreamLiveChannelItem { readonly added?: string; @@ -59,7 +60,7 @@ export class XtreamLiveChannelNavigationService { return playing?.owner === this.owner() ? playing.item : null; }); readonly displayedChannels = computed(() => { - const term = this.query().trim().toLowerCase(); + const term = foldSearchText(this.query().trim()); const channels = sortPortalChannelItems( this.store.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[], this.sortMode(), @@ -67,9 +68,9 @@ export class XtreamLiveChannelNavigationService { ); return term ? channels.filter((item) => - `${item.title ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${item.title ?? ''} ${item.name ?? ''}` + ).includes(term) ) : channels; }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index a18305d3b..6df4a38cf 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -28,6 +28,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { + foldSearchText, buildXtreamEpgMappingKey, EpgItem, EpgProgram, @@ -146,7 +147,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { ); }); readonly filteredChannels = computed(() => { - const term = this.searchTermInput().trim().toLowerCase(); + const term = foldSearchText(this.searchTermInput().trim()); const channels = this.sortedChannels(); if (!term) { @@ -154,9 +155,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } return channels.filter((item) => - `${item.title ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText(`${item.title ?? ''} ${item.name ?? ''}`).includes( + term + ) ); }); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 057ede141..8bd5c3f09 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -60,6 +60,7 @@ export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; export * from './lib/theme.enum'; export * from './lib/season-marker.util'; +export * from './lib/search-text-fold.util'; export * from './lib/stalker-account-info-dialog-data.interface'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; diff --git a/libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts b/libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts new file mode 100644 index 000000000..5a5604000 --- /dev/null +++ b/libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts @@ -0,0 +1,58 @@ +import { foldSearchText } from './search-text-fold.util'; + +describe('foldSearchText', () => { + it('folds the Turkish dotted capital I onto a plain lower-case i (issue #609)', () => { + expect(foldSearchText('İnşaat Kanalı')).toBe('inşaat kanalı'); + expect( + foldSearchText('İnşaat Kanalı').includes(foldSearchText('inş')) + ).toBe(true); + expect(foldSearchText('İnş')).toBe(foldSearchText('inş')); + }); + + it.each([ + ['Ünlü Şef', 'ünlü şef'], + ['ÇANAKKALE', 'çanakkale'], + ['Первый канал HD', 'первый канал hd'], + ['Ёлки', 'ёлки'], + ['Йога для всех', 'йога для всех'], + ['Ελλάδα Σήμερα', 'ελλάδα σήμερα'], + ['Amélie', 'amélie'], + ['Straße', 'straße'], + ])( + 'keeps precomposed letters of other scripts intact: %s', + (input, expected) => { + expect(foldSearchText(input)).toBe(expected); + } + ); + + it.each([ + ['Amélie', 'Amélie'], + ['İnşaat', 'İnşaat'], + ['Ёлки', 'Ёлки'], + ['Йога', 'Йога'], + ['Ünlü', 'Ünlü'], + ['Ά', 'Ά'], + ])( + 'folds canonically equivalent spellings of %s to one string', + (precomposed, decomposed) => { + expect(foldSearchText(decomposed)).toBe( + foldSearchText(precomposed) + ); + } + ); + + it('stays accent-sensitive, unlike the diacritic-folding SQL index', () => { + // The renderer filters were accent-sensitive before this helper and + // stay so: only marks that cannot compose are dropped. Accent-blind + // matching is the FTS index's own behaviour, not this fold's. + expect(foldSearchText('Amélie')).not.toBe(foldSearchText('Amelie')); + }); + + it('does not fold the dotless ı onto i', () => { + // The Turkish I/ı pair is a separate letter, not a case form of i; + // it is deliberately left alone (see the FTS index, which does not + // fold it either). + expect(foldSearchText('Işık')).toBe('işık'); + expect(foldSearchText('ışık')).toBe('ışık'); + }); +}); diff --git a/libs/shared/interfaces/src/lib/search-text-fold.util.ts b/libs/shared/interfaces/src/lib/search-text-fold.util.ts new file mode 100644 index 000000000..d3b37d17d --- /dev/null +++ b/libs/shared/interfaces/src/lib/search-text-fold.util.ts @@ -0,0 +1,35 @@ +/** + * Locale-invariant case fold for in-memory search filters (channel lists, + * catalog and category filters, source and download lists). Pure function, + * no Angular/Node dependencies. + * + * `toLowerCase()` alone is not enough for one visible input: the Turkish + * dotted capital "İ" (U+0130) lower-cases to "i" plus a combining dot above + * (U+0307), so `"İnşaat".toLowerCase().includes("inş")` is `false` even + * though the two spellings are the same word (issue #609). + * + * The fold therefore runs `toLowerCase()`, re-composes the result to NFC and + * drops whatever combining marks are left. Composing first is what makes the + * fold agree on canonically equivalent text: a provider title stored + * decomposed ("e" + U+0301) and a query typed precomposed ("é") reach the + * same string, and a Greek "Ά" lower-cases to a form NFC maps onto the same + * "ά" the user types. Only marks that cannot compose survive to be stripped — + * the dotted I's leftover dot among them — so precomposed letters (ç, ş, ü, + * é, ё, й) are preserved and the fold stays accent-sensitive, exactly as the + * filters were before. A lower-cased string of printable ASCII is already NFC + * and carries no marks, which is the fast path taken by most titles. + * + * `toLocaleLowerCase()` is deliberately not used: under a Turkish or Azeri + * OS locale it maps ASCII "I" to the dotless "ı", so the same list would + * filter differently per machine. + */ +const COMBINING_MARKS_REGEXP = /[̀-ͯ]/g; +const NON_PRINTABLE_ASCII_REGEXP = /[^ -~]/; + +export function foldSearchText(value: string): string { + const lowered = value.toLowerCase(); + + return NON_PRINTABLE_ASCII_REGEXP.test(lowered) + ? lowered.normalize('NFC').replace(COMBINING_MARKS_REGEXP, '') + : lowered; +} diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts index 216855a52..d0a9124c2 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts @@ -91,6 +91,30 @@ describe('AllChannelsViewComponent', () => { localStorage.removeItem(ALL_CHANNELS_SORT_STORAGE_KEY); }); + it('filters the Turkish dotted capital İ case-insensitively (issue #609)', () => { + const turkishChannel = createChannel( + 'channel-2', + 'İnşaat Kanalı', + 'https://example.com/insaat.m3u8' + ); + fixture.componentRef.setInput('channels', [ + primaryChannel, + turkishChannel, + ]); + + fixture.componentRef.setInput('searchTerm', 'inş'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([turkishChannel]); + + fixture.componentRef.setInput('searchTerm', 'İNŞ'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([turkishChannel]); + + fixture.componentRef.setInput('searchTerm', 'news'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([primaryChannel]); + }); + it('defaults to playlist order when no saved sort mode exists', () => { expect(component.allChannelsSortMode()).toBe('server'); expect(component.allChannelsSortLabel()).toBe('Playlist Order'); diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index 76e82f7af..4a983c055 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -21,6 +21,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { SettingsStore } from '@iptvnator/services'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -127,10 +128,12 @@ export class AllChannelsViewComponent { * reference when there is no search term, so large lists avoid cloning. */ readonly filteredChannels = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); const channels = this.channels(); const filteredChannels = term - ? channels.filter((ch) => ch.name?.toLowerCase().includes(term)) + ? channels.filter((ch) => + foldSearchText(ch.name ?? '').includes(term) + ) : channels; return sortPlaylistChannelItems( diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 57cf66041..ea3df8870 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -50,6 +50,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + foldSearchText, Channel, EpgProgram, isM3uRecentlyViewedItem, @@ -217,12 +218,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly routeSearchTerm = queryParamSignal( this.route, 'q', - (value) => (value ?? '').trim().toLowerCase() + (value) => foldSearchText((value ?? '').trim()) ); readonly workspaceSearchTerm = computed(() => { const hostTerm = this.searchTerm(); if (hostTerm !== null) { - return hostTerm.trim().toLowerCase(); + return foldSearchText(hostTerm.trim()); } return this.isWorkspaceLayout ? this.routeSearchTerm() : ''; }); diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index 3ead7ebf1..ba9a72e55 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -22,6 +22,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { SettingsStore } from '@iptvnator/services'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -97,16 +98,16 @@ export class FavoritesViewComponent { ); readonly filteredFavorites = computed(() => { const favorites = this.favorites(); - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); if (!term) { return favorites; } return favorites.filter((channel) => - `${channel.name ?? ''} ${channel.group?.title ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${channel.name ?? ''} ${channel.group?.title ?? ''}` + ).includes(term) ); }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts index 65d577f99..d4a7501d2 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts @@ -15,6 +15,7 @@ import { } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export interface GroupManagementDialogGroup { readonly count: number; @@ -59,14 +60,14 @@ export class GroupManagementDialogComponent { ); readonly filteredGroups = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); if (!term) { return this.groups(); } return this.groups().filter((group) => - group.key.toLowerCase().includes(term) + foldSearchText(group.key).includes(term) ); }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index b2d1f0302..85efc462a 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -24,6 +24,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -320,7 +321,7 @@ export class GroupsViewComponent { }); readonly workspaceFilteredGroups = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); const groups = this.visibleGroups(); if (!term) { @@ -333,11 +334,11 @@ export class GroupsViewComponent { } return groups.reduce((acc, group) => { - const titleMatches = group.key.toLowerCase().includes(term); + const titleMatches = foldSearchText(group.key).includes(term); const channels = titleMatches ? group.channels : group.channels.filter((channel) => - `${channel.name ?? ''}`.toLowerCase().includes(term) + foldSearchText(`${channel.name ?? ''}`).includes(term) ); if (channels.length === 0) { @@ -355,14 +356,16 @@ export class GroupsViewComponent { }); readonly filteredGroups = computed(() => { - const term = this.localGroupSearchTerm().trim().toLowerCase(); + const term = foldSearchText(this.localGroupSearchTerm().trim()); const groups = this.workspaceFilteredGroups(); if (!term) { return groups; } - return groups.filter((group) => group.key.toLowerCase().includes(term)); + return groups.filter((group) => + foldSearchText(group.key).includes(term) + ); }); readonly hasAnyGroups = computed(() => this.allGroups().length > 0); diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index 2ed5c55fc..ca7bb2c6e 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -17,6 +17,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -78,16 +79,16 @@ export class RecentViewComponent { readonly filteredRecentItems = computed(() => { const recentItems = this.recentItems(); - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); if (!term) { return recentItems; } return recentItems.filter(({ channel }) => - `${channel.name ?? ''} ${channel.group?.title ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${channel.name ?? ''} ${channel.group?.title ?? ''}` + ).includes(term) ); }); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts index ebe6a15c5..ceeef0feb 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts @@ -14,6 +14,7 @@ import { } from '@angular/material/slide-toggle'; import { TranslatePipe } from '@ngx-translate/core'; import { + foldSearchText, playlistDisplayLabel, VodSourceDescriptor, VodSourceMatchKind, @@ -111,16 +112,16 @@ export class VodSourcesMenuComponent { /** Sources surviving the search box; what "check all" acts on. */ private readonly searchedSources = computed(() => { - const query = this.filter().trim().toLowerCase(); + const query = foldSearchText(this.filter().trim()); const sources = this.sources(); if (!query || !this.showFilter()) { return sources; } return sources.filter((source) => - playlistDisplayLabel(source.playlistName, source.playlistId) - .toLowerCase() - .includes(query) + foldSearchText( + playlistDisplayLabel(source.playlistName, source.playlistId) + ).includes(query) ); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts index ba6bbcfba..c522f9d66 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts @@ -1,5 +1,5 @@ import { computed, effect, signal } from '@angular/core'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { foldSearchText, EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgDateNavigationDirection, getTodayEpgDateKey, @@ -101,11 +101,11 @@ export class EpgGuideViewState { channels: readonly EpgGuideChannel[], isCovered: (channelId: string) => boolean ): EpgGuideChannel[] { - const needle = this.filter().trim().toLowerCase(); + const needle = foldSearchText(this.filter().trim()); const onlyWithEpg = this.onlyWithEpg(); return channels.filter( (channel) => - (!needle || channel.name.toLowerCase().includes(needle)) && + (!needle || foldSearchText(channel.name).includes(needle)) && (!onlyWithEpg || isCovered(channel.id)) ); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index 42c1d6022..65b776133 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -164,6 +164,78 @@ describe('WorkspaceCommandPaletteComponent', () => { }); }); +describe('WorkspaceCommandPaletteComponent - Turkish case folding', () => { + const turkishCommands: WorkspaceResolvedCommandItem[] = [ + { + id: 'open-downloads', + label: 'İndirilenleri aç', + description: '', + group: 'global', + icon: 'download', + keywords: [], + priority: 50, + visible: true, + enabled: true, + run: () => undefined, + }, + { + id: 'open-settings', + label: 'Ayarlar', + description: '', + group: 'global', + icon: 'settings', + keywords: [], + priority: 40, + visible: true, + enabled: true, + run: () => undefined, + }, + ]; + + function setup(query: string): WorkspaceCommandPaletteComponent { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [WorkspaceCommandPaletteComponent], + providers: [ + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { + provide: MAT_DIALOG_DATA, + useValue: { query, commands: turkishCommands }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }); + const fixture = TestBed.createComponent( + WorkspaceCommandPaletteComponent + ); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it.each(['indir', 'İndir', 'İNDİR'])( + 'finds a command whose label carries the dotted capital İ for the query %s (issue #609)', + (query) => { + expect( + setup(query) + .flatCommands() + .map((command) => command.id) + ).toEqual(['open-downloads']); + } + ); +}); + describe('WorkspaceCommandPaletteComponent - recent section', () => { function setupComponent(options: { query: string; diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index 44f92aee7..2d35a1e8b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -21,6 +21,7 @@ import { WorkspaceCommandSelection, WorkspaceResolvedCommandItem, } from '@iptvnator/portal/shared/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; interface WorkspaceCommandPaletteData { commands: WorkspaceResolvedCommandItem[]; @@ -63,7 +64,7 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { ); readonly filteredCommands = computed(() => { - const term = this.query().trim().toLowerCase(); + const term = foldSearchText(this.query().trim()); const commands = this.visibleCommands(); if (!term) { @@ -75,11 +76,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { command.label, command.description, ...(command.keywords ?? []), - ] - .join(' ') - .toLowerCase(); + ].join(' '); - return haystack.includes(term); + return foldSearchText(haystack).includes(term); }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index d505eb0aa..d3303bb23 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -34,6 +34,7 @@ import { WorkspaceContextCategoryViewComponent } from './components/workspace-co import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component'; import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils'; import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists'; @@ -297,10 +298,10 @@ export class WorkspaceContextPanelComponent { readonly filteredXtreamCategories = computed(() => { const cats = this.xtreamCategories(); - const term = this.categorySearchTerm().trim().toLowerCase(); + const term = foldSearchText(this.categorySearchTerm().trim()); const filtered = term ? cats.filter((category) => - this.getCategoryLabel(category).toLowerCase().includes(term) + foldSearchText(this.getCategoryLabel(category)).includes(term) ) : cats; @@ -314,10 +315,10 @@ export class WorkspaceContextPanelComponent { readonly filteredStalkerCategories = computed(() => { const cats = this.stalkerCategories(); - const term = this.categorySearchTerm().trim().toLowerCase(); + const term = foldSearchText(this.categorySearchTerm().trim()); const filtered = term ? cats.filter((category) => - this.getCategoryLabel(category).toLowerCase().includes(term) + foldSearchText(this.getCategoryLabel(category)).includes(term) ) : cats; diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 515577abd..76c60d2a3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -15,6 +15,7 @@ import { } from '@iptvnator/portal/shared/util'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { + foldSearchText, sourceHealthType, PlaylistUpdateState, } from '@iptvnator/shared/interfaces'; @@ -195,7 +196,7 @@ export class WorkspaceSourcesComponent { }); readonly visibleSourcesCount = computed(() => { - const query = this.searchQuery().trim().toLowerCase(); + const query = foldSearchText(this.searchQuery().trim()); const filters = this.activeTypeFilters(); const allPlaylists = this.playlists(); @@ -217,7 +218,7 @@ export class WorkspaceSourcesComponent { return isStalkerFilter || isXtreamFilter || isM3uFilter; }) - .filter((item) => (item.title || '').toLowerCase().includes(query)) + .filter((item) => foldSearchText(item.title || '').includes(query)) .length; }); From 954c8ad65eac7e18a5c8592805c2513984315261 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:48:55 +0200 Subject: [PATCH 4/8] fix(dashboard): live rails find XMLTV programmes outside the global EPG sources (#1637) --- .../dashboard-live-rails-epg-any-source.md | 9 + CLAUDE.md | 1 + apps/electron-backend-e2e/src/epg.e2e.ts | 129 ++++ docs/architecture/m3u-playlist-module.md | 29 +- .../src/lib/epg-channel-metadata.lookup.ts | 101 +++ .../src/lib/epg-current-programs.lookup.ts | 256 ++++++++ .../data-access/src/lib/epg-lookup-context.ts | 17 + .../src/lib/epg-lookup-normalization.util.ts | 20 + .../data-access/src/lib/epg-program-cache.ts | 166 +++++ .../src/lib/epg-runtime-bridge.service.ts | 13 +- .../src/lib/epg-scoped-batch.lookup.ts | 180 +++++ .../src/lib/epg-single-program.lookup.ts | 129 ++++ .../data-access/src/lib/epg.service.spec.ts | 252 +++++++ libs/epg/data-access/src/lib/epg.service.ts | 614 ++---------------- libs/workspace/dashboard/feature/src/index.ts | 9 +- .../dashboard-live-epg.presenter.spec.ts | 221 +++++++ .../lib/rails/dashboard-live-epg.presenter.ts | 183 ++++++ .../src/lib/rails/dashboard-live-epg.utils.ts | 111 +++- .../src/lib/rails/dashboard-rail.component.ts | 6 + ...orkspace-dashboard-rails.component.spec.ts | 143 +++- .../workspace-dashboard-rails.component.ts | 88 +-- tools/eslint/max-lines-baseline.mjs | 1 - 22 files changed, 2019 insertions(+), 659 deletions(-) create mode 100644 .changes/dashboard-live-rails-epg-any-source.md create mode 100644 libs/epg/data-access/src/lib/epg-channel-metadata.lookup.ts create mode 100644 libs/epg/data-access/src/lib/epg-current-programs.lookup.ts create mode 100644 libs/epg/data-access/src/lib/epg-lookup-context.ts create mode 100644 libs/epg/data-access/src/lib/epg-lookup-normalization.util.ts create mode 100644 libs/epg/data-access/src/lib/epg-program-cache.ts create mode 100644 libs/epg/data-access/src/lib/epg-scoped-batch.lookup.ts create mode 100644 libs/epg/data-access/src/lib/epg-single-program.lookup.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts diff --git a/.changes/dashboard-live-rails-epg-any-source.md b/.changes/dashboard-live-rails-epg-any-source.md new file mode 100644 index 000000000..e0dce1935 --- /dev/null +++ b/.changes/dashboard-live-rails-epg-any-source.md @@ -0,0 +1,9 @@ +--- +type: fix +area: dashboard +--- + +The dashboard's "Now on air on favorite channels" and "Recently watched Live +TV" rails now show the current programme for channels whose guide lives in an +XMLTV another playlist imported, not only in the global EPG sources from +Settings — the same lookup the "See all" pages already used. diff --git a/CLAUDE.md b/CLAUDE.md index 908f3a906..2a2c2b99c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1606,6 +1606,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - XMLTV format support, from `http(s)` links or local files (Electron only): a `file:` URL, an absolute POSIX path, or a Windows drive/UNC path, plain `.xml` or gzip (detected by signature). A folder button beside each row opens the native picker (`EPG_OPEN_FILE_DIALOG`, `RuntimeCapabilitiesService.supportsEpgFilePicker`). Shape rules: `classifyEpgSourceReference` in `libs/shared/interfaces`; the worker opens both kinds through `openEpgSourceStream` (`workers/epg-source-stream.ts`). Only hand-chosen sources may be local: `extractM3uEpgUrls` harvests only remote links from M3U headers (legacy stored non-remote entries are dropped unless manual), and `EpgWorkerService.startFetch` asks the main-process `EpgLocalSourceAuthorizer` before a local path reaches the worker — picker results are trusted, a typed path is confirmed once in a native message box, allowed paths persist under `TRUSTED_LOCAL_EPG_SOURCES`, and the worker's local branch requires the main-set `allowLocalFile` flag (deny-all until wired). Contract: `docs/architecture/m3u-playlist-module.md` ("Local XMLTV files") - Background parsing in worker thread; HTTP/file gzip compatibility follows `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression"). - Stored in database for quick lookup +- Source scope: batch "now" lookups search the playlist's own XMLTV first, then the Settings-managed global sources, and stop there; only `EpgLookupOptions.anySourceFallback` (renderer-only) retries the still-unresolved keys against every imported source. The dashboard live rails pass it, so a favourite whose guide lives in another playlist's XMLTV gets the same programme its "See all" row already showed; they still issue one lookup per distinct playlist source scope and namespace the answers by it (`liveEpgProgramKey`), since a `tvg-id` is unique inside a guide but not across imports, and only cards carrying a real XMLTV key are widened — a portal card's key is just its title (wiring: `DashboardLiveEpgPresenter`). The channel list keeps the strict scope. Contract: `docs/architecture/m3u-playlist-module.md` (the "Scoped lookups" bullet under playlist-scoped URLs) - Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset") - Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide"). - Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/` diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 58525a4af..a1453e3bb 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -801,6 +801,135 @@ test.describe('Electron EPG', () => { } }); + test("@epg @electron dashboard live rails find a programme that only another playlist's XMLTV carries", async ({ + dataDir, + }) => { + test.setTimeout(120000); + // The reported case: the favourited channel's own playlist declares + // no guide, Settings hold a global XMLTV that does not know it, and + // the programme exists only in the guide a DIFFERENT playlist + // imported. The rails searched the global scope alone and showed + // nothing, while the "See all" row — resolved unscoped — had it. + const otherPlaylistEpgServer = await createMutableTextServer( + createCurrentXmltvFixture( + 'playlist-guide-news', + 'Playlist Guide News', + 'Other Playlist Bulletin' + ), + { + contentType: 'application/xml; charset=utf-8', + resourcePath: '/guides/other-playlist.xml', + } + ); + const globalEpgServer = await createMutableTextServer( + createCurrentXmltvFixture( + 'global-other', + 'Global Other', + 'Global Other Bulletin' + ), + { + contentType: 'application/xml; charset=utf-8', + resourcePath: '/guides/global-guide.xml', + } + ); + // Declares the guide, so importing it is what puts those programmes + // in the database under that source. + const guideOwnerServer = await createMutableTextServer( + buildM3uContent([ + { + name: 'Playlist Guide News', + tvgId: 'playlist-guide-news', + url: 'https://example.com/live/guide-owner.m3u8', + }, + ]).replace( + '#EXTM3U', + `#EXTM3U x-tvg-url="${otherPlaylistEpgServer.resourceUrl}"` + ), + { + contentType: 'application/x-mpegurl; charset=utf-8', + resourcePath: '/guide-owner.m3u', + } + ); + // Carries the same XMLTV id under its own display name and declares + // no guide at all — the playlist the dashboard card comes from. + const guidelessServer = await createMutableTextServer( + buildM3uContent([ + { + name: 'Mirror News', + tvgId: 'playlist-guide-news', + url: 'https://example.com/live/mirror-news.m3u8', + }, + ]), + { + contentType: 'application/x-mpegurl; charset=utf-8', + resourcePath: '/guideless.m3u', + } + ); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromUrl( + app.mainWindow, + guideOwnerServer.resourceUrl + ); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-complete' + ) + ).toHaveCount(1, { timeout: 30000 }); + + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + await app.mainWindow + .locator('.epg-source-row input') + .first() + .fill(globalEpgServer.resourceUrl); + await saveSettings(app.mainWindow); + await expect + .poll(() => getEpgChannelCount(app.mainWindow), { + timeout: 30000, + }) + .toBe(2); + + await importM3uPlaylistFromUrl( + app.mainWindow, + guidelessServer.resourceUrl + ); + + await openWorkspaceSection(app.mainWindow, 'All channels'); + const channelItem = channelItemByTitle( + app.mainWindow, + 'Mirror News' + ); + await expect(channelItem).toBeVisible({ timeout: 20000 }); + await channelItem.hover(); + await channelItem.locator('.favorite-button').first().click(); + await expect( + channelItem.locator('.favorite-button mat-icon').first() + ).toHaveText(/star/); + + await goToDashboard(app.mainWindow); + const card = app.mainWindow + .locator('[data-test-id="dashboard-live-favorites-rail-card"]') + .filter({ hasText: 'Mirror News' }) + .first(); + await expect(card).toBeVisible({ timeout: 20000 }); + await expect(card.locator('.rail__channel-now')).toContainText( + 'Other Playlist Bulletin', + { timeout: 30000 } + ); + } finally { + await closeElectronApp(app); + await guidelessServer.close(); + await guideOwnerServer.close(); + await otherPlaylistEpgServer.close(); + await globalEpgServer.close(); + } + }); + test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({ dataDir, }) => { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index ca85817f1..75d698dc9 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -978,7 +978,34 @@ These URLs are playlist-scoped by default: TTL expires. - Scoped lookups fall back only to Settings-managed EPG URLs for channels missing from the playlist-declared source. Playlist-local sources from other - playlists are not treated as global fallback sources. Single-channel current + playlists are not treated as global fallback sources. The one opt-out is + `EpgLookupOptions.anySourceFallback` (renderer-only, never forwarded to the + bridge): after the scope — playlist sources, then the global ones — has + answered, the keys still without a programme are retried once against every + imported source through the source-less batch path and its cache. The + ladder has the same shape with or without the bridge's batch endpoint: on + an older preload the scoped pass runs as per-channel scoped lookups + (`getScopedCurrentProgramForChannel`, same scope -> fallback-scope walk, + same scoped cache key) and only the any-source retry is source-less. + Collapsing that preload straight into the source-less lookup would drop + the caller's scope, which is what the scopes exist to prevent. The + dashboard live + rails pass the option: without it a favourite whose guide only exists in + another playlist's XMLTV showed no programme on the dashboard while its + "See all" row — resolved by `StreamResolverService`, which never scopes by + source — had one. They still ask **per source scope**, one lookup per + distinct set of playlist-declared XMLTV URLs, and namespace the answers by + that scope: a `tvg-id` is unique inside a guide, not across imports, so a + single flat map keyed by lookup key alone would hand one playlist's card + the programme another playlist's guide resolved for the same id. Playlists + sharing a guide share one lookup. Only a card that carries a real XMLTV key + is widened: an Xtream or Stalker card has none, so its lookup key is just + its display title, and searching every guide by title would let a + same-named M3U channel answer for a portal channel. Those cards keep the + strict scope (their own programmes come from the portal), and the + any-source flag is part of the scope identity so the two never share an + answer. Wiring: `DashboardLiveEpgPresenter` in + `libs/workspace/dashboard/feature/src/lib/rails/`. The channel list keeps the strict scope. Single-channel current program lookups include the source URL set in their cache and in-flight keys, so playlist-local and global lookups deduplicate without reusing the wrong source scope. Batch current-program lookups use the same source-scoped diff --git a/libs/epg/data-access/src/lib/epg-channel-metadata.lookup.ts b/libs/epg/data-access/src/lib/epg-channel-metadata.lookup.ts new file mode 100644 index 000000000..648a6a6b0 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-channel-metadata.lookup.ts @@ -0,0 +1,101 @@ +import { MonoTypeOperatorFunction, Observable, from, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { EpgChannelMetadata } from '@iptvnator/shared/interfaces'; +import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; + +/** + * Channel icons and display names from the imported XMLTV, resolved with the + * same playlist-first, Settings-managed-fallback strategy as the programme + * lookups: a playlist-local guide that only supplies programmes must still + * let icons come from a global source. + * + * Extracted from `EpgService` to keep that file under the repository's + * production size limit; it carries no state of its own. + */ +export class EpgChannelMetadataLookup { + constructor( + private readonly epgBridge: EpgRuntimeBridgeService, + private readonly guard: () => MonoTypeOperatorFunction, + private readonly globalSourceUrls: (excluding?: string[]) => string[] + ) {} + + forChannels( + normalizedChannelIds: string[], + sourceUrls: string[] + ): Observable> { + const globalSourceUrls = + sourceUrls.length > 0 + ? this.globalSourceUrls(sourceUrls) + : this.globalSourceUrls(); + const effectiveSourceUrls = + sourceUrls.length > 0 ? sourceUrls : globalSourceUrls; + + return this.forSourceUrls( + normalizedChannelIds, + effectiveSourceUrls + ).pipe( + this.guard>(), + switchMap((metadataMap) => { + const fallbackChannelIds = + sourceUrls.length > 0 && globalSourceUrls.length > 0 + ? normalizedChannelIds.filter( + (channelId) => !metadataMap.get(channelId) + ) + : []; + + if (fallbackChannelIds.length === 0) { + return of(metadataMap); + } + + return this.forSourceUrls( + fallbackChannelIds, + globalSourceUrls + ).pipe( + this.guard>(), + map((globalMetadataMap) => { + fallbackChannelIds.forEach((channelId) => { + metadataMap.set( + channelId, + globalMetadataMap.get(channelId) ?? null + ); + }); + return metadataMap; + }), + catchError((err) => { + console.error( + 'EPG global fallback channel metadata error:', + err + ); + return of(metadataMap); + }) + ); + }) + ); + } + + private forSourceUrls( + channelIds: string[], + sourceUrls: string[] + ): Observable> { + return from( + this.epgBridge.getChannelMetadata( + channelIds, + sourceUrls.length > 0 ? { sourceUrls } : undefined + ) + ).pipe( + this.guard | null>(), + map((metadataByChannelId) => { + return new Map( + channelIds.map((channelId) => [ + channelId, + metadataByChannelId?.[channelId] ?? null, + ]) + ); + }), + catchError((err) => { + console.error('EPG get channel metadata error:', err); + return of(new Map()); + }) + ); + } +} diff --git a/libs/epg/data-access/src/lib/epg-current-programs.lookup.ts b/libs/epg/data-access/src/lib/epg-current-programs.lookup.ts new file mode 100644 index 000000000..38c296596 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-current-programs.lookup.ts @@ -0,0 +1,256 @@ +import { Observable, forkJoin, from, of } from 'rxjs'; +import { catchError, map, switchMap, timeout } from 'rxjs/operators'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { EpgLookupOptions } from './epg-runtime-bridge.service'; +import { + normalizeLookupChannelIds, + normalizeLookupSourceUrls, +} from './epg-lookup-normalization.util'; +import type { EpgLookupContext } from './epg-lookup-context'; +import { EpgSingleProgramLookup } from './epg-single-program.lookup'; +import { EpgScopedBatchLookup } from './epg-scoped-batch.lookup'; + +/** + * "What is on air right now" for a batch of channels. + * + * The source-scope ladder lives here: the caller's playlist sources first, + * then the Settings-managed global ones, and — only when the caller opts in + * with `anySourceFallback` — a final retry across every imported guide. A + * bridge without the batch endpoint walks the same ladder one channel at a + * time through `EpgSingleProgramLookup`, so both shapes answer identically. + */ +export class EpgCurrentProgramsLookup { + constructor( + private readonly ctx: EpgLookupContext, + private readonly single: EpgSingleProgramLookup, + private readonly scopedBatch: EpgScopedBatchLookup + ) {} + + /** + * Gets current programs for multiple channels (batch operation) + * @param channelIds Array of channel IDs + * @returns Observable of Map with channelId -> current program + */ + forChannels( + channelIds: string[], + options?: EpgLookupOptions + ): Observable> { + if (!this.ctx.bridge.supportsProgramLookup) { + return of(new Map()); + } + + if (!channelIds || channelIds.length === 0) { + return of(new Map()); + } + + const scoped$ = this.getSourceScopedCurrentProgramsForChannels( + channelIds, + options + ); + if (!scoped$) { + // Nothing declares a scope — neither the caller nor Settings — so + // the pool of every imported source is the only answer there is. + return this.getUnscopedCurrentProgramsForChannels(channelIds); + } + if (!options?.anySourceFallback) { + return scoped$; + } + + return scoped$.pipe( + switchMap((scopedMap) => this.fillFromAnySource(scopedMap)) + ); + } + + /** + * The scoped lookup: the caller's playlist sources first (with the global + * sources as fallback), else the Settings-managed global sources alone. + * `null` when no scope applies and the unscoped pool is the only answer. + * + * The ladder is the same whether or not the bridge has the batch + * endpoint. Collapsing a legacy preload straight into the source-less + * lookup would drop the caller's scope entirely, which is exactly what + * the scopes exist to prevent: two imported guides reusing one XMLTV id + * would answer each other's channels. + */ + private getSourceScopedCurrentProgramsForChannels( + channelIds: string[], + options?: EpgLookupOptions + ): Observable> | null { + const sourceUrls = normalizeLookupSourceUrls(options); + if (sourceUrls.length > 0) { + return this.scopedCurrentProgramsForChannels( + channelIds, + sourceUrls, + this.ctx.globalSourceUrls(sourceUrls) + ); + } + + const globalSourceUrls = this.ctx.globalSourceUrls(); + if (globalSourceUrls.length > 0) { + return this.scopedCurrentProgramsForChannels( + channelIds, + globalSourceUrls, + [] + ); + } + + return null; + } + + /** One scoped batch, or its per-channel equivalent on an older preload. */ + private scopedCurrentProgramsForChannels( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls: string[] + ): Observable> { + if (this.ctx.bridge.supportsCurrentProgramBatch) { + return this.scopedBatch.forChannels( + channelIds, + sourceUrls, + fallbackSourceUrls + ); + } + + const normalizedChannelIds = normalizeLookupChannelIds(channelIds); + if (normalizedChannelIds.length === 0) { + return of(new Map()); + } + + // The per-channel lookup walks the same scope -> fallback-scope + // ladder the batch query does, and caches under the same scoped key. + return forkJoin( + normalizedChannelIds.map((channelId) => + this.single + .scoped(channelId, sourceUrls, fallbackSourceUrls) + .pipe( + this.ctx.guard(), + timeout(5000), + map((program) => ({ channelId, program })), + catchError(() => of({ channelId, program: null })) + ) + ) + ).pipe( + this.ctx.guard(), + map( + (results) => + new Map( + results.map((result) => [ + result.channelId, + result.program, + ]) + ) + ) + ); + } + + /** + * `anySourceFallback`: keys the scope answered with `null` are retried + * against every imported source. A scoped miss is kept as the answer + * when the pool has nothing either, so the merged map still names every + * requested key. + */ + private fillFromAnySource( + scopedMap: Map + ): Observable> { + const unresolvedIds = Array.from(scopedMap.entries()) + .filter(([, program]) => !program) + .map(([channelId]) => channelId); + if (unresolvedIds.length === 0) { + return of(scopedMap); + } + + return this.getUnscopedCurrentProgramsForChannels(unresolvedIds).pipe( + map((anySourceMap) => { + const mergedMap = new Map(scopedMap); + anySourceMap.forEach((program, channelId) => { + if (program) { + mergedMap.set(channelId, program); + } + }); + return mergedMap; + }) + ); + } + + /** Lookup across every imported source, cached under the source-less key. */ + private getUnscopedCurrentProgramsForChannels( + channelIds: string[] + ): Observable> { + const resultMap = new Map(); + const channelsToFetch: string[] = []; + const now = Date.now(); + const offsetMinutes = this.ctx.offsetMinutes(); + + // Check cache for each channel + channelIds.forEach((channelId) => { + const cached = this.ctx.cache.getFresh(channelId, now); + if (cached && cached.offsetMinutes === offsetMinutes) { + resultMap.set(channelId, cached.program); + } else { + channelsToFetch.push(channelId); + } + }); + + // If all channels were cached, return immediately + if (channelsToFetch.length === 0) { + return of(resultMap); + } + + // Single batched IPC + SQL query when the backend supports it. + // Replaces the legacy N+1 forkJoin where each channel fired its own + // GET_CHANNEL_PROGRAMS round-trip. + if (this.ctx.bridge.supportsCurrentProgramBatch) { + return from( + this.ctx.bridge.getCurrentProgramsBatch(channelsToFetch, { + nowMs: this.ctx.clockMs(), + }) + ).pipe( + this.ctx.guard(), + timeout(5000), + map((batchResult) => { + const cacheTimestamp = Date.now(); + channelsToFetch.forEach((channelId) => { + const program = batchResult?.[channelId] ?? null; + resultMap.set(channelId, program); + this.ctx.cache.set( + channelId, + program, + offsetMinutes, + cacheTimestamp + ); + }); + return resultMap; + }), + catchError((err) => { + console.error('EPG batch current programs error:', err); + return of(resultMap); + }) + ); + } + + // Fallback for older preload bundles without the batch endpoint. + // Deliberately the unscoped per-channel lookup: everything reaching + // here wants the source-less pool — either nothing declared a scope, + // or this is the any-source retry after the scoped pass. The public + // `getCurrentProgramForChannel` would put the Settings scope back on + // and make the retry re-ask the question the scoped pass answered. + const fetchObservables = channelsToFetch.map((channelId) => + this.single.unscoped(channelId).pipe( + this.ctx.guard(), + timeout(5000), + map((program) => ({ channelId, program })), + catchError(() => of({ channelId, program: null })) + ) + ); + + return forkJoin(fetchObservables).pipe( + this.ctx.guard(), + map((results) => { + results.forEach((result) => { + resultMap.set(result.channelId, result.program); + }); + return resultMap; + }) + ); + } +} diff --git a/libs/epg/data-access/src/lib/epg-lookup-context.ts b/libs/epg/data-access/src/lib/epg-lookup-context.ts new file mode 100644 index 000000000..c32c8e245 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-lookup-context.ts @@ -0,0 +1,17 @@ +import { MonoTypeOperatorFunction } from 'rxjs'; +import { EpgProgramCache } from './epg-program-cache'; +import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; + +/** Everything the lookups need from `EpgService`, and nothing else. */ +export interface EpgLookupContext { + readonly bridge: EpgRuntimeBridgeService; + readonly cache: EpgProgramCache; + /** Retires work that a changed XMLTV source set has invalidated. */ + guard(): MonoTypeOperatorFunction; + /** Current EPG display offset, in minutes. */ + offsetMinutes(): number; + /** Wall-clock now, expressed in the provider's uncorrected EPG clock. */ + clockMs(): number; + /** Settings-managed XMLTV URLs, optionally minus a caller's own scope. */ + globalSourceUrls(excluding?: string[]): string[]; +} diff --git a/libs/epg/data-access/src/lib/epg-lookup-normalization.util.ts b/libs/epg/data-access/src/lib/epg-lookup-normalization.util.ts new file mode 100644 index 000000000..41eb39a26 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-lookup-normalization.util.ts @@ -0,0 +1,20 @@ +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; +import { EpgLookupOptions } from './epg-runtime-bridge.service'; + +/** Trimmed, de-duplicated, non-empty channel ids, in request order. */ +export function normalizeLookupChannelIds(channelIds: string[]): string[] { + return Array.from( + new Set( + channelIds + .map((channelId) => channelId.trim()) + .filter((channelId) => channelId.length > 0) + ) + ); +} + +/** The caller's declared XMLTV scope, normalized like every stored URL. */ +export function normalizeLookupSourceUrls( + options?: EpgLookupOptions +): string[] { + return normalizeEpgUrls(options?.sourceUrls ?? []); +} diff --git a/libs/epg/data-access/src/lib/epg-program-cache.ts b/libs/epg/data-access/src/lib/epg-program-cache.ts new file mode 100644 index 000000000..51f851a68 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-program-cache.ts @@ -0,0 +1,166 @@ +import { MonoTypeOperatorFunction, Observable, of } from 'rxjs'; +import { finalize, shareReplay, tap } from 'rxjs/operators'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; + +export interface CachedProgram { + program: EpgProgram | null; + timestamp: number; + /** Display offset the "now" verdict was computed with; a changed setting invalidates the entry. */ + offsetMinutes: number; +} + +const CACHE_TTL_MS = 60000; + +/** + * The 60 s "currently airing" memory behind every lookup in `EpgService`, + * plus the in-flight requests sharing one answer. + * + * Owning it here keeps one rule in one place: an entry answers "what is on + * at the provider clock, in this source scope", so the scope and the display + * offset are both part of its identity. A lookup issued after the offset + * changed must neither read the previous entry nor join a request still in + * flight for it. + * + * Deliberately not `@Injectable`: it has no dependencies of its own, it + * takes the two facts it cannot know (the current offset, and the operator + * that retires work across an EPG source change) from its owner. + */ +export class EpgProgramCache { + private readonly programs = new Map(); + private readonly inFlight = new Map< + string, + Observable + >(); + private readonly inFlightBatches = new Map< + string, + Observable> + >(); + + constructor( + private readonly offsetMinutes: () => number, + private readonly guard: () => MonoTypeOperatorFunction + ) {} + + /** Cache and in-flight identity of a single-channel lookup. */ + keyFor(channelId: string, sourceUrls: string[] = []): string { + const normalizedSourceUrls = normalizeEpgUrls(sourceUrls); + const key = + normalizedSourceUrls.length === 0 + ? channelId + : `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`; + const offsetMinutes = this.offsetMinutes(); + return offsetMinutes === 0 ? key : `${key}|offset:${offsetMinutes}`; + } + + /** Identity of a batch request; order-insensitive in the channel ids. */ + batchKeyFor( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls: string[] + ): string { + return JSON.stringify({ + channelIds: [...channelIds].sort(), + sourceUrls: normalizeEpgUrls(sourceUrls), + fallbackSourceUrls: normalizeEpgUrls(fallbackSourceUrls), + offsetMinutes: this.offsetMinutes(), + }); + } + + /** A live entry, or `undefined` once it expired or the offset moved. */ + get(cacheKey: string): CachedProgram | undefined { + const cached = this.programs.get(cacheKey); + if (!cached) { + return undefined; + } + + if ( + Date.now() - cached.timestamp >= CACHE_TTL_MS || + cached.offsetMinutes !== this.offsetMinutes() + ) { + this.programs.delete(cacheKey); + return undefined; + } + + return cached; + } + + /** + * `offsetMinutes` is the offset the verdict was computed with, not the + * one current when the answer lands. + */ + set( + cacheKey: string, + program: EpgProgram | null, + offsetMinutes: number, + timestamp = Date.now() + ): void { + this.programs.set(cacheKey, { program, timestamp, offsetMinutes }); + } + + /** Unexpired entry without the scope/offset checks `get` applies. */ + getFresh(cacheKey: string, now: number): CachedProgram | undefined { + const cached = this.programs.get(cacheKey); + return cached && now - cached.timestamp < CACHE_TTL_MS + ? cached + : undefined; + } + + /** The cached answer, an in-flight one, else one started from `fetch`. */ + getOrFetch( + cacheKey: string, + fetchProgram: () => Observable + ): Observable { + const cached = this.get(cacheKey); + if (cached) { + return of(cached.program); + } + + const existingRequest = this.inFlight.get(cacheKey); + if (existingRequest) { + return existingRequest; + } + + const offsetMinutes = this.offsetMinutes(); + const request$ = fetchProgram().pipe( + this.guard(), + tap((program) => this.set(cacheKey, program, offsetMinutes)), + finalize(() => { + if (this.inFlight.get(cacheKey) === request$) + this.inFlight.delete(cacheKey); + }), + shareReplay({ bufferSize: 1, refCount: false }) + ); + this.inFlight.set(cacheKey, request$); + return request$; + } + + batchInFlight( + batchCacheKey: string + ): Observable> | undefined { + return this.inFlightBatches.get(batchCacheKey); + } + + registerBatch( + batchCacheKey: string, + request$: Observable> + ): void { + this.inFlightBatches.set(batchCacheKey, request$); + } + + /** Only the request that owns the slot may release it. */ + releaseBatch( + batchCacheKey: string, + request$: Observable> + ): void { + if (this.inFlightBatches.get(batchCacheKey) === request$) { + this.inFlightBatches.delete(batchCacheKey); + } + } + + clear(): void { + this.programs.clear(); + this.inFlight.clear(); + this.inFlightBatches.clear(); + } +} diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 9b4193bfd..a73e49dc9 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -27,7 +27,18 @@ export type EpgImportProgress = ElectronBridgeEpgProgress; export type EpgFetchResult = ElectronBridgeEpgFetchResult; export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult; export type EpgClearResult = ElectronBridgeResult; -export type EpgLookupOptions = ElectronBridgeEpgLookupOptions; +export interface EpgLookupOptions extends ElectronBridgeEpgLookupOptions { + /** + * Retry the keys the scoped lookup (playlist scope, then Settings-managed + * global sources) left without a programme against every imported XMLTV + * source. Off by default: the scopes exist so a playlist's own guide wins + * over a same-named channel in another playlist's guide. Surfaces with no + * playlist context — the dashboard live rails — opt in, matching the + * collection resolver and the timeline, which never scope by source. + * Renderer-only: it is not forwarded to the desktop bridge. + */ + anySourceFallback?: boolean; +} export type EpgCurrentProgramsOptions = ElectronBridgeCurrentProgramsOptions; type EpgElectronBridge = Pick< diff --git a/libs/epg/data-access/src/lib/epg-scoped-batch.lookup.ts b/libs/epg/data-access/src/lib/epg-scoped-batch.lookup.ts new file mode 100644 index 000000000..9080f9036 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-scoped-batch.lookup.ts @@ -0,0 +1,180 @@ +import { Observable, from, of } from 'rxjs'; +import { + catchError, + finalize, + map, + shareReplay, + switchMap, + tap, + timeout, +} from 'rxjs/operators'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { normalizeLookupChannelIds } from './epg-lookup-normalization.util'; +import type { EpgLookupContext } from './epg-lookup-context'; + +/** + * How ONE scope is asked: the per-channel cache in front of the request, the + * order-insensitive deduplication of identical batches still in flight, and + * the retry of whatever that scope could not answer against the fallback + * scope. `EpgCurrentProgramsLookup` decides WHICH scope to ask. + */ +export class EpgScopedBatchLookup { + constructor(private readonly ctx: EpgLookupContext) {} + + forChannels( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls = this.ctx.globalSourceUrls(sourceUrls) + ): Observable> { + const normalizedChannelIds = normalizeLookupChannelIds(channelIds); + if (normalizedChannelIds.length === 0) { + return of(new Map()); + } + + const resultMap = new Map(); + const channelsToFetch: string[] = []; + + normalizedChannelIds.forEach((channelId) => { + const cached = this.ctx.cache.get( + this.ctx.cache.keyFor(channelId, sourceUrls) + ); + if (cached) { + resultMap.set(channelId, cached.program); + } else { + channelsToFetch.push(channelId); + } + }); + + if (channelsToFetch.length === 0) { + return of(resultMap); + } + + const batchCacheKey = this.ctx.cache.batchKeyFor( + channelsToFetch, + sourceUrls, + fallbackSourceUrls + ); + const existingRequest = this.ctx.cache.batchInFlight(batchCacheKey); + // Tag entries with the offset the verdict was computed with, not the + // one current when the response lands. + const offsetMinutes = this.ctx.offsetMinutes(); + const request$ = + existingRequest ?? + this.fetchBatch( + channelsToFetch, + sourceUrls, + fallbackSourceUrls + ).pipe( + this.ctx.guard(), + tap((fetchedMap) => { + const cacheTimestamp = Date.now(); + channelsToFetch.forEach((channelId) => { + this.ctx.cache.set( + this.ctx.cache.keyFor(channelId, sourceUrls), + fetchedMap.get(channelId) ?? null, + offsetMinutes, + cacheTimestamp + ); + }); + }), + finalize(() => + this.ctx.cache.releaseBatch(batchCacheKey, request$) + ), + shareReplay({ bufferSize: 1, refCount: false }) + ); + + if (!existingRequest) { + this.ctx.cache.registerBatch(batchCacheKey, request$); + } + + return request$.pipe( + this.ctx.guard(), + map((fetchedMap) => { + const mergedResultMap = new Map(resultMap); + channelsToFetch.forEach((channelId) => { + mergedResultMap.set( + channelId, + fetchedMap.get(channelId) ?? null + ); + }); + return mergedResultMap; + }) + ); + } + + private fetchBatch( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls: string[] + ): Observable> { + const nowMs = this.ctx.clockMs(); + return from( + this.ctx.bridge.getCurrentProgramsBatch(channelIds, { + sourceUrls, + nowMs, + }) + ).pipe( + this.ctx.guard(), + timeout(5000), + switchMap((scopedResult) => { + const resultMap = new Map(); + const fallbackChannelIds: string[] = []; + + channelIds.forEach((channelId) => { + const program = scopedResult?.[channelId] ?? null; + resultMap.set(channelId, program); + if (!program) { + fallbackChannelIds.push(channelId); + } + }); + + if (fallbackChannelIds.length === 0) { + return of(resultMap); + } + + if (fallbackSourceUrls.length === 0) { + return of(resultMap); + } + + return from( + this.ctx.bridge.getCurrentProgramsBatch( + fallbackChannelIds, + { + sourceUrls: fallbackSourceUrls, + nowMs, + } + ) + ).pipe( + this.ctx.guard(), + timeout(5000), + map((globalResult) => { + fallbackChannelIds.forEach((channelId) => { + resultMap.set( + channelId, + globalResult?.[channelId] ?? null + ); + }); + return resultMap; + }), + catchError((err) => { + console.error( + 'EPG global fallback current programs error:', + err + ); + return of(resultMap); + }) + ); + }), + catchError((err) => { + console.error('EPG scoped batch current programs error:', err); + return of(this.nullProgramMap(channelIds)); + }) + ); + } + + private nullProgramMap( + channelIds: string[] + ): Map { + return new Map(channelIds.map((channelId) => [channelId, null])); + } +} diff --git a/libs/epg/data-access/src/lib/epg-single-program.lookup.ts b/libs/epg/data-access/src/lib/epg-single-program.lookup.ts new file mode 100644 index 000000000..036db380c --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-single-program.lookup.ts @@ -0,0 +1,129 @@ +import { Observable, from, of } from 'rxjs'; +import { catchError, map, switchMap, timeout } from 'rxjs/operators'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { EpgLookupOptions } from './epg-runtime-bridge.service'; +import { normalizeEpgPrograms } from './epg-program-normalization.util'; +import { normalizeLookupSourceUrls } from './epg-lookup-normalization.util'; +import type { EpgLookupContext } from './epg-lookup-context'; + +/** + * "What is on air" for a single channel, and the scope ladder one channel + * walks: the caller's sources, then the Settings-managed ones, then — for a + * caller that asked for it — the whole imported pool. The batch lookup backs + * its own per-channel work with these, so both shapes answer identically. + */ +export class EpgSingleProgramLookup { + constructor(private readonly ctx: EpgLookupContext) {} + + /** + * Gets the current EPG program for a specific channel (with caching) + * @param channelId Channel ID (tvg-id or channel name) + * @returns Observable of current program or null + */ + forChannel( + channelId: string, + options?: EpgLookupOptions + ): Observable { + if (!this.ctx.bridge.supportsProgramLookup || !channelId) { + return of(null); + } + + const sourceUrls = normalizeLookupSourceUrls(options); + if (sourceUrls.length > 0) { + return this.scoped( + channelId, + sourceUrls, + this.ctx.globalSourceUrls(sourceUrls) + ); + } + + const globalSourceUrls = this.ctx.globalSourceUrls(); + if (globalSourceUrls.length > 0) { + return this.scoped(channelId, globalSourceUrls, []); + } + + return this.unscoped(channelId); + } + + /** + * The lookup across every imported source, cached under the source-less + * key. Kept separate from `getCurrentProgramForChannel`, which re-applies + * the Settings-managed scope whenever global URLs exist: a caller that + * has already decided it wants the unscoped pool — the `anySourceFallback` + * retry on a preload without the batch endpoint — must not have that + * scope put back on. + */ + unscoped(channelId: string): Observable { + // Check cache first + const cacheKey = this.ctx.cache.keyFor(channelId); + + // Fetch from backend + return this.ctx.cache.getOrFetch(cacheKey, () => + from(this.ctx.bridge.getChannelPrograms(channelId)).pipe( + this.ctx.guard(), + map((programs) => normalizeEpgPrograms(programs ?? [])), + map((programs: EpgProgram[]) => this.findCurrent(programs)), + catchError((err) => { + console.error('EPG get current program error:', err); + return of(null); + }) + ) + ); + } + + /** + * Finds the current program from a list of programs + */ + findCurrent(programs: EpgProgram[]): EpgProgram | null { + const now = this.ctx.clockMs(); + + return ( + programs.find((program) => { + const start = new Date(program.start).getTime(); + const stop = new Date(program.stop).getTime(); + return start <= now && now <= stop; + }) || null + ); + } + + scoped( + channelId: string, + sourceUrls: string[], + fallbackSourceUrls: string[] + ): Observable { + const cacheKey = this.ctx.cache.keyFor(channelId, sourceUrls); + + return this.ctx.cache.getOrFetch(cacheKey, () => + from( + this.ctx.bridge.getChannelPrograms(channelId, { sourceUrls }) + ).pipe( + this.ctx.guard(), + timeout(3000), + map((programs) => normalizeEpgPrograms(programs ?? [])), + switchMap((programs) => { + const currentProgram = this.findCurrent(programs); + if (currentProgram) { + return of(currentProgram); + } + + return this.fallbackFor(channelId, fallbackSourceUrls); + }), + catchError((err) => { + console.error('EPG scoped current program error:', err); + return this.fallbackFor(channelId, fallbackSourceUrls); + }) + ) + ); + } + + private fallbackFor( + channelId: string, + sourceUrls: string[] + ): Observable { + if (sourceUrls.length === 0) { + return of(null); + } + + return this.scoped(channelId, sourceUrls, []); + } +} diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 69d4b29e0..4a0fbcb12 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -594,6 +594,258 @@ describe('EpgService', () => { ); }); + it('retries keys the global scope left unresolved against every source when anySourceFallback is set', async () => { + // The dashboard rails: no playlist scope, one global XMLTV in + // Settings, and a channel whose guide only exists in an XMLTV another + // playlist imported. The "See all" pages resolve it unscoped, so the + // rails must too — but only after the configured scope had its say. + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValueOnce({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Global Bulletin', + }, + 'guide-sports': null, + }) + .mockResolvedValueOnce({ + 'guide-sports': { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Other Playlist Sports', + }, + }); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels( + ['guide-news', 'guide-sports'], + { anySourceFallback: true } + ) + ); + + expect(result.get('guide-news')?.title).toBe('Global Bulletin'); + expect(result.get('guide-sports')?.title).toBe('Other Playlist Sports'); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith( + 1, + ['guide-news', 'guide-sports'], + expect.objectContaining({ + sourceUrls: ['https://global.example.com/guide.xml'], + }) + ); + // The retry carries only the unresolved key and no source scope. + const [retryIds, retryOptions] = ( + epgBridge.getCurrentProgramsBatch as jest.Mock + ).mock.calls[1]; + expect(retryIds).toEqual(['guide-sports']); + expect(retryOptions).not.toHaveProperty('sourceUrls'); + }); + + it('walks scope then any-source per channel on a preload without the batch endpoint', async () => { + // The ladder must not change shape with the capability: the scoped + // pass runs first and only its misses reach the source-less pool. + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = false; + epgBridge.getChannelPrograms = jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Other Playlist Sports', + }, + ]); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + + try { + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + anySourceFallback: true, + }) + ); + + expect(result.get('guide-sports')?.title).toBe( + 'Other Playlist Sports' + ); + const calls = (epgBridge.getChannelPrograms as jest.Mock).mock + .calls; + expect(calls.map(([, options]) => options?.sourceUrls)).toEqual([ + ['https://playlist.example.com/guide.xml'], + ['https://global.example.com/guide.xml'], + undefined, + ]); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps the playlist scope on a preload without the batch endpoint when no any-source retry was asked for', async () => { + // Jumping straight to the source-less lookup here would defeat the + // scopes: two guides reusing one XMLTV id would answer each other. + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = false; + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([]); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + + expect(result.get('guide-sports')).toBeNull(); + const calls = (epgBridge.getChannelPrograms as jest.Mock).mock.calls; + expect(calls.map(([, options]) => options?.sourceUrls)).toEqual([ + ['https://playlist.example.com/guide.xml'], + ['https://global.example.com/guide.xml'], + ]); + }); + + it('keeps the Settings scope on the legacy per-channel path when no any-source retry was asked for', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = false; + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([]); + + await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports']) + ); + + expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith( + 'guide-sports', + expect.objectContaining({ + sourceUrls: ['https://global.example.com/guide.xml'], + }) + ); + }); + + it('keeps the scoped verdict when the any-source retry finds nothing either', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValueOnce({ 'guide-sports': null }) + .mockResolvedValueOnce({ 'guide-sports': null }); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + anySourceFallback: true, + }) + ); + + expect(result.size).toBe(1); + expect(result.get('guide-sports')).toBeNull(); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2); + }); + + it('skips the any-source retry when every key resolved in scope, and never retries without the option', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValueOnce({ + 'guide-news': { + channel: 'guide-news', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Global Bulletin', + }, + }) + .mockResolvedValueOnce({ 'guide-sports': null }); + + const resolved = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + anySourceFallback: true, + }) + ); + expect(resolved.get('guide-news')?.title).toBe('Global Bulletin'); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + + // Scoped callers (the channel list) keep today's contract: a miss in + // the configured scope stays a miss. + const unresolved = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports']) + ); + expect(unresolved.get('guide-sports')).toBeNull(); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2); + }); + + it('runs the any-source retry after the playlist scope and its global fallback', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockResolvedValueOnce({ 'guide-sports': null }) + .mockResolvedValueOnce({ 'guide-sports': null }) + .mockResolvedValueOnce({ + 'guide-sports': { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Other Playlist Sports', + }, + }); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + anySourceFallback: true, + }) + ); + + expect(result.get('guide-sports')?.title).toBe('Other Playlist Sports'); + const calls = (epgBridge.getCurrentProgramsBatch as jest.Mock).mock + .calls; + expect(calls.map(([, options]) => options.sourceUrls)).toEqual([ + ['https://playlist.example.com/guide.xml'], + ['https://global.example.com/guide.xml'], + undefined, + ]); + }); + it('caches scoped batch current programs by EPG source URL scope', async () => { epgBridge.supportsProgramLookup = true; epgBridge.supportsCurrentProgramBatch = true; diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index d76ae9bf6..525e308da 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -1,16 +1,8 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; -import { - catchError, - finalize, - map, - shareReplay, - switchMap, - tap, - timeout, -} from 'rxjs/operators'; +import { BehaviorSubject, from, Observable, of } from 'rxjs'; +import { catchError, map, tap, timeout } from 'rxjs/operators'; import { createDevLogger, EpgChannelMetadata, @@ -23,15 +15,18 @@ import { EpgRuntimeBridgeService, } from './epg-runtime-bridge.service'; import { normalizeEpgPrograms } from './epg-program-normalization.util'; +import { EpgProgramCache } from './epg-program-cache'; +import { EpgChannelMetadataLookup } from './epg-channel-metadata.lookup'; +import { EpgCurrentProgramsLookup } from './epg-current-programs.lookup'; +import { EpgSingleProgramLookup } from './epg-single-program.lookup'; +import { EpgScopedBatchLookup } from './epg-scoped-batch.lookup'; +import type { EpgLookupContext } from './epg-lookup-context'; +import { + normalizeLookupChannelIds, + normalizeLookupSourceUrls, +} from './epg-lookup-normalization.util'; import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; -interface CachedProgram { - program: EpgProgram | null; - timestamp: number; - /** Display offset the "now" verdict was computed with; a changed setting invalidates the entry. */ - offsetMinutes: number; -} - const debugEpgService = createDevLogger('EpgService'); @Injectable({ @@ -55,17 +50,36 @@ export class EpgService { private epgAvailable = new BehaviorSubject(false); private currentEpgPrograms = new BehaviorSubject([]); - // Cache for channel programs with 60-second TTL - private programCache = new Map(); - private fetchingCurrentPrograms = new Map< - string, - Observable - >(); - private fetchingCurrentProgramBatches = new Map< - string, - Observable> - >(); - private readonly CACHE_TTL = 60000; // 60 seconds + /** Channel icons/display names; same scope ladder as the programmes. */ + private readonly channelMetadata = new EpgChannelMetadataLookup( + this.epgBridge, + () => this.sourceSettings.guard(), + (excluding) => this.getGlobalEpgSourceUrls(excluding) + ); + + /** 60 s "currently airing" memory, keyed by scope + display offset. */ + private readonly programCache = new EpgProgramCache( + () => this.epgOffsetMinutes(), + () => this.sourceSettings.guard() + ); + + /** The whole "what is on air" scope ladder, single channel or batch. */ + private readonly lookupContext: EpgLookupContext = { + bridge: this.epgBridge, + cache: this.programCache, + guard: () => this.sourceSettings.guard(), + offsetMinutes: () => this.epgOffsetMinutes(), + clockMs: () => this.epgClockMs(), + globalSourceUrls: (excluding) => this.getGlobalEpgSourceUrls(excluding), + }; + private readonly singleProgram = new EpgSingleProgramLookup( + this.lookupContext + ); + private readonly currentPrograms = new EpgCurrentProgramsLookup( + this.lookupContext, + this.singleProgram, + new EpgScopedBatchLookup(this.lookupContext) + ); /** Display offset every "currently airing" decision in here is made with. */ private epgOffsetMinutes(): number { @@ -179,57 +193,7 @@ export class EpgService { if (!this.epgBridge.supportsProgramLookup || !channelId) { return of(null); } - - const sourceUrls = this.normalizeSourceUrls(options); - if (sourceUrls.length > 0) { - return this.getScopedCurrentProgramForChannel( - channelId, - sourceUrls, - this.getGlobalEpgSourceUrls(sourceUrls) - ); - } - - const globalSourceUrls = this.getGlobalEpgSourceUrls(); - if (globalSourceUrls.length > 0) { - return this.getScopedCurrentProgramForChannel( - channelId, - globalSourceUrls, - [] - ); - } - - // Check cache first - const cacheKey = this.createProgramCacheKey(channelId); - - // Fetch from backend - return this.getCachedOrFetchCurrentProgram(cacheKey, () => - from(this.epgBridge.getChannelPrograms(channelId)).pipe( - this.sourceSettings.guard(), - map((programs) => normalizeEpgPrograms(programs ?? [])), - map((programs: EpgProgram[]) => - this.findCurrentProgram(programs) - ), - catchError((err) => { - console.error('EPG get current program error:', err); - return of(null); - }) - ) - ); - } - - /** - * Finds the current program from a list of programs - */ - private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null { - const now = this.epgClockMs(); - - return ( - programs.find((program) => { - const start = new Date(program.start).getTime(); - const stop = new Date(program.stop).getTime(); - return start <= now && now <= stop; - }) || null - ); + return this.singleProgram.forChannel(channelId, options); } /** @@ -244,266 +208,10 @@ export class EpgService { if (!this.epgBridge.supportsProgramLookup) { return of(new Map()); } - if (!channelIds || channelIds.length === 0) { return of(new Map()); } - - const sourceUrls = this.normalizeSourceUrls(options); - if ( - sourceUrls.length > 0 && - this.epgBridge.supportsCurrentProgramBatch - ) { - return this.getScopedCurrentProgramsForChannels( - channelIds, - sourceUrls - ); - } - - const globalSourceUrls = this.getGlobalEpgSourceUrls(); - if ( - globalSourceUrls.length > 0 && - this.epgBridge.supportsCurrentProgramBatch - ) { - return this.getScopedCurrentProgramsForChannels( - channelIds, - globalSourceUrls, - [] - ); - } - - const resultMap = new Map(); - const channelsToFetch: string[] = []; - const now = Date.now(); - const offsetMinutes = this.epgOffsetMinutes(); - - // Check cache for each channel - channelIds.forEach((channelId) => { - const cached = this.programCache.get(channelId); - if ( - cached && - now - cached.timestamp < this.CACHE_TTL && - cached.offsetMinutes === offsetMinutes - ) { - resultMap.set(channelId, cached.program); - } else { - channelsToFetch.push(channelId); - } - }); - - // If all channels were cached, return immediately - if (channelsToFetch.length === 0) { - return of(resultMap); - } - - // Single batched IPC + SQL query when the backend supports it. - // Replaces the legacy N+1 forkJoin where each channel fired its own - // GET_CHANNEL_PROGRAMS round-trip. - if (this.epgBridge.supportsCurrentProgramBatch) { - return from( - this.epgBridge.getCurrentProgramsBatch(channelsToFetch, { - nowMs: this.epgClockMs(), - }) - ).pipe( - this.sourceSettings.guard(), - timeout(5000), - map((batchResult) => { - const cacheTimestamp = Date.now(); - channelsToFetch.forEach((channelId) => { - const program = batchResult?.[channelId] ?? null; - resultMap.set(channelId, program); - this.programCache.set(channelId, { - program, - timestamp: cacheTimestamp, - offsetMinutes, - }); - }); - return resultMap; - }), - catchError((err) => { - console.error('EPG batch current programs error:', err); - return of(resultMap); - }) - ); - } - - // Fallback for older preload bundles without the batch endpoint. - const fetchObservables = channelsToFetch.map((channelId) => - this.getCurrentProgramForChannel(channelId).pipe( - this.sourceSettings.guard(), - timeout(5000), - map((program) => ({ channelId, program })), - catchError(() => of({ channelId, program: null })) - ) - ); - - return forkJoin(fetchObservables).pipe( - this.sourceSettings.guard(), - map((results) => { - results.forEach((result) => { - resultMap.set(result.channelId, result.program); - }); - return resultMap; - }) - ); - } - - private getScopedCurrentProgramsForChannels( - channelIds: string[], - sourceUrls: string[], - fallbackSourceUrls = this.getGlobalEpgSourceUrls(sourceUrls) - ): Observable> { - const normalizedChannelIds = this.normalizeChannelIds(channelIds); - if (normalizedChannelIds.length === 0) { - return of(new Map()); - } - - const resultMap = new Map(); - const channelsToFetch: string[] = []; - - normalizedChannelIds.forEach((channelId) => { - const cached = this.getCachedProgram( - this.createProgramCacheKey(channelId, sourceUrls) - ); - if (cached) { - resultMap.set(channelId, cached.program); - } else { - channelsToFetch.push(channelId); - } - }); - - if (channelsToFetch.length === 0) { - return of(resultMap); - } - - const batchCacheKey = this.createProgramBatchCacheKey( - channelsToFetch, - sourceUrls, - fallbackSourceUrls - ); - const existingRequest = - this.fetchingCurrentProgramBatches.get(batchCacheKey); - // Tag entries with the offset the verdict was computed with, not the - // one current when the response lands. - const offsetMinutes = this.epgOffsetMinutes(); - const request$ = - existingRequest ?? - this.fetchScopedCurrentProgramsBatch( - channelsToFetch, - sourceUrls, - fallbackSourceUrls - ).pipe( - this.sourceSettings.guard(), - tap((fetchedMap) => { - const cacheTimestamp = Date.now(); - channelsToFetch.forEach((channelId) => { - this.programCache.set( - this.createProgramCacheKey(channelId, sourceUrls), - { - program: fetchedMap.get(channelId) ?? null, - timestamp: cacheTimestamp, - offsetMinutes, - } - ); - }); - }), - finalize(() => { - if ( - this.fetchingCurrentProgramBatches.get( - batchCacheKey - ) === request$ - ) - this.fetchingCurrentProgramBatches.delete( - batchCacheKey - ); - }), - shareReplay({ bufferSize: 1, refCount: false }) - ); - - if (!existingRequest) { - this.fetchingCurrentProgramBatches.set(batchCacheKey, request$); - } - - return request$.pipe( - this.sourceSettings.guard(), - map((fetchedMap) => { - const mergedResultMap = new Map(resultMap); - channelsToFetch.forEach((channelId) => { - mergedResultMap.set( - channelId, - fetchedMap.get(channelId) ?? null - ); - }); - return mergedResultMap; - }) - ); - } - - private fetchScopedCurrentProgramsBatch( - channelIds: string[], - sourceUrls: string[], - fallbackSourceUrls: string[] - ): Observable> { - const nowMs = this.epgClockMs(); - return from( - this.epgBridge.getCurrentProgramsBatch(channelIds, { - sourceUrls, - nowMs, - }) - ).pipe( - this.sourceSettings.guard(), - timeout(5000), - switchMap((scopedResult) => { - const resultMap = new Map(); - const fallbackChannelIds: string[] = []; - - channelIds.forEach((channelId) => { - const program = scopedResult?.[channelId] ?? null; - resultMap.set(channelId, program); - if (!program) { - fallbackChannelIds.push(channelId); - } - }); - - if (fallbackChannelIds.length === 0) { - return of(resultMap); - } - - if (fallbackSourceUrls.length === 0) { - return of(resultMap); - } - - return from( - this.epgBridge.getCurrentProgramsBatch(fallbackChannelIds, { - sourceUrls: fallbackSourceUrls, - nowMs, - }) - ).pipe( - this.sourceSettings.guard(), - timeout(5000), - map((globalResult) => { - fallbackChannelIds.forEach((channelId) => { - resultMap.set( - channelId, - globalResult?.[channelId] ?? null - ); - }); - return resultMap; - }), - catchError((err) => { - console.error( - 'EPG global fallback current programs error:', - err - ); - return of(resultMap); - }) - ); - }), - catchError((err) => { - console.error('EPG scoped batch current programs error:', err); - return of(this.createNullProgramMap(channelIds)); - }) - ); + return this.currentPrograms.forChannels(channelIds, options); } getChannelMetadataForChannels( @@ -514,243 +222,17 @@ export class EpgService { return of(new Map()); } - const normalizedChannelIds = this.normalizeChannelIds(channelIds); - + const normalizedChannelIds = normalizeLookupChannelIds(channelIds); if (normalizedChannelIds.length === 0) { return of(new Map()); } - const sourceUrls = this.normalizeSourceUrls(options); - const globalSourceUrls = - sourceUrls.length > 0 - ? this.getGlobalEpgSourceUrls(sourceUrls) - : this.getGlobalEpgSourceUrls(); - const effectiveSourceUrls = - sourceUrls.length > 0 ? sourceUrls : globalSourceUrls; - - return this.getChannelMetadataMapForSourceUrls( + return this.channelMetadata.forChannels( normalizedChannelIds, - effectiveSourceUrls - ).pipe( - this.sourceSettings.guard(), - switchMap((metadataMap) => { - const fallbackChannelIds = - sourceUrls.length > 0 && globalSourceUrls.length > 0 - ? normalizedChannelIds.filter( - (channelId) => !metadataMap.get(channelId) - ) - : []; - - if (fallbackChannelIds.length === 0) { - return of(metadataMap); - } - - return this.getChannelMetadataMapForSourceUrls( - fallbackChannelIds, - globalSourceUrls - ).pipe( - this.sourceSettings.guard(), - map((globalMetadataMap) => { - fallbackChannelIds.forEach((channelId) => { - metadataMap.set( - channelId, - globalMetadataMap.get(channelId) ?? null - ); - }); - return metadataMap; - }), - catchError((err) => { - console.error( - 'EPG global fallback channel metadata error:', - err - ); - return of(metadataMap); - }) - ); - }) + normalizeLookupSourceUrls(options) ); } - private normalizeChannelIds(channelIds: string[]): string[] { - return Array.from( - new Set( - channelIds - .map((channelId) => channelId.trim()) - .filter((channelId) => channelId.length > 0) - ) - ); - } - - private normalizeSourceUrls(options?: EpgLookupOptions): string[] { - return normalizeEpgUrls(options?.sourceUrls ?? []); - } - - private getChannelMetadataMapForSourceUrls( - channelIds: string[], - sourceUrls: string[] - ): Observable> { - return from( - this.epgBridge.getChannelMetadata( - channelIds, - sourceUrls.length > 0 ? { sourceUrls } : undefined - ) - ).pipe( - this.sourceSettings.guard(), - map((metadataByChannelId) => { - return new Map( - channelIds.map((channelId) => [ - channelId, - metadataByChannelId?.[channelId] ?? null, - ]) - ); - }), - catchError((err) => { - console.error('EPG get channel metadata error:', err); - return of(new Map()); - }) - ); - } - - /** - * Cache and in-flight identity of a lookup. The display offset is part of - * it: a request evaluated at another provider clock answers a different - * question, so a lookup issued after the setting changed must neither - * read the previous entry nor join a batch still in flight for it. - */ - private createProgramCacheKey( - channelId: string, - sourceUrls: string[] = [] - ): string { - const normalizedSourceUrls = normalizeEpgUrls(sourceUrls); - const key = - normalizedSourceUrls.length === 0 - ? channelId - : `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`; - const offsetMinutes = this.epgOffsetMinutes(); - return offsetMinutes === 0 ? key : `${key}|offset:${offsetMinutes}`; - } - - private createProgramBatchCacheKey( - channelIds: string[], - sourceUrls: string[], - fallbackSourceUrls: string[] - ): string { - return JSON.stringify({ - channelIds: [...channelIds].sort(), - sourceUrls: normalizeEpgUrls(sourceUrls), - fallbackSourceUrls: normalizeEpgUrls(fallbackSourceUrls), - offsetMinutes: this.epgOffsetMinutes(), - }); - } - - private getCachedProgram(cacheKey: string): CachedProgram | undefined { - const cached = this.programCache.get(cacheKey); - if (!cached) { - return undefined; - } - - if ( - Date.now() - cached.timestamp >= this.CACHE_TTL || - cached.offsetMinutes !== this.epgOffsetMinutes() - ) { - this.programCache.delete(cacheKey); - return undefined; - } - - return cached; - } - - private getCachedOrFetchCurrentProgram( - cacheKey: string, - fetchProgram: () => Observable - ): Observable { - const cached = this.getCachedProgram(cacheKey); - if (cached) { - return of(cached.program); - } - - const existingRequest = this.fetchingCurrentPrograms.get(cacheKey); - if (existingRequest) { - return existingRequest; - } - - const offsetMinutes = this.epgOffsetMinutes(); - const request$ = fetchProgram().pipe( - this.sourceSettings.guard(), - tap((program) => { - this.programCache.set(cacheKey, { - program, - timestamp: Date.now(), - offsetMinutes, - }); - }), - finalize(() => { - if (this.fetchingCurrentPrograms.get(cacheKey) === request$) - this.fetchingCurrentPrograms.delete(cacheKey); - }), - shareReplay({ bufferSize: 1, refCount: false }) - ); - this.fetchingCurrentPrograms.set(cacheKey, request$); - return request$; - } - - private getScopedCurrentProgramForChannel( - channelId: string, - sourceUrls: string[], - fallbackSourceUrls: string[] - ): Observable { - const cacheKey = this.createProgramCacheKey(channelId, sourceUrls); - - return this.getCachedOrFetchCurrentProgram(cacheKey, () => - from( - this.epgBridge.getChannelPrograms(channelId, { sourceUrls }) - ).pipe( - this.sourceSettings.guard(), - timeout(3000), - map((programs) => normalizeEpgPrograms(programs ?? [])), - switchMap((programs) => { - const currentProgram = this.findCurrentProgram(programs); - if (currentProgram) { - return of(currentProgram); - } - - return this.getFallbackCurrentProgramForChannel( - channelId, - fallbackSourceUrls - ); - }), - catchError((err) => { - console.error('EPG scoped current program error:', err); - return this.getFallbackCurrentProgramForChannel( - channelId, - fallbackSourceUrls - ); - }) - ) - ); - } - - private getFallbackCurrentProgramForChannel( - channelId: string, - sourceUrls: string[] - ): Observable { - if (sourceUrls.length === 0) { - return of(null); - } - - return this.getScopedCurrentProgramForChannel( - channelId, - sourceUrls, - [] - ); - } - - private createNullProgramMap( - channelIds: string[] - ): Map { - return new Map(channelIds.map((channelId) => [channelId, null])); - } - private getGlobalEpgSourceUrls(excluding: string[] = []): string[] { const excludedUrls = new Set(excluding); return normalizeEpgUrls( @@ -763,7 +245,5 @@ export class EpgService { */ clearCache(): void { this.programCache.clear(); - this.fetchingCurrentPrograms.clear(); - this.fetchingCurrentProgramBatches.clear(); } } diff --git a/libs/workspace/dashboard/feature/src/index.ts b/libs/workspace/dashboard/feature/src/index.ts index 363b10a9a..767acd00c 100644 --- a/libs/workspace/dashboard/feature/src/index.ts +++ b/libs/workspace/dashboard/feature/src/index.ts @@ -9,12 +9,17 @@ export type { export { buildDashboardLiveEpgDetails, buildLiveEpgCardsForEnabledRails, - buildLiveEpgLookupKeys, + buildLiveEpgLookupGroups, calcEpgProgress, formatEpgTimeRange, getLiveEpgProgramForCard, + liveEpgProgramKey, + liveEpgScopeKey, +} from './lib/rails/dashboard-live-epg.utils'; +export type { + DashboardLiveEpgDetails, + DashboardLiveEpgLookupGroup, } from './lib/rails/dashboard-live-epg.utils'; -export type { DashboardLiveEpgDetails } from './lib/rails/dashboard-live-epg.utils'; export { buildPlaybackPositionReloadKey, formatRemainingLabel, diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts new file mode 100644 index 000000000..fa4f72d3a --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -0,0 +1,221 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EMPTY, of, throwError } from 'rxjs'; +import { EpgService } from '@iptvnator/epg/data-access'; +import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access'; +import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter'; +import type { DashboardRailCard } from './dashboard-rail.component'; + +const guideA = 'https://a.example/guide.xml'; +const guideB = 'https://b.example/guide.xml'; + +const m3uPlaylist = (id: string, epgUrls: string[]): PlaylistMeta => + ({ _id: id, epgUrls }) as PlaylistMeta; + +const card = (overrides: Partial): DashboardRailCard => + ({ + id: 'card', + title: 'Das Erste HD', + icon: 'live_tv', + contentType: 'live', + link: ['/workspace'], + ...overrides, + }) as DashboardRailCard; + +const program = (title: string): EpgProgram => + ({ + channel: 'ard.de', + title, + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + }) as EpgProgram; + +describe('DashboardLiveEpgPresenter', () => { + let presenter: DashboardLiveEpgPresenter; + let getCurrentProgramsForChannels: jest.Mock; + let playlists: ReturnType>; + + const setup = (cards: DashboardRailCard[]) => { + presenter.connect(signal(cards)); + // `toObservable` pushes through an effect, so the connected cards + // only reach the lookup once effects run. + TestBed.tick(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + getCurrentProgramsForChannels = jest.fn(() => of(new Map())); + playlists = signal([ + m3uPlaylist('a', [guideA]), + m3uPlaylist('a2', [guideA]), + m3uPlaylist('b', [guideB]), + { _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta, + ]); + + TestBed.configureTestingModule({ + providers: [ + DashboardLiveEpgPresenter, + { + provide: DashboardDataService, + useValue: { playlists }, + }, + { + provide: EpgService, + useValue: { getCurrentProgramsForChannels }, + }, + { + provide: SettingsStore, + useValue: { resolvedEpgOffsetMinutes: () => 0 }, + }, + ], + }); + presenter = TestBed.inject(DashboardLiveEpgPresenter); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('asks each guide once and lets playlists on the same guide share the lookup', () => { + setup([ + card({ id: 'a', epgLookupKey: 'ard.de', epgPlaylistId: 'a' }), + card({ id: 'a2', epgLookupKey: 'zdf.de', epgPlaylistId: 'a2' }), + card({ id: 'b', epgLookupKey: 'ard.de', epgPlaylistId: 'b' }), + ]); + + expect( + getCurrentProgramsForChannels.mock.calls.map(([keys, options]) => [ + keys, + options.sourceUrls, + ]) + ).toEqual([ + [['ard.de', 'zdf.de'], [guideA]], + [['ard.de'], [guideB]], + ]); + }); + + it('keeps a portal card out of the any-source retry and in its own answer space', () => { + const portalCard = card({ id: 'p', epgPlaylistId: 'portal' }); + const m3uCard = card({ + id: 'm', + epgLookupKey: 'Das Erste HD', + epgPlaylistId: 'guideless', + }); + getCurrentProgramsForChannels.mockImplementation((keys, options) => + of( + new Map([ + [ + 'Das Erste HD', + program( + options.anySourceFallback + ? 'From any guide' + : 'From Settings only' + ), + ], + ]) + ) + ); + + setup([m3uCard, portalCard]); + + expect( + getCurrentProgramsForChannels.mock.calls.map( + ([, options]) => options.anySourceFallback + ) + ).toEqual([true, false]); + // Both resolve against Settings, both key on the same title, and they + // still do not share an answer. + expect(presenter.detailsFor(m3uCard)?.nowPlayingTitle).toBe( + 'From any guide' + ); + expect(presenter.detailsFor(portalCard)?.nowPlayingTitle).toBe( + 'From Settings only' + ); + }); + + it('never hands a card the programme another guide resolved for the same id', () => { + const fromA = card({ + id: 'a', + epgLookupKey: 'ard.de', + epgPlaylistId: 'a', + }); + const fromB = card({ + id: 'b', + epgLookupKey: 'ard.de', + epgPlaylistId: 'b', + }); + getCurrentProgramsForChannels.mockImplementation((keys, options) => + of( + new Map([ + [ + 'ard.de', + program( + options.sourceUrls[0] === guideA + ? 'Guide A bulletin' + : 'Guide B bulletin' + ), + ], + ]) + ) + ); + + setup([fromA, fromB]); + + expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe( + 'Guide A bulletin' + ); + expect(presenter.detailsFor(fromB)?.nowPlayingTitle).toBe( + 'Guide B bulletin' + ); + }); + + it('keeps the other guides when one lookup is retired or fails mid-tick', () => { + const fromA = card({ + id: 'a', + epgLookupKey: 'ard.de', + epgPlaylistId: 'a', + }); + const fromB = card({ + id: 'b', + epgLookupKey: 'ard.de', + epgPlaylistId: 'b', + }); + getCurrentProgramsForChannels.mockImplementation((keys, options) => + options.sourceUrls[0] === guideA + ? of( + new Map([ + ['ard.de', program('Guide A bulletin')], + ]) + ) + : // What the EPG source-change guard does to work in flight: + // complete without ever emitting. + EMPTY + ); + + setup([fromA, fromB]); + + expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe( + 'Guide A bulletin' + ); + expect(presenter.detailsFor(fromB)).toBeNull(); + + getCurrentProgramsForChannels.mockImplementation((keys, options) => + options.sourceUrls[0] === guideA + ? of( + new Map([ + ['ard.de', program('Guide A bulletin')], + ]) + ) + : throwError(() => new Error('lookup failed')) + ); + jest.advanceTimersByTime(30_000); + + expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe( + 'Guide A bulletin' + ); + expect(presenter.detailsFor(fromB)).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts new file mode 100644 index 000000000..803280606 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -0,0 +1,183 @@ +import { + computed, + inject, + Injectable, + signal, + type Signal, +} from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { + catchError, + defaultIfEmpty, + forkJoin, + interval, + map, + of, + startWith, + switchMap, +} from 'rxjs'; +import { EpgService } from '@iptvnator/epg/data-access'; +import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; +import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access'; +import type { DashboardRailCard } from './dashboard-rail.component'; +import { + buildDashboardLiveEpgDetails, + buildLiveEpgLookupGroups, + getLiveEpgProgramForCard, + liveEpgAllowsAnySource, + liveEpgProgramKey, + liveEpgScopeKey, + LIVE_EPG_TICK_MS, + type DashboardLiveEpgDetails, + type DashboardLiveEpgLookupGroup, +} from './dashboard-live-epg.utils'; + +type ScopeAnswer = { + readonly scopeKey: string; + readonly programs: ReadonlyMap; +}; + +const emptyAnswer = (scopeKey: string): ScopeAnswer => ({ + scopeKey, + programs: new Map(), +}); + +/** + * The dashboard's uploaded-XMLTV "now on air" lookup for live cards. + * Component-provided, so the 30 s heartbeat dies with the page. + * + * Best-effort, keyed by the app-wide M3U chain (tvg-id -> tvg-name -> name) + * with the card title as a final fallback. Xtream/Stalker live items often + * have no XMLTV side-channel and simply return null — the card then renders + * without the programme row. + * + * One lookup per source scope, not one for the whole page: a `tvg-id` is + * unique inside a guide, not across imports, so a card is only ever handed + * the answer resolved in ITS playlist's scope. Each lookup then opts into the + * any-source retry — Settings global sources first, then every imported + * XMLTV, which is what the "See all" collection pages resolve against. + * Without it a channel whose guide only exists in another playlist's XMLTV + * showed no programme here while its "See all" row had one. + */ +@Injectable() +export class DashboardLiveEpgPresenter { + private readonly data = inject(DashboardDataService); + private readonly epgService = inject(EpgService); + private readonly settingsStore = inject(SettingsStore); + + private readonly cards = signal | null>(null); + + // The XMLTV sources each playlist declares. Same rule as + // `ChannelListContainerComponent`: only an M3U playlist carries its own + // guide; a portal playlist is answered from the Settings-managed URLs. + private readonly sourceUrlsByPlaylist = computed(() => { + const byPlaylistId = new Map(); + for (const playlist of this.data.playlists()) { + byPlaylistId.set( + playlist._id, + playlist.serverUrl || playlist.macAddress + ? [] + : normalizeEpgUrls(playlist.epgUrls ?? []) + ); + } + return byPlaylistId; + }); + + private readonly lookupGroups = computed(() => + buildLiveEpgLookupGroups(this.cards()?.() ?? [], (card) => + this.sourceUrlsForCard(card) + ) + ); + + // Re-fetch on rail change AND on a 30s heartbeat so the progress bar + // catches the boundary between programs without a full page revisit. + private readonly programs = toSignal( + toObservable(this.lookupGroups).pipe( + switchMap((groups) => + groups.length === 0 + ? of(new Map()) + : interval(LIVE_EPG_TICK_MS).pipe( + startWith(0), + switchMap(() => + forkJoin( + groups.map((group) => this.askScope(group)) + ).pipe(map((answers) => mergeAnswers(answers))) + ) + ) + ) + ), + { initialValue: new Map() } + ); + + /** The live cards whose rails are enabled, hero included. */ + connect(cards: Signal): void { + this.cards.set(cards); + } + + /** `null` when nothing is known about the card's current programme. */ + detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null { + if (!card) { + return null; + } + const program = getLiveEpgProgramForCard( + card, + this.programs(), + liveEpgScopeKey( + this.sourceUrlsForCard(card), + liveEpgAllowsAnySource(card) + ) + ); + // Recompute the now-window each tick so progress moves between + // 30s ticks even if the program identity is unchanged. + return buildDashboardLiveEpgDetails( + program, + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ); + } + + /** The XMLTV scope a live card's programme must be resolved in. */ + private sourceUrlsForCard(card: DashboardRailCard): string[] { + const byPlaylistId = this.sourceUrlsByPlaylist(); + return ( + (card.epgPlaylistId + ? byPlaylistId.get(card.epgPlaylistId) + : undefined) ?? [] + ); + } + + private askScope(group: DashboardLiveEpgLookupGroup) { + const scopeKey = group.scopeKey; + return this.epgService + .getCurrentProgramsForChannels(group.lookupKeys, { + sourceUrls: group.sourceUrls, + anySourceFallback: group.anySourceFallback, + }) + .pipe( + map((programs): ScopeAnswer => ({ scopeKey, programs })), + // One scope retired by an EPG source change, or failing, must + // not blank every other scope's answer for the tick: + // `forkJoin` emits nothing at all when one input completes + // without a value. + defaultIfEmpty(emptyAnswer(scopeKey)), + catchError(() => of(emptyAnswer(scopeKey))) + ); + } +} + +/** Namespaced by scope, so two guides sharing an id stay apart. */ +function mergeAnswers( + answers: readonly ScopeAnswer[] +): Map { + const merged = new Map(); + for (const answer of answers) { + for (const [lookupKey, program] of answer.programs) { + merged.set(liveEpgProgramKey(answer.scopeKey, lookupKey), program); + } + } + return merged; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index 99d22d4aa..98526cabb 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -110,18 +110,96 @@ function liveEpgLookupKeyForCard(card: DashboardRailCard): string { return card.epgLookupKey?.trim() || card.title.trim(); } -export function buildLiveEpgLookupKeys( - cards: readonly DashboardRailCard[] -): string[] { - const seen = new Set(); - const keys: string[] = []; +/** One XMLTV lookup: the scope it is answered in and the keys asked for. */ +export interface DashboardLiveEpgLookupGroup { + /** Identity of the scope, as `liveEpgScopeKey` builds it. */ + readonly scopeKey: string; + readonly sourceUrls: string[]; + readonly lookupKeys: string[]; + /** Whether unresolved keys may be retried against every imported guide. */ + readonly anySourceFallback: boolean; +} + +/** + * Only a card whose playlist gave it a real XMLTV key may be retried against + * every imported guide. An Xtream or Stalker card carries no such key, so the + * lookup falls back to its display title, and searching every playlist's + * guide by title would let a same-named M3U channel answer for a portal + * channel. Those cards keep the strict scope; their own programmes come from + * the portal instead. + */ +export function liveEpgAllowsAnySource(card: DashboardRailCard): boolean { + return Boolean(card.epgLookupKey?.trim()); +} + +/** + * Cards are grouped by the XMLTV sources their playlist declares, not by the + * playlist itself: two playlists pointing at the same guide ask one question + * and share the answer, while two playlists with DIFFERENT guides never do. + * That separation is the point — a bare `tvg-id` like `ard.de` is not unique + * across imports, so one flat map keyed by lookup key alone would hand one + * playlist's card the other playlist's programme. The any-source flag is part + * of that identity too: a guide-less M3U playlist and a portal playlist both + * resolve against Settings, but only the first may widen the search, so their + * answers for one title are not interchangeable. + */ +export function liveEpgScopeKey( + sourceUrls: readonly string[], + anySourceFallback: boolean +): string { + // JSON, not a separator character: a URL may contain anything, and + // a raw control byte would classify this source file as binary. + return JSON.stringify([ + Array.from(new Set(sourceUrls)).sort(), + anySourceFallback, + ]); +} + +/** Namespaces an answer by the scope it was resolved in. */ +export function liveEpgProgramKey(scopeKey: string, lookupKey: string): string { + return JSON.stringify([scopeKey, lookupKey]); +} + +export function buildLiveEpgLookupGroups( + cards: readonly DashboardRailCard[], + sourceUrlsForCard: (card: DashboardRailCard) => string[] +): DashboardLiveEpgLookupGroup[] { + const groups = new Map< + string, + { + sourceUrls: string[]; + lookupKeys: string[]; + anySourceFallback: boolean; + seen: Set; + } + >(); + for (const card of cards) { - const key = liveEpgLookupKeyForCard(card); - if (!key || seen.has(key)) continue; - seen.add(key); - keys.push(key); + const lookupKey = liveEpgLookupKeyForCard(card); + if (!lookupKey) continue; + + const sourceUrls = sourceUrlsForCard(card); + const anySourceFallback = liveEpgAllowsAnySource(card); + const scopeKey = liveEpgScopeKey(sourceUrls, anySourceFallback); + const group = groups.get(scopeKey) ?? { + sourceUrls: Array.from(new Set(sourceUrls)), + lookupKeys: [], + anySourceFallback, + seen: new Set(), + }; + if (!group.seen.has(lookupKey)) { + group.seen.add(lookupKey); + group.lookupKeys.push(lookupKey); + } + groups.set(scopeKey, group); } - return keys; + + return Array.from(groups.entries(), ([scopeKey, group]) => ({ + scopeKey, + sourceUrls: group.sourceUrls, + lookupKeys: group.lookupKeys, + anySourceFallback: group.anySourceFallback, + })); } type DashboardLiveEpgRailSettings = Pick< @@ -142,16 +220,23 @@ export function buildLiveEpgCardsForEnabledRails( ]; } +/** + * `epgMap` is keyed by `liveEpgProgramKey`, so a card only ever reads the + * answer resolved in its own source scope. + */ export function getLiveEpgProgramForCard( card: DashboardRailCard, - epgMap: ReadonlyMap + epgMap: ReadonlyMap, + scopeKey: string ): EpgProgram | null { const key = liveEpgLookupKeyForCard(card); - const program = epgMap.get(key); + const program = epgMap.get(liveEpgProgramKey(scopeKey, key)); if (program) { return program; } const titleKey = card.title.trim(); - return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null; + return key !== titleKey + ? (epgMap.get(liveEpgProgramKey(scopeKey, titleKey)) ?? null) + : null; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index e3eaaa589..60d01a07a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -40,6 +40,12 @@ export interface DashboardRailCard { state?: Record; actions?: DashboardRailAction[]; epgLookupKey?: string; + /** + * Playlist the live card belongs to. An XMLTV key is only unique inside + * the guide its playlist declares, so the dashboard's EPG lookup is + * grouped and namespaced by that playlist's source scope. + */ + epgPlaylistId?: string; /** * Optional EPG enrichment shown by the 'channel' rail layout. Populated diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 3d9d265b5..74feeb38a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -12,10 +12,12 @@ import { import { buildDashboardLiveEpgDetails, buildLiveEpgCardsForEnabledRails, - buildLiveEpgLookupKeys, + buildLiveEpgLookupGroups, calcEpgProgress, formatEpgTimeRange, getLiveEpgProgramForCard, + liveEpgProgramKey, + liveEpgScopeKey, } from './dashboard-live-epg.utils'; import { buildPlaybackPositionReloadKey, @@ -311,9 +313,15 @@ describe('Live rail helpers', () => { ...overrides, }); + const guideA = ['https://a.example/guide.xml']; + const guideB = ['https://b.example/guide.xml']; + const scopeFor = + (byPlaylist: Record) => (card: DashboardRailCard) => + byPlaylist[card.epgPlaylistId ?? ''] ?? []; + it('uses explicit EPG lookup keys before falling back to card titles', () => { - expect( - buildLiveEpgLookupKeys([ + const groups = buildLiveEpgLookupGroups( + [ channelCard({ title: 'Das Erste HD', epgLookupKey: 'ard.de', @@ -327,8 +335,75 @@ describe('Live rail helpers', () => { id: 'card-3', title: 'Fallback News', }), - ]) - ).toEqual(['ard.de', 'Fallback News']); + ], + () => [] + ); + + expect(groups).toHaveLength(2); + // 'ard.de' carries an explicit key, 'Fallback News' falls back to its + // title — different any-source eligibility, so different groups. + expect(groups[0].lookupKeys).toEqual(['ard.de']); + expect(groups[0].anySourceFallback).toBe(true); + expect(groups[1].lookupKeys).toEqual(['Fallback News']); + expect(groups[1].anySourceFallback).toBe(false); + expect(groups[0].sourceUrls).toEqual([]); + }); + + it('asks each XMLTV source scope separately and shares one lookup between playlists on the same guide', () => { + const groups = buildLiveEpgLookupGroups( + [ + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'a' }), + channelCard({ + id: 'card-2', + epgLookupKey: 'ard.de', + epgPlaylistId: 'b', + }), + // Same guide as playlist a: one question, shared answer. + channelCard({ + id: 'card-3', + epgLookupKey: 'zdf.de', + epgPlaylistId: 'a2', + }), + // No guide of its own: answered from Settings. + channelCard({ + id: 'card-4', + epgLookupKey: 'cnn.us', + epgPlaylistId: 'portal', + }), + ], + scopeFor({ a: guideA, a2: guideA, b: guideB }) + ); + + expect( + groups.map((group) => [group.sourceUrls, group.lookupKeys]) + ).toEqual([ + [guideA, ['ard.de', 'zdf.de']], + [guideB, ['ard.de']], + [[], ['cnn.us']], + ]); + }); + + it('never widens a portal card to every guide: its key is only a title', () => { + const groups = buildLiveEpgLookupGroups( + [ + // M3U: a real XMLTV key from the playlist. + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'm3u' }), + // Xtream/Stalker: no key at all, so the title stands in. + channelCard({ + id: 'card-2', + title: 'Das Erste HD', + epgPlaylistId: 'portal', + }), + ], + () => [] + ); + + expect( + groups.map((group) => [group.lookupKeys, group.anySourceFallback]) + ).toEqual([ + [['ard.de'], true], + [['Das Erste HD'], false], + ]); }); it('reads EPG programs by explicit lookup key instead of display title', () => { @@ -343,13 +418,65 @@ describe('Live rail helpers', () => { getLiveEpgProgramForCard( card, new Map([ - ['Das Erste HD', wrongProgram], - ['ard.de', program], - ]) + [liveEpgProgramKey('', 'Das Erste HD'), wrongProgram], + [liveEpgProgramKey('', 'ard.de'), program], + ]), + '' ) ).toBe(program); }); + it('never hands a card the programme another playlist resolved for the same XMLTV id', () => { + // `ard.de` is unique inside a guide, not across imports. + const fromGuideA = { title: 'Guide A bulletin' } as EpgProgram; + const fromGuideB = { title: 'Guide B bulletin' } as EpgProgram; + const epgMap = new Map([ + [ + liveEpgProgramKey(liveEpgScopeKey(guideA, true), 'ard.de'), + fromGuideA, + ], + [ + liveEpgProgramKey(liveEpgScopeKey(guideB, true), 'ard.de'), + fromGuideB, + ], + ]); + + expect( + getLiveEpgProgramForCard( + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'a' }), + epgMap, + liveEpgScopeKey(guideA, true) + ) + ).toBe(fromGuideA); + expect( + getLiveEpgProgramForCard( + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'b' }), + epgMap, + liveEpgScopeKey(guideB, true) + ) + ).toBe(fromGuideB); + // A scope with no answer stays empty instead of borrowing one. + expect( + getLiveEpgProgramForCard( + channelCard({ epgLookupKey: 'ard.de' }), + epgMap, + '' + ) + ).toBeNull(); + }); + + it('treats the same URL set as one scope whatever its order or duplicates', () => { + expect(liveEpgScopeKey(['b', 'a'], true)).toBe( + liveEpgScopeKey(['a', 'b', 'a'], true) + ); + expect(liveEpgScopeKey(['a'], true)).not.toBe( + liveEpgScopeKey(['a', 'b'], true) + ); + // A portal card and a guide-less M3U card both resolve against + // Settings, but their answers must not be interchangeable. + expect(liveEpgScopeKey([], true)).not.toBe(liveEpgScopeKey([], false)); + }); + it('uses honest, semantically named title keys for favorite and recent live rails', () => { expect(liveRailTitleKeyForSource('favorites')).toBe( 'WORKSPACE.DASHBOARD.LIVE_FAVORITES' diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 1b851edf3..118fe9ffc 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -7,11 +7,9 @@ import { signal, untracked, } from '@angular/core'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { interval, map, of, startWith, switchMap } from 'rxjs'; -import { EpgService } from '@iptvnator/epg/data-access'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { interval, map, startWith } from 'rxjs'; import { - type EpgProgram, isStalkerAccountPlaylist, isXtreamAccountPlaylist, normalizeDashboardRailsSettings, @@ -63,14 +61,8 @@ import type { import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; import type { DashboardHeroModel } from './dashboard-hero.utils'; import { resolveDashboardHeroArtwork } from './dashboard-hero.utils'; -import { - buildDashboardLiveEpgDetails, - buildLiveEpgCardsForEnabledRails, - buildLiveEpgLookupKeys, - getLiveEpgProgramForCard, - LIVE_EPG_TICK_MS, -} from './dashboard-live-epg.utils'; -import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils'; +import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils'; +import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter'; import { buildPlaybackPositionReloadKey, formatRemainingLabel, @@ -110,9 +102,11 @@ import type { host: { '[class.rails-page-host--empty]': 'ready() && !hasPlaylists()', }, + providers: [DashboardLiveEpgPresenter], }) export class WorkspaceDashboardRailsComponent { readonly data = inject(DashboardDataService); + private readonly liveEpg = inject(DashboardLiveEpgPresenter); private readonly dialog = inject(MatDialog); private readonly dialogService = inject(DialogService); private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); @@ -128,7 +122,6 @@ export class WorkspaceDashboardRailsComponent { { initialValue: null } ); private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS); - private readonly epgService = inject(EpgService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly heroTmdb = inject(DashboardHeroTmdbService); @@ -190,7 +183,7 @@ export class WorkspaceDashboardRailsComponent { const position = this.data.getPlaybackPositionForItem(item); const liveEpgDetails = item.type === 'live' - ? this.getLiveEpgDetailsForCard(this.heroLiveCard()) + ? this.liveEpg.detailsFor(this.heroLiveCard()) : null; const episodeBadge = item.type === 'series' && @@ -267,46 +260,21 @@ export class WorkspaceDashboardRailsComponent { }) ); - // Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain - // (tvg-id -> tvg-name -> name), with the card title as a final fallback. - // Xtream/Stalker live items often have no XMLTV side-channel and will - // simply return null — the card renders without the program row. - private readonly liveChannelLookupKeys = computed(() => { - const heroLiveCard = this.heroLiveCard(); - return buildLiveEpgLookupKeys( - buildLiveEpgCardsForEnabledRails( - this.dashboardRails(), - heroLiveCard, - this.liveFavoriteCards(), - this.recentLiveCards() - ) - ); - }); + // The live cards whose rails are enabled; the presenter looks their + // programmes up per XMLTV source scope. + private readonly enabledLiveCards = computed(() => + buildLiveEpgCardsForEnabledRails( + this.dashboardRails(), + this.heroLiveCard(), + this.liveFavoriteCards(), + this.recentLiveCards() + ) + ); private readonly playbackPositionReloadKey = computed(() => buildPlaybackPositionReloadKey(this.data.globalRecentVodItems()) ); - // Re-fetch on rail change AND on a 30s heartbeat so the progress bar - // catches the boundary between programs without a full page revisit. - private readonly liveEpgPrograms = toSignal( - toObservable(this.liveChannelLookupKeys).pipe( - switchMap((keys) => - keys.length === 0 - ? of(new Map()) - : interval(LIVE_EPG_TICK_MS).pipe( - startWith(0), - switchMap(() => - this.epgService.getCurrentProgramsForChannels( - keys - ) - ) - ) - ) - ), - { initialValue: new Map() } - ); - readonly liveFavoriteCardsEnriched = computed(() => this.enrichLiveCards(this.liveFavoriteCards()) ); @@ -418,6 +386,8 @@ export class WorkspaceDashboardRailsComponent { void this.data.reloadGlobalRecentItems(); void this.data.reloadGlobalFavorites(); + this.liveEpg.connect(this.enabledLiveCards); + // Refresh when Xtream playlist count changes so a newly added provider // populates the rail without a manual dashboard reload. The Xtream // recently-added query can be the slowest dashboard worker request on @@ -605,7 +575,7 @@ export class WorkspaceDashboardRailsComponent { cards: readonly DashboardRailCard[] ): DashboardRailCard[] { return cards.map((card) => { - const details = this.getLiveEpgDetailsForCard(card); + const details = this.liveEpg.detailsFor(card); if (!details) { return card; } @@ -613,22 +583,6 @@ export class WorkspaceDashboardRailsComponent { }); } - private getLiveEpgDetailsForCard( - card: DashboardRailCard | null - ): DashboardLiveEpgDetails | null { - if (!card) { - return null; - } - const program = getLiveEpgProgramForCard(card, this.liveEpgPrograms()); - // Recompute the now-window each tick so progress moves between - // 30s ticks even if the program identity is unchanged. - return buildDashboardLiveEpgDetails( - program, - Date.now(), - this.settingsStore.resolvedEpgOffsetMinutes() - ); - } - private buildNonLiveSeeAllState( cards: readonly DashboardRailCard[] ): Record { @@ -671,6 +625,7 @@ export class WorkspaceDashboardRailsComponent { icon: this.typeIcon(item.type), contentType: item.type, epgLookupKey: item.epg_lookup_key, + epgPlaylistId: item.playlist_id, link: this.data.getRecentItemLink(item), // Default click is detail-only for every card — an in-progress // series no longer auto-plays on click (issue #1441); resuming @@ -707,6 +662,7 @@ export class WorkspaceDashboardRailsComponent { icon: this.typeIcon(item.type), contentType: item.type, epgLookupKey: item.epg_lookup_key, + epgPlaylistId: item.playlist_id, link: this.data.getGlobalFavoriteLink(item), state: this.data.getGlobalFavoriteNavigationState(item), }; diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 26f1032a9..1afe8ffae 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -23,7 +23,6 @@ export const maxLinesBaseline = [ 'apps/web/src/app/services/electron.service.ts', 'apps/web/src/app/services/pwa.service.ts', 'apps/xtream-mock-server/src/app/generators/marketing.generator.ts', - 'libs/epg/data-access/src/lib/epg.service.ts', 'libs/m3u-state/src/lib/effects.ts', 'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts', 'libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts', From 4e575a810e5bd913bbe0688c691303aa4fd00db9 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:19:47 +0200 Subject: [PATCH 5/8] feat(dashboard): say where you left off instead of which provider it came from (#1646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing. The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered. Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding. Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero. Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch. Co-Authored-By: Claude Opus 5 --- .changes/dashboard-continue-watching-meta.md | 6 + CLAUDE.md | 2 +- .../global-collection-route.component.spec.ts | 45 ++ .../app/global-collection-route.component.ts | 7 +- docs/architecture/portal-detail-navigation.md | 35 +- docs/architecture/workspace-dashboard.md | 42 +- .../workspace-portal-navigation.spec.ts | 27 ++ .../navigation/workspace-portal-navigation.ts | 19 +- .../stalker-collection-detail.component.ts | 11 + .../stalker-series-resume.spec.ts | 218 +++++++++ .../stalker-series-resume.ts | 162 +++++++ .../stalker-series-view.component.ts | 114 ++++- .../stalker-series-view.resume.spec.ts | 418 ++++++++++++++++++ .../playlist-recently-viewed.utils.spec.ts | 92 ++++ .../src/lib/playlist-recently-viewed.utils.ts | 5 + .../src/lib/portal-activity-item.interface.ts | 37 ++ .../src/lib/dashboard-data.service.spec.ts | 97 ++++ .../src/lib/dashboard-data.service.ts | 57 +-- .../data-access/src/lib/dashboard-mappers.ts | 12 +- .../src/lib/dashboard-navigation.util.spec.ts | 63 ++- .../src/lib/dashboard-navigation.util.ts | 10 +- .../lib/rails/dashboard-rail.component.html | 65 +-- .../lib/rails/dashboard-rail.component.scss | 15 + .../rails/dashboard-rail.component.spec.ts | 29 ++ .../src/lib/rails/dashboard-rail.component.ts | 8 + .../workspace-dashboard-rails.component.ts | 78 ++-- 26 files changed, 1514 insertions(+), 160 deletions(-) create mode 100644 .changes/dashboard-continue-watching-meta.md create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts create mode 100644 libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts diff --git a/.changes/dashboard-continue-watching-meta.md b/.changes/dashboard-continue-watching-meta.md new file mode 100644 index 000000000..4c2525bd2 --- /dev/null +++ b/.changes/dashboard-continue-watching-meta.md @@ -0,0 +1,6 @@ +--- +type: feature +area: dashboard +--- + +The dashboard no longer labels every title with "Xtream · Series" or "Stalker · Movie": the hero shows only the source name, and Continue Watching cards show the episode you are on plus the minutes left. Stalker shows filed under Movies now get that S·E badge, the progress bar, and a working "Resume episode" that opens the show at the saved episode. diff --git a/CLAUDE.md b/CLAUDE.md index 2a2c2b99c..0d335504a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1574,7 +1574,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons; the dropdown's menu rows and closed trigger carry a 28×42 season thumbnail from the same `seasonPosters` map when that season has one — no placeholder when it does not, a failed image is dropped — while the pill row deliberately stays text-only) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker). The tabs sit in a season card with the selected season's own **season cover** on the left (`SeasonContainerComponent.seasonPosters`, keyed like the descriptions; TMDB-first: `tmdb_season_posters` written by the same lazy season enrichment as a `w342` `tmdbSeasonPosterUrl`, then Xtream's provider `seasons[].cover_big`/`cover` when it is a trimmed http(s) URL other than the show poster — `buildSeasonPosters` in `serial-details/season-posters.util.ts`; Stalker is TMDB-only via `StalkerSeriesTmdbSeasonsService.posters()`). Sized by `--season-cover-width`; the column is not rendered for one-season items, seasons without a poster, or a failed image, so those cases are today's markup. The hero poster never follows the season. The fullscreen episode panel shows the same poster as a season strip (poster + name + episode count, `PORTALS.EPISODE_COUNT_ONE/OTHER`) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates, and hands the same map to its tabs' dropdown - The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md` - Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite, `VodDetailsWatchedService`; Stalker: labelled button in the shared `app-vod-details`, both the routed catalog detail and the collection inline detail wire it). Both portals go through one helper, `createVodWatchedToggle` in `@iptvnator/portal/shared/util`: marking writes a full-progress `vod` position row (stored duration → provider `duration_secs` → 1 s fallback, since Stalker states no runtime), unmarking deletes the row and so forgets the resume point, both through the rejecting `*OrThrow` persistence boundary so the row on screen changes only after a confirmed write. The toggle is disabled while the movie plays inline or in an external session (the ~15 s position tick would overwrite the row), acts only on the route copy's row (a pinned multi-source alternative keeps its own), and a completion landing after navigation refreshes the catalog badges but neither patches the new page nor shows its snackbar. A watched copy shows Play, never "Resume" from its final seconds. Catalog cards on both portals derive their corner badge from one shared `PortalWatchState` (`resolvePortalWatchState` / `watchStateFromProgressPercent`, 90% threshold; `resolvePortalSeriesWatchState` reports a series as at most `in-progress`, because the list payload never carries the episode total). -- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only. +- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for a series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Xtream consumes it in `SerialDetailsPlaybackService` (`XTREAM_SERIES_RESUME_TARGET`); Stalker in `StalkerSeriesViewComponent` (`STALKER_SERIES_RESUME_TARGET`, provided by `StalkerCollectionDetailComponent`; a lazy Ministra season the target lives in is hydrated first, and an episode matched by coordinates rather than tracking id still resumes at the row's offset). "Series" here is the item's WATCH kind, not its routing type. The shape that forces the distinction is a Stalker embedded-VOD row: its stored entry announces episodes through a `series[]` array but carries no `is_series` flag, and `extractStalkerItemType` is deliberately blind to that array (the item must route to the VOD catalog), so it reports `type: 'movie'` while its progress is a set of episode rows. The mappers set `watch_kind: 'series'` on it and on a lazy Ministra `is_series` row (which `extractStalkerItemType` already types `series`, the flag being read) through `isStalkerSeriesItem`, and every dashboard reader — position lookup, S·E badge, resume/mark-watched actions — goes through `resolvePortalActivityWatchKind` (`libs/shared/interfaces`). Hero and card subtitles no longer name the provider kind or content kind: the hero shows only the source name (`playlistDisplayLabel`, since stored names carry URLs/MACs), Continue Watching cards show the S·E chip plus "N min left", favorites cards show the title alone, and Recently Added keeps the source name. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only. - See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout") **VOD Multi-Source** (alternative sources for a movie): diff --git a/apps/web/src/app/global-collection-route.component.spec.ts b/apps/web/src/app/global-collection-route.component.spec.ts index fb577cbfe..920a33e4d 100644 --- a/apps/web/src/app/global-collection-route.component.spec.ts +++ b/apps/web/src/app/global-collection-route.component.spec.ts @@ -17,9 +17,22 @@ class MockXtreamCollectionDetailComponent { readonly closeRequested = output(); } +@Component({ + selector: 'app-stalker-collection-detail', + template: '', +}) +class MockStalkerCollectionDetailComponent { + readonly item = input(null); + readonly seriesResume = input(null); + readonly closeRequested = output(); +} + jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({ XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent, })); +jest.unstable_mockModule('@iptvnator/portal/stalker/feature', () => ({ + StalkerCollectionDetailComponent: MockStalkerCollectionDetailComponent, +})); @Component({ imports: [GlobalCollectionDetailHostComponent], @@ -123,6 +136,38 @@ describe('GlobalCollectionDetailHostComponent', () => { expect(instance.seriesResume()).toEqual(seriesResume); }); + it('creates the Stalker detail with the series resume target attached', async () => { + // A Stalker embedded-VOD show routes as a movie; the resume target + // still has to reach the detail so the series view can consume it. + const seriesResume = { + seriesXtreamId: 17672, + contentXtreamId: 1879880442, + seasonNumber: 2, + episodeNumber: 6, + }; + fixture.componentInstance.seriesResume.set(seriesResume); + fixture.componentInstance.item.set({ + uid: 'stalker::stalker-1::17672', + name: 'Old School', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Portal', + stalkerId: '17672', + categoryId: '7', + }); + await stabilize(); + + const detail = fixture.debugElement.query( + By.directive(MockStalkerCollectionDetailComponent) + ); + expect(detail).not.toBeNull(); + const instance = + detail?.componentInstance as MockStalkerCollectionDetailComponent; + expect(instance.item()?.stalkerId).toBe('17672'); + expect(instance.seriesResume()).toEqual(seriesResume); + }); + it('re-emits close requests and clears the detail with the item', async () => { fixture.componentInstance.item.set(xtreamSeriesItem); await stabilize(); diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index 32542d89d..3a91a8dcf 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -113,9 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy { environmentInjector: this.environmentInjector, }); componentRef.setInput('item', item); - if (item.sourceType === 'xtream') { - componentRef.setInput('seriesResume', seriesResume); - } + // Both portal details accept the one-shot resume handoff (Xtream + // series, Stalker embedded-VOD / lazy is_series shows). + componentRef.setInput('seriesResume', seriesResume); this.subscribeToClose(componentRef.instance); } @@ -196,6 +196,7 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy { } @else if (item.sourceType === 'stalker') { } diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index ab49ff539..18c17f8c2 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -74,13 +74,27 @@ with a return handler keep Back available. Stalker movies/series into the matching global collection route with detail pre-opened. - The dashboard hero CTA and the Continue Watching cards' explicit "Resume - episode" ⋮ action for Xtream series carry a one-shot season/episode resume - target. The collection-owned Xtream detail consumes it after its episode - positions load and starts that exact episode; the cards' default click is - detail-only (movie-like), as is opening the series from the collection grid - itself. If the positions load fails, the target stays unconsumed and the - handoff degrades to detail-only rather than starting the episode at offset - zero. Continue Watching cards also expose "Mark as Watched" (maxes out the + episode" ⋮ action for Xtream and Stalker series carry a one-shot + season/episode resume target. The collection-owned detail consumes it after + its episode positions load and starts that exact episode; the cards' default + click is detail-only (movie-like), as is opening the series from the + collection grid itself. If the positions load fails, the target stays + unconsumed and the handoff degrades to detail-only rather than starting the + episode at offset zero. Xtream reads it from `XTREAM_SERIES_RESUME_TARGET`; + Stalker from `STALKER_SERIES_RESUME_TARGET`, provided by + `StalkerCollectionDetailComponent` and consumed by + `StalkerSeriesViewComponent` (`stalker-series-resume.ts`), which first + hydrates the lazy Ministra season the target lives in. The two Stalker + shapes differ in the handoff state: a lazy `is_series` row carries + `contentType: 'series'` (the flag is read by `extractStalkerItemType`) and + `resolveStalkerCollectionDetailMode` sends it through the VOD detail flow + without changing that type, while an embedded-VOD row — a `series[]` + episode array and no flag — carries `contentType: 'movie'`, because the + type resolver is deliberately blind to that array so the item keeps + routing to the VOD catalog. Only the detail, reading the stored row, can + tell the second shape from a real movie, so + `getOpenCollectionDetailItemState` keeps a resume target for any non-live + Stalker item, not only `contentType: 'series'`. Continue Watching cards also expose "Mark as Watched" (maxes out the tracked position row) and "Remove from history" in the same ⋮ menu. - Ready Download Manager cards open one of the three focused `downloads/:downloadId` routes. These local details hide the workspace @@ -227,9 +241,10 @@ Dashboard behavior to preserve: Xtream movie/series items into `/workspace/global-favorites` or `/workspace/global-recent` with collection detail pre-opened from navigation state. -- When an Xtream series recent has a saved episode position, the dashboard hero - and Continue Watching card should include that exact series/episode target in - the navigation state. It is a one-shot playback request and must not leak into +- When a series recent (Xtream, or a Stalker row that + `resolvePortalActivityWatchKind` answers `series` for) has a saved episode + position, the dashboard hero and Continue Watching card should include that + exact series/episode target in the navigation state. It is a one-shot playback request and must not leak into normal favorites, search, category, or collection-grid navigation. Only position rows that name their parent `seriesXtreamId` produce a target: episode-keyed recents make `item.xtream_id` an episode id, so legacy rows diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 81266e7e8..fe8525ce9 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -113,17 +113,29 @@ Render rules: 2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series cover cards. Portal playback positions are bulk-loaded per playlist so hero and cards can show progress, remaining time, and series season/ - episode badges. This includes Stalker VOD activity normalized to series - through `is_series`. Series lookup uses keyed maps for both direct - episode ids and parent series ids; card renders must not scan the full - playback-position map. The badge uses saved `seasonNumber` / + episode badges. Whether an item is looked up as a movie (one `vod` + row) or a series (episode rows under the parent id) is its WATCH + kind, `resolvePortalActivityWatchKind`, not its routing `type`. The + shape that needs the distinction is a Stalker embedded-VOD row: its + stored entry carries a `series[]` episode array but no `is_series` + flag, so `extractStalkerItemType` reports `movie` (deliberately — the + item belongs in the VOD catalog) while its progress lives in episode + rows. The mappers give both it and a lazy Ministra `is_series` row + (already typed `series`) `watch_kind: 'series'`. Series lookup uses + keyed maps for both direct episode ids and parent series ids; card + renders must not scan the full playback-position map. The badge uses saved `seasonNumber` / `episodeNumber` metadata and does not infer it from provider payloads; legacy rows without that metadata remain badge-less until replay. - Dashboard-originated Xtream series clicks also carry that exact episode - target through the global-recent inline-detail handoff. Once the series - metadata and playback positions load, the detail player consumes the - target once and resumes the saved episode. Opening the same item normally - from the global recent grid remains a detail-only action. + Dashboard-originated Xtream and Stalker series clicks also carry that + exact episode target through the global-recent inline-detail handoff. + Once the series metadata and playback positions load, the detail player + consumes the target once and resumes the saved episode. Opening the same + item normally from the global recent grid remains a detail-only action. + Continue Watching cards carry no provider/content-kind subtitle: their + meta row is the S·E chip plus a "N min left" label (`remainingLabel`, + from `formatRemainingLabel`), and the row is not rendered when both are + absent. The hero subtitle is the source name alone, through + `playlistDisplayLabel`. 3. `liveFavoriteCardsEnriched` and `recentLiveCardsEnriched` — two independent rails (`dashboard-live-favorites-rail` and `dashboard-recent-live-rail`); there is no fallback from one to the @@ -136,8 +148,9 @@ Render rules: which calls `getGlobalRecentlyAdded('all', limit, 'xtream')` with the DB-level `playlists.type = 'xtream'` filter. The rail is Electron-only (PWA returns `[]`) and auto-hides when empty, so users without Xtream - playlists never see it. Cards carry a `playlist_name · type` subtitle - so users can tell which provider each item came from. Driven by an + playlists never see it. Cards carry the source name as their subtitle + (`playlistDisplayLabel`) so users can tell where each item was added; + the content kind is not repeated on every card. Driven by an effect that re-runs whenever the Xtream playlist count changes, but the first run waits for `globalFavoritesLoaded()` so the slower recently-added DB query does not block the live favorites rail on @@ -217,9 +230,10 @@ The welcome state is rendered via the existing rails have data. 5. Navigation from a rail card must deep-link into the appropriate workspace route without switching the active playlist in the header switcher. -6. Xtream series hero/Continue Watching clicks with a saved episode position - must resume that exact episode while preserving the collection-owned detail - and Back behavior. Do not apply autoplay to ordinary collection-grid clicks. +6. Xtream and Stalker series hero/Continue Watching clicks with a saved episode + position must resume that exact episode while preserving the + collection-owned detail and Back behavior. Do not apply autoplay to + ordinary collection-grid clicks. 7. `Recently Used Sources` reflects recent source usage across all provider types, not just recent imports. 8. The live rail title key must match the rendered source: favorites use diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts index 8a0640f4b..426f221ff 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -258,6 +258,33 @@ describe('workspace-portal-navigation', () => { xtreamId: 55, }) ).toBeUndefined(); + + // …except for a Stalker VOD row, which may be an embedded-VOD or + // lazy is_series show that routes as a movie; the detail decides. + expect( + readResume(validResume, { + uid: 'stalker::stalker-1::17572', + name: 'Fake (10 episodes)', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Portal', + stalkerId: '17572', + categoryId: '7', + }) + ).toEqual(validResume); + expect( + readResume(validResume, { + uid: 'stalker::stalker-1::712', + name: 'Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Portal', + stalkerId: '712', + categoryId: 'itv', + }) + ).toBeUndefined(); }); it('omits the resume key when building detail state without a valid target', () => { diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 4e7970c0c..fe06778fa 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -88,12 +88,13 @@ export function getRecentItemNavigation( xtreamId: seriesResume.seriesXtreamId, } : collectionItem; + // The caller builds `seriesResume` only for items whose progress is + // per episode (see `resolvePortalActivityWatchKind`); a Stalker + // embedded-VOD row arrives here with the routing type `movie`. return buildGlobalCollectionDetailNavigationTarget( 'recent', detailItem, - item.type === 'series' && !options?.resumeIdentityOnly - ? seriesResume - : null + !options?.resumeIdentityOnly ? seriesResume : null ); } @@ -287,12 +288,18 @@ export function getOpenCollectionDetailItemState( candidate?.['seriesResume'] ); + // An Xtream series, or any Stalker VOD detail: a Stalker `movie` may + // still be an embedded-VOD show (a `series[]` array and no flag, which + // `extractStalkerItemType` reports as a movie on purpose), and only the + // detail — reading the stored row — can tell. A plain movie detail + // simply never mounts the series view. + const canResume = + item?.contentType === 'series' || + (item?.sourceType === 'stalker' && item.contentType !== 'live'); return item ? { item, - ...(item.contentType === 'series' && seriesResume - ? { seriesResume } - : {}), + ...(canResume && seriesResume ? { seriesResume } : {}), } : null; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 599159a6f..2034e9d63 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -25,6 +25,7 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, + SeriesResumeTarget, UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { @@ -44,6 +45,7 @@ import { import { Playlist, VodDetailsItem } from '@iptvnator/shared/interfaces'; import { firstValueFrom } from 'rxjs'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; +import { STALKER_SERIES_RESUME_TARGET } from './stalker-series-view/stalker-series-resume'; import { resolveStalkerCollectionDetailMode, resolveStalkerCollectionItem, @@ -101,6 +103,14 @@ import { provide: VIEW_IN_PORTAL_HANDOFF, useExisting: forwardRef(() => StalkerCollectionDetailComponent), }, + { + // The nested series view reads the one-shot resume handoff + // through this token, like the Xtream detail injector does. + provide: STALKER_SERIES_RESUME_TARGET, + useFactory: (host: StalkerCollectionDetailComponent) => + host.seriesResume, + deps: [forwardRef(() => StalkerCollectionDetailComponent)], + }, ], styles: [ ` @@ -115,6 +125,7 @@ import { }) export class StalkerCollectionDetailComponent implements ViewInPortalHandoff { readonly item = input(null); + readonly seriesResume = input(null); readonly closeRequested = output(); private readonly playlistsService = inject(PlaylistsService); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts new file mode 100644 index 000000000..46708c1be --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts @@ -0,0 +1,218 @@ +import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util'; +import type { + StalkerMappedEpisode, + VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import { + findStalkerResumeLazySeason, + MAX_RESUME_SEASON_HYDRATION_ATTEMPTS, + resolveStalkerResumeEpisode, + StalkerResumeSeasonHydration, + stalkerSeriesResumeKey, +} from './stalker-series-resume'; + +function episode( + overrides: Partial & { id: number } +): StalkerMappedEpisode { + return { + title: `Episode ${overrides.episode_num ?? 1}`, + container_extension: 'mpg', + custom_sid: 'vod-series', + added: '', + season: 1, + episode_num: 1, + direct_source: '', + info: {}, + ...overrides, + } as StalkerMappedEpisode; +} + +function season(overrides: Partial): VodSeriesSeasonVm { + return { + id: 'season-1', + video_id: '17572', + name: 'Season 1', + season_number: '1', + episodes: [], + isLoading: false, + isExpanded: false, + ...overrides, + }; +} + +const target: SeriesResumeTarget = { + seriesXtreamId: 17572, + contentXtreamId: 1750797722, + seasonNumber: 1, + episodeNumber: 5, +}; + +describe('resolveStalkerResumeEpisode', () => { + it('prefers the exact scoped tracking id the position was saved under', () => { + const scoped = episode({ id: 1750797722, season: 2, episode_num: 9 }); + const byCoordinates = episode({ id: 42, season: 1, episode_num: 5 }); + + expect( + resolveStalkerResumeEpisode({ + target, + episodesBySeason: { '1': [byCoordinates], '2': [scoped] }, + }) + ).toBe(scoped); + }); + + it('accepts the pre-scope legacy tracking id', () => { + const legacy = episode({ + id: 7, + legacyTrackingId: 1750797722, + season: 3, + episode_num: 1, + }); + + expect( + resolveStalkerResumeEpisode({ + target, + episodesBySeason: { '3': [legacy] }, + }) + ).toBe(legacy); + }); + + it('falls back to season/episode coordinates, including the provider season', () => { + const corrected = episode({ + id: 9, + season: 4, + providerSeasonNumber: 1, + episode_num: 5, + }); + + expect( + resolveStalkerResumeEpisode({ + target, + episodesBySeason: { + '4': [ + episode({ id: 8, season: 4, episode_num: 4 }), + corrected, + ], + }, + }) + ).toBe(corrected); + }); + + it('returns null while the episode is not on the page', () => { + expect( + resolveStalkerResumeEpisode({ + target, + episodesBySeason: { + '1': [episode({ id: 1, season: 1, episode_num: 1 })], + }, + }) + ).toBeNull(); + }); +}); + +describe('findStalkerResumeLazySeason', () => { + it('names the unhydrated season the target lives in', () => { + const seasons = [ + season({ id: 'season-1', season_number: '1' }), + season({ id: 'season-2', season_number: '2' }), + ]; + + expect(findStalkerResumeLazySeason({ target, seasons })?.id).toBe( + 'season-1' + ); + }); + + it('returns null once that season is loading, loaded, or answered empty', () => { + expect( + findStalkerResumeLazySeason({ + target, + seasons: [season({ isLoading: true })], + }) + ).toBeNull(); + expect( + findStalkerResumeLazySeason({ + target, + seasons: [season({ episodesLoaded: true })], + }) + ).toBeNull(); + expect( + findStalkerResumeLazySeason({ + target, + seasons: [ + season({ + episodes: [{ id: 'e1', series_number: 1 } as never], + }), + ], + }) + ).toBeNull(); + expect( + findStalkerResumeLazySeason({ + target: { ...target, seasonNumber: 3 }, + seasons: [season({})], + }) + ).toBeNull(); + }); +}); + +describe('StalkerResumeSeasonHydration', () => { + it('holds the claim while a request is in flight', () => { + const hydration = new StalkerResumeSeasonHydration(); + + expect(hydration.canRequest('a')).toBe(true); + hydration.begin('a'); + expect(hydration.canRequest('a')).toBe(false); + }); + + it('allows a bounded retry after a failed fetch, then gives up', () => { + // A failed fetch leaves the season unloaded and re-runs the effect, + // so an unbounded release would loop against a dead portal. + const hydration = new StalkerResumeSeasonHydration(); + + for ( + let attempt = 1; + attempt < MAX_RESUME_SEASON_HYDRATION_ATTEMPTS; + attempt++ + ) { + hydration.begin('a'); + hydration.settle('a', false); + expect(hydration.canRequest('a')).toBe(true); + } + + hydration.begin('a'); + hydration.settle('a', false); + expect(hydration.canRequest('a')).toBe(false); + }); + + it('stops asking once the portal answered', () => { + const hydration = new StalkerResumeSeasonHydration(); + + hydration.begin('a'); + hydration.settle('a', true); + + expect(hydration.canRequest('a')).toBe(false); + }); + + it('starts a different target fresh and ignores a late settlement', () => { + const hydration = new StalkerResumeSeasonHydration(); + + hydration.begin('a'); + hydration.begin('b'); + // 'a' settling late must not hand 'b' an extra attempt. + hydration.settle('a', false); + expect(hydration.canRequest('b')).toBe(false); + + hydration.settle('b', false); + expect(hydration.canRequest('b')).toBe(true); + expect(hydration.canRequest('a')).toBe(true); + }); +}); + +describe('stalkerSeriesResumeKey', () => { + it('identifies one handoff per playlist and coordinates', () => { + expect(stalkerSeriesResumeKey('stalker-1', target)).toBe( + 'stalker-1:17572:1750797722:1:5' + ); + expect(stalkerSeriesResumeKey('stalker-2', target)).not.toBe( + stalkerSeriesResumeKey('stalker-1', target) + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts new file mode 100644 index 000000000..a17021a17 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts @@ -0,0 +1,162 @@ +import { InjectionToken, Signal, signal, WritableSignal } from '@angular/core'; +import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util'; +import { + getVodSeriesSeasonNumber, + type StalkerMappedEpisode, + type VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; + +/** + * One-shot "resume this episode" handoff for a Stalker series, the + * counterpart of `XTREAM_SERIES_RESUME_TARGET`. Provided by the collection + * detail host that received the target through the global-recent inline + * detail state (dashboard hero CTA, Continue Watching ⋮ → Resume); the + * catalog route never provides it and falls back to the null signal. + */ +export const STALKER_SERIES_RESUME_TARGET = new InjectionToken< + Signal +>('STALKER_SERIES_RESUME_TARGET', { + factory: () => signal(null), +}); + +/** Identity of one handoff, so a target is consumed at most once per series. */ +export function stalkerSeriesResumeKey( + playlistId: string, + target: SeriesResumeTarget +): string { + return [ + playlistId, + target.seriesXtreamId, + target.contentXtreamId, + target.seasonNumber, + target.episodeNumber, + ].join(':'); +} + +/** + * The mapped episode a resume target names, or null while it is not on the + * page yet (lazy VOD season not hydrated) or does not exist. + * + * The exact tracking id wins — for lazy VOD that is the scoped id the + * position row was saved under — then the pre-scope legacy id, then the + * season/episode coordinates, which also cover embedded-VOD rows whose + * tracking id derives from the command string. + */ +export function resolveStalkerResumeEpisode(options: { + target: SeriesResumeTarget; + episodesBySeason: Readonly>; +}): XtreamSerieEpisode | null { + const { target } = options; + const episodes: StalkerMappedEpisode[] = []; + for (const seasonEpisodes of Object.values(options.episodesBySeason)) { + episodes.push(...(seasonEpisodes as StalkerMappedEpisode[])); + } + + return ( + episodes.find( + (episode) => Number(episode.id) === target.contentXtreamId + ) ?? + episodes.find( + (episode) => episode.legacyTrackingId === target.contentXtreamId + ) ?? + episodes.find( + (episode) => + Number(episode.episode_num) === target.episodeNumber && + (Number(episode.season) === target.seasonNumber || + episode.providerSeasonNumber === target.seasonNumber) + ) ?? + null + ); +} + +/** + * How many times one resume target may ask the portal for the lazy season + * it lives in. + * + * A failed fetch flips the season's `isLoading` back and leaves it + * unloaded, which re-runs the effect that asked — so releasing the claim on + * every failure would hammer a portal that is simply down, while holding it + * forever strands the handoff after a single transient error. Two attempts + * buys the recovery and then stops: the detail is open either way, and its + * "Resume episode" button hydrates the same season on demand. + */ +export const MAX_RESUME_SEASON_HYDRATION_ATTEMPTS = 2; + +/** + * The lazy-season hydration a resume target has asked for, so the owning + * effect can tell "already in flight" from "failed, may try once more" from + * "given up". Scoped to one series view; a different target starts fresh. + */ +export class StalkerResumeSeasonHydration { + /** + * A signal, not a plain field: nothing else changes when a fetch fails + * (the season's own `isLoading` has already flipped back by then), so a + * reader effect would never re-run to spend the retry. + */ + private readonly claim: WritableSignal<{ + key: string; + attempts: number; + pending: boolean; + } | null> = signal(null); + + /** May this target request its season now? */ + canRequest(key: string): boolean { + const claim = this.claim(); + if (!claim || claim.key !== key) { + return true; + } + return ( + !claim.pending && + claim.attempts < MAX_RESUME_SEASON_HYDRATION_ATTEMPTS + ); + } + + /** Claim the request before dispatching it. */ + begin(key: string): void { + const claim = this.claim(); + const attempts = claim?.key === key ? claim.attempts : 0; + this.claim.set({ key, attempts: attempts + 1, pending: true }); + } + + /** + * Record how the request ended. A late settlement for a target that is + * no longer the one being hydrated is ignored, so it cannot hand a + * retry to whatever claimed the slot after it. + */ + settle(key: string, answered: boolean): void { + const claim = this.claim(); + if (!claim || claim.key !== key) { + return; + } + this.claim.set({ + key, + attempts: answered + ? MAX_RESUME_SEASON_HYDRATION_ATTEMPTS + : claim.attempts, + pending: false, + }); + } +} + +/** + * For a lazy Ministra VOD series: the not-yet-hydrated season the target + * lives in, so the host can fetch it before the episode can be resolved. + * Null once that season is loaded, loading, or unknown. + */ +export function findStalkerResumeLazySeason(options: { + target: SeriesResumeTarget; + seasons: ReadonlyArray; +}): VodSeriesSeasonVm | null { + const { target, seasons } = options; + return ( + seasons.find( + (season) => + !season.episodesLoaded && + !season.isLoading && + season.episodes.length === 0 && + getVodSeriesSeasonNumber(season, seasons) === + target.seasonNumber + ) ?? null + ); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 1a4a7773f..417b8f293 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -17,6 +17,13 @@ import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component'; import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; +import { + findStalkerResumeLazySeason, + resolveStalkerResumeEpisode, + STALKER_SERIES_RESUME_TARGET, + StalkerResumeSeasonHydration, + stalkerSeriesResumeKey, +} from './stalker-series-resume'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -220,6 +227,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly rawSeriesPositions = signal< readonly PlaybackPositionData[] >([]); + /** + * `playlistId:seriesId` once the persisted positions for the shown + * series have been READ (a failed read leaves it null): the dashboard + * resume handoff must not start an episode from the beginning because + * the offsets have not arrived yet. + */ + private readonly seriesPositionsLoadedKey = signal(null); + private readonly seriesResumeTarget = inject(STALKER_SERIES_RESUME_TARGET); + private consumedSeriesResumeKey: string | null = null; + private readonly seriesResumeSeasonHydration = + new StalkerResumeSeasonHydration(); private readonly legacyPositionByTrackingId = signal< Map >(new Map()); @@ -417,6 +435,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { isSeries: item.is_series, }); this.rawSeriesPositions.set([]); + this.seriesPositionsLoadedKey.set(null); this.episodePlaybackPositions.set(new Map()); this.legacyPositionByTrackingId.set(new Map()); const context = this.activateSeriesPositionContext( @@ -435,6 +454,78 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.applyReconciledSeriesPositions(); }); + // Dashboard "Continue watching" handoff: once the persisted + // positions for THIS series are in (so the episode resumes at its + // saved offset, not from zero) and the target episode is on the + // page, play it exactly once. A lazy Ministra season the target + // lives in is hydrated first; the effect re-runs when its episodes + // land. Mirrors the Xtream serial-details resume effect. + effect(() => { + const target = this.seriesResumeTarget(); + const item = this.displayItem(); + const playlistId = this.stalkerStore.currentPlaylist()?._id; + const seriesXtreamId = this.toSeriesId(item?.id ?? 0); + const episodesBySeason = this.mappedSeasons(); + const seasons = this.vodSeriesSeasons(); + // Read so the effect re-runs after reconciliation, which is + // what `onEpisodeClicked` takes the start offset from. + this.episodePlaybackPositions(); + if ( + !target || + !item || + !playlistId || + seriesXtreamId <= 0 || + target.seriesXtreamId !== seriesXtreamId || + this.seriesPositionsLoadedKey() !== + this.seriesPositionsKey(playlistId, seriesXtreamId) + ) { + return; + } + + const resumeKey = stalkerSeriesResumeKey(playlistId, target); + if (this.consumedSeriesResumeKey === resumeKey) { + return; + } + + const episode = resolveStalkerResumeEpisode({ + target, + episodesBySeason, + }); + if (episode) { + this.consumedSeriesResumeKey = resumeKey; + // Reconciliation attaches positions by exact or legacy + // tracking id only; an episode found by coordinates still + // resumes at the offset the dashboard card displayed. + const savedOffset = this.rawSeriesPositions().find( + (position) => + position.contentXtreamId === target.contentXtreamId + )?.positionSeconds; + untracked(() => this.onEpisodeClicked(episode, savedOffset)); + return; + } + + const lazySeason = this.isVodSeries() + ? findStalkerResumeLazySeason({ target, seasons }) + : null; + if ( + !lazySeason || + !this.seriesResumeSeasonHydration.canRequest(resumeKey) + ) { + return; + } + this.seriesResumeSeasonHydration.begin(resumeKey); + untracked( + () => + void this.loadEpisodesForSeason(lazySeason).then( + (answered) => + this.seriesResumeSeasonHydration.settle( + resumeKey, + answered + ) + ) + ); + }); + effect(() => { const session = this.externalPlayback.activeSession(); const item = this.displayItem(); @@ -857,7 +948,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { /** * Handles episode click from the container */ - onEpisodeClicked(episode: XtreamSerieEpisode) { + /** + * `startTimeOverride` lets the dashboard resume handoff carry the saved + * offset for an episode whose position row the page could not attach + * (matched by coordinates only), so it resumes where the card said. + */ + onEpisodeClicked(episode: XtreamSerieEpisode, startTimeOverride?: number) { const item = this.displayItem(); const episodeState = resolveSelectedStalkerEpisodeState({ episodesBySeason: this.mappedSeasons(), @@ -876,7 +972,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { : item.info.name; const trackingId = Number(mappedEpisode.id); const startTime = - this.episodePlaybackPositions().get(trackingId)?.positionSeconds; + this.episodePlaybackPositions().get(trackingId)?.positionSeconds ?? + startTimeOverride; void this.startPlayback( command, @@ -1552,11 +1649,24 @@ export class StalkerSeriesViewComponent implements OnDestroy { } this.rawSeriesPositions.set(positions); + this.seriesPositionsLoadedKey.set( + this.seriesPositionsKey( + context.playlistId, + context.seriesXtreamId + ) + ); } finally { this.untrackPendingSeriesPositionLoad(context, generation); } } + private seriesPositionsKey( + playlistId: string, + seriesXtreamId: number + ): string { + return `${playlistId}:${seriesXtreamId}`; + } + private activateSeriesPositionContext( playlistId: string, seriesXtreamId: number diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts new file mode 100644 index 000000000..8c7aa38d6 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts @@ -0,0 +1,418 @@ +import { EMPTY, of } from 'rxjs'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + type SeriesResumeTarget, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerVodSource, +} from '@iptvnator/portal/stalker/data-access'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { STALKER_SERIES_RESUME_TARGET } from './stalker-series-resume'; + +/** + * Dashboard "Continue watching" handoff into the Stalker series view: the + * one-shot resume target provided through `STALKER_SERIES_RESUME_TARGET`. + */ +describe('StalkerSeriesViewComponent dashboard resume handoff', () => { + let fixture: ComponentFixture; + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasons = signal([]); + const vodSeasons = signal([]); + const seriesResumeTarget = signal(null); + const resolveVodPlayback = jest.fn(); + const fetchVodSeriesEpisodes = jest.fn(); + const getSeriesPlaybackPositions = jest.fn(); + const openResolvedPlayback = jest.fn(); + const currentPlaylist = signal({ + _id: 'stalker-1', + title: 'Portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + + async function stabilize(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + } + + /** Runs the effect chain until the async position read has landed. */ + async function settle(): Promise { + await stabilize(); + await stabilize(); + await stabilize(); + } + + function trackingIdOf(seasonKey: string, episodeNum: number): number { + return Number( + fixture.componentInstance + .mappedSeasons() + [seasonKey]?.find( + (episode) => Number(episode.episode_num) === episodeNum + )?.id + ); + } + + beforeEach(async () => { + selectedContentType.set('series'); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { name: 'Regular Series', movie_image: 'poster.jpg' }, + }); + serialSeasons.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + vodSeasons.set([]); + seriesResumeTarget.set(null); + resolveVodPlayback + .mockReset() + .mockImplementation( + async ( + _command: string, + title: string, + thumbnail: string, + _episodeNumber: number, + episodeId: number, + startTime?: number + ) => ({ + streamUrl: 'https://resolved.example/episode.mpg', + title, + thumbnail, + startTime, + contentInfo: { + playlistId: 'stalker-1', + contentXtreamId: episodeId, + contentType: 'episode', + seriesXtreamId: Number(selectedItem()?.id ?? 0), + }, + }) + ); + fetchVodSeriesEpisodes.mockReset(); + getSeriesPlaybackPositions.mockReset().mockResolvedValue([]); + openResolvedPlayback.mockReset(); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist, + getSerialSeasonsResource: () => serialSeasons(), + getVodSeriesSeasonsResource: () => vodSeasons(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes, + resolveVodPlayback, + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => false, + openResolvedPlayback, + }, + }, + { + provide: STALKER_SERIES_RESUME_TARGET, + useValue: seriesResumeTarget, + }, + { provide: Router, useValue: { navigateByUrl: jest.fn() } }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn().mockResolvedValue(null), + getSeasonEpisodes: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => fixture.destroy()); + + it('resumes the handoff episode at its saved offset once the series positions are read', async () => { + // Position saved under the episode's own tracking id — resolved + // lazily, since a regular-series id hashes the season command. + getSeriesPlaybackPositions.mockImplementation(async () => [ + { + playlistId: 'stalker-1', + contentXtreamId: trackingIdOf('1', 2), + contentType: 'episode', + seriesXtreamId: 30001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 300, + durationSeconds: 1800, + } satisfies PlaybackPositionData, + ]); + await stabilize(); + seriesResumeTarget.set({ + seriesXtreamId: 30001, + contentXtreamId: trackingIdOf('1', 2), + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_30001.mpg', + 'Regular Series', + 'poster.jpg', + 2, + trackingIdOf('1', 2), + 300 + ); + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + + // One-shot: further change detection never replays the handoff. + await settle(); + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + }); + + it('ignores a handoff that names another series', async () => { + seriesResumeTarget.set({ + seriesXtreamId: 777, + contentXtreamId: 1, + seasonNumber: 1, + episodeNumber: 1, + }); + + await settle(); + + expect(resolveVodPlayback).not.toHaveBeenCalled(); + }); + + it('does nothing when the positions read failed, so the episode never restarts from zero', async () => { + getSeriesPlaybackPositions.mockRejectedValue(new Error('db down')); + seriesResumeTarget.set({ + seriesXtreamId: 30001, + contentXtreamId: 1, + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + + expect(resolveVodPlayback).not.toHaveBeenCalled(); + }); + + it('retries the lazy season once after a transient portal failure', async () => { + // A failed fetch leaves the season unloaded, so the handoff would + // otherwise be stranded for the lifetime of this detail host. + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { name: 'VOD Flagged Series', movie_image: 'vod.jpg' }, + }); + serialSeasons.set([]); + vodSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + ]); + fetchVodSeriesEpisodes + .mockRejectedValueOnce(new Error('portal down')) + .mockResolvedValue([ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + { id: 'episode-2', series_number: 2, name: 'Second' }, + ]); + getSeriesPlaybackPositions.mockResolvedValue([ + { + playlistId: 'stalker-1', + contentXtreamId: 123_456, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 90, + durationSeconds: 1500, + } satisfies PlaybackPositionData, + ]); + seriesResumeTarget.set({ + seriesXtreamId: 50001, + contentXtreamId: 123_456, + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + await settle(); + await settle(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-2.mpg', + 'VOD Flagged Series - Second', + 'vod.jpg', + 2, + trackingIdOf('1', 2), + 90 + ); + }); + + it('stops asking the portal after the bounded retry also fails', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { name: 'VOD Flagged Series', movie_image: 'vod.jpg' }, + }); + serialSeasons.set([]); + vodSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + ]); + fetchVodSeriesEpisodes.mockRejectedValue(new Error('portal down')); + getSeriesPlaybackPositions.mockResolvedValue([ + { + playlistId: 'stalker-1', + contentXtreamId: 123_456, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 90, + durationSeconds: 1500, + } satisfies PlaybackPositionData, + ]); + seriesResumeTarget.set({ + seriesXtreamId: 50001, + contentXtreamId: 123_456, + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + await settle(); + await settle(); + await settle(); + + // Bounded: a portal that is simply down is asked twice, not forever. + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).not.toHaveBeenCalled(); + }); + + it('hydrates the lazy VOD season the handoff lives in, then resumes its episode', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { name: 'VOD Flagged Series', movie_image: 'vod.jpg' }, + }); + serialSeasons.set([]); + vodSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + ]); + fetchVodSeriesEpisodes.mockResolvedValue([ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + { id: 'episode-2', series_number: 2, name: 'Second' }, + ]); + // A pre-scope row: its id matches no minted tracking id, so the + // episode is found by coordinates and the offset still comes from + // the row the dashboard card was built from. + getSeriesPlaybackPositions.mockResolvedValue([ + { + playlistId: 'stalker-1', + contentXtreamId: 123_456, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 90, + durationSeconds: 1500, + } satisfies PlaybackPositionData, + ]); + seriesResumeTarget.set({ + seriesXtreamId: 50001, + contentXtreamId: 123_456, + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + await settle(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( + '50001', + 'season-1' + ); + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-2.mpg', + 'VOD Flagged Series - Second', + 'vod.jpg', + 2, + trackingIdOf('1', 2), + 90 + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts new file mode 100644 index 000000000..0528ed64b --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts @@ -0,0 +1,92 @@ +import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils'; +import { resolvePortalActivityWatchKind } from './portal-activity-item.interface'; +import type { PlaylistMeta } from './playlist-meta.type'; + +const labels = { stalker: 'Stalker', m3u: 'M3U' }; + +function stalkerPlaylist(recentlyViewed: unknown[]): PlaylistMeta { + return { + _id: 'stalker-1', + title: 'Portal', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + recentlyViewed, + } as unknown as PlaylistMeta; +} + +describe('buildPlaylistRecentItems (Stalker watch kind)', () => { + it('keeps embedded-VOD shows routing as movies while tracking them as series', () => { + // Real stored shape: no `is_series`, numeric VOD category, and the + // `series[]` episode array the portal answered with. + const [item] = buildPlaylistRecentItems( + [ + stalkerPlaylist([ + { + id: '17572', + title: 'Fake (10 episodes)', + category_id: '7', + series: [1, 2, 3], + added_at: '2026-09-19T16:13:50.000Z', + }, + ]), + ], + labels + ); + + expect(item.type).toBe('movie'); + expect(item.watch_kind).toBe('series'); + expect(resolvePortalActivityWatchKind(item)).toBe('series'); + }); + + it('marks lazy Ministra is_series rows as series-tracked too', () => { + const [item] = buildPlaylistRecentItems( + [ + stalkerPlaylist([ + { + id: '50001', + title: 'Flagged', + category_id: 'vod', + is_series: '1', + added_at: '2026-09-19T16:13:50.000Z', + }, + ]), + ], + labels + ); + + expect(item.type).toBe('series'); + expect(item.watch_kind).toBe('series'); + }); + + it('leaves plain movies and live channels without a watch kind override', () => { + const items = buildPlaylistRecentItems( + [ + stalkerPlaylist([ + { + id: '900', + title: 'A Movie', + category_id: '7', + series: [], + added_at: '2026-09-19T16:13:50.000Z', + }, + { + id: '712', + title: 'Channel', + category_id: 'itv', + added_at: '2026-09-19T14:49:22.000Z', + }, + ]), + ], + labels + ); + + expect(items.map((item) => item.watch_kind)).toEqual([ + undefined, + undefined, + ]); + expect(resolvePortalActivityWatchKind(items[0])).toBe('movie'); + expect(resolvePortalActivityWatchKind(items[1])).toBeNull(); + }); +}); diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts index 1ce60d2ee..2c539ca6e 100644 --- a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts @@ -6,6 +6,7 @@ import { extractStalkerItemPoster, extractStalkerItemTitle, extractStalkerItemType, + isStalkerSeriesItem, normalizeStalkerDate, } from './stalker-item.normalizer'; import { @@ -59,6 +60,10 @@ function mapStalkerPlaylistRecentItems( backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl, source: 'stalker', stalker_item: rawItem, + // `type` stays the routing verdict: an embedded-VOD row + // (a `series[]` array, no flag) routes as a movie. Either + // shape tracks progress per episode, keyed by this parent id. + ...(isStalkerSeriesItem(item) ? { watch_kind: 'series' } : {}), }); return acc; diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index 79f808bb3..d66c6c907 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -47,6 +47,43 @@ export interface PortalActivityItem { source?: 'xtream' | 'stalker' | 'm3u'; /** Original stalker item for navigation state; undefined for xtream. */ stalker_item?: StalkerPortalItem; + /** + * How the item's watch progress is tracked, when that differs from the + * catalog section `type` routes it to. + * + * The case that needs it is a Stalker embedded-VOD row: its stored entry + * announces episodes through a `series[]` array, but `extractStalkerItemType` + * is deliberately blind to that array so the item keeps routing to the VOD + * catalog, and it therefore reports `type: 'movie'` while its progress is a + * set of EPISODE positions keyed by the parent id. (A lazy Ministra + * `is_series` row already reports `series` — the flag is read — so there + * this field only restates the type.) + * + * Readers that decide between a movie position and a series position go + * through `resolvePortalActivityWatchKind`, never `type` alone. Absent + * means "same as `type`". + */ + watch_kind?: PortalActivityWatchKind; +} + +/** Progress model of a VOD activity item: one position, or per-episode positions. */ +export type PortalActivityWatchKind = 'movie' | 'series'; + +/** + * The progress model a dashboard/collection reader should use for an item: + * the explicit `watch_kind` when a mapper recorded one, else the catalog + * `type`. Live items have no progress and resolve to `null`. + */ +export function resolvePortalActivityWatchKind( + item: Pick +): PortalActivityWatchKind | null { + if (item.watch_kind === 'movie' || item.watch_kind === 'series') { + return item.watch_kind; + } + if (item.type === 'movie' || item.type === 'series') { + return item.type; + } + return null; } /** A recently-viewed item with a `viewed_at` timestamp. */ diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index acbd1ed89..e6a2bf911 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -1066,6 +1066,103 @@ describe('DashboardDataService', () => { ); }); + it('resolves episode progress for a Stalker embedded-VOD show stored without is_series', async () => { + // Real stored shape (RUcolor-style portal): the recent entry keeps + // the `series[]` episode array and a numeric VOD category, but no + // `is_series` flag. It must keep routing as a movie while its + // progress, badge, and resume handoff come from the episode rows + // saved under the parent id. + playlistsSignal.set([ + ...createDefaultPlaylists(), + { + _id: 'stalker-embedded', + title: 'RUcolor.tv', + count: 1, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + macAddress: '00:11:22:33:44:55', + recentlyViewed: [ + { + id: '17572', + title: 'Fake (10 episodes)', + category_id: '7', + cmd: '/media/17572.mpg', + series: [1, 2, 3, 4, 5, 6, 7, 8], + added_at: '2026-09-19T16:13:50.000Z', + }, + ], + }, + ]); + playbackPositionsMock.getAllPlaybackPositions.mockImplementation( + async (playlistId: string) => + playlistId === 'stalker-embedded' + ? [ + { + playlistId, + contentXtreamId: 1750797719, + contentType: 'episode', + seriesXtreamId: 17572, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 2673, + durationSeconds: 2761, + updatedAt: '2026-09-17T10:55:35.000Z', + }, + { + playlistId, + contentXtreamId: 1750797722, + contentType: 'episode', + seriesXtreamId: 17572, + seasonNumber: 1, + episodeNumber: 5, + positionSeconds: 2306, + durationSeconds: 2920, + updatedAt: '2026-09-18T21:45:56.000Z', + }, + ] + : [] + ); + + await service.reloadPlaybackPositions(); + + const item = service + .globalRecentItems() + .find((recent) => recent.playlist_id === 'stalker-embedded'); + if (!item) { + throw new Error('expected the Stalker embedded-series recent item'); + } + + expect(item.type).toBe('movie'); + expect(item.watch_kind).toBe('series'); + expect(service.getPlaybackPositionForItem(item)).toEqual( + expect.objectContaining({ + contentType: 'episode', + seasonNumber: 1, + episodeNumber: 5, + positionSeconds: 2306, + }) + ); + expect(service.getRecentItemResumeNavigation(item)).toEqual( + expect.objectContaining({ + link: ['/workspace', 'global-recent'], + state: { + openCollectionDetailItem: expect.objectContaining({ + item: expect.objectContaining({ + sourceType: 'stalker', + contentType: 'movie', + }), + seriesResume: { + seriesXtreamId: 17572, + contentXtreamId: 1750797722, + seasonNumber: 1, + episodeNumber: 5, + }, + }), + }, + }) + ); + }); + it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => { dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index cdd47daf2..4471d1e2b 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -36,11 +36,11 @@ import { PortalActivityType, PortalFavoriteItem, PortalRecentItem, + resolvePortalActivityWatchKind, stalkerItemMatchesId, } from '@iptvnator/shared/interfaces'; import { buildStalkerFavoriteItems, - getActivityTypeLabelKey, mapDbFavoriteToItem, mapDbRecentlyAddedToItem, mapDbRecentToItem, @@ -293,7 +293,11 @@ export class DashboardDataService { getPlaybackPositionForItem( item: PortalActivityItem ): PlaybackPositionData | null { - if (item.type !== 'movie' && item.type !== 'series') { + // The progress model, not the routing type: a Stalker embedded-VOD + // row routes as a movie but tracks episodes under its parent id, + // and a `vod` lookup for it finds nothing. + const watchKind = resolvePortalActivityWatchKind(item); + if (!watchKind) { return null; } const xtreamId = @@ -304,7 +308,7 @@ export class DashboardDataService { return null; } - if (item.type === 'movie') { + if (watchKind === 'movie') { const key = playbackPositionMapKey( item.playlist_id, xtreamId, @@ -883,11 +887,6 @@ export class DashboardDataService { return this.translateText('WORKSPACE.DASHBOARD.PROVIDER'); } - getRecentItemTypeLabel(item: GlobalRecentItem): string { - this.languageTick(); - return this.translateText(getActivityTypeLabelKey(item.type)); - } - getRecentItemLink(item: GlobalRecentItem): string[] { return getRecentItemLinkUtil(item); } @@ -1002,48 +1001,6 @@ export class DashboardDataService { } } - getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string { - return this.getActivityItemProviderLabel(item); - } - - getRecentlyAddedItemProviderLabel( - item: DashboardRecentlyAddedItem - ): string { - return this.getActivityItemProviderLabel(item); - } - - private getActivityItemProviderLabel( - item: Pick - ): string { - this.languageTick(); - - if (item.source === 'stalker') { - return this.translateText('WORKSPACE.DASHBOARD.STALKER'); - } - if (item.source === 'xtream') { - return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); - } - if (item.source === 'm3u') { - return this.translateText('WORKSPACE.DASHBOARD.M3U'); - } - return this.translateText('WORKSPACE.DASHBOARD.PROVIDER'); - } - - getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string { - return this.getActivityItemTypeLabel(item); - } - - getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string { - return this.getActivityItemTypeLabel(item); - } - - private getActivityItemTypeLabel( - item: Pick - ): string { - this.languageTick(); - return this.translateText(getActivityTypeLabelKey(item.type)); - } - getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] { return getGlobalFavoriteLinkUtil(item); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index 6330e83f8..be0cc61d6 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -16,6 +16,7 @@ import { extractStalkerItemTitle, extractStalkerItemTmdbHints, extractStalkerItemType, + isStalkerSeriesItem, normalizeStalkerDate, } from '@iptvnator/shared/interfaces'; @@ -25,12 +26,6 @@ export function normalizeActivityType(value: string): PortalActivityType { return value === 'live' || value === 'series' ? value : 'movie'; } -export function getActivityTypeLabelKey(type: PortalActivityType): string { - if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE'; - if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES'; - return 'WORKSPACE.DASHBOARD.TYPE_MOVIE'; -} - // ────── Xtream DB → ViewModel ────── /** @@ -146,6 +141,11 @@ export function buildStalkerFavoriteItems( backdrop_url: extractStalkerItemTmdbHints(raw).backdropUrl, source: 'stalker' as const, stalker_item: item, + // Same split as the recent-items mapper: routing type + // vs. per-episode progress model. + ...(isStalkerSeriesItem(raw) + ? { watch_kind: 'series' as const } + : {}), } as PortalFavoriteItem; }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts index 8e16e73d4..79942a9a2 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts @@ -37,7 +37,13 @@ function episodePosition( type RecentNavigationState = { openCollectionDetailItem?: { - item?: { uid?: string; xtreamId?: number }; + item?: { + uid?: string; + xtreamId?: number; + sourceType?: string; + contentType?: string; + stalkerId?: string; + }; seriesResume?: unknown; }; }; @@ -124,13 +130,64 @@ describe('getRecentItemResumeNavigation', () => { ).toBeNull(); }); - it('returns null for non-Xtream sources and missing positions', () => { + it('returns null for M3U sources and missing positions', () => { expect( getRecentItemResumeNavigation( - { ...recentSeries, source: 'stalker' }, + { ...recentSeries, source: 'm3u' }, episodePosition() ) ).toBeNull(); expect(getRecentItemResumeNavigation(recentSeries, null)).toBeNull(); }); + + it('resumes a Stalker embedded-VOD show that routes as a movie', () => { + // The stored row lives in the VOD catalog (`type: 'movie'` keeps it + // routing there) but tracks per-episode progress under its parent id. + const stalkerShow: PortalRecentItem = { + id: '17572', + title: 'Fake (10 episodes)', + type: 'movie', + watch_kind: 'series', + source: 'stalker', + playlist_id: 'stalker-R', + category_id: '7', + xtream_id: '17572', + viewed_at: '2026-09-19T16:13:50.000Z', + stalker_item: { id: '17572', series: [1, 2, 3] } as never, + }; + const navigation = getRecentItemResumeNavigation( + stalkerShow, + episodePosition({ + playlistId: 'stalker-R', + seriesXtreamId: 17572, + contentXtreamId: 1750797722, + seasonNumber: 1, + episodeNumber: 5, + }) + ); + + expect(navigation?.link).toEqual(['/workspace', 'global-recent']); + const state = navigation?.state as RecentNavigationState; + expect(state.openCollectionDetailItem?.item).toEqual( + expect.objectContaining({ + sourceType: 'stalker', + contentType: 'movie', + stalkerId: '17572', + }) + ); + expect(state.openCollectionDetailItem?.seriesResume).toEqual({ + seriesXtreamId: 17572, + contentXtreamId: 1750797722, + seasonNumber: 1, + episodeNumber: 5, + }); + + // Without the watch kind the same row is a plain movie: no handoff. + expect( + getRecentItemResumeNavigation( + { ...stalkerShow, watch_kind: undefined }, + episodePosition({ seriesXtreamId: 17572 }) + ) + ).toBeNull(); + }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts index 56b025258..eeecaec1f 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts @@ -5,6 +5,7 @@ import { PortalAddedItem, PortalFavoriteItem, PortalRecentItem, + resolvePortalActivityWatchKind, } from '@iptvnator/shared/interfaces'; import { buildStalkerDetailNavigationTarget, @@ -92,7 +93,7 @@ export function getRecentItemDetailNavigationState( /** * Full navigation target carrying the one-shot resume handoff, or null when - * the item/position cannot produce one (non-Xtream, non-series, watched or + * the item/position cannot produce one (M3U, non-series, watched or * coordinate-less rows). Powers the card's explicit "Resume episode" action. */ export function getRecentItemResumeNavigation( @@ -127,9 +128,12 @@ function buildRecentSeriesIdentityTarget( item: PortalRecentItem, playbackPosition?: PlaybackPositionData | null ): SeriesResumeTarget | null { + // Watch kind, not `type`: a Stalker embedded-VOD row routes as a movie + // yet resumes an episode (the detail decides the mode from the stored + // item, so the collection item keeps its routing type). if ( - item.type !== 'series' || - item.source !== 'xtream' || + resolvePortalActivityWatchKind(item) !== 'series' || + (item.source !== 'xtream' && item.source !== 'stalker') || playbackPosition?.contentType !== 'episode' ) { return null; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index cfe91881b..d43ede1b6 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -193,32 +193,45 @@ {{ cardTitle(card) }} - - @if (card.episodeBadge) { - {{ - card.episodeBadge - }} - } - @if (card.subtitle) { - {{ - card.subtitle - }} - } - @if (card.expiryBadge; as expiry) { - {{ expiry.label }} - } - + @if ( + card.episodeBadge || + card.remainingLabel || + card.subtitle || + card.expiryBadge + ) { + + @if (card.episodeBadge) { + {{ + card.episodeBadge + }} + } + @if (card.remainingLabel; as remaining) { + {{ + remaining.key + | translate: remaining.params + }} + } + @if (card.subtitle) { + {{ + card.subtitle + }} + } + @if (card.expiryBadge; as expiry) { + {{ expiry.label }} + } + + } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index b7fb55c6b..55d04266f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -404,6 +404,21 @@ color: var(--app-expiry-expired-text, #b3261e); } +// "12 min left" beside the episode chip: same tone as the subtitle, but it +// keeps its digits whole instead of truncating when the row runs short. +.rail__card-remaining { + flex: 0 1 auto; + min-width: 0; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.4; + color: var(--app-muted-color, #8891a4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + .rail__card-subtitle { flex: 1 1 auto; min-width: 0; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index 506825dd7..2a89999ae 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -133,5 +133,34 @@ describe('DashboardRailComponent', () => { chips[1].classList.contains('rail__card-expiry--expired') ).toBe(true); }); + + it('renders the episode chip and remaining time, and drops an empty meta row', async () => { + const element = await renderCards([ + card({ + id: 'show', + title: 'Fake', + contentType: 'movie', + episodeBadge: 'S1·E5', + remainingLabel: { + key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES', + params: { minutes: 12 }, + }, + }), + card({ id: 'bare', title: 'Bare', contentType: 'movie' }), + ]); + + const cards = element.querySelectorAll('.rail__card'); + expect(cards).toHaveLength(2); + expect( + cards[0].querySelector('.rail__card-episode')?.textContent + ).toBe('S1·E5'); + expect( + cards[0] + .querySelector('.rail__card-remaining') + ?.textContent?.trim() + ).toBe('WORKSPACE.DASHBOARD.REMAINING_MINUTES'); + expect(cards[0].querySelector('.rail__card-subtitle')).toBeNull(); + expect(cards[1].querySelector('.rail__card-meta-row')).toBeNull(); + }); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 60d01a07a..39f1d5d1a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -18,6 +18,7 @@ import { RouterLink } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; +import type { DashboardRemainingLabel } from './dashboard-playback.utils'; export interface DashboardRailAction { id: string; @@ -73,6 +74,13 @@ export interface DashboardRailCard { */ episodeBadge?: string | null; + /** + * Localised "12 min left" for Continue Watching cards with a known + * duration. Sits beside the episode chip in the meta row; absent when + * the position carries no duration. + */ + remainingLabel?: DashboardRemainingLabel | null; + /** * Subscription-expiry warning for portal source cards: a quiet amber * chip when the account expires soon, an error-toned one once it has. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 118fe9ffc..49a9fc10a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -13,6 +13,9 @@ import { isStalkerAccountPlaylist, isXtreamAccountPlaylist, normalizeDashboardRailsSettings, + type PlaybackPositionData, + playlistDisplayLabel, + resolvePortalActivityWatchKind, } from '@iptvnator/shared/interfaces'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; @@ -185,18 +188,7 @@ export class WorkspaceDashboardRailsComponent { item.type === 'live' ? this.liveEpg.detailsFor(this.heroLiveCard()) : null; - const episodeBadge = - item.type === 'series' && - position?.seasonNumber != null && - position?.episodeNumber != null - ? this.translate.instant( - 'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', - { - season: position.seasonNumber, - episode: position.episodeNumber, - } - ) - : null; + const episodeBadge = this.buildEpisodeBadge(item, position); return { ...artwork, @@ -593,34 +585,49 @@ export class WorkspaceDashboardRailsComponent { ); } + /** + * Only the source name under the hero title. Provider kind (Xtream / + * Stalker / M3U) and content kind (movie / series) are the app's own + * taxonomy, not a property of the title, and the badges row already + * says "S1·E5"; a stored playlist name can be a pasted URL with + * credentials or a MAC, so it goes through `playlistDisplayLabel`. + */ private buildHeroSubtitle(item: GlobalRecentItem): string { - const parts = [ + return playlistDisplayLabel( item.playlist_name, - this.data.getRecentItemProviderLabel(item), - this.data.getRecentItemTypeLabel(item), - ].filter((value): value is string => Boolean(value)); - return parts.join(' · '); + this.data.getRecentItemProviderLabel(item) + ); + } + + /** + * "S1·E5" for an item whose progress is tracked per episode. Keyed on + * the WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes + * as a movie but still names the episode it is on. + */ + private buildEpisodeBadge( + item: GlobalRecentItem, + position: PlaybackPositionData | null + ): string | null { + return resolvePortalActivityWatchKind(item) === 'series' && + position?.seasonNumber != null && + position?.episodeNumber != null + ? this.translate.instant( + 'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', + { + season: position.seasonNumber, + episode: position.episodeNumber, + } + ) + : null; } private toRecentCard(item: GlobalRecentItem): DashboardRailCard { const position = this.data.getPlaybackPositionForItem(item); const watchProgress = playbackProgressPercent(position); - const episodeBadge = - item.type === 'series' && - position?.seasonNumber != null && - position?.episodeNumber != null - ? this.translate.instant( - 'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', - { - season: position.seasonNumber, - episode: position.episodeNumber, - } - ) - : null; + const episodeBadge = this.buildEpisodeBadge(item, position); return { id: this.recentCardId(item), title: item.title, - subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`, imageUrl: item.poster_url, icon: this.typeIcon(item.type), contentType: item.type, @@ -633,6 +640,9 @@ export class WorkspaceDashboardRailsComponent { state: this.data.getRecentItemDetailNavigationState(item), watchProgress, episodeBadge, + // What still separates one card from the next: where in the + // show, and how much is left — not which provider it came from. + remainingLabel: formatRemainingLabel(position), ...(item.type === 'movie' || item.type === 'series' ? { actions: buildDashboardContinueWatchingActions({ @@ -657,7 +667,6 @@ export class WorkspaceDashboardRailsComponent { return { id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`, title: item.title, - subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`, imageUrl: item.poster_url, icon: this.typeIcon(item.type), contentType: item.type, @@ -671,14 +680,11 @@ export class WorkspaceDashboardRailsComponent { private toRecentlyAddedCard( item: DashboardRecentlyAddedItem ): DashboardRailCard { - const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item); - const subtitleParts = [item.playlist_name, typeLabel].filter( - (value): value is string => Boolean(value) - ); return { id: `added-${item.id}-${item.playlist_id}-${item.added_at}`, title: item.title, - subtitle: subtitleParts.join(' · '), + // "Where was it added" is the one fact that varies per card here. + subtitle: playlistDisplayLabel(item.playlist_name), imageUrl: item.poster_url, icon: this.typeIcon(item.type), contentType: item.type, From 6578e4073cf51cdacbf37139ef6a927a8d8cb855 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:53:55 +0200 Subject: [PATCH 6/8] feat(dashboard): portal EPG on the live rails, loaded lazily per visible card (#1638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the rails never asked anything for them and showed only the LIVE chip. Their programme now comes from the portal, one card at a time and only once the card is on screen. - `lib-dashboard-rail` reports the cards inside its viewport through an IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions them with the pinned hero row and hands the set to `DashboardPortalLiveEpgPresenter`. - `DashboardPortalLiveEpgService` runs the bounded queue — two requests in flight, 200 ms apart, one card each — through `StreamResolverService.loadEpgForItems`, publishing every answer the moment it lands, so the page never waits and a slow portal delays no other card. A card scrolled past before its turn is never requested. - A programme is trusted for 60 s, an empty answer for 30 s (the resolver reports a dead portal and a guide-less channel identically), and a completion captures both the display offset and the EPG source revision, requeueing itself when either moved. - The presenter hands its wanted set back on destroy. Desktop only: the shared collection resolver is gated on the local XMLTV bridge. - A shimmer placeholder shows only before a card's first portal answer; M3U cards keep the batched XMLTV lookup. --- .changes/dashboard-portal-live-epg.md | 9 + CLAUDE.md | 1 + .../src/dashboard-activation.e2e.ts | 45 +++ docs/architecture/workspace-dashboard.md | 64 +++- .../dashboard/data-access/src/index.ts | 2 + .../dashboard-portal-live-epg.service.spec.ts | 324 ++++++++++++++++++ .../lib/dashboard-portal-live-epg.service.ts | 275 +++++++++++++++ .../dashboard-portal-live-epg.util.spec.ts | 160 +++++++++ .../src/lib/dashboard-portal-live-epg.util.ts | 136 ++++++++ .../dashboard-live-epg.presenter.spec.ts | 130 ++++++- .../lib/rails/dashboard-live-epg.presenter.ts | 124 ++++++- ...ashboard-portal-live-epg.presenter.spec.ts | 166 +++++++++ .../dashboard-portal-live-epg.presenter.ts | 135 ++++++++ .../lib/rails/dashboard-rail.component.html | 20 +- .../lib/rails/dashboard-rail.component.scss | 40 ++- .../rails/dashboard-rail.component.spec.ts | 208 +++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 98 ++++++ .../workspace-dashboard-rails.component.html | 21 +- .../workspace-dashboard-rails.component.ts | 25 +- 19 files changed, 1937 insertions(+), 46 deletions(-) create mode 100644 .changes/dashboard-portal-live-epg.md create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts diff --git a/.changes/dashboard-portal-live-epg.md b/.changes/dashboard-portal-live-epg.md new file mode 100644 index 000000000..6b4547213 --- /dev/null +++ b/.changes/dashboard-portal-live-epg.md @@ -0,0 +1,9 @@ +--- +type: feature +area: dashboard +--- + +On the desktop app, favourite and recently watched Xtream and Stalker channels +on the dashboard now show what is on air, with the programme's time and +progress. Each card asks its portal only once it scrolls into view and fills in +on its own, so a slow portal never holds up the page or the other cards. diff --git a/CLAUDE.md b/CLAUDE.md index 0d335504a..2165f4ebe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1607,6 +1607,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - Background parsing in worker thread; HTTP/file gzip compatibility follows `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression"). - Stored in database for quick lookup - Source scope: batch "now" lookups search the playlist's own XMLTV first, then the Settings-managed global sources, and stop there; only `EpgLookupOptions.anySourceFallback` (renderer-only) retries the still-unresolved keys against every imported source. The dashboard live rails pass it, so a favourite whose guide lives in another playlist's XMLTV gets the same programme its "See all" row already showed; they still issue one lookup per distinct playlist source scope and namespace the answers by it (`liveEpgProgramKey`), since a `tvg-id` is unique inside a guide but not across imports, and only cards carrying a real XMLTV key are widened — a portal card's key is just its title (wiring: `DashboardLiveEpgPresenter`). The channel list keeps the strict scope. Contract: `docs/architecture/m3u-playlist-module.md` (the "Scoped lookups" bullet under playlist-scoped URLs) +- Dashboard live rails, portal side: an Xtream or Stalker card carries no XMLTV key, so its programme comes from the portal instead, lazily and per card — `lib-dashboard-rail` reports the cards inside its viewport (`visibleCardsChanged`, `IntersectionObserver` on the track), `DashboardLiveEpgPresenter` forwards them plus the pinned hero to `DashboardPortalLiveEpgPresenter`, and `DashboardPortalLiveEpgService` (data-access, root) runs the bounded queue (2 in flight / 200 ms, 60 s TTL for a programme, 30 s for an empty answer) through `StreamResolverService.loadEpgForItems`, publishing each answer as it lands. A completion captures the display offset AND `EpgSourceSettingsService.revision()` and requeues itself when either moved; the presenter hands its wanted set back on destroy; desktop only, since the resolver is gated on `supportsProgramLookup`. The one shared facade the rails talk to is `DashboardLiveEpgPresenter`: a portal answer wins, an XMLTV title match is the fallback, and a shimmer placeholder shows only before a card's first portal answer. Contract: `docs/architecture/workspace-dashboard.md` (Data Flow, item 3) - Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset") - Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide"). - Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/` diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index e78ad2d26..86a19d0d0 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -18,6 +18,7 @@ import { waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; import { + fetchXtreamEpgFixture, fetchXtreamLiveFixture, fetchXtreamSeriesFixture, fetchXtreamVodFixture, @@ -47,6 +48,33 @@ test.describe('Dashboard Activation', () => { liveFixture.items, getXtreamTitle ); + // The mock's guide for the channel favourited below (its first live + // stream). The programme on air is read off the full guide at + // assertion time: the mock cuts its slots from the second the guide + // was generated, so the "current" listing of a fixture fetched in + // that same second is the slot that ended just then. + const epgFixture = await fetchXtreamEpgFixture( + request, + xtreamCredentials + ); + expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle); + const liveNowTitle = () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const index = epgFixture.fullEpg.findIndex( + (listing) => + listing.startTimestamp <= nowSeconds && + nowSeconds < listing.stopTimestamp + ); + expect(index).toBeGreaterThanOrEqual(0); + // Tolerate a slot boundary passing between the app's answer and + // this assertion. + const titles = epgFixture.fullEpg + .slice(index, index + 2) + .map((listing) => + listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + return new RegExp(titles.join('|')); + }; const app = await launchElectronApp(dataDir); try { @@ -139,6 +167,15 @@ test.describe('Dashboard Activation', () => { app.mainWindow, 'dashboard-live-favorites-rail' ); + // An Xtream card has no XMLTV key: its "now on air" line comes + // from the portal, asked for lazily once the card is on screen. + await expect( + dashboardRailCardByTitle( + app.mainWindow, + 'dashboard-live-favorites-rail', + liveTitle + ).locator('.rail__channel-now') + ).toContainText(liveNowTitle(), { timeout: 30000 }); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-live-favorites-rail', @@ -167,6 +204,14 @@ test.describe('Dashboard Activation', () => { app.mainWindow, 'dashboard-recent-live-rail' ); + // Same channel, same key: the recent card shares the answer. + await expect( + dashboardRailCardByTitle( + app.mainWindow, + 'dashboard-recent-live-rail', + liveTitle + ).locator('.rail__channel-now') + ).toContainText(liveNowTitle(), { timeout: 30000 }); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-recent-live-rail', diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index fe8525ce9..529713149 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -141,7 +141,69 @@ Render rules: `dashboard-recent-live-rail`); there is no fallback from one to the other. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment - must use that key before falling back to the card title. + must use that key before falling back to the card title. Both rails are + enriched by `DashboardLiveEpgPresenter`, the one component-provided + facade for live EPG: it owns the XMLTV lookup described under "Scoped + lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped + to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards + with their "now on air" row filled in. + Xtream and Stalker cards have no XMLTV key of their own; their "now on + air" line comes from the portal, **lazily and per card**: + - `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a + live `PortalActivityItem` into the `UnifiedCollectionItem` the + collection pages hand `StreamResolverService.loadEpgForItems`, keyed + by the collection uid — favourites and recent rows of one channel + share the answer. Radio rows and rows without a usable provider id + get no entry. Cards carry that key as `liveEpgSourceKey`. + - `lib-dashboard-rail` reports the cards inside its track viewport + (plus ~one card of `rootMargin`) through `visibleCardsChanged`, from + an `IntersectionObserver` rooted at the track; without the API every + card counts as visible. Cards that leave the list are reported gone + at once. + - `DashboardPortalLiveEpgPresenter` (component-provided) unions the + visible keys of both rails with the pinned hero key and calls + `DashboardPortalLiveEpgService.sync()` with exactly those entries — + on every change, on the 30 s tick, and on a display-offset change. + It is reached through `DashboardLiveEpgPresenter`, which derives the + portal rows itself from the enabled rails and pins the hero, so the + page component only forwards what a rail can see. The queue lives in + the root service, so leaving the dashboard hands the wanted set back + (`sync([])` on destroy); otherwise the queue would keep asking for + cards on a page that is gone. + - `DashboardPortalLiveEpgService` (root) owns the queue: at most two + requests in flight, 200 ms between starts (the numbers + `EpgQueueService` proved against real panels), one card per request, + each answer published the moment it lands in `programs`, so the page + never waits and a slow portal delays no other card. Only wanted keys + are dequeued, so a card scrolled past before its turn is never + requested. A programme lives 60 s; a programme that ended is asked + again, but not within 30 s of the last answer (a portal may keep + returning the stale row). An answer with **no** programme lives only + 30 s, because the resolver reports a failed portal and a guide-less + channel identically (it files per-channel failures as `null`), so + there is no failure cooldown to keep and the short TTL is what lets + an outage recover on the next tick. + - Every answer is "at the provider clock" and against one XMLTV source + set. A request captures both the display offset and + `EpgSourceSettingsService.revision()` — the same fence + `EpgService.guard()` uses — and a completion whose either fact moved + is discarded and requeued instead of published. That requeue has to + happen in the completion: while the key is in flight the retire pass + cannot queue a replacement, and without it the pre-change answer + would be trusted for a full TTL (the repo's late-result + invalidation contract). + - Desktop only in practice: the shared collection resolver is gated on + the local XMLTV bridge (`supportsProgramLookup`) and answers nothing + without it, so `sync()` returns immediately in the PWA rather than + filing an empty answer for every card. Lifting that gate for portal + lookups would change the collection pages too and is deliberately + out of scope here. + - `DashboardLiveEpgPresenter.enrich()` prefers the portal answer, falls + back to the XMLTV title match when the portal said "nothing on air", + and marks a card + `nowPlayingState: 'pending'` only before its FIRST answer — the + channel layout then shows a shimmer placeholder in the programme + slot; a refresh keeps the previous answer on screen. 4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail cards. Aggregates newly added VOD and series across *all* Xtream playlists via `DashboardDataService.reloadXtreamRecentlyAddedItems()`, diff --git a/libs/workspace/dashboard/data-access/src/index.ts b/libs/workspace/dashboard/data-access/src/index.ts index 29b5d17ab..7d1146a21 100644 --- a/libs/workspace/dashboard/data-access/src/index.ts +++ b/libs/workspace/dashboard/data-access/src/index.ts @@ -1,4 +1,6 @@ export * from './lib/dashboard-data.service'; +export * from './lib/dashboard-portal-live-epg.service'; +export * from './lib/dashboard-portal-live-epg.util'; export * from './lib/dashboard-recommendations.service'; export * from './lib/dashboard-recommendations.util'; export * from './lib/dashboard-source-expiry.service'; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts new file mode 100644 index 000000000..9e9df0559 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts @@ -0,0 +1,324 @@ +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EpgSourceSettingsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { StreamResolverService } from '@iptvnator/portal/shared/data-access'; +import { + DASHBOARD_PORTAL_LIVE_EPG_TIMING, + DashboardPortalLiveEpgService, +} from './dashboard-portal-live-epg.service'; +import type { DashboardPortalLiveEpgEntry } from './dashboard-portal-live-epg.util'; + +interface Deferred { + readonly key: string; + resolve: (program: EpgProgram | null) => void; + reject: (error: unknown) => void; +} + +const entry = (id: number): DashboardPortalLiveEpgEntry => ({ + key: `xtream::p::${id}`, + item: { + uid: `xtream::p::${id}`, + name: `Channel ${id}`, + contentType: 'live', + sourceType: 'xtream', + playlistId: 'p', + playlistName: 'Portal', + xtreamId: id, + tvgId: String(id), + }, +}); + +const program = (title: string, stopIso?: string): EpgProgram => + ({ + channel: 'x', + title, + start: '2026-05-23T10:00:00.000Z', + stop: stopIso ?? '2026-05-23T11:00:00.000Z', + }) as EpgProgram; + +describe('DashboardPortalLiveEpgService', () => { + let service: DashboardPortalLiveEpgService; + let loadEpgForItems: jest.Mock; + let deferred: Deferred[]; + let offsetMinutes: number; + let sourceChanged: Subject; + let sourceRevision: number; + let supportsEpgProgramLookup: boolean; + + const { delayMs, ttlMs, emptyTtlMs, endedRefetchFloorMs } = + DASHBOARD_PORTAL_LIVE_EPG_TIMING; + + /** What a reconciliation does: bump the fence, then announce it. */ + const changeEpgSources = () => { + sourceRevision++; + sourceChanged.next(); + }; + + /** Let the queue loop take its next step (one inter-request delay). */ + const step = async (rounds = 1): Promise => { + for (let i = 0; i < rounds; i++) { + await jest.advanceTimersByTimeAsync(delayMs); + } + }; + + /** Settle the LATEST request for `key` — a key can be asked more than once. */ + const settle = async (key: string, value: EpgProgram | null) => { + const request = [...deferred] + .reverse() + .find((request) => request.key === key); + if (!request) throw new Error(`no request in flight for ${key}`); + request.resolve(value); + await jest.advanceTimersByTimeAsync(0); + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + deferred = []; + offsetMinutes = 0; + sourceRevision = 0; + supportsEpgProgramLookup = true; + sourceChanged = new Subject(); + loadEpgForItems = jest.fn((items: { tvgId?: string }[]) => { + const key = `xtream::p::${items[0].tvgId}`; + return new Promise>( + (resolve, reject) => { + deferred.push({ + key, + resolve: (value) => + resolve(new Map([[String(items[0].tvgId), value]])), + reject, + }); + } + ); + }); + + TestBed.configureTestingModule({ + providers: [ + DashboardPortalLiveEpgService, + { + provide: StreamResolverService, + useValue: { loadEpgForItems }, + }, + { + provide: SettingsStore, + useValue: { + resolvedEpgOffsetMinutes: () => offsetMinutes, + }, + }, + { + provide: EpgSourceSettingsService, + useValue: { + changed$: sourceChanged, + revision: () => sourceRevision, + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { + get supportsEpgProgramLookup() { + return supportsEpgProgramLookup; + }, + }, + }, + ], + }); + service = TestBed.inject(DashboardPortalLiveEpgService); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('answers each card as its own request lands, never waiting for the slowest', async () => { + service.sync([entry(1), entry(2)]); + expect(service.pending()).toEqual( + new Set(['xtream::p::1', 'xtream::p::2']) + ); + await step(2); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + + await settle('xtream::p::2', program('Fast answer')); + expect(service.programs().get('xtream::p::2')?.title).toBe( + 'Fast answer' + ); + expect(service.programs().has('xtream::p::1')).toBe(false); + expect(service.pending()).toEqual(new Set(['xtream::p::1'])); + + await settle('xtream::p::1', null); + expect(service.programs().get('xtream::p::1')).toBeNull(); + expect(service.pending().size).toBe(0); + }); + + it('never has more than two requests in flight and spaces starts by the delay', async () => { + service.sync([entry(1), entry(2), entry(3), entry(4)]); + expect(loadEpgForItems).toHaveBeenCalledTimes(1); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + await step(3); + // Two in flight: the loop keeps waiting instead of starting a third. + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + + await settle('xtream::p::1', null); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(3); + }); + + it('drops a queued card that scrolled away before its turn and keeps the rest', async () => { + service.sync([entry(1), entry(2), entry(3)]); + // 1 started; 2 and 3 still queued. + service.sync([entry(1), entry(3)]); + expect(service.pending()).toEqual( + new Set(['xtream::p::1', 'xtream::p::3']) + ); + await step(2); + const requested = loadEpgForItems.mock.calls.map( + ([items]) => items[0].tvgId + ); + expect(requested).toEqual(['1', '3']); + }); + + it('serves a fresh answer from cache and asks again once the TTL has passed', async () => { + service.sync([entry(1)]); + await settle('xtream::p::1', program('Cached')); + await step(); + + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(1); + + jest.setSystemTime(Date.now() + ttlMs); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + }); + + it('asks again when the programme on air has ended, but not more often than the floor', async () => { + service.sync([entry(1)]); + await settle( + 'xtream::p::1', + program('Ending soon', '2026-05-23T10:35:00.000Z') + ); + await step(); + + // Ended 5 min in, but the floor (30 s) already passed → refetch. + jest.setSystemTime(Date.now() + 6 * 60_000); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + + // Portal keeps returning the ended row: not re-asked within the floor. + await settle( + 'xtream::p::1', + program('Still ended', '2026-05-23T10:35:00.000Z') + ); + await step(); + jest.setSystemTime(Date.now() + endedRefetchFloorMs / 2); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + }); + + it('expires an answer with no programme sooner than one with a programme', async () => { + // The resolver reports a failed portal and a guide-less channel the + // same way, so the short TTL is what lets an outage recover. + service.sync([entry(1)]); + await settle('xtream::p::1', null); + await step(); + expect(service.programs().get('xtream::p::1')).toBeNull(); + + jest.setSystemTime(Date.now() + emptyTtlMs / 2); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(1); + + jest.setSystemTime(Date.now() + emptyTtlMs / 2); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + + // A programme keeps the full TTL, which outlives the empty one. + await settle('xtream::p::1', program('On air')); + await step(); + jest.setSystemTime(Date.now() + emptyTtlMs); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + }); + + it('treats a rejected resolver call as an answer with no programme', async () => { + service.sync([entry(1)]); + deferred[0].reject(new Error('portal down')); + await jest.advanceTimersByTimeAsync(0); + + expect(service.programs().get('xtream::p::1')).toBeNull(); + expect(service.pending().size).toBe(0); + + jest.setSystemTime(Date.now() + emptyTtlMs); + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + }); + + it('retires an answer evaluated under a previous display offset and asks again', async () => { + service.sync([entry(1)]); + offsetMinutes = 60; + await settle('xtream::p::1', program('Old clock')); + await step(); + expect(service.programs().has('xtream::p::1')).toBe(false); + // Still wanted → requeued under the new clock. + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + + await settle('xtream::p::1', program('New clock')); + expect(service.programs().get('xtream::p::1')?.title).toBe('New clock'); + }); + + it('drops every answer when the EPG sources change and asks the wanted cards again', async () => { + service.sync([entry(1)]); + await settle('xtream::p::1', program('Before import')); + await step(); + + changeEpgSources(); + expect(service.programs().size).toBe(0); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + }); + + it('discards an answer computed before an EPG source change and asks again', async () => { + // The key is in flight when the sources change, so the retire pass + // cannot requeue it; the completion must not publish the old guide's + // answer and must ask again itself. + service.sync([entry(1)]); + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(1); + + changeEpgSources(); + await settle('xtream::p::1', program('Removed guide')); + expect(service.programs().has('xtream::p::1')).toBe(false); + + await step(); + expect(loadEpgForItems).toHaveBeenCalledTimes(2); + await settle('xtream::p::1', program('Current guide')); + expect(service.programs().get('xtream::p::1')?.title).toBe( + 'Current guide' + ); + }); + + it('does nothing at all without the local EPG program-lookup capability', async () => { + // PWA: the collection resolver is gated on the desktop XMLTV bridge + // and answers nothing, so no request is worth queuing. + supportsEpgProgramLookup = false; + + service.sync([entry(1), entry(2)]); + await step(3); + + expect(loadEpgForItems).not.toHaveBeenCalled(); + expect(service.pending().size).toBe(0); + expect(service.programs().size).toBe(0); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts new file mode 100644 index 000000000..b4a9e158d --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts @@ -0,0 +1,275 @@ +import { inject, Injectable, OnDestroy, signal } from '@angular/core'; +import { + epgProviderClockMs, + type EpgProgram, +} from '@iptvnator/shared/interfaces'; +import { + EpgSourceSettingsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { StreamResolverService } from '@iptvnator/portal/shared/data-access'; +import { + dashboardPortalLiveEpgProgramStopMs, + resolveDashboardPortalLiveEpgProgram, + type DashboardPortalLiveEpgEntry, +} from './dashboard-portal-live-epg.util'; + +interface CachedProgram { + readonly program: EpgProgram | null; + readonly fetchedAt: number; +} + +/** + * Same numbers `EpgQueueService` uses against real Xtream panels: two + * requests in flight, 200 ms between starts. A programme is trusted for a + * minute; a programme that ended is asked again, but never more often than + * every 30 s in case the portal keeps returning the stale row. + * + * An answer with no programme expires sooner (`emptyTtlMs`) because the + * collection resolver reports a failed portal and a channel with no guide + * identically — it catches per-channel failures and files them as `null`. + * There is therefore no failure cooldown to keep: the short TTL is what lets + * a transient outage recover on the next tick, at the price of re-asking a + * genuinely guide-less channel while its card stays on screen. + */ +export const DASHBOARD_PORTAL_LIVE_EPG_TIMING = Object.freeze({ + maxConcurrency: 2, + delayMs: 200, + ttlMs: 60_000, + emptyTtlMs: 30_000, + endedRefetchFloorMs: 30_000, +}); + +/** + * Lazy, per-card "what is on air" for the dashboard's Xtream and Stalker + * live cards. `sync()` receives the cards currently worth asking for — the + * visible ones — and the service answers each through the collection + * resolver one card at a time, publishing every answer the moment it lands + * (`programs`) so a slow portal never delays a fast one. The queue holds + * only wanted keys, so a card scrolled away before its turn is never + * requested. One instance for the app: favourites and recent rows of the + * same channel share the key and therefore the answer. + */ +@Injectable({ providedIn: 'root' }) +export class DashboardPortalLiveEpgService implements OnDestroy { + private readonly streamResolver = inject(StreamResolverService); + private readonly settingsStore = inject(SettingsStore); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly sourceSettings = inject(EpgSourceSettingsService); + private readonly sourceSubscription = + this.sourceSettings.changed$.subscribe(() => this.retireAnswers()); + + private readonly cache = new Map(); + private wanted = new Map(); + private queue: string[] = []; + private readonly inFlight = new Set(); + private processing = false; + /** Display offset every cached answer was evaluated under. */ + private stateOffsetMinutes = this.offsetMinutes(); + + private readonly programsState = signal< + ReadonlyMap + >(new Map()); + private readonly pendingState = signal>(new Set()); + + /** Answers by entry key; absent = not asked yet, `null` = nothing on air. */ + readonly programs = this.programsState.asReadonly(); + /** Keys queued or in flight — the cards that may show a placeholder. */ + readonly pending = this.pendingState.asReadonly(); + + /** + * Replace the wanted set. Keys without a fresh answer are queued; keys no + * longer wanted are dropped from the queue (an in-flight request is + * allowed to finish and is cached for when the card scrolls back). + */ + sync(entries: readonly DashboardPortalLiveEpgEntry[]): void { + // The collection resolver this queue asks through is gated on the + // local XMLTV bridge (`supportsProgramLookup`, desktop only) and + // answers nothing without it, so the PWA never queues at all rather + // than filing an empty answer for every card. + if (!this.runtime.supportsEpgProgramLookup) { + return; + } + this.retireStateOfPreviousOffset(); + this.wanted = new Map(entries.map((entry) => [entry.key, entry])); + this.queue = this.queue.filter((key) => this.wanted.has(key)); + const now = Date.now(); + for (const entry of entries) { + if ( + this.needsFetch(entry.key, now) && + !this.queue.includes(entry.key) + ) { + this.queue.push(entry.key); + } + } + this.publishPending(); + if (!this.processing && this.queue.length > 0) { + void this.processQueue(); + } + } + + ngOnDestroy(): void { + this.sourceSubscription.unsubscribe(); + } + + private offsetMinutes(): number { + return this.settingsStore.resolvedEpgOffsetMinutes(); + } + + private needsFetch(key: string, now: number): boolean { + if (this.inFlight.has(key)) { + return false; + } + const cached = this.cache.get(key); + if (!cached) { + return true; + } + const ttlMs = cached.program + ? DASHBOARD_PORTAL_LIVE_EPG_TIMING.ttlMs + : DASHBOARD_PORTAL_LIVE_EPG_TIMING.emptyTtlMs; + if (now - cached.fetchedAt >= ttlMs) { + return true; + } + const stopMs = dashboardPortalLiveEpgProgramStopMs(cached.program); + return ( + stopMs !== null && + epgProviderClockMs(now, this.stateOffsetMinutes) >= stopMs && + now - cached.fetchedAt >= + DASHBOARD_PORTAL_LIVE_EPG_TIMING.endedRefetchFloorMs + ); + } + + private async processQueue(): Promise { + this.processing = true; + try { + while (this.queue.length > 0) { + if ( + this.inFlight.size >= + DASHBOARD_PORTAL_LIVE_EPG_TIMING.maxConcurrency + ) { + await this.delay(); + continue; + } + const key = this.queue.shift(); + if ( + key == null || + !this.wanted.has(key) || + !this.needsFetch(key, Date.now()) + ) { + this.publishPending(); + continue; + } + this.inFlight.add(key); + void this.fetch(key); + await this.delay(); + } + } finally { + this.processing = false; + } + } + + private async fetch(key: string): Promise { + const entry = this.wanted.get(key); + if (!entry) { + this.inFlight.delete(key); + this.publishPending(); + return; + } + // Both facts this answer is evaluated against. The revision is the + // same fence `EpgService.guard()` uses: a reconciliation bumps it, so + // a result computed against the previous XMLTV source set can be told + // apart from one computed against the current one. + const offsetMinutes = this.offsetMinutes(); + const revision = this.sourceSettings.revision(); + let program: EpgProgram | null = null; + try { + const epgMap = await this.streamResolver.loadEpgForItems([ + entry.item, + ]); + program = resolveDashboardPortalLiveEpgProgram(epgMap, entry); + } catch { + // The resolver files a failed portal as `null` itself, so this + // only catches a resolver-level throw. Same answer either way, + // and `emptyTtlMs` is what makes it recoverable. + program = null; + } + this.inFlight.delete(key); + + // A setting or the source set changed while the request was on the + // wire: this answer belongs to the previous provider clock or the + // previous guide. `retireAnswers()` could not requeue the key while + // it was in flight, so the requeue happens here — otherwise the stale + // answer would be published and trusted for a full TTL. + if ( + offsetMinutes !== this.offsetMinutes() || + revision !== this.sourceSettings.revision() + ) { + this.retireStateOfPreviousOffset(); + this.requeueIfWanted(key); + return; + } + + this.cache.set(key, { program, fetchedAt: Date.now() }); + this.programsState.update((programs) => { + const next = new Map(programs); + next.set(key, program); + return next; + }); + this.publishPending(); + } + + private requeueIfWanted(key: string): void { + if (this.wanted.has(key) && !this.queue.includes(key)) { + this.queue.push(key); + } + this.publishPending(); + if (!this.processing && this.queue.length > 0) { + void this.processQueue(); + } + } + + /** + * Every answer here is "what is on at the provider clock", so a changed + * display offset drops all of them. The caller's own `sync` (the + * presenter re-syncs on an offset change) asks the wanted cards again. + */ + private retireStateOfPreviousOffset(): void { + const current = this.offsetMinutes(); + if (current === this.stateOffsetMinutes) { + return; + } + this.stateOffsetMinutes = current; + this.dropAnswers(); + } + + /** Removed or re-imported XMLTV: drop every answer and ask again now. */ + private retireAnswers(): void { + this.dropAnswers(); + if (this.wanted.size > 0) { + this.sync(Array.from(this.wanted.values())); + } + } + + private dropAnswers(): void { + this.cache.clear(); + this.programsState.set(new Map()); + } + + private publishPending(): void { + const pending = new Set(); + for (const key of this.queue) { + if (this.wanted.has(key)) pending.add(key); + } + for (const key of this.inFlight) { + if (this.wanted.has(key)) pending.add(key); + } + this.pendingState.set(pending); + } + + private delay(): Promise { + return new Promise((resolve) => + setTimeout(resolve, DASHBOARD_PORTAL_LIVE_EPG_TIMING.delayMs) + ); + } +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.spec.ts new file mode 100644 index 000000000..9f235bbce --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.spec.ts @@ -0,0 +1,160 @@ +import type { + EpgProgram, + PortalActivityItem, +} from '@iptvnator/shared/interfaces'; +import { + buildDashboardPortalLiveEpgEntry, + buildDashboardPortalLiveEpgKey, + dashboardPortalLiveEpgProgramStopMs, + resolveDashboardPortalLiveEpgProgram, +} from './dashboard-portal-live-epg.util'; + +const baseItem = (overrides: Partial): PortalActivityItem => + ({ + id: 'item-1', + title: 'News 24', + type: 'live', + playlist_id: 'playlist-1', + playlist_name: 'My portal', + category_id: '7', + xtream_id: 42, + poster_url: 'https://example.com/logo.png', + ...overrides, + }) as PortalActivityItem; + +describe('buildDashboardPortalLiveEpgEntry', () => { + it('builds an Xtream live entry keyed by the collection uid with the stream id as tvgId', () => { + const entry = buildDashboardPortalLiveEpgEntry( + baseItem({ source: 'xtream', xtream_id: 42 }) + ); + + expect(entry?.key).toBe('xtream::playlist-1::42'); + expect(entry?.item).toMatchObject({ + uid: 'xtream::playlist-1::42', + name: 'News 24', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'playlist-1', + playlistName: 'My portal', + logo: 'https://example.com/logo.png', + xtreamId: 42, + tvgId: '42', + categoryId: '7', + }); + }); + + it('accepts a numeric string Xtream id and rejects ids that cannot name a stream', () => { + expect( + buildDashboardPortalLiveEpgEntry( + baseItem({ source: 'xtream', xtream_id: '17' }) + )?.item.xtreamId + ).toBe(17); + for (const xtream_id of ['', 'abc', 0, -3, 1.5]) { + expect( + buildDashboardPortalLiveEpgEntry( + baseItem({ source: 'xtream', xtream_id }) + ) + ).toBeNull(); + } + }); + + it('builds a Stalker live entry from the stored portal item, keyed by the extracted id', () => { + const stalkerItem = { + id: 'ch-9', + cmd: 'ffrt http://portal/9', + radio: false, + }; + const entry = buildDashboardPortalLiveEpgEntry( + baseItem({ + source: 'stalker', + id: 'ch-9', + xtream_id: 'ch-9', + stalker_item: stalkerItem, + }) + ); + + expect(entry?.key).toBe('stalker::playlist-1::ch-9'); + expect(entry?.item).toMatchObject({ + sourceType: 'stalker', + stalkerId: 'ch-9', + tvgId: 'ch-9', + stalkerCmd: 'ffrt http://portal/9', + stalkerItem, + }); + }); + + it('skips Stalker radio rows and rows without the stored item, like the collection resolver', () => { + expect( + buildDashboardPortalLiveEpgEntry( + baseItem({ + source: 'stalker', + stalker_item: { id: 'r-1', radio: 'true' }, + }) + ) + ).toBeNull(); + expect( + buildDashboardPortalLiveEpgEntry( + baseItem({ source: 'stalker', stalker_item: undefined }) + ) + ).toBeNull(); + }); + + it('leaves M3U rows and non-live rows to the XMLTV batch', () => { + expect( + buildDashboardPortalLiveEpgEntry(baseItem({ source: 'm3u' })) + ).toBeNull(); + expect( + buildDashboardPortalLiveEpgEntry( + baseItem({ source: 'xtream', type: 'movie' }) + ) + ).toBeNull(); + expect( + buildDashboardPortalLiveEpgKey(baseItem({ source: 'xtream' })) + ).toBe('xtream::playlist-1::42'); + expect( + buildDashboardPortalLiveEpgKey(baseItem({ source: 'm3u' })) + ).toBeNull(); + }); +}); + +describe('resolveDashboardPortalLiveEpgProgram', () => { + it('reads the resolver map by the entry tvgId and treats a missing key as nothing on air', () => { + const entry = buildDashboardPortalLiveEpgEntry( + baseItem({ source: 'xtream', xtream_id: 42 }) + ); + const program = { title: 'Evening news' } as EpgProgram; + if (!entry) throw new Error('expected an entry'); + + expect( + resolveDashboardPortalLiveEpgProgram( + new Map([['42', program]]), + entry + ) + ).toBe(program); + expect( + resolveDashboardPortalLiveEpgProgram(new Map(), entry) + ).toBeNull(); + }); +}); + +describe('dashboardPortalLiveEpgProgramStopMs', () => { + it('prefers the unix-seconds stop, falls back to the ISO string, and reports null otherwise', () => { + expect( + dashboardPortalLiveEpgProgramStopMs({ + stopTimestamp: 1_700_000_000, + stop: '2026-01-01T00:00:00.000Z', + } as EpgProgram) + ).toBe(1_700_000_000_000); + expect( + dashboardPortalLiveEpgProgramStopMs({ + stop: '2026-01-01T00:00:00.000Z', + } as EpgProgram) + ).toBe(Date.parse('2026-01-01T00:00:00.000Z')); + expect( + dashboardPortalLiveEpgProgramStopMs({ + stop: 'garbage', + } as EpgProgram) + ).toBeNull(); + expect(dashboardPortalLiveEpgProgramStopMs(null)).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts new file mode 100644 index 000000000..f38a7c44a --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts @@ -0,0 +1,136 @@ +import { + isStalkerRadioItem, + type EpgProgram, + type PortalActivityItem, +} from '@iptvnator/shared/interfaces'; +import { + buildCollectionUid, + type UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; + +/** + * One dashboard live card's portal EPG request: the key its answer is filed + * under (the collection uid, stable across favourites and recent rows of the + * same channel) and the item `StreamResolverService.loadEpgForItems` fetches + * for — the same shape the "See all" pages hand it, so the two surfaces + * resolve a channel identically. + */ +export interface DashboardPortalLiveEpgEntry { + readonly key: string; + readonly item: UnifiedCollectionItem; +} + +/** The key alone, for cards that only need to find their answer. */ +export function buildDashboardPortalLiveEpgKey( + item: PortalActivityItem +): string | null { + return buildDashboardPortalLiveEpgEntry(item)?.key ?? null; +} + +/** + * M3U channels keep the dashboard's batched XMLTV lookup; Xtream and Stalker + * live rows have no XMLTV key of their own and are answered by their portal. + * Radio rows are skipped like the collection resolver skips them. + */ +export function buildDashboardPortalLiveEpgEntry( + item: PortalActivityItem +): DashboardPortalLiveEpgEntry | null { + if (item.type !== 'live') { + return null; + } + if (item.source === 'xtream') { + return buildXtreamEntry(item); + } + if (item.source === 'stalker') { + return buildStalkerEntry(item); + } + return null; +} + +/** + * The resolver keys its map by the item's `tvgId`; the entry sets it to the + * provider id, so a missing key means "asked, nothing on air". + */ +export function resolveDashboardPortalLiveEpgProgram( + epgMap: ReadonlyMap, + entry: DashboardPortalLiveEpgEntry +): EpgProgram | null { + const key = entry.item.tvgId?.trim(); + return key ? (epgMap.get(key) ?? null) : null; +} + +/** + * When the programme on air ends, in wall-clock milliseconds; `null` when the + * row states no usable end. Reads the pre-computed unix-seconds field first, + * then the ISO string, like the dashboard's own time-range helper. + */ +export function dashboardPortalLiveEpgProgramStopMs( + program: EpgProgram | null +): number | null { + if (!program) { + return null; + } + const cached = Number(program.stopTimestamp); + if (Number.isFinite(cached) && cached > 0) { + return cached * 1000; + } + const parsed = program.stop ? Date.parse(program.stop) : NaN; + return Number.isFinite(parsed) ? parsed : null; +} + +function buildXtreamEntry( + item: PortalActivityItem +): DashboardPortalLiveEpgEntry | null { + const xtreamId = Number(item.xtream_id); + if (!Number.isInteger(xtreamId) || xtreamId <= 0) { + return null; + } + const key = buildCollectionUid('xtream', item.playlist_id, xtreamId); + return { + key, + item: { + uid: key, + name: item.title, + contentType: 'live', + sourceType: 'xtream', + playlistId: item.playlist_id, + playlistName: item.playlist_name ?? 'Xtream', + logo: item.poster_url ?? null, + xtreamId, + tvgId: String(xtreamId), + categoryId: item.category_id, + }, + }; +} + +function buildStalkerEntry( + item: PortalActivityItem +): DashboardPortalLiveEpgEntry | null { + const raw = item.stalker_item; + if (!raw || isStalkerRadioItem(raw)) { + return null; + } + // Both dashboard mappers already store the extracted portal id as `id`. + const stalkerId = String(item.id ?? '').trim(); + if (!stalkerId) { + return null; + } + const key = buildCollectionUid('stalker', item.playlist_id, stalkerId); + return { + key, + item: { + uid: key, + name: item.title, + contentType: 'live', + sourceType: 'stalker', + playlistId: item.playlist_id, + playlistName: item.playlist_name ?? 'Stalker', + logo: item.poster_url ?? null, + stalkerId, + tvgId: stalkerId, + stalkerCmd: raw.cmd, + categoryId: item.category_id, + stalkerItem: raw, + }, + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts index fa4f72d3a..6b5d53e2b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -2,10 +2,16 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { EMPTY, of, throwError } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; -import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + DEFAULT_DASHBOARD_RAILS_SETTINGS, + type EpgProgram, + type PlaylistMeta, + type PortalActivityItem, +} from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access'; import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter'; +import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter'; import type { DashboardRailCard } from './dashboard-rail.component'; const guideA = 'https://a.example/guide.xml'; @@ -36,6 +42,18 @@ describe('DashboardLiveEpgPresenter', () => { let presenter: DashboardLiveEpgPresenter; let getCurrentProgramsForChannels: jest.Mock; let playlists: ReturnType>; + let recentItems: ReturnType>; + let dashboardRails: ReturnType< + typeof signal + >; + /** Portal answers are the sibling presenter's job; stub it out here. */ + let portal: { + connect: jest.Mock; + setPinnedKeys: jest.Mock; + setVisibleCards: jest.Mock; + programFor: jest.Mock; + isPending: jest.Mock; + }; const setup = (cards: DashboardRailCard[]) => { presenter.connect(signal(cards)); @@ -55,12 +73,32 @@ describe('DashboardLiveEpgPresenter', () => { { _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta, ]); + recentItems = signal([]); + dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS }); + portal = { + connect: jest.fn(), + setPinnedKeys: jest.fn(), + setVisibleCards: jest.fn(), + programFor: jest.fn(() => undefined), + isPending: jest.fn(() => false), + }; + TestBed.configureTestingModule({ providers: [ DashboardLiveEpgPresenter, + { + provide: DashboardPortalLiveEpgPresenter, + useValue: portal, + }, { provide: DashboardDataService, - useValue: { playlists }, + useValue: { + playlists, + // The presenter also derives the portal source list. + globalRecentItems: recentItems, + globalFavoriteLiveItems: signal([]), + globalRecentLiveItems: signal([]), + }, }, { provide: EpgService, @@ -68,7 +106,10 @@ describe('DashboardLiveEpgPresenter', () => { }, { provide: SettingsStore, - useValue: { resolvedEpgOffsetMinutes: () => 0 }, + useValue: { + resolvedEpgOffsetMinutes: () => 0, + dashboardRails, + }, }, ], }); @@ -136,6 +177,89 @@ describe('DashboardLiveEpgPresenter', () => { ); }); + it('pins the hero row only while the hero rail shows one', () => { + const heroLive = { + id: 'x-7', + title: 'Hero channel', + type: 'live', + source: 'xtream', + playlist_id: 'p', + category_id: '1', + xtream_id: 7, + } as PortalActivityItem; + recentItems.set([heroLive]); + TestBed.tick(); + + expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']); + + // With the rail hidden nothing is pinned: the first portal row is + // then a favourite, and pinning it would keep asking for a card + // nobody can see. + dashboardRails.set({ + ...DEFAULT_DASHBOARD_RAILS_SETTINGS, + hero: false, + }); + TestBed.tick(); + expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]); + }); + + it('prefers the portal answer and forwards what the portal presenter owns', () => { + const xmltvCard = card({ + id: 'x', + epgLookupKey: 'ard.de', + epgPlaylistId: 'a', + liveEpgSourceKey: 'xtream::p::7', + }); + getCurrentProgramsForChannels.mockImplementation(() => + of( + new Map([ + ['ard.de', program('From XMLTV')], + ]) + ) + ); + portal.programFor.mockImplementation( + (key: string | null | undefined) => + key === 'xtream::p::7' ? program('From the portal') : undefined + ); + + setup([xmltvCard]); + + expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe( + 'From the portal' + ); + + // A portal that answered "nothing on air" falls back to XMLTV. + portal.programFor.mockReturnValue(null); + expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe( + 'From XMLTV' + ); + + // The placeholder is only for a card still awaiting its FIRST answer. + getCurrentProgramsForChannels.mockImplementation(() => + of(new Map()) + ); + setup([xmltvCard]); + portal.programFor.mockReturnValue(undefined); + portal.isPending.mockReturnValue(true); + expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBe( + 'pending' + ); + // An answered card keeps what it has instead of flashing. + portal.programFor.mockReturnValue(program('Answered')); + expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBeNull(); + expect(presenter.enrich([xmltvCard])[0].nowPlayingTitle).toBe( + 'Answered' + ); + + // The portal source list and the hero pin are the presenter's own + // job; the rails only report what they can see. + expect(portal.connect).toHaveBeenCalled(); + presenter.setVisibleCards('favorites', [xmltvCard]); + expect(portal.setVisibleCards).toHaveBeenCalledWith('favorites', [ + xmltvCard, + ]); + }); + it('never hands a card the programme another guide resolved for the same id', () => { const fromA = card({ id: 'a', diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts index 803280606..70baea7e6 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -1,8 +1,10 @@ import { computed, + effect, inject, Injectable, signal, + untracked, type Signal, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; @@ -17,11 +19,19 @@ import { switchMap, } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; -import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + normalizeDashboardRailsSettings, + type EpgProgram, + type PortalActivityItem, +} from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; -import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access'; +import { + buildDashboardPortalLiveEpgKey, + DashboardDataService, +} from '@iptvnator/workspace/dashboard/data-access'; import type { DashboardRailCard } from './dashboard-rail.component'; +import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter'; import { buildDashboardLiveEpgDetails, buildLiveEpgLookupGroups, @@ -33,6 +43,7 @@ import { type DashboardLiveEpgDetails, type DashboardLiveEpgLookupGroup, } from './dashboard-live-epg.utils'; +import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils'; type ScopeAnswer = { readonly scopeKey: string; @@ -60,12 +71,19 @@ const emptyAnswer = (scopeKey: string): ScopeAnswer => ({ * XMLTV, which is what the "See all" collection pages resolve against. * Without it a channel whose guide only exists in another playlist's XMLTV * showed no programme here while its "See all" row had one. + * + * It is also the one facade the rails talk to for live EPG: an Xtream or + * Stalker card has no XMLTV key of its own, so its programme comes from + * `DashboardPortalLiveEpgPresenter` and only falls back to the title match + * here. */ @Injectable() export class DashboardLiveEpgPresenter { private readonly data = inject(DashboardDataService); private readonly epgService = inject(EpgService); private readonly settingsStore = inject(SettingsStore); + /** Xtream/Stalker cards are answered by their portal, not by XMLTV. */ + private readonly portal = inject(DashboardPortalLiveEpgPresenter); private readonly cards = signal() } ); + private readonly rails = computed(() => + normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.()) + ); + + /** The live row behind the hero panel, when that rail shows one. */ + private readonly heroLiveItem = computed(() => { + const hero = this.data.globalRecentItems()[0] ?? null; + return this.rails().hero && hero?.type === 'live' ? hero : null; + }); + + // The Xtream/Stalker live rows behind the hero and the two live rails. + // Their programmes come from the portal, asked for lazily per visible + // card; M3U rows stay on the XMLTV batch above. + private readonly portalItems = computed( + () => { + const rails = this.rails(); + const hero = this.heroLiveItem(); + return [ + ...(hero ? [hero] : []), + ...(rails.liveFavorites + ? this.data + .globalFavoriteLiveItems() + .slice(0, RAIL_ITEM_LIMIT) + : []), + ...(rails.recentlyWatchedLive + ? this.data + .globalRecentLiveItems() + .slice(0, RAIL_ITEM_LIMIT) + : []), + ]; + } + ); + + constructor() { + this.portal.connect(this.portalItems); + // The hero sits at the top of the page and is never scrolled into + // view, so its key is wanted regardless of what the rails report. + // Only the hero: the first entry of `portalItems` is a favourite + // when that rail is hidden, and pinning it would keep asking for a + // card nobody can see. + effect(() => { + const hero = this.heroLiveItem(); + const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null; + untracked(() => this.portal.setPinnedKeys([heroKey])); + }); + } + /** The live cards whose rails are enabled, hero included. */ connect(cards: Signal): void { this.cards.set(cards); } + /** A rail reported the cards inside its viewport. */ + setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void { + this.portal.setVisibleCards(railId, cards); + } + + /** The rails' cards with their "now on air" row filled in. */ + enrich(cards: readonly DashboardRailCard[]): DashboardRailCard[] { + return cards.map((card) => { + const details = this.detailsFor(card); + // Placeholder only before the FIRST portal answer: a refresh + // keeps the previous answer on screen instead of flashing. + const pending = !details && this.isAwaitingFirstAnswer(card); + if (!details && !pending) { + return card; + } + return { + ...card, + ...(details ?? {}), + nowPlayingState: pending ? 'pending' : null, + }; + }); + } + /** `null` when nothing is known about the card's current programme. */ detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null { if (!card) { return null; } - const program = getLiveEpgProgramForCard( - card, - this.programs(), - liveEpgScopeKey( - this.sourceUrlsForCard(card), - liveEpgAllowsAnySource(card) - ) - ); + // A portal answer wins. Its `null` ("asked, nothing on air") and + // "not asked yet" both fall back to the XMLTV lookup, which for a + // portal card can only ever be a title match. + const program = + this.portal.programFor(card.liveEpgSourceKey) ?? + getLiveEpgProgramForCard( + card, + this.programs(), + liveEpgScopeKey( + this.sourceUrlsForCard(card), + liveEpgAllowsAnySource(card) + ) + ); // Recompute the now-window each tick so progress moves between // 30s ticks even if the program identity is unchanged. return buildDashboardLiveEpgDetails( @@ -140,6 +233,17 @@ export class DashboardLiveEpgPresenter { ); } + /** + * True only before a card's FIRST portal answer, so a refresh keeps the + * previous answer on screen instead of flashing a placeholder. + */ + private isAwaitingFirstAnswer(card: DashboardRailCard): boolean { + return ( + this.portal.programFor(card.liveEpgSourceKey) === undefined && + this.portal.isPending(card.liveEpgSourceKey) + ); + } + /** The XMLTV scope a live card's programme must be resolved in. */ private sourceUrlsForCard(card: DashboardRailCard): string[] { const byPlaylistId = this.sourceUrlsByPlaylist(); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts new file mode 100644 index 000000000..9ac14530e --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts @@ -0,0 +1,166 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { + EpgProgram, + PortalActivityItem, +} from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { + DashboardPortalLiveEpgService, + type DashboardPortalLiveEpgEntry, +} from '@iptvnator/workspace/dashboard/data-access'; +import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter'; + +const xtreamLive = (id: number, playlist = 'p'): PortalActivityItem => + ({ + id: `x-${id}`, + title: `Channel ${id}`, + type: 'live', + source: 'xtream', + playlist_id: playlist, + category_id: '1', + xtream_id: id, + }) as PortalActivityItem; + +describe('DashboardPortalLiveEpgPresenter', () => { + let presenter: DashboardPortalLiveEpgPresenter; + let sync: jest.Mock; + let programs: ReturnType< + typeof signal> + >; + let pending: ReturnType>>; + let offsetMinutes: ReturnType>; + + /** Keys of every sync call, sorted: the wanted set has no order. */ + const wantedKeys = (): string[][] => + sync.mock.calls.map(([entries]: [DashboardPortalLiveEpgEntry[]]) => + entries.map((entry) => entry.key).sort() + ); + + beforeEach(() => { + sync = jest.fn(); + programs = signal>(new Map()); + pending = signal>(new Set()); + offsetMinutes = signal(0); + TestBed.configureTestingModule({ + providers: [ + DashboardPortalLiveEpgPresenter, + { + provide: DashboardPortalLiveEpgService, + useValue: { sync, programs, pending }, + }, + { + provide: SettingsStore, + useValue: { resolvedEpgOffsetMinutes: offsetMinutes }, + }, + ], + }); + presenter = TestBed.inject(DashboardPortalLiveEpgPresenter); + }); + + it('asks only for pinned and visible cards, deduplicated across rails', () => { + const items = signal([ + xtreamLive(1), + xtreamLive(2), + xtreamLive(3), + // The same channel in the recent rail shares its key. + xtreamLive(2), + ]); + presenter.connect(items); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual([]); + + presenter.setPinnedKeys(['xtream::p::1', null, undefined]); + presenter.setVisibleCards('favorites', [ + { id: 'f2', liveEpgSourceKey: 'xtream::p::2' }, + { id: 'f3', liveEpgSourceKey: 'xtream::p::3' }, + ] as never); + presenter.setVisibleCards('recent', [ + { id: 'r2', liveEpgSourceKey: 'xtream::p::2' }, + { id: 'm3u', liveEpgSourceKey: null }, + ] as never); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual([ + 'xtream::p::1', + 'xtream::p::2', + 'xtream::p::3', + ]); + + // Scrolled away: the favourites rail now shows only channel 3. + presenter.setVisibleCards('favorites', [ + { id: 'f3', liveEpgSourceKey: 'xtream::p::3' }, + ] as never); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual([ + 'xtream::p::1', + 'xtream::p::2', + 'xtream::p::3', + ]); + presenter.setVisibleCards('recent', []); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual(['xtream::p::1', 'xtream::p::3']); + }); + + it('ignores visible keys whose item is no longer on the dashboard', () => { + const items = signal([xtreamLive(1)]); + presenter.connect(items); + presenter.setVisibleCards('favorites', [ + { id: 'f1', liveEpgSourceKey: 'xtream::p::1' }, + { id: 'gone', liveEpgSourceKey: 'xtream::p::9' }, + ] as never); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']); + + items.set([]); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual([]); + }); + + it('re-syncs the same wanted set when the display offset changes', () => { + presenter.connect( + signal([xtreamLive(1)]) + ); + presenter.setPinnedKeys(['xtream::p::1']); + TestBed.tick(); + const before = sync.mock.calls.length; + + offsetMinutes.set(30); + TestBed.tick(); + expect(sync.mock.calls.length).toBe(before + 1); + expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']); + }); + + it('hands its wanted set back to the root service when the dashboard is destroyed', () => { + presenter.connect( + signal([xtreamLive(1)]) + ); + presenter.setPinnedKeys(['xtream::p::1']); + TestBed.tick(); + expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']); + + // The queue lives in the root service and would otherwise keep + // asking for cards on a page the user has left. + TestBed.resetTestingModule(); + expect(wantedKeys().at(-1)).toEqual([]); + }); + + it('answers a card from the service: undefined until asked, null when nothing is on air', () => { + const program = { title: 'Now' } as EpgProgram; + expect(presenter.programFor('xtream::p::1')).toBeUndefined(); + expect(presenter.programFor(null)).toBeUndefined(); + expect(presenter.isPending('xtream::p::1')).toBe(false); + + pending.set(new Set(['xtream::p::1'])); + expect(presenter.isPending('xtream::p::1')).toBe(true); + expect(presenter.isPending(undefined)).toBe(false); + + programs.set( + new Map([ + ['xtream::p::1', program], + ['xtream::p::2', null], + ]) + ); + expect(presenter.programFor('xtream::p::1')).toBe(program); + expect(presenter.programFor('xtream::p::2')).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts new file mode 100644 index 000000000..8d883fc96 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts @@ -0,0 +1,135 @@ +import { + computed, + DestroyRef, + effect, + inject, + Injectable, + signal, + untracked, + type Signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { interval, map } from 'rxjs'; +import type { + EpgProgram, + PortalActivityItem, +} from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { + buildDashboardPortalLiveEpgEntry, + DashboardPortalLiveEpgService, + type DashboardPortalLiveEpgEntry, +} from '@iptvnator/workspace/dashboard/data-access'; +import type { DashboardRailCard } from './dashboard-rail.component'; +import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils'; + +/** + * The rails component's view of `DashboardPortalLiveEpgService`: which + * portal live cards exist, which of them are on screen, and the answer for + * a card. Component-provided, so its wanted set dies with the dashboard. + * + * "On screen" is what the rails report through their visibility output, + * plus the pinned keys (the hero card, always at the top). A card the user + * never scrolls to is never asked for. The 30 s tick re-syncs so a programme + * that ended is asked again and progress bars keep moving; a changed display + * offset re-syncs at once because every cached answer was just retired. + */ +@Injectable() +export class DashboardPortalLiveEpgPresenter { + private readonly service = inject(DashboardPortalLiveEpgService); + private readonly settingsStore = inject(SettingsStore); + + private readonly source = signal | null>(null); + private readonly visibleKeysByRail = signal< + ReadonlyMap> + >(new Map()); + private readonly pinnedKeys = signal>(new Set()); + + /** Heartbeat shared with the XMLTV batch: shifted by one so the first + * emission differs from `initialValue` and is not swallowed. */ + readonly tick = toSignal( + interval(LIVE_EPG_TICK_MS).pipe(map((tick) => tick + 1)), + { initialValue: 0 } + ); + + private readonly entries = computed< + ReadonlyMap + >(() => { + const source = this.source(); + const entries = new Map(); + for (const item of source?.() ?? []) { + const entry = buildDashboardPortalLiveEpgEntry(item); + if (entry && !entries.has(entry.key)) { + entries.set(entry.key, entry); + } + } + return entries; + }); + + private readonly wanted = computed(() => { + const entries = this.entries(); + const keys = new Set(this.pinnedKeys()); + for (const railKeys of this.visibleKeysByRail().values()) { + for (const key of railKeys) keys.add(key); + } + const wanted: DashboardPortalLiveEpgEntry[] = []; + for (const key of keys) { + const entry = entries.get(key); + if (entry) wanted.push(entry); + } + return wanted; + }); + + constructor() { + effect(() => { + const wanted = this.wanted(); + this.tick(); + this.settingsStore.resolvedEpgOffsetMinutes(); + untracked(() => this.service.sync(wanted)); + }); + // The queue lives in the root service; this presenter owns what it + // wants. Leaving the dashboard must hand that back, or the queue + // would keep asking for cards on a page that is gone — and a later + // source change would ask for them again. + inject(DestroyRef).onDestroy(() => this.service.sync([])); + } + + /** The live rows every portal card on the dashboard is built from. */ + connect(source: Signal): void { + this.source.set(source); + } + + /** Keys wanted regardless of scrolling (the hero card). */ + setPinnedKeys(keys: readonly (string | null | undefined)[]): void { + this.pinnedKeys.set( + new Set(keys.filter((key): key is string => !!key)) + ); + } + + /** A rail reported the cards inside its viewport. */ + setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void { + const keys = new Set(); + for (const card of cards) { + if (card.liveEpgSourceKey) keys.add(card.liveEpgSourceKey); + } + this.visibleKeysByRail.update((byRail) => { + const next = new Map(byRail); + next.set(railId, keys); + return next; + }); + } + + /** + * `undefined` = not asked yet or still in flight, `null` = asked and + * nothing on air, else the programme. + */ + programFor(key: string | null | undefined): EpgProgram | null | undefined { + return key ? this.service.programs().get(key) : undefined; + } + + isPending(key: string | null | undefined): boolean { + return !!key && this.service.pending().has(key); + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index d43ede1b6..2dff0ea0d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -21,8 +21,7 @@ 'WORKSPACE.DASHBOARD.SEE_ALL_COUNT' | translate : { - count: - totalCount() ?? items().length, + count: totalCount() ?? items().length, } }} } @else { @@ -56,8 +55,10 @@ @for (card of items(); track card.id) { @if (layout() === 'channel') {
@if (card.nowPlayingTitle) { {{ card.nowPlayingTitle }} + } @else if ( + card.nowPlayingState === 'pending' + ) { + } @else if (card.subtitle) { {{ card.subtitle }} } @@ -132,8 +141,10 @@
} @else {