mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
d3cc18dc72
commit
2adf687b9b
24 files changed
+1139
-141
No files matched your search
@@ -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.
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+122
@@ -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;
|
||||
|
||||
+26
-22
@@ -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';
|
||||
|
||||
+129
@@ -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;
|
||||
}
|
||||
+104
@@ -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_')
|
||||
);
|
||||
}
|
||||
+107
-1
@@ -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({
|
||||
|
||||
+28
-98
@@ -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>(
|
||||
|
||||
+8
-2
@@ -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 ||
|
||||
|
||||
+51
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user