fix(stalker): only mint a temporary link when the row asks for one

`create_link` ran on every Stalker playback. The reference client — the
portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link
only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`;
otherwise it plays the static `cmd` that `get_all_channels` /
`get_ordered_list` already returned. Neither flag was read anywhere in the
codebase, so every channel paid a round trip and gained a failure point the
reference client does not have.

One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used
by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path,
and by `StreamResolverService` for Favorites/Recently Viewed. Its guards
are deliberately wider than the flags alone and can only route a row back
onto the `create_link` path: no row to read flags from, a relative or
query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a
loopback host. An episode always mints, since `series` selects it
server-side. Radio joins the same decision, so a station the portal proxies
now gets its link instead of playing a URL the portal never meant to serve.

Temporary links live ~5 s, so the audit that came with this: favorites and
recently-viewed persist the `cmd`, playback positions store ids, and the
main-process context map stores headers keyed by origin+path — none replay
a resolved URL. Downloads are the documented exception, and honouring the
flags shrinks even that, since an unflagged movie now yields a permanent
URL that survives retry.

`forced_storage` and `play_token` stay unwired, with the reasoning recorded
in the docs rather than left ambiguous.

The mock's ITV/radio rows now carry both flags, and the new
`static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows
with a playable command so the e2e can assert that NO `create_link` request
reaches the portal — verified to fail when the change is reverted, with a
companion test proving the recorder sees a link when one is due.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 08:51:31 +02:00
1 parent d3cc18dc72
commit 2adf687b9b
24 files changed
+1139 -141

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: fix
area: stalker
---
Stalker channels that the portal serves directly now start without an extra
link request to the portal, so they open faster and no longer fail when that
request does. Channels the portal does proxy still get their temporary link,
and radio stations the portal proxies now get one too instead of playing a URL
the portal never meant to serve.
+17
View File
@@ -698,6 +698,23 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Xtream Codes API (`username`, `password`, `serverUrl`)
- Stalker portal (`macAddress`, `url`)
**Stalker playback links**: `create_link` runs only when the catalog row sets
`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays
directly. One helper decides
(`resolveStalkerStaticPlaybackUrl` in
`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied
by `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by
`StreamResolverService` for Favorites/Recently Viewed. It falls back to
`create_link` for anything it cannot resolve alone: no row to read flags from,
a relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,
or a loopback host; an episode (`series` set) always mints, since the parameter
selects the episode server-side. Temporary links live ~5 s, so no resolved URL
is persisted or replayed — favorites and recently-viewed store the `cmd`,
playback positions store ids, and the main-process context map stores headers
keyed by origin+path. Downloads are the one exception (they must retry a URL).
`forced_storage`/`play_token` are deliberately unwired. Contract:
`docs/architecture/stalker-portal.md` ("Playback Link Resolution").
**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed
on the command line, opened through a file association, or delivered by macOS'
`open-file` event is normalized to an absolute path in the main process
@@ -106,4 +106,47 @@ describe('stalker playback context', () => {
expect(headers).not.toHaveProperty('Cookie');
expect(headers).not.toHaveProperty('Authorization');
});
describe('temporary-link retention', () => {
// A Stalker temporary link expires after ~5 s. This map is a
// header lookup keyed BY the stream URL the player is already
// opening — it must never become a place a stale URL can be read
// back out of and replayed.
it('stores headers only, never the URL it was keyed with', () => {
const streamUrl =
'http://tmp-link.example.test/ch/1?token=SECRET-TMP';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'http://tmp-link.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(Object.keys(headers).length).toBeGreaterThan(0);
expect(JSON.stringify(headers)).not.toContain('SECRET-TMP');
expect(JSON.stringify(headers)).not.toContain('/ch/1');
});
it('matches a re-minted link that differs only in its expiring query', () => {
// Consecutive create_link calls return the same path with a fresh
// token; the lookup keys on origin+path so the second link still
// finds its portal headers instead of playing bare.
const firstLink = 'http://tmp-link2.example.test/ch/7?token=first';
const secondLink = 'http://tmp-link2.example.test/ch/7?token=second';
rememberStalkerPlaybackContext({
streamUrl: firstLink,
portalUrl:
'http://tmp-link2.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
expect(
getStalkerPlaybackContextHeaders(secondLink)?.['Cookie']
).toContain(`mac=${macAddress}`);
});
});
});
+8
View File
@@ -83,8 +83,16 @@ actually get wrong:
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
Every generated ITV channel and radio station carries the two temporary-link
flags a real portal sends. Outside the `static-channel-cmd` scenario they are
`use_http_tmp_link: '1'`, which is the honest annotation of their
`ffrt4://…` commands: those are portal-internal pseudo-URLs that only
`create_link` can resolve.
## Configuration
| Environment Variable | Default | Description |
@@ -17,7 +17,17 @@ export interface RawCategory {
censored?: string;
}
export interface RawChannel {
/**
* The two flags that tell a client whether the row needs `create_link`. Real
* portals send them on every ITV/radio row as `'0'`/`'1'` strings; a client
* that honours them plays the static `cmd` when both are `'0'`.
*/
export interface RawTemporaryLinkFlags {
use_http_tmp_link: '0' | '1';
use_load_balancing: '0' | '1';
}
export interface RawChannel extends RawTemporaryLinkFlags {
id: string;
name: string;
o_name: string;
@@ -28,7 +38,7 @@ export interface RawChannel {
xmltv_id: string;
}
export interface RawRadioStation {
export interface RawRadioStation extends RawTemporaryLinkFlags {
id: string;
name: string;
o_name: string;
@@ -238,7 +248,12 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
});
let channelIndex = 0;
for (const cat of data.itvCategories) {
const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex);
const channels = generateChannels(
cat.id,
config.itemsPerCategory,
channelIndex,
config.staticChannelCmd === true
);
data.channels.set(cat.id, channels);
for (const ch of channels) {
data.epg.set(ch.id, generateEpg(ch.name));
@@ -377,7 +392,12 @@ function generateCategories(
// Channel generators
// ---------------------------------------------------------------------------
function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] {
function generateChannels(
categoryId: string,
count: number,
startIndex: number,
staticCmd: boolean
): RawChannel[] {
return Array.from({ length: count }, (_, i) => {
const globalIndex = startIndex + i;
const id = String(10000 + globalIndex);
@@ -386,11 +406,19 @@ function generateChannels(categoryId: string, count: number, startIndex: number)
id,
name,
o_name: name,
cmd: `ffrt4://ch/live/${id}/index.m3u8`,
// A static row carries a playable address with the usual
// `<solution> <url>` prefix; the default `ffrt4://` command is a
// portal-internal pseudo-URL that only `create_link` can resolve,
// which is exactly what `use_http_tmp_link` announces.
cmd: staticCmd
? `ffrt3 ${pickStream(globalIndex)}`
: `ffrt4://ch/live/${id}/index.m3u8`,
logo: logoUrl(`ch-${id}`),
category_id: categoryId,
tv_genre_id: categoryId,
xmltv_id: `channel-${id}.example`,
use_http_tmp_link: staticCmd ? '0' : '1',
use_load_balancing: '0',
};
});
}
@@ -414,6 +442,8 @@ function generateRadioStations(
tv_genre_id: categoryId,
number: String(globalIndex + 1),
radio: true,
use_http_tmp_link: '1',
use_load_balancing: '0',
};
});
}
@@ -32,6 +32,12 @@ export interface ScenarioConfig {
* the portal credentials (the "only VLC works" cluster).
*/
gatedStream?: true;
/**
* Generate ITV channels that need NO temporary link: a directly playable
* `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'`. A client
* honouring the flags must play those without calling `create_link`.
*/
staticChannelCmd?: true;
}
/**
@@ -157,6 +163,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
embeddedSeriesFraction: 0,
gatedStream: true,
},
'00:1a:79:00:00:0a': {
name: 'static-channel-cmd',
description:
'ITV rows that need no temporary link — playable cmd with ' +
'use_http_tmp_link/use_load_balancing both 0',
seed: 1010,
categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
staticChannelCmd: true,
},
};
/**
+64
View File
@@ -65,6 +65,13 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
/**
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
* expects no `create_link` call at all.
*/
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
* by MAC, so keeping these distinct from the content scenarios above means an
@@ -120,6 +127,7 @@ const OWNED_MACS = [
MINIMAL_MAC,
EMBEDDED_SERIES_MAC,
LEGACY_PAGINATION_MAC,
STATIC_CMD_MAC,
AUTH_FLOW_MAC,
AUTH_REAUTH_MAC,
AUTH_REJECTED_MAC,
@@ -681,6 +689,62 @@ test('@stalker create_link returns a playable stream URL', async ({
expect(streamUrl).toMatch(/\.m3u8$/);
});
/**
* 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,
}) => {
// The reference client only calls create_link when the row sets
// use_http_tmp_link or use_load_balancing; this portal sets neither, so
// the static cmd must reach the player untouched. The companion test
// below proves the recorder does see a create_link when one is due.
await addStalkerPortal(page, {
name: 'Static Cmd Portal',
mac: STATIC_CMD_MAC,
});
expect(await playFirstItvChannel(page)).toEqual([]);
});
test('@stalker ITV mints a link for a channel that asks for one', async ({
page,
}) => {
await addStalkerPortal(page, { name: 'Tmp Link Portal' });
const createLinkRequests = await playFirstItvChannel(page);
expect(createLinkRequests.length).toBeGreaterThan(0);
expect(createLinkRequests[0]).toContain('type=itv');
});
test('@stalker mock server returns radio categories and stations', async ({
request,
}) => {
+13
View File
@@ -297,6 +297,9 @@ interface ScenarioConfig {
supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
// without the ITV get_all_channels action
marketingFixture?: true; // replace generated VOD with shared posters
requiresLogin?: true; // get_profile answers status 2 until do_auth
gatedStream?: true; // create_link returns a credential-gated URL
staticChannelCmd?: true; // ITV rows need no temporary link
}
```
@@ -317,6 +320,16 @@ forwarded host/protocol) as
`<portal-origin>/assets/marketing/poster/<slug>.png`. `main.ts` serves the
committed PNG directory directly, so the Xtream server does not need to run.
Every generated ITV channel and radio station carries `use_http_tmp_link` and
`use_load_balancing`, the flags a real portal uses to tell a client whether the
row needs `create_link`. They are `'1'`/`'0'` for the default generators —
honest, because those rows carry `ffrt4://…` pseudo-URLs. The
`static-channel-cmd` scenario (`00:1A:79:00:00:0A`) sets `staticChannelCmd`,
which gives ITV rows both flags at `'0'` and a real
`ffrt3 https://…m3u8` command, so `apps/web-e2e/src/stalker.e2e.ts` can assert
that no `create_link` request reaches the portal. See
`docs/architecture/stalker-portal.md`, "Playback Link Resolution".
### Adding a New Scenario
1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`.
+109
View File
@@ -305,6 +305,115 @@ the cross-portal collection resolver (`StreamResolverService`) use
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
replies against the portal base URL.
## Playback Link Resolution
### When `create_link` is called
A Stalker catalog row decides for itself whether it needs a temporary link.
The portal's own `player.js` — mirrored by Kodi's `pvr.stalker` — calls
`create_link` only when the row sets `use_http_tmp_link` (the portal proxies
the stream through a per-session URL) or `use_load_balancing` (the portal
picks a storage server per request). Every other row plays the static `cmd`
that `get_all_channels` / `get_ordered_list` already returned. Until PR 8 the
app called `create_link` unconditionally, so every playback paid a round trip
and gained a failure point that the reference client does not have.
One helper owns the decision:
`resolveStalkerStaticPlaybackUrl(row, cmd)` in
`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts`.
It returns the playable URL when the static path applies and `null` when the
portal has to resolve the command. `null` is deliberately wider than the flag
check alone; the extra guards can only push a row back onto the `create_link`
path, so they cannot regress a portal that works today:
| Input | Verdict | Why |
| --- | --- | --- |
| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. |
| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. This is NOT the same as a row that carries none — a portal too old to send them is genuinely announcing "no temporary link". |
| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. |
| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. |
| Loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. |
| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. |
`fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and
short-circuits before the request. It never applies it when `series` is set:
an episode is selected server-side by that parameter, so the parent row's
static `cmd` addresses the series, not the episode.
Callers pass the row they resolved the `cmd` from:
- ITV and radio — the channel/station row (`withStalkerPlayer`); radio
previously bypassed `create_link` for any directly playable command, which
meant a proxied station played a URL the portal never intended to serve.
- VOD and series — `selectedItem()`.
- Downloads — the movie payload, through the optional `linkFlags` argument on
`fetchLinkToPlay()`.
- Favorites and Recently Viewed — `StreamResolverService.resolveStalker()`
reads the flags off the persisted raw row (`UnifiedCollectionItem.stalkerItem`).
An item with no row snapshot gets no verdict, except radio, which keeps its
long-standing "directly usable command plays as-is" behaviour.
### Resolved links are never stored
A temporary link lives about 5 seconds (`tv_tmp_link_ttl` /
`vclub_tmp_link_ttl`, both default 5). It is time-limited but not single-use,
so the rule is to resolve immediately before playback and never persist,
cache or replay the result. What each persisting path actually stores:
| Path | Stores | Verdict |
| --- | --- | --- |
| Recently Viewed | the raw row including `cmd` (`buildStalkerRecentlyViewedPayload` spreads the item) | Re-resolves on replay. |
| Favorites | the raw row including `cmd` (`addStalkerFavorite`, `toggleFavorite`) | Re-resolves on replay. |
| Playback positions | `playlist_id` + `content_xtream_id` + content type only — no URL column | Not applicable. |
| Main-process playback context (`stalker-playback-context.service.ts`) | header sets keyed by the stream URL's origin + path, 15 min TTL | Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare. |
| ITV full-list cache (`StalkerItvCacheService`) | catalog rows | Rows, not links. |
| Downloads | the resolved `url` on the `downloads` row | **The one exception** — see below. |
The download row is the only place a resolved URL outlives the playback that
produced it, because the main-process downloader needs a URL it can retry and
resume with. Honouring the flags shrinks the exposure: a movie whose row needs
no temporary link now yields a permanent URL that survives retry. A movie that
genuinely needs one still stores a link that is dead by the time retry runs.
Fixing that needs the `cmd` on the download row plus a re-resolution step
before retry/resume, which is a schema change and is deliberately out of scope
here.
### `forced_storage` and `play_token`
Both are deliberately unused, and neither appears in the 4.9.35 reference
fact set as a parameter the stock server enforces:
- **`forced_storage`** is a `create_link` request parameter that pins VOD
playback to one storage server. It exists for clients that let the user pick
a storage; IPTVnator has no such concept, and omitting the parameter is what
produces the empty value the portal treats as "no preference". Wiring it
would first need storage discovery and a picker in the VOD detail view.
- **`play_token`** is a `create_link` response field for clients that assemble
the stream URL themselves. IPTVnator plays the `cmd` the portal returns
verbatim (after the solution-prefix strip and base resolution above), so the
token the stream needs is already in the URL. Note the coupling with the
section above: on the static path no `create_link` runs at all, so no
`play_token` is ever produced — which is consistent, because a row that
wants neither flag is announcing that its `cmd` needs no portal-minted
credential.
Revisit both only with a portal that demonstrably fails without them.
### Regression coverage
- `stalker-link-semantics.utils.spec.ts` — the decision table above.
- `stalker-player-request.utils.spec.ts` — static short-circuit, both flags,
the `series` exception, relative VOD commands.
- `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that
Recently Viewed stores the `cmd`, never the stream URL.
- `stream-resolver.service.spec.ts` — the collection route.
- `stalker-playback-context.service.spec.ts` — headers only, query-insensitive
key.
- `apps/web-e2e/src/stalker.e2e.ts` — mock scenario `00:1A:79:00:00:0A` serves
unflagged ITV rows with a playable `cmd`; the spec asserts NO `create_link`
request reaches the portal, and a companion test on the default (flagged)
scenario proves the recorder does see one when a link is due.
## Playback Header Contract
Every playback kind — ITV, VOD, series episodes, and radio — resolves its
@@ -808,6 +808,91 @@ describe('StreamResolverService', () => {
expect(playback.origin).toBeUndefined();
});
it('plays an unflagged Stalker favorite from its stored cmd without create_link', async () => {
// Favorites persist the raw catalog row, so `use_http_tmp_link` /
// `use_load_balancing` come back with it — a row that sets neither is
// playable as it stands and must not cost a portal round trip.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::90',
name: 'Static Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '90',
stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
stalkerItem: {
id: '90',
cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
use_http_tmp_link: '0',
use_load_balancing: '0',
},
} as UnifiedCollectionItem);
// The detail route still loads EPG; what must not happen is a link
// request.
const requestedActions = [
...dataService.sendIpcEvent.mock.calls,
...stalkerSession.makeAuthenticatedRequest.mock.calls,
].map(
(call) =>
(call[1] as { params?: { action?: string } } | undefined)
?.params?.action
);
expect(requestedActions).not.toContain('create_link');
expect(playback.streamUrl).toBe('http://cdn.example.com/live/90.m3u8');
expect(playback.isLive).toBe(true);
});
it('mints a link for a flagged Stalker favorite', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'ffmpeg http://cdn.example.com/tmp/90.m3u8?tok=1' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::90',
name: 'Balanced Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '90',
stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
stalkerItem: {
id: '90',
cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
use_load_balancing: '1',
},
} as UnifiedCollectionItem);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
params: expect.objectContaining({ action: 'create_link' }),
})
);
expect(playback.streamUrl).toBe(
'http://cdn.example.com/tmp/90.m3u8?tok=1'
);
});
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -22,10 +22,11 @@ import {
executeStalkerRequest,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
normalizeStalkerPlaybackCommand,
resolveStalkerPlaybackUrl,
resolveStalkerStaticPlaybackUrl,
StalkerPortalRepairService,
StalkerSessionService,
type StalkerLinkFlagSource,
} from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
@@ -333,14 +334,26 @@ export class StreamResolverService {
const portalUrl =
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
const normalizedCmd = normalizeStalkerPlaybackCommand(
// Favorites and Recently Viewed persist the raw catalog row, so the
// temporary-link flags travel with the item and this route makes the
// same decision the portal views make: an unflagged, directly playable
// `cmd` plays as-is and never mints a 5 s link. An item that carries
// no row snapshot (router state holds only the projection) gets no
// verdict — except radio, whose directly usable commands have always
// played as-is, so it keeps behaving like a row without flags.
const linkFlags =
(item.stalkerItem as StalkerLinkFlagSource | undefined) ??
(item.radio === 'true' ? {} : undefined);
const staticUrl = resolveStalkerStaticPlaybackUrl(
linkFlags,
item.stalkerCmd ?? ''
);
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
if (staticUrl) {
return this.buildStalkerPlayback(item, playlist, {
macAddress,
portalUrl,
streamUrl: normalizedCmd,
streamUrl: staticUrl,
isLive: item.radio === 'true' ? undefined : true,
});
}
@@ -1168,10 +1181,6 @@ export class StreamResolverService {
}));
}
private isHttpUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}
/**
* All keys a manual mapping for this channel may have been saved under:
* the playlist-scoped Xtream key first, then the M3U lookup keys.
@@ -87,10 +87,17 @@ export interface StalkerPortalCatalogFacade<
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void;
removeFromFavorites(favoriteId: string, onDone?: () => void): void;
fetchMovieFileId(itemId: string): Promise<string | null>;
/**
* `linkFlags` carries the catalog row's `use_http_tmp_link` /
* `use_load_balancing`; without it the portal is always asked for a
* temporary link.
*/
fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
cmd: string,
series?: number,
linkFlags?: { use_http_tmp_link?: unknown; use_load_balancing?: unknown }
): Promise<string>;
resolveVodPlayback(
cmd?: string,
@@ -339,6 +339,128 @@ describe('withStalkerPlayer', () => {
expect(playback.origin).toBeUndefined();
});
describe('temporary-link semantics', () => {
const CHANNEL = {
id: '10001',
cmd: 'ffrt3 http://cdn.example/live/10001.m3u8',
name: 'Static TV',
o_name: 'Static TV',
logo: 'static-tv.png',
category_id: '1001',
};
it('plays an unflagged ITV channel straight from its static cmd', async () => {
store.setSelectedContentType('itv');
const playback = await store.resolveItvPlayback({
...CHANNEL,
use_http_tmp_link: '0',
use_load_balancing: '0',
});
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playback.streamUrl).toBe(
'http://cdn.example/live/10001.m3u8'
);
expect(playback.isLive).toBe(true);
});
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
'mints a temporary link for an ITV channel with %s set',
async (flag) => {
store.setSelectedContentType('itv');
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'ffmpeg http://cdn.example/tmp/10001.m3u8' },
});
const playback = await store.resolveItvPlayback({
...CHANNEL,
[flag]: '1',
});
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.CreateLink,
cmd: CHANNEL.cmd,
type: 'itv',
}),
})
);
expect(playback.streamUrl).toBe(
'http://cdn.example/tmp/10001.m3u8'
);
}
);
it('mints a temporary link for a flagged radio station with a playable cmd', async () => {
// Before the flags were read, a directly playable radio command
// always bypassed create_link — a proxied station then played a
// URL the portal never intended to serve.
store.setSelectedContentType('radio');
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'http://cdn.example/tmp/jazz.mp3' },
});
const playback = await store.resolveRadioPlayback({
id: 'radio-3',
cmd: 'ifm https://stream.example/jazz.mp3',
name: 'Jazz FM',
o_name: 'Jazz FM',
logo: 'jazz.png',
category_id: '4001',
use_http_tmp_link: '1',
});
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.CreateLink,
type: 'radio',
}),
})
);
expect(playback.streamUrl).toBe('http://cdn.example/tmp/jazz.mp3');
});
it.each([
['static', { use_http_tmp_link: '0' }, undefined],
[
'minted',
{ use_http_tmp_link: '1' },
{ js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=SECRET' } },
],
])(
'stores the portal cmd, never the %s stream URL, in recently viewed',
async (_label, flags, response) => {
// A temporary link dies after ~5 s, so a replayed one is worse
// than useless — the row has to keep the `cmd` and re-resolve.
store.setSelectedContentType('itv');
if (response) {
dataService.sendIpcEvent.mockResolvedValueOnce(response);
}
await store.resolveItvPlayback({ ...CHANNEL, ...flags });
expect(
playlistService.addPortalRecentlyViewed
).toHaveBeenCalledWith(
PLAYLIST._id,
expect.objectContaining({
id: '10001',
cmd: CHANNEL.cmd,
})
);
const [, persisted] =
playlistService.addPortalRecentlyViewed.mock.calls[0];
expect(JSON.stringify(persisted)).not.toContain('SECRET');
expect(JSON.stringify(persisted)).not.toContain('/tmp/');
}
);
});
it('attaches the portal header set to radio playback resolved from the portal', async () => {
const session = TestBed.inject(StalkerSessionService) as unknown as {
getCachedToken: jest.Mock;
@@ -32,14 +32,16 @@ import {
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
normalizeStalkerPlaybackCommand,
resolveStalkerStaticPlaybackUrl,
shouldResolveMovieFileId,
type StalkerLinkFlagSource,
} from '../utils';
type StalkerPlayableItem = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
type StalkerPlayableItem = StalkerPortalItem &
StalkerLinkFlagSource & {
cmd?: string;
has_files?: unknown;
};
/**
* Playback/link/player concern methods.
@@ -189,6 +191,7 @@ export function withStalkerPlayer() {
storeState.selectedContentType(),
cmd: cmdToUse,
series: episodeNum,
linkFlags: item,
}
);
@@ -265,6 +268,7 @@ export function withStalkerPlayer() {
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'itv',
linkFlags: item,
}
);
@@ -318,22 +322,20 @@ export function withStalkerPlayer() {
throw new Error('nothing_to_play');
}
let streamUrl = normalizeStalkerPlaybackCommand(item.cmd);
if (
!streamUrl.startsWith('http://') &&
!streamUrl.startsWith('https://')
) {
streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'radio',
}
);
}
// Radio already skipped `create_link` for a directly
// playable command; going through the shared decision adds
// the flags, so a station the portal proxies now gets its
// temporary link instead of a dead static URL.
const streamUrl =
resolveStalkerStaticPlaybackUrl(item, item.cmd) ??
(await fetchStalkerPlaybackLink(requestDeps, {
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'radio',
linkFlags: item,
}));
if (!streamUrl) {
throw new Error('nothing_to_play');
@@ -384,7 +386,8 @@ export function withStalkerPlayer() {
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
series?: number,
linkFlags?: StalkerLinkFlagSource | null
) {
return fetchStalkerPlaybackLink(requestDeps, {
playlist: createRequestPlaylist(
@@ -395,6 +398,7 @@ export function withStalkerPlayer() {
storeState.selectedContentType(),
cmd,
series,
linkFlags,
});
},
async getExpireDate() {
@@ -1,4 +1,6 @@
export * from './stalker-collection-persistence.utils';
export * from './stalker-link-semantics.utils';
export * from './stalker-playback-command.utils';
export * from './stalker-player-request.utils';
export * from './stalker-request.utils';
export * from './stalker-content-mappers';
@@ -0,0 +1,129 @@
import {
isStalkerPortalFlagEnabled,
requiresStalkerTemporaryLink,
resolveStalkerStaticPlaybackUrl,
} from './stalker-link-semantics.utils';
describe('stalker-link-semantics', () => {
describe('isStalkerPortalFlagEnabled', () => {
it.each([
['1', true],
[1, true],
[true, true],
['true', true],
['0', false],
[0, false],
[false, false],
['false', false],
['', false],
[' ', false],
[null, false],
[undefined, false],
[Number.NaN, false],
])('reads %p as %p', (value, expected) => {
expect(isStalkerPortalFlagEnabled(value)).toBe(expected);
});
});
describe('requiresStalkerTemporaryLink', () => {
it('is false for a row that sets neither flag', () => {
expect(
requiresStalkerTemporaryLink({
use_http_tmp_link: '0',
use_load_balancing: '0',
})
).toBe(false);
});
it('is false when the row carries no flags at all', () => {
expect(requiresStalkerTemporaryLink({})).toBe(false);
expect(requiresStalkerTemporaryLink(undefined)).toBe(false);
});
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
'is true when %s is set',
(flag) => {
expect(requiresStalkerTemporaryLink({ [flag]: '1' })).toBe(true);
}
);
});
describe('resolveStalkerStaticPlaybackUrl', () => {
it('plays an unflagged absolute command and strips the solution prefix', () => {
expect(
resolveStalkerStaticPlaybackUrl(
{ use_http_tmp_link: '0', use_load_balancing: '0' },
'ffrt3 http://cdn.example/live/42.m3u8'
)
).toBe('http://cdn.example/live/42.m3u8');
});
it('accepts a bare URL with no solution prefix', () => {
expect(
resolveStalkerStaticPlaybackUrl(
{},
'https://cdn.example/live/42.m3u8'
)
).toBe('https://cdn.example/live/42.m3u8');
});
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
'defers to create_link when %s is set',
(flag) => {
expect(
resolveStalkerStaticPlaybackUrl(
{ [flag]: '1' },
'ffrt3 http://cdn.example/live/42.m3u8'
)
).toBeNull();
}
);
it.each([
// The VOD has_files rewrite produces exactly this shape.
['/media/file_42.mpg'],
['?token=abc'],
['ffrt4://ch/live/10001/index.m3u8'],
['rtmp://cdn.example/live/42'],
[''],
[' '],
])('defers to create_link for the unresolvable command %p', (cmd) => {
expect(resolveStalkerStaticPlaybackUrl({}, cmd)).toBeNull();
});
it.each([
['ffrt3 http://localhost/ch/1234_'],
['http://127.0.0.1:8080/ch/1234_'],
['http://0.0.0.0/ch/1234_'],
['http://[::1]/ch/1234_'],
])('defers to create_link for the portal-local address %p', (cmd) => {
expect(resolveStalkerStaticPlaybackUrl({}, cmd)).toBeNull();
});
it('gives no verdict when the caller has no row to read flags from', () => {
// Distinct from a row that simply carries no flags: the caller
// cannot vouch for the row, so the portal decides.
expect(
resolveStalkerStaticPlaybackUrl(
undefined,
'ffrt3 http://cdn.example/live/42.m3u8'
)
).toBeNull();
expect(
resolveStalkerStaticPlaybackUrl(
null,
'ffrt3 http://cdn.example/live/42.m3u8'
)
).toBeNull();
});
it('keeps a non-loopback host that merely looks local', () => {
expect(
resolveStalkerStaticPlaybackUrl(
{},
'http://localhost.cdn.example/live/42.m3u8'
)
).toBe('http://localhost.cdn.example/live/42.m3u8');
});
});
});
@@ -0,0 +1,111 @@
import { normalizeStalkerPlaybackCommand } from './stalker-playback-command.utils';
/**
* The two catalog flags that decide whether a row needs a temporary link.
*
* Reference behaviour (portal `player.js`, mirrored by Kodi's pvr.stalker):
* a client calls `create_link` only when the row asks for it — either because
* the portal proxies the stream through a per-session temporary URL
* (`use_http_tmp_link`) or because it picks a storage server per request
* (`use_load_balancing`). Every other row plays the static `cmd` that
* `get_all_channels` / `get_ordered_list` already returned.
*
* Portals send these as `'1'`/`'0'` strings, `1`/`0` numbers or booleans, so
* the values arrive untyped.
*/
export interface StalkerLinkFlagSource {
use_http_tmp_link?: unknown;
use_load_balancing?: unknown;
}
/**
* Hosts that can only mean "the portal itself". A `cmd` such as
* `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, never an
* address the set-top box could open, so it always needs resolving.
*/
const PORTAL_LOCAL_HOSTNAMES = new Set([
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'[::1]',
]);
/** Truthiness for portal flags, which arrive as strings, numbers or booleans. */
export function isStalkerPortalFlagEnabled(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return Number.isFinite(value) && value !== 0;
}
const normalized = String(value).trim().toLowerCase();
if (!normalized) {
return false;
}
return normalized !== '0' && normalized !== 'false';
}
/**
* Whether the row explicitly asks the client to mint a temporary link.
*/
export function requiresStalkerTemporaryLink(
source: StalkerLinkFlagSource | null | undefined
): boolean {
return (
isStalkerPortalFlagEnabled(source?.use_http_tmp_link) ||
isStalkerPortalFlagEnabled(source?.use_load_balancing)
);
}
/**
* The playable URL for a row that does NOT need `create_link`, or `null` when
* the portal has to resolve it.
*
* `null` is returned for every shape a client cannot resolve on its own, which
* is deliberately wider than the flag check alone — the flags are the rule,
* these guards only ever push a row back onto today's `create_link` path and
* so cannot regress a portal that works now:
*
* - no row at all — a caller that cannot show the flags gets no verdict, which
* is not the same as a row that carries none (a portal too old to send them
* is genuinely announcing "no temporary link");
* - either flag set — the portal asked for a temporary link;
* - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command —
* only `create_link` turns those into an address, and the VOD `has_files`
* rewrite produces exactly the first shape;
* - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL;
* - a loopback host — a portal-side placeholder (see
* {@link PORTAL_LOCAL_HOSTNAMES}).
*/
export function resolveStalkerStaticPlaybackUrl(
source: StalkerLinkFlagSource | null | undefined,
cmd: string
): string | null {
if (!source || requiresStalkerTemporaryLink(source)) {
return null;
}
const url = normalizeStalkerPlaybackCommand(cmd);
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return null;
}
try {
const hostname = new URL(url).hostname.toLowerCase();
if (PORTAL_LOCAL_HOSTNAMES.has(hostname)) {
return null;
}
} catch {
return null;
}
return url;
}
@@ -0,0 +1,104 @@
/**
* Pure `cmd` handling shared by every Stalker playback path.
*
* Kept apart from the request helpers so the link-semantics decision (which
* needs the normalizer) and the request helpers (which need the decision) do
* not import each other.
*/
export function normalizeStalkerPlaybackCommand(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return '';
}
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
candidate.startsWith('/') ||
candidate.startsWith('?')
) {
return candidate;
}
}
return trimmed;
}
export function resolveStalkerPlaybackUrl(
portalUrl: string,
originalCmd: string,
responseCmd: string
): string {
const url = normalizeStalkerPlaybackCommand(responseCmd);
if (!url) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
try {
const portalUrlObj = new URL(portalUrl);
// The installation base is the endpoint path MINUS the API suffix
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
// discovery can persist arbitrary nested installations
// (`/cp/server/load.php`), so a fixed segment allowlist would
// resolve `/media/...` against the wrong root. The legacy marker
// segments stay as the fallback for URLs that carry neither suffix.
const endpointPath = portalUrlObj.pathname;
let basePath = '';
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
if (apiSuffix.test(endpointPath)) {
basePath = endpointPath.replace(apiSuffix, '');
} else {
const pathParts = endpointPath.split('/');
for (let index = 0; index < pathParts.length; index += 1) {
if (
pathParts[index] === 'stalker_portal' ||
pathParts[index] === 'c' ||
pathParts[index] === 'portal'
) {
basePath = '/' + pathParts.slice(1, index + 1).join('/');
break;
}
}
}
if (url.startsWith('?')) {
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
if (
normalizedCmd.startsWith('http://') ||
normalizedCmd.startsWith('https://')
) {
return `${normalizedCmd}${url}`;
}
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
}
if (url.startsWith('/')) {
return `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
return url;
}
return url;
}
export function shouldResolveMovieFileId(
item: { has_files?: unknown } | null | undefined,
cmd: string
): boolean {
return (
item?.has_files !== undefined &&
!cmd.includes('://') &&
cmd.includes('/media/') &&
!cmd.includes('/media/file_')
);
}
@@ -1,10 +1,10 @@
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { shouldResolveMovieFileId } from './stalker-playback-command.utils';
import {
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
shouldResolveMovieFileId,
} from './stalker-player-request.utils';
const PLAYLIST = {
@@ -180,6 +180,112 @@ describe('stalker-player-request.utils', () => {
);
});
describe('temporary-link semantics', () => {
const deps = () => ({
dataService: dataService as never,
stalkerSession: stalkerSession as StalkerSessionService,
});
it('plays the static cmd of an unflagged row without asking the portal', async () => {
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
selectedContentType: 'itv',
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
linkFlags: {
use_http_tmp_link: '0',
use_load_balancing: '0',
},
});
expect(streamUrl).toBe('http://cdn.example/live/42.m3u8');
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
'mints a temporary link when %s is set',
async (flag) => {
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'http://cdn.example/tmp/42.m3u8?tok=1' },
});
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
selectedContentType: 'itv',
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
linkFlags: { [flag]: '1' },
});
expect(streamUrl).toBe('http://cdn.example/tmp/42.m3u8?tok=1');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.CreateLink,
}),
})
);
}
);
it('mints a link when the caller supplies no flags', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'http://cdn.example/tmp/42.m3u8' },
});
await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
selectedContentType: 'itv',
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
});
expect(dataService.sendIpcEvent).toHaveBeenCalled();
});
it('always mints a link for an episode, whose cmd addresses the series', async () => {
// `series` selects the episode server-side, so the parent row's
// static cmd is not an answer even when it is unflagged.
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'http://cdn.example/tmp/ep3.m3u8' },
});
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
selectedContentType: 'series',
cmd: 'ffrt3 http://cdn.example/series/7.m3u8',
series: 3,
linkFlags: {
use_http_tmp_link: '0',
use_load_balancing: '0',
},
});
expect(streamUrl).toBe('http://cdn.example/tmp/ep3.m3u8');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({ series: '3' }),
})
);
});
it('still mints a link for a relative unflagged VOD command', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: '/media/video_77.mpg' },
});
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
selectedContentType: 'vod',
cmd: '/media/file_42.mpg',
linkFlags: { use_http_tmp_link: '0' },
});
expect(streamUrl).toBe(
'http://demo.example/stalker_portal/media/video_77.mpg'
);
});
});
it('returns a localized expire date string from account info', async () => {
const expireDate = 1_713_139_200;
dataService.sendIpcEvent.mockResolvedValue({
@@ -7,6 +7,11 @@ import {
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerContentType } from '../stalker-store.contracts';
import {
resolveStalkerStaticPlaybackUrl,
type StalkerLinkFlagSource,
} from './stalker-link-semantics.utils';
import { resolveStalkerPlaybackUrl } from './stalker-playback-command.utils';
import {
executeStalkerRequest,
type StalkerPortalRepairApi,
@@ -29,108 +34,13 @@ export interface StalkerPlayerRequestDeps {
portalRepair?: StalkerPortalRepairApi;
}
export interface StalkerPlayableItemLike extends StalkerPortalItem {
export interface StalkerPlayableItemLike
extends StalkerPortalItem,
StalkerLinkFlagSource {
cmd?: string;
has_files?: unknown;
}
export function normalizeStalkerPlaybackCommand(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return '';
}
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
candidate.startsWith('/') ||
candidate.startsWith('?')
) {
return candidate;
}
}
return trimmed;
}
export function resolveStalkerPlaybackUrl(
portalUrl: string,
originalCmd: string,
responseCmd: string
): string {
const url = normalizeStalkerPlaybackCommand(responseCmd);
if (!url) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
try {
const portalUrlObj = new URL(portalUrl);
// The installation base is the endpoint path MINUS the API suffix
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
// discovery can persist arbitrary nested installations
// (`/cp/server/load.php`), so a fixed segment allowlist would
// resolve `/media/...` against the wrong root. The legacy marker
// segments stay as the fallback for URLs that carry neither suffix.
const endpointPath = portalUrlObj.pathname;
let basePath = '';
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
if (apiSuffix.test(endpointPath)) {
basePath = endpointPath.replace(apiSuffix, '');
} else {
const pathParts = endpointPath.split('/');
for (let index = 0; index < pathParts.length; index += 1) {
if (
pathParts[index] === 'stalker_portal' ||
pathParts[index] === 'c' ||
pathParts[index] === 'portal'
) {
basePath = '/' + pathParts.slice(1, index + 1).join('/');
break;
}
}
}
if (url.startsWith('?')) {
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
if (
normalizedCmd.startsWith('http://') ||
normalizedCmd.startsWith('https://')
) {
return `${normalizedCmd}${url}`;
}
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
}
if (url.startsWith('/')) {
return `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
return url;
}
return url;
}
export function shouldResolveMovieFileId(
item: Pick<StalkerPlayableItemLike, 'has_files'> | null | undefined,
cmd: string
): boolean {
return (
item?.has_files !== undefined &&
!cmd.includes('://') &&
cmd.includes('/media/') &&
!cmd.includes('/media/file_')
);
}
export async function fetchStalkerPlaybackLink(
deps: StalkerPlayerRequestDeps,
options: {
@@ -139,8 +49,28 @@ export async function fetchStalkerPlaybackLink(
cmd: string;
series?: number;
forcedContentType?: StalkerContentType;
/**
* The catalog row this `cmd` came from. Without it every playback
* mints a temporary link; with it, rows that set neither
* `use_http_tmp_link` nor `use_load_balancing` play their static
* `cmd` and never touch the portal.
*/
linkFlags?: StalkerLinkFlagSource | null;
}
): Promise<string> {
// An episode is selected server-side by the `series` parameter, so a
// series request has no static answer even when the parent row is
// unflagged — the static `cmd` addresses the series, not the episode.
if (options.series === undefined) {
const staticUrl = resolveStalkerStaticPlaybackUrl(
options.linkFlags,
options.cmd
);
if (staticUrl) {
return staticUrl;
}
}
const contentType =
options.forcedContentType ?? options.selectedContentType;
const response = await executeStalkerRequest<StalkerPlayerResponse>(
@@ -262,8 +262,14 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
playlist: this.catalog.playlist(),
downloadsService: this.downloadsService,
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
fetchLinkToPlay: (portalUrl, macAddress, cmd) =>
this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd),
fetchLinkToPlay: (portalUrl, macAddress, cmd, linkFlags) =>
this.catalog.fetchLinkToPlay(
portalUrl,
macAddress,
cmd,
undefined,
linkFlags
),
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
@@ -120,4 +120,55 @@ describe('startStalkerVodDownload', () => {
expect(snapshot).not.toHaveProperty('rating');
expect(snapshot).not.toHaveProperty('tmdbId');
});
it('hands the movie row on so an unflagged one can yield a permanent URL', async () => {
// The download row stores whatever URL comes back and retry replays
// it, so a row that needs no temporary link must be recognised as
// such — a 5 s link would survive only the first attempt.
const startDownload = jest.fn().mockResolvedValue({ success: true });
const fetchLinkToPlay = jest
.fn()
.mockResolvedValue('https://cdn.example.test/movie.mpg');
const data = {
id: '42',
cmd: 'ffrt3 https://cdn.example.test/movie.mpg',
use_http_tmp_link: '0',
use_load_balancing: '0',
info: { name: 'Static Movie' },
};
await startStalkerVodDownload(
{
type: 'stalker',
playlistId: 'stalker-1',
cmd: data.cmd,
data,
} as unknown as VodDetailsItem,
{
playlist: {
id: 'stalker-1',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
},
downloadsService: { startDownload },
fetchMovieFileId: jest.fn(),
fetchLinkToPlay,
}
);
expect(fetchLinkToPlay).toHaveBeenCalledWith(
'https://stalker.example.test',
'00:1A:79:12:34:56',
data.cmd,
expect.objectContaining({
use_http_tmp_link: '0',
use_load_balancing: '0',
})
);
expect(startDownload).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://cdn.example.test/movie.mpg',
})
);
});
});
@@ -10,6 +10,7 @@ type StalkerVodDetailsItem = Extract<VodDetailsItem, { type: 'stalker' }>;
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
type StalkerLinkFlagSource,
} from '@iptvnator/portal/stalker/data-access';
/**
@@ -25,6 +26,9 @@ import {
export interface DownloadVodData {
id?: string | number;
has_files?: unknown;
/** Temporary-link flags — see {@link StalkerLinkFlagSource}. */
use_http_tmp_link?: unknown;
use_load_balancing?: unknown;
title?: string;
category_id?: string | number;
info?: {
@@ -61,7 +65,8 @@ export interface StalkerVodDownloadDeps {
fetchLinkToPlay: (
portalUrl: string,
macAddress: string,
cmd: string
cmd: string,
linkFlags?: StalkerLinkFlagSource | null
) => Promise<string | null>;
language?: string;
}
@@ -124,10 +129,14 @@ export async function startStalkerVodDownload(
firstText(itemData?.info?.name, itemData?.title) ?? 'Unknown';
const cmdToUse = await resolveDownloadCmd(item, itemData, deps);
// A movie whose row needs no temporary link yields a permanent URL, which
// is what the download row stores — a 5 s link would only ever survive the
// first attempt.
const url = await deps.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
cmdToUse,
itemData
);
if (!url) {
return;
@@ -10,6 +10,7 @@ import {
import {
buildStalkerSelectedVodItem,
isStalkerSeriesFlag,
StalkerLinkFlagSource,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
@@ -251,9 +252,17 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
cmd: string,
series?: number,
linkFlags?: StalkerLinkFlagSource | null
): Promise<string> {
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
return this.stalkerStore.fetchLinkToPlay(
portalUrl,
macAddress,
cmd,
series,
linkFlags
);
}
resolveVodPlayback(