diff --git a/.changes/collections-open-stalker-live-channel-in-playlist.md b/.changes/collections-open-stalker-live-channel-in-playlist.md new file mode 100644 index 000000000..5295e1b16 --- /dev/null +++ b/.changes/collections-open-stalker-live-channel-in-playlist.md @@ -0,0 +1,6 @@ +--- +type: feature +area: collections +--- + +"Open in " for live channels in Favorites and Recently viewed now works for Stalker portals too: the chip in the programme panel and the channel's right-click menu jump to that channel inside its portal's Live TV, with its category selected and the channel playing. Radio stations keep the existing behaviour. 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/.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/.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/.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/.changes/stalker-blank-provider-id.md b/.changes/stalker-blank-provider-id.md new file mode 100644 index 000000000..b45639116 --- /dev/null +++ b/.changes/stalker-blank-provider-id.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Stalker items whose portal sends an empty `id` next to a valid one are no longer lost: such a channel or movie can be favorited, played, resumed and found again, instead of being stored without an identity and quietly refusing to open. diff --git a/.changes/xtream-epg-current-program-refresh.md b/.changes/xtream-epg-current-program-refresh.md new file mode 100644 index 000000000..aac681249 --- /dev/null +++ b/.changes/xtream-epg-current-program-refresh.md @@ -0,0 +1,7 @@ +--- +type: fix +area: epg +issues: [767] +--- + +The current program shown under each channel in the Xtream Live TV list now moves on by itself once that program ends, and its progress bar keeps advancing. Previously both stayed frozen on the old program until you left the category and came back in. 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/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/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/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/apps/web-e2e/src/stalker-itv-playback.fixture.ts b/apps/web-e2e/src/stalker-itv-playback.fixture.ts new file mode 100644 index 000000000..2a0ad5a62 --- /dev/null +++ b/apps/web-e2e/src/stalker-itv-playback.fixture.ts @@ -0,0 +1,32 @@ +import { expect, Page } from '@playwright/test'; + +/** + * Enters the ITV section, selects the second category (the first row is + * "All") and plays its first channel, returning every `create_link` request + * observed so callers can assert whether a link was minted. + */ +export async function playFirstItvChannel(page: Page): Promise { + const createLinkRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('action=create_link')) { + createLinkRequests.push(request.url()); + } + }); + + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + await channels.first().click(); + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); + await expect(page.locator('app-web-player-view')).toBeVisible({ + timeout: 20_000, + }); + + return createLinkRequests; +} diff --git a/apps/web-e2e/src/stalker-open-in-playlist.fixture.ts b/apps/web-e2e/src/stalker-open-in-playlist.fixture.ts new file mode 100644 index 000000000..79ed78d29 --- /dev/null +++ b/apps/web-e2e/src/stalker-open-in-playlist.fixture.ts @@ -0,0 +1,70 @@ +import { expect, Page } from '@playwright/test'; + +/** + * Favorites → "Open in " → the same channel selected and playing in + * the portal's ITV section (the live counterpart of "View in portal"). + * + * Runs against the PWA, where the collection tab renders no EPG panel and + * therefore no toolbar chip, so the row context menu is the entry point. The + * mock portal answers `get_all_channels`, so the layout locates the channel + * through the full ITV list and lands in its genre. + * + * Expects the portal to be imported already (`addStalkerPortal`). + */ +export async function verifyStalkerOpenInPlaylist( + page: Page, + portalName: string +): Promise { + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + const portalUrl = new URL(page.url()); + const itvPath = portalUrl.pathname; + + // Favorite the first channel of the second category (the first row is + // "All"), remembering its name for the round-trip assertion. + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + const channelName = ( + await channels.first().locator('.channel-name').innerText() + ).trim(); + expect(channelName).not.toBe(''); + await channels.first().locator('.favorite-button').click(); + await expect( + channels.first().locator('.favorite-button mat-icon') + ).toHaveText('star', { timeout: 10_000 }); + + // Global favorites, all playlists: the row offers the jump. + await page.goto('/workspace/global-favorites'); + const allPlaylists = page + .locator('.scope-toggle') + .getByRole('radio', { name: 'All playlists' }); + if (await allPlaylists.count()) { + await allPlaylists.click(); + } + const favoriteRow = page + .locator('app-global-favorites-list [data-test-id="channel-item"]') + .filter({ hasText: channelName }) + .first(); + await expect(favoriteRow).toBeVisible({ timeout: 20_000 }); + await favoriteRow.click({ button: 'right' }); + const openInPlaylist = page.locator( + '[data-testid="channel-open-in-playlist"]' + ); + await expect(openInPlaylist).toBeVisible({ timeout: 10_000 }); + await expect(openInPlaylist).toContainText(portalName); + await openInPlaylist.click(); + + // Lands inside the portal's ITV section with that channel selected and + // playing, in its own genre. + await page.waitForURL((url) => url.pathname === itvPath, { + timeout: 20_000, + }); + const activeRow = page.locator('[data-test-id="channel-item"].active'); + await expect(activeRow).toContainText(channelName, { timeout: 30_000 }); + await expect(page.locator('app-web-player-view')).toBeVisible({ + timeout: 20_000, + }); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index be251b322..5b2143329 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -6,6 +6,8 @@ import { verifyUncachedStalkerSearch, } from './stalker-category-search.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; +import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; +import { playFirstItvChannel } from './stalker-itv-playback.fixture'; import { expect, test } from './fixtures'; import { getRegisteredProviderUrl, @@ -467,6 +469,13 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => { expect(shortEpgRequests).toHaveLength(0); }); +test('@stalker ITV favorite opens inside its portal from global favorites', async ({ + page, +}) => { + await addStalkerPortal(page); + await verifyStalkerOpenInPlaylist(page, 'Mock Stalker Portal'); +}); + test('@stalker ITV playback survives a category switch', async ({ page }) => { await addStalkerPortal(page); await verifyStalkerPlaybackCategoryReturn(page); @@ -891,32 +900,6 @@ test('@stalker create_link returns a playable stream URL', async ({ * Play the first channel of the first ITV category and report every * `create_link` request the page made while doing so. */ -async function playFirstItvChannel(page: Page): Promise { - const createLinkRequests: string[] = []; - page.on('request', (request) => { - if (request.url().includes('action=create_link')) { - createLinkRequests.push(request.url()); - } - }); - - await page.getByRole('link', { name: /live|itv/i }).click(); - await page.waitForURL(/stalker.*itv/); - - const categories = page.locator('.category-item'); - await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - - const channels = page.locator('[data-test-id="channel-item"]'); - await expect(channels.first()).toBeVisible({ timeout: 20_000 }); - await channels.first().click(); - await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); - await expect(page.locator('app-web-player-view')).toBeVisible({ - timeout: 20_000, - }); - - return createLinkRequests; -} - test('@stalker ITV plays an unflagged channel without minting a link', async ({ page, }) => { 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/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 8ae92d2e7..585ec4245 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 @@ -1063,6 +1090,62 @@ These URLs are playlist-scoped by default: ## EPG Integration +### Xtream channel-row programme refresh + +The "current programme" line under each Xtream Live TV channel used to be +written only when a row scrolled into view, when an EPG result arrived, or +when the display offset changed. Nothing re-evaluated it as wall-clock time +passed, so once a programme ended the row stayed on it until the category was +left and re-entered (#767). + +`PortalChannelsListComponent` re-checks the rows on screen once a minute. The +rules, each of which exists to avoid a specific failure: + +- A programme still on air only has its **progress bar** advanced. No cache + read, no request: a row with nothing to learn must not cost traffic. +- Once it ends the row is re-picked from the queue's cache, and only a + programme that is **on air or upcoming** may replace it. A finished + programme is never re-applied — it would keep presenting itself as current, + and the earliest-item fallback that fills a blank row on first paint would + move an advanced row *backwards*. +- A cached guide whose programmes have **all** ended is dropped + (`EpgQueueService.invalidate`) and refetched, because the queue skips any + stream that still holds a cached answer. An **empty** answer means the + provider has no guide for that channel and is left alone; re-asking would + put one call per EPG-less visible row on the wire every minute. +- What is on screen stays there until a replacement arrives, so a refreshing + row never blanks out. + +A programme occupies `[start, stop)` in every one of these comparisons, so +"has it ended" and "what is on air" cannot disagree on the boundary instant. +The selection rules are pure functions in +`libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.ts` +and take an explicit `nowMs` in the PROVIDER's clock (`epgProviderClockMs`), +never `Date.now()`. + +Two root-provided services exist because a live layout mounts the channel list +more than once — the sidebar and the fullscreen channel panel render side by +side — over one shared `EpgQueueService`: + +- `EpgRefillLimiter` is the floor on dropping an exhausted cache, the one + place that overrides the queue's own throttling. A provider whose guide has + genuinely run out answers the refill with the same finished programmes, so + without a floor the row would ask again on the very next tick. Records carry + the owning playlist, since a stream id is provider-local and the service + outlives a playlist switch, and they expire by age rather than by viewport + membership — a claim dropped when its row scrolled away would be handed back + the moment the user scrolled to it again. +- `EpgRefreshCoordinator` owns the single timer and merges what every mounted + list needs into one queue request. `EpgQueueService.enqueue` is latest-wins: + it bumps one generation, replaces the queue and the visible set, and drops an + earlier caller's entries after its XMLTV await. Separate timers would cancel + each other whenever both lists had rows to fill, which is exactly what + happens on a programme boundary. Each list still decides for itself what is + stale (that reads only its own state) and contributes its whole visible + slice, since the queue drops anything outside the visible set it was last + handed; a channel both lists show is fetched once, and playlists stay apart + because their credentials differ. + ### XMLTV response compression The Electron EPG worker decodes HTTP `Content-Encoding` layers in reverse @@ -1227,9 +1310,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/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index ab49ff539..1bc555910 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 @@ -162,9 +176,63 @@ with a return handler keep Back available. effect re-runs as the store switches and loads; M3U navigates to `/workspace/playlists/:id/all` with `openM3uChannelUrl` (`OPEN_M3U_CHANNEL_URL_STATE_KEY`), the same key global search writes and - the M3U player selects by URL. Stalker resolves to `null` — its ITV layout - has no open-on-arrival contract yet, and a jump that only reached `/itv` - would not be the affordance promised — so the action is hidden there. + the M3U player selects by URL; Stalker navigates to `/workspace/stalker/:id/itv` + with `openStalkerLiveItemId` + `openStalkerLivePlaylistId` (+ the row's + genre as `openStalkerLiveCategoryId` when known) from + `buildStalkerLiveNavigationTarget`, which `StalkerLiveAutoOpen` + (`stalker-live-stream-layout/stalker-live-auto-open.ts`, the Stalker + counterpart of the Xtream service + effect) consumes: it reads the state at + construction and on every `NavigationEnd`, waits until `currentPlaylist` is + the requested portal (channel ids are provider-local, so a colliding id in + the previous portal's list must never match), then locates the channel in + the full ITV channel list cache — `get_ordered_list` is server-paged, so + the row may sit on any page of its genre — selects that genre (`'*'` for a + channel without one), expands the rail and plays it. While the list loads it + waits (the cache turning ready re-runs the effect); a portal that cannot + serve a full list (`itvFullListUnsupported`, the cache's reactive + unsupported set), a load that fails transiently (the cache only arms a + retry cooldown and changes no signal, so the flow awaits the preload + promise and treats "settled, neither ready nor unsupported" as the same + outcome) or a channel missing from the list (censored genres are excluded + from `get_all_channels`) falls back to selecting the remembered genre, so + the user still lands in the right list, and the handoff is consumed either + way. That remembered genre is the stored row's `tv_genre_id` (an opaque + portal id, numeric on most panels but not all); the row's `categoryId` + counts only when it is not a section marker, because app-written + favorites/recent rows carry `'itv'` there. Playback is deferred whenever + selecting the genre changes the list scope (another genre, the All Items + grid — `null`, a different row source from the `'*'` All list — or an + active search): `playChannel` → `navigation.prepare` captures the + displayed rows as the remote/numeric channel order, and the store serves a + category a tick after `setSelectedCategory` — even from the full-list + cache — so playing right away would capture the previous scope's queue. + The store answers "whose channels are on screen?" with + `itvChannelsCategory` (set wherever `itvChannels` is served, cleared by + `setItvChannels`), which is what the deferred play waits for. Array + identity cannot answer it: filtering by `'*'` hands back the cache by + reference, and clearing a search replaces the rendered list without the + source moving. Clearing the search IS synchronous, so a genre already on + screen plays at once. A pending play is dropped when a newer handoff + arrives, when the user switches portal, genre or section or starts a + search, and when the layout is destroyed. Two things settle before any of + that: `StalkerWorkspaceRouteSession.isReady` (its sync resets the selected + category and item for the arrival, and the store keeps the previous + portal's playlist and cache across a revisit, so the playlist check alone + passes too early and the selection would be wiped a tick later), and the + user — the handoff is abandoned when the genre or search changes away from + what it captured once it became actionable, which is measured after the + session's own resets so they never read as a user action. Readiness is + published by the NEWEST sync only, and never before the store holds that + portal's row: the session applies arrivals one at a time and claims the + playlist id only after `setCurrentPlaylist()` resolves. The constructor + starts a sync before the first `NavigationEnd` starts another, so two run + at once — the second used to find the id already claimed, skip the + bootstrap and report ready while the first was still awaiting that write, + which let a revisited same-id portal whose endpoint or credentials had + changed play against the PREVIOUS row. A failed bootstrap leaves readiness + false rather than handing the arrival a stale row. Stalker radio stations resolve to `null`: they live in the separate + `radio` section, whose station list is legacy-paged with no + open-on-arrival contract, so the action stays hidden for them. Two surfaces render the one verdict: `app-open-in-playlist-chip` (`libs/portal/shared/ui`), projected into the EPG timeline / list-view toolbar through the panels' `[epgToolbarAction]` content slot beside the @@ -227,9 +295,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/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 diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 2e7d8726e..afcc9584c 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1278,6 +1278,12 @@ The Stalker live route and radio route intentionally share in the channel header. Remote up/down, numeric selection and status retain the captured playback order while browsing. See the [queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order). +- Arrival with `openStalkerLiveItemId` in history state (a collection's + "open in playlist") is served by `StalkerLiveAutoOpen` + (`stalker-live-auto-open.ts`): once the store serves the requested portal + and the full ITV list (below) is ready, the channel is located there, its + genre selected and playback started; see + [Portal Detail Navigation](./portal-detail-navigation.md). ## Full ITV Channel List Cache diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index cea0e9e67..51f61c67c 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -113,31 +113,106 @@ 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 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()`, 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 +292,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/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 2bd1c9081..239feed90 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -209,3 +209,15 @@ Electron backend build depends on the web build; outputs live under `dist/apps/electron-backend` and `dist/apps/web` and packaging combines them. Use [the validation map](../architecture/validation-map.md) for test/lint tasks and [the release pipeline](../architecture/release-pipeline.md) for packaging. + +## Shared search text folding + +Use `foldSearchText` from `libs/shared/interfaces/src/lib/search-text-fold.util.ts` +on both sides of every in-memory search comparison. Channel lists, catalog and +category filters, command palette, sources and downloads must share this fold; +row filtering and count/queue sources must not diverge. It lowercases without a +locale, normalizes to NFC, then strips remaining combining marks. Composing first +keeps canonical spellings equivalent while preserving accents; the leftover dot +in Turkish dotted İ is stripped so it matches plain i. Do not substitute +`toLocaleLowerCase`. Electron content search uses the same fold and adds explicit +Turkish-locale İ variants to LIKE/GLOB queries because SQLite LIKE folds only ASCII. diff --git a/docs/maintenance/agent-guidance-migration.md b/docs/maintenance/agent-guidance-migration.md index 58b1e9c2a..e1fe2eaf3 100644 --- a/docs/maintenance/agent-guidance-migration.md +++ b/docs/maintenance/agent-guidance-migration.md @@ -776,3 +776,22 @@ navigation surface; this ledger records the one-time relocation. | CLAUDE.md:2005–2010 | Desktop Source Health — Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL checks through… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract | | CLAUDE.md:2012–2018 | Desktop Source Health — Desktop Sources also offers library-wide selective cleanup through dialog-scoped SourceCleanupService.… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract | | CLAUDE.md:2020–2022 | Desktop Source Health — Startup source auto-refresh uses SourceActivityService to protect busy IDs from cleanup. Late batch… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract | + +## Master integration follow-up + +During conflict resolution with master on 2026-09-21, the compact root guidance +was retained and the new upstream knowledge was checked against canonical docs: + +- Live channel playlist handoff, including Stalker arrival and fallback behavior: + [portal navigation](../architecture/portal-detail-navigation.md). +- Shared locale-independent search folding and SQLite search variants: + [agent workflow](../development/agent-workflow.md#shared-search-text-folding). +- Stalker series resume, watch kind versus routing kind, and dashboard labels: + [portal navigation](../architecture/portal-detail-navigation.md) and + [workspace dashboard](../architecture/workspace-dashboard.md). +- Scoped XMLTV fallback and Xtream programme refresh helpers: + [M3U contracts](../architecture/m3u-playlist-module.md). +- Lazy portal EPG queues, revision handling and dashboard fallback: + [workspace dashboard](../architecture/workspace-dashboard.md). + +The original 716-entry inventory above remains tied to its immutable source. 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/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/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/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index 8cdf687c4..4187ac620 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -284,11 +284,13 @@ describe('GlobalFavoritesListComponent', () => { ); }); - it('hides "open in playlist" for Stalker rows and never emits for them', async () => { + it('hides "open in playlist" for Stalker radio rows and never emits for them', async () => { const row = buildChannel('s', 'Stalker', { sourceType: 'stalker', streamUrl: undefined, + stalkerId: '30', stalkerCmd: 'ffmpeg http://stalker/30', + radio: 'true', }); const requested = jest.fn(); fixture.componentInstance.openInPlaylistRequested.subscribe(requested); 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-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/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..2bf3c87fb --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts @@ -0,0 +1,96 @@ +import { computed, Signal } from '@angular/core'; +import { + deriveVisibleFavoriteChannels, + FavoritesChannelSortMode, + resolveStalkerLiveGenreId, + resolveStalkerProviderId, + 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, + // Rows have no stored item to re-check, so only the row's proven + // provider id travels; a synthetic list id would open nothing. + stalkerId: + item.sourceType !== 'stalker' || item.stalkerItem == null + ? item.stalkerId + : (resolveStalkerProviderId(item.stalkerItem) ?? undefined), + stalkerGenreId: + item.sourceType === 'stalker' + ? resolveStalkerLiveGenreId(item) + : undefined, + 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/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts index 10d8bbf9c..f2a1fb9e2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts @@ -191,11 +191,39 @@ describe('UnifiedLiveTabComponent open in playlist', () => { ); }); - it('keeps the chip out of the panel for a Stalker channel', async () => { + it('offers the chip for a Stalker channel and lands on its ITV section', async () => { await activate('stalker'); + expect(chip()).not.toBeNull(); + ( + chip().componentInstance as OpenInPlaylistChipComponent + ).activated.emit(); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', 'pl-3', 'itv'], + { + state: { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveTitle: 'Stalker Live', + openStalkerLivePoster: 'stalker.png', + }, + } + ); + }); + + it('keeps the chip out of the panel for a Stalker radio station', async () => { + const item = { ...buildLiveItem('stalker'), radio: 'true' }; + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.openInPlaylistTarget()).toBeNull(); - expect(chip()).toBeNull(); + const list = fixture.debugElement.query( + By.directive(StubGlobalFavoritesListComponent) + ).componentInstance as StubGlobalFavoritesListComponent; + list.openInPlaylistRequested.emit(list.channels()[0]); + expect(navigate).not.toHaveBeenCalled(); }); it('navigates when a sidebar row asks to open in its playlist', async () => { @@ -216,7 +244,7 @@ describe('UnifiedLiveTabComponent open in playlist', () => { }); it('ignores row requests whose target does not resolve', async () => { - const item = buildLiveItem('stalker'); + const item = { ...buildLiveItem('stalker'), stalkerId: undefined }; fixture.componentRef.setInput('items', [item]); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts index 690d44773..057662a3d 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -73,6 +73,11 @@ export interface UnifiedFavoriteChannel { /** Stalker portal credentials needed to resolve the live stream */ stalkerPortalUrl?: string; stalkerMacAddress?: string; + + /** Stalker channel id — lets a row be opened inside its portal's ITV list */ + stalkerId?: string | number; + /** Stalker ITV genre id (numeric), the fallback list when the channel cannot be located */ + stalkerGenreId?: string | null; } /** 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/navigation/live-collection-playlist-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts index 888f2a198..3d22a5f0a 100644 --- a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts @@ -41,7 +41,157 @@ describe('getLiveCollectionPlaylistNavigation', () => { }); }); - it('hides the action for Stalker rows until their ITV layout can open a channel', () => { + it('opens a Stalker channel inside its portal ITV section, remembering its genre', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + contentType: 'live', + name: 'Stalker Live', + logo: 'stalker.png', + stalkerId: 30, + stalkerItem: { id: 30, tv_genre_id: 7 }, + }) + ).toEqual({ + link: ['/workspace', 'stalker', 'pl-3', 'itv'], + state: { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '7', + openStalkerLiveTitle: 'Stalker Live', + openStalkerLivePoster: 'stalker.png', + }, + }); + // The stored row's genre wins; `categoryId` is the section marker + // on app-written favorites and counts only when it is not one. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: '5', + stalkerItem: { id: 30, tv_genre_id: 7 }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('7'); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: '5', + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('5'); + // A stored row without a genre still falls back to the All list; + // the section marker alone (no stored row) yields no fallback. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + stalkerItem: { id: 30, tv_genre_id: ' ' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('*'); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + })?.state + ).not.toHaveProperty('openStalkerLiveCategoryId'); + // A stored row's own `category_id` is a genre when it is not the + // section marker. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + stalkerItem: { id: 30, category_id: '8' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('8'); + // ...and the row's section marker does not shadow the collection + // row's own genre. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: '4', + stalkerItem: { id: 30, category_id: 'itv' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('4'); + // Genre ids are opaque portal strings, not necessarily numeric. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + categoryId: 'itv', + stalkerItem: { id: 30, tv_genre_id: 'sports' }, + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('sports'); + // List rows carry the genre already resolved by the collection tab. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + stalkerGenreId: '9', + })?.state?.['openStalkerLiveCategoryId'] + ).toBe('9'); + }); + + it('hides the action for a stored Stalker row whose id is synthetic', () => { + // Collection services mint `-` for id-less rows; + // the ITV catalog cannot match it. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: 'pl-3-0', + stalkerItem: { name: 'Nameless', cmd: 'x' }, + }) + ).toBeNull(); + // VOD identities are not channel ids: the ITV list cannot match them. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '77', + stalkerItem: { series_id: 77, cmd: 'x' }, + }) + ).toBeNull(); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '30', + stalkerItem: { stream_id: 30 }, + })?.link + ).toEqual(['/workspace', 'stalker', 'pl-3', 'itv']); + // The stored row's own id wins over a synthetic list id minted from + // a blank `id` beside a valid `stream_id`. + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: '-', + stalkerItem: { id: '', stream_id: 30 }, + })?.state?.['openStalkerLiveItemId'] + ).toBe('30'); + }); + + it('hides the action for Stalker radio stations and rows without a channel id', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + stalkerId: 30, + radio: 'true', + }) + ).toBeNull(); expect( getLiveCollectionPlaylistNavigation({ sourceType: 'stalker', diff --git a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts index 6c04ac3de..d0bd554b9 100644 --- a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts @@ -1,4 +1,5 @@ import { + buildStalkerLiveNavigationTarget, buildXtreamNavigationTarget, WorkspaceNavigationTarget, } from './workspace-portal-navigation'; @@ -25,6 +26,20 @@ export interface LiveCollectionPlaylistNavigationSource { logo?: string | null; xtreamId?: number | null; streamUrl?: string; + /** Stalker channel id (`UnifiedCollectionItem.stalkerId`). */ + stalkerId?: string | number | null; + /** + * Stalker: used as the fallback genre unless it is a section marker. + * Favorites/recent rows written by the app carry the SECTION marker here + * (`'itv'`), which is not a genre. + */ + categoryId?: string | number | null; + /** Stalker: the stored row; its `tv_genre_id` is the channel's real genre. */ + stalkerItem?: unknown; + /** Stalker: the genre already resolved by `resolveStalkerLiveGenreId` (list rows). */ + stalkerGenreId?: string | null; + /** `'true'` for radio stations, which live in a different Stalker section. */ + radio?: string; } /** @@ -34,14 +49,15 @@ export interface LiveCollectionPlaylistNavigationSource { * The target is the channel itself, not the playlist root: Xtream lands on * the live layout with `openXtreamLiveItemId`, which its auto-open state * service turns into a playing, selected channel; M3U lands on the player's - * `all` view with `openM3uChannelUrl`, which selects the channel by URL. - * Stalker returns `null` for now — its ITV layout has no open-on-arrival - * contract yet, and a jump that only reached the section root would not be - * the affordance this promises. + * `all` view with `openM3uChannelUrl`, which selects the channel by URL; + * Stalker lands on the ITV section with `openStalkerLiveItemId`, which + * `StalkerLiveAutoOpen` resolves through the full channel list cache. Stalker + * radio stations return `null`: they live in the separate `radio` section, + * whose station list is legacy-paged and has no open-on-arrival contract. * * Like `getUnifiedCollectionDetailNavigation`, this never degrades to a - * playlist-only route: without a positive stream id (Xtream) or a stream URL - * (M3U) the caller is expected to hide the action. + * playlist-only route: without a positive stream id (Xtream), a stream URL + * (M3U) or a channel id (Stalker) the caller is expected to hide the action. */ export function getLiveCollectionPlaylistNavigation( source: LiveCollectionPlaylistNavigationSource @@ -82,5 +98,107 @@ export function getLiveCollectionPlaylistNavigation( }; } + if (source.sourceType === 'stalker') { + if (source.radio === 'true') { + return null; + } + // The stored row is the authority on the provider id: the collection + // services mint a synthetic list-only id (`-`, or + // `-` for a blank `id` beside a valid `stream_id`) for rows they + // cannot identify, and the ITV catalog can never match those. + const itemId = + source.stalkerItem === undefined || source.stalkerItem === null + ? source.stalkerId + : resolveStalkerProviderId(source.stalkerItem); + if (!itemId) { + return null; + } + + return buildStalkerLiveNavigationTarget({ + playlistId, + itemId, + categoryId: resolveStalkerLiveGenreId(source), + title: source.name, + imageUrl: source.logo ?? null, + }); + } + return null; } + +/** + * The Stalker live row's genre for the auto-open fallback: an already + * resolved value, else the stored row's `tv_genre_id` (genre ids are opaque + * portal strings, numeric on most panels but not all), else `categoryId` + * unless it is a section marker — app-written favorites/recent carry + * `'itv'` there, which is not a genre. + */ +export function resolveStalkerLiveGenreId( + source: Pick< + LiveCollectionPlaylistNavigationSource, + 'stalkerGenreId' | 'stalkerItem' | 'categoryId' + > +): string | null { + const stored = source.stalkerItem; + return ( + stalkerGenreId(source.stalkerGenreId) ?? + stalkerItemGenre(stored) ?? + stalkerCategoryGenre(source.categoryId) ?? + // An authoritative row answers even without a genre: a genreless + // channel lives in the All list, and a portal without a full list + // would otherwise land on the empty "select a category" screen. + (stored === undefined || stored === null ? null : '*') + ); +} + +const STALKER_SECTION_MARKERS = new Set(['itv', 'radio', 'vod', 'series']); + +/** + * The stored Stalker LIVE row's provider id: the first NON-BLANK of + * `id`/`stream_id`, or `null` for an id-less row. Exactly the fields the + * ITV cache mapper and crawler identify a channel by — `series_id` / + * `movie_id` name VOD entities that no channel list can match, so a row + * carrying only those is not openable here. (Unlike the collection + * services' extractor, a blank `id` does not shadow a valid `stream_id`.) + */ +export function resolveStalkerProviderId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const raw = item as Record; + for (const key of ['id', 'stream_id']) { + const value = String(raw[key] ?? '').trim(); + if (value) { + return value; + } + } + return null; +} + +/** + * The stored row's own genre, read in the order the live navigation reads + * it (`tv_genre_id` then `category_id`); the row's `category_id` can be the + * section marker, which is not a genre. + */ +function stalkerItemGenre(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const raw = item as { tv_genre_id?: unknown; category_id?: unknown }; + return ( + stalkerGenreId(raw.tv_genre_id) ?? stalkerCategoryGenre(raw.category_id) + ); +} + +/** Any non-blank genre id, the All pseudo-genre included. */ +function stalkerGenreId(value: unknown): string | null { + const text = String(value ?? '').trim(); + return text || null; +} + +function stalkerCategoryGenre(value: unknown): string | null { + const genre = stalkerGenreId(value); + return genre && !STALKER_SECTION_MARKERS.has(genre.toLowerCase()) + ? genre + : null; +} 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..0b79f1df5 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 @@ -20,6 +20,12 @@ export interface WorkspaceNavigationTarget { } export const OPEN_LIVE_COLLECTION_ITEM_STATE_KEY = 'openLiveCollectionItem'; +/** Stalker ITV arrival: the channel to select and play (consumed by the live layout). */ +export const OPEN_STALKER_LIVE_ITEM_STATE_KEY = 'openStalkerLiveItemId'; +export const OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY = 'openStalkerLivePlaylistId'; +export const OPEN_STALKER_LIVE_CATEGORY_STATE_KEY = 'openStalkerLiveCategoryId'; +export const OPEN_STALKER_LIVE_TITLE_STATE_KEY = 'openStalkerLiveTitle'; +export const OPEN_STALKER_LIVE_POSTER_STATE_KEY = 'openStalkerLivePoster'; export const OPEN_COLLECTION_DETAIL_STATE_KEY = 'openCollectionDetailItem'; export const OPEN_STALKER_ITEM_STATE_KEY = 'openStalkerItem'; export const STALKER_RETURN_TO_STATE_KEY = 'stalkerReturnTo'; @@ -88,12 +94,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 ); } @@ -195,6 +202,40 @@ export function buildXtreamNavigationTarget(params: { }; } +/** + * Lands on a Stalker live channel inside its portal's ITV section. The + * layout locates the channel through the full ITV channel list, scoped to + * `playlistId` (channel ids are provider-local); `categoryId` is the + * fallback list to open when the channel cannot be located. + */ +export function buildStalkerLiveNavigationTarget(params: { + playlistId: string; + itemId: string | number | null | undefined; + categoryId?: string | number | null; + title?: string; + imageUrl?: string | null; +}): WorkspaceNavigationTarget | null { + const playlistId = toPathSegment(params.playlistId); + const itemId = toPathSegment(params.itemId); + if (!playlistId || !itemId) { + return null; + } + + const categoryId = toPathSegment(params.categoryId); + return { + link: ['/workspace', 'stalker', playlistId, 'itv'], + state: { + [OPEN_STALKER_LIVE_ITEM_STATE_KEY]: itemId, + [OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY]: playlistId, + ...(categoryId + ? { [OPEN_STALKER_LIVE_CATEGORY_STATE_KEY]: categoryId } + : {}), + [OPEN_STALKER_LIVE_TITLE_STATE_KEY]: params.title || '', + [OPEN_STALKER_LIVE_POSTER_STATE_KEY]: params.imageUrl || '', + }, + }; +} + export function buildLiveCollectionNavigationTarget(params: { mode: 'favorites' | 'recent'; sourceType: CollectionSourceType; @@ -287,12 +328,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/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/data-access/src/lib/stalker-itv-cache.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts index bc5ab51bd..406da914d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts @@ -179,6 +179,30 @@ describe('StalkerItvCacheService', () => { expect(callsFor('get_ordered_list')).toBe(2); }); + it('keeps crawled rows whose blank id sits beside a stream_id', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: () => + pageOf( + [ + { + ...channel('', 'Stream id only', '5'), + stream_id: 30, + }, + channel('2', 'Plain', '5'), + ], + 2 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect( + service.getChannels(PLAYLIST)?.map((item) => String(item.id)) + ).toEqual(['30', '2']); + }); + it('reports crawl progress while loading and clears it afterwards', async () => { let resolveSecondPage!: (value: unknown) => void; mockRequests({ @@ -251,12 +275,14 @@ describe('StalkerItvCacheService', () => { page: () => pageOf([], 0), }); + expect(service.isUnsupported(PLAYLIST)).toBe(false); await service.ensureLoaded(PLAYLIST); const callsAfterFirstAttempt = sendIpcEvent.mock.calls.length; await service.ensureLoaded(PLAYLIST); expect(service.isReady(PLAYLIST)).toBe(false); + expect(service.isUnsupported(PLAYLIST)).toBe(true); expect(sendIpcEvent.mock.calls.length).toBe(callsAfterFirstAttempt); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts index e462ccde1..5279d6291 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts @@ -1,9 +1,6 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { - DataService, - resetHostConnectivityGuard, -} from '@iptvnator/services'; +import { DataService, resetHostConnectivityGuard } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { StalkerItvChannel } from './models'; import { @@ -57,7 +54,11 @@ export class StalkerItvCacheService { private readonly versionSignals = new Map>(); private readonly channelsByKey = new Map(); - private readonly unsupportedKeys = new Set(); + /** + * Portals whose full list cannot be served (legacy paged flow stays in + * charge). A signal so consumers can wait on "ready OR unsupported". + */ + private readonly unsupportedKeys = signal>(new Set()); private readonly inflight = new Map>(); /** Wall-clock (ms) until which a portal's crawl error suppresses retries. */ private readonly errorCooldownUntil = new Map(); @@ -83,6 +84,12 @@ export class StalkerItvCacheService { return key !== null && this.loadingKeys().has(key); } + /** Reactive: the portal answered but cannot provide a full list this session. */ + isUnsupported(playlist: PlaylistMeta | undefined): boolean { + const key = this.keyFor(playlist); + return key !== null && this.unsupportedKeys().has(key); + } + progressOf( playlist: PlaylistMeta | undefined ): StalkerItvLoadProgress | null { @@ -99,7 +106,9 @@ export class StalkerItvCacheService { * per-portal dependency via {@link versionFor}; boolean readiness for UI * stays on the reactive {@link isReady}. */ - getChannels(playlist: PlaylistMeta | undefined): StalkerItvChannel[] | null { + getChannels( + playlist: PlaylistMeta | undefined + ): StalkerItvChannel[] | null { const key = this.keyFor(playlist); return key === null ? null : (this.channelsByKey.get(key) ?? null); } @@ -114,7 +123,7 @@ export class StalkerItvCacheService { key === null || !playlist || this.readyKeys().has(key) || - this.unsupportedKeys.has(key) || + this.unsupportedKeys().has(key) || this.isInErrorCooldown(key) ) { return; @@ -143,7 +152,7 @@ export class StalkerItvCacheService { playlist.portalUrl ); - this.unsupportedKeys.delete(key); + this.patchKeySet(this.unsupportedKeys, key, false); this.errorCooldownUntil.delete(key); await this.runLoad(key, playlist); } @@ -194,7 +203,7 @@ export class StalkerItvCacheService { Date.now() + ERROR_COOLDOWN_MS ); } else if (!this.readyKeys().has(key)) { - this.unsupportedKeys.add(key); + this.patchKeySet(this.unsupportedKeys, key, true); } } finally { this.inflight.delete(key); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts index b90efc694..d8babfb04 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts @@ -3,6 +3,7 @@ import { StalkerPortalActions, } from '@iptvnator/shared/interfaces'; import { StalkerItvChannel } from './models'; +import { firstNonBlankStalkerIdText } from './stalker-vod.utils'; import { StalkerRequestDeps, executeStalkerRequest, @@ -21,9 +22,7 @@ export interface StalkerItvLoadProgress { * timeout, a page that failed both attempts) that is worth retrying later. */ export type StalkerItvLoadOutcome = - | StalkerItvChannel[] - | 'unsupported' - | 'error'; + StalkerItvChannel[] | 'unsupported' | 'error'; export interface StalkerItvLoadLogger { info(...args: unknown[]): void; @@ -112,7 +111,12 @@ async function crawlOrderedPages( onProgress: (loaded: number, total: number) => void, logger: StalkerItvLoadLogger ): Promise { - const firstResponse = await fetchOrderedPageWithRetry(deps, playlist, 1, logger); + const firstResponse = await fetchOrderedPageWithRetry( + deps, + playlist, + 1, + logger + ); if (firstResponse === null) { return 'error'; } @@ -208,13 +212,17 @@ function collectUnique( return added; } +/** + * First NON-BLANK of `id`/`stream_id`, matching `toStalkerItvChannel`: a + * blank `id` beside a valid `stream_id` must not make the row look id-less + * and get dropped by the de-duplication. + */ function rawChannelId(item: unknown): string | null { if (!item || typeof item !== 'object') { return null; } const source = item as { id?: unknown; stream_id?: unknown }; - const raw = source.id ?? source.stream_id; - return raw === undefined || raw === null || raw === '' ? null : String(raw); + return firstNonBlankStalkerIdText(source.id, source.stream_id) || null; } async function fetchOrderedPageWithRetry( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 8ac4bbda2..c41277099 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -2,6 +2,8 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces'; import { StalkerFavoriteItem } from './models'; import { buildStalkerFavoritePayload, + firstNonBlankStalkerId, + firstNonBlankStalkerIdText, buildStalkerSelectedVodItem, createStalkerInfo, createStalkerInlineDetailState, @@ -337,3 +339,28 @@ describe('stalker-vod.utils regressions', () => { }); }); }); + +describe('firstNonBlankStalkerId', () => { + it('skips a blank candidate instead of treating it as set', () => { + expect(firstNonBlankStalkerId('', ' ', '77')).toBe('77'); + expect(firstNonBlankStalkerId(null, undefined, 42)).toBe(42); + }); + + it('keeps a numeric id as a number so round-trips do not stringify it', () => { + expect(firstNonBlankStalkerId(12, '99')).toBe(12); + }); + + it('accepts zero, which is a set id and not a blank one', () => { + expect(firstNonBlankStalkerId(0, '99')).toBe(0); + }); + + it('skips a non-finite number rather than yielding "NaN"', () => { + expect(firstNonBlankStalkerId(Number.NaN, '5')).toBe('5'); + }); + + it('trims the text form and reports nothing when every candidate is blank', () => { + expect(firstNonBlankStalkerIdText(' 21 ')).toBe('21'); + expect(firstNonBlankStalkerIdText('', null, undefined)).toBe(''); + expect(firstNonBlankStalkerId('', null)).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index 398228470..18782b89e 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -1,7 +1,4 @@ -import { - signal, - WritableSignal, -} from '@angular/core'; +import { signal, WritableSignal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; import { Observable } from 'rxjs'; import { PlaylistsService } from '@iptvnator/services'; @@ -42,10 +39,7 @@ export interface StalkerFavoriteToggleOptions { item: Record, onComplete?: () => void ) => void; - removeFromFavorites: ( - favoriteId: string, - onComplete?: () => void - ) => void; + removeFromFavorites: (favoriteId: string, onComplete?: () => void) => void; onComplete?: () => void; } @@ -97,6 +91,44 @@ export function normalizeStalkerEntityId(value: unknown): string { return String(value).trim(); } +/** + * The first candidate that actually carries an identity. + * + * Portals send `id` and `stream_id` inconsistently, and a BLANK one is not + * an absent one: `a ?? b` keeps `''` and would store a row with no provider + * identity, which then cannot be selected, played or found again. Every + * reader normalizes with {@link normalizeStalkerEntityId}, so blank and + * whitespace-only values are equivalent to missing here too. + * + * Numbers keep their type so a numeric provider id survives round-trips; + * non-finite ones are not identities and are skipped rather than stringified + * into `"NaN"`. Callers own the PREFERENCE order — favorites read + * `stream_id` first, the channel cache reads `id` first — this only skips the + * empty ones. + */ +export function firstNonBlankStalkerId( + ...values: unknown[] +): string | number | undefined { + for (const value of values) { + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return value; + } + continue; + } + const text = normalizeStalkerEntityId(value); + if (text) { + return text; + } + } + return undefined; +} + +/** {@link firstNonBlankStalkerId} as the normalized text every reader compares. */ +export function firstNonBlankStalkerIdText(...values: unknown[]): string { + return normalizeStalkerEntityId(firstNonBlankStalkerId(...values)); +} + export function normalizeStalkerEntityIdAsNumber( value: unknown ): number | null { @@ -148,9 +180,7 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { // views normalize the selected item on every render ...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}), ...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}), - ...(info.tmdb_directors - ? { tmdb_directors: info.tmdb_directors } - : {}), + ...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}), ...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}), ...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}), ...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}), @@ -175,7 +205,7 @@ export function buildStalkerSelectedVodItem( forceSeries = false ): StalkerSelectedVodItem { return { - id: toStringOrFallback(item.id ?? item.stream_id), + id: firstNonBlankStalkerIdText(item.id, item.stream_id), cmd: toStringOrFallback(item.cmd), series: item.series, has_files: item.has_files, @@ -187,9 +217,7 @@ export function buildStalkerSelectedVodItem( : undefined, video_id: item.video_id, category_id: - typeof item.category_id === 'string' - ? item.category_id - : undefined, + typeof item.category_id === 'string' ? item.category_id : undefined, info: createStalkerInfo(item), }; } @@ -222,11 +250,10 @@ export function normalizeStalkerFavoriteItem( cmd: toStringOrFallback( (item as StalkerVodSource).cmd ?? source.cmd ), - id: toStringOrFallback(item.stream_id ?? item.id ?? source.id), + id: firstNonBlankStalkerIdText(item.stream_id, item.id, source.id), series: (item as StalkerVodSource).series ?? source.series, is_series: normalizedIsSeries ? true : undefined, - video_id: - (item as StalkerVodSource).video_id ?? source.video_id, + video_id: (item as StalkerVodSource).video_id ?? source.video_id, category_id: typeof (item as StalkerVodSource).category_id === 'string' ? (item as StalkerVodSource).category_id @@ -356,8 +383,7 @@ export function createPortalCollectionResource( refreshVersion: getRefreshVersion(), }; }, - stream: ({ params }) => - streamFactory(playlistService, params.portalId), + stream: ({ params }) => streamFactory(playlistService, params.portalId), }); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 3a4d0a669..23ffbb4f1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -120,6 +120,7 @@ function createItvCacheMock( return { versionFor: jest.fn(() => version()), getChannels: jest.fn(() => channels), + isUnsupported: jest.fn(() => false), ensureLoaded: jest.fn().mockResolvedValue(undefined), refresh: jest.fn().mockResolvedValue(undefined), isReady: jest.fn(() => channels !== null), @@ -396,6 +397,102 @@ describe('withStalkerContent failure states', () => { expect(store.hasMoreChannels()).toBe(false); }); + it('reports which category the ITV channels on screen were served for', async () => { + dataService.sendIpcEvent.mockImplementation(() => + Promise.resolve({ + js: { + data: [{ id: 'channel-1', name: 'One', category_id: '5' }], + total_items: 1, + }, + }) + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '9', category_name: 'Sports' }, + ]); + store.setCurrentPlaylist(PLAYLIST); + expect(store.itvChannelsCategory()).toBeNull(); + + store.setSelectedCategory('5'); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.itvChannels().length === 1); + + expect(store.itvChannelsCategory()).toBe('5'); + + // Selecting another genre does not retroactively re-label the rows + // still on screen: they belong to '5' until the new page arrives. + store.setSelectedCategory('9'); + expect(store.itvChannelsCategory()).toBe('5'); + + await waitForCondition(() => store.itvChannelsCategory() === '9'); + + // Clearing the list leaves no category on screen. + store.setItvChannels([]); + expect(store.itvChannelsCategory()).toBeNull(); + }); + + it("does not report another portal's served category as its own", async () => { + dataService.sendIpcEvent.mockImplementation(() => + Promise.resolve({ + js: { + data: [{ id: 'channel-1', name: 'One', category_id: '5' }], + total_items: 1, + }, + }) + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + ]); + store.setCurrentPlaylist(PLAYLIST); + store.setSelectedCategory('5'); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.itvChannelsCategory() === '5'); + + // Switching portal keeps the previous rows until the new portal's + // own load lands; genre ids are provider-local, so the marker must + // not answer for a portal that did not serve them. + store.setCurrentPlaylist({ ...PLAYLIST, _id: 'other-portal' }); + + expect(store.itvChannelsCategory()).toBeNull(); + }); + + it('forgets the served category when a failed ITV page clears the rows', async () => { + dataService.sendIpcEvent.mockImplementation(() => + Promise.resolve({ + js: { + data: [{ id: 'channel-1', name: 'One', category_id: '5' }], + total_items: 1, + }, + }) + ); + + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '9', category_name: 'Sports' }, + ]); + store.setCurrentPlaylist(PLAYLIST); + store.setSelectedCategory('5'); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.itvChannelsCategory() === '5'); + + // The next category's first page fails: the rows are cleared, so no + // category is on screen — a stale marker would tell an auto-open + // handoff for genre 5 that its channels are still rendered. + dataService.sendIpcEvent.mockImplementation(() => + Promise.reject(new Error('portal down')) + ); + store.setSelectedCategory('9'); + + await waitForCondition(() => store.contentError() !== null); + expect(store.itvChannels()).toEqual([]); + expect(store.itvChannelsCategory()).toBeNull(); + }); + it('appends later VOD pages into one continuous deduplicated list', async () => { dataService.sendIpcEvent.mockImplementation( (_event: unknown, payload: { params?: { p?: number } }) => { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index f4e69d4f6..739007c96 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -44,6 +44,18 @@ export interface StalkerContentState { radioCategories: StalkerCategoryItem[]; hasMoreChannels: boolean; itvChannels: StalkerItvChannel[]; + /** + * Who `itvChannels` belong to: the portal AND category they were served + * for, or `null` while no channels are. Rows lag `selectedCategoryId` — + * the resource resolves a tick later even when it serves from the + * full-list cache — so this is the only honest answer to "whose channels + * are on screen?". Array identity cannot answer it: filtering by `'*'` + * hands back the cache by reference, and clearing a search replaces the + * RENDERED list without the source changing at all. The portal is part + * of the record because a switch keeps the previous portal's rows until + * its own load lands, and two portals share genre ids. + */ + itvChannelsSource: { playlistKey: string | null; category: string } | null; radioChannels: StalkerItvChannel[]; paginatedContent: StalkerContentItem[]; categoryError: unknown; @@ -64,6 +76,7 @@ const initialContentState: StalkerContentState = { radioCategories: [], hasMoreChannels: false, itvChannels: [], + itvChannelsSource: null, radioChannels: [], paginatedContent: [], categoryError: null, @@ -170,6 +183,13 @@ function fallbackRadioCategories( return [buildAllCategory('radio', translateService)]; } +/** Stable identity of a Stalker portal inside the content resource. */ +function stalkerPlaylistKey( + playlist: { _id?: string; portalUrl?: string } | null | undefined +): string | null { + return playlist?._id ?? playlist?.portalUrl ?? null; +} + function buildEmptyContentPatch( contentType: StalkerContentType, error: unknown @@ -185,6 +205,11 @@ function buildEmptyContentPatch( patch.hasMoreChannels = false; if (contentType === 'itv') { patch.itvChannels = []; + // The source record describes the rows: cleared rows belong to + // nobody, or an auto-open handoff for the category these rows + // CAME from would read the stale marker as proof that its genre + // is on screen and prepare playback from an empty queue. + patch.itvChannelsSource = null; } else { patch.radioChannels = []; } @@ -458,6 +483,11 @@ export function withStalkerContent() { totalCount: channels.length, paginatedContent: channels, itvChannels: channels, + itvChannelsSource: { + playlistKey: + stalkerPlaylistKey(playlist), + category: String(categoryParam), + }, hasMoreChannels: false, contentError: null, }); @@ -471,17 +501,14 @@ export function withStalkerContent() { void itvCache.ensureLoaded(playlist); } - const paramsPlaylistKey = - params.currentPlaylist?._id ?? - params.currentPlaylist?.portalUrl ?? - null; + const paramsPlaylistKey = stalkerPlaylistKey( + params.currentPlaylist + ); const isCurrentRequest = (): boolean => { const currentPlaylist = storeContext.currentPlaylist(); const currentPlaylistKey = - currentPlaylist?._id ?? - currentPlaylist?.portalUrl ?? - null; + stalkerPlaylistKey(currentPlaylist); return ( !abortSignal.aborted && @@ -624,7 +651,16 @@ export function withStalkerContent() { paginatedContent: newItems, contentError: null, ...(params.contentType === 'itv' - ? { itvChannels: nextChannels } + ? { + itvChannels: nextChannels, + itvChannelsSource: { + playlistKey: + paramsPlaylistKey, + category: String( + params.category ?? '*' + ), + }, + } : { radioChannels: nextChannels }), hasMoreChannels: channels.length > 0 && @@ -753,6 +789,24 @@ export function withStalkerContent() { itvFullListLoading: computed(() => itvCache.isLoading(storeContext.currentPlaylist()) ), + /** + * The category the channels on screen were served for, but + * only while they belong to the portal on screen: a switch + * keeps the previous portal's rows until its own load lands, + * and two portals share genre ids. + */ + itvChannelsCategory: computed(() => { + const source = store.itvChannelsSource(); + return source && + source.playlistKey === + stalkerPlaylistKey(storeContext.currentPlaylist()) + ? source.category + : null; + }), + /** True once the portal proved it cannot serve a full list this session. */ + itvFullListUnsupported: computed(() => + itvCache.isUnsupported(storeContext.currentPlaylist()) + ), itvFullListProgress: computed(() => itvCache.progressOf(storeContext.currentPlaylist()) ), @@ -881,8 +935,10 @@ export function withStalkerContent() { * available immediately. Safe to call repeatedly — the cache * de-duplicates in-flight loads and memoizes unsupported portals. */ - preloadItvChannels(): void { - void itvCache.ensureLoaded(storeContext.currentPlaylist()); + preloadItvChannels(): Promise { + return itvCache.ensureLoaded( + storeContext.currentPlaylist() + ); }, /** * Re-runs the content loader with unchanged params — the retry @@ -922,7 +978,13 @@ export function withStalkerContent() { }); }, setItvChannels(channels: StalkerItvChannel[]) { - patchState(store, { itvChannels: channels }); + patchState(store, { + itvChannels: channels, + // The only caller clears the list for a category the + // resource has not served yet, so nothing on screen + // belongs to a category until it does. + itvChannelsSource: null, + }); }, setRadioChannels(channels: StalkerItvChannel[]) { patchState(store, { radioChannels: channels }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts index 474141b86..0577300c9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts @@ -53,9 +53,7 @@ describe('withStalkerFavorites', () => { addPortalFavorite: jest.fn(() => of({ favorites: [{ id: '42', title: 'Movie Title' }] }) ), - removeFromPortalFavorites: jest.fn(() => - of({ favorites: [] }) - ), + removeFromPortalFavorites: jest.fn(() => of({ favorites: [] })), }; snackBar = { open: jest.fn() }; ngrxStore = { dispatch: jest.fn() }; @@ -77,6 +75,22 @@ describe('withStalkerFavorites', () => { }); describe('addToFavorites', () => { + it('keeps a valid id when the provider sends a blank stream_id', () => { + // `??` would keep the blank value and leave the stored row with + // no provider identity at all. + store.addToFavorites({ + stream_id: '', + id: 'channel-7', + name: 'Blank stream id', + category_id: '17', + }); + + expect(playlistService.addPortalFavorite).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ id: 'channel-7' }) + ); + }); + it('persists the favorite with a normalized payload and syncs playlist meta', () => { const onDone = jest.fn(); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts index 3150ea696..6ad7e2634 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts @@ -5,6 +5,7 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistMeta, StalkerPortalItem } from '@iptvnator/shared/interfaces'; +import { firstNonBlankStalkerId } from '../../stalker-vod.utils'; import { StalkerSelectionStoreContract } from '../stalker-store.contracts'; import { dispatchStalkerPlaylistMetaUpdate, @@ -53,7 +54,16 @@ export function withStalkerFavorites() { storeContext.selectedContentType() ), added_at: Date.now(), - id: item.stream_id ?? item.id, + // First NON-BLANK: `??` keeps a blank + // `stream_id`, which would overwrite a valid + // `id` and leave the stored row with no + // provider identity at all — unremovable by + // id, and invisible to anything that looks + // the channel up again. + id: firstNonBlankStalkerId( + item.stream_id, + item.id + ), }) .subscribe((updatedPlaylist) => { dispatchStalkerPlaylistMetaUpdate( diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts index fb6c5c4fe..5b97905f4 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts @@ -6,8 +6,8 @@ import { import { StalkerVodInfo, TmdbMediaType } from '@iptvnator/shared/interfaces'; import { StalkerVodSource } from '../models'; import { + firstNonBlankStalkerIdText, isStalkerSeriesItem, - normalizeStalkerEntityId, } from '../stalker-vod.utils'; /** @@ -52,14 +52,14 @@ export async function enrichStalkerSelectionWithTmdb( return; } - const itemId = normalizeStalkerEntityId(item.id ?? item.stream_id); + const itemId = firstNonBlankStalkerIdText(item.id, item.stream_id); const isCurrent = (): StalkerVodSource | null => { const current = store.selectedItem(); if (!current) { return null; } const matches = itemId - ? normalizeStalkerEntityId(current.id ?? current.stream_id) === + ? firstNonBlankStalkerIdText(current.id, current.stream_id) === itemId : current === item; return matches ? current : null; diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts index fa7968792..615f60a9b 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts @@ -1,7 +1,7 @@ import { Store } from '@ngrx/store'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { PlaylistMeta, StalkerPortalItem } from '@iptvnator/shared/interfaces'; -import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; +import { firstNonBlankStalkerIdText } from '../../stalker-vod.utils'; import { StalkerContentType, StalkerRecentlyViewedItem, @@ -49,7 +49,7 @@ export function buildStalkerRecentlyViewedPayload( ), ...getStalkerSeriesRecentMetadata(selectedContentType), added_at: Date.now(), - id: normalizeStalkerEntityId(item.id ?? item.stream_id ?? ''), + id: firstNonBlankStalkerIdText(item.id, item.stream_id), title: item.title ?? item.name ?? item.o_name ?? '', }; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts index 3533e68ec..6d84c97cf 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts @@ -1,5 +1,8 @@ import { StalkerItvChannel } from '../../models'; -import { filterItvChannelsByGenre } from './stalker-content-mappers'; +import { + filterItvChannelsByGenre, + toStalkerItvChannel, +} from './stalker-content-mappers'; describe('filterItvChannelsByGenre', () => { const CHANNELS: StalkerItvChannel[] = [ @@ -30,3 +33,22 @@ describe('filterItvChannelsByGenre', () => { expect(filterItvChannelsByGenre(CHANNELS, '404')).toEqual([]); }); }); + +describe('toStalkerItvChannel', () => { + it('keeps a provided id', () => { + expect(toStalkerItvChannel({ id: 7, cmd: 'x' }).id).toBe(7); + }); + + it('does not let a blank id shadow the stream_id', () => { + expect( + toStalkerItvChannel({ id: '', stream_id: 30, cmd: 'x' }).id + ).toBe(30); + expect( + toStalkerItvChannel({ id: ' ', stream_id: '31', cmd: 'x' }).id + ).toBe('31'); + }); + + it('falls back to an empty id when neither is set', () => { + expect(toStalkerItvChannel({ cmd: 'x' }).id).toBe(''); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts index 359dcd5d1..229fda1c4 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts @@ -1,3 +1,4 @@ +import { firstNonBlankStalkerId } from '../../stalker-vod.utils'; import { StalkerContentItem, StalkerItvChannel, @@ -79,17 +80,17 @@ export function filterItvChannelsByGenre( ); } -export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { +export function toStalkerItvChannel( + item: StalkerContentItem +): StalkerItvChannel { return { ...item, - id: item.id ?? item.stream_id ?? '', + // First NON-BLANK id: a blank `id` beside a valid `stream_id` must + // not shadow it, or the row is unselectable and unplayable. + id: firstNonBlankStalkerId(item.id, item.stream_id) ?? '', cmd: String(item.cmd ?? ''), - name: - typeof item.name === 'string' ? item.name : undefined, - o_name: - typeof item.o_name === 'string' ? item.o_name : undefined, - logo: - typeof item.logo === 'string' ? item.logo : undefined, + name: typeof item.name === 'string' ? item.name : undefined, + o_name: typeof item.o_name === 'string' ? item.o_name : undefined, + logo: typeof item.logo === 'string' ? item.logo : undefined, }; } - 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-collection-playback.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts index e53c547ab..7e2c54dc0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts @@ -8,7 +8,7 @@ import type { UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { - normalizeStalkerEntityId, + firstNonBlankStalkerIdText, StalkerStore, } from '@iptvnator/portal/stalker/data-access'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @@ -223,11 +223,13 @@ function captureStalkerCollectionPlaybackOwner( const providerItem = item.stalkerItem as { id?: unknown; stream_id?: unknown } | undefined; const uidParts = item.uid.split('::'); - const contentId = normalizeStalkerEntityId( - providerItem?.id ?? - providerItem?.stream_id ?? - item.stalkerId ?? - uidParts[uidParts.length - 1] + // A blank provider id is not an absent one: `??` would keep it and leave + // the session without an identity instead of falling through. + const contentId = firstNonBlankStalkerIdText( + providerItem?.id, + providerItem?.stream_id, + item.stalkerId, + uidParts[uidParts.length - 1] ); if (!sourceId || !contentId) return null; 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-auto-open-state.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open-state.ts new file mode 100644 index 000000000..a923ea54b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open-state.ts @@ -0,0 +1,95 @@ +import { signal } from '@angular/core'; +import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access'; +import { + OPEN_STALKER_LIVE_CATEGORY_STATE_KEY, + OPEN_STALKER_LIVE_ITEM_STATE_KEY, + OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY, + OPEN_STALKER_LIVE_POSTER_STATE_KEY, + OPEN_STALKER_LIVE_TITLE_STATE_KEY, +} from '@iptvnator/portal/shared/util'; + +/** + * The live handoff carried by the arrival's history state, and nothing else: + * which channel, in which portal, with which remembered genre. The + * counterpart of Xtream's `LiveStreamAutoOpenStateService`, kept apart from + * the coordinator that decides WHEN to act on it + * (`StalkerLiveAutoOpen`), because reading and retiring the browser state is + * a separate concern from waiting for the portal, the list and the user. + * + * `generation` is what makes a retired handoff final: work already in flight + * for it (a full-list preload promise) captures the value and drops itself + * when it no longer matches. + */ +export class StalkerLiveAutoOpenState { + readonly pendingItemId = signal(null); + readonly pendingPlaylistId = signal(null); + readonly pendingCategoryId = signal(null); + + private currentGeneration = 0; + + get generation(): number { + return this.currentGeneration; + } + + /** + * Reads the arrival's handoff. Returns true when a NEW pending item was + * recorded, so the caller can retire whatever the previous one started. + */ + captureFromHistoryState(): boolean { + const state = (window.history.state ?? null) as Record< + string, + unknown + > | null; + const itemId = normalizeStalkerEntityId( + state?.[OPEN_STALKER_LIVE_ITEM_STATE_KEY] + ); + if (!itemId) { + this.clearPendingItem(); + return false; + } + + this.pendingPlaylistId.set( + normalizeStalkerEntityId( + state?.[OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY] + ) || null + ); + this.pendingCategoryId.set( + normalizeStalkerEntityId( + state?.[OPEN_STALKER_LIVE_CATEGORY_STATE_KEY] + ) || null + ); + this.currentGeneration += 1; + this.pendingItemId.set(itemId); + return true; + } + + clearPendingItem(): void { + this.currentGeneration += 1; + this.pendingItemId.set(null); + this.pendingPlaylistId.set(null); + this.pendingCategoryId.set(null); + } + + /** Retires the handoff from the history entry so it cannot replay. */ + clearHistoryState(): void { + try { + const state = (window.history.state ?? {}) as Record< + string, + unknown + >; + if (!(OPEN_STALKER_LIVE_ITEM_STATE_KEY in state)) { + return; + } + + const nextState = { ...state }; + delete nextState[OPEN_STALKER_LIVE_ITEM_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_PLAYLIST_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_CATEGORY_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_TITLE_STATE_KEY]; + delete nextState[OPEN_STALKER_LIVE_POSTER_STATE_KEY]; + window.history.replaceState(nextState, document.title); + } catch { + // Browser history state can be unavailable in restricted contexts. + } + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts new file mode 100644 index 000000000..8f36491ca --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts @@ -0,0 +1,639 @@ +import { DestroyRef, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { StalkerItvChannel } from '@iptvnator/portal/stalker/data-access'; +import { Subject } from 'rxjs'; +import { StalkerLiveAutoOpen } from './stalker-live-auto-open'; + +const channel = ( + id: string, + genre: string | number = 7 +): StalkerItvChannel => ({ + id, + name: `Channel ${id}`, + cmd: `stream-${id}`, + tv_genre_id: genre, +}); + +describe('StalkerLiveAutoOpen', () => { + let autoOpen: StalkerLiveAutoOpen; + let events: Subject; + const store = { + currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }), + selectedContentType: signal('itv'), + selectedCategoryId: signal(null), + itvChannelsCategory: signal(null), + searchPhrase: signal(''), + itvFullChannelList: signal([]), + itvFullListActive: signal(false), + itvFullListUnsupported: signal(false), + preloadItvChannels: jest.fn(() => Promise.resolve()), + setSearchPhrase: jest.fn((phrase: string) => + store.searchPhrase.set(phrase) + ), + setSelectedCategory: jest.fn((category: string | null) => + store.selectedCategoryId.set(category) + ), + setPage: jest.fn(), + }; + const sidebar = { expand: jest.fn() }; + const play = jest.fn(); + const routeReady = signal(true); + const options = () => ({ + store, + router: { events } as unknown as Router, + destroyRef: TestBed.inject(DestroyRef), + sidebar, + routeReady: () => routeReady(), + play, + }); + + function arrive( + state: Record = { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + } + ): void { + window.history.replaceState(state, ''); + events.next(new NavigationEnd(1, '/itv', '/itv')); + TestBed.tick(); + } + + beforeEach(() => { + jest.clearAllMocks(); + window.history.replaceState({}, ''); + store.currentPlaylist.set({ _id: 'pl-3' }); + store.selectedContentType.set('itv'); + store.selectedCategoryId.set(null); + store.itvChannelsCategory.set(null); + store.searchPhrase.set(''); + store.itvFullChannelList.set([]); + store.itvFullListActive.set(false); + store.itvFullListUnsupported.set(false); + store.preloadItvChannels.mockImplementation(() => Promise.resolve()); + routeReady.set(true); + events = new Subject(); + TestBed.configureTestingModule({}); + autoOpen = TestBed.runInInjectionContext( + () => new StalkerLiveAutoOpen(options()) + ); + TestBed.tick(); + }); + + /** The store serves the selected genre's channels (a tick later). */ + function serveCategory(category: string): void { + store.itvChannelsCategory.set(category); + TestBed.tick(); + } + + afterEach(() => { + window.history.replaceState({}, ''); + }); + + it('reads a handoff that was in history before construction', () => { + window.history.replaceState( + { openStalkerLiveItemId: '30', openStalkerLivePlaylistId: 'pl-3' }, + '' + ); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30')]); + store.selectedCategoryId.set('7'); + store.itvChannelsCategory.set('7'); + + const mounted = TestBed.runInInjectionContext( + () => new StalkerLiveAutoOpen({ ...options(), router: null }) + ); + TestBed.tick(); + + // Genre 7's channels were already on screen: play at once. + expect(play).toHaveBeenCalledWith(channel('30')); + expect(mounted.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('starts the full-list load and plays the channel in its genre once the list is ready', () => { + arrive(); + + expect(store.preloadItvChannels).toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + + store.itvFullChannelList.set([channel('1', 2), channel('30', 7)]); + store.itvFullListActive.set(true); + TestBed.tick(); + + expect(store.setSearchPhrase).toHaveBeenCalledWith(''); + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(store.setPage).toHaveBeenCalledWith(0); + expect(sidebar.expand).toHaveBeenCalledWith('portal'); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + // Playback waits for genre 7's channels: `navigation.prepare` would + // otherwise capture the previous scope's rows as the channel order. + expect(play).not.toHaveBeenCalled(); + + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('defers while another genre is still on screen, even when it shows the channel', () => { + // The All list on screen holds the channel, but those rows are not + // genre 7's: prepare() would capture the wrong channel order. + const allItems = [channel('1', 2), channel('30', 7)]; + store.selectedCategoryId.set('*'); + store.itvChannelsCategory.set('*'); + store.itvFullChannelList.set(allItems); + store.itvFullListActive.set(true); + + arrive(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(play).not.toHaveBeenCalled(); + + serveCategory('7'); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('plays at once when a search narrows the genre already on screen', () => { + // Clearing the search re-derives the rendered rows synchronously, + // and the source list is already genre 7's. + store.selectedCategoryId.set('7'); + store.itvChannelsCategory.set('7'); + store.searchPhrase.set('news'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + + expect(store.setSearchPhrase).toHaveBeenCalledWith(''); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('plays once a legacy-paged genre is served, even without the row on page 1', () => { + store.selectedCategoryId.set('2'); + store.itvChannelsCategory.set('2'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + expect(play).not.toHaveBeenCalled(); + + serveCategory('7'); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('keeps waiting while the previous genre is still the served list', () => { + store.selectedCategoryId.set('2'); + store.itvChannelsCategory.set('2'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + TestBed.tick(); + + expect(play).not.toHaveBeenCalled(); + }); + + it('lets a newer handoff supersede a channel still waiting for its rows', () => { + store.itvFullChannelList.set([channel('30', 7), channel('31', 7)]); + store.itvFullListActive.set(true); + + arrive(); + arrive({ + openStalkerLiveItemId: '31', + openStalkerLivePlaylistId: 'pl-3', + }); + serveCategory('7'); + + expect(play).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledWith(channel('31', 7)); + }); + + it('defers the jump from the All Items grid into the All category list', () => { + // The grid (`selectedCategoryId` null) is not a served category, so + // even a genre-less channel waits for the '*' list. + const noGenre = { id: '30', cmd: 'x', name: 'No genre' }; + store.selectedCategoryId.set(null); + store.itvFullChannelList.set([noGenre]); + store.itvFullListActive.set(true); + + arrive(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('*'); + expect(play).not.toHaveBeenCalled(); + + serveCategory('*'); + expect(play).toHaveBeenCalledWith(noGenre); + }); + + it('drops the deferred play when the user switches portal first', () => { + // A genre-less channel targets '*', which a fresh portal's reset + // (selectedCategoryId null) would otherwise look identical to. + const noGenre = { id: '30', cmd: 'x', name: 'No genre' }; + store.selectedCategoryId.set('2'); + store.itvChannelsCategory.set('2'); + store.itvFullChannelList.set([noGenre]); + store.itvFullListActive.set(true); + + arrive(); + expect(play).not.toHaveBeenCalled(); + + store.currentPlaylist.set({ _id: 'pl-9' }); + store.selectedCategoryId.set('*'); + TestBed.tick(); + serveCategory('*'); + + expect(play).not.toHaveBeenCalled(); + }); + + it('drops the deferred play when the user starts a search first', () => { + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + store.setSearchPhrase('sport'); + TestBed.tick(); + serveCategory('7'); + + expect(play).not.toHaveBeenCalled(); + }); + + it('drops the deferred play when the user selects another genre first', () => { + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + store.setSelectedCategory('2'); + TestBed.tick(); + serveCategory('7'); + + expect(play).not.toHaveBeenCalled(); + }); + + it('falls back to the remembered genre when the full-list load fails transiently', async () => { + // The cache resolves the preload without turning ready or unsupported + // (it only arms a retry cooldown), so nothing reactive would ever + // re-run the effect. + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive({ + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }); + expect(autoOpen.pendingItemId()).toBe('30'); + + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('5'); + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('retires the transient fallback when the layout is destroyed mid-load', async () => { + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + let destroy: (() => void) | undefined; + const destroyRef = { + onDestroy: (callback: () => void) => { + destroy = callback; + return () => undefined; + }, + } as unknown as DestroyRef; + window.history.replaceState( + { + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }, + '' + ); + const mounted = TestBed.runInInjectionContext( + () => + new StalkerLiveAutoOpen({ + ...options(), + router: null, + destroyRef, + }) + ); + TestBed.tick(); + expect(mounted.pendingItemId()).toBe('30'); + + destroy?.(); + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(mounted.pendingItemId()).toBeNull(); + // History belongs to the page the user navigated to: left untouched. + expect(window.history.state.openStalkerLiveItemId).toBe('30'); + }); + + it('lets a load that turns ready win over its own settled promise', async () => { + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive(); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + TestBed.tick(); + serveCategory('7'); + expect(play).toHaveBeenCalledTimes(1); + + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledTimes(1); + }); + + it('waits while the store still serves another portal, even one holding the same id', () => { + store.currentPlaylist.set({ _id: 'pl-1' }); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30', 9)]); + + arrive(); + + expect(play).not.toHaveBeenCalled(); + expect(store.preloadItvChannels).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + + store.currentPlaylist.set({ _id: 'pl-3' }); + store.itvFullChannelList.set([channel('30', 7)]); + TestBed.tick(); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(channel('30', 7)); + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + }); + + it('matches a cached channel whose blank id sits beside its stream_id', () => { + const byStreamId = { + id: '', + stream_id: 30, + cmd: 'x', + name: 'Stream id only', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([byStreamId]); + + arrive(); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(byStreamId); + }); + + it('waits for the route session before touching the store', () => { + // Revisiting a portal keeps its playlist and cache, so the playlist + // check passes at once — but the session's own reset would wipe the + // selection a tick later. + routeReady.set(false); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30', 7)]); + + arrive(); + + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(store.preloadItvChannels).not.toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + + routeReady.set(true); + TestBed.tick(); + serveCategory('7'); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('drops the handoff when the user picks another genre while the list loads', () => { + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive(); + expect(autoOpen.pendingItemId()).toBe('30'); + + // The user moved on before the full list arrived. + store.selectedCategoryId.set('2'); + TestBed.tick(); + + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + + settle(); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + TestBed.tick(); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).not.toHaveBeenCalledWith('7'); + }); + + it('does not fall back over a genre the user chose while the preload was pending', async () => { + // The promise can settle before the effect that abandons the handoff + // runs, so the continuation rechecks the user's own state. + let settle!: () => void; + store.preloadItvChannels.mockImplementation( + () => new Promise((resolve) => (settle = resolve)) + ); + + arrive({ + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }); + expect(autoOpen.pendingItemId()).toBe('30'); + + // The user picks another genre; the preload settles before the + // effect pass that would retire the handoff. + store.selectedCategoryId.set('2'); + settle(); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.setSelectedCategory).not.toHaveBeenCalledWith('5'); + expect(play).not.toHaveBeenCalled(); + }); + + it('drops the handoff when the user starts searching while the list loads', () => { + store.preloadItvChannels.mockImplementation( + () => new Promise(() => undefined) + ); + + arrive(); + expect(autoOpen.pendingItemId()).toBe('30'); + + store.searchPhrase.set('sport'); + TestBed.tick(); + + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it("does not read the route session's own reset as the user moving on", () => { + // The session clears the category for the arrival; that is not a + // user action and must not drop the handoff. + routeReady.set(false); + store.selectedCategoryId.set('4'); + store.itvFullChannelList.set([channel('30', 7)]); + store.itvFullListActive.set(true); + + arrive(); + store.selectedCategoryId.set(null); + routeReady.set(true); + TestBed.tick(); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(channel('30', 7)); + }); + + it('opens the All list for a cached row whose genre the cache cannot filter', () => { + // `filterItvChannelsByGenre` mirrors the portal's `genre=` filter, + // i.e. `tv_genre_id`: a row carrying only `category_id` is absent + // from that genre's cached slice, so selecting it would strand the + // channel behind a paged request. The All list always holds it. + const byCategoryId = { + id: '30', + cmd: 'x', + name: 'Category id only', + category_id: '8', + } as unknown as StalkerItvChannel; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([byCategoryId]); + + arrive(); + serveCategory('*'); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('*'); + expect(play).toHaveBeenCalledWith(byCategoryId); + }); + + it('finds a channel persisted under its stream_id while the cache keys it by id', () => { + // A favorite stores `stream_id ?? id`, a cached channel keeps + // `id ?? stream_id` — a row with two different ids must still match. + const twoIds = { + id: '5', + stream_id: '99', + cmd: 'x', + name: 'Two ids', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + const other = channel('7', 7); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([other, twoIds]); + + arrive({ + openStalkerLiveItemId: '99', + openStalkerLivePlaylistId: 'pl-3', + }); + serveCategory('7'); + + expect(play).toHaveBeenCalledWith(twoIds); + }); + + it('plays nothing when two channels claim the handoff id under different fields', () => { + // The handoff carries a bare value and cannot say which field it came + // from, so an ambiguous identity must not gamble on playback; the + // remembered genre still opens. + const byId = { + id: '99', + cmd: 'x', + name: 'By id', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + const byStreamId = { + id: '5', + stream_id: '99', + cmd: 'x', + name: 'By stream id', + tv_genre_id: 7, + } as unknown as StalkerItvChannel; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([byStreamId, byId]); + + arrive({ + openStalkerLiveItemId: '99', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '4', + }); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('4'); + expect(autoOpen.pendingItemId()).toBeNull(); + }); + + it('does nothing outside the ITV section', () => { + store.selectedContentType.set('radio'); + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('30')]); + + arrive(); + + expect(play).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBe('30'); + }); + + it('falls back to the remembered category when the portal has no full list', () => { + store.itvFullListUnsupported.set(true); + + arrive({ + openStalkerLiveItemId: '30', + openStalkerLivePlaylistId: 'pl-3', + openStalkerLiveCategoryId: '5', + }); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).toHaveBeenCalledWith('5'); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('consumes a handoff whose channel is missing from the loaded list', () => { + store.itvFullListActive.set(true); + store.itvFullChannelList.set([channel('1')]); + + arrive(); + + expect(play).not.toHaveBeenCalled(); + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(autoOpen.pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it.each([ + { label: 'missing', genre: undefined }, + { label: 'blank', genre: ' ' }, + ])( + 'opens the All pseudo-category for a channel with a $label genre', + ({ genre }) => { + const noGenre = { + id: '30', + cmd: 'x', + name: 'No genre', + tv_genre_id: genre, + }; + store.itvFullListActive.set(true); + store.itvFullChannelList.set([noGenre]); + + arrive(); + serveCategory('*'); + + expect(store.setSelectedCategory).toHaveBeenCalledWith('*'); + expect(play).toHaveBeenCalledWith(noGenre); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.ts new file mode 100644 index 000000000..948454bdc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.ts @@ -0,0 +1,349 @@ +import { + DestroyRef, + effect, + signal, + untracked, + type Signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { + normalizeStalkerEntityId, + StalkerItvChannel, +} from '@iptvnator/portal/stalker/data-access'; +import { StalkerLiveAutoOpenState } from './stalker-live-auto-open-state'; + +/** The slice of `StalkerStore` the auto-open flow reads and drives. */ +export interface StalkerLiveAutoOpenStore { + currentPlaylist: Signal<{ _id?: string } | null | undefined>; + selectedContentType: Signal; + selectedCategoryId: Signal; + /** The category the channels on screen were served for (see the store). */ + itvChannelsCategory: Signal; + searchPhrase: Signal; + itvFullChannelList: Signal; + itvFullListActive: Signal; + itvFullListUnsupported: Signal; + /** Resolves when the full-list load settles (also when it fails). */ + preloadItvChannels(): Promise | void; + setSearchPhrase(phrase: string): void; + setSelectedCategory(category: string | null): void; + setPage(page: number): void; +} + +export interface StalkerLiveAutoOpenOptions { + store: StalkerLiveAutoOpenStore; + router: Router | null; + destroyRef: DestroyRef; + sidebar?: { expand(surface: 'portal'): void }; + /** + * False while the route session is still applying the route to the store + * (`StalkerWorkspaceRouteSession.isReady`). Defaults to ready when no + * session is provided. + */ + routeReady?: () => boolean; + play: (item: StalkerItvChannel) => void; +} + +interface DeferredPlay { + item: StalkerItvChannel; + /** The portal the channel belongs to; a portal switch drops the request. */ + playlistId: string; + /** The genre whose channels must be on screen before playback starts. */ + category: string; +} + +/** + * Opens the channel named in the arrival's history state + * (`openStalkerLiveItemId`, written by `buildStalkerLiveNavigationTarget`) + * inside the ITV layout — the Stalker counterpart of Xtream's + * `LiveStreamAutoOpenStateService` + effect. + * + * The state is read once at construction and again on every NavigationEnd: + * a cross-route arrival may activate this component after or before that + * event depending on the shell, and a playlist switch reuses it. The pending + * item is scoped to its playlist (`openStalkerLivePlaylistId`): the shared + * store may still serve the previous portal, and channel ids are + * provider-local, so nothing is matched until `currentPlaylist` is the + * requested one. + * + * Location goes through the full ITV channel list cache (the same list the + * count badges and search use), because `get_ordered_list` is server-paged + * and the channel may sit on any page of its genre. While that list loads the + * effect waits (it re-runs when the cache turns ready). A portal that cannot + * provide the list, or a channel missing from it (censored genres are + * excluded from `get_all_channels`), falls back to selecting the category the + * collection row remembered, when it did, so the user lands in the right list + * even though the row cannot be picked; then the handoff is consumed. A load + * that fails transiently (the cache only arms a retry cooldown, no signal + * changes) resolves the same way: the preload promise settles, the list is + * neither ready nor unsupported, and the genre fallback runs instead of + * leaving the handoff pending forever. + * + * Playback waits until the channels on screen are the genre's: `playChannel` + * → `navigation.prepare` captures the displayed rows as the remote/numeric + * channel order, and the store serves a category a tick after + * `setSelectedCategory` — even from the full-list cache — so playing right + * away would capture the previous scope's queue. The store answers "whose + * channels are these?" with `itvChannelsCategory`; array identity cannot, + * because filtering by `'*'` hands back the cache by reference and clearing + * a search replaces the rendered list without the source moving. Clearing + * the search IS synchronous, so a genre already on screen plays at once. A + * newer handoff, a portal/section/genre switch or a fresh search drops a + * pending play. + * + * Two things must settle before any of that: the route session, which resets + * the selected category and item on arrival and would wipe a selection made + * ahead of it (the store keeps the previous portal's playlist and cache + * across a revisit, so the playlist check alone passes too early), and the + * user, who may pick another genre or start searching while the full list + * loads — measured against the list state captured once the handoff became + * actionable, so the session's own resets never read as a user action. + */ +export class StalkerLiveAutoOpen { + private readonly state = new StalkerLiveAutoOpenState(); + readonly pendingItemId = this.state.pendingItemId; + readonly pendingPlaylistId = this.state.pendingPlaylistId; + readonly pendingCategoryId = this.state.pendingCategoryId; + private readonly deferredPlay = signal(null); + /** The list state when the handoff became actionable (see the class). */ + private pendingBaseline: { + category: string | null; + search: string; + } | null = null; + + constructor(private readonly options: StalkerLiveAutoOpenOptions) { + this.captureFromHistoryState(); + options.router?.events + ?.pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(options.destroyRef) + ) + .subscribe(() => this.captureFromHistoryState()); + + effect(() => this.run()); + effect(() => this.runDeferredPlay()); + // A preload promise can settle after the layout is gone; retire the + // handoff so its fallback cannot touch the store or history state of + // whatever page the user navigated to. + options.destroyRef.onDestroy(() => { + this.clearPendingItem(); + this.deferredPlay.set(null); + }); + } + + captureFromHistoryState(): void { + this.state.captureFromHistoryState(); + // Whatever the previous handoff started is retired either way: a + // newer one supersedes a channel still waiting for its genre's rows, + // and an arrival without a handoff has nothing to continue. + this.deferredPlay.set(null); + this.pendingBaseline = null; + } + + clearPendingItem(): void { + this.pendingBaseline = null; + this.state.clearPendingItem(); + } + + clearHistoryState(): void { + this.state.clearHistoryState(); + } + + private run(): void { + const pendingId = this.pendingItemId(); + if (!pendingId) { + return; + } + + const { store } = this.options; + if (store.selectedContentType() !== 'itv') { + return; + } + + // The route session resets the selected category and item for this + // arrival; settling first would be undone a tick later. + if (!(this.options.routeReady?.() ?? true)) { + return; + } + + const playlist = store.currentPlaylist(); + const pendingPlaylistId = this.pendingPlaylistId(); + if ( + !playlist || + (pendingPlaylistId && + normalizeStalkerEntityId(playlist._id) !== pendingPlaylistId) + ) { + return; + } + + // The user owns the list once the handoff is actionable: picking + // another genre or starting a search means they moved on, and the + // channel must not hijack the view when the full list finally lands. + if (!this.pendingBaseline) { + this.pendingBaseline = { + category: store.selectedCategoryId() ?? null, + search: store.searchPhrase().trim(), + }; + } else if (!this.handoffStillOwnsTheList()) { + untracked(() => { + this.clearPendingItem(); + this.clearHistoryState(); + }); + return; + } + + if (store.itvFullListUnsupported()) { + untracked(() => this.settle(null)); + return; + } + + if (!store.itvFullListActive()) { + // Idempotent: the layout's own preload effect may already have + // started it, and the cache de-duplicates in-flight loads. When + // the load settles without the list turning ready or unsupported + // it failed transiently — fall back to the genre rather than wait + // for a retry nothing schedules. + const generation = this.state.generation; + untracked(() => { + void Promise.resolve(store.preloadItvChannels()).then(() => { + // The promise can settle before the effect that would + // abandon a handoff the user walked away from, so the + // user's own state is rechecked here too. + if ( + generation !== this.state.generation || + !this.pendingItemId() || + store.itvFullListActive() || + store.itvFullListUnsupported() || + !this.handoffStillOwnsTheList() + ) { + return; + } + this.settle(null); + }); + }); + return; + } + + // Either provider id can be the one that was persisted: a favorite + // prefers `stream_id` (`with-stalker-favorites.feature.ts`) while a + // cached channel prefers `id`, both skipping blanks, so a row + // carrying two different non-blank ids is reachable only by + // accepting both. + // The handoff carries a bare value, so it cannot say WHICH field it + // came from: when one channel claims it as its id and another as its + // stream id, the identity is ambiguous and neither is played — + // guessing an order would open the wrong channel half the time. The + // genre fallback still runs, so the user lands in the right list. + const candidates = store + .itvFullChannelList() + .filter( + (channel) => + normalizeStalkerEntityId(channel.id) === pendingId || + normalizeStalkerEntityId(channel.stream_id) === pendingId + ); + untracked(() => + this.settle(candidates.length === 1 ? candidates[0] : null) + ); + } + + private settle(item: StalkerItvChannel | null): void { + const { store } = this.options; + // A located row is played FROM THE CACHE, and the cached list is + // filtered by `tv_genre_id` alone — `filterItvChannelsByGenre` + // deliberately mirrors the portal's own `genre=` filter. Selecting a + // genre that filter cannot serve (a row carrying only `category_id`) + // would strand the channel behind a paged request that may not + // answer, so the All list, which always holds it, is the honest + // destination. A blank genre (`''`, whitespace) is as absent as a + // missing one. The remembered genre for a row that could NOT be + // located is a different case: it is served by the portal, so it + // does read `category_id` (`resolveStalkerLiveGenreId`). + const category = item + ? normalizeStalkerEntityId(item.tv_genre_id) || '*' + : this.pendingCategoryId(); + + if (category) { + store.setSearchPhrase(''); + store.setSelectedCategory(category); + store.setPage(0); + this.options.sidebar?.expand('portal'); + } + if (item && category) { + if (store.itvChannelsCategory() === category) { + this.options.play(item); + } else { + this.deferredPlay.set({ + item, + playlistId: normalizeStalkerEntityId( + store.currentPlaylist()?._id + ), + category, + }); + } + } + + this.clearPendingItem(); + this.clearHistoryState(); + } + + /** + * Whether the handoff still describes what the user is looking at: the + * requested portal, the ITV section, and the genre and search it + * captured once it became actionable. + */ + private handoffStillOwnsTheList(): boolean { + const { store } = this.options; + if (store.selectedContentType() !== 'itv') { + return false; + } + + const playlist = store.currentPlaylist(); + const pendingPlaylistId = this.pendingPlaylistId(); + if ( + !playlist || + (pendingPlaylistId && + normalizeStalkerEntityId(playlist._id) !== pendingPlaylistId) + ) { + return false; + } + + const baseline = this.pendingBaseline; + return ( + !baseline || + (baseline.category === (store.selectedCategoryId() ?? null) && + baseline.search === store.searchPhrase().trim()) + ); + } + + private runDeferredPlay(): void { + const deferred = this.deferredPlay(); + if (!deferred) { + return; + } + + const { store } = this.options; + if ( + normalizeStalkerEntityId(store.currentPlaylist()?._id) !== + deferred.playlistId || + store.selectedContentType() !== 'itv' || + store.selectedCategoryId() !== deferred.category || + store.searchPhrase().trim() !== '' + ) { + // The user moved on (another portal, genre or section, or + // started a search) before the genre's rows arrived. + untracked(() => this.deferredPlay.set(null)); + return; + } + + if (store.itvChannelsCategory() !== deferred.category) { + return; + } + + untracked(() => { + this.deferredPlay.set(null); + this.options.play(deferred.item); + }); + } +} 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..58bf07cbf 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 @@ -1,6 +1,7 @@ import { ChannelScrollFocusDirective } from '@iptvnator/ui/components'; import { NgTemplateOutlet } from '@angular/common'; import { + DestroyRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -42,6 +43,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + foldSearchText, buildStalkerEpgMappingKey, Channel, EpgItem, @@ -132,12 +134,14 @@ 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'; +import { StalkerLiveAutoOpen } from './stalker-live-auto-open'; +import { StalkerWorkspaceRouteSession } from '../stalker-workspace-route-session.service'; @Component({ selector: 'app-stalker-live-stream-layout', @@ -187,6 +191,9 @@ export class StalkerLiveStreamLayoutComponent private readonly streamHeaders = inject(ElectronStreamHeadersService); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + private readonly routeSession = inject(StalkerWorkspaceRouteSession, { + optional: true, + }); private readonly liveSidebarStateService = inject( LiveLayoutSidebarStateService ); @@ -203,7 +210,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( @@ -606,6 +613,17 @@ export class StalkerLiveStreamLayoutComponent this.isLoadingMore() || this.stalkerStore.isPaginatedContentLoading(), }); + /** Arrival handoff: select and play `openStalkerLiveItemId` (see the class). */ + readonly autoOpen = new StalkerLiveAutoOpen({ + store: this.stalkerStore, + router: inject(Router, { optional: true }), + destroyRef: inject(DestroyRef), + sidebar: this.liveSidebarStateService, + routeReady: () => this.routeSession?.isReady() ?? true, + play: (item) => { + void this.playChannel(item, true); + }, + }); private epgPreviewRefreshTimer: ReturnType | null = null; private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; @@ -1099,7 +1117,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 +1794,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/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/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts index 93854bffa..92f60e416 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { EMPTY, Subject, of } from 'rxjs'; +import { EMPTY, Observable, Subject, of } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from '@iptvnator/services'; @@ -17,6 +17,13 @@ const ACTIVE_PLAYLIST: PlaylistMeta = { title: 'Test Stalker', } as PlaylistMeta; +const OTHER_PLAYLIST_ID = 'stalker-2'; +const OTHER_PLAYLIST: PlaylistMeta = { + ...ACTIVE_PLAYLIST, + _id: OTHER_PLAYLIST_ID, + title: 'Other Stalker', +} as PlaylistMeta; + const FULL_STALKER_PLAYLIST: PlaylistMeta = { ...ACTIVE_PLAYLIST, isFullStalkerPortal: true, @@ -28,8 +35,11 @@ const FULL_STALKER_PLAYLIST: PlaylistMeta = { } as PlaylistMeta; async function flushEffects(): Promise { - await Promise.resolve(); - await Promise.resolve(); + // The route session serializes its syncs on a promise queue, so settling + // one arrival costs several microtask hops rather than a fixed two. + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } } function getStalkerSectionFromUrl(url: string): string | null { @@ -99,6 +109,9 @@ describe('StalkerWorkspaceRouteSession', () => { stalkerStore.setSelectedContentType.mockClear(); stalkerStore.setSearchPhrase.mockClear(); playlistsService.getPlaylistById.mockClear(); + // mockClear keeps a return value a previous case installed, so restore + // the default here: a leaked pending observable hangs the next sync. + playlistsService.getPlaylistById.mockReturnValue(of(ACTIVE_PLAYLIST)); await TestBed.configureTestingModule({ providers: [ @@ -200,4 +213,88 @@ describe('StalkerWorkspaceRouteSession', () => { FULL_STALKER_PLAYLIST ); }); + it('stays unready until an overlapping sync has installed the portal row', async () => { + // The constructor starts one sync; a NavigationEnd starts a second + // while the first is still awaiting the playlist. The second must not + // skip the bootstrap and report ready against the previous row. + let releasePlaylist: (playlist: PlaylistMeta) => void = () => undefined; + playlistsService.getPlaylistById.mockReturnValue( + new Observable((subscriber) => { + releasePlaylist = (playlist) => { + subscriber.next(playlist); + subscriber.complete(); + }; + }) + ); + + const session = TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled(); + + routerEvents.next( + new NavigationEnd(1, router.url, router.url) as NavigationEnd + ); + await flushEffects(); + + expect(session.isReady()).toBe(false); + + releasePlaylist(FULL_STALKER_PLAYLIST); + await flushEffects(); + await flushEffects(); + + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes(1); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + FULL_STALKER_PLAYLIST + ); + expect(session.isReady()).toBe(true); + }); + + it('does not let a superseded sync publish readiness', async () => { + const session = TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(session.isReady()).toBe(true); + + let releaseSecond: (playlist: PlaylistMeta) => void = () => undefined; + playlistsService.getPlaylistById.mockReturnValue( + new Observable((subscriber) => { + releaseSecond = (playlist) => { + subscriber.next(playlist); + subscriber.complete(); + }; + }) + ); + + // Arrive at a DIFFERENT portal, then immediately at a third: the + // first arrival must not flip readiness back on behind the newest one. + activePlaylist.set(null); + router.url = `/workspace/stalker/${OTHER_PLAYLIST_ID}/itv`; + playlistContext.syncFromUrl.mockImplementation((url: string) => ({ + inWorkspace: true, + provider: 'stalker', + playlistId: OTHER_PLAYLIST_ID, + section: getStalkerSectionFromUrl(url) as 'itv', + })); + + routerEvents.next( + new NavigationEnd(2, router.url, router.url) as NavigationEnd + ); + await flushEffects(); + routerEvents.next( + new NavigationEnd(3, router.url, router.url) as NavigationEnd + ); + await flushEffects(); + + expect(session.isReady()).toBe(false); + + releaseSecond(OTHER_PLAYLIST); + await flushEffects(); + await flushEffects(); + + expect(session.isReady()).toBe(true); + expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith( + OTHER_PLAYLIST + ); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts index 7aa702d7b..053cdfb22 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -28,6 +28,29 @@ export class StalkerWorkspaceRouteSession { private currentPlaylistId: string | null = null; private readonly currentSection = signal(null); + private readonly synced = signal(false); + + /** + * Arrivals are applied one at a time, and only the newest one publishes + * readiness. Both halves are load-bearing: the constructor starts a sync + * before the first `NavigationEnd` starts another, so two can be in + * flight at once, and the playlist id is claimed only once the store + * actually holds that row — otherwise the second sync saw the id already + * claimed, skipped the bootstrap, and reported ready while the first was + * still awaiting `setCurrentPlaylist()`, leaving the auto-open handoff to + * resolve and play against the PREVIOUS portal's row. + */ + private syncGeneration = 0; + private pendingSync: Promise = Promise.resolve(); + /** + * False while this session is applying the route to the store. Its sync + * resets the selected category and item (and, on a section change, the + * search) before awaiting the playlist, and the store keeps the previous + * portal's playlist and cache across a revisit — so anything that acts on + * store state for an arrival (the live auto-open handoff) must wait, or + * its selection is wiped a tick later. + */ + readonly isReady = this.synced.asReadonly(); constructor() { this.router.events @@ -39,6 +62,11 @@ export class StalkerWorkspaceRouteSession { takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { + // Synchronous, and before the async sync: this session's + // subscription is registered from an ENVIRONMENT_INITIALIZER + // when the route injector is created, so it runs ahead of the + // components that read `isReady`. + this.synced.set(false); void this.syncRouteContext(); }); @@ -51,25 +79,53 @@ export class StalkerWorkspaceRouteSession { }); } - private async syncRouteContext(): Promise { + private syncRouteContext(): Promise { + const generation = ++this.syncGeneration; + // Read the route SYNCHRONOUSLY: `syncFromUrl` publishes the active + // playlist context, and deferring it onto the queue would leave every + // consumer of that context a tick behind the navigation. const routeContext = this.playlistContext.syncFromUrl(this.router.url); + const run = async (): Promise => { + try { + await this.applyRouteContext(generation, routeContext); + } catch { + // A failed bootstrap deliberately leaves readiness false: the + // store may still hold the previous portal, and acting on that + // is worse than not acting at all. + } + }; + + this.pendingSync = this.pendingSync.then(run, run); + + return this.pendingSync; + } + + private async applyRouteContext( + generation: number, + routeContext: ReturnType + ): Promise { const playlistId = routeContext.provider === 'stalker' ? routeContext.playlistId : null; if (playlistId && this.currentPlaylistId !== playlistId) { - this.currentPlaylistId = playlistId; - this.stalkerStore.resetCategories(); this.stalkerStore.setSelectedCategory(null); this.stalkerStore.clearSelectedItem(); const playlist = await this.resolveStalkerPlaylist(playlistId); await this.stalkerStore.setCurrentPlaylist(playlist); + this.currentPlaylistId = playlistId; + } + + if (generation !== this.syncGeneration) { + // A newer arrival is queued behind this one and owns readiness. + return; } this.syncRouteState(routeContext.section); + this.synced.set(true); } private syncRouteState(section: PortalRailSection | null): void { 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/epg-preview-program.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.spec.ts new file mode 100644 index 000000000..49b098ab5 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.spec.ts @@ -0,0 +1,153 @@ +import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + epgProgramProgressPercent, + hasEpgProgramEnded, + pickAiringOrUpcomingEpgItem, + pickEpgPreviewItem, + toSharedEpgProgram, +} from './epg-preview-program'; + +function item(title: string, startIso: string, stopIso: string): EpgItem { + return { + id: title, + epg_id: `epg-${title}`, + title, + lang: 'en', + start: startIso, + end: stopIso, + stop: stopIso, + description: '', + channel_id: 'channel-1', + start_timestamp: String(Math.floor(Date.parse(startIso) / 1000)), + stop_timestamp: String(Math.floor(Date.parse(stopIso) / 1000)), + }; +} + +const at = (iso: string) => Date.parse(iso); + +describe('pickAiringOrUpcomingEpgItem', () => { + const early = item('Early', '2026-04-05T05:30:00Z', '2026-04-05T06:00:00Z'); + const late = item('Late', '2026-04-05T06:00:00Z', '2026-04-05T06:30:00Z'); + + it('returns the program on air', () => { + expect( + pickAiringOrUpcomingEpgItem( + [late, early], + at('2026-04-05T05:45:00Z') + ) + ).toBe(early); + }); + + it('returns the next program to start when nothing is on air yet', () => { + expect( + pickAiringOrUpcomingEpgItem([late], at('2026-04-05T05:45:00Z')) + ).toBe(late); + }); + + it('returns null once every program has ended', () => { + // The caller must request fresh data rather than present a finished + // program as the current one (#767). + expect( + pickAiringOrUpcomingEpgItem( + [early, late], + at('2026-04-05T07:00:00Z') + ) + ).toBeNull(); + }); + + it('returns null for an empty guide', () => { + expect( + pickAiringOrUpcomingEpgItem([], at('2026-04-05T07:00:00Z')) + ).toBeNull(); + }); + + it('hands the boundary instant to the program that starts on it', () => { + // A program occupies [start, stop), the same rule hasEpgProgramEnded + // applies -- otherwise the two disagree at the boundary and the row + // re-applies the finished program for another minute. + expect( + pickAiringOrUpcomingEpgItem( + [early, late], + at('2026-04-05T06:00:00Z') + ) + ).toBe(late); + }); +}); + +describe('pickEpgPreviewItem', () => { + it('falls back to the earliest item so a first paint is never blank', () => { + const early = item( + 'Early', + '2026-04-05T05:00:00Z', + '2026-04-05T05:30:00Z' + ); + const late = item( + 'Late', + '2026-04-05T05:30:00Z', + '2026-04-05T06:00:00Z' + ); + + expect( + pickEpgPreviewItem([late, early], at('2026-04-05T07:00:00Z')) + ).toBe(early); + }); +}); + +describe('hasEpgProgramEnded', () => { + const shown = toSharedEpgProgram( + item('Show', '2026-04-05T05:30:00Z', '2026-04-05T06:00:00Z') + ); + + it('is false while the program runs and true at its stop time', () => { + expect(hasEpgProgramEnded(shown, at('2026-04-05T05:59:00Z'))).toBe( + false + ); + expect(hasEpgProgramEnded(shown, at('2026-04-05T06:00:00Z'))).toBe( + true + ); + }); + + it('keeps a program with an unreadable end time on screen', () => { + // Answering "ended" would re-request this row once a minute forever. + const broken: EpgProgram = { + ...shown, + stop: 'not-a-date', + stopTimestamp: null, + }; + + expect(hasEpgProgramEnded(broken, at('2026-04-05T09:00:00Z'))).toBe( + false + ); + }); +}); + +describe('epgProgramProgressPercent', () => { + const shown = toSharedEpgProgram( + item('Show', '2026-04-05T05:00:00Z', '2026-04-05T07:00:00Z') + ); + + it('reports the elapsed share of the running program', () => { + expect( + epgProgramProgressPercent(shown, at('2026-04-05T06:00:00Z')) + ).toBeCloseTo(50, 5); + }); + + it('reports nothing outside the program', () => { + expect( + epgProgramProgressPercent(shown, at('2026-04-05T04:00:00Z')) + ).toBeNull(); + expect( + epgProgramProgressPercent(shown, at('2026-04-05T08:00:00Z')) + ).toBeNull(); + }); + + it('reports nothing for a zero-length program', () => { + const instant = toSharedEpgProgram( + item('Instant', '2026-04-05T06:00:00Z', '2026-04-05T06:00:00Z') + ); + + expect( + epgProgramProgressPercent(instant, at('2026-04-05T06:00:00Z')) + ).toBeNull(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.ts new file mode 100644 index 000000000..e5f6167c1 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.ts @@ -0,0 +1,168 @@ +import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; + +/** + * Selection rules for the "current program" line under a channel row, kept + * out of the component so both the scroll-driven fill and the periodic + * refresh (#767) read the guide the same way. + * + * Every comparison here takes an explicit `nowMs` in the PROVIDER's clock + * (`epgProviderClockMs`), never `Date.now()` — the EPG display offset moves + * "now", not the programmes. + * + * A programme occupies `[start, stop)`: at exactly its stop time it is over + * and its successor has begun. One rule for every function below, so the + * "has it ended" and "what is on air" answers cannot disagree on the + * boundary and leave the row a minute behind the rest of the EPG surfaces. + */ + +/** + * Milliseconds for one guide boundary. The provider's unix timestamp wins + * when it is present; the ISO string is the fallback. `NaN` when neither + * form parses, so callers must check before comparing. + */ +export function epgBoundaryMs( + dateValue: string | undefined, + unixTimestampValue: string | undefined +): number { + const unixTimestamp = Number(unixTimestampValue); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp * 1000; + } + + return new Date(dateValue ?? '').getTime(); +} + +/** Same boundary in whole seconds, or `null` when neither form parses. */ +export function epgBoundarySeconds( + dateValue: string | undefined, + unixTimestampValue: string | undefined +): number | null { + const boundaryMs = epgBoundaryMs(dateValue, unixTimestampValue); + return Number.isFinite(boundaryMs) ? Math.floor(boundaryMs / 1000) : null; +} + +function epgItemStartMs(item: EpgItem): number { + return epgBoundaryMs(item.start, item.start_timestamp); +} + +function epgItemEndMs(item: EpgItem): number { + return epgBoundaryMs(item.stop ?? item.end, item.stop_timestamp); +} + +/** + * The programme on air at `nowMs`, else the next one to start. + * + * `null` when every item has already ended: a finished programme must never + * be presented as the current one, which is the whole point of the periodic + * refresh. Returning the newest ended item instead would be just as wrong — + * the row would still name a programme that is over. + */ +export function pickAiringOrUpcomingEpgItem( + items: readonly EpgItem[], + nowMs: number +): EpgItem | null { + if (items.length === 0) { + return null; + } + + const byStart = [...items].sort( + (left, right) => epgItemStartMs(left) - epgItemStartMs(right) + ); + + const airing = byStart.find( + (item) => nowMs >= epgItemStartMs(item) && nowMs < epgItemEndMs(item) + ); + + return ( + airing ?? byStart.find((item) => epgItemStartMs(item) > nowMs) ?? null + ); +} + +/** + * The programme to show for a row that has none yet. Falls back to the + * earliest known item so a first paint shows what the guide holds rather + * than an empty line; the refresh path deliberately does NOT use this + * fallback, because re-applying it to a row that already advanced could + * move it backwards to an older programme. + */ +export function pickEpgPreviewItem( + items: readonly EpgItem[], + nowMs: number +): EpgItem | null { + if (items.length === 0) { + return null; + } + + const airingOrUpcoming = pickAiringOrUpcomingEpgItem(items, nowMs); + if (airingOrUpcoming) { + return airingOrUpcoming; + } + + return [...items].sort( + (left, right) => epgItemStartMs(left) - epgItemStartMs(right) + )[0]; +} + +function epgProgramStartMs(program: EpgProgram): number { + return program.startTimestamp != null + ? program.startTimestamp * 1000 + : new Date(program.start ?? '').getTime(); +} + +function epgProgramEndMs(program: EpgProgram): number { + return program.stopTimestamp != null + ? program.stopTimestamp * 1000 + : new Date(program.stop ?? '').getTime(); +} + +/** + * Whether the programme already on screen has run out. + * + * An unreadable end time answers `false`: the row keeps what it has instead + * of asking the provider for a replacement once a minute, forever. + */ +export function hasEpgProgramEnded( + program: EpgProgram, + nowMs: number +): boolean { + const endMs = epgProgramEndMs(program); + return Number.isFinite(endMs) ? endMs <= nowMs : false; +} + +/** Elapsed percentage, or `null` while `nowMs` sits outside the programme. */ +export function epgProgramProgressPercent( + program: EpgProgram, + nowMs: number +): number | null { + const startMs = epgProgramStartMs(program); + const endMs = epgProgramEndMs(program); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return null; + } + + if (nowMs < startMs || nowMs >= endMs) { + return null; + } + + return ((nowMs - startMs) / (endMs - startMs)) * 100; +} + +/** Guide item in the shape the shared channel-row components render. */ +export function toSharedEpgProgram(program: EpgItem): EpgProgram { + return { + start: program.start, + stop: program.stop ?? program.end, + channel: program.channel_id ?? program.id, + title: program.title, + desc: program.description ?? null, + category: null, + startTimestamp: epgBoundarySeconds( + program.start, + program.start_timestamp + ), + stopTimestamp: epgBoundarySeconds( + program.stop ?? program.end, + program.stop_timestamp + ), + }; +} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refill-limiter.service.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refill-limiter.service.spec.ts new file mode 100644 index 000000000..882a6be0d --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refill-limiter.service.spec.ts @@ -0,0 +1,78 @@ +import { TestBed } from '@angular/core/testing'; +import { + EPG_REFILL_MIN_INTERVAL_MS, + EpgRefillLimiter, +} from './epg-refill-limiter.service'; + +describe('EpgRefillLimiter', () => { + let limiter: EpgRefillLimiter; + + beforeEach(() => { + TestBed.configureTestingModule({}); + limiter = TestBed.inject(EpgRefillLimiter); + }); + + it('is one shared instance for every mounted channel list', () => { + // The sidebar list and the fullscreen channel panel are mounted at + // once over a single EPG queue; a record per component would give + // each copy its own allowance and divide the floor between them. + expect(TestBed.inject(EpgRefillLimiter)).toBe(limiter); + }); + + it('allows one claim per interval and then blocks', () => { + expect(limiter.claim('playlist-1', 1, 0)).toBe(true); + expect( + limiter.claim('playlist-1', 1, EPG_REFILL_MIN_INTERVAL_MS - 1) + ).toBe(false); + expect(limiter.claim('playlist-1', 1, EPG_REFILL_MIN_INTERVAL_MS)).toBe( + true + ); + }); + + it('tracks each stream separately', () => { + expect(limiter.claim('playlist-1', 1, 0)).toBe(true); + expect(limiter.claim('playlist-1', 2, 0)).toBe(true); + }); + + it('lets a released stream claim again straight away', () => { + limiter.claim('playlist-1', 1, 0); + limiter.release('playlist-1', 1); + + expect(limiter.claim('playlist-1', 1, 1)).toBe(true); + }); + + it('forgets records only once they have expired', () => { + limiter.claim('playlist-1', 1, 0); + + limiter.forgetExpired(EPG_REFILL_MIN_INTERVAL_MS - 1); + expect( + limiter.claim('playlist-1', 1, EPG_REFILL_MIN_INTERVAL_MS - 1) + ).toBe(false); + + limiter.forgetExpired(EPG_REFILL_MIN_INTERVAL_MS); + expect(limiter.claim('playlist-1', 1, EPG_REFILL_MIN_INTERVAL_MS)).toBe( + true + ); + }); + + it('does not let one playlist hold back another playlist same stream id', () => { + // Stream ids are provider-local and the root-provided service outlives + // a playlist switch, so two accounts numbering a channel alike must + // not share an allowance. + expect(limiter.claim('playlist-1', 1, 0)).toBe(true); + + expect(limiter.claim('playlist-2', 1, 0)).toBe(true); + }); + + it('holds a claim across a row scrolling out of view and back', () => { + // Housekeeping runs on every tick while the row is off screen; the + // floor must survive it, or scrolling up and down the list would hand + // out a fresh request each pass. + limiter.claim('playlist-1', 1, 0); + + limiter.forgetExpired(60_000); + limiter.forgetExpired(120_000); + + expect(limiter.claim('playlist-1', 1, 180_000)).toBe(false); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refill-limiter.service.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refill-limiter.service.ts new file mode 100644 index 000000000..5418a6334 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refill-limiter.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; + +/** + * Floor between two refills of the same channel's exhausted guide. Matches + * the EPG queue's own cache lifetime, so a provider with no fresh data is + * asked no more often than its cached answer would have expired anyway. + */ +export const EPG_REFILL_MIN_INTERVAL_MS = 5 * 60_000; + +/** Stream ids are provider-local, so a record belongs to one playlist. */ +function refillKey( + playlistId: string | null | undefined, + streamId: number +): string { + return `${playlistId ?? ''}:${streamId}`; +} + +/** + * Rate limit for the one place that overrides the EPG queue's own throttling: + * dropping a cached guide whose programmes have all ended so that it can be + * fetched again. + * + * A provider whose guide has genuinely run out answers that refill with the + * same finished programmes, which would leave the row stale and ask again on + * the very next tick — one request per visible channel per minute, against the + * queue that exists to keep providers from banning the client. + * + * Root-provided on purpose: a live layout mounts the channel list more than + * once (the sidebar and the fullscreen channel panel render side by side) + * over one shared queue, so a per-component record would hand every mounted + * copy its own allowance and divide the floor between them. + * + * Because it is root-provided it also outlives a playlist switch, so records + * carry the owning playlist: a stream id is provider-local, and two Xtream + * accounts routinely number their channels alike. A bare id would let one + * account's claim hold back a channel of the account now on screen. + */ +@Injectable({ providedIn: 'root' }) +export class EpgRefillLimiter { + private readonly requestedAt = new Map(); + + /** True when this stream may be refilled now, recording the attempt. */ + claim( + playlistId: string | null | undefined, + streamId: number, + wallClockMs: number + ): boolean { + const key = refillKey(playlistId, streamId); + const previous = this.requestedAt.get(key); + if ( + previous !== undefined && + wallClockMs - previous < EPG_REFILL_MIN_INTERVAL_MS + ) { + return false; + } + + this.requestedAt.set(key, wallClockMs); + return true; + } + + /** The guide is flowing again, so the next gap may refill immediately. */ + release(playlistId: string | null | undefined, streamId: number): void { + this.requestedAt.delete(refillKey(playlistId, streamId)); + } + + /** + * Drops records that have outlived the interval and so no longer hold + * anything back, which is what keeps the map bounded. + * + * Deliberately not keyed on the viewport: a claim dropped when its row + * scrolls out of sight would be handed back the moment the user scrolled + * to it again, and a few passes up and down the list would bypass the + * floor entirely. + */ + forgetExpired(wallClockMs: number): void { + for (const [streamId, requestedAt] of this.requestedAt) { + if (wallClockMs - requestedAt >= EPG_REFILL_MIN_INTERVAL_MS) { + this.requestedAt.delete(streamId); + } + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.spec.ts new file mode 100644 index 000000000..e2a0ffd67 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.spec.ts @@ -0,0 +1,132 @@ +import { TestBed } from '@angular/core/testing'; +import { EpgQueueService } from '@iptvnator/portal/xtream/data-access'; +import { + EPG_REFRESH_INTERVAL_MS, + EpgRefreshContribution, + EpgRefreshCoordinator, +} from './epg-refresh-coordinator.service'; + +const credentials = { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', +}; + +function contribution( + overrides: Partial = {} +): EpgRefreshContribution { + return { + playlistId: 'playlist-1', + credentials, + visibleStreamIds: [1], + staleEntries: [{ streamId: 1 }], + ...overrides, + }; +} + +describe('EpgRefreshCoordinator', () => { + const epgQueueService = { + enqueue: jest.fn().mockResolvedValue(undefined), + }; + let coordinator: EpgRefreshCoordinator; + + beforeEach(() => { + jest.useFakeTimers(); + epgQueueService.enqueue.mockClear(); + TestBed.configureTestingModule({ + providers: [ + { provide: EpgQueueService, useValue: epgQueueService }, + ], + }); + coordinator = TestBed.inject(EpgRefreshCoordinator); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('merges what every mounted list needs into one request', () => { + // Separate requests would cancel one another: the queue is latest-wins + // and replaces its visible set and pending queue on every call. + coordinator.register(() => + contribution({ + visibleStreamIds: [1, 2], + staleEntries: [{ streamId: 1 }], + }) + ); + coordinator.register(() => + contribution({ + visibleStreamIds: [3, 4], + staleEntries: [{ streamId: 3 }], + }) + ); + + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + + expect(epgQueueService.enqueue).toHaveBeenCalledTimes(1); + const [entries, visibleIds] = epgQueueService.enqueue.mock.calls[0]; + expect(entries.map((entry: { streamId: number }) => entry.streamId)) // + .toEqual([1, 3]); + // The whole of both slices, so neither list's rows are dropped while + // the queue works through them. + expect([...(visibleIds as Set)].sort()).toEqual([1, 2, 3, 4]); + }); + + it('fetches a channel both lists show only once', () => { + coordinator.register(() => contribution()); + coordinator.register(() => contribution()); + + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + + const [entries] = epgQueueService.enqueue.mock.calls[0]; + expect(entries).toHaveLength(1); + }); + + it('keeps playlists apart, since their credentials differ', () => { + coordinator.register(() => contribution({ playlistId: 'playlist-1' })); + coordinator.register(() => + contribution({ + playlistId: 'playlist-2', + staleEntries: [{ streamId: 9 }], + }) + ); + + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + + expect(epgQueueService.enqueue).toHaveBeenCalledTimes(2); + }); + + it('asks for nothing when no list needs anything', () => { + coordinator.register(() => null); + coordinator.register(() => contribution({ staleEntries: [] })); + + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + + expect(epgQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('stops ticking once the last list has left', () => { + const leaveFirst = coordinator.register(() => contribution()); + const leaveSecond = coordinator.register(() => contribution()); + + leaveFirst(); + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + expect(epgQueueService.enqueue).toHaveBeenCalledTimes(1); + + leaveSecond(); + jest.advanceTimersByTime(5 * EPG_REFRESH_INTERVAL_MS); + expect(epgQueueService.enqueue).toHaveBeenCalledTimes(1); + }); + + it('survives a rejected enqueue', async () => { + epgQueueService.enqueue.mockRejectedValueOnce(new Error('offline')); + coordinator.register(() => contribution()); + + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + await Promise.resolve(); + + epgQueueService.enqueue.mockResolvedValue(undefined); + jest.advanceTimersByTime(EPG_REFRESH_INTERVAL_MS); + expect(epgQueueService.enqueue).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.ts new file mode 100644 index 000000000..c65b1a4ae --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.ts @@ -0,0 +1,148 @@ +import { Injectable, inject } from '@angular/core'; +import { + EpgQueueService, + XtreamCredentials, +} from '@iptvnator/portal/xtream/data-access'; +import { createLogger } from '@iptvnator/portal/shared/util'; + +/** How often the rows on screen re-check the programme they are showing. */ +export const EPG_REFRESH_INTERVAL_MS = 60_000; + +/** What one mounted channel list needs fetched, and the rows it is showing. */ +export interface EpgRefreshContribution { + readonly playlistId: string | null | undefined; + readonly credentials: XtreamCredentials; + /** Every row on screen, whether or not it needs anything. */ + readonly visibleStreamIds: readonly number[]; + readonly staleEntries: readonly { + streamId: number; + epgChannelId?: string | null; + playlistId?: string | null; + }[]; +} + +/** Credentials for the queue, from the playlist row the store holds. */ +export function xtreamCredentialsOf(playlist: { + serverUrl: string; + username: string; + password: string; + serverTimezone?: string | null; +}): XtreamCredentials { + return { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + serverTimezone: playlist.serverTimezone ?? undefined, + }; +} + +/** One queue entry for a channel row. */ +export function epgQueueEntryFor( + channel: { xtream_id: number; epg_channel_id?: string | null }, + playlistId: string | null | undefined +) { + return { + streamId: channel.xtream_id, + epgChannelId: channel.epg_channel_id ?? null, + playlistId: playlistId ?? null, + }; +} + +/** Returns what this list needs on a tick, or `null` when it needs nothing. */ +export type EpgRefreshParticipant = () => EpgRefreshContribution | null; + +/** + * Owns the one timer behind the channel rows' periodic EPG refresh (#767) and + * merges what every mounted list needs into a single queue request. + * + * A live layout mounts the channel list more than once — the sidebar and the + * fullscreen channel panel render side by side — and `EpgQueueService.enqueue` + * is latest-wins: it bumps one generation, replaces the queue and the visible + * set, and drops an earlier caller's entries after its XMLTV await. Two lists + * ticking on their own timers would therefore cancel each other whenever both + * had rows to fill, which is exactly what happens on a programme boundary, + * leaving one of them a minute behind. Rows that share a timer and one merged + * request cannot. + * + * Each list still decides for itself what is stale — that part reads only its + * own state and costs nothing — and contributes its whole visible slice, since + * the queue drops anything outside the visible set it was last handed. + */ +@Injectable({ providedIn: 'root' }) +export class EpgRefreshCoordinator { + private readonly epgQueueService = inject(EpgQueueService); + private readonly logger = createLogger('EpgRefreshCoordinator'); + private readonly participants = new Set(); + private intervalId?: number; + + /** Joins the shared tick. Call the returned function on teardown. */ + register(participant: EpgRefreshParticipant): () => void { + this.participants.add(participant); + if (this.intervalId === undefined) { + this.intervalId = window.setInterval( + () => this.tick(), + EPG_REFRESH_INTERVAL_MS + ); + } + + return () => { + this.participants.delete(participant); + if (this.participants.size === 0 && this.intervalId !== undefined) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + }; + } + + private tick(): void { + const contributions: EpgRefreshContribution[] = []; + for (const participant of this.participants) { + const contribution = participant(); + if (contribution) { + contributions.push(contribution); + } + } + + // Lists of different playlists never share a queue request: the + // credentials differ, and a stream id only means anything inside its + // own account. In practice one live layout means one playlist. + const byPlaylist = new Map(); + for (const contribution of contributions) { + const key = contribution.playlistId ?? ''; + byPlaylist.set(key, [...(byPlaylist.get(key) ?? []), contribution]); + } + + for (const group of byPlaylist.values()) { + this.requestMerged(group); + } + } + + private requestMerged(group: EpgRefreshContribution[]): void { + const visibleIds = new Set(); + const entries = new Map< + number, + EpgRefreshContribution['staleEntries'][number] + >(); + for (const contribution of group) { + for (const streamId of contribution.visibleStreamIds) { + visibleIds.add(streamId); + } + for (const entry of contribution.staleEntries) { + // Both lists can show the same channel; it is fetched once. + if (!entries.has(entry.streamId)) { + entries.set(entry.streamId, entry); + } + } + } + + if (entries.size === 0) { + return; + } + + this.epgQueueService + .enqueue([...entries.values()], visibleIds, group[0].credentials) + .catch((error) => { + this.logger.warn('EPG refresh enqueue failed', error); + }); + } +} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 1c40d00b3..7afa25423 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -70,7 +70,8 @@ describe('PortalChannelsListComponent', () => { const epgQueueService = { epgResult$: epgResults$, getCached: jest.fn().mockReturnValue(null), - enqueue: jest.fn(), + enqueue: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn(), }; beforeEach(async () => { @@ -91,6 +92,7 @@ describe('PortalChannelsListComponent', () => { favoritesService.getFavorites.mockReturnValue(of([] as FavoriteItem[])); epgQueueService.getCached.mockReturnValue(null); epgQueueService.enqueue.mockClear(); + epgQueueService.invalidate.mockClear(); await TestBed.configureTestingModule({ imports: [PortalChannelsListComponent, NoopAnimationsModule], @@ -272,6 +274,504 @@ describe('PortalChannelsListComponent', () => { expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); }); + /** Sizes the viewport so its rendered range covers the given channels and + * `lastVisibleChannels` gets populated, matching what a real scroll does. */ + async function renderViewport( + fixture: ComponentFixture + ) { + const viewport = fixture.componentInstance.viewport(); + if (!viewport) { + throw new Error('Expected channel viewport'); + } + Object.defineProperty( + viewport.elementRef.nativeElement, + 'clientHeight', + { + configurable: true, + value: 520, + } + ); + viewport.checkViewportSize(); + fixture.detectChanges(); + await fixture.whenStable(); + jest.advanceTimersByTime(300); // renderedRangeStream's debounceTime + fixture.detectChanges(); + } + + it('re-picks a channel current program once its end time passes, without a scroll or re-entry (#767)', async () => { + jest.useFakeTimers(); + const firstStartTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const firstStopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + const secondStartTimestamp = firstStopTimestamp; + const secondStopTimestamp = Math.floor( + Date.parse('2026-04-05T06:30:00.000Z') / 1000 + ); + + jest.setSystemTime(new Date('2026-04-05T05:45:00.000Z')); + + selectedTypeContentLoading.set(false); + selectedChannels.set([ + { + title: 'Cartoon Network', + xtream_id: 50, + }, + ]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + + fixture.detectChanges(); + await renderViewport(fixture); + + const firstProgram = buildEpgItem({ + id: 'first', + title: 'Current Show', + start: '2026-04-05T05:30:00.000Z', + stop: '2026-04-05T06:00:00.000Z', + startTimestamp: firstStartTimestamp, + stopTimestamp: firstStopTimestamp, + }); + const secondProgram = buildEpgItem({ + id: 'second', + title: 'Next Show', + start: '2026-04-05T06:00:00.000Z', + stop: '2026-04-05T06:30:00.000Z', + startTimestamp: secondStartTimestamp, + stopTimestamp: secondStopTimestamp, + }); + + epgResults$.next({ + streamId: 50, + items: [firstProgram, secondProgram], + }); + fixture.detectChanges(); + + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)?.title).toBe('Current Show'); + + // The queue's cache holds both programmes for this channel, as it + // would once the EPG result above had arrived through the real + // service. + epgQueueService.getCached.mockImplementation((streamId: number) => + streamId === 50 ? [firstProgram, secondProgram] : null + ); + epgQueueService.enqueue.mockClear(); + + // Wall-clock time moves past the first programme's end with no + // scroll-out/in and no EPG settings change: the reporter's exact + // scenario from #767. The channel stays on screen throughout; the + // 60s interval fires several times along the way, landing on the + // next programme by the time 20 minutes have passed. + jest.advanceTimersByTime(20 * 60 * 1000); + + expect(component.epgPrograms.get(50)?.title).toBe('Next Show'); + // The cache was already warm, so the refresh re-picked from it + // instead of enqueuing a new fetch. + expect(epgQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('re-fetches EPG on the refresh tick once the cache has expired, without blanking the row (#767)', async () => { + // The queue's own cache is short-lived (a 5 minute TTL); a channel + // left on screen without a scroll event needs the periodic refresh + // to re-fetch it, not just re-pick from a cache entry that is gone. + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-04-05T05:45:00.000Z')); + + selectedTypeContentLoading.set(false); + selectedChannels.set([ + { + title: 'Cartoon Network', + xtream_id: 50, + }, + ]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + + fixture.detectChanges(); + await renderViewport(fixture); + + epgResults$.next({ + streamId: 50, + items: [ + buildEpgItem({ + id: 'first', + title: 'Current Show', + start: '2026-04-05T05:30:00.000Z', + stop: '2026-04-05T06:00:00.000Z', + startTimestamp: Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ), + stopTimestamp: Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ), + }), + ], + }); + fixture.detectChanges(); + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)?.title).toBe('Current Show'); + + // Simulate the cache having aged out: getCached now returns null. + epgQueueService.getCached.mockReturnValue(null); + epgQueueService.enqueue.mockClear(); + + jest.advanceTimersByTime(20 * 60 * 1000); + + expect(epgQueueService.enqueue).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ streamId: 50 })]), + expect.any(Set), + expect.objectContaining({ serverUrl: 'http://demo.example' }) + ); + // The row is never cleared while the fetch is in flight: it keeps + // showing the last-known program instead of going blank. + expect(component.epgPrograms.get(50)?.title).toBe('Current Show'); + + // The fetch resolves with the next programme; the row updates. + epgResults$.next({ + streamId: 50, + items: [ + buildEpgItem({ + id: 'second', + title: 'Next Show', + start: '2026-04-05T06:00:00.000Z', + stop: '2026-04-05T06:30:00.000Z', + startTimestamp: Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ), + stopTimestamp: Math.floor( + Date.parse('2026-04-05T06:30:00.000Z') / 1000 + ), + }), + ], + }); + fixture.detectChanges(); + expect(component.epgPrograms.get(50)?.title).toBe('Next Show'); + }); + + it('does not fetch off-screen channels on the refresh tick when nothing has been scrolled into view', () => { + // No renderViewport() here: lastVisibleChannels stays empty, as it + // would before the first scroll event settles. + jest.useFakeTimers(); + selectedTypeContentLoading.set(false); + selectedChannels.set( + Array.from({ length: 60 }, (_, index) => ({ + title: `Channel ${index + 1}`, + xtream_id: index + 1, + })) + ); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + + fixture.detectChanges(); + epgQueueService.enqueue.mockClear(); + + jest.advanceTimersByTime(60_000); + + expect(epgQueueService.enqueue).not.toHaveBeenCalled(); + }); + + /** + * One channel on screen, the clock parked at `nowIso`. + * + * `keepTimers` is for a second list mounted beside the first: re-installing + * the fake clock would drop the intervals the first one had registered, and + * the test would silently observe a single component. + */ + async function renderSingleChannel( + fixture: ComponentFixture, + nowIso: string, + options: { keepTimers?: boolean } = {} + ) { + if (!options.keepTimers) { + jest.useFakeTimers(); + } + jest.setSystemTime(new Date(nowIso)); + selectedTypeContentLoading.set(false); + selectedChannels.set([{ title: 'Cartoon Network', xtream_id: 50 }]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + + fixture.detectChanges(); + await renderViewport(fixture); + epgQueueService.enqueue.mockClear(); + epgQueueService.invalidate.mockClear(); + } + + function buildProgram(title: string, startIso: string, stopIso: string) { + return buildEpgItem({ + id: title, + title, + start: startIso, + stop: stopIso, + startTimestamp: Math.floor(Date.parse(startIso) / 1000), + stopTimestamp: Math.floor(Date.parse(stopIso) / 1000), + }); + } + + it('never falls back to a finished program once the cached guide runs out (#767)', async () => { + // The queue caches a short EPG for five minutes, so a channel whose + // listings are shorter than that reaches a state where every cached + // program has already ended -- the shape an uploaded-XMLTV fallback + // always has, since it caches the single current program. + await renderSingleChannel(fixture, '2026-04-05T05:45:00.000Z'); + + const early = buildProgram( + 'Early Show', + '2026-04-05T05:30:00.000Z', + '2026-04-05T06:00:00.000Z' + ); + const short = buildProgram( + 'Short Show', + '2026-04-05T06:00:00.000Z', + '2026-04-05T06:02:00.000Z' + ); + epgQueueService.getCached.mockImplementation((streamId: number) => + streamId === 50 ? [early, short] : null + ); + + epgResults$.next({ streamId: 50, items: [early, short] }); + fixture.detectChanges(); + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)?.title).toBe('Early Show'); + + // 05:45 -> 06:03: both cached programs are now over. The row must not + // rewind to the oldest one, which is what the preview pick returns + // when nothing is on air. + jest.advanceTimersByTime(18 * 60 * 1000); + + expect(component.epgPrograms.get(50)?.title).toBe('Short Show'); + // The exhausted entry is dropped, because the queue skips any stream + // that still has a cached answer, and fresh data is requested. + expect(epgQueueService.invalidate).toHaveBeenCalledWith(50); + expect(epgQueueService.enqueue).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ streamId: 50 })]), + expect.any(Set), + expect.objectContaining({ serverUrl: 'http://demo.example' }) + ); + + epgResults$.next({ + streamId: 50, + items: [ + buildProgram( + 'Live Show', + '2026-04-05T06:02:00.000Z', + '2026-04-05T06:40:00.000Z' + ), + ], + }); + fixture.detectChanges(); + expect(component.epgPrograms.get(50)?.title).toBe('Live Show'); + }); + + it('keeps the last program when a refill answers with the same stale guide', async () => { + // The provider has simply stopped publishing for this channel, so the + // refill returns the window that is already over. Feeding that through + // the first-paint fallback would walk the row back to the oldest entry. + await renderSingleChannel(fixture, '2026-04-05T05:45:00.000Z'); + + const early = buildProgram( + 'Early Show', + '2026-04-05T05:30:00.000Z', + '2026-04-05T06:00:00.000Z' + ); + const short = buildProgram( + 'Short Show', + '2026-04-05T06:00:00.000Z', + '2026-04-05T06:02:00.000Z' + ); + epgQueueService.getCached.mockImplementation((streamId: number) => + streamId === 50 ? [early, short] : null + ); + epgResults$.next({ streamId: 50, items: [early, short] }); + fixture.detectChanges(); + + jest.advanceTimersByTime(18 * 60 * 1000); + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)?.title).toBe('Short Show'); + + epgResults$.next({ streamId: 50, items: [early, short] }); + fixture.detectChanges(); + + expect(component.epgPrograms.get(50)?.title).toBe('Short Show'); + }); + + it('refills an exhausted guide at most once per cache lifetime', async () => { + // A provider whose guide has genuinely run out answers the refill with + // the same finished program, so without a floor the row would drop and + // re-request its cache on every single tick. + await renderSingleChannel(fixture, '2026-04-05T06:03:00.000Z'); + + const finished = buildProgram( + 'Finished Show', + '2026-04-05T05:30:00.000Z', + '2026-04-05T06:00:00.000Z' + ); + epgQueueService.getCached.mockImplementation((streamId: number) => + streamId === 50 ? [finished] : null + ); + epgResults$.next({ streamId: 50, items: [finished] }); + fixture.detectChanges(); + + jest.advanceTimersByTime(4 * 60 * 1000); + expect(epgQueueService.invalidate).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(2 * 60 * 1000); + expect(epgQueueService.invalidate).toHaveBeenCalledTimes(2); + }); + + it('shares the refill floor between the sidebar list and its fullscreen copy', async () => { + // A live layout mounts this component more than once over one EPG + // queue. A refill record per component would hand each copy its own + // allowance, so the same exhausted guide would be dropped and + // refetched once per mounted list every minute. + await renderSingleChannel(fixture, '2026-04-05T06:03:00.000Z'); + const fullscreenCopy = TestBed.createComponent( + PortalChannelsListComponent + ); + await renderSingleChannel(fullscreenCopy, '2026-04-05T06:03:00.000Z', { + keepTimers: true, + }); + + const finished = buildProgram( + 'Finished Show', + '2026-04-05T05:30:00.000Z', + '2026-04-05T06:00:00.000Z' + ); + epgQueueService.getCached.mockImplementation((streamId: number) => + streamId === 50 ? [finished] : null + ); + epgResults$.next({ streamId: 50, items: [finished] }); + fixture.detectChanges(); + fullscreenCopy.detectChanges(); + + jest.advanceTimersByTime(4 * 60 * 1000); + + expect(epgQueueService.invalidate).toHaveBeenCalledTimes(1); + }); + + it('refreshes the rows on screen now, not the ones a stale snapshot holds', async () => { + // The viewport's rendered range only emits when the INDEX range + // changes, so swapping a one-channel category for another leaves the + // snapshot pointing at the previous list while the indices still + // describe what is rendered. + await renderSingleChannel(fixture, '2026-04-05T06:03:00.000Z'); + + selectedChannels.set([{ title: 'Boomerang', xtream_id: 77 }]); + fixture.detectChanges(); + epgQueueService.getCached.mockReturnValue(null); + epgQueueService.enqueue.mockClear(); + + jest.advanceTimersByTime(60_000); + + expect(epgQueueService.enqueue).toHaveBeenCalledWith( + [expect.objectContaining({ streamId: 77 })], + new Set([77]), + expect.objectContaining({ serverUrl: 'http://demo.example' }) + ); + }); + + it('refreshes nothing while a search shows no results', async () => { + // A search with no matches destroys the viewport, so there is no + // rendered range at all; naming the rows it used to hold would keep + // fetching channels that are off screen. + await renderSingleChannel(fixture, '2026-04-05T06:03:00.000Z'); + epgQueueService.getCached.mockReturnValue(null); + + fixture.componentRef.setInput('searchTermInput', 'no-such-channel'); + fixture.detectChanges(); + epgQueueService.enqueue.mockClear(); + + jest.advanceTimersByTime(5 * 60 * 1000); + + expect(epgQueueService.enqueue).not.toHaveBeenCalled(); + expect(epgQueueService.invalidate).not.toHaveBeenCalled(); + }); + + it('does not walk a row back when the selected channel guide has run out', async () => { + // Selecting a row loads the provider's full guide. When that guide + // holds only finished programmes, the first-paint fallback would pick + // the earliest of them and undo what the refresh advanced to. + await renderSingleChannel(fixture, '2026-04-05T06:03:00.000Z'); + + const early = buildProgram( + 'Early Show', + '2026-04-05T05:30:00.000Z', + '2026-04-05T06:00:00.000Z' + ); + const short = buildProgram( + 'Short Show', + '2026-04-05T06:00:00.000Z', + '2026-04-05T06:02:00.000Z' + ); + epgResults$.next({ streamId: 50, items: [short] }); + fixture.detectChanges(); + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)?.title).toBe('Short Show'); + + selectedItem.set({ xtream_id: 50 }); + epgItems.set([early, short]); + fixture.detectChanges(); + + expect(component.epgPrograms.get(50)?.title).toBe('Short Show'); + }); + + it('leaves a channel the provider has no EPG for alone', async () => { + // An empty answer is cached deliberately; re-requesting it would put + // one call per EPG-less visible row on the wire every minute. + await renderSingleChannel(fixture, '2026-04-05T06:03:00.000Z'); + epgQueueService.getCached.mockReturnValue([]); + + jest.advanceTimersByTime(5 * 60 * 1000); + + expect(epgQueueService.enqueue).not.toHaveBeenCalled(); + expect(epgQueueService.invalidate).not.toHaveBeenCalled(); + }); + + it('advances the progress bar of a running program without touching the queue', async () => { + await renderSingleChannel(fixture, '2026-04-05T06:00:00.000Z'); + epgQueueService.getCached.mockReturnValue(null); + + epgResults$.next({ + streamId: 50, + items: [ + buildProgram( + 'Long Show', + '2026-04-05T05:00:00.000Z', + '2026-04-05T07:00:00.000Z' + ), + ], + }); + fixture.detectChanges(); + const component = fixture.componentInstance; + expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); + epgQueueService.getCached.mockClear(); + + jest.advanceTimersByTime(30 * 60 * 1000); + + expect(component.currentProgramsProgress.get(50)).toBeCloseTo(75, 1); + expect(epgQueueService.getCached).not.toHaveBeenCalled(); + expect(epgQueueService.enqueue).not.toHaveBeenCalled(); + }); + it('does not derive or subscribe to row EPG previews in browser/PWA mode', async () => { Object.defineProperty(window, 'electron', { configurable: true, 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..a8723a295 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, @@ -41,13 +42,13 @@ import { EpgMappingDialogComponent, } from '@iptvnator/ui/components'; import { + createLogger, getXtreamCatchupDays, isXtreamCatchupAvailable, PortalChannelSortMode, sortPortalChannelItems, } from '@iptvnator/portal/shared/util'; import { EpgQueueService } from '@iptvnator/portal/xtream/data-access'; -import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access'; import { FavoritesService } from '@iptvnator/portal/xtream/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { @@ -55,6 +56,20 @@ import { RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; +import { + epgProgramProgressPercent, + hasEpgProgramEnded, + pickAiringOrUpcomingEpgItem, + pickEpgPreviewItem, + toSharedEpgProgram, +} from './epg-preview-program'; +import { EpgRefillLimiter } from './epg-refill-limiter.service'; +import { + epgQueueEntryFor, + EpgRefreshContribution, + EpgRefreshCoordinator, + xtreamCredentialsOf, +} from './epg-refresh-coordinator.service'; import { XtreamFavoriteMarksService } from './xtream-favorite-marks.service'; export interface XtreamChannelListItem { @@ -146,7 +161,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 +169,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 + ) ); }); @@ -164,8 +179,18 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { epgPrograms = new Map(); currentProgramsProgress = new Map(); - /** Last viewport slice, reused to refresh previews after a mapping change. */ - private lastVisibleChannels: XtreamChannelListItem[] = []; + /** Leaves the shared refresh tick; called from `ngOnDestroy`. */ + private leaveEpgRefresh?: () => void; + + private readonly epgRefreshCoordinator = inject(EpgRefreshCoordinator); + private readonly logger = createLogger('XtreamPortalChannelsList'); + + /** + * Shared, not per-instance: a live layout mounts this list more than once + * (sidebar plus the fullscreen channel panel) over one EPG queue, so a + * local record would give each copy its own refill allowance. + */ + private readonly epgRefill = inject(EpgRefillLimiter); readonly viewport = viewChild(CdkVirtualScrollViewport); @@ -274,12 +299,15 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return; } - const previewProgram = this.pickPreviewProgram(epgItems); - if (!previewProgram) { - return; - } + // Same rule as a queue result: selecting a row must not walk it + // back when the provider's guide holds only finished programmes. + const program = this.pickProgramFor( + selectedItem.xtream_id, + epgItems + ); + if (!program) return; - this.applyProgram(selectedItem.xtream_id, previewProgram); + this.applyProgram(selectedItem.xtream_id, program); }); } @@ -329,13 +357,26 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { this.subscriptions.add( this.epgQueueService.epgResult$.subscribe( ({ streamId, items }) => { - const previewProgram = this.pickPreviewProgram(items); - if (previewProgram) { - this.applyProgram(streamId, previewProgram); - } + // The earliest-item fallback belongs to a row that has + // nothing to show yet. Once a row holds a programme, + // only one on air or upcoming may replace it: a refill + // answered with the same finished window would + // otherwise walk the row back to an older programme, + // the very thing the refresh exists to prevent (#767). + const program = this.pickProgramFor(streamId, items); + if (program) this.applyProgram(streamId, program); } ) ); + + // Nothing else re-evaluates the shown "current program" as + // wall-clock time passes (#767): applyProgram() only runs on + // scroll-into-view, a new EPG result, or an offset change. The + // tick is shared with every other mounted list so their requests + // merge instead of cancelling one another. + this.leaveEpgRefresh = this.epgRefreshCoordinator.register(() => + this.collectEpgRefresh() + ); } } @@ -354,13 +395,46 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { range.start, range.end ); - this.lastVisibleChannels = visibleChannels; this.loadEpgForVisibleChannels(visibleChannels); }) ); } } + /** + * The rows actually on screen now, read from the viewport rather than + * remembered. `renderedRangeStream` only emits when the index RANGE + * changes, so a new category, search term or sort order leaves a snapshot + * holding the previous list's rows; and a search with no results destroys + * the viewport altogether, where a snapshot would keep naming rows that + * are gone. Empty means nothing is on screen, which is the honest answer. + */ + private visibleChannelsNow(): XtreamChannelListItem[] { + const range = this.viewport()?.getRenderedRange(); + return range + ? this.filteredChannels().slice(range.start, range.end) + : []; + } + + /** Rows on screen, or a head of the list while nothing is rendered. */ + private channelsToPreview(): XtreamChannelListItem[] { + const visible = this.visibleChannelsNow(); + return visible.length ? visible : this.filteredChannels().slice(0, 50); + } + + /** + * The programme to show for `streamId`. A row with nothing yet may take + * the earliest known item so it is not blank; a row that already holds one + * accepts only an airing or upcoming programme, or a guide that has run + * out would walk it backwards (#767). + */ + private pickProgramFor(streamId: number, items: EpgItem[]): EpgItem | null { + const now = this.epgClockMs(); + return this.epgPrograms.has(streamId) + ? pickAiringOrUpcomingEpgItem(items, now) + : pickEpgPreviewItem(items, now); + } + /** Wall-clock now in the provider's EPG clock (`epg-display-offset.util.ts`). */ private epgClockMs(): number { return epgProviderClockMs( @@ -372,10 +446,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private repickPreviewsForOffsetChange(): void { this.epgPrograms.clear(); this.currentProgramsProgress.clear(); - const visible = this.lastVisibleChannels.length - ? this.lastVisibleChannels - : this.filteredChannels().slice(0, 50); - this.loadEpgForVisibleChannels(visible); + this.loadEpgForVisibleChannels(this.channelsToPreview()); this.cdr.markForCheck(); } @@ -384,76 +455,164 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return; } - const playlist = this.xtreamStore.currentPlaylist(); - if (!playlist) return; + if (!this.xtreamStore.currentPlaylist()) return; - const credentials: XtreamCredentials = { - serverUrl: playlist.serverUrl, - username: playlist.username, - password: playlist.password, - serverTimezone: playlist.serverTimezone, - }; - - const visibleIds = new Set(channels.map((ch) => ch.xtream_id)); - const uncachedEntries: { - streamId: number; - epgChannelId?: string | null; - playlistId?: string | null; - }[] = []; + const uncachedChannels: XtreamChannelListItem[] = []; // Apply cached results immediately for (const channel of channels) { const cached = this.epgQueueService.getCached(channel.xtream_id); if (cached !== null) { - const previewProgram = this.pickPreviewProgram(cached); - if (previewProgram) { - if (!this.epgPrograms.has(channel.xtream_id)) { - this.applyProgram(channel.xtream_id, previewProgram); - } + const previewProgram = pickEpgPreviewItem( + cached, + this.epgClockMs() + ); + if ( + previewProgram && + !this.epgPrograms.has(channel.xtream_id) + ) { + this.applyProgram(channel.xtream_id, previewProgram); } continue; } if (!this.epgPrograms.has(channel.xtream_id)) { - uncachedEntries.push({ - streamId: channel.xtream_id, - epgChannelId: channel.epg_channel_id ?? null, - playlistId: playlist.id ?? null, - }); + uncachedChannels.push(channel); } } - if (uncachedEntries.length > 0) { - this.epgQueueService - .enqueue(uncachedEntries, visibleIds, credentials) - .catch((error) => { - console.warn('EPG enqueue failed', error); - }); - } + this.requestEpgFor(uncachedChannels, channels); } - private updateProgramProgress(streamId: number, program: EpgItem) { + /** + * Advances the programme shown under the rows on screen as wall-clock + * time passes (#767) — nothing else re-evaluates it, so a row stayed + * pinned to a finished programme until the category was left and + * re-entered. + * + * A programme that is still on air only has its progress bar moved: no + * cache read, no request. Once it ends the row is re-picked from the + * queue's cache, and a cache holding nothing on air or upcoming is + * dropped so the next fetch can refill it. A finished programme is never + * re-applied — it would keep presenting itself as current, and the + * earliest-item fallback used for a first paint could even move the row + * backwards. What is on screen stays there until a replacement arrives, + * so a refreshing row never blanks out. + * + * Deciding all this costs nothing but local state, so every mounted list + * does it for itself; what it cannot do alone is ASK, which is why the + * rows to fetch are handed back to the coordinator to merge. + */ + private collectEpgRefresh(): EpgRefreshContribution | null { + const channels = this.visibleChannelsNow(); + if (!this.supportsEpg || channels.length === 0) return null; + + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) return null; + const now = this.epgClockMs(); - const start = this.getProgramTimestampMs( - program.start, - program.start_timestamp - ); - const end = this.getProgramTimestampMs( - program.stop ?? program.end, - program.stop_timestamp - ); + const wallClockNow = Date.now(); + const staleChannels: XtreamChannelListItem[] = []; + let movedProgress = false; - if (now >= start && now <= end) { - const duration = end - start; - const elapsed = now - start; - const progress = (elapsed / duration) * 100; + for (const channel of channels) { + const streamId = channel.xtream_id; + const shown = this.epgPrograms.get(streamId); + if (shown && !hasEpgProgramEnded(shown, now)) { + this.updateProgramProgress(streamId, shown); + movedProgress = true; + continue; + } - this.currentProgramsProgress.set(streamId, progress); + const cached = this.epgQueueService.getCached(streamId); + if (cached === null) { + staleChannels.push(channel); + continue; + } + + const replacement = pickAiringOrUpcomingEpgItem(cached, now); + if (replacement) { + this.applyProgram(streamId, replacement); + continue; + } + + if (cached.length === 0) { + // The provider has nothing for this channel and said so; that + // empty answer is cached on purpose, and asking again before + // it expires would put one request per EPG-less visible row + // on the wire every minute. + continue; + } + + if (!this.epgRefill.claim(playlist.id, streamId, wallClockNow)) { + continue; + } + + // Every cached programme has ended. The entry stays valid for + // minutes and the queue skips a stream that still has one, so it + // has to go before the refill below can reach the provider. + this.epgQueueService.invalidate(streamId); + staleChannels.push(channel); + } + + this.epgRefill.forgetExpired(wallClockNow); + // A replaced row already rendered through applyProgram(); this is for + // the progress bars that advanced without one. + if (movedProgress) this.cdr.markForCheck(); + + if (staleChannels.length === 0) { + return null; + } + + return { + playlistId: playlist.id, + credentials: xtreamCredentialsOf(playlist), + // The whole slice, not just what is being fetched: the queue drops + // anything outside the visible set it was last handed. + visibleStreamIds: channels.map((channel) => channel.xtream_id), + staleEntries: staleChannels.map((channel) => + epgQueueEntryFor(channel, playlist.id) + ), + }; + } + + /** + * Queues EPG for `channels`. `visibleChannels` is the full viewport slice: + * the queue drops anything outside the visible set it was last handed, so + * passing only the subset being fetched would strand entries queued for + * the other rows on screen. + */ + private requestEpgFor( + channels: XtreamChannelListItem[], + visibleChannels: XtreamChannelListItem[] + ): void { + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist || channels.length === 0) return; + + this.epgQueueService + .enqueue( + channels.map((channel) => + epgQueueEntryFor(channel, playlist.id) + ), + new Set(visibleChannels.map((channel) => channel.xtream_id)), + xtreamCredentialsOf(playlist) + ) + .catch((error) => { + // An Xtream failure carries the stream URL, which is built out + // of the username and password. + this.logger.warn('EPG enqueue failed', error); + }); + } + + private updateProgramProgress(streamId: number, program: EpgProgram) { + const progress = epgProgramProgressPercent(program, this.epgClockMs()); + if (progress === null) { + this.currentProgramsProgress.delete(streamId); return; } - this.currentProgramsProgress.delete(streamId); + this.currentProgramsProgress.set(streamId, progress); } isSelected(item: XtreamCategory | XtreamCategoryLike): boolean { @@ -527,98 +686,22 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.subscriptions.unsubscribe(); + this.leaveEpgRefresh?.(); } private applyProgram(streamId: number, program: EpgItem): void { - this.epgPrograms.set(streamId, this.toSharedEpgProgram(program)); - this.updateProgramProgress(streamId, program); + const shownProgram = toSharedEpgProgram(program); + this.epgPrograms.set(streamId, shownProgram); + this.updateProgramProgress(streamId, shownProgram); + if (!hasEpgProgramEnded(shownProgram, this.epgClockMs())) { + // A programme that has not run out proves the guide is flowing, + // so the next gap on this row may be refilled straight away. + const { id } = this.xtreamStore.currentPlaylist() ?? {}; + this.epgRefill.release(id, streamId); + } this.cdr.detectChanges(); } - private pickPreviewProgram(items: EpgItem[]): EpgItem | null { - if (!items.length) { - return null; - } - - const now = this.epgClockMs(); - const normalizedItems = [...items].sort( - (a, b) => - this.getProgramTimestampMs(a.start, a.start_timestamp) - - this.getProgramTimestampMs(b.start, b.start_timestamp) - ); - - const currentProgram = normalizedItems.find((item) => { - const start = this.getProgramTimestampMs( - item.start, - item.start_timestamp - ); - const end = this.getProgramTimestampMs( - item.stop ?? item.end, - item.stop_timestamp - ); - return now >= start && now <= end; - }); - - if (currentProgram) { - return currentProgram; - } - - const nextProgram = normalizedItems.find((item) => { - return ( - this.getProgramTimestampMs(item.start, item.start_timestamp) > - now - ); - }); - - return nextProgram ?? normalizedItems[0]; - } - - private getProgramTimestampMs( - dateValue: string | undefined, - unixTimestampValue: string | undefined - ): number { - const unixTimestamp = Number(unixTimestampValue); - if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { - return unixTimestamp * 1000; - } - - return new Date(dateValue ?? '').getTime(); - } - - private toSharedEpgProgram(program: EpgItem): EpgProgram { - return { - start: program.start, - stop: program.stop ?? program.end, - channel: program.channel_id ?? program.id, - title: program.title, - desc: program.description ?? null, - category: null, - startTimestamp: this.getProgramTimestampSeconds( - program.start, - program.start_timestamp - ), - stopTimestamp: this.getProgramTimestampSeconds( - program.stop ?? program.end, - program.stop_timestamp - ), - }; - } - - private getProgramTimestampSeconds( - dateValue: string | undefined, - unixTimestampValue: string | undefined - ): number | null { - const unixTimestamp = Number(unixTimestampValue); - if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { - return unixTimestamp; - } - - const parsedDate = new Date(dateValue ?? '').getTime(); - return Number.isFinite(parsedDate) - ? Math.floor(parsedDate / 1000) - : null; - } - // ── Context menu ──────────────────────────────────────────── onChannelContextMenu( @@ -688,10 +771,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { this.epgQueueService.invalidate(streamId); this.epgPrograms.delete(streamId); this.currentProgramsProgress.delete(streamId); - const visible = this.lastVisibleChannels.length - ? this.lastVisibleChannels - : this.filteredChannels().slice(0, 50); - this.loadEpgForVisibleChannels(visible); + this.loadEpgForVisibleChannels(this.channelsToPreview()); }); } 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/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/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/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-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/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/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..6b5d53e2b --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -0,0 +1,345 @@ +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 { + 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'; +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>; + 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)); + // `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, + ]); + + 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, + // The presenter also derives the portal source list. + globalRecentItems: recentItems, + globalFavoriteLiveItems: signal([]), + globalRecentLiveItems: signal([]), + }, + }, + { + provide: EpgService, + useValue: { getCurrentProgramsForChannels }, + }, + { + provide: SettingsStore, + useValue: { + resolvedEpgOffsetMinutes: () => 0, + dashboardRails, + }, + }, + ], + }); + 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('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', + 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..70baea7e6 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -0,0 +1,287 @@ +import { + computed, + effect, + inject, + Injectable, + signal, + untracked, + 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 { + normalizeDashboardRailsSettings, + type EpgProgram, + type PortalActivityItem, +} from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; +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, + getLiveEpgProgramForCard, + liveEpgAllowsAnySource, + liveEpgProgramKey, + liveEpgScopeKey, + LIVE_EPG_TICK_MS, + type DashboardLiveEpgDetails, + type DashboardLiveEpgLookupGroup, +} from './dashboard-live-epg.utils'; +import { RAIL_ITEM_LIMIT } from './dashboard-rail.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. + * + * 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 | 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() } + ); + + 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; + } + // 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( + program, + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ); + } + + /** + * 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(); + 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-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 cfe91881b..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 { 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..4717177d3 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; @@ -523,8 +538,7 @@ .rail__card--channel { background: var(--app-widget-bg, #1e2330); - border: 1px solid - var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); + border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); border-radius: var(--rail-radius); padding: 10px 12px; transition: @@ -534,10 +548,7 @@ &:hover { transform: translateY(-1px); - border-color: var( - --app-selection-border, - rgba(47, 123, 255, 0.4) - ); + border-color: var(--app-selection-border, rgba(47, 123, 255, 0.4)); box-shadow: 0 6px 16px -10px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.3)); @@ -628,6 +639,38 @@ min-height: 1.35em; } +// Shown while a portal card's first "now on air" answer is on its way: a +// quiet shimmer bar in the programme slot, sized like one line of text. +.rail__channel-now-placeholder { + display: inline-block; + width: 62%; + height: 0.66em; + margin-top: 0.3em; + border-radius: 4px; + vertical-align: top; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--app-muted-color, #8891a4) 18%, transparent) 25%, + color-mix(in srgb, var(--app-muted-color, #8891a4) 34%, transparent) 50%, + color-mix(in srgb, var(--app-muted-color, #8891a4) 18%, transparent) 75% + ); + background-size: 200% 100%; + animation: rail-now-placeholder-shimmer 1.4s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes rail-now-placeholder-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + .rail__channel-progress-row { display: flex; align-items: center; 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..f5f748fdb 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,242 @@ 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(); + }); + }); + + describe('visible cards', () => { + type ObserverCallback = (entries: IntersectionObserverEntry[]) => void; + let observers: { + callback: ObserverCallback; + options: IntersectionObserverInit; + observed: Element[]; + disconnect: jest.Mock; + }[]; + + const installObservers = (available: boolean) => { + observers = []; + const scope = globalThis as unknown as { + IntersectionObserver?: unknown; + ResizeObserver: unknown; + }; + scope.ResizeObserver = class { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + }; + if (!available) { + delete scope.IntersectionObserver; + return; + } + scope.IntersectionObserver = class { + observed: Element[] = []; + disconnect = jest.fn(); + constructor( + callback: ObserverCallback, + options: IntersectionObserverInit + ) { + observers.push({ + callback, + options, + observed: this.observed, + disconnect: this.disconnect, + }); + } + observe(element: Element): void { + this.observed.push(element); + } + unobserve = jest.fn(); + }; + }; + + const render = async ( + items: DashboardRailCard[], + layout: 'channel' | 'cover' = 'channel' + ) => { + await TestBed.configureTestingModule({ + imports: [DashboardRailComponent, TranslateModule.forRoot()], + providers: [ + provideRouter([]), + { + provide: SettingsStore, + useValue: { stripCountryPrefix: signal(false) }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(DashboardRailComponent); + const visible: string[][] = []; + fixture.componentInstance.visibleCardsChanged.subscribe((cards) => + visible.push(cards.map((card) => card.id)) + ); + fixture.componentRef.setInput('label', 'Live'); + fixture.componentRef.setInput('layout', layout); + fixture.componentRef.setInput('items', items); + fixture.detectChanges(); + await fixture.whenStable(); + return { fixture, visible }; + }; + + const intersect = ( + observer: (typeof observers)[number], + states: Record + ) => { + observer.callback( + observer.observed + .filter( + (element) => + (element as HTMLElement).dataset['cardId']! in + states + ) + .map( + (element) => + ({ + target: element, + isIntersecting: + states[ + (element as HTMLElement).dataset[ + 'cardId' + ]! + ], + }) as IntersectionObserverEntry + ) + ); + }; + + it('reports only the cards the observer sees, in item order, inside the track viewport', async () => { + installObservers(true); + const { visible } = await render([ + card({ id: 'a', contentType: 'live' }), + card({ id: 'b', contentType: 'live' }), + card({ id: 'c', contentType: 'live' }), + ]); + expect(observers).toHaveLength(1); + const [observer] = observers; + expect(observer.observed).toHaveLength(3); + expect( + (observer.options.root as HTMLElement).classList.contains( + 'rail__track' + ) + ).toBe(true); + expect(observer.options.rootMargin).toContain('160px'); + + intersect(observer, { c: true, a: true, b: false }); + expect(visible.at(-1)).toEqual(['a', 'c']); + + // Scrolling: b enters, a leaves. + intersect(observer, { b: true, a: false }); + expect(visible.at(-1)).toEqual(['b', 'c']); + + // Same set again does not re-emit. + const emissions = visible.length; + intersect(observer, { b: true }); + expect(visible).toHaveLength(emissions); + }); + + it('re-observes the rendered elements when the item set changes and forgets removed cards', async () => { + installObservers(true); + const { fixture, visible } = await render([ + card({ id: 'a', contentType: 'live' }), + card({ id: 'b', contentType: 'live' }), + ]); + const [observer] = observers; + intersect(observer, { a: true, b: true }); + expect(visible.at(-1)).toEqual(['a', 'b']); + + fixture.componentRef.setInput('items', [ + card({ id: 'b', contentType: 'live' }), + card({ id: 'c', contentType: 'live' }), + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // One observer, disconnected and re-armed on the new elements. + expect(observers).toHaveLength(1); + expect(observer.disconnect).toHaveBeenCalled(); + expect( + observer.observed + .slice(-2) + .map( + (element) => (element as HTMLElement).dataset['cardId'] + ) + ).toEqual(['b', 'c']); + // `a` is gone from the visible set even before c is notified. + expect(visible.at(-1)).toEqual(['b']); + }); + + it('reports every card when IntersectionObserver is unavailable', async () => { + installObservers(false); + const { visible } = await render( + [ + card({ id: 'a', contentType: 'movie' }), + card({ id: 'b', contentType: 'movie' }), + ], + 'cover' + ); + expect(visible.at(-1)).toEqual(['a', 'b']); + }); + + it('shows the placeholder only while a live card is pending its first answer', async () => { + installObservers(false); + const { fixture } = await render([ + card({ + id: 'pending', + contentType: 'live', + subtitle: 'Xtream · TV', + nowPlayingState: 'pending', + }), + card({ + id: 'answered', + contentType: 'live', + subtitle: 'Xtream · TV', + nowPlayingTitle: 'Evening news', + nowPlayingState: null, + }), + card({ id: 'none', contentType: 'live', subtitle: 'M3U · TV' }), + ]); + const rows = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.rail__channel-now' + ) + ); + expect( + rows[0].querySelector('.rail__channel-now-placeholder') + ).not.toBeNull(); + expect(rows[0].textContent?.trim()).toBe(''); + expect(rows[1].textContent?.trim()).toBe('Evening news'); + expect(rows[2].textContent?.trim()).toBe('M3U · TV'); + expect( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.rail__channel-now-placeholder' + ) + ).toHaveLength(1); + }); }); }); 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..4620a472d 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 @@ -9,7 +9,9 @@ import { input, output, signal, + untracked, viewChild, + viewChildren, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; @@ -18,6 +20,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; @@ -40,6 +43,24 @@ 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; + /** + * Key of the portal (Xtream/Stalker) EPG answer for a live card, asked + * for lazily once the card is on screen — see + * `DashboardPortalLiveEpgPresenter`. Unset for M3U cards. + */ + liveEpgSourceKey?: string | null; + /** + * `'pending'` while the card's first portal answer is on its way: the + * 'channel' layout shows a placeholder instead of the subtitle. A later + * refresh keeps the previous answer on screen, so it never flashes. + */ + nowPlayingState?: 'pending' | null; /** * Optional EPG enrichment shown by the 'channel' rail layout. Populated @@ -67,6 +88,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. @@ -114,6 +142,13 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { readonly layout = input('cover'); readonly testId = input(null); readonly actionSelected = output(); + /** + * The cards inside (or just beyond, see `rootMargin`) the rail's + * viewport, in item order; emitted whenever that set changes. Lets the + * host ask for per-card data — portal EPG — only for cards the user can + * see. Environments without `IntersectionObserver` report every card. + */ + readonly visibleCardsChanged = output(); /** * True total in the underlying dataset. Shown as a count badge next to * the rail label. Falls back to `items().length` when not supplied. @@ -122,6 +157,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { private readonly track = viewChild.required>('track'); + private readonly cardElements = + viewChildren>('cardEl'); readonly canScrollLeft = signal(false); readonly canScrollRight = signal(false); @@ -129,6 +166,9 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { private readonly viewReady = signal(false); private resizeObserver?: ResizeObserver; + private intersectionObserver?: IntersectionObserver; + private readonly visibleCardIds = new Set(); + private lastVisibleSignature: string | null = null; private resetFrameId: number | null = null; private settleFrameId: number | null = null; @@ -138,6 +178,14 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { if (!this.viewReady()) return; this.scheduleResetToStart(); }); + // The rendered card set changed: watch the new elements. Reading + // `items()` too keeps an id-only change (same elements, new cards) + // from leaving a stale visible set behind. + effect(() => { + const elements = this.cardElements(); + this.items(); + untracked(() => this.observeCards(elements)); + }); } ngAfterViewInit(): void { @@ -151,9 +199,73 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.resizeObserver?.disconnect(); + this.intersectionObserver?.disconnect(); this.cancelPendingReset(); } + private observeCards(elements: readonly ElementRef[]): void { + const renderedIds = new Set( + elements.map((element) => element.nativeElement.dataset['cardId']) + ); + for (const id of [...this.visibleCardIds]) { + if (!renderedIds.has(id)) this.visibleCardIds.delete(id); + } + + if (typeof IntersectionObserver === 'undefined') { + for (const id of renderedIds) { + if (id) this.visibleCardIds.add(id); + } + this.emitVisibleCards(); + return; + } + + this.intersectionObserver?.disconnect(); + // Cards that left the list are reported gone at once; the observer's + // initial notifications then settle the cards that are still here. + this.emitVisibleCards(); + if (elements.length === 0) { + return; + } + // Lazily created: the first non-empty card list means the track + // exists. A margin of roughly one card lets the next card's answer + // arrive before the user scrolls to it. Observing fires an initial + // notification for every target, which settles the visible set. + this.intersectionObserver ??= new IntersectionObserver( + (entries) => this.onCardsIntersect(entries), + { + root: this.track().nativeElement, + rootMargin: '0px 160px 0px 160px', + threshold: 0, + } + ); + for (const element of elements) { + this.intersectionObserver.observe(element.nativeElement); + } + } + + private onCardsIntersect(entries: IntersectionObserverEntry[]): void { + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset['cardId']; + if (!id) continue; + if (entry.isIntersecting) { + this.visibleCardIds.add(id); + } else { + this.visibleCardIds.delete(id); + } + } + this.emitVisibleCards(); + } + + private emitVisibleCards(): void { + const visible = this.items().filter((card) => + this.visibleCardIds.has(card.id) + ); + const signature = visible.map((card) => card.id).join(''); + if (signature === this.lastVisibleSignature) return; + this.lastVisibleSignature = signature; + this.visibleCardsChanged.emit(visible); + } + onScroll(): void { this.updateScrollState(); } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index a4beb0101..34db676c1 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -99,9 +99,7 @@ > } @for (genre of item.genres; track genre) { - {{ - genre - }} + {{ genre }} } } @@ -149,7 +147,10 @@ } - @if (item.watchProgress !== null && item.watchProgress !== undefined) { + @if ( + item.watchProgress !== null && + item.watchProgress !== undefined + ) {