mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
merge: preserve compact guidance alongside latest master contracts
This commit is contained in:
commit
cbca0383d2
158 files changed
+12638
-2776
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: collections
|
||||
---
|
||||
|
||||
"Open in <playlist>" 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 <tag>` and to the tags API. Enumerate releases
|
||||
# and match on the stored tag_name, exactly as the event job does.
|
||||
# The `test-<branch>` drafts and every other release are left
|
||||
# alone by the ^test-pr-<n>$ 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-<n> 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}"
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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<readonly [string, string]> = [
|
||||
['İ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);
|
||||
|
||||
@@ -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<Record<Arm, string[]>> {
|
||||
const out: Partial<Record<Arm, string[]>> = {};
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string>([
|
||||
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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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, '""')}"`);
|
||||
|
||||
@@ -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<string[]> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Favorites → "Open in <portal>" → 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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -17,9 +17,22 @@ class MockXtreamCollectionDetailComponent {
|
||||
readonly closeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-detail',
|
||||
template: '',
|
||||
})
|
||||
class MockStalkerCollectionDetailComponent {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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') {
|
||||
<app-global-collection-detail-host
|
||||
[item]="item"
|
||||
[seriesResume]="seriesResume"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<n>`; a non-PR, non-tag build (a dispatch on a branch) uses
|
||||
`test-<branch>`, 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-<n>` 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-<branch>` draft — is left alone.
|
||||
|
||||
## Nightly channel
|
||||
|
||||
Every push to `master` of `4gray/iptvnator` is also a nightly. The same
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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: <T>() => MonoTypeOperatorFunction<T>,
|
||||
private readonly globalSourceUrls: (excluding?: string[]) => string[]
|
||||
) {}
|
||||
|
||||
forChannels(
|
||||
normalizedChannelIds: string[],
|
||||
sourceUrls: string[]
|
||||
): Observable<Map<string, EpgChannelMetadata | null>> {
|
||||
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<Map<string, EpgChannelMetadata | null>>(),
|
||||
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<string, EpgChannelMetadata | null>>(),
|
||||
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<Map<string, EpgChannelMetadata | null>> {
|
||||
return from(
|
||||
this.epgBridge.getChannelMetadata(
|
||||
channelIds,
|
||||
sourceUrls.length > 0 ? { sourceUrls } : undefined
|
||||
)
|
||||
).pipe(
|
||||
this.guard<Record<string, EpgChannelMetadata | null> | null>(),
|
||||
map((metadataByChannelId) => {
|
||||
return new Map<string, EpgChannelMetadata | null>(
|
||||
channelIds.map((channelId) => [
|
||||
channelId,
|
||||
metadataByChannelId?.[channelId] ?? null,
|
||||
])
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('EPG get channel metadata error:', err);
|
||||
return of(new Map<string, EpgChannelMetadata | null>());
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Map<string, EpgProgram | null>> {
|
||||
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<Map<string, EpgProgram | null>> | 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<Map<string, EpgProgram | null>> {
|
||||
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<string, EpgProgram | null>
|
||||
): Observable<Map<string, EpgProgram | null>> {
|
||||
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<Map<string, EpgProgram | null>> {
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<T>(): MonoTypeOperatorFunction<T>;
|
||||
/** 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[];
|
||||
}
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
@@ -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<string, CachedProgram>();
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Observable<EpgProgram | null>
|
||||
>();
|
||||
private readonly inFlightBatches = new Map<
|
||||
string,
|
||||
Observable<Map<string, EpgProgram | null>>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly offsetMinutes: () => number,
|
||||
private readonly guard: <T>() => MonoTypeOperatorFunction<T>
|
||||
) {}
|
||||
|
||||
/** 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<EpgProgram | null>
|
||||
): Observable<EpgProgram | null> {
|
||||
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<EpgProgram | null>(),
|
||||
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<Map<string, EpgProgram | null>> | undefined {
|
||||
return this.inFlightBatches.get(batchCacheKey);
|
||||
}
|
||||
|
||||
registerBatch(
|
||||
batchCacheKey: string,
|
||||
request$: Observable<Map<string, EpgProgram | null>>
|
||||
): void {
|
||||
this.inFlightBatches.set(batchCacheKey, request$);
|
||||
}
|
||||
|
||||
/** Only the request that owns the slot may release it. */
|
||||
releaseBatch(
|
||||
batchCacheKey: string,
|
||||
request$: Observable<Map<string, EpgProgram | null>>
|
||||
): void {
|
||||
if (this.inFlightBatches.get(batchCacheKey) === request$) {
|
||||
this.inFlightBatches.delete(batchCacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.programs.clear();
|
||||
this.inFlight.clear();
|
||||
this.inFlightBatches.clear();
|
||||
}
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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<Map<string, EpgProgram | null>> {
|
||||
const normalizedChannelIds = normalizeLookupChannelIds(channelIds);
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
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<Map<string, EpgProgram | null>> {
|
||||
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<string, EpgProgram | null>();
|
||||
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<string, EpgProgram | null> {
|
||||
return new Map(channelIds.map((channelId) => [channelId, null]));
|
||||
}
|
||||
}
|
||||
@@ -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<EpgProgram | null> {
|
||||
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<EpgProgram | null> {
|
||||
// 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<EpgProgram | null> {
|
||||
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<EpgProgram | null> {
|
||||
if (sourceUrls.length === 0) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.scoped(channelId, sourceUrls, []);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
||||
|
||||
// Cache for channel programs with 60-second TTL
|
||||
private programCache = new Map<string, CachedProgram>();
|
||||
private fetchingCurrentPrograms = new Map<
|
||||
string,
|
||||
Observable<EpgProgram | null>
|
||||
>();
|
||||
private fetchingCurrentProgramBatches = new Map<
|
||||
string,
|
||||
Observable<Map<string, EpgProgram | null>>
|
||||
>();
|
||||
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<string, EpgProgram | null>();
|
||||
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<Map<string, EpgProgram | null>> {
|
||||
const normalizedChannelIds = this.normalizeChannelIds(channelIds);
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
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<Map<string, EpgProgram | null>> {
|
||||
const nowMs = this.epgClockMs();
|
||||
return from(
|
||||
this.epgBridge.getCurrentProgramsBatch(channelIds, {
|
||||
sourceUrls,
|
||||
nowMs,
|
||||
})
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
timeout(5000),
|
||||
switchMap((scopedResult) => {
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
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<Map<string, EpgChannelMetadata | null>> {
|
||||
return from(
|
||||
this.epgBridge.getChannelMetadata(
|
||||
channelIds,
|
||||
sourceUrls.length > 0 ? { sourceUrls } : undefined
|
||||
)
|
||||
).pipe(
|
||||
this.sourceSettings.guard(),
|
||||
map((metadataByChannelId) => {
|
||||
return new Map<string, EpgChannelMetadata | null>(
|
||||
channelIds.map((channelId) => [
|
||||
channelId,
|
||||
metadataByChannelId?.[channelId] ?? null,
|
||||
])
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('EPG get channel metadata error:', err);
|
||||
return of(new Map<string, EpgChannelMetadata | null>());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<EpgProgram | null>
|
||||
): Observable<EpgProgram | null> {
|
||||
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<EpgProgram | null> {
|
||||
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<EpgProgram | null> {
|
||||
if (sourceUrls.length === 0) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.getScopedCurrentProgramForChannel(
|
||||
channelId,
|
||||
sourceUrls,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
private createNullProgramMap(
|
||||
channelIds: string[]
|
||||
): Map<string, EpgProgram | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -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';
|
||||
@@ -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<UnifiedCollectionItem[]>([]);
|
||||
readonly favoriteUidSet = signal<ReadonlySet<string>>(new Set<string>());
|
||||
|
||||
private readonly request = signal<CollectionLoadRequest | null>(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<CollectionLoadRequest | null> =
|
||||
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<UnifiedCollectionItem[] | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ReadonlySet<string>> {
|
||||
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<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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);
|
||||
|
||||
+144
@@ -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<CollectionMode>;
|
||||
/** Rows of the tab on screen — the ones this clears. */
|
||||
items: Signal<UnifiedCollectionItem[]>;
|
||||
typeLabelKey: Signal<string>;
|
||||
/** 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<void>;
|
||||
}): ClearCollectionAction {
|
||||
const dialogService = inject(DialogService);
|
||||
const translate = inject(TranslateService);
|
||||
|
||||
const clearFavorites = async (
|
||||
itemsToRemove: UnifiedCollectionItem[]
|
||||
): Promise<void> => {
|
||||
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<CollectionMode>;
|
||||
selectedContentType: Signal<CollectionContentType>;
|
||||
/** Rows the command would clear; with none there is nothing to offer. */
|
||||
currentTypeItems: Signal<UnifiedCollectionItem[]>;
|
||||
labelKey: Signal<string>;
|
||||
typeLabelKey: Signal<string>;
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
+96
@@ -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<CollectionContentType>;
|
||||
readonly liveItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly movieItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly seriesItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly hasLive: Signal<boolean>;
|
||||
readonly hasMovies: Signal<boolean>;
|
||||
readonly hasSeries: Signal<boolean>;
|
||||
readonly availableTypes: Signal<CollectionContentType[]>;
|
||||
readonly showToggle: Signal<boolean>;
|
||||
readonly currentTypeItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly currentTypeLabelKey: Signal<string>;
|
||||
/** Move off a tab the collection no longer has items for. */
|
||||
autoSelect(): void;
|
||||
}
|
||||
|
||||
export function createCollectionContentTypeState(
|
||||
allItems: Signal<readonly UnifiedCollectionItem[]>,
|
||||
initialType: CollectionContentType
|
||||
): CollectionContentTypeState {
|
||||
const selected = signal<CollectionContentType>(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]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+88
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+166
@@ -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<UnifiedCollectionItem | null>;
|
||||
readonly seriesResume: WritableSignal<SeriesResumeTarget | null>;
|
||||
/** Template context for the host's projected detail template. */
|
||||
readonly context: Signal<UnifiedCollectionDetailContext | null>;
|
||||
/** 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<CollectionMode>;
|
||||
portalType: Signal<string | undefined>;
|
||||
/** Absent while the host projects no detail template. */
|
||||
detailTemplate: Signal<unknown>;
|
||||
/** 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<UnifiedCollectionItem | null>(null);
|
||||
const seriesResume = signal<SeriesResumeTarget | null>(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<UnifiedCollectionDetailContext | null>(() => {
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+66
@@ -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<FavoritesChannelSortMode>;
|
||||
readonly labelKey: Signal<string>;
|
||||
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<FavoritesChannelSortMode>(
|
||||
restoreFavoritesChannelSortMode()
|
||||
);
|
||||
|
||||
return {
|
||||
mode: mode.asReadonly(),
|
||||
labelKey: computed(() =>
|
||||
getFavoritesChannelSortModeTranslationKey(mode())
|
||||
),
|
||||
options: FAVORITES_SORT_OPTIONS,
|
||||
setMode(next: FavoritesChannelSortMode): void {
|
||||
mode.set(next);
|
||||
persistFavoritesChannelSortMode(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
+141
@@ -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<CollectionViewState | null>(
|
||||
getCollectionViewState(window.history.state)
|
||||
);
|
||||
|
||||
/** View state of the history entry currently on screen. */
|
||||
readonly current: Signal<CollectionViewState | null> =
|
||||
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<CollectionContentType>;
|
||||
/** `undefined` wherever the scope toggle is not offered. */
|
||||
scope: Signal<CollectionScope | undefined>;
|
||||
}): 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<string, unknown> {
|
||||
return state && typeof state === 'object'
|
||||
? { ...(state as Record<string, unknown>) }
|
||||
: {};
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { computed, Signal } from '@angular/core';
|
||||
import { CollectionMode } from '@iptvnator/portal/shared/data-access';
|
||||
|
||||
export interface CollectionModeLabels {
|
||||
readonly title: Signal<string>;
|
||||
readonly clearTooltipKey: Signal<string>;
|
||||
readonly emptyStateIcon: Signal<string>;
|
||||
readonly emptyStateTitleKey: Signal<string>;
|
||||
readonly emptyStateBodyKey: Signal<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CollectionMode>
|
||||
): CollectionModeLabels {
|
||||
const byMode = <T>(favorites: T, recent: T): Signal<T> =>
|
||||
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',
|
||||
};
|
||||
}
|
||||
+78
@@ -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<CollectionMode>;
|
||||
portalType: Signal<string | undefined>;
|
||||
playlistId: Signal<string | undefined>;
|
||||
scope: Signal<CollectionScope>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+164
-732
File diff suppressed because it is too large.
Load diff
+78
@@ -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<CollectionScope>;
|
||||
/** Only a playlist-bound collection can narrow to one playlist. */
|
||||
readonly showToggle: Signal<boolean>;
|
||||
/** The scope to load with: `all` wherever the toggle is absent. */
|
||||
readonly effective: Signal<CollectionScope>;
|
||||
/** 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<string>;
|
||||
playlistId: Signal<string | undefined>;
|
||||
defaultScope: Signal<CollectionScope | undefined>;
|
||||
historyScope: Signal<CollectionScope | undefined>;
|
||||
}): CollectionScopeState {
|
||||
const route = inject(ActivatedRoute);
|
||||
const scopeService = inject(ScopeToggleService);
|
||||
const queryScope = queryParamSignal<CollectionScope | null>(
|
||||
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<CollectionScope>(() => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-2
@@ -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<UnifiedCollectionItem>();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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<EpgProgram | null>;
|
||||
/** 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<UnifiedLiveTimeshift | null>;
|
||||
activeItem: Signal<UnifiedCollectionItem | null>;
|
||||
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
|
||||
isM3uSelection: Signal<boolean>;
|
||||
currentM3uChannel: Signal<Channel | null>;
|
||||
shouldUseInlinePlayer: Signal<boolean>;
|
||||
/** 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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+96
@@ -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<UnifiedFavoriteChannel[]>;
|
||||
/**
|
||||
* 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<readonly UnifiedFavoriteChannel[]>;
|
||||
/**
|
||||
* 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<UnifiedFavoriteChannel[]>;
|
||||
}
|
||||
|
||||
export function createUnifiedLiveChannelRows(options: {
|
||||
items: Signal<UnifiedCollectionItem[]>;
|
||||
searchTerm: Signal<string>;
|
||||
mode: Signal<'favorites' | 'recent'>;
|
||||
sortMode: Signal<FavoritesChannelSortMode>;
|
||||
}): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<Map<string, EpgProgram | null>>;
|
||||
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<UnifiedCollectionItem[]>;
|
||||
/** EPG display offset; the map answers "what is on at that clock". */
|
||||
offsetMinutes: Signal<number>;
|
||||
}): UnifiedLiveEpgMap {
|
||||
const streamResolver = inject(StreamResolverService);
|
||||
const programs = signal<Map<string, EpgProgram | null>>(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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<LiveEpgPanelState>;
|
||||
readonly isPanelCollapsed: Signal<boolean>;
|
||||
readonly selectedDate: Signal<string>;
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly viewMode: Signal<EpgViewMode>;
|
||||
readonly offsetMinutes: Signal<number>;
|
||||
readonly timelinePrograms: Signal<EpgProgram[]>;
|
||||
readonly timelineChannelName: Signal<string>;
|
||||
readonly timelineChannelLogo: Signal<string>;
|
||||
readonly timelineArchiveAvailable: Signal<boolean>;
|
||||
readonly timelineArchiveDays: Signal<number>;
|
||||
readonly panelSummary: Signal<LiveEpgPanelSummary | null>;
|
||||
readonly panelSummaryLabelKey: Signal<string>;
|
||||
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<ResolvedLiveCollectionDetail | null>;
|
||||
activeItem: Signal<UnifiedCollectionItem | null>;
|
||||
activeTimeshift: Signal<UnifiedLiveTimeshift | null>;
|
||||
isM3uSelection: Signal<boolean>;
|
||||
currentM3uChannel: Signal<Channel | null>;
|
||||
currentM3uPrograms: Signal<EpgProgram[]>;
|
||||
currentPortalEpgItems: Signal<EpgItem[]>;
|
||||
/** 30 s tick that re-evaluates "what is on now". */
|
||||
progressTick: Signal<number>;
|
||||
}): UnifiedLiveEpgView {
|
||||
const settingsStore = inject(SettingsStore);
|
||||
const panelState = signal<LiveEpgPanelState>(restoreLiveEpgPanelState());
|
||||
const selectedDate = signal(getTodayEpgDateKey());
|
||||
const offsetMinutes = settingsStore.resolvedEpgOffsetMinutes;
|
||||
|
||||
const currentM3uArchivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(options.currentM3uChannel())
|
||||
);
|
||||
|
||||
const timelinePrograms = computed<EpgProgram[]>(() =>
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+132
@@ -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<RecordingStartMetadata | null>;
|
||||
/** 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<UnifiedCollectionItem | null>;
|
||||
timelinePrograms: Signal<EpgProgram[]>;
|
||||
timelineChannelLogo: Signal<string>;
|
||||
epgOffsetMinutes: Signal<number>;
|
||||
/** 30 s tick; see `metadata`. */
|
||||
progressTick: Signal<number>;
|
||||
}): UnifiedLiveRecording {
|
||||
const recordingsService = inject(RecordingsService);
|
||||
|
||||
const metadata = computed<RecordingStartMetadata | null>(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
};
|
||||
}
|
||||
+184
@@ -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<VideoPlayer>;
|
||||
readonly isEmbeddedPlayer: Signal<boolean>;
|
||||
/** Playback target for the inline player, honouring a catch-up override. */
|
||||
readonly inlinePlayback: Signal<ResolvedPortalPlayback | null>;
|
||||
readonly currentStreamUrl: Signal<string>;
|
||||
readonly isM3uSelection: Signal<boolean>;
|
||||
readonly currentPortalEpgItems: Signal<EpgItem[]>;
|
||||
readonly currentM3uPrograms: Signal<EpgProgram[]>;
|
||||
readonly currentM3uChannel: Signal<Channel | null>;
|
||||
readonly activeRadioChannel: Signal<Channel | null>;
|
||||
readonly isRadioSelection: Signal<boolean>;
|
||||
readonly isM3uDashSelection: Signal<boolean>;
|
||||
/** Player the inline surface uses, after the DASH correction. */
|
||||
readonly inlinePlayer: Signal<VideoPlayer>;
|
||||
readonly shouldUseInlinePlayer: Signal<boolean>;
|
||||
/** Identity of the selection an EPG archive action would act on. */
|
||||
readonly archiveContextKey: Signal<string>;
|
||||
/**
|
||||
* "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<WorkspaceNavigationTarget | null>;
|
||||
readonly openInPlaylistName: Signal<string>;
|
||||
readonly playbackSessionKey: Signal<string>;
|
||||
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<UnifiedCollectionItem | null>;
|
||||
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
|
||||
activeTimeshift: Signal<UnifiedLiveTimeshift | null>;
|
||||
}): 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+273
@@ -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<void>;
|
||||
/** 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<string | null>;
|
||||
activeItem: WritableSignal<UnifiedCollectionItem | null>;
|
||||
activeDetail: WritableSignal<ResolvedLiveCollectionDetail | null>;
|
||||
activeTimeshift: WritableSignal<UnifiedLiveTimeshift | null>;
|
||||
isSelecting: WritableSignal<boolean>;
|
||||
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<void> => {
|
||||
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<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+136
-801
File diff suppressed because it is too large.
Load diff
+31
-3
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<T>(
|
||||
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;
|
||||
|
||||
|
||||
+151
-1
@@ -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 `<playlist>-<index>` 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',
|
||||
|
||||
@@ -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 (`<playlist>-<index>`, 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
: 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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, WritableSignal<number>>();
|
||||
|
||||
private readonly channelsByKey = new Map<string, StalkerItvChannel[]>();
|
||||
private readonly unsupportedKeys = new Set<string>();
|
||||
/**
|
||||
* 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<ReadonlySet<string>>(new Set());
|
||||
private readonly inflight = new Map<string, Promise<void>>();
|
||||
/** Wall-clock (ms) until which a portal's crawl error suppresses retries. */
|
||||
private readonly errorCooldownUntil = new Map<string, number>();
|
||||
@@ -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);
|
||||
|
||||
@@ -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<StalkerItvLoadOutcome> {
|
||||
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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
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<T>(
|
||||
refreshVersion: getRefreshVersion(),
|
||||
};
|
||||
},
|
||||
stream: ({ params }) =>
|
||||
streamFactory(playlistService, params.portalId),
|
||||
stream: ({ params }) => streamFactory(playlistService, params.portalId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+97
@@ -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 } }) => {
|
||||
|
||||
+73
-11
@@ -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<void> {
|
||||
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 });
|
||||
|
||||
+17
-3
@@ -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();
|
||||
|
||||
|
||||
+11
-1
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
+23
-1
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+5
-4
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+95
@@ -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<string | null>(null);
|
||||
readonly pendingPlaylistId = signal<string | null>(null);
|
||||
readonly pendingCategoryId = signal<string | null>(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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+639
@@ -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<unknown>;
|
||||
const store = {
|
||||
currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }),
|
||||
selectedContentType: signal<string | null>('itv'),
|
||||
selectedCategoryId: signal<string | null>(null),
|
||||
itvChannelsCategory: signal<string | null>(null),
|
||||
searchPhrase: signal(''),
|
||||
itvFullChannelList: signal<StalkerItvChannel[]>([]),
|
||||
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<string, unknown> = {
|
||||
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<void>((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<void>((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<void>((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<void>((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<void>((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);
|
||||
}
|
||||
);
|
||||
});
|
||||
+349
@@ -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<string | null | undefined>;
|
||||
selectedCategoryId: Signal<string | null | undefined>;
|
||||
/** The category the channels on screen were served for (see the store). */
|
||||
itvChannelsCategory: Signal<string | null>;
|
||||
searchPhrase: Signal<string>;
|
||||
itvFullChannelList: Signal<StalkerItvChannel[]>;
|
||||
itvFullListActive: Signal<boolean>;
|
||||
itvFullListUnsupported: Signal<boolean>;
|
||||
/** Resolves when the full-list load settles (also when it fails). */
|
||||
preloadItvChannels(): Promise<void> | 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<DeferredPlay | null>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+24
-6
@@ -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<typeof setTimeout> | 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<string>): 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)
|
||||
|
||||
+218
@@ -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<StalkerMappedEpisode> & { 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>): 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<SeriesResumeTarget | null>
|
||||
>('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<Record<string, readonly XtreamSerieEpisode[]>>;
|
||||
}): 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>;
|
||||
}): VodSeriesSeasonVm | null {
|
||||
const { target, seasons } = options;
|
||||
return (
|
||||
seasons.find(
|
||||
(season) =>
|
||||
!season.episodesLoaded &&
|
||||
!season.isLoading &&
|
||||
season.episodes.length === 0 &&
|
||||
getVodSeriesSeasonNumber(season, seasons) ===
|
||||
target.seasonNumber
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
+112
-2
@@ -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<string | null>(null);
|
||||
private readonly seriesResumeTarget = inject(STALKER_SERIES_RESUME_TARGET);
|
||||
private consumedSeriesResumeKey: string | null = null;
|
||||
private readonly seriesResumeSeasonHydration =
|
||||
new StalkerResumeSeasonHydration();
|
||||
private readonly legacyPositionByTrackingId = signal<
|
||||
Map<number, PlaybackPositionData>
|
||||
>(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
|
||||
|
||||
+418
@@ -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<StalkerSeriesViewComponent>;
|
||||
const selectedContentType = signal<'series' | 'vod'>('series');
|
||||
const selectedItem = signal<StalkerVodSource | null>(null);
|
||||
const serialSeasons = signal<unknown[]>([]);
|
||||
const vodSeasons = signal<unknown[]>([]);
|
||||
const seriesResumeTarget = signal<SeriesResumeTarget | null>(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<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
/** Runs the effect chain until the async position read has landed. */
|
||||
async function settle(): Promise<void> {
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
+100
-3
@@ -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<void> {
|
||||
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<PlaylistMeta>((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<PlaylistMeta>((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
|
||||
);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user